Skip to content

Architecture

The workbench is one Python process behind nginx. It serves the pages, runs the chemistry, and answers the JSON API. Anything that scales with screen pixels runs in the visitor's browser instead; anything that needs RDKit or the orbital layer runs on the server, once per molecule, and is cached.

The parts

browser                                 server (one Python process)
───────                                 ───────────────────────────
catalogue    RDKit.js + SMARTS          server.py       HTTP, routing, caching, security headers
molviewer    3Dmol.js meshes      ◀──── /api/model      RDKit conformer → qo_chem → orbitals JSON
pka-density  pi-field-engine.js   ◀──── /api/pi-model   qo_chem π layer → forms, sites, forecasts
molviewer    Mol* (second tab)    ◀──── same /api/model; π fields as cube volumes, surfaces as PLY
sites        Mol* + qo-orbitals   ◀──── /api/site       structure → motif, metal site or ligand
             (whole protein)                            (site_service.py, qo_chem/structure.py,
                                                        qo_chem/ligands.py) → fragment orbitals
studio, overlap, pauli  (pure JS)       site_meta.py    page registry: titles, canonical URLs,
docs/*       KaTeX                                      sitemap, header, JSON-LD
                                        md_lite.py      these documentation pages
                                        qo_chem/        the orbital layer (see below)

One layer under everything

Before version 16 each page carried its own simplified chemistry: the 3D viewer had a fixed s:p ratio per hybridisation, and the π density study had hand-typed atoms for 20 molecules. Now both read from the orbital layer, qo_chem:

  • /api/model builds the conformer, runs qo_chem.analysis.analyze, and converts the result into the orbitals list the 3D viewers already understood. Existing clients keep working; the coefficients are simply better (Coulson hybrids, σ and π lone pairs). The full result is also returned as chemistry.
  • /api/pi-model runs the same layer and returns the π system of the molecule and of each acid/base pair in the payload format of pi-field-engine.js.
  • The curated dataset of /pka-density is regenerated from the same code by tools/build_pka_dataset.py, so the 20 reference molecules and any molecule a visitor types are computed identically.

Every ionisable site of a molecule is titrated together by qo_chem/speciation.py (microstates, site pKa with a substituent term, site–site charge term), with parameters fitted on the IUPAC Digitized pKa Dataset (pKa of every site). /api/model's protonation, /api/pi-model's speciation block and the pH slider of /pka-density (mirrored in solution-ph.js) all use it.

One radial table serves all three: qo_chem/data/radial_fits.json, the free-atom Hartree–Fock valence orbitals fitted by short Slater sums (Radial functions: an experiment). The orbital layer integrates with it, the π maps evaluate it, and the 3D viewer draws its hybrid surfaces and π fields with it.

qo_chem depends on NumPy only. RDKit is used through one adapter (qo_chem/rdkit_adapter.py), so the layer itself is testable without RDKit.

Where the work happens

WorkWhereWhy
SMILES parsing, SMARTS, 2D depiction (catalogue)browser, RDKit.jsinstant, no server load
conformer (ETKDG + MMFF94/UFF)server, RDKitneeds the C++ toolkit
orbital layer (typing, hybrids, Hückel, overlaps)server, NumPysmall matrices, cached
168 × 168 density, potential and orbital gridsbrowserscales with pixels and visitors, not with the server
3D orbital meshes and π fields (density, interference, MOs, potential)browser, pi-field-engine.js + 3Dmol.jsWebGL; the grids scale with the screen, not with visitors
the same fields in Mol*, and as cube / PLY / MolBlock filesbrowser, qo-export.js + molstar-bridge.jsstandard formats in the MolBlock's frame
typesetting the equations in these docsbrowser, KaTeXstatic, self-hosted

The server's cost per molecule is one conformer plus one layer run (two per ionisable site for pKa, at most six sites). Results are kept in an in-process LRU cache, so a molecule shared by several visitors is computed once.

Pages, URLs and indexing

site_meta.py is the single registry of public pages. From it the server

  • gives every page the same <head>: title, meta description, canonical URL, Open Graph and Twitter cards, and schema.org JSON-LD (breadcrumbs plus WebApplication, LearningResource or TechArticle);
  • injects one shared site header with grouped menus and a mobile menu, and removes each page's older navigation bar;
  • serves /sitemap.xml and /robots.txt (the API is excluded from crawling, its reference page is not);
  • answers every alias (/catalogue.html, /catalogue/, /pi-map, …) with a 301 to one canonical URL;
  • renders the Markdown files listed in DOCS as the pages under /docs/.

Adding a page means adding one line to PAGES (and, for documentation, one line to DOCS). The tests in tests/test_site_and_api.py fail if a page is missing, has two headers, two canonical links or a description of the wrong length.

HTTP behaviour

  • ThreadingHTTPServer: a slow conformer does not block the static pages.
  • gzip for text responses over 1 kB; strong ETags on pages, so a revisit is a 304.
  • Cache-Control: one year (immutable) for /vendor/, one day for CSS, JS, data and icons (busted by ?v= on upgrade), no-cache for HTML.
  • Security headers on every response: X-Content-Type-Options, Referrer-Policy, X-Frame-Options, Permissions-Policy.
  • No directory listings; an HTML 404 page with the site header for browsers and JSON errors for /api/.
  • Third-party browser code is self-hosted: KaTeX ships in the package, and the fonts, 3Dmol.js, Mol*, RDKit.js and Swagger UI are installed by the install_*.sh scripts. Until they are, the 3D viewer, catalogue and Swagger page fall back to the same pinned versions on unpkg or cdn.jsdelivr.net (Mol*, only when its tab is opened), and the pages use system fonts (never Google's servers). "Load PDB" in the Mol* tab fetches the entry from the RCSB PDB, on request.
  • One exception, on purpose: page views are counted with GoatCounter (lcjoe.goatcounter.com). Every HTML page loads gc.zgo.at/count.js, which sends the path, referrer, screen size and browser to GoatCounter. It sets no cookies, and GoatCounter does not store IP addresses. /api/ is never counted, and count.js skips localhost. Switch it off with --goatcounter off or QO_GOATCOUNTER=off.

What scales with the number of atoms

QuantityGrowth
σ hybridslinear (constant work per atom)
ring searchlinear: depth-limited, cached until the graph changes (16.4; it was quadratic per call)
Hückelcubic in the size of each π component, not of the molecule; sign alignment and component search use adjacency lists (16.4)
overlap integralspairs within 7 Å: linear for large molecules
browser gridsfixed by the pixel count; linear in π atoms per pixel
orbital surfaceslinear in orbitals: one small box per orbital, vertices welded by grid edge (16.4)
payloadquadratic in π atoms per component (S and M matrices)

--max-atoms (default 120) caps a SMILES request, which must also find a conformer. Proteins take the other route (16.4): /sites cuts a motif or metal site out of a deposited structure and sends only that fragment through the orbital layer, up to --max-site-atoms (default 1500 atoms with hydrogens). Measured on one core, the layer costs 0.6–1.1 ms per atom from 155 to 5074 atoms (tools/sites/RESULTS.md). The whole protein stays in the viewer (Mol*), in its own frame, and the fragment's surfaces land on it.

Tests

bash
python -m unittest discover -s tests -t .
  • tests/test_structure.py: protein sites: PDB/mmCIF/BinaryCIF readers, PROSITE motifs, capped fragments, zinc placement, donor and acceptor orbitals, /api/site (16.4).
  • tests/test_ligands.py: ligands (16.5): perception from coordinates against the PDB dictionary (the test set and the held-out set of tools/ligands/), stereo, links to the protein, the bound fragment, and the ligand mode of /api/site.
  • tests/test_qo_chem.py: the orbital layer against known values (Coulson angles, benzene bond order, overlaps, Hammett, σ*, sum rules, the 20 reference molecules).
  • tests/test_rdkit_adapter.py and the API part of tests/test_site_and_api.py: need RDKit, so they run on the server and are skipped elsewhere.
  • tests/test_site_and_api.py: the page registry, every page's head and header, sitemap, robots, aliases and the documentation pages.
  • Browser modules, with Node: node tests/test_mol3d_scene.js (the scene owns the coordinates; overlays never move atoms), node test_solution_ph.js (Henderson–Hasselbalch populations, adaptive map window) and node tests/test_coupled_ph.js (the browser's coupled pH model gives the same numbers as qo_chem.speciation, from one shared fixture), node tests/test_mesh.js (isosurfaces closed, outward-facing, smooth), node tests/test_orbitals.js (the shared orbital surfaces of /molviewer and /sites), node tests/test_export.js (cube and PLY files of real π fields read back in the right frame) and node tests/test_molstar_bridge.js (what the Mol* tab sends, against a recording stand-in).

Every browser module carries in VERSION the release in which it last changed; pages load it with ?v= of that release or a later one, so a browser reloads exactly the files that changed.

Source: ARCHITECTURE.md in the server package · updated