Skip to content

Commit 6a04662

Browse files
committed
[feat.ligand.rho.D7+] two panels were computing halves of one question; they are now joined
Ivan showed me the Ligand Physics panel and asked whether that was the thing. It is half of it, and it is the half I did not have. Another session built ligand-physics while I built the halogen audit. Its header says a clean-context medicinal chemist ranked its two numbers 9/10 — the only ones in the repo that would change a synthesis queue tomorrow — and it computes exactly the quantity my panel was reporting as UNMEASURED: V_S,max on the 0.001 a.u. isodensity surface, with the C–X···max angle that decides whether a positive cap is a σ-hole at all. My panel computes the other half: whether that hole is AIMED at anything, what it is aimed at, and what a purely geometric profiler would have claimed instead. Rather than issue a second SCF for numbers already on screen — wasteful, and a way for two panels to disagree about one molecule — ligand-physics now publishes its completed run through a six-line additive hook, and the audit subscribes. Pressing Compute there resolves the UNMEASURED rows here with no second click. The publisher clears the result on ligand change, because a number belongs to the molecule it was computed on. The mapping is keyed by ELEMENT and the code says why that is a stopgap: MEP extrema are indexed against the isolated ligand RDKit parsed, the pocket walk against the mol* loci, and quietly assuming those two indexings agree is how a confident wrong answer is made. One halogen per element — lapatinib's case — is unambiguous. Two chlorines is not, and then the deeper hole is applied to both, which overstates the weaker one. Stated in the panel's own source rather than discovered later. [coord] Six added lines in another session's live facet, purely additive: a module-level last-result, a getter, a subscribe, and one call at the point the result already lands. No existing behaviour touched. tsc clean, prd build clean.
1 parent 8bf9c03 commit 6a04662

2 files changed

Lines changed: 71 additions & 6 deletions

File tree

  • src/app.frontend.facets.molstar-rdkit.editable/facets

‎src/app.frontend.facets.molstar-rdkit.editable/facets/halogen-audit/index.ts‎

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { StructureSelectionQueries } from '../../../mol-plugin-state/helpers/str
2121
import { auditHalogen, HalogenAudit, PocketAtom, ELECTRONIC } from '../../../chemistry.backend.perception.rdkit-wasm.editable/halogen-audit';
2222
import { extractLigandAtomData, lociFromFocusOptions } from '../../../chemistry.backend.perception.rdkit-wasm.editable/ligand-pipeline';
2323
import type { LigandFocusOptions } from '../../../chemistry.backend.perception.rdkit-wasm.editable/semantic-focus';
24+
import { onSurfaceResult, getLastSurfaceResult } from '../ligand-physics';
2425

2526
const HALOGENS = new Set(['F', 'CL', 'BR', 'I']);
2627

@@ -134,14 +135,56 @@ export function renderHalogenPanel(rows: HalogenRow[]) {
134135
</div>`).join('');
135136
}
136137

138+
/**
139+
* V_S,max comes from the Ligand Physics panel's own SCF rather than a second one.
140+
*
141+
* That panel answers "is there a σ-hole and how deep"; this one answers "is anything on
142+
* the axis". Two halves of one question that have lived in two different products for
143+
* years — and issuing our own /surface/mep for numbers already on screen would be both
144+
* wasteful and a way for the two panels to disagree about the same molecule.
145+
*
146+
* Keyed by element, and that is a stopgap the code says out loud: the MEP extrema are
147+
* indexed against the isolated ligand RDKit parsed, while the pocket walk is indexed
148+
* against the mol* loci, and quietly assuming those agree is how a confident wrong answer
149+
* gets made. With one halogen of each element — the common case, and true of lapatinib —
150+
* the mapping is unambiguous. With two chlorines it is not, and then the deeper hole is
151+
* used for both, which overstates the weaker one.
152+
*/
153+
function qmFromSurface(): Map<string, { vsMax: number, anisotropy: number | null, basis?: string, method?: string }> {
154+
const out = new Map<string, { vsMax: number, anisotropy: number | null, basis?: string, method?: string }>();
155+
const last = getLastSurfaceResult();
156+
if (!last) return out;
157+
for (const e of last.extrema) {
158+
if (e.kind !== 'maximum' || !e.sigma_hole) continue;
159+
const el = (e.element || '').toUpperCase();
160+
if (!HALOGENS.has(el)) continue;
161+
const prev = out.get(el);
162+
if (prev && prev.vsMax >= e.value_kcal_per_mol) continue;
163+
out.set(el, {
164+
vsMax: e.value_kcal_per_mol,
165+
anisotropy: e.sigma_hole.anisotropy_kcal_per_mol ?? null,
166+
basis: last.meta.basis,
167+
method: last.meta.method,
168+
});
169+
}
170+
return out;
171+
}
172+
173+
let current: { structure: Structure, options: LigandFocusOptions } | null = null;
174+
let subscribed = false;
175+
137176
/** Called from the same lifecycle point that refreshes the other ligand facets. */
138-
export function updateHalogenAudit(
139-
structure: Structure | null, options: LigandFocusOptions,
140-
qmBySymbol?: Map<string, { vsMax: number, anisotropy: number | null, basis?: string, method?: string }>,
141-
) {
142-
if (!structure) { renderHalogenPanel([]); return; }
177+
export function updateHalogenAudit(structure: Structure | null, options: LigandFocusOptions) {
178+
if (!subscribed) {
179+
subscribed = true;
180+
// Re-render the moment the physics panel finishes, so pressing Compute over there
181+
// resolves the UNMEASURED rows here without a second click.
182+
onSurfaceResult(() => { if (current) updateHalogenAudit(current.structure, current.options); });
183+
}
184+
if (!structure) { current = null; renderHalogenPanel([]); return; }
185+
current = { structure, options };
143186
try {
144-
renderHalogenPanel(auditLigandHalogens(structure, options, qmBySymbol));
187+
renderHalogenPanel(auditLigandHalogens(structure, options, qmFromSurface()));
145188
} catch (error) {
146189
console.error('halogen audit failed', error);
147190
renderHalogenPanel([]);

‎src/app.frontend.facets.molstar-rdkit.editable/facets/ligand-physics/index.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,20 @@ function extremaTable(extrema: Extremum[], pct: number): string {
191191
<tbody>${rows}</tbody></table>`;
192192
}
193193

194+
/** The last completed surface run, for consumers that need the numbers rather than the table. */
195+
let lastSurface: { extrema: Extremum[], meta: SurfaceMeta } | null = null;
196+
const surfaceListeners = new Set<(r: { extrema: Extremum[], meta: SurfaceMeta }) => void>();
197+
198+
export function getLastSurfaceResult() { return lastSurface; }
199+
export function onSurfaceResult(cb: (r: { extrema: Extremum[], meta: SurfaceMeta }) => void) {
200+
surfaceListeners.add(cb);
201+
return () => surfaceListeners.delete(cb);
202+
}
203+
function publishSurfaceResult(extrema: Extremum[], meta: SurfaceMeta) {
204+
lastSurface = { extrema, meta };
205+
for (const cb of surfaceListeners) { try { cb(lastSurface); } catch (e) { console.error(e); } }
206+
}
207+
194208
function surfacePanel(extrema: Extremum[], meta: SurfaceMeta): string {
195209
const pct = meta.absolute_uncertainty_pct ?? 25;
196210
const holes = meta.sigma_holes_found;
@@ -295,6 +309,11 @@ async function runSurface() {
295309
}
296310
const host = byId('phys-surface-body');
297311
if (host) host.innerHTML = surfacePanel(out.extrema, out.meta);
312+
// Publish, so the halogen audit can ask what the σ-hole POINTS AT without issuing a
313+
// second SCF for numbers that already exist. This panel answers "is there a hole and
314+
// how deep"; that one answers "is anything on the axis" — two halves of one question
315+
// that have historically lived in two different products.
316+
publishSurfaceResult(out.extrema, out.meta);
298317
setStatus('phys-surface-status',
299318
`Surface electrostatics for ${ligandLabel ?? 'ligand'}.`, 'ok');
300319
try { await markExtrema(out.extrema); } catch { /* table still stands */ }
@@ -376,6 +395,9 @@ export function initLigandPhysicsPanel(p: PluginContext) {
376395
/** Same contract as the other facets: the lab hands over the focused ligand's
377396
* molfile, already in scene coordinates. */
378397
export function updateLigandPhysics(nextMolfile: string | null, label: string | null) {
398+
// A result belongs to the molecule it was computed on; carrying it across a ligand
399+
// switch is how a number ends up describing something it never saw.
400+
lastSurface = null;
379401
const changed = nextMolfile !== molfile;
380402
molfile = nextMolfile;
381403
ligandLabel = label;

0 commit comments

Comments
 (0)