OSCR

Cryo-EM insights into isoform-specific properties of the IP<sub>3</sub>R2 channel.

Code ↔ Paper

2 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 2 matches
  1. [1] § Methods › Cryo-EM data acquisition, image processing, and model building ↔ pyHole.py, lines 137–145 · score 0.53 · van der Waals, radius
  2. [2] § Methods › Cryo-EM data acquisition, image processing, and model building ↔ pyhole_chimerax_fix4/src/holepy.py, lines 139–147 · score 0.53 · van der Waals, radius

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 1,089 lines · 48 KB · GPL-3.0 · 1 match

  1. """pyHole: HOLE-like pore profile calculation with straight/curved centerline options.
  2. Standalone command-line tool. Accepts legacy PDB directly, or any other
  3. gemmi-readable structure format (mmCIF, mmJSON, ...), which is transparently
  4. converted to a temp legacy PDB file before parsing.
  5. """
  6. from pathlib import Path
  7. import argparse
  8. import sys
  9. import re
  10. import math
  11. import json
  12. import csv
  13. import os
  14. import tempfile
  15. from typing import Dict, List, Optional, Tuple
  16. import numpy as np
  17. from scipy.spatial import cKDTree
  18. from scipy.ndimage import label as _cc_label
  19. import gemmi
  20. import mrcfile
  21. __version__ = "1.1"
  22. # --- Constants (in sync with CLI v8) ---
  23. ONE_LETTER = {
  24. 'ALA': 'A', 'CYS': 'C', 'ASP': 'D', 'GLU': 'E', 'PHE': 'F', 'GLY': 'G',
  25. 'HIS': 'H', 'ILE': 'I', 'LYS': 'K', 'LEU': 'L', 'MET': 'M', 'ASN': 'N',
  26. 'PRO': 'P', 'GLN': 'Q', 'ARG': 'R', 'SER': 'S', 'THR': 'T', 'VAL': 'V',
  27. 'TRP': 'W', 'TYR': 'Y', 'SEC': 'U', 'PYL': 'O',
  28. }
  29. VDW_DEFAULT = {
  30. 'H': 1.20, 'C': 1.70, 'N': 1.55, 'O': 1.52, 'F': 1.47, 'P': 1.80,
  31. 'S': 1.80, 'CL': 1.75, 'BR': 1.85, 'I': 1.98, 'NA': 2.27, 'MG': 1.73,
  32. 'K': 2.75, 'CA': 2.31, 'ZN': 1.39, 'FE': 1.56, 'CU': 1.40, 'MN': 1.61,
  33. }
  34. KD_HYDRO = {
  35. 'ILE': 4.5, 'VAL': 4.2, 'LEU': 3.8, 'PHE': 2.8, 'CYS': 2.5, 'MET': 1.9,
  36. 'ALA': 1.8, 'GLY': -0.4, 'THR': -0.7, 'SER': -0.8, 'TRP': -0.9,
  37. 'TYR': -1.3, 'PRO': -1.6, 'HIS': -3.2, 'GLU': -3.5, 'GLN': -3.5,
  38. 'ASP': -3.5, 'ASN': -3.5, 'LYS': -3.9, 'ARG': -4.5,
  39. }
  40. KD_MIN = min(KD_HYDRO.values())
  41. KD_MAX = max(KD_HYDRO.values())
  42. CHARGE_MAP = {'ARG': +1.0, 'LYS': +1.0, 'HIS': +0.1, 'ASP': -1.0, 'GLU': -1.0}
  43. ELEC_MIN, ELEC_MAX = -1.0, +1.0 # mean formal charge per slice maps into [-1, 1] before optional 01 remap
  44. # Floor for reported mesh/centerline radius (Å); avoids zero/negative clearance breaking mesh / coloring.
  45. MIN_PORE_RADIUS_A = 0.1
  46. # --- mmCIF / any-gemmi-format -> legacy PDB (self-contained; see
  47. # cryomodel/pore/structure_io.py for the full rationale) ---
  48. _MAX_ATOMS = 99_999
  49. _MAX_RESI = 9_999
  50. _MIN_RESI = -999
  51. _LEGACY_PDB_SUFFIXES = {'.pdb', '.ent', '.pdb1'}
  52. class StructureTooLargeForLegacyPDB(ValueError):
  53. """Raised when a structure can't be safely represented as legacy PDB."""
  54. def ensure_legacy_pdb(path, tmp_dir: Optional[str] = None) -> Path:
  55. """Return a path to a legacy fixed-width PDB file equivalent to ``path``.
  56. holepy.py's parser assumes fixed-width legacy-PDB columns and does not
  57. understand hybrid-36 encoding, so structures that would overflow legacy
  58. PDB's fields (>99,999 atoms, >4-digit residue numbers, multi-character
  59. chain IDs) raise StructureTooLargeForLegacyPDB instead of silently
  60. corrupting atom/residue numbering. Subset the structure and retry.
  61. """
  62. path = Path(path)
  63. if path.suffix.lower() in _LEGACY_PDB_SUFFIXES:
  64. return path
  65. st = gemmi.read_structure(str(path))
  66. if len(st) == 0:
  67. raise ValueError(f"No models found in '{path.name}'.")
  68. single = gemmi.Structure()
  69. single.name = st.name
  70. single.cell = st.cell
  71. single.spacegroup_hm = st.spacegroup_hm
  72. single.add_model(st[0])
  73. single.setup_entities()
  74. n_atoms = 0
  75. bad_chains = set()
  76. bad_residues = []
  77. for chain in single[0]:
  78. if len(chain.name) > 1:
  79. bad_chains.add(chain.name)
  80. for res in chain:
  81. if res.seqid.num > _MAX_RESI or res.seqid.num < _MIN_RESI:
  82. bad_residues.append((chain.name, res.seqid.num))
  83. n_atoms += len(res)
  84. problems = []
  85. if n_atoms > _MAX_ATOMS:
  86. problems.append(f"{n_atoms} atoms (legacy PDB's serial field caps at {_MAX_ATOMS})")
  87. if bad_chains:
  88. problems.append(f"chain ID(s) longer than 1 character: {sorted(bad_chains)}")
  89. if bad_residues:
  90. sample = ', '.join(f"{c}:{n}" for c, n in bad_residues[:5])
  91. more = f" (+{len(bad_residues) - 5} more)" if len(bad_residues) > 5 else ""
  92. problems.append(f"{len(bad_residues)} residue number(s) outside PDB's 4-digit field, e.g. {sample}{more}")
  93. if problems:
  94. raise StructureTooLargeForLegacyPDB(
  95. f"'{path.name}' cannot be safely converted to legacy PDB for pyHole: " + "; ".join(problems) + ". "
  96. "Subset the structure (e.g. to the chain(s)/region around your pore) and retry."
  97. )
  98. fd, tmp_path = tempfile.mkstemp(suffix='.pdb', prefix='pyhole_cif2pdb_', dir=tmp_dir)
  99. os.close(fd)
  100. single.write_pdb(tmp_path)
  101. return Path(tmp_path)
  102. def guess_element(atom_name, element_field):
  103. if element_field and element_field.strip():
  104. return element_field.strip().upper()
  105. an = atom_name.strip()
  106. if not an:
  107. return 'C'
  108. if an[0].isdigit() and len(an) >= 2:
  109. c = an[1]
  110. if len(an) >= 3 and an[2].isalpha():
  111. return (c + an[2]).upper()
  112. return c.upper()
  113. c = an[0]
  114. if len(an) >= 2 and an[1].isalpha() and an[1].islower():
  115. return (c + an[1]).upper()
  116. return c.upper()
  117. def vdw_radius(element, custom):
  118. """Get van der Waals radius for element.
  119. ``custom`` must already have upper-cased keys -- callers should normalize
  120. a loaded JSON file with normalize_vdw_keys() before passing it in here,
  121. since this does a case-sensitive lookup after upper-casing ``element``.
  122. """
  123. e = element.upper()
  124. return custom.get(e, VDW_DEFAULT.get(e, 1.7))
  125. def normalize_vdw_keys(raw: Dict[str, float]) -> Dict[str, float]:
  126. """Upper-case all keys in a custom VDW radii dict.
  127. Without this, a JSON file with lowercase element keys (e.g. {"na": 1.1})
  128. silently fails every lookup in vdw_radius() and falls back to the
  129. generic default -- the custom radii are ignored with no warning.
  130. """
  131. return {str(k).upper(): v for k, v in (raw or {}).items()}
  132. class Atom:
  133. __slots__ = ('x', 'y', 'z', 'name', 'resname', 'chain', 'resi', 'icode', 'element', 'occ')
  134. def __init__(self, x, y, z, name, resname, chain, resi, icode, element, occ):
  135. self.x = float(x)
  136. self.y = float(y)
  137. self.z = float(z)
  138. self.name = name.strip()
  139. self.resname = resname
  140. self.chain = chain
  141. self.resi = int(resi)
  142. self.icode = icode # PDB insertion code (single char, '' if none)
  143. self.element = element
  144. self.occ = float(occ)
  145. def load_pdb_atoms(path, include_h=True, include_hetatm=True):
  146. atoms = []
  147. with open(path, 'r') as f:
  148. for line in f:
  149. if line[:6] not in ('ATOM ', 'HETATM'):
  150. continue
  151. if (not include_hetatm) and line[:6] == 'HETATM':
  152. continue
  153. name = line[12:16]
  154. resname = line[17:20].strip()
  155. chain = line[21].strip() or 'A'
  156. resi_str = line[22:26].strip() or '0'
  157. # Column 27 (index 26) is the PDB insertion code. Residues that
  158. # differ only by insertion code (e.g. 55 and 55A) are distinct
  159. # residues -- dropping this silently merged them.
  160. icode = line[26].strip() if len(line) > 26 else ''
  161. try:
  162. resi = int(resi_str)
  163. except ValueError:
  164. continue
  165. x = float(line[30:38])
  166. y = float(line[38:46])
  167. z = float(line[46:54])
  168. occ = float(line[54:60]) if line[54:60].strip() else 1.0
  169. elem_field = line[76:78] if len(line) >= 78 else ''
  170. elem = guess_element(name, elem_field)
  171. if (not include_h) and elem.upper() == 'H':
  172. continue
  173. atoms.append(Atom(x, y, z, name, resname, chain, resi, icode, elem, occ))
  174. return atoms
  175. def parse_residue_tokens(s):
  176. tokens = []
  177. if not s:
  178. return tokens
  179. for t in s.split(','):
  180. t = t.strip()
  181. if not t:
  182. continue
  183. # ONELETTER...NUM/CHAIN or THREELETNUM/CHAIN (chain optional -> match any chain_id)
  184. m = re.match(r'^([A-Za-z]{1,3})(\d+)\s*/\s*([A-Za-z0-9]*)$', t)
  185. if m:
  186. chain = m.group(3).strip() or '*'
  187. tokens.append((chain, int(m.group(2))))
  188. continue
  189. m = re.match(r'^([A-Za-z0-9])\s*[:\s]\s*(\d+)$', t)
  190. if m:
  191. tokens.append((m.group(1), int(m.group(2))))
  192. continue
  193. m = re.match(r'^\s*(\d+)\s*$', t)
  194. if m:
  195. tokens.append(('*', int(m.group(1))))
  196. continue
  197. raise ValueError(f"Could not parse residue token: '{t}'.")
  198. return tokens
  199. def ca_positions_for(atoms, sel):
  200. out = []
  201. for (ch, rnum) in sel:
  202. for a in atoms:
  203. if a.name == 'CA' and a.resi == rnum and (ch == '*' or a.chain == ch):
  204. out.append([a.x, a.y, a.z])
  205. return np.array(out, float) if out else np.zeros((0, 3), float)
  206. def orthonormal_basis_from_axis(axis):
  207. u = axis / (np.linalg.norm(axis) + 1e-12)
  208. a = np.array([1.0, 0.0, 0.0]) if abs(u[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
  209. v = np.cross(u, a)
  210. n = np.linalg.norm(v)
  211. if n < 1e-8:
  212. a = np.array([0.0, 0.0, 1.0])
  213. v = np.cross(u, a)
  214. n = np.linalg.norm(v)
  215. v /= (n + 1e-12)
  216. w = np.cross(u, v)
  217. w /= (np.linalg.norm(w) + 1e-12)
  218. return v, w
  219. class SpatialIndex:
  220. """KD-tree wrapper for fast local clearance queries.
  221. Evaluating a slice used to scan every atom in the structure (O(N) per
  222. query point), which dominates runtime for large assemblies with many
  223. query points (dense/adaptive sampling, curved-centerline refinement).
  224. We instead query only atoms within a radius of the candidate point and
  225. grow that radius until we can prove no atom outside the search ball
  226. could produce a smaller clearance -- returns exactly the same value as
  227. the brute-force scan, just faster.
  228. """
  229. def __init__(self, atom_xyz: np.ndarray, atom_r: np.ndarray, initial_radius: float = 12.0):
  230. self.atom_xyz = atom_xyz
  231. self.atom_r = atom_r
  232. self.tree = cKDTree(atom_xyz) if len(atom_xyz) else None
  233. self.initial_radius = initial_radius
  234. self.max_atom_r = float(np.max(atom_r)) if len(atom_r) else 0.0
  235. def clearance_and_mask(self, c: np.ndarray, contact_eps: float = 0.0):
  236. if self.tree is None or len(self.atom_xyz) == 0:
  237. return float('inf'), np.zeros(0, dtype=bool)
  238. r = self.initial_radius
  239. n_atoms = len(self.atom_xyz)
  240. while True:
  241. idx = self.tree.query_ball_point(c, r=r)
  242. if idx:
  243. idx = np.asarray(idx)
  244. dv = self.atom_xyz[idx] - c
  245. d = np.sqrt((dv * dv).sum(axis=1)) - self.atom_r[idx]
  246. d_min = float(np.min(d))
  247. # Any atom outside this ball is at distance > r, so its
  248. # clearance is > r - max_atom_r. If our current best is
  249. # already <= that bound, no unseen atom can beat it.
  250. if d_min <= (r - self.max_atom_r) or len(idx) >= n_atoms:
  251. full_d = np.full(n_atoms, np.inf)
  252. full_d[idx] = d
  253. mask = full_d <= (d_min + contact_eps)
  254. return d_min, mask
  255. r *= 1.5
  256. def batch_knn_clearance(self, points: np.ndarray, k: int = 12) -> np.ndarray:
  257. """Vectorized clearance (dist - vdw_r) to the nearest atom for many
  258. points at once, via a k-nearest-neighbor search rather than the exact
  259. expanding-radius search used by clearance_and_mask().
  260. This is an approximation: it's only exact if the atom that actually
  261. minimizes clearance is among the k nearest by *raw* distance, which
  262. holds in practice because VDW radii vary within a bounded range
  263. (~1-2.8 A) -- an atom much farther away by raw distance essentially
  264. never wins on clearance. Used for area/volume rasterization and the
  265. density-map surface, where we evaluate thousands-to-millions of grid
  266. points and the expanding-radius search's per-point Python loop would
  267. be too slow; not used for the single-point clearance that determines
  268. the reported minimum pore radius (clearance_and_mask stays exact there).
  269. """
  270. if self.tree is None or len(self.atom_xyz) == 0:
  271. return np.full(len(points), np.inf)
  272. k_eff = min(k, len(self.atom_r))
  273. dists, idxs = self.tree.query(points, k=k_eff)
  274. if k_eff == 1:
  275. dists = dists[:, None]
  276. idxs = idxs[:, None]
  277. clearances = dists - self.atom_r[idxs]
  278. return clearances.min(axis=1)
  279. def _clearance_at_point(atom_xyz, atom_r, c, index: Optional[SpatialIndex] = None):
  280. if index is not None:
  281. d_min, _ = index.clearance_and_mask(c)
  282. return d_min
  283. dv = atom_xyz - c
  284. d = np.sqrt((dv * dv).sum(axis=1)) - atom_r
  285. return float(np.min(d))
  286. def _evaluate_slice(atom_xyz, atom_r, atom_meta, c, contact_eps, hydro_scale, electro_scale,
  287. index: Optional[SpatialIndex] = None):
  288. if index is not None:
  289. r_raw, mask = index.clearance_and_mask(c, contact_eps)
  290. else:
  291. dv = atom_xyz - c
  292. d = np.sqrt((dv * dv).sum(axis=1)) - atom_r
  293. r_raw = float(np.min(d))
  294. mask = d <= (r_raw + contact_eps)
  295. seen = set()
  296. tags = []
  297. hyd = []
  298. chg = []
  299. for i, ok in enumerate(mask):
  300. if not ok:
  301. continue
  302. chain, resname, resi, icode = atom_meta[i]
  303. # Insertion code is part of residue identity: 55 and 55A must not
  304. # be collapsed into a single contributor tag.
  305. key = (chain, resi, icode, resname)
  306. if key in seen:
  307. continue
  308. seen.add(key)
  309. resi_label = f"{resi}{icode}" if icode else str(resi)
  310. tags.append(f"{ONE_LETTER.get(resname.upper(), '?')}{resi_label}/{chain}")
  311. hyd.append(KD_HYDRO.get(resname.upper(), 0.0))
  312. chg.append(CHARGE_MAP.get(resname.upper(), 0.0))
  313. hydro = float(np.mean(hyd)) if hyd else 0.0
  314. electro = float(np.mean(chg)) if chg else 0.0
  315. if hydro_scale == '01':
  316. hydro = (hydro - KD_MIN) / (KD_MAX - KD_MIN) if KD_MAX > KD_MIN else 0.0
  317. if electro_scale == '01':
  318. electro = (electro - ELEC_MIN) / (ELEC_MAX - ELEC_MIN) if ELEC_MAX > ELEC_MIN else 0.5
  319. rmin = max(r_raw, MIN_PORE_RADIUS_A)
  320. return rmin, ';'.join(tags[:50]), hydro, electro
  321. def _trapz(y, x) -> float:
  322. """Trapezoidal integral of y(x). Hand-rolled instead of np.trapz/np.trapezoid
  323. since that function's name changed between numpy versions."""
  324. y = np.asarray(y, dtype=float)
  325. x = np.asarray(x, dtype=float)
  326. if len(y) < 2:
  327. return 0.0
  328. return float(np.sum((y[1:] + y[:-1]) * 0.5 * (x[1:] - x[:-1])))
  329. def slice_area(index: 'SpatialIndex', center: np.ndarray, v: np.ndarray, w: np.ndarray,
  330. probe: float = 0.0, seed_clearance: Optional[float] = None,
  331. min_half_extent: float = 4.0, max_half_extent: float = 20.0,
  332. radius_factor: float = 3.0, grid_step: float = 0.25, k: int = 12):
  333. """Rasterized true cross-sectional area of the pore in the plane through
  334. ``center`` spanned by orthonormal (v, w), instead of treating the slice
  335. as a circle of the single reported min-clearance radius.
  336. A single "radius" number assumes a circular cross-section; real pores are
  337. often lobed, eccentric, or locally widen off-axis, which a circular
  338. assumption hides entirely and which HOLE-style single-radius profiles
  339. can't represent. This grids the plane, marks each cell as accessible
  340. (clearance > probe) or not, and keeps only the connected component
  341. reachable from ``center`` by flood fill -- so an unrelated cavity or
  342. pocket that happens to intersect the same cutting plane elsewhere in the
  343. structure isn't counted as part of the pore.
  344. Returns (area_geometric_A2, area_accessible_A2, half_extent_used_A).
  345. """
  346. if seed_clearance is None:
  347. seed_clearance = float(index.batch_knn_clearance(center[None, :], k=k)[0])
  348. half_extent = float(np.clip(radius_factor * max(seed_clearance, 0.5), min_half_extent, max_half_extent))
  349. n = max(3, int(round(2 * half_extent / grid_step)) + 1)
  350. axis_vals = np.linspace(-half_extent, half_extent, n)
  351. gv, gw = np.meshgrid(axis_vals, axis_vals, indexing='ij')
  352. pts = center[None, None, :] + gv[..., None] * v[None, None, :] + gw[..., None] * w[None, None, :]
  353. clearance = index.batch_knn_clearance(pts.reshape(-1, 3), k=k).reshape(n, n)
  354. cell_area = grid_step * grid_step
  355. center_idx = (n // 2, n // 2) # axis_vals is symmetric about 0 -> this cell is (0, 0)
  356. def flood_area(mask):
  357. if not mask[center_idx]:
  358. # Center point comes from the already-validated centerline, so
  359. # this shouldn't normally trigger; fail safe to 0 rather than
  360. # grabbing an unrelated component if it ever does.
  361. return 0.0
  362. labeled, _ = _cc_label(mask, structure=np.ones((3, 3)))
  363. comp_id = labeled[center_idx]
  364. return float(np.sum(labeled == comp_id)) * cell_area if comp_id else 0.0
  365. area_geom = flood_area(clearance > 0.0)
  366. area_access = flood_area(clearance > probe) if probe > 0 else area_geom
  367. return area_geom, area_access, half_extent
  368. def build_density_grid(index: 'SpatialIndex', rows: List[Dict],
  369. probe: float = 0.0, voxel_size: float = 0.5, margin: float = 3.0,
  370. min_half_extent: float = 4.0, max_half_extent: float = 20.0,
  371. radius_factor: float = 1.3, max_voxels: int = 40_000_000, k: int = 12):
  372. """Build a 3D scalar field (clearance-to-nearest-atom minus probe radius)
  373. over a padded box around the sampled centerline, for isosurfacing the
  374. true pore lumen (level=0 is exactly the probe-accessible boundary)
  375. instead of the tube-of-spheres mesh approximation.
  376. Masked to a swept tube around the centerline: any grid point farther from
  377. its nearest centerline sample than that sample's local half-extent
  378. (+ margin) is set to a large negative sentinel value instead of its raw
  379. clearance. Without this, isosurfacing raw clearance over the *entire*
  380. padded box picks up every ordinary side-chain/helix packing gap in the
  381. surrounding protein, not just the pore -- producing a "swiss cheese"
  382. density full of unrelated cavities rather than one clean pore-shaped
  383. tube. `radius_factor` here defaults smaller/more conservative than the
  384. one used for the 2D area rasterization (slice_area's area_radius_factor,
  385. default 3.0): a too-generous tube here re-admits the same packing-gap
  386. noise this masking exists to remove.
  387. Returns (values[nx,ny,nz] as (z,y,x)-ordered for MRC, origin_xyz, voxel_size).
  388. """
  389. centers = np.array([[r['x'], r['y'], r['z']] for r in rows], dtype=float)
  390. # Per-slice half-extent estimate (same adaptive sizing as slice_area) sets
  391. # both the swept-tube radius and how far sideways from the centerline the
  392. # grid needs to cover.
  393. seed_clear = index.batch_knn_clearance(centers, k=k)
  394. half_extents = np.clip(radius_factor * np.maximum(seed_clear, 0.5), min_half_extent, max_half_extent)
  395. pad = float(np.max(half_extents)) + margin
  396. lo = centers.min(axis=0) - pad
  397. hi = centers.max(axis=0) + pad
  398. nx = max(2, int(np.ceil((hi[0] - lo[0]) / voxel_size)) + 1)
  399. ny = max(2, int(np.ceil((hi[1] - lo[1]) / voxel_size)) + 1)
  400. nz = max(2, int(np.ceil((hi[2] - lo[2]) / voxel_size)) + 1)
  401. n_total = nx * ny * nz
  402. if n_total > max_voxels:
  403. raise ValueError(
  404. f"Requested density grid is {nx}x{ny}x{nz} = {n_total:,} voxels, over the "
  405. f"{max_voxels:,} safety limit. Increase --surface-voxel (coarser spacing) or "
  406. f"reduce --surface-margin / --surface-radius-factor."
  407. )
  408. xs = lo[0] + voxel_size * np.arange(nx)
  409. ys = lo[1] + voxel_size * np.arange(ny)
  410. zs = lo[2] + voxel_size * np.arange(nz)
  411. gx, gy, gz = np.meshgrid(xs, ys, zs, indexing='ij')
  412. pts = np.stack([gx.ravel(), gy.ravel(), gz.ravel()], axis=1)
  413. clearance = index.batch_knn_clearance(pts, k=k)
  414. # Swept-tube mask: find each grid point's nearest centerline sample and
  415. # that sample's local half-extent, and blank out anything farther away
  416. # than that (+ margin) so isosurfacing can't pick up unrelated cavities
  417. # elsewhere in the structure that happen to fall inside the padded box.
  418. centerline_tree = cKDTree(centers)
  419. dist_to_centerline, nearest_idx = centerline_tree.query(pts, k=1)
  420. allowed_radius = half_extents[nearest_idx] + margin
  421. outside_tube = dist_to_centerline > allowed_radius
  422. SENTINEL = -1000.0
  423. values = (clearance - probe).astype(np.float32)
  424. values[outside_tube] = SENTINEL
  425. values = values.reshape(nx, ny, nz)
  426. values_zyx = np.transpose(values, (2, 1, 0)) # mrcfile wants (z, y, x)
  427. return values_zyx, lo, voxel_size
  428. def write_density_map(path, origin: np.ndarray, voxel_size: float, values_zyx: np.ndarray):
  429. """Write a scalar field to a non-crystallographic MRC map (MRC2014 ORIGIN
  430. convention), so it opens already aligned with the source structure in
  431. ChimeraX/ChimeraX-compatible viewers. level=0 is the probe-accessible
  432. pore boundary by construction (see build_density_grid)."""
  433. with mrcfile.new(str(path), overwrite=True) as mrc:
  434. mrc.set_data(np.ascontiguousarray(values_zyx, dtype=np.float32))
  435. mrc.voxel_size = float(voxel_size)
  436. mrc.header.origin.x = float(origin[0])
  437. mrc.header.origin.y = float(origin[1])
  438. mrc.header.origin.z = float(origin[2])
  439. mrc.update_header_stats()
  440. def profile_along_axis(atom_xyz, atom_r, c0, c1, step, eps, atom_meta,
  441. adaptive=False, slope_thresh=0.5, max_refine=3,
  442. hydro_scale='raw', electro_scale='raw', occupancy_metric='hydro',
  443. true_area=True, probe=0.0, area_grid_step=0.25,
  444. area_min_radius=4.0, area_max_radius=20.0, area_radius_factor=3.0):
  445. axis = c1 - c0
  446. L = np.linalg.norm(axis)
  447. if L < 1e-6:
  448. raise ValueError("Top and bottom centers are too close.")
  449. u = axis / L
  450. index = SpatialIndex(atom_xyz, atom_r)
  451. v_global, w_global = orthonormal_basis_from_axis(u)
  452. def ctr(s):
  453. return c0 + u * s
  454. svals = list(np.linspace(0.0, L, max(1, int(round(L / step)) + 1)))
  455. def eval_rows(vals):
  456. out = []
  457. for s in vals:
  458. c = ctr(s)
  459. rmin, tags, hyd, elec = _evaluate_slice(
  460. atom_xyz, atom_r, atom_meta, c, eps, hydro_scale, electro_scale, index=index)
  461. out.append({
  462. 's_A': float(s), 'x': float(c[0]), 'y': float(c[1]), 'z': float(c[2]),
  463. 'radius_A': float(rmin), 'hydro_index': float(hyd), 'electro_index': float(elec),
  464. 'contributors': tags,
  465. })
  466. return out
  467. rows = eval_rows(svals)
  468. if adaptive:
  469. for _ in range(max_refine):
  470. rows.sort(key=lambda r: r['s_A'])
  471. ns = []
  472. for i in range(len(rows) - 1):
  473. s0, r0 = rows[i]['s_A'], rows[i]['radius_A']
  474. s1, r1 = rows[i + 1]['s_A'], rows[i + 1]['radius_A']
  475. ds = s1 - s0
  476. if ds <= step / 2:
  477. continue
  478. slope = abs((r1 - r0) / ds) if ds > 1e-9 else 0.0
  479. if slope > slope_thresh:
  480. ns.append(0.5 * (s0 + s1))
  481. if not ns:
  482. break
  483. rows += eval_rows(ns)
  484. rows.sort(key=lambda r: r['s_A'])
  485. if true_area:
  486. for r in rows:
  487. c = np.array([r['x'], r['y'], r['z']], float)
  488. area_geom, area_access, half_extent = slice_area(
  489. index, c, v_global, w_global, probe=probe,
  490. min_half_extent=area_min_radius, max_half_extent=area_max_radius,
  491. radius_factor=area_radius_factor, grid_step=area_grid_step)
  492. r['area_A2'] = float(area_geom)
  493. r['area_access_A2'] = float(area_access)
  494. r['area_half_extent_A'] = float(half_extent)
  495. for r in rows:
  496. if occupancy_metric == 'hydro':
  497. r['occ_value'] = float(r['hydro_index'])
  498. elif occupancy_metric == 'electro':
  499. r['occ_value'] = float(r['electro_index'])
  500. else:
  501. r['occ_value'] = float(r['radius_A'])
  502. return rows, u, L
  503. def construct_centers_curved(atom_xyz, atom_r, c0, c1, step, curve_radius, curve_iters):
  504. axis = c1 - c0
  505. L = float(np.linalg.norm(axis))
  506. if L < 1e-6:
  507. raise ValueError("Top and bottom centers are too close.")
  508. u = axis / L
  509. v, w = orthonormal_basis_from_axis(u)
  510. index = SpatialIndex(atom_xyz, atom_r)
  511. svals = list(np.linspace(0.0, L, max(1, int(round(L / step)) + 1)))
  512. centers = []
  513. c_prev = c0.copy()
  514. for idx, s in enumerate(svals):
  515. if s <= 1e-9:
  516. c = c0.copy()
  517. elif abs(s - L) <= 1e-9:
  518. c = c1.copy()
  519. else:
  520. ds = svals[idx] - svals[idx - 1]
  521. c = c_prev + u * ds
  522. r = float(curve_radius)
  523. for _ in range(int(curve_iters)):
  524. best_c = c
  525. best_cl = _clearance_at_point(atom_xyz, atom_r, c, index=index)
  526. for dx, dy in [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (-1, 1), (1, -1), (-1, -1), (0, 0)]:
  527. cand = c + (dx * r) * v + (dy * r) * w
  528. cl = _clearance_at_point(atom_xyz, atom_r, cand, index=index)
  529. if cl > best_cl:
  530. best_cl = cl
  531. best_c = cand
  532. c = best_c
  533. r *= 0.5
  534. centers.append(c)
  535. c_prev = c
  536. return centers, u, L
  537. def sample_straight_centers(c0: np.ndarray, c1: np.ndarray, step: float) -> List[np.ndarray]:
  538. """Evenly-spaced points along the straight line from c0 to c1 (inclusive
  539. of both ends). This is the same axis-sampling profile_along_axis does
  540. internally, pulled out standalone so a straight segment can be one leg
  541. of a multi-waypoint path (see build_waypoint_centers)."""
  542. axis = c1 - c0
  543. L = float(np.linalg.norm(axis))
  544. if L < 1e-6:
  545. raise ValueError("Segment endpoints are too close together.")
  546. u = axis / L
  547. svals = np.linspace(0.0, L, max(1, int(round(L / step)) + 1))
  548. return [c0 + u * s for s in svals]
  549. def build_waypoint_centers(atom_xyz, atom_r, waypoints: List[np.ndarray], step: float,
  550. centerline: str, curve_radius: float, curve_iters: float) -> List[np.ndarray]:
  551. """Join 2+ waypoints (bottom, [mid ...], top) into one continuous list of
  552. centerline points, sampling each consecutive pair as either a straight
  553. segment or a curved (locally-refined) segment depending on `centerline`.
  554. This is the "midpoint" feature: an optional intermediate waypoint lets a
  555. user who already knows roughly where a channel bends route the profile
  556. through it, without pyHole having to *find* the bend itself the way
  557. --centerline explore's search does. Joining is genuinely just
  558. concatenation -- arc length (s_A) and local tangent direction are
  559. already computed generically over an arbitrary point sequence by
  560. profile_along_centers, so nothing downstream needs to know a path was
  561. assembled from more than one segment.
  562. """
  563. if len(waypoints) < 2:
  564. raise ValueError("build_waypoint_centers needs at least 2 waypoints.")
  565. centers: List[np.ndarray] = [waypoints[0]]
  566. for c0, c1 in zip(waypoints[:-1], waypoints[1:]):
  567. if centerline == 'curved':
  568. seg, _, _ = construct_centers_curved(atom_xyz, atom_r, c0, c1, step, curve_radius, curve_iters)
  569. else:
  570. seg = sample_straight_centers(c0, c1, step)
  571. centers.extend(seg[1:]) # skip seg[0]: it duplicates the previous segment's endpoint
  572. return centers
  573. def profile_along_centers(atom_xyz, atom_r, centers, eps, atom_meta, hydro_scale, electro_scale, occupancy_metric,
  574. true_area=True, probe=0.0, area_grid_step=0.25,
  575. area_min_radius=4.0, area_max_radius=20.0, area_radius_factor=3.0):
  576. rows = []
  577. s_acc = 0.0
  578. index = SpatialIndex(atom_xyz, atom_r)
  579. for i, c in enumerate(centers):
  580. if i > 0:
  581. s_acc += float(np.linalg.norm(centers[i] - centers[i - 1]))
  582. rmin, tags, hyd, elec = _evaluate_slice(
  583. atom_xyz, atom_r, atom_meta, c, eps, hydro_scale, electro_scale, index=index)
  584. rows.append({
  585. 's_A': float(s_acc), 'x': float(c[0]), 'y': float(c[1]), 'z': float(c[2]),
  586. 'radius_A': float(rmin), 'hydro_index': float(hyd), 'electro_index': float(elec),
  587. 'contributors': tags,
  588. })
  589. for i in range(len(rows)):
  590. if i == 0:
  591. t = np.array([rows[1]['x'] - rows[0]['x'], rows[1]['y'] - rows[0]['y'], rows[1]['z'] - rows[0]['z']], float)
  592. elif i == len(rows) - 1:
  593. t = np.array([rows[i]['x'] - rows[i - 1]['x'], rows[i]['y'] - rows[i - 1]['y'], rows[i]['z'] - rows[i - 1]['z']], float)
  594. else:
  595. t = np.array([rows[i + 1]['x'] - rows[i - 1]['x'], rows[i + 1]['y'] - rows[i - 1]['y'], rows[i + 1]['z'] - rows[i - 1]['z']], float)
  596. n = np.linalg.norm(t)
  597. t = np.array([0.0, 0.0, 1.0]) if n < 1e-9 else (t / n)
  598. rows[i]['tx'] = float(t[0])
  599. rows[i]['ty'] = float(t[1])
  600. rows[i]['tz'] = float(t[2])
  601. if true_area:
  602. # Cross-section area is measured perpendicular to the *local*
  603. # centerline direction, not a fixed global axis -- important once
  604. # the path is genuinely curved.
  605. v_local, w_local = orthonormal_basis_from_axis(t)
  606. c = np.array([rows[i]['x'], rows[i]['y'], rows[i]['z']], float)
  607. area_geom, area_access, half_extent = slice_area(
  608. index, c, v_local, w_local, probe=probe,
  609. min_half_extent=area_min_radius, max_half_extent=area_max_radius,
  610. radius_factor=area_radius_factor, grid_step=area_grid_step)
  611. rows[i]['area_A2'] = float(area_geom)
  612. rows[i]['area_access_A2'] = float(area_access)
  613. rows[i]['area_half_extent_A'] = float(half_extent)
  614. if occupancy_metric == 'hydro':
  615. rows[i]['occ_value'] = float(rows[i]['hydro_index'])
  616. elif occupancy_metric == 'electro':
  617. rows[i]['occ_value'] = float(rows[i]['electro_index'])
  618. else:
  619. rows[i]['occ_value'] = float(rows[i]['radius_A'])
  620. return rows
  621. def write_csv(path, rows):
  622. if not rows:
  623. return
  624. cols = list(rows[0].keys())
  625. if 'occ_value' not in cols:
  626. cols.append('occ_value')
  627. with open(path, 'w', newline='') as f:
  628. w = csv.DictWriter(f, fieldnames=cols)
  629. w.writeheader()
  630. for r in rows:
  631. w.writerow(r)
  632. def _format_pdb_atom_line(serial, name, resName, chainID, resSeq, x, y, z, occupancy, bfactor, element,
  633. altLoc=' ', iCode=' '):
  634. return (
  635. f"ATOM {serial:5d} {name:^4}{altLoc}{resName:>3} {chainID}{resSeq:>4}{iCode} "
  636. f"{x:8.3f}{y:8.3f}{z:8.3f}{occupancy:6.3f}{bfactor:6.3f} {element:>2} \r\n"
  637. )
  638. def write_mesh_pdb(path, rows, axis_u, rings=24):
  639. use_local = ('tx' in rows[0])
  640. if not use_local:
  641. u = axis_u / (np.linalg.norm(axis_u) + 1e-12)
  642. base_v, base_w = orthonormal_basis_from_axis(u)
  643. coords = []
  644. bvals = []
  645. occs = []
  646. for r in rows:
  647. c = np.array([r['x'], r['y'], r['z']], float)
  648. rad = max(MIN_PORE_RADIUS_A, float(r['radius_A']))
  649. occ = float(r.get('occ_value', 0.0))
  650. if use_local:
  651. u_loc = np.array([r['tx'], r['ty'], r['tz']], float)
  652. v, w = orthonormal_basis_from_axis(u_loc)
  653. else:
  654. v, w = base_v, base_w
  655. for k in range(rings):
  656. ang = 2 * np.pi * (k / rings)
  657. p = c + rad * (np.cos(ang) * v + np.sin(ang) * w)
  658. coords.append(p)
  659. bvals.append(rad)
  660. occs.append(occ)
  661. lines = []
  662. serial_start = 1
  663. for i, pnt in enumerate(coords, start=serial_start):
  664. x, y, z = pnt
  665. b = bvals[i - serial_start]
  666. occ = occs[i - serial_start]
  667. lines.append(_format_pdb_atom_line(i, 'C', 'ALA', 'M', 1, x, y, z, occ, b, 'C'))
  668. # ring & longitudinal CONECT like v8
  669. n = len(rows)
  670. R = rings
  671. def idx(step, k):
  672. return serial_start + step * R + k
  673. for step in range(n):
  674. for k in range(R):
  675. lines.append(f"CONECT{idx(step, k):5d}{idx(step, (k + 1) % R):5d}\r\n")
  676. for step in range(n - 1):
  677. for k in range(R):
  678. lines.append(f"CONECT{idx(step, k):5d}{idx(step + 1, k):5d}\r\n")
  679. with open(path, 'w', newline='') as f:
  680. f.writelines(lines)
  681. def write_centerline_pdb(path, rows):
  682. lines = []
  683. serial = 1
  684. for i, r in enumerate(rows, start=1):
  685. x, y, z = r['x'], r['y'], r['z']
  686. b = r['radius_A']
  687. occ = float(r.get('occ_value', 0.0))
  688. lines.append(f"HETATM{serial:5d} O PORE Z{i:4d} {x:8.3f}{y:8.3f}{z:8.3f}{occ:6.3f}{b:6.3f} O \r\n")
  689. serial += 1
  690. with open(path, 'w', newline='') as f:
  691. f.writelines(lines)
  692. # --- Extras (volumes, conductance, passability), matched to CLI v8 ---
  693. def _trap_volume_A3(rows, probe_A):
  694. if len(rows) < 2:
  695. return 0.0
  696. vol = 0.0
  697. for i in range(len(rows) - 1):
  698. ds = float(rows[i + 1]['s_A'] - rows[i]['s_A'])
  699. r0 = max(0.0, float(rows[i]['radius_A']) - probe_A)
  700. r1 = max(0.0, float(rows[i + 1]['radius_A']) - probe_A)
  701. vol += 0.5 * (math.pi * r0 * r0 + math.pi * r1 * r1) * ds
  702. return vol
  703. def _trap_true_volume_A3(rows, area_key: str) -> float:
  704. """Integrate a rasterized area column over s_A -- the true-cross-section
  705. counterpart to _trap_volume_A3's circular-radius approximation."""
  706. if len(rows) < 2 or area_key not in rows[0]:
  707. return 0.0
  708. s_vals = [r['s_A'] for r in rows]
  709. a_vals = [r[area_key] for r in rows]
  710. return _trapz(a_vals, s_vals)
  711. def _geometric_openness_index(rows, conductivity_S_per_m):
  712. """Unitless index of how geometrically open a pore is along its length --
  713. NOT a physical ionic conductance, and no longer reported in Siemens.
  714. Modeled loosely as the reciprocal of a series resistor-network integral
  715. (per-slice inverse cross-sectional area, plus an access-resistance-like
  716. end correction), using the parallel/series-resistor analogy for how a
  717. single bottleneck dominates a narrow pore's overall openness. This
  718. captures geometry only: it cannot account for ion occupancy,
  719. selectivity-filter state, channel gating, or electrostatics, so a
  720. wide-but-inactivated channel and a wide-and-conducting channel of
  721. identical shape score identically. ``conductivity_S_per_m`` is used only
  722. as an internal weighting term on the resistor-network terms above (it
  723. does not change the relative ranking of different pore geometries at a
  724. fixed conductivity value) -- it is not a claim about actual ionic
  725. conductance.
  726. """
  727. kappa = max(1e-12, float(conductivity_S_per_m))
  728. rho = 1.0 / kappa
  729. R_pore = 0.0
  730. blocked = False
  731. for i in range(len(rows) - 1):
  732. ds_m = float(rows[i + 1]['s_A'] - rows[i]['s_A']) * 1e-10
  733. r0 = max(1e-6, float(rows[i]['radius_A'])) * 1e-10
  734. r1 = max(1e-6, float(rows[i + 1]['radius_A'])) * 1e-10
  735. if r0 <= 0.0 or r1 <= 0.0:
  736. blocked = True
  737. A0 = math.pi * r0 * r0
  738. A1 = math.pi * r1 * r1
  739. R_pore += 0.5 * ((1.0 / max(A0, 1e-30)) + (1.0 / max(A1, 1e-30))) * ds_m * rho
  740. rmin_A = max(1e-6, float(min([r['radius_A'] for r in rows])))
  741. rmin_m = rmin_A * 1e-10
  742. R_access = (1.0 / (2.0 * kappa * max(rmin_m, 1e-12)))
  743. if blocked:
  744. return 0.0, float('inf'), R_access
  745. R_total = R_pore + R_access
  746. return (1.0 / R_total), R_pore, R_access
  747. def _passability_report(rows, pass_radii):
  748. pass_report = {}
  749. for sp, radA in pass_radii.items():
  750. radA = float(radA)
  751. spans = []
  752. start = None
  753. local_min = 1e9
  754. local_idx = None
  755. for i, row in enumerate(rows):
  756. rA = float(row['radius_A'])
  757. if rA < radA:
  758. if start is None:
  759. start = row['s_A']
  760. local_min = rA
  761. local_idx = i
  762. elif rA < local_min:
  763. local_min = rA
  764. local_idx = i
  765. elif start is not None:
  766. end = rows[i]['s_A']
  767. contrib = rows[local_idx]['contributors'] if local_idx is not None else ''
  768. spans.append({
  769. 'start_s_A': float(start), 'end_s_A': float(end),
  770. 'min_radius_A': float(local_min), 'min_contributors': contrib,
  771. })
  772. start = None
  773. local_min = 1e9
  774. local_idx = None
  775. if start is not None:
  776. end = rows[-1]['s_A']
  777. contrib = rows[local_idx]['contributors'] if local_idx is not None else ''
  778. spans.append({
  779. 'start_s_A': float(start), 'end_s_A': float(end),
  780. 'min_radius_A': float(local_min), 'min_contributors': contrib,
  781. })
  782. pass_report[sp] = {'is_passable': len(spans) == 0, 'blocked_spans': spans}
  783. return pass_report
  784. # --- CLI entry point ---
  785. def main(args=None):
  786. p = argparse.ArgumentParser(description="HOLE-like pore profile with straight/curved centerline options.")
  787. p.add_argument("pdb", help="Legacy PDB, or mmCIF/other gemmi-readable format (auto-converted)")
  788. p.add_argument("--top", default="")
  789. p.add_argument("--bottom", default="")
  790. p.add_argument("--mid", default="", help="Optional intermediate waypoint (same selection syntax as --top/--bottom). "
  791. "Routes the profile through this point between bottom and top -- useful "
  792. "for channels with a bend you already know the location of, without "
  793. "needing a full path search.")
  794. p.add_argument("--interactive", action="store_true")
  795. p.add_argument("--step", type=float, default=1.0)
  796. p.add_argument("--eps", type=float, default=0.25)
  797. p.add_argument("--noH", action="store_true")
  798. p.add_argument("--noHet", action="store_true")
  799. p.add_argument("--vdwjson", type=str, default="")
  800. p.add_argument("--rings", type=int, default=24)
  801. p.add_argument("--outprefix", type=str, default="holepy_out")
  802. p.add_argument("--probe", type=float, default=0.0)
  803. p.add_argument("--conductivity", type=float, default=1.5,
  804. help="Weights the internal resistor-network terms of the geometric openness index "
  805. "only; this is NOT a physical bulk-solution conductivity lookup and the index "
  806. "is not a reported conductance.")
  807. p.add_argument("--occupancy", choices=["hydro", "electro", "radii"], default="hydro")
  808. p.add_argument("--hydroscale", choices=["raw", "01"], default="raw")
  809. p.add_argument("--electroscale", choices=["raw", "01"], default="raw")
  810. p.add_argument("--passable_json", type=str, default="")
  811. p.add_argument("--centerline", choices=["straight", "curved"], default="straight")
  812. p.add_argument("--adaptive", action="store_true")
  813. p.add_argument("--slope_thresh", type=float, default=0.5)
  814. p.add_argument("--max_refine", type=int, default=3)
  815. p.add_argument("--curve_radius", type=float, default=2.0)
  816. p.add_argument("--curve_iters", type=int, default=3)
  817. p.add_argument("--no-true-area", dest="true_area", action="store_false",
  818. help="Skip rasterized true cross-sectional area/volume (faster; falls back to circular-radius approximation only)")
  819. p.add_argument("--area-grid-step", type=float, default=0.25, help="Grid spacing (A) for area rasterization")
  820. p.add_argument("--area-min-radius", type=float, default=4.0, help="Minimum half-extent (A) of the per-slice area search box")
  821. p.add_argument("--area-max-radius", type=float, default=20.0, help="Maximum half-extent (A) of the per-slice area search box")
  822. p.add_argument("--area-radius-factor", type=float, default=3.0, help="Area search box half-extent = this * local clearance, clamped to [min,max]")
  823. p.add_argument("--surface", action="store_true", help="Also write a density map (.mrc) for true isosurface rendering of the pore lumen")
  824. p.add_argument("--surface-voxel", type=float, default=0.5, help="Voxel spacing (A) for the density map")
  825. p.add_argument("--surface-margin", type=float, default=3.0, help="Extra padding (A) around the swept pore region in the density map")
  826. p.add_argument("--surface-radius-factor", type=float, default=1.3,
  827. help="Swept-tube half-width around the centerline for the density map = this * local clearance, "
  828. "clamped to [--area-min-radius, --area-max-radius]. Kept smaller/more conservative than "
  829. "--area-radius-factor to avoid picking up unrelated packing gaps ('swiss cheese') in the "
  830. "isosurface; increase only if the true surface looks clipped too close to the centerline.")
  831. p.set_defaults(true_area=True)
  832. ns = p.parse_args(args=args)
  833. try:
  834. pdb_path = ensure_legacy_pdb(Path(ns.pdb))
  835. except StructureTooLargeForLegacyPDB as e:
  836. print(f"ERROR: {e}", file=sys.stderr)
  837. return 4
  838. atoms = load_pdb_atoms(pdb_path, include_h=not ns.noH, include_hetatm=not ns.noHet)
  839. if ns.interactive or (not ns.top and not ns.bottom):
  840. print("Enter TOP:", file=sys.stderr)
  841. ns.top = input().strip()
  842. print("Enter BOTTOM:", file=sys.stderr)
  843. ns.bottom = input().strip()
  844. top_sel = parse_residue_tokens(ns.top)
  845. bot_sel = parse_residue_tokens(ns.bottom)
  846. ca_top = ca_positions_for(atoms, top_sel)
  847. ca_bot = ca_positions_for(atoms, bot_sel)
  848. if ca_top.size == 0 or ca_bot.size == 0:
  849. print("ERROR: Could not find CA atoms.", file=sys.stderr)
  850. return 3
  851. c_top = ca_top.mean(axis=0)
  852. c_bot = ca_bot.mean(axis=0)
  853. c_mid = None
  854. if ns.mid.strip():
  855. mid_sel = parse_residue_tokens(ns.mid)
  856. ca_mid = ca_positions_for(atoms, mid_sel)
  857. if ca_mid.size == 0:
  858. print("ERROR: Could not find CA atoms for --mid selection.", file=sys.stderr)
  859. return 3
  860. c_mid = ca_mid.mean(axis=0)
  861. custom_vdw = {}
  862. if ns.vdwjson:
  863. with open(ns.vdwjson, 'r') as jf:
  864. custom_vdw = normalize_vdw_keys(json.load(jf))
  865. coords = np.array([[a.x, a.y, a.z] for a in atoms], float)
  866. radii = np.array([vdw_radius(a.element, custom_vdw) for a in atoms], float)
  867. metas = [(a.chain, a.resname, a.resi, a.icode) for a in atoms]
  868. if c_mid is not None:
  869. waypoints = [c_bot, c_mid, c_top]
  870. centers = build_waypoint_centers(coords, radii, waypoints, ns.step, ns.centerline, ns.curve_radius, ns.curve_iters)
  871. rows = profile_along_centers(
  872. coords, radii, centers, ns.eps, metas, ns.hydroscale, ns.electroscale, ns.occupancy,
  873. true_area=ns.true_area, probe=ns.probe, area_grid_step=ns.area_grid_step,
  874. area_min_radius=ns.area_min_radius, area_max_radius=ns.area_max_radius,
  875. area_radius_factor=ns.area_radius_factor)
  876. if ns.adaptive:
  877. print("NOTE: --adaptive has no effect together with --mid (adaptive resampling only applies to a "
  878. "single straight segment); ignoring.", file=sys.stderr)
  879. elif ns.centerline == 'straight':
  880. rows, u, L = profile_along_axis(
  881. coords, radii, c_bot, c_top, ns.step, ns.eps, metas, adaptive=ns.adaptive,
  882. slope_thresh=ns.slope_thresh, max_refine=ns.max_refine,
  883. hydro_scale=ns.hydroscale, electro_scale=ns.electroscale, occupancy_metric=ns.occupancy,
  884. true_area=ns.true_area, probe=ns.probe, area_grid_step=ns.area_grid_step,
  885. area_min_radius=ns.area_min_radius, area_max_radius=ns.area_max_radius,
  886. area_radius_factor=ns.area_radius_factor)
  887. else:
  888. centers, u, L = construct_centers_curved(coords, radii, c_bot, c_top, ns.step, ns.curve_radius, ns.curve_iters)
  889. rows = profile_along_centers(
  890. coords, radii, centers, ns.eps, metas, ns.hydroscale, ns.electroscale, ns.occupancy,
  891. true_area=ns.true_area, probe=ns.probe, area_grid_step=ns.area_grid_step,
  892. area_min_radius=ns.area_min_radius, area_max_radius=ns.area_max_radius,
  893. area_radius_factor=ns.area_radius_factor)
  894. # volumes & conductance
  895. volume_geom = _trap_volume_A3(rows, 0.0)
  896. volume_access = _trap_volume_A3(rows, float(ns.probe))
  897. volume_true_geom = _trap_true_volume_A3(rows, 'area_A2') if ns.true_area else None
  898. volume_true_access = _trap_true_volume_A3(rows, 'area_access_A2') if ns.true_area else None
  899. openness_index, R_pore, R_access = _geometric_openness_index(rows, ns.conductivity)
  900. # passability reporting
  901. pass_radii = {}
  902. if ns.passable_json:
  903. try:
  904. with open(ns.passable_json, 'r') as pf:
  905. pass_radii = json.load(pf)
  906. except Exception:
  907. pass
  908. if not pass_radii:
  909. pass_radii = {'water': 1.4, 'na': 1.02, 'k': 1.38, 'ca': 1.00}
  910. pass_report = _passability_report(rows, pass_radii)
  911. density_map_path = None
  912. if ns.surface:
  913. surf_index = SpatialIndex(coords, radii)
  914. try:
  915. values_zyx, origin, voxel = build_density_grid(
  916. surf_index, rows, probe=ns.probe, voxel_size=ns.surface_voxel, margin=ns.surface_margin,
  917. min_half_extent=ns.area_min_radius, max_half_extent=ns.area_max_radius,
  918. radius_factor=ns.surface_radius_factor)
  919. except ValueError as e:
  920. print(f"ERROR: {e}", file=sys.stderr)
  921. return 5
  922. outprefix_tmp = Path(ns.outprefix)
  923. if outprefix_tmp.suffix.lower() == '.csv':
  924. outprefix_tmp = outprefix_tmp.with_suffix('')
  925. density_map_path = outprefix_tmp.with_name(outprefix_tmp.stem + '_density.mrc')
  926. write_density_map(density_map_path, origin, voxel, values_zyx)
  927. outprefix = Path(ns.outprefix)
  928. if outprefix.suffix.lower() == '.csv':
  929. outprefix = outprefix.with_suffix('')
  930. csv_path = outprefix.with_suffix('.csv')
  931. pdb_center = outprefix.with_name(outprefix.stem + '_centerline.pdb')
  932. pdb_mesh = outprefix.with_name(outprefix.stem + '_mesh.pdb')
  933. summary_path = outprefix.with_name(outprefix.stem + '_summary.json')
  934. with open(summary_path, 'w') as jf:
  935. json.dump({
  936. 'pdb': str(Path(ns.pdb).resolve()), 'top': ns.top, 'bottom': ns.bottom, 'mid': (ns.mid or None), 'centerline': ns.centerline,
  937. 'adaptive': bool(ns.adaptive), 'slope_thresh': float(ns.slope_thresh), 'max_refine': int(ns.max_refine),
  938. 'curve_radius_A': float(ns.curve_radius), 'curve_iters': int(ns.curve_iters),
  939. 'step_A': float(ns.step), 'eps_A': float(ns.eps), 'probe_A': float(ns.probe),
  940. 'length_A': float(rows[-1]['s_A'] if rows else 0.0),
  941. 'volume_geometric_A3': float(volume_geom), 'volume_accessible_A3': float(volume_access),
  942. 'volume_true_geometric_A3': (float(volume_true_geom) if volume_true_geom is not None else None),
  943. 'volume_true_accessible_A3': (float(volume_true_access) if volume_true_access is not None else None),
  944. 'true_area_enabled': bool(ns.true_area),
  945. 'area_grid_step_A': float(ns.area_grid_step),
  946. 'conductivity_S_per_m': float(ns.conductivity),
  947. 'R_pore_ohm': float(R_pore), 'R_access_ohm': float(R_access), 'R_total_ohm': float(R_pore + R_access),
  948. 'geometric_openness_index': float(openness_index),
  949. 'passability': pass_report,
  950. 'min_radius_A': float(min([r['radius_A'] for r in rows]) if rows else 0.0),
  951. 'num_samples': len(rows),
  952. 'occupancy_metric': ns.occupancy, 'hydroscale': ns.hydroscale, 'electroscale': ns.electroscale,
  953. 'no_hydrogens': bool(ns.noH), 'ignore_hetatm': bool(ns.noHet),
  954. 'density_map': (str(density_map_path) if density_map_path else None),
  955. 'density_map_voxel_A': (float(ns.surface_voxel) if density_map_path else None),
  956. }, jf, indent=2)
  957. write_csv(csv_path, rows)
  958. write_centerline_pdb(pdb_center, rows)
  959. write_mesh_pdb(pdb_mesh, axis_u=(c_top - c_bot), rows=rows, rings=ns.rings)
  960. print(f"Wrote: {csv_path}")
  961. print(f"Wrote: {pdb_center}")
  962. print(f"Wrote: {pdb_mesh}")
  963. print(f"Wrote: {summary_path}")
  964. if density_map_path:
  965. print(f"Wrote: {density_map_path}")
  966. print(f" -> In ChimeraX: open {density_map_path} then volume #<N> level 0")
  967. print(f" (level 0 is the accessible pore boundary at probe radius {ns.probe:.2f} A)")
  968. return 0
  969. if __name__ == '__main__':
  970. sys.exit(main())

pyHole.py at commit 890947b, under GPL-3.0 · at the source

Overview

Authors: Mariah R Baker1, Xiaoxuan Lin2, Guizhen Fan1, Ariel Martinez-Chavez1, Larry E Wagner2, Sundeep Malik2, Tyler Allison3, Briar Bell1, Alexander B Seryshev1, Julio Cordero-Morales1,3, Matthew L Baker1,3, David I Yule2, Irina I Serysheva1,3
  1. Department of Biochemistry and Molecular Biology, McGovern Medical School, University of Texas Health Science Center, Houston, TX USA
  2. Department of Pharmacology and Physiology, University of Rochester, Rochester, NY USA
  3. MD Anderson Cancer Center, UTHealth Graduate School of Biomedical Sciences, University of Texas Health Science Center at Houston, Houston, TX USA
Journal: Nature communications, volume 17, issue 1, article 8929
Dates: received 18 December 2025; accepted 9 July 2026; published online 22 July 2026
Type: Research article · Language: English
License: CC BY-NC-ND
Identifiers: DOI 10.1038/s41467-026-75806-y · PMID 42637755 · PMCID PMC13503717 · OpenAlex W7170038317
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: histology / microscopy (modality), human (organism), cellular / molecular (subfield)
Methods: Spectral & time-frequency, fMRI & imaging
Keywords: Permeation and transport, Cryoelectron microscopy, Ion channels
MeSH: Cryoelectron Microscopy*, Inositol 1,4,5-Trisphosphate Receptors*, Adenosine Triphosphate, Animals, Binding Sites, Calcium, Humans, Inositol 1,4,5-Trisphosphate, Models, Molecular, Protein Conformation, Protein Isoforms (* major topic)
Topic: Calcium signaling and nucleotide metabolism (Physiology, Biochemistry, Genetics and Molecular Biology), according to OpenAlex
Funding: U.S. Department of Health &amp; Human Services | NIH | National Institute of General Medical Sciences (R21AR082833, R35GM153178, R35GM153178-01S1); U.S. Department of Health & Human Services | NIH | National Institute of General Medical Sciences (NIGMS) (R21AR082833, R35GM153178-01S1, R35GM153178); U.S. Department of Health & Human Services | NIH | NIH Office of the Director (OD) (S10OD032204); U.S. Department of Health &amp; Human Services | NIH | NIH Office of the Director (S10OD032204); NIH HHS (S10 OD032204); NIAMS NIH HHS (R21 AR082833); NIGMS NIH HHS (R35 GM153178)
Citations: not cited yet (Europe PMC); 77 references in the paper

Abstract

The abstract is not reproduced here: the paper's license (CC BY-NC-ND) does not allow it. Read it in the paper, at the publisher or on Europe PMC.

Repository

Its files are read in the Code ↔ Paper reader above, with 2 matches between paragraphs and lines of code.

mlbaker-uth/pyHole

License: GPL-3.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 890947b36475fa200411536bb929bdd8dac7cbea, 23 September 2026
Languages: Python (15)
Size: 31 files, 15 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file, environment (requirements.txt)
Not found: CITATION.cff, tests, continuous integration, documentation
Tools: NumPy (6 files), SciPy (3 files), Matplotlib (1 file), pandas (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
17 files

Code availability statement

The paper has a code availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:

Read it in the paper: doi.org/10.1038/s41467-026-75806-y.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 15 scripts, each with its path and the digest of its content;
  • 2 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

Datasets cited

Code and data availability statement

The paper has a code and data availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:

Read it in the paper: doi.org/10.1038/s41467-026-75806-y.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 13 authors, 3 keywords, 11 MeSH terms, 7 funders, 76 references.

Cite

This paper

Baker, M. R., Lin, X., Fan, G., Martinez-Chavez, A., Wagner, L. E., Malik, S., Allison, T., Bell, B., Seryshev, A. B., Cordero-Morales, J., Baker, M. L., Yule, D. I., & Serysheva, I. I. (2026). Cryo-EM insights into isoform-specific properties of the IP&lt;sub&gt;3&lt;/sub&gt;R2 channel. Nature communications, 17(1), 8929. https://doi.org/10.1038/s41467-026-75806-y

BibTeX

@article{baker2026cryo,
author = {Baker, Mariah R and Lin, Xiaoxuan and Fan, Guizhen and Martinez-Chavez, Ariel and Wagner, Larry E and Malik, Sundeep and Allison, Tyler and Bell, Briar and Seryshev, Alexander B and Cordero-Morales, Julio and Baker, Matthew L and Yule, David I and Serysheva, Irina I},
title = {{Cryo-EM insights into isoform-specific properties of the IP\&lt;sub\&gt;3\&lt;/sub\&gt;R2 channel}},
journal = {Nature communications},
year = {2026},
month = jul,
volume = {17},
number = {1},
pages = {8929},
publisher = {Nature Publishing Group},
issn = {2041-1723},
doi = {10.1038/s41467-026-75806-y},
url = {https://doi.org/10.1038/s41467-026-75806-y},
pmid = {42637755},
pmcid = {PMC13503717}
}

RIS

TY - JOUR
AU - Baker, Mariah R
AU - Lin, Xiaoxuan
AU - Fan, Guizhen
AU - Martinez-Chavez, Ariel
AU - Wagner, Larry E
AU - Malik, Sundeep
AU - Allison, Tyler
AU - Bell, Briar
AU - Seryshev, Alexander B
AU - Cordero-Morales, Julio
AU - Baker, Matthew L
AU - Yule, David I
AU - Serysheva, Irina I
TI - Cryo-EM insights into isoform-specific properties of the IP&lt;sub&gt;3&lt;/sub&gt;R2 channel
T2 - Nature communications
J2 - Nat Commun
PY - 2026
DA - 2026/07/22
VL - 17
IS - 1
SP - 8929
SN - 2041-1723
PB - Nature Publishing Group
DO - 10.1038/s41467-026-75806-y
UR - https://doi.org/10.1038/s41467-026-75806-y
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41467-026-75806-y",
"type": "article-journal",
"title": "Cryo-EM insights into isoform-specific properties of the IP&lt;sub&gt;3&lt;/sub&gt;R2 channel",
"container-title": "Nature communications",
"author": [
{
"family": "Baker",
"given": "Mariah R"
},
{
"family": "Lin",
"given": "Xiaoxuan"
},
{
"family": "Fan",
"given": "Guizhen"
},
{
"family": "Martinez-Chavez",
"given": "Ariel"
},
{
"family": "Wagner",
"given": "Larry E"
},
{
"family": "Malik",
"given": "Sundeep"
},
{
"family": "Allison",
"given": "Tyler"
},
{
"family": "Bell",
"given": "Briar"
},
{
"family": "Seryshev",
"given": "Alexander B"
},
{
"family": "Cordero-Morales",
"given": "Julio"
},
{
"family": "Baker",
"given": "Matthew L"
},
{
"family": "Yule",
"given": "David I"
},
{
"family": "Serysheva",
"given": "Irina I"
}
],
"container-title-short": "Nat Commun",
"volume": "17",
"issue": "1",
"page": "8929",
"DOI": "10.1038/s41467-026-75806-y",
"PMID": "42637755",
"PMCID": "PMC13503717",
"ISSN": "2041-1723",
"publisher": "Nature Publishing Group",
"URL": "https://doi.org/10.1038/s41467-026-75806-y",
"language": "en",
"issued": {
"date-parts": [
[
2026,
7,
22
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1038/s41467-026-75444-4 [code]
Structural insights enable drug discovery for the neuronal NBCn2 carbonate transporter.
Journal: Nature communications
In common: pandas, SciPy, Matplotlib, 1 other tool, ebi.ac.uk/pdbe/entry, histology / microscopy, cellular / molecular, 5 references
[2] doi:10.1038/s41467-026-74087-9 [code]
Cryo-EM structures of heteromeric Kir4.1/5.1 channel suggest mechanisms of inward rectification and channel blockage.
Journal: Nature communications
In common: ebi.ac.uk/pdbe/entry, histology / microscopy, cellular / molecular, 6 references
[3] doi:10.1038/s41594-026-01866-9 [code]
Structural and mechanistic insights into gating and allosteric modulation of GluN1-GluN3A NMDA receptors.
Journal: Nature structural & molecular biology
In common: SciPy, Matplotlib, NumPy, ebi.ac.uk/pdbe/entry, histology / microscopy, cellular / molecular, 4 references
[4] doi:10.1038/s41594-026-01845-0
Conformational plasticity of human acid-sensing ion channel 1a.
Journal: Nature structural & molecular biology
In common: ebi.ac.uk/pdbe/entry, histology / microscopy, cellular / molecular, 4 references
[5] doi:10.1371/journal.pbio.3003777
Structure of the human P2X3 receptor reveals the basis for subtype-selective inhibition by sivopixant.
Journal: PLoS biology
In common: ebi.ac.uk/pdbe/entry, histology / microscopy, cellular / molecular, 4 references
[6] doi:10.1038/s41467-026-74814-2
Structural mechanism of Necrocide 1 activation of human TRPM4 that triggers necrosis by sodium overload.
Journal: Nature communications
In common: ebi.ac.uk/pdbe/entry, histology / microscopy, cellular / molecular, 4 references
[7] doi:10.1038/s41467-026-70575-0
Structure of a pH-sensitive pentameric ligand-gated ion channel from the Sarcoptes scabies mite.
Journal: Nature communications
In common: ebi.ac.uk/pdbe/entry, histology / microscopy, cellular / molecular, 4 references
[8] doi:10.1038/s41594-026-01787-7 [code]
Microtubules in the axon are GDP bound but adopt a stable GTP-like expanded state.
Journal: Nature structural & molecular biology
In common: Matplotlib, NumPy, ebi.ac.uk/pdbe/entry, histology / microscopy, cellular / molecular, 2 references
[9] doi:10.1038/s41467-026-75564-x
Cooperative mechanism of neurotransmitter recognition and transport by the human vesicular polyamine transporter.
Journal: Nature communications
In common: ebi.ac.uk/pdbe/entry, histology / microscopy, cellular / molecular, 3 references
[10] doi:10.1038/s41467-026-75877-x
Structure of NHE6 and its lipid-mediated interactions regulating endosomal pH.
Journal: Nature communications
In common: ebi.ac.uk/pdbe/entry, histology / microscopy, cellular / molecular, 3 references

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.