Skip to content

Commit 7423a87

Browse files
committed
[fix.fields.rho.D8+] the box was sized for the default contour and the slider walks below it
Ivan sent a zoomed screenshot: flat cut faces on the blue and red lobes. I had a plausible story ready — the DB serves cubes on METHOD currency, not producer currency, so yesterday's cubes computed before this morning's adaptive-box fix would still be served under an unchanged 'gasteiger' label. Good story. I pulled the oldest cached cube and measured it instead of acting on it: wall 3.75 kcal/mol against a 10.00 contour. It closes cleanly. The story was wrong and would have had me invalidating a cache for nothing. THE REAL CAUSE is a hole I opened this morning. The backend grows the box until the contour closes — at the FIXED contour. The UI slider then multiplies that contour by 10^[-1,1]. So the box is sized for 10 kcal/mol and the slider can ask for 1.0, which is below the 3.75 the field still has at the box wall, and the surface exits through the side and is drawn as a flat face. Computed exactly: truncation begins at slider -0.43. THE BOTTOM 29% OF THE SLIDER PRODUCED CUT-OFF LOBES. Not an edge case — a third of the control. The adaptive box was the right idea aimed one value too high. It now sizes for the LOWEST contour the slider can reach (iso x 0.1). Classical fields cost ~0.1 s so this is free; measured after the change, pad grows 4 -> 6-10 A and the wall lands under the floor contour on both fields for both molecules, so the whole slider range closes. The quantum grid belongs to pyscf's cubegen and cannot be grown the same way, so those report instead: the readout recomputes closure against the CURRENT slider position rather than the value the box was sized for, and says "OPEN SURFACE — field reaches X at the box edge, so the lobes are cut off." CACHE: I deleted the 22 mep rows by hand. The geometry of the cube changed while its METHOD label did not, and the servable view keys on method — which is correct for its purpose (an edit to a log line must not empty the cache) but has no category for "same method, different output geometry". Worth raising with the session that owns that view; grid parameters belong in servability. warm-fields.sh: precompute every field for every bundled molecule THROUGH THE APP. The durable cache is keyed on sha256(molfile) and the app's molfile is reconstructed by mol* from scene loci, so warming with molfiles I generate from SMILES writes rows the app will never hit. Driving the real UI makes every key a key the app will ask for, by construction.
1 parent 6a04662 commit 7423a87

3 files changed

Lines changed: 160 additions & 6 deletions

File tree

‎backend/field_server.py‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,17 @@ def grid_spacing_meta(lo, hi, dims, requested: float) -> dict:
699699
'mep_qm': 10.0, # kcal/mol, converted to Ha/e for the cube
700700
'mlp': 0.25, # Crippen/Fauchere units
701701
}
702+
# The UI slider multiplies the contour by 10^[-1,1]. The box must therefore be
703+
# sized for the LOWEST contour the slider can reach, not for the default one —
704+
# otherwise the bottom of the slider walks the contour below the box's wall and
705+
# the surface exits through the side, drawn as a flat face. Measured on 1CBS:
706+
# wall 3.75 against a default contour of 10, so truncation began at slider
707+
# -0.43 and the bottom 29% of the range produced cut-off lobes.
708+
#
709+
# Classical fields cost ~0.1 s, so sizing for the floor is free. The quantum
710+
# grid belongs to pyscf's cubegen and cannot be grown this way, so those report
711+
# the wall instead and the UI says the surface is open.
712+
ISO_SLIDER_FLOOR = 0.1
702713
PAD_START = 4.0
703714
PAD_MAX = 12.0
704715
PAD_STEP = 2.0
@@ -738,7 +749,7 @@ def field_mep(mol: Chem.Mol, spacing=0.4, pad=4.0):
738749
raise ValueError('all Gasteiger charges are zero — classical MEP would '
739750
'be an empty picture; use mep_qm')
740751

741-
iso = FIXED_ISO['mep']
752+
iso = FIXED_ISO['mep'] * ISO_SLIDER_FLOOR
742753

743754
def evaluate(pad_a: float):
744755
lo = coords.min(axis=0) - pad_a
@@ -784,7 +795,8 @@ def evaluate(pad_a: float):
784795
'model_caveat': ('Gasteiger point charges: no lone-pair or sigma-hole '
785796
'anisotropy, and ~0.4x the QM molecular dipole. A '
786797
'qualitative map, not an interaction energy.'),
787-
'iso_fixed': iso,
798+
'iso_fixed': FIXED_ISO['mep'],
799+
'iso_sized_for': round(iso, 4),
788800
'pad_used_angstrom': round(pad_used, 1),
789801
'wall_max': round(wall_max(v), 3),
790802
# False means the surface still runs off the edge of the grid and is
@@ -811,7 +823,7 @@ def field_mlp(mol: Chem.Mol, spacing=0.4, pad=4.0):
811823
raise ValueError('Crippen contributions undefined for this molecule')
812824
syms, coords = mol_atoms(mol)
813825

814-
iso = FIXED_ISO['mlp']
826+
iso = FIXED_ISO['mlp'] * ISO_SLIDER_FLOOR
815827

816828
def evaluate(pad_a: float):
817829
lo = coords.min(axis=0) - pad_a
@@ -844,7 +856,8 @@ def evaluate(pad_a: float):
844856
'total_logp': float(f.sum()),
845857
'dims': dims.tolist(), **grid_spacing_meta(lo, hi, dims, spacing),
846858
'vmin': float(v.min()), 'vmax': float(v.max()),
847-
'iso_fixed': iso,
859+
'iso_fixed': FIXED_ISO['mlp'],
860+
'iso_sized_for': round(iso, 4),
848861
'pad_used_angstrom': round(pad_used, 1),
849862
'wall_max': round(wall_max(v), 4),
850863
'contour_closes_in_box': bool(clear),

‎scripts/warm-fields.sh‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
#!/usr/bin/env bash
2+
# Precompute and CACHE every field for every bundled molecule, then screenshot.
3+
#
4+
# scripts/warm-fields.sh [outdir]
5+
#
6+
# Ivan needs this for an interview: nothing may be slow or fail live.
7+
#
8+
# THE TRAP THIS FILE EXISTS TO AVOID. The durable cache is keyed on
9+
# sha256(molfile), and the molfile the app sends is reconstructed by mol* from
10+
# scene loci — not one I can generate from a SMILES. Warming the cache with my
11+
# own molfiles produces perfectly good rows that the app will never hit,
12+
# because the hashes differ. So the warmer drives the REAL APP: every cache key
13+
# it writes is a key the app will ask for, by construction.
14+
#
15+
# One browser session per MOLECULE (not per field): booting mol* costs ~20 s and
16+
# clicking six fields afterwards costs nothing, so per-field boots would have
17+
# multiplied the boot cost by six for no benefit.
18+
#
19+
# Both caches are warmed by one pass. The browser cache dies with the tab; the
20+
# DB cache is the one that matters tomorrow, and `[db] cached ...` in the
21+
# daemon log is the evidence it landed.
22+
set -u
23+
24+
ROOT=/home/ivan/dirac
25+
OUT=${1:-/tmp/claude-1000/-home-ivan/ff9c7c07-88fe-4345-a0cc-59d17b42683e/scratchpad/warm}
26+
STAGE=$OUT/_app
27+
PORT=1347
28+
# Every bundled fixture. Most carry no small-molecule ligand and will report so
29+
# — that is a RESULT, not a failure, and the warmer records which is which so
30+
# nobody discovers it live in front of an interviewer.
31+
MOLECULES=${MOLECULES:-"1CBS 1XKK 4HHB 1EMA 1CRN 1GRM 1BNA 1TUP 2POR 7QPD"}
32+
# QM first. If the run is cut short, the expensive things are already cached and
33+
# the sub-second classical ones can be recomputed live without anyone noticing.
34+
FIELDS=${FIELDS:-"mep_qm homo lumo density mep mlp"}
35+
36+
mkdir -p "$OUT"
37+
rm -rf "$STAGE"; cp -r "$ROOT/build/dirac" "$STAGE"
38+
( cd "$STAGE" && python3 -m http.server $PORT --bind 127.0.0.1 >/dev/null 2>&1 & echo $! > "$OUT/.srv" )
39+
sleep 1
40+
41+
for MOL in $MOLECULES; do
42+
cat > "$STAGE/index.html.driver" <<EOF
43+
<script>
44+
(function () {
45+
var MOL = '$MOL', FIELDS = '$FIELDS'.split(' ');
46+
var txt = function (s) { var e = document.querySelector(s); return e ? e.textContent.trim() : ''; };
47+
function wait(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
48+
var log = [];
49+
(async function () {
50+
await wait(9000);
51+
var sel = document.getElementById('molecule');
52+
for (var i = 0; i < sel.options.length; i++) {
53+
if (sel.options[i].textContent.indexOf(MOL) >= 0) {
54+
sel.selectedIndex = i;
55+
sel.dispatchEvent(new Event('change', { bubbles: true }));
56+
break;
57+
}
58+
}
59+
await wait(13000);
60+
document.querySelector('[data-jump="fields"]').click();
61+
await wait(1200);
62+
63+
var summary = txt('#fields-summary');
64+
if (/no ligand/i.test(summary)) {
65+
log.push('NO LIGAND — nothing to precompute');
66+
} else {
67+
for (var f = 0; f < FIELDS.length; f++) {
68+
var btn = document.querySelector('.field-btn[data-field="' + FIELDS[f] + '"]');
69+
if (!btn) { log.push(FIELDS[f] + ':no-button'); continue; }
70+
for (var w = 0; w < 30 && btn.disabled; w++) await wait(1000);
71+
if (btn.disabled) { log.push(FIELDS[f] + ':still-disabled'); continue; }
72+
btn.click();
73+
var done = false;
74+
for (var k = 0; k < 240; k++) {
75+
await wait(1000);
76+
var st = txt('#field-status');
77+
if (/rendered/i.test(st)) { log.push(FIELDS[f] + ':OK'); done = true; break; }
78+
if (/refus|unreach|cannot|exceed|budget|not quotable/i.test(st)) {
79+
log.push(FIELDS[f] + ':REFUSED(' + st.slice(0, 60) + ')'); done = true; break;
80+
}
81+
}
82+
if (!done) log.push(FIELDS[f] + ':TIMEOUT');
83+
}
84+
// The physics panel too — same molecule, same session, both daemons warm.
85+
var pt = document.querySelector('[data-jump="physics"]');
86+
if (pt) {
87+
pt.click(); await wait(900);
88+
var tors = document.getElementById('phys-run-torsion');
89+
if (tors && !tors.disabled) {
90+
tors.click();
91+
for (var a = 0; a < 90; a++) { await wait(1000);
92+
if (/rotors scanned|unreach|refus|cannot/i.test(txt('#phys-torsion-status'))) break; }
93+
log.push('torsion:' + txt('#phys-torsion-status').slice(0, 40));
94+
}
95+
var surf = document.getElementById('phys-run-surface');
96+
if (surf && !surf.disabled) {
97+
surf.click();
98+
for (var b = 0; b < 300; b++) { await wait(1000);
99+
if (/electrostatics for|unreach|refus|cannot|exceed/i.test(txt('#phys-surface-status'))) break; }
100+
log.push('sigma:' + txt('#phys-surface-status').slice(0, 40));
101+
}
102+
document.querySelector('[data-jump="fields"]').click();
103+
await wait(800);
104+
}
105+
}
106+
await wait(1500);
107+
var bar = document.createElement('div');
108+
bar.style.cssText = 'position:fixed;left:0;right:0;bottom:0;z-index:99999;'
109+
+ 'font:10px ui-monospace,monospace;padding:5px 9px;background:#111;color:#0f0';
110+
bar.textContent = MOL + ' | ' + summary + ' | ' + log.join(' ');
111+
document.body.appendChild(bar);
112+
document.title = 'WARM_DONE';
113+
window.__warm = log;
114+
})();
115+
})();
116+
</script>
117+
EOF
118+
cat "$ROOT/build/dirac/index.html" "$STAGE/index.html.driver" > "$STAGE/index.html"
119+
echo "warming $MOL ..."
120+
timeout 900 google-chrome --headless=new --enable-unsafe-swiftshader \
121+
--use-angle=swiftshader --window-size=1600,1000 \
122+
--virtual-time-budget=800000 \
123+
--screenshot="$OUT/warm_$MOL.png" \
124+
"http://127.0.0.1:$PORT/index.html" >/dev/null 2>&1
125+
echo " -> $OUT/warm_$MOL.png"
126+
done
127+
128+
kill "$(cat "$OUT/.srv")" 2>/dev/null
129+
rm -f "$OUT/.srv" "$STAGE/index.html.driver"
130+
echo
131+
echo "DB rows now cached (the ones that survive a restart):"
132+
psql -U ivan -d dirac -tAc \
133+
"select kind, basis, count(*) from app.field_cube group by 1,2 order by 1,2" 2>/dev/null \
134+
|| echo " (psql unavailable — check the daemon log for '[db] cached')"

‎src/app.frontend.facets.molstar-rdkit.editable/facets/field-wells/index.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,8 +445,15 @@ function updateIsoReadout() {
445445
let note = '';
446446
// Whether the surface actually closes is a fact about this picture, and it
447447
// is the one the crate bug turned on. Stated, not inferred from the shape.
448-
if (lastMeta && lastMeta.contour_closes_in_box === false) {
449-
note = ` · open surface (field is ${lastMeta.wall_max ?? '?'} at the box edge)`;
448+
// Recomputed against the CURRENT slider position, not the value the
449+
// backend sized the box for. The slider multiplies the contour by
450+
// 10^[-1,1], so a box that closes at the default can still be exited at
451+
// the low end — that is where the cut-off lobes came from.
452+
const wall = lastMeta?.wall_max;
453+
const open = typeof wall === 'number' && currentIso() <= wall;
454+
if (open) {
455+
note = ` · OPEN SURFACE — field reaches ${wall} at the box edge, `
456+
+ `so the lobes are cut off. Raise the isovalue.`;
450457
} else if (lastMeta && lastMeta.single_signed) {
451458
note = ' · field is single-signed — no negative lobe exists';
452459
}

0 commit comments

Comments
 (0)