Skip to content

Commit ad36b15

Browse files
committed
[feat.fields.rho.D8+] the reverse field gets a charge model that a truncated pocket does not invalidate
Ivan: 反向场…不是都已经做完了吗为什么没有上线呢 He is right and it is the exact sin I spent this morning diagnosing in someone else's work and then committed myself, hours later: /field/region was built, measured, positive-controlled — and had ZERO consumers. My own commit body said "NEXT: the frontend half". I went and did other things. Two blockers between "built" and "online". This removes the first. THE CHARGE MODEL. The additivity measurement cuts both ways and the second edge is the one that bites: the FIELD is exactly linear in the atom set (5.2e-16), and the CHARGE MODEL is not. Gasteiger is computed per-molecule, so a truncated pocket gets different charges than the intact protein and the sum of the parts stops being the whole. A group needs charges defined per atom WITHOUT seeing the rest of the system — a residue template. pdb2pqr 3.7.1 installed into backend/env (verified it lands in the env's own site-packages and imports under PYTHONNOUSERSITE=1, which is how the physics daemon re-execs — a package in ~/.local would have been invisible to it). Taking the TABLE rather than the pipeline is deliberate. AMBER.DAT is 2257 rows of RESNAME ATOM charge radius type: exactly the context-free per-atom mapping the measurement demands. The full pdb2pqr pipeline wants a PDB file and does its own hydrogen placement and titration, while this route is handed atoms mol* already has. And writing my own residue→charge table would have been the trap — the first thing anyone thinks of, and a worse re-derivation of a solved problem. Unresolvable atoms are NAMED AND REFUSED, never defaulted to zero. Zero is not "unknown", it is a claim that the atom is neutral — and the charged residues are the dominant sources in a pocket field, so zeroing them switches off precisely the atoms the picture is about. Verified: an unknown residue comes back "no AMBER template charge for 1 atom type(s): XYZ:QQ". Working end to end from residue identity alone, no charges sent by the caller: Arg+Asp fragment → net_charge -1.721, field -583.8 to +93.2 kcal/mol, charge_model reported as "AMBER residue templates (pdb2pqr)". NEXT, and this time it is the whole remaining distance: the panel control. The route is reachable and correct and a chemist still cannot press anything.
1 parent 7a85fa6 commit ad36b15

1 file changed

Lines changed: 88 additions & 6 deletions

File tree

‎backend/field_server.py‎

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import time
3737
import traceback
3838
from collections import OrderedDict
39+
from pathlib import Path
3940
from datetime import datetime, timezone
4041
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
4142

@@ -1236,6 +1237,75 @@ def field_quantum(mol: Chem.Mol, kind: str, basis: str,
12361237

12371238

12381239

1240+
1241+
# ── residue-template charges: the paved road's DATA, not its pipeline ───────
1242+
#
1243+
# The additivity measurement (5.2e-16) says the FIELD is exactly linear in the
1244+
# atom set. It also says, on the other edge, that the CHARGE MODEL must not be:
1245+
# Gasteiger is computed per-molecule, so a truncated pocket gets different
1246+
# charges than the intact protein and the "sum of parts" stops being the whole.
1247+
# A group therefore needs charges defined per atom WITHOUT seeing the rest of
1248+
# the system — which is exactly what a residue template is.
1249+
#
1250+
# pdb2pqr ships those tables (AMBER.DAT, 2257 rows of RESNAME ATOM charge
1251+
# radius type). Taking the table rather than running the whole pdb2pqr pipeline
1252+
# is deliberate: the pipeline wants a PDB file and does its own hydrogen
1253+
# placement and titration, and this route is handed atoms that mol* already
1254+
# has. Rolling my own residue→charge table would have been the L21 trap — the
1255+
# first thing anyone thinks of, and a worse re-derivation of a solved problem.
1256+
1257+
_charge_table: dict[tuple[str, str], float] | None = None
1258+
_charge_table_lock = threading.Lock()
1259+
CHARGE_FORCEFIELD = 'AMBER'
1260+
1261+
1262+
def charge_table() -> dict[tuple[str, str], float]:
1263+
global _charge_table
1264+
with _charge_table_lock:
1265+
if _charge_table is not None:
1266+
return _charge_table
1267+
import pdb2pqr
1268+
path = (Path(pdb2pqr.__file__).parent / 'dat' / f'{CHARGE_FORCEFIELD}.DAT')
1269+
table: dict[tuple[str, str], float] = {}
1270+
for line in path.read_text().splitlines():
1271+
if not line.strip() or line.startswith('#'):
1272+
continue
1273+
parts = line.split()
1274+
if len(parts) < 3:
1275+
continue
1276+
try:
1277+
table[(parts[0].upper(), parts[1].upper())] = float(parts[2])
1278+
except ValueError:
1279+
continue
1280+
_charge_table = table
1281+
return table
1282+
1283+
1284+
def resolve_charges(sources: list[dict]) -> tuple[list[float], list[str]]:
1285+
"""Per-atom charges from the residue template, and what could not be found.
1286+
1287+
Unresolvable atoms are REPORTED, never defaulted to zero. A zero charge is
1288+
not "unknown" — it is a claim that the atom is neutral, and the charged
1289+
residues are the dominant sources in a pocket field. Silently zeroing them
1290+
would switch off precisely the atoms the picture is about.
1291+
"""
1292+
table = charge_table()
1293+
charges: list[float] = []
1294+
missing: list[str] = []
1295+
for a in sources:
1296+
if a.get('charge') is not None:
1297+
charges.append(float(a['charge']))
1298+
continue
1299+
key = (str(a.get('resname', '')).upper(), str(a.get('atom_name', '')).upper())
1300+
q = table.get(key)
1301+
if q is None:
1302+
missing.append(f'{key[0]}:{key[1]}')
1303+
charges.append(0.0)
1304+
else:
1305+
charges.append(q)
1306+
return charges, sorted(set(missing))
1307+
1308+
12391309
# ── SOURCE ⊥ FRAME: a classical field of an arbitrary atom set ──────────────
12401310

12411311
MAX_REGION_SOURCES = 20000
@@ -1286,16 +1356,26 @@ def field_region(sources, lo, hi, spacing: float, kind: str):
12861356
raise ValueError(f'{len(sources)} source atoms exceeds the '
12871357
f'{MAX_REGION_SOURCES} cap for one region field')
12881358

1359+
# Charges come from the residue template when the caller sent residue
1360+
# identity instead of a number. Unresolved atoms are named, not zeroed.
1361+
resolved, missing = (resolve_charges(sources) if kind == 'mep'
1362+
else ([None] * len(sources), []))
1363+
if missing:
1364+
raise ValueError(
1365+
f'no {CHARGE_FORCEFIELD} template charge for {len(missing)} atom '
1366+
f'type(s): {", ".join(missing[:8])}'
1367+
+ (' …' if len(missing) > 8 else '')
1368+
+ '. Zeroing them would switch off exactly the atoms a pocket '
1369+
'field is about, so the request is refused instead.')
1370+
12891371
syms, coords, weights = [], [], []
12901372
for i, a in enumerate(sources):
1291-
w = a.get('charge') if kind == 'mep' else a.get('logp')
1373+
w = resolved[i] if kind == 'mep' else a.get('logp')
12921374
if w is None:
12931375
raise ValueError(
1294-
f'source atom {i} ({a.get("element", "?")}) has no '
1295-
f'{"charge" if kind == "mep" else "logp"}. This route does not '
1296-
f'invent one: a per-molecule charge model gives a truncated '
1297-
f'region different values than the intact system, so the '
1298-
f'caller must supply a per-atom model (residue templates).')
1376+
f'source atom {i} ({a.get("element", "?")}) has no logp. This '
1377+
f'route does not invent one: a per-molecule model gives a '
1378+
f'truncated region different values than the intact system.')
12991379
if not math.isfinite(float(w)):
13001380
raise ValueError(f'source atom {i} has a non-finite weight')
13011381
syms.append(str(a.get('element', 'C')))
@@ -1351,6 +1431,8 @@ def field_region(sources, lo, hi, spacing: float, kind: str):
13511431
'n_sources_sent': len(sources), 'n_sources_used': used,
13521432
'cutoff_angstrom': cutoff,
13531433
'net_charge': round(float(weights.sum()), 3),
1434+
'charge_model': (f'{CHARGE_FORCEFIELD} residue templates (pdb2pqr)'
1435+
if kind == 'mep' else 'caller-supplied'),
13541436
'dims': dims.tolist(), **grid_spacing_meta(lo, hi, dims, spacing),
13551437
'vmin': float(v.min()), 'vmax': float(v.max()),
13561438
'iso_fixed': iso,

0 commit comments

Comments
 (0)