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.

That is the vps profile, the public 2-core server. On a many-core lab server (profile lab, 17.0) the same process only handles HTTP, and the chemistry runs in a pool of worker processes: see the lab server below and LAB_SERVER.md.

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 (level 1 above 4 MB, where level 6 would cost seconds); strong ETags on pages, so a revisit is a 304. Under lab the workers compress their own answers.
  • 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 (120 on vps, 250 on lab) 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 (1500 atoms with hydrogens on vps, 12 000 on lab: about 1.35 ms per atom, 16 s, tools/lab/RESULTS.md). 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.

The lab profile: workers and batches

runtime_profile.py chooses at start what the server may spend: vps (the limits above) or lab (17.0; a private server with many cores). Under lab:

browser ──HTTP──▶ front process (server.py)                 worker processes (compute_pool.py), cores − 2
                  reads the request, fetches a GL1X file,   ──job──▶  site_service / server's model builders,
                  keeps raw files for structure_ref                    each with its own caches of parsed
                  ◀── finished, gzipped answer ──────────────────────  structures, sites and molecules
  • Routing. A structure's SHA-256 (or a SMILES) ranks the workers by rendezvous hashing; the request goes to its first or second choice if free, else to any free worker, else waits in a queue of four, else 503 busy. A worker parses each structure once and keeps it.
  • Bytes on demand. The front sends a file only to a worker that does not hold it; a worker that dropped it answers "need" and gets it.
  • Batches. POST /api/batch surveys many ligands: one task per structure, on the worker that parsed it, largest first, with work stealing when it pays (tools/lab/RESULTS.md).
  • Local dictionary. qo_chem/ccd.py indexes the PDB's components.cif once and memory-maps it, so the workers share one copy.

Python's global interpreter lock is why: one process runs one thread of Python at a time, and the orbital layer is Python. The answers are the same as in one process, apart from the timing fields.

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_lab.py: the lab server (17.0): profiles, the local component dictionary (on an excerpt in tests/data/ccd/), structure_ref, surveys and their identity key, the worker pool (same answers as in-process, a worker that dropped a file, busy, a worker that dies) and, with RDKit, the HTTP routes through the pool.
  • 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