asciichem 0.31.3 → 0.31.5
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/ARCHITECTURE.adoc +78 -0
- data/CHANGELOG.md +58 -0
- data/README.adoc +22 -0
- data/lib/asciichem/formatter/text.rb +11 -1
- data/lib/asciichem/layout.rb +30 -3
- data/lib/asciichem/mechanism_graph.rb +3 -2
- data/lib/asciichem/model/mechanism.rb +11 -2
- data/lib/asciichem/perception.rb +50 -11
- data/lib/asciichem/smiles/writer.rb +74 -8
- data/lib/asciichem/transform_rules.rb +33 -2
- data/lib/asciichem/version.rb +1 -1
- data/lib/asciichem/wire/extended.rb +11 -0
- data/lib/asciichem/wire_adapter.rb +37 -4
- 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: 6c9a131b0a052014c60f5be8b74972de24db882f7db088b82bdea1c506709a46
|
|
4
|
+
data.tar.gz: 9b280851bdc1fb8c4f4681f1887796f2eb688298f8c495cd81a766a879fd8a09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d5580a3f5f314447b3f1f932b37396bb5741bd5363d96e4ae97cc95fcbcf7cfbcd0690ae16253c532d21b2c790093fda0652cd3d1a330f3bcbce9e1801c66e66
|
|
7
|
+
data.tar.gz: 44a0d45dfca800429e648a5a3c3adab685c5813c7c23d5be68afb55c7dc4cf34410b8cb16ff43681a439d9ec258c7e4f71aa141e7d3561a6a05c0ccc2cbc3f5b
|
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,64 @@
|
|
|
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.5] - 2026-10-20
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
- Aromaticity perception is now **Hückel counting** (TODO.v2/13 item 2,
|
|
10
|
+
finishing the documented later slice): 5- and 6-membered cycles
|
|
11
|
+
count π electrons — in-system double = 1, lone-pair donor
|
|
12
|
+
(N/O/S/P/Se/As) with no double = 2, exocyclic-double carbon = 0 —
|
|
13
|
+
and are aromatic at totals 6 or 10. Charged rings decline;
|
|
14
|
+
aromatic-flagged rings are taken as stated. Furan, pyrrole, and
|
|
15
|
+
thiophene are perceived from Kekulé bond orders; quinones remain
|
|
16
|
+
excluded, as do four-π and three-membered rings.
|
|
17
|
+
- Canonical SMILES covers the five-membered heteroaromatics:
|
|
18
|
+
`C1=COC=C1` → `c1cocc1`, `C1=CNC=C1` → `c1c[nH]cc1`,
|
|
19
|
+
`C1=CSC=C1` → `c1cscc1`. A perceived pyrrole nitrogen is written
|
|
20
|
+
`[nH]` (bare aromatic `n` implies no hydrogen); inferred heteroatom
|
|
21
|
+
hydrogens apply only to rings perceived from Kekulé orders, so
|
|
22
|
+
source-aromatic thiophene never gains a phantom H.
|
|
23
|
+
- Corpus contract 0.8.0 (`asciichem-tests`): Hückel fixtures for
|
|
24
|
+
furan/pyrrole/thiophene, cyclopentadiene and cyclopropene pinned as
|
|
25
|
+
not aromatic, plus `canonicalSmiles` expectations. TS and Python
|
|
26
|
+
parity merged; the Python writer also emits `H0` for stated-zero
|
|
27
|
+
hydrogens.
|
|
28
|
+
|
|
29
|
+
## [0.31.4] - 2026-10-20
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- Canonical SMILES now consumes the perception pass (item 2): atoms
|
|
33
|
+
in perceived aromatic rings are written lowercase with implicit ring
|
|
34
|
+
bonds, so Kekulé input canonicalizes — `C1=CC=CC=C1` → `c1ccccc1`,
|
|
35
|
+
aspirin → `CC(=O)Oc1ccccc1C(=O)O`. Canonical-form stability is a
|
|
36
|
+
fixed point (`write(parse(write(x))) == write(x)`); composition and
|
|
37
|
+
connectivity are preserved. Perception's sp2 rule now requires the
|
|
38
|
+
double-bond partner to itself be in a ring, keeping quinone rings
|
|
39
|
+
Kekulé — the guard that makes aromatic canonicalization chemically
|
|
40
|
+
safe. Corpus contract 0.7.0 (`canonicalSmiles` fixture field).
|
|
41
|
+
- `Molecule#stoichiometric_coefficient` is the single coefficient
|
|
42
|
+
rule shared by `Reaction#stoichiometry`, `BalanceCheck`, and
|
|
43
|
+
`element_counts`.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
- Mechanism electron-flow markers (item 4, slice 4c): a step value may
|
|
47
|
+
carry `| flow: SOURCE > TARGET` clauses (semicolon-separated),
|
|
48
|
+
typed as `Mechanism::Flow`, carried by `MechanismGraph`, emitted on
|
|
49
|
+
the wire (`MechanismStep.flows`, asciichem-model v1 additive), and
|
|
50
|
+
re-emitted in canonical text. Malformed clauses raise; nothing is
|
|
51
|
+
dropped.
|
|
52
|
+
- Depiction backends (item 7): `AsciiChem::Layout.backend` lets a
|
|
53
|
+
depiction engine (RDKit, CDK, a Rust layout core) replace the
|
|
54
|
+
built-in elkrb pipeline without touching the renderer or the model.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
- Mechanism wire emission: the adapter passed raw step/spectator
|
|
58
|
+
strings to the reaction serializer, so `Mechanism#to_model_json`
|
|
59
|
+
had never worked. Steps and spectators are now parsed with the same
|
|
60
|
+
grammar; spectators that are not valid AsciiChem raise clearly.
|
|
61
|
+
- Test isolation: the CLI `--engine parsanol` spec leaked the engine
|
|
62
|
+
selection into later examples.
|
|
63
|
+
|
|
6
64
|
## [0.31.3] - 2026-10-20
|
|
7
65
|
|
|
8
66
|
### Added
|
data/README.adoc
CHANGED
|
@@ -45,6 +45,28 @@ formula.to_mathml # => "<math>...</math>"
|
|
|
45
45
|
formula.to_text # => "H_2O" (round trip)
|
|
46
46
|
----
|
|
47
47
|
|
|
48
|
+
== Conformance
|
|
49
|
+
|
|
50
|
+
The reference implementation of https://www.asciichem.org[the AsciiChem
|
|
51
|
+
contract]. Every claim is gated by the shared corpus
|
|
52
|
+
(https://github.com/asciichem/asciichem-tests[asciichem-tests]) in CI —
|
|
53
|
+
a level without the green corpus run behind it does not ship.
|
|
54
|
+
|
|
55
|
+
[cols="1,1,4"]
|
|
56
|
+
|===
|
|
57
|
+
|Level |Claim |Basis
|
|
58
|
+
|
|
59
|
+
|L0 notation |yes |parse/reject contract; every `roundTrip` fixture returns its input
|
|
60
|
+
|L1 semantics |yes |wire form validates against the v1 schemas and round-trips; the perception pass (implicit hydrogens, aromatic rings) is corpus-gated
|
|
61
|
+
|L2 render |yes |MathML byte-goldens, canonical text, and the wire form for every construct
|
|
62
|
+
|L3 stereo |yes |tetrahedral chirality, directional bonds, wedge/hash; parity-correct canonical SMILES including ring closures
|
|
63
|
+
|L4 identity |partial |opt-in InChI engine with stereo layers, OPSIN adapter, identifier cross-check, citation bibitems; CAS source decision pending
|
|
64
|
+
|===
|
|
65
|
+
|
|
66
|
+
TypeScript and Python claim the same levels they pass; see their
|
|
67
|
+
READMEs and the https://www.asciichem.org/guides/conformance-levels/[levels
|
|
68
|
+
document].
|
|
69
|
+
|
|
48
70
|
== Design
|
|
49
71
|
|
|
50
72
|
See link:ARCHITECTURE.adoc[] and the specification at
|
|
@@ -199,13 +199,23 @@ module AsciiChem
|
|
|
199
199
|
def visit_mechanism(mech)
|
|
200
200
|
parts = ["mechanism"]
|
|
201
201
|
unless mech.steps.empty? && mech.spectators.empty?
|
|
202
|
-
lines = mech.steps.map { |s| "#{s.label}: #{s
|
|
202
|
+
lines = mech.steps.map { |s| "#{s.label}: #{mechanism_step_text(s)}" }
|
|
203
203
|
mech.spectators.each { |sp| lines << "spectator: #{sp}" }
|
|
204
204
|
parts << "{\n #{lines.join("\n ")}\n}"
|
|
205
205
|
end
|
|
206
206
|
parts.join
|
|
207
207
|
end
|
|
208
208
|
|
|
209
|
+
# Reaction plus its electron-flow clauses in canonical form:
|
|
210
|
+
# ` | flow: SRC > DST; SRC2 > DST2`.
|
|
211
|
+
def mechanism_step_text(step)
|
|
212
|
+
flows = step.flows
|
|
213
|
+
return step.reaction if flows.nil? || flows.empty?
|
|
214
|
+
|
|
215
|
+
arrows = flows.map { |f| "#{f.source} > #{f.target}" }.join('; ')
|
|
216
|
+
"#{step.reaction} | flow: #{arrows}"
|
|
217
|
+
end
|
|
218
|
+
|
|
209
219
|
def visit_opaque_cml(opaque)
|
|
210
220
|
%(<!-- opaque: #{opaque.element_name} -->)
|
|
211
221
|
end
|
data/lib/asciichem/layout.rb
CHANGED
|
@@ -46,13 +46,40 @@ module AsciiChem
|
|
|
46
46
|
Result.new(atoms: [], bonds: [], width: 0.0, height: 0.0)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
#
|
|
50
|
-
#
|
|
49
|
+
# The built-in backend: elkrb layered layout (or authored
|
|
50
|
+
# coordinates when present).
|
|
51
|
+
class BuiltinBackend
|
|
52
|
+
def layout(molecule, algorithm: 'layered')
|
|
53
|
+
Layout.builtin_layout(molecule, algorithm: algorithm)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
# Depictions are views: a backend maps a molecule to a
|
|
59
|
+
# Layout::Result and the renderer never learns which engine
|
|
60
|
+
# placed the atoms. The built-in elkrb backend is the default;
|
|
61
|
+
# external backends (RDKit, CDK, a Rust layout core) plug in
|
|
62
|
+
# with `AsciiChem::Layout.backend = backend`, where backend
|
|
63
|
+
# responds to `layout(molecule, algorithm:)`.
|
|
64
|
+
attr_writer :backend
|
|
65
|
+
|
|
66
|
+
def backend
|
|
67
|
+
@backend ||= BuiltinBackend.new
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Compute 2D positions through the configured backend.
|
|
71
|
+
def layout(molecule, algorithm: 'layered')
|
|
72
|
+
backend.layout(molecule, algorithm: algorithm)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Compute 2D positions for a molecule's atoms and bonds with the
|
|
77
|
+
# built-in elkrb pipeline. Algorithm defaults to `layered`
|
|
51
78
|
# (Sugiyama-style hierarchical) which is deterministic across
|
|
52
79
|
# runs — essential for visual regression testing. Pass
|
|
53
80
|
# `algorithm: "force"` for organic-looking layouts, with the
|
|
54
81
|
# caveat that output may vary between runs.
|
|
55
|
-
def self.
|
|
82
|
+
def self.builtin_layout(molecule, algorithm: 'layered')
|
|
56
83
|
walker = MoleculeWalker.new(molecule)
|
|
57
84
|
walk = walker.walk
|
|
58
85
|
return empty_result if walk.atoms.empty?
|
|
@@ -12,7 +12,7 @@ module AsciiChem
|
|
|
12
12
|
# a reaction (prose, "proton transfer") keeps a nil reaction and
|
|
13
13
|
# takes part in no links — never an error.
|
|
14
14
|
module MechanismGraph
|
|
15
|
-
Step = Struct.new(:label, :reaction, :catalysts, keyword_init: true)
|
|
15
|
+
Step = Struct.new(:label, :reaction, :catalysts, :flows, keyword_init: true)
|
|
16
16
|
Link = Struct.new(:from, :to, :species, keyword_init: true)
|
|
17
17
|
Graph = Struct.new(:steps, :links, keyword_init: true)
|
|
18
18
|
|
|
@@ -27,7 +27,8 @@ module AsciiChem
|
|
|
27
27
|
reaction = parse_reaction(step.reaction)
|
|
28
28
|
sides = sides_of(reaction)
|
|
29
29
|
catalysts = sides ? sides[:reactants].keys & sides[:products].keys : []
|
|
30
|
-
Step.new(label: step.label, reaction: reaction, catalysts: catalysts
|
|
30
|
+
Step.new(label: step.label, reaction: reaction, catalysts: catalysts,
|
|
31
|
+
flows: step.flows || [])
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def links(steps)
|
|
@@ -7,12 +7,21 @@ module AsciiChem
|
|
|
7
7
|
# Syntax:
|
|
8
8
|
# mechanism{
|
|
9
9
|
# step1: A + B -> C
|
|
10
|
-
# step2: C -> D + E
|
|
10
|
+
# step2: C -> D + E | flow: C > D; C-B > D
|
|
11
11
|
# spectator: Na+
|
|
12
12
|
# }
|
|
13
|
+
#
|
|
14
|
+
# A step's value is its reaction, optionally followed by
|
|
15
|
+
# `| flow: SOURCE > TARGET` clauses (semicolon-separated) naming
|
|
16
|
+
# the electron-flow arrows drawn for that step. Sources/targets
|
|
17
|
+
# stay verbatim strings: an atom-level binding needs references
|
|
18
|
+
# the mechanism text does not yet carry.
|
|
13
19
|
class Mechanism < Node
|
|
20
|
+
# One electron-flow marker within a step.
|
|
21
|
+
Flow = Struct.new(:source, :target, keyword_init: true)
|
|
22
|
+
|
|
14
23
|
# A single step in the mechanism, identified by a label.
|
|
15
|
-
Step = Struct.new(:label, :reaction, keyword_init: true)
|
|
24
|
+
Step = Struct.new(:label, :reaction, :flows, keyword_init: true)
|
|
16
25
|
|
|
17
26
|
attr_accessor :steps, :spectators
|
|
18
27
|
|
data/lib/asciichem/perception.rb
CHANGED
|
@@ -91,23 +91,62 @@ module AsciiChem
|
|
|
91
91
|
.select { |deficit| deficit >= 0 && deficit.modulo(1).zero? }
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
|
|
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).
|
|
100
115
|
def aromatic_rings(atoms, edges)
|
|
101
116
|
cycles = ring_cycles(edges, max_size: 6, atom_count: atoms.length)
|
|
102
|
-
|
|
103
|
-
cycles.select { |cycle|
|
|
117
|
+
cyclic_atoms = cycles.flatten.uniq
|
|
118
|
+
cycles.select { |cycle| aromatic_cycle?(cycle, atoms, edges, cyclic_atoms) }
|
|
104
119
|
end
|
|
105
120
|
|
|
106
|
-
def
|
|
107
|
-
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)
|
|
108
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)
|
|
109
144
|
edges.any? do |edge|
|
|
110
|
-
|
|
145
|
+
next false unless edge.from == atom_index || edge.to == atom_index
|
|
146
|
+
next false unless edge.kind == :double
|
|
147
|
+
|
|
148
|
+
partner = edge.from == atom_index ? edge.to : edge.from
|
|
149
|
+
cyclic_atoms.include?(partner)
|
|
111
150
|
end
|
|
112
151
|
end
|
|
113
152
|
|
|
@@ -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
|
|
@@ -36,6 +40,7 @@ module AsciiChem
|
|
|
36
40
|
raise ParseError, 'molecule has no bonds — a formula is not a structure' if edges.empty? && atoms.length > 1
|
|
37
41
|
|
|
38
42
|
@atoms = atoms
|
|
43
|
+
perceive_aromaticity(atoms, edges)
|
|
39
44
|
build_adjacency(edges)
|
|
40
45
|
@visited = {}
|
|
41
46
|
@digits = Array.new(atoms.length) { [] }
|
|
@@ -66,6 +71,62 @@ module AsciiChem
|
|
|
66
71
|
|
|
67
72
|
private
|
|
68
73
|
|
|
74
|
+
# The canonicalizer consumes the perception pass (TODO.v2/13
|
|
75
|
+
# item 2): atoms in perceived aromatic rings are written as
|
|
76
|
+
# aromatic (lowercase) and the ring bonds between them carry no
|
|
77
|
+
# bond symbol — Kekulé benzene canonicalizes to `c1ccccc1`, so
|
|
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.
|
|
85
|
+
def perceive_aromaticity(atoms, edges)
|
|
86
|
+
@perceived_aromatic = {}
|
|
87
|
+
@perceived_ring_edges = {}
|
|
88
|
+
@perceived_hydrogens = {}
|
|
89
|
+
AsciiChem::Perception.aromatic_rings(atoms, edges).each do |ring|
|
|
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
|
|
99
|
+
ring.each_with_index do |atom, i|
|
|
100
|
+
other = ring[(i + 1) % ring.length]
|
|
101
|
+
@perceived_ring_edges[[atom, other].minmax] = true
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
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
|
+
|
|
119
|
+
# Aromatic for WRITING: the source flagged the atom, or
|
|
120
|
+
# perception found it in an aromatic ring.
|
|
121
|
+
def aromatic?(index)
|
|
122
|
+
@atoms[index].aromatic == true || @perceived_aromatic.key?(index)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def perceived_ring_bond?(from, to)
|
|
126
|
+
@perceived_ring_edges.key?([from, to].minmax) &&
|
|
127
|
+
aromatic?(from) && aromatic?(to)
|
|
128
|
+
end
|
|
129
|
+
|
|
69
130
|
def build_adjacency(edges)
|
|
70
131
|
@adjacency = Array.new(@atoms.length) { {} }
|
|
71
132
|
@directions = {}
|
|
@@ -205,6 +266,11 @@ module AsciiChem
|
|
|
205
266
|
token = BOND_TOKENS[kind]
|
|
206
267
|
raise ParseError, "#{kind} bonds have no SMILES form (v1 subset)" unless token
|
|
207
268
|
|
|
269
|
+
# A bond inside a perceived aromatic ring is implied by the
|
|
270
|
+
# lowercase atoms: writing `=`/`-` there would state Kekulé
|
|
271
|
+
# alternation the canonical form does not carry.
|
|
272
|
+
return '' if perceived_ring_bond?(from, to)
|
|
273
|
+
|
|
208
274
|
return token if kind != :single && kind != :aromatic
|
|
209
275
|
|
|
210
276
|
if kind == :single
|
|
@@ -232,10 +298,14 @@ module AsciiChem
|
|
|
232
298
|
return @plain_tokens[index] if !stereo && @plain_tokens[index]
|
|
233
299
|
|
|
234
300
|
atom = @atoms[index]
|
|
235
|
-
aromatic =
|
|
301
|
+
aromatic = aromatic?(index)
|
|
236
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)
|
|
237
306
|
needs_bracket =
|
|
238
|
-
atom.charge || atom.isotope || atom.hydrogens || atom.chirality ||
|
|
307
|
+
atom.charge || atom.isotope || !atom.hydrogens.nil? || atom.chirality ||
|
|
308
|
+
hetero_needs_h ||
|
|
239
309
|
(!ORGANIC.include?(atom.element) && !lowercase) ||
|
|
240
310
|
(aromatic && !lowercase)
|
|
241
311
|
token =
|
|
@@ -245,10 +315,10 @@ module AsciiChem
|
|
|
245
315
|
t << atom.isotope if atom.isotope
|
|
246
316
|
t << symbol
|
|
247
317
|
t << chirality_token(index) if stereo && atom.chirality
|
|
248
|
-
if atom.hydrogens
|
|
318
|
+
if !atom.hydrogens.nil? || hetero_needs_h
|
|
249
319
|
t << 'H'
|
|
250
320
|
# Stated zero is H0, not a bare H (which means one).
|
|
251
|
-
t <<
|
|
321
|
+
t << hyd.to_s unless hyd == 1
|
|
252
322
|
end
|
|
253
323
|
t << charge_suffix(atom.charge) if atom.charge
|
|
254
324
|
t << ']'
|
|
@@ -271,10 +341,6 @@ module AsciiChem
|
|
|
271
341
|
|
|
272
342
|
"#{sign}#{count}"
|
|
273
343
|
end
|
|
274
|
-
|
|
275
|
-
def aromatic?(index)
|
|
276
|
-
@atoms[index].aromatic == true
|
|
277
|
-
end
|
|
278
344
|
end
|
|
279
345
|
end
|
|
280
346
|
end
|
|
@@ -441,13 +441,44 @@ module AsciiChem
|
|
|
441
441
|
if key == 'spectator'
|
|
442
442
|
spectators.concat(value.split(/\s+/).map(&:strip))
|
|
443
443
|
else
|
|
444
|
-
|
|
444
|
+
reaction, flows = split_flows(key, value)
|
|
445
|
+
steps << Model::Mechanism::Step.new(label: key, reaction: reaction, flows: flows)
|
|
445
446
|
end
|
|
446
447
|
end
|
|
447
448
|
Model::Mechanism.new(steps: steps, spectators: spectators)
|
|
448
449
|
end
|
|
449
|
-
|
|
450
|
+
|
|
450
451
|
private
|
|
452
|
+
|
|
453
|
+
# A step value is its reaction, optionally followed by
|
|
454
|
+
# `| flow: SOURCE > TARGET` clauses (semicolon-separated).
|
|
455
|
+
# Unknown clause kinds are rejected, never dropped.
|
|
456
|
+
def split_flows(label, value)
|
|
457
|
+
clauses = value.split('|').map(&:strip)
|
|
458
|
+
reaction = clauses.shift
|
|
459
|
+
flows = clauses.flat_map { |clause| parse_flow_clause(label, clause) }
|
|
460
|
+
[reaction, flows]
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def parse_flow_clause(label, clause)
|
|
464
|
+
kind, payload = clause.split(':', 2)
|
|
465
|
+
unless kind&.strip == 'flow' && payload
|
|
466
|
+
raise AsciiChem::ParseError,
|
|
467
|
+
"mechanism step #{label.inspect} has an unknown clause: #{clause.inspect} " \
|
|
468
|
+
'(supported: "flow: SOURCE > TARGET")'
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
payload.split(';').map(&:strip).reject(&:empty?).map do |arrow|
|
|
472
|
+
source, target = arrow.split('>', 2).map(&:strip)
|
|
473
|
+
if source.nil? || target.nil? || source.empty? || target.empty?
|
|
474
|
+
raise AsciiChem::ParseError,
|
|
475
|
+
"mechanism step #{label.inspect} has a malformed flow: #{arrow.inspect} " \
|
|
476
|
+
'(expected "SOURCE > TARGET")'
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
Model::Mechanism::Flow.new(source: source, target: target)
|
|
480
|
+
end
|
|
481
|
+
end
|
|
451
482
|
|
|
452
483
|
def parse_entries(str)
|
|
453
484
|
return [] unless str
|
data/lib/asciichem/version.rb
CHANGED
|
@@ -6,12 +6,23 @@ module AsciiChem
|
|
|
6
6
|
# zmatrix, calculation. Emission covers the full v1 schemas;
|
|
7
7
|
# ingestion (from_wire) is currently implemented for the core
|
|
8
8
|
# chemistry set only — see WireAdapter.
|
|
9
|
+
class MechanismFlow < Lutaml::Model::Serializable
|
|
10
|
+
attribute :source, :string
|
|
11
|
+
attribute :target, :string
|
|
12
|
+
json do
|
|
13
|
+
map "source", to: :source
|
|
14
|
+
map "target", to: :target
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
9
18
|
class MechanismStep < Lutaml::Model::Serializable
|
|
10
19
|
attribute :label, :string
|
|
11
20
|
attribute :reaction, "AsciiChem::Wire::Reaction"
|
|
21
|
+
attribute :flows, MechanismFlow, collection: true
|
|
12
22
|
json do
|
|
13
23
|
map "label", to: :label
|
|
14
24
|
map "reaction", to: :reaction
|
|
25
|
+
map "flows", to: :flows
|
|
15
26
|
end
|
|
16
27
|
end
|
|
17
28
|
|
|
@@ -236,13 +236,46 @@ module AsciiChem
|
|
|
236
236
|
def mechanism_to_wire(node)
|
|
237
237
|
AsciiChem::Wire::Mechanism.new(
|
|
238
238
|
type: "mechanism",
|
|
239
|
-
steps: node.steps.map
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
239
|
+
steps: node.steps.map { |step| mechanism_step_to_wire(step) },
|
|
240
|
+
spectators: node.spectators.map { |sp| mechanism_spectator_to_wire(sp) }
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Spectators are verbatim strings in the model (the mechanism
|
|
245
|
+
# body is text); the wire wants molecules, so the same grammar
|
|
246
|
+
# parses them here — a spectator that is not valid AsciiChem
|
|
247
|
+
# cannot reach the wire and says so.
|
|
248
|
+
def mechanism_spectator_to_wire(text)
|
|
249
|
+
molecule_to_wire(AsciiChem.parse(text).nodes.first)
|
|
250
|
+
rescue AsciiChem::ParseError
|
|
251
|
+
raise AsciiChem::ParseError,
|
|
252
|
+
"mechanism spectator is not valid AsciiChem: #{text.inspect}"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def mechanism_step_to_wire(step)
|
|
256
|
+
AsciiChem::Wire::MechanismStep.new(
|
|
257
|
+
label: step.label,
|
|
258
|
+
reaction: mechanism_reaction_to_wire(step.reaction),
|
|
259
|
+
flows: (step.flows || []).map do |flow|
|
|
260
|
+
AsciiChem::Wire::MechanismFlow.new(source: flow.source, target: flow.target)
|
|
261
|
+
end
|
|
243
262
|
)
|
|
244
263
|
end
|
|
245
264
|
|
|
265
|
+
# The model keeps a step's reaction as TEXT (the mechanism body
|
|
266
|
+
# is verbatim); the wire carries the typed node, so the same
|
|
267
|
+
# grammar parses it here.
|
|
268
|
+
def mechanism_reaction_to_wire(text)
|
|
269
|
+
node = AsciiChem.parse(text).nodes.first
|
|
270
|
+
case node
|
|
271
|
+
when AsciiChem::Model::Reaction then reaction_to_wire(node)
|
|
272
|
+
when AsciiChem::Model::ReactionCascade then cascade_to_wire(node)
|
|
273
|
+
else
|
|
274
|
+
raise AsciiChem::ParseError,
|
|
275
|
+
"mechanism step is not a reaction: #{text.inspect}"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
246
279
|
def spectrum_to_wire(node)
|
|
247
280
|
AsciiChem::Wire::Spectrum.new(
|
|
248
281
|
type: "spectrum",
|