Methods
The pipeline starts from a cheap calculation of a molecule's infrared spectrum. A trained model then predicts how a far more accurate calculation (coupled cluster, CCSD(T)) would shift each vibration. That prediction is shown for a family of vibrations only after it has been checked against anchors: molecules for which the accurate calculation was actually done.
The model is still in training on a growing set of molecules, so no page shows a predicted correction yet. Six molecules have an anchor: benzene, naphthalene, anthracene, pyridine, fluorobenzene and benzonitrile. Their pages show the anchor's spectrum first, and benzene's is also checked against a measured laboratory spectrum (NIST). Three more anchors (quinoxaline, azulene, acenaphthylene) are computed or in progress for a registered test and are not shown until it is read. Every other molecule shows the cheap spectrum only. Everything below links to the pre-registration or the note behind it.
The pictures — the architecture sheets, read from the repository at build time
Rectangles are steps (operation + software), rounded shapes are data objects, dashed shapes are not built yet. (loads a diagram library, ≈ 600 kB)
Overview 00_overview.mmd
Overview of plan 05 (level 1): the kinds of process and the data objects that connect them. Target architecture; no decisions, no data. Rectangle = process (here: a whole sheet); rounded ends = data object; arrow = data flow. Dashed = not built yet. 7 October 2026: the candidate generator (module 06) feeds the corpus as a registered source; the Spectrum Atlas publishes the catalogue with the status and the provenance of every molecule; training exists (the carried ΔH model); test and licence, and the spectrum pipeline as one chain, do not yet.
flowchart LR linkStyle default stroke:#8a9bb0,stroke-width:2.2px classDef planned stroke-dasharray: 6 4,stroke:#b8860b,fill:#fff3c4,color:#111 classDef ext fill:#cfd8e3,stroke:#3d5a80,color:#111 classDef data fill:#e9ecef,stroke:#555,color:#111 classDef proc fill:#f7f7fb,stroke:#333,stroke-width:1.5px,color:#111 classDef research fill:#fdf2e3,stroke:#8a5a00,color:#111 MOL(["Molecule: geometry, charge, multiplicity"]):::data COND(["Observation conditions: internal energy after UV absorption (emission) or temperature (absorption); resolution of the instrument"]):::data PUB(["PubChem fused aromatics, frozen set"]):::ext LABDB(["Laboratory spectra"]):::ext PAHDB(["Opponents: scaled DFT spectra (PAHdb and others) and other predictors"]):::ext GENF["Candidate generator (module 06)"]:::proc CANDS(["New fused ring systems with PubChem membership"]):::data LABF["Label factory (sheet 4)"]:::proc LABELS(["Label: ΔH blocks with error margin per family and geometry term per mode, sealed"]):::data CORPF["Corpus step (sheet 3)"]:::proc CORPUS(["Corpus records: two DFT Hessians, proxy correction, local-coordinate features and deck responses per molecule"]):::data ATLASF["Atlas export (website)"]:::proc ATLAS(["Spectrum Atlas: catalogue with status and provenance per molecule"]):::data TRAIN["Training (sheet 6)"]:::proc TRAINED(["Trained ΔH model: ensemble of members"]):::data TESTSET(["Test set"]):::data EVAL["Test and licence (sheet 7)"]:::planned LICCAL(["Licence table and calibration"]):::data PIPE["Spectrum pipeline (sheet 8)"]:::planned SPEC(["Spectrum: bands with position, intensity, profile and error margin; licence status per family"]):::data RES["Research process (sheets 1 and 2)"]:::research PUB --> GENF --> CANDS --> CORPF MOL --> LABF --> LABELS MOL --> CORPF --> CORPUS CORPUS --> ATLASF LABELS --> ATLASF --> ATLAS LABELS --> TRAIN CORPUS --> TRAIN TRAIN --> TRAINED TRAINED --> EVAL TRAIN --> TESTSET --> EVAL LABDB --> EVAL PAHDB --> EVAL EVAL --> LICCAL MOL --> PIPE LABELS --> PIPE COND --> PIPE TRAINED --> PIPE LICCAL --> PIPE PIPE --> SPEC RES -. decides on .-> LABF RES -. decides on .-> TRAIN RES -. decides on .-> EVAL RES -. decides on .-> PIPE
Data creation: the corpus 30_data_creation_corpus.mmd
Data creation — the corpus: two DFT Hessians per molecule and the proxy correction (level 3). Target architecture. Rectangle = process step (operation + software); rounded ends = data object (the thing). Every step yields one data object. Dashed = not built yet. 7 October 2026: the molecules come from a manifest with a source per row (parents by hand, enumerated children, Hessian-QM9, pool 3, the candidate generator of module 06 with its gate); what is computed next is chosen by the composition rule. The two Hessians are analytic (pyscf) — the finite-difference psi4 Hessians of the first layers stay in the record as the first route and are being replaced pool-wide. The record keeps the Cartesian Hessians, the modes, the local-coordinate features and the probe deck's pattern responses (the mode tokens of 6 September were dropped by decision 49). 9 October 2026, the cheap level's reach (measured): both DFT Hessians by finite differences (psi4, 6-31G*, 8 threads of a CPX62) take 4.0 h for perylene (32 atoms), 5.3 h for a 33-atom, 6.6 h for a 35-atom and 7.6 h for a 38-atom molecule of pool 3; the analytic route (pyscf, the labels server) takes 6,000–14,000 s per functional at 30 atoms. So the corpus can hold the large families themselves (≈ 40 atoms); chain 39 (9 Oct) showed that it has to — the network does not carry the correction across a large gap in size and ring count.
flowchart LR linkStyle default stroke:#8a9bb0,stroke-width:2.2px classDef planned stroke-dasharray: 6 4,stroke:#b8860b,fill:#fff3c4,color:#111 classDef data fill:#e9ecef,stroke:#555,color:#111 classDef ext fill:#cfd8e3,stroke:#3d5a80,color:#111 SRCX(["Parent molecules, Hessian-QM9, pool 3"]):::ext PUB(["PubChem fused aromatics, frozen set"]):::ext ENUM["Enumeration of substituted children (RDKit)"] KIDS(["Enumerated children of the parent cores"]):::data GEN["Candidate generation and gate (module 06; PyTorch, RDKit)"] CANDS(["New fused ring systems with PubChem membership"]):::data MAN["Manifest assembly with a source per row (own software)"] MANI(["Manifest: molecule, layer, source, status"]):::data SEL["Selection by the composition rule (own software)"] MOL(["Molecule: SMILES or geometry, charge, multiplicity"]):::data OPT["Geometry optimisation at low level (psi4)"] GEO(["Optimised geometry"]):::data DFTL["Analytic Hessian at low level, B3LYP (pyscf)"] SK(["Hessian H0 and dipole derivatives"]):::data DFTH["Analytic Hessian at high level, ωB97X (pyscf)"] H1(["Hessian H1"]):::data MODE["Mode analysis (own software)"] MODES(["Normal modes: L, frequencies, families, symmetry blocks"]):::data PROXY["Proxy correction (own software)"] DHP(["Proxy correction: ΔH = H1 − H0 (Cartesian), with its projections on the modes and on the primitive-pair coordinates"]):::data LOCC["Local-coordinate features (own software, geomeTRIC)"] PF(["Primitive internal coordinates and their pairs — diagonal, atom-sharing, ring bond–bond — with pair features from H0"]):::data DECK["Deck responses (own software)"] RESP(["Pattern responses R = ½ aᵀ ΔH a of the probe deck: the measurement planner's records"]):::data REC["Record assembly (own software)"] CORP(["Corpus records: two DFT Hessians, proxy correction, local-coordinate features and deck responses per molecule"]):::data SRCX --> ENUM --> KIDS --> MAN PUB --> GEN --> CANDS --> MAN SRCX --> MAN --> MANI --> SEL --> MOL MOL --> OPT --> GEO GEO --> DFTL --> SK --> MODE --> MODES GEO --> DFTH --> H1 SK --> PROXY H1 --> PROXY MODES --> PROXY --> DHP --> REC GEO --> LOCC SK --> LOCC --> PF --> REC DHP --> DECK MODES --> DECK --> RESP --> REC SK --> REC REC --> CORP
Data creation: the labels 40_data_creation_labels.mmd
Data creation — the label factory: how one label comes about (level 3). Target architecture: no decisions, no data. Rectangle = process step (operation + software); rounded ends = data object (the thing). Every step yields one data object. Dashed = not built yet. 7 October 2026: redrawn to the built route. A label is a canonical CCSD(T) Hessian by central differences of analytic gradients over the symmetry-unique displacements, raised to cc-pVTZ quality by the MP2 basis step (the composite level), checked by two routes per quantity and kept in the anchor registry (one carried version per molecule). The open-shell gradients are the planned branch for cations; the deck-transport route of September is replaced by the full symmetric deck. 26 October 2026: the local-correlation (LNO) route is gone (its out-of-plane offset does not shrink); the MP2 basis step is psi4's density-fitted MP2 gradient.
flowchart LR linkStyle default stroke:#8a9bb0,stroke-width:2.2px classDef planned stroke-dasharray: 6 4,stroke:#b8860b,fill:#fff3c4,color:#111 classDef data fill:#e9ecef,stroke:#555,color:#111 MOL(["Molecule: geometry, charge, multiplicity"]):::data SK(["Hessian H0 and dipole derivatives"]):::data SYM["Symmetry analysis (own software)"] DECK(["Deck: symmetry-unique Cartesian displacements"]):::data CCG["CCSD(T) gradients with relaxed density (pyscf, own (T) kernels in C)"] GRADS(["Gradients, energies and dipoles at every displacement"]):::data OPEN["UCCSD(T) gradients for open shell (pyscf, own unrestricted (T) kernels)"]:::planned OPENG(["Open-shell gradients, energies and dipoles at every displacement"]):::data FD["Central differences and symmetry reconstruction (own software)"] HCC(["CCSD(T)/cc-pVDZ Hessian and dipole derivatives"]):::data MP2["DF-MP2 gradients in two basis sets (psi4)"] MROWS(["MP2 Hessian rows, cc-pVDZ and cc-pVTZ"]):::data COMP["Composite assembly (own software)"] HCOMP(["Composite Hessian: CCSD(T)/cc-pVDZ plus the MP2 basis step"]):::data DIFF["Difference to the cheap level (own software)"] DH(["ΔH blocks per family: diagonal and couplings"]):::data CHECK["Consistency check (own software)"] NOISE(["Noise term per quantity: difference between two routes or between symmetry partners"]):::data BUDGET["Error budget (own software)"] MARG(["Error margin per family: noise and additivity error of the composite"]):::data CUB(["Cubic constants of the low level (from the VPT2 step of sheet 8)"]):::data GEO["Geometry term (own software)"]:::planned GTERM(["Geometry term per mode"]):::data SEAL["Sealing and registration (own software)"] LABEL(["Label: ΔH blocks with error margin per family and geometry term per mode, sealed"]):::data MOL --> SYM --> DECK DECK --> CCG --> GRADS --> FD DECK --> OPEN --> OPENG --> FD FD --> HCC --> COMP DECK --> MP2 --> MROWS --> COMP COMP --> HCOMP --> DIFF SK --> DIFF --> DH --> SEAL GRADS --> CHECK HCC --> CHECK MROWS --> CHECK CHECK --> NOISE --> BUDGET DH --> BUDGET --> MARG --> SEAL GRADS --> GEO CUB --> GEO --> GTERM --> SEAL SEAL --> LABEL
The correction model 50_deltaH_model_components.mmd
Components of the ΔH model (level 4): what happens inside the step "Forward pass (ΔH model, PyTorch)" of sheet 8 (the spectrum pipeline). Rectangle = process step (operation + software); rounded ends = data object (the thing). Every step yields one data object. Dashed = not built yet. 7 October 2026: redrawn to the carried design (the hybrid model, ΔH-network v1.1, chain 34). An equivariant body turns atoms, positions, charge state and the rotation invariants of H0 into per-atom features; a pair head predicts one force-constant correction per pair of primitive internal coordinates on a fixed pattern, from the features of the atoms involved, the pair features and the cheap force constants; a learned scale per pair class adds a share of the cheap force constant (SQM-like); ΔH = Bᵀ ΔF B is exactly symmetric and translation- and rotation-free. The tensor head (Cartesian 3×3 blocks) and the stand-alone pairwise head of rungs B and C remain in the code as measured variants, not in the carried design.
flowchart LR linkStyle default stroke:#8a9bb0,stroke-width:2.2px classDef planned stroke-dasharray: 6 4,stroke:#b8860b,fill:#fff3c4,color:#111 classDef data fill:#e9ecef,stroke:#555,color:#111 GEO(["Molecule: atomic numbers, coordinates of the cheap minimum, charge, multiplicity"]):::data SK(["Hessian H0 of the cheap level"]):::data PRIMC["Primitive internal coordinates and the pair pattern (geomeTRIC, own software)"] PRIM(["Primitive pairs of the pattern, Wilson B matrix, cheap force constants F0 per pair"]):::data FEAT["Pair features (own software)"] PF(["Pair features: pair classes, ring relations, projections of H0"]):::data EQB["Equivariant body: message passing over atoms with scalar and vector channels, charge-state embedding, fed by the rotation invariants of H0 (PyTorch)"] ATOMF(["Per-atom scalar and vector features"]):::data HEAD["Pair head: pooled atom features of the two primitives, pair features, F0 (PyTorch)"] DFR(["ΔF residual per primitive pair"]):::data SQM["Class scaling: ΔF = α per pair class × F0 + residual (PyTorch)"] DFL(["ΔF: force-constant correction per primitive pair"]):::data BACK["Back-transformation ΔH = Bᵀ ΔF B (own software)"] DHC(["ΔH in Cartesian coordinates of one member"]):::data PROJ["Projection onto the cheap modes: Lᵀ ΔH L (own software)"] KLOC(["ΔH blocks per family of one member, couplings included"]):::data ENSA["Ensemble averaging over the members (own software)"]:::planned OUT(["ΔH blocks per family, with ensemble uncertainty"]):::data GEO --> PRIMC --> PRIM PRIM --> FEAT SK --> FEAT --> PF --> HEAD GEO --> EQB SK --> EQB --> ATOMF --> HEAD PRIM --> HEAD --> DFR --> SQM PRIM --> SQM --> DFL --> BACK PRIM --> BACK --> DHC --> PROJ --> KLOC --> ENSA --> OUT
Test and licence 70_test_and_licence.mmd
Test and licence (level 3b): from the trained ΔH model and the test set come the licence table and the calibration that the spectrum pipeline uses alongside the trained model. The model's weights no longer change here. Rectangle = process step (operation + software); rounded ends = data object (the thing). Every step yields one data object. Dashed = not built yet. Built: the test per family on the fixed hold-outs (analytic labels where they exist), the promotion of a model against lines registered before its run, and the licence (decision 65: a family is licensed where the carried model's error is within 3 cm⁻¹ on both hold-outs; neutral molecules). Not yet: the calibration of the ensemble spread and the score against laboratory spectra and opponents.
flowchart LR linkStyle default stroke:#8a9bb0,stroke-width:2.2px classDef planned stroke-dasharray: 6 4,stroke:#b8860b,fill:#fff3c4,color:#111 classDef data fill:#e9ecef,stroke:#555,color:#111 classDef ext fill:#cfd8e3,stroke:#3d5a80,color:#111 ENS(["Trained ΔH model: ensemble of members"]):::data TESTSET(["Test set"]):::data LABDB(["Laboratory spectra with the margin per reference column"]):::ext PAHDB(["Opponents: scaled DFT spectra (PAHdb and others) and other predictors"]):::ext TEST["Test on the hold-out molecules per family (own software)"] TESTR(["Test errors per family and charge state, alongside the opponents"]):::data PROMO["Promotion against the registered lines and the learning curve (own software)"] STAT(["Model status: carried, candidate or superseded"]):::data LIC["Licence determination: within 3 cm⁻¹ on both hold-outs (own software)"] LICT(["Licence table per family (neutral; charge states once cations are trained)"]):::data CAL["Uncertainty calibration (own software)"]:::planned CALR(["Calibration of the ensemble spread"]):::data ENS --> TEST TESTSET --> TEST LABDB --> TEST PAHDB --> TEST TEST --> TESTR TESTR --> PROMO --> STAT TESTR --> LIC --> LICT TESTR --> CAL --> CALR
The spectrum pipeline 80_spectrum_pipeline.mmd
Spectrum pipeline (level 3): a molecule in, its certified infrared spectrum out. As built: modules/08_industry_synthesis/m08/pipeline.py, pipeline 0.4 (versions in modules/PIPELINE.md). Rectangle = process step (operation + software); rounded ends = data object (the thing). Every step yields one data object. Dashed = not built yet. 30 October 2026: brought to the built pipeline (TASKS 153). The licence enters at the certificate, per family and band (decision 65), not as a filter on ΔH blocks: the whole ΔH is applied and every band says whether its family is licensed. A measured label (sheet 4) is not a ΔH source here, and observation conditions (temperature, emission) are later work (TASKS 11). Planned: reuse of a corpus Hessian (TASKS 150), the training-set check (TASKS 155), the geometry term and the anharmonic step (TASKS 53).
flowchart LR linkStyle default stroke:#8a9bb0,stroke-width:2.2px classDef planned stroke-dasharray: 6 4,stroke:#b8860b,fill:#fff3c4,color:#111 classDef data fill:#e9ecef,stroke:#555,color:#111 MOL(["Molecule: SMILES, neutral and closed shell"]):::data ADM["Admission (own software, RDKit)"] ADMM(["Admitted molecule: canonical SMILES, start geometry, carried members"]):::data OPT["Geometry optimisation (DFT, psi4)"] GEO(["Optimised geometry"]):::data DFT["Hessian and dipole derivatives (DFT, pyscf, analytic)"] CORP(["Corpus records: Hessians and dipole derivatives of computed molecules"]):::data REUSE["Reuse in the canonical atom order (own software)"]:::planned SK(["Hessian H0 and dipole derivatives"]):::data MODE["Mode analysis (own software)"] MODES(["Normal modes: L, frequencies, families"]):::data LOCC["Local-coordinate features (own software, geomeTRIC)"] PF(["Primitive pairs with their features; atoms and coordinates"]):::data FWD["Forward pass (ΔH model: equivariant body and hybrid pair head, ΔH = Bᵀ ΔF B; PyTorch)"] DH(["ΔH of each member and their mean"]):::data APPLY["Assembly of H (own software)"] H(["Force constants H = H0 + ΔH"]):::data EIG["Diagonalisation (own software)"] POS(["Band positions with the members' range"]):::data INT["Intensity calculation, double harmonic (own software)"] INTS(["Band heights"]):::data VPT["VPT2 (pyVPT2 on pyscf Hessians)"]:::planned ANHC(["Anharmonic constants, with route difference per constant"]):::data GEOP["Geometry term (own software)"]:::planned POSG(["Band positions with geometry term"]):::data ANH["Anharmonic correction (own software)"]:::planned ANHS(["Anharmonic shifts per band, overtones and combination bands"]):::data SHAPE["Profile formation (own software)"] SPEC0(["Spectrum: bands with position, height and Lorentzian profile"]):::data TSET(["Training set of the carried members"]):::data TCHK["Training-set check (own software)"]:::planned TFLAG(["Trained on or unseen, with the ωB97X truth where the corpus holds it"]):::data LICT(["Licence table and measured errors per family (sheet 7)"]):::data LTOL(["Laboratory tolerance per spectral region"]):::data CERT["Certificate (own software)"] SPEC(["Certified spectrum: per band position, height, members' range, family, licence, measured error, laboratory tolerance"]):::data MOL --> ADM --> ADMM --> OPT --> GEO --> DFT --> SK ADMM --> REUSE CORP --> REUSE --> SK SK --> MODE --> MODES SK --> LOCC --> PF --> FWD --> DH --> APPLY --> H --> EIG --> POS --> SHAPE MODES --> FWD ADMM --> FWD SK --> APPLY H --> INT SK --> INT --> INTS --> SHAPE SK --> VPT --> ANHC POS --> GEOP --> POSG --> SHAPE ANHC --> GEOP ANHC --> ANH --> ANHS --> SHAPE MODES --> ANH SHAPE --> SPEC0 --> CERT --> SPEC ADMM --> TCHK TSET --> TCHK --> TFLAG --> CERT MODES --> CERT LICT --> CERT LTOL --> CERT
Six lessons
- Compute every number twice, by two different methods. One vibration of benzene in our training set was off by 133 cm⁻¹. At first it looked as if the model could not learn benzene. A second calculation by another method showed that the number itself was wrong: an artefact of the step-by-step (finite-difference) way it had been computed. Since then every computed quantity gets a second route. Quality policy.
- Code that runs and looks right can still be wrong. For six days our coupled-cluster calculations called a library function in a way that quietly used an ingredient of a simpler method. Nothing crashed, the numbers looked reasonable and our own checks passed, yet the frequencies were off by 2.5 to 7.7 cm⁻¹ on water. The library's own tests showed the correct call all along. Now, the first time we use a library function, we write down which of the library's own tests makes the same call. Incident note.
- A check needs its own check. The infrared intensities come from an equation that pyscf solves step by step. Sometimes the solver stopped before the answer was finished, without a warning. We now test every answer against the equation it should satisfy, recomputed 49 molecules, and added our evidence to the known pyscf issue. The second method we then used to confirm the result had a weakness of its own: on one molecule it needed a second electric-field strength before the two methods agreed. Blog post, pyscf issue #2702.
- A result on a few molecules is one point on a curve. On 30 September we concluded that more training data would not help, because the errors stayed flat as the data grew. They stayed flat because of a flaw in the training targets, which held every model at the same floor. Once that was fixed, more data clearly helped. A result on a small set now counts as one point on a learning curve, and we only stop when the curve has stopped falling. Ledger.
- The couplings can only be learned in local terms. When one bond in a molecule vibrates, its neighbours feel it. Written in the molecule's overall vibration patterns (its normal modes), these couplings could not be learned from any amount of data. Written as pairs of bonds and angles, the same data teach them. E7.
- The coupled-cluster correction is local too, one bond further. About 95 % of benzene's correction and 93 % of naphthalene's sits in pairs of bonds and angles at most two bonds apart. An earlier reading said 98 % for benzene; it came from the calculations of lesson 2 and was redone. E8, benzene, naphthalene.