Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 37 additions & 37 deletions pixi.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ dependencies = [
"loguru",
"omegaconf",
"python-dotenv",
"sfcalculator-torch>=0.3.2"
"sfcalculator-torch>=0.3.3"
]
description = "Experimentally guided biomolecular ensemble generation."
keywords = ["biomolecular-structure", "crystallography", "diffusion", "ensemble-generation", "protein-structure"]
Expand Down
513 changes: 513 additions & 0 deletions src/sampleworks/core/rewards/structure_factor.py

Large diffs are not rendered by default.

148 changes: 79 additions & 69 deletions src/sampleworks/eval/generate_synthetic_sf.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Generate synthetic structure factor amplitudes via SFcalculator-torch.

Produces an MTZ file of |Fmodel| (or |Fprotein| if not simulate solvent and
scale) for each input PDB/mmCIF structure. The MTZ file has dummy values for
SIGFP and optionally R-free flag column. Each structure can be optionally
overridden with unit cell, space group, atom selection, and occupancy.
For each input PDB/mmCIF structure, produces an MTZ file of protein structure factors
only, or when ``--simulate-solvent-and-scale`` is set, both the protein and total sets
(Fprotein/SIGFprotein/PHIFprotein and Ftotal/SIGFtotal/PHIFtotal) in the same

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been confused in the past about what the standard names of these are. Are there other common conventions? I feel like there were a couple. Should we address that?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each refinement program has there own names used, so I don't think there is a standard (which is why auto-detect by column type). Usually F* is for amplitude, SIGF* is for amplitude uncertainty, PHIF* is for phase, and that's the convention I am following here.

MTZ. The MTZ file has dummy values for the SIGF column(s) and optionally an R-free flag
column. Each structure can be optionally overridden with unit cell, space group, atom
selection, and occupancy.
"""

import argparse
Expand All @@ -15,20 +17,18 @@
from typing import Any, ClassVar

import gemmi
import numpy as np
import reciprocalspaceship as rs
import reciprocalspaceship.utils
import torch
from biotite.structure import AtomArray
from loguru import logger
from sampleworks.eval.synthetic_utils import (
Comment thread
marcuscollins marked this conversation as resolved.
atomarray_to_gemmi,
load_structure_for_synthetic_reward,
validate_occupancy_values,
)
from sampleworks.utils.atom_array_utils import BLANK_ALTLOC_IDS
from sampleworks.utils.torch_utils import try_gpu
from SFC_Torch import SFcalculator
from SFC_Torch.io import array2hier, PDBParser
from SFC_Torch.io import PDBParser


@dataclass
Expand Down Expand Up @@ -127,62 +127,59 @@ def from_dict(cls, row: dict[str, Any]) -> "BatchRowForMTZ":
)


def atomarray_to_gemmi(
atom_array: AtomArray,
unit_cell: gemmi.UnitCell | None = None,
space_group: str | None = None,
) -> gemmi.Structure:
"""Convert a biotite AtomArray to a gemmi.Structure for SFcalculator.
def _amplitude_phase_columns(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe rename _extract_amplitude_and_phase_columns?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed as _build_rs_dataset_for_one_label

sfc: SFcalculator,
label: str,
structure_factor_column: str,
miller_index_column: str,
sigma_f_scale: float,
) -> rs.DataSet:
"""Build a one-amplitude rs.DataSet with labelled F / SIGF / PHIF columns.

Anisotropic B-factors are set to zero since biotite does not store them.
Blank altloc labels are converted from biotite's '' to gemmi's '\\x00'.
``sfc.prepare_dataset`` returns an amplitude column and a phase column (degrees)
for the given ``structure_factor_column`` attribute. We auto-detect those by MTZ
dtype (rather than assuming the unexposed ``FMODEL`` / ``PHIFMODEL`` names),
rename them to ``F{label}`` / ``PHIF{label}``, and synthesize a ``SIGF{label}``
column so several structure-factor sets (e.g. protein and total) can coexist in
one MTZ.

Parameters
----------
atom_array
Input structure with occupancy and b_factor annotations
unit_cell
Crystallographic unit cell for the structure. If None, gemmi defaults
to (1.0, 1.0, 1.0, 90.0, 90.0, 90.0) in units of Angstroms and degrees.
space_group
Space group (in Hermann-Mauguin string format) for the structure. If
empty or invalid, SFcalculator defaults to P1.
sfc : SFcalculator
Structure-factor calculator holding the requested ASU amplitudes and phases.
label : str
Output column-label suffix, e.g. ``"protein"`` or ``"total"``.
structure_factor_column : str
SFcalculator attribute name passed to ``prepare_dataset`` (the amplitudes to emit).
miller_index_column : str
SFcalculator attribute name holding the Miller indices for ``prepare_dataset``.
sigma_f_scale : float
Multiplier used to synthesize the ``SIGF{label}`` column from the amplitudes.

Returns
-------
gemmi.Structure
Structure ready to be wrapped by SFC_Torch.io.PDBParser
rs.DataSet
Dataset with columns ``F{label}``, ``SIGF{label}``, ``PHIF{label}`` (in that order).
"""
n = len(atom_array)
cra_names = [
f"{atom_array.chain_id[i]}-0-{atom_array.res_name[i]}-{atom_array.atom_name[i]}"
for i in range(n)
]
# gemmi uses '\x00' for blank altloc
atom_altloc = ["\x00" if a in BLANK_ALTLOC_IDS else a for a in atom_array.altloc_id]
structure: gemmi.Structure = array2hier(
atom_pos=atom_array.coord,
atom_b_aniso=np.zeros((n, 3, 3), dtype=np.float64),
atom_b_iso=atom_array.b_factor,
atom_occ=atom_array.occupancy,
atom_name=atom_array.element,
cra_name=cra_names,
atom_altloc=atom_altloc,
res_id=atom_array.res_id,
dataset: rs.DataSet = sfc.prepare_dataset(miller_index_column, structure_factor_column)
amplitude_column = dataset.select_mtzdtype(rs.StructureFactorAmplitudeDtype()).columns[0]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's more than one amplitude column, how do you know which one you need?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for this case (a dataset produced by SFcalculator), it's not that complicated -- there should be only 1 amplitude and 1 phase columns. I auto-select first column here to avoid using the undocumented hardcoded str names in SFC ("Fmodel") which was a concern you raised in previous pr.

phase_column = dataset.select_mtzdtype(rs.PhaseDtype()).columns[0]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto.

logger.debug(
f"Auto-detected amplitude column: {amplitude_column}, "
f"phase column: {phase_column} for {label}"
)
if unit_cell is not None:
structure.cell = unit_cell
if space_group is not None:
structure.spacegroup_hm = space_group
return structure
f_col, phi_col, sig_col = f"F{label}", f"PHIF{label}", f"SIGF{label}"
dataset = dataset.rename(columns={amplitude_column: f_col, phase_column: phi_col})
dataset[sig_col] = (dataset[f_col] * sigma_f_scale).astype(rs.StandardDeviationDtype())
return dataset[[f_col, sig_col, phi_col]]


def process_amplitudes_to_dataset(
sfc: SFcalculator,
structure_factor_columns: dict[str, str],
test_fraction: float = 0.05,
seed: int | None = None,
miller_index_column: str = "Hasu_array",
structure_factor_column: str = "Ftotal_asu",
ccp4_convention: bool = False,
sigma_f_scale: float = 0.2,
output_path: Path | None = None,
Expand All @@ -193,14 +190,20 @@ def process_amplitudes_to_dataset(
----------
sfc: SFcalculator
SFcalculator instance
structure_factor_columns: dict[str, str]
Mapping of ``label -> SFcalculator attribute``. One structure-factor set

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a little confusing since it is a mapping from string to string; I guess SFcalculator attribute is also a string? If so, what are/how do we know the available SFcalculator attributes?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SFC sets the attributes as they become populated/available rather than declare all of them at init, which is why this dict looks confusing. For practicality, we really just need to look at Fprotein_asu and/or Ftotal_asu that we care about for now. Let me know if you think this needs better handling and how.

(``F{label}``/``SIGF{label}``/``PHIF{label}``) is emitted per entry, and
multiple entries are merged into one MTZ sharing the same HKL list
(``miller_index_column``) and a single R-free column, e.g.
``{"protein": "Fprotein_asu", "total": "Ftotal_asu"}`` produces
``Fprotein``/``SIGFprotein``/``PHIFprotein`` and
``Ftotal``/``SIGFtotal``/``PHIFtotal``.
test_fraction: float
Fraction of reflections to mark as R-free test set (0 disables)
seed: int | None
Optional seed for reproducible R-free flag assignment
miller_index_column: str
Attribute name in SFcalculator for hkl indices
structure_factor_column: str
Attribute name in SFcalculator for structure factors
ccp4_convention: bool
If True, use CCP4 convention for R-free flag assignment. Default
is False, which uses Phenix convention (1 = test, 0 = working).
Expand All @@ -214,18 +217,18 @@ def process_amplitudes_to_dataset(
Returns
-------
rs.DataSet
Dataset with structure factor amplitudes, fake sigma column, and optionally
R-free flags.
Dataset with structure factor amplitudes, dummy sigma column(s), phases,
and optionally R-free flags.
"""
dataset: rs.DataSet = sfc.prepare_dataset(miller_index_column, structure_factor_column)
# assumes the first detected column of dtype F is the structure factor amplitude column
# avoids hardcoding unexposed column name "FMODEL" from sfc.prepare_dataset().
structure_factor_amplitude_column = dataset.select_mtzdtype(
rs.StructureFactorAmplitudeDtype()
).columns[0]
sigma_f_column = f"SIG{structure_factor_amplitude_column}"
dataset[sigma_f_column] = dataset[structure_factor_amplitude_column] * sigma_f_scale
dataset[sigma_f_column] = dataset[sigma_f_column].astype(rs.StandardDeviationDtype())
if not structure_factor_columns:
raise ValueError("structure_factor_columns must contain at least one entry.")
column_items = iter(structure_factor_columns.items())
label, attribute = next(column_items)
dataset = _amplitude_phase_columns(sfc, label, attribute, miller_index_column, sigma_f_scale)
for label, attribute in column_items:
ds = _amplitude_phase_columns(sfc, label, attribute, miller_index_column, sigma_f_scale)
for col in ds.columns:
dataset[col] = ds[col]
Comment on lines +225 to +231

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a straaaange construction. Much clearer:

dataset = rs.DataSet(...)  # fill in whatever is needed, maybe nothing.
for label, attribute in structure_factor_columns.items():
    ds = _amplitude_phase_columns(sfc, label, attribute, miller_index_column, sigma_f_scale)
    dataset = dataset.merge(ds, ...)  # you'll have to specify pandas style merge columns, probably hkl?

rs.DataSet is just a pd.DataFrame with some extra attributes, so I think this will be much cleaner.

@DorisMai DorisMai Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The index is identical, so when there are multiple labels, we just need column concat rather than a merge/join. I can get rid of the iter/next and do directly column assignment/concatenation if this reads better:

dataset: rs.DataSet | None = None
for label, attribute in structure_factor_columns.items():
    ds = _build_rs_dataset_for_one_label(sfc, label, attribute, miller_index_column, sigma_f_scale)
    if dataset is None:
        dataset = ds                 # first set carries cell / spacegroup / dtypes
    else:
        dataset[ds.columns] = ds     # graft the remaining columns on (index-aligned)

if test_fraction > 0:
dataset = rs.utils.add_rfree(
dataset,
Expand Down Expand Up @@ -288,8 +291,9 @@ def _process_single_row(
If True, remove ligand molecules (non-water heteroatoms) before computing structure
factors. Default is False.
simulate_solvent_and_scale
If True, compute bulk solvent and scale factors for Ftotal instead of Fprotein.
Default is False.
If True, compute bulk solvent and scale factors and write a single MTZ containing
both the protein and total structure factor sets. If False (default), only the
protein set is written. One set contains F{label}/SIGF{label}/PHIF{label}.
save_structure
If True, save the processed structure (after selection and occupancy assignment)
as mmCIF to output_dir. Unit cell and space group are preserved. Default is False.
Expand Down Expand Up @@ -354,15 +358,17 @@ def _process_single_row(
f"n_atoms: {len(sfc.atom_pos_orth)}"
)
sfc.calc_fprotein()
structure_factor_columns = {"protein": "Fprotein_asu"}
if simulate_solvent_and_scale:
sfc.inspect_data()
sfc.calc_fsolvent()
sfc.init_scales(requires_grad=False)
sfc.calc_ftotal()
F_attribute = "Ftotal_asu"
else:
F_attribute = "Fprotein_asu"
logger.debug(f"Computed {F_attribute} for {row.filename} on {device}")
structure_factor_columns.update({"total": "Ftotal_asu"})
logger.debug(
f"Computed {'Fprotein + Ftotal' if simulate_solvent_and_scale else 'Fprotein'} "
f"for {row.filename} on {device}"
)
except Exception as e:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please file an issue to work through this file and make the except statements more specific.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is just a generic safe guard to mirror the generate_synthetic_density file (line 205-209)

logger.error(
f"Failed to compute for {row.filename} ({type(e).__name__}): {e}\n"
Expand All @@ -375,7 +381,7 @@ def _process_single_row(
try:
process_amplitudes_to_dataset(
sfc,
structure_factor_column=F_attribute,
structure_factor_columns=structure_factor_columns,
test_fraction=test_fraction,
seed=seed,
output_path=output_path,
Expand Down Expand Up @@ -552,7 +558,11 @@ def parse_args() -> argparse.Namespace:
sf_group.add_argument(
"--simulate-solvent-and-scale",
action="store_true",
help="Compute bulk solvent and overall scale factors (outputs Ftotal instead of Fprotein)",
help=(
"Compute bulk solvent and overall scale factors and write both protein and "
"total structure factor in one MTZ. Without this flag, protein only. Each "
"set contains F{label}/SIGF{label}/PHIF{label}."
Comment thread
DorisMai marked this conversation as resolved.
),
Comment thread
DorisMai marked this conversation as resolved.
)
sf_group.add_argument(
"--remove-hydrogens",
Expand Down
105 changes: 105 additions & 0 deletions src/sampleworks/eval/synthetic_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
import traceback
from pathlib import Path

import gemmi
from atomworks.io.transforms.atom_array import remove_waters
from biotite.structure import AtomArray
from loguru import logger
from sampleworks.eval.structure_utils import apply_selection
from sampleworks.utils.atom_array_utils import (
AltlocInfo,
BLANK_ALTLOC_IDS,
detect_altlocs,
keep_amino_acids,
keep_polymer,
Expand Down Expand Up @@ -194,3 +196,106 @@ def load_structure_for_synthetic_reward(
raise ValueError(f"Invalid occupancy mode '{occupancy_mode}'")

return atom_array


def atomarray_to_gemmi(
atom_array: AtomArray,
unit_cell: gemmi.UnitCell | None = None,
space_group: str | None = None,
) -> gemmi.Structure:
"""Convert a biotite AtomArray to a gemmi.Structure for SFcalculator.

Anisotropic B-factors are set to zero since biotite does not store them.
Blank altloc labels are converted from biotite's '' to gemmi's '\\x00'. If
the atom array has no ``altloc_id`` annotation (e.g. arrays reconstructed by
a model wrapper), all altlocs default to blank.

Parameters
----------
atom_array
Input structure with occupancy and b_factor annotations
unit_cell
Crystallographic unit cell for the structure. If None, gemmi defaults
to (1.0, 1.0, 1.0, 90.0, 90.0, 90.0) in units of Angstroms and degrees.
space_group
Space group (in Hermann-Mauguin string format) for the structure. If
empty or invalid, SFcalculator defaults to P1.

Returns
-------
gemmi.Structure
Structure ready to be wrapped by SFC_Torch.io.PDBParser

Notes
-----
Built directly rather than via ``SFC_Torch.io.array2hier``: that helper packs atom
identity into a hyphen-delimited ``cra_name`` string, sets only the author seqid, and
keys residue boundaries on ``res_id`` alone (merging residues that share a res_id across
a chain boundary). Building the hierarchy here from the AtomArray annotations avoids the
string round-trip and lets us set the author seqid, ``label_seq`` (so ``label_seq_id``
survives a write -> atomworks reload; otherwise res_id collapses to -1), and ``subchain``
(a <=1-char ``label_asym_id`` that SFcalculator's PDB-header step accepts) at construction.
"""
n = len(atom_array)
Comment thread
DorisMai marked this conversation as resolved.
# altloc_id is not a mandatory biotite annotation; default to blank when absent.
# gemmi uses '\x00' for blank altloc.
if "altloc_id" in atom_array.get_annotation_categories():
atom_altloc = ["\x00" if a in BLANK_ALTLOC_IDS else a for a in atom_array.altloc_id]
else:
atom_altloc = ["\x00"] * n

model = gemmi.Model("1") # numeric name -> valid mmCIF pdbx_PDB_model_num
current_chain: gemmi.Chain | None = None
current_res: gemmi.Residue | None = None
prev_key: tuple[str, int] | None = None # (chain_id, res_id) of the previous atom

for i in range(n):
Comment thread
DorisMai marked this conversation as resolved.
chain_id = str(atom_array.chain_id[i])
res_id = int(atom_array.res_id[i])
key = (chain_id, res_id)

if current_chain is None or chain_id != current_chain.name:
Comment thread
DorisMai marked this conversation as resolved.
if current_chain is not None:
assert current_res is not None # a chain always holds >=1 residue here
current_chain.add_residue(current_res)
model.add_chain(current_chain)
current_chain = gemmi.Chain(chain_id)
prev_key = None # force a fresh residue for the new chain

# New residue on any (chain_id, res_id) change. Keying on the pair (not res_id
# alone) keeps residues that share a res_id across a chain boundary separate.
if key != prev_key:
if prev_key is not None:
assert current_res is not None # prev_key set implies a residue exists
current_chain.add_residue(current_res)
current_res = gemmi.Residue()
current_res.name = str(atom_array.res_name[i])
current_res.seqid = gemmi.SeqId(str(res_id)) # author numbering
current_res.label_seq = res_id # label numbering -> label_seq_id on write
current_res.subchain = chain_id # label_asym_id == chain name
prev_key = key
Comment on lines +265 to +276

@coderabbitai coderabbitai Bot Jul 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether ins_code is present/used in the atom arrays flowing into atomarray_to_gemmi
rg -nP '\bins_code\b' -C2 --type=py
# Confirm biotite AtomArray exposes ins_code in this environment's version
rg -nP 'atomworks|biotite' pyproject.toml

Repository: diff-use/sampleworks

Length of output: 279


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and locate the converter/callers.
ast-grep outline src/sampleworks/eval/synthetic_utils.py --view expanded || true
rg -n "atomarray_to_gemmi|ins_code|label_seq|res_id|SeqId" src/sampleworks -C 3

# Inspect the relevant slice of the converter.
sed -n '220,320p' src/sampleworks/eval/synthetic_utils.py

# Find where the atom arrays come from and whether any pipeline preserves insertion codes.
rg -n "get_annotation_categories|ins_code|AtomArray|AtomArrayStack|load_structure|to_atom_array|atom_array" src -C 2

Repository: diff-use/sampleworks

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Narrow search for actual insertion-code handling in the repo.
rg -n "\bins_code\b|ins[-_ ]?code|insertion code" src tests .

# Inspect the exact converter slice with line numbers.
sed -n '250,290p' src/sampleworks/eval/synthetic_utils.py | cat -n

# Inspect the atom-array ID helpers, since they define the matching semantics used upstream.
sed -n '580,760p' src/sampleworks/utils/atom_array_utils.py | cat -n

# Check whether the code assumes res_id alone anywhere else for residue identity.
rg -n "\bres_id\b.*\bres_id\b|make_atom_id|make_normalized_atom_id|filter_to_common_atoms" src/sampleworks/utils/atom_array_utils.py src/sampleworks/eval/synthetic_utils.py -C 2

Repository: diff-use/sampleworks

Length of output: 21337


🌐 Web query:

biotite AtomArray ins_code annotation PDBx PDB insertion code AtomArray load_any

💡 Result:

In Biotite, the insertion code is a mandatory annotation within an AtomArray, stored in the 'ins_code' array [1]. When loading structures from PDBx/mmCIF files, Biotite maps the PDBx field _atom_site.pdbx_PDB_ins_code to this 'ins_code' annotation [2]. Regarding your specific queries: 1. 'ins_code' annotation: Every AtomArray object in Biotite stores fundamental structural attributes as NumPy arrays, including 'ins_code' (residue insertion code), 'chain_id', 'res_id', 'res_name', 'atom_name', and 'element' [1][3]. These are essential components of the molecular structure representation in Biotite [1]. 2. PDBx/PDB integration: When using PDBx (mmCIF) files, the Biotite parser automatically attempts to extract the insertion code [2]. Note that historical issues have been reported where files lacking the _atom_site.pdbx_PDB_ins_code field caused errors, though this has been addressed in recent updates to better handle missing values [4]. 3. load_any: Biotite does not have a single monolithic 'load_any' function; instead, structure loading is handled through format-specific modules in biotite.structure.io, such as pdb.get_structure or pdbx.get_structure [5][6][7]. To load a structure and preserve attributes like insertion codes, you should use the get_structure method provided by the appropriate IO module (e.g., pdbx for PDBx/mmCIF files) [5][7]. These methods automatically populate the standard annotations, including 'ins_code', if the information is available in the source file [5][2].

Citations:


Include ins_code in residue identity src/sampleworks/eval/synthetic_utils.py:265-276
Biotite exposes ins_code on AtomArray, and these CIF inputs already carry _atom_site.pdbx_PDB_ins_code, so residues that share a res_id but differ by insertion code will be merged here and written back without the author insertion code. Fold ins_code into the residue boundary key and gemmi.SeqId.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sampleworks/eval/synthetic_utils.py` around lines 265 - 276, Residue
grouping in the AtomArray-to-Gemmi conversion currently ignores insertion codes,
so residues with the same res_id but different ins_code get merged. Update the
residue identity used in the synthetic_utils flow (the key comparison around
current_res / prev_key) to include atom_array.ins_code, and propagate that value
into gemmi.SeqId when creating each residue so author insertion codes are
preserved on write.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently the entire repo does not use ins_code, and key on (chain_id, res_id) or (chain_id, res_id, atom_name). I am not sure when or how often we encounter ins_code. I did notice that Vratin uses (chain_id, res_id, ins_code) in WaterFlow. @marcuscollins Do you think this is worth opening an issue to fix repo-wide or low priority / assume no ins code handling? either way I don't think it should block this PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DorisMai Agreed this shouldn't block this PR — merging residues that share res_id but differ by ins_code is a pre-existing, repo-wide convention here rather than something introduced by this change. Since it's a broader design question (repo-wide key convention vs. one-off fix), it does seem worth tracking separately so it doesn't get lost, especially given the inconsistency you noticed with WaterFlow's (chain_id, res_id, ins_code) keying.

If you and @marcuscollins agree, I'm happy to open a follow-up GitHub issue summarizing this discussion (current repo convention, the WaterFlow inconsistency, and the specific spot in atomarray_to_gemmi as a starting point) — just let me know and I'll file it.


✏️ Learnings added
Learnt from: DorisMai
Repo: diff-use/sampleworks PR: 272
File: src/sampleworks/eval/synthetic_utils.py:265-276
Timestamp: 2026-07-06T17:02:04.734Z
Learning: In the diff-use/sampleworks repo, residue identity throughout the codebase (e.g., in `src/sampleworks/eval/synthetic_utils.py`'s `atomarray_to_gemmi`) is generally keyed on (chain_id, res_id) or (chain_id, res_id, atom_name), without incorporating `ins_code`. This is an intentional, repo-wide convention, not a bug specific to one function. Note: WaterFlow code (referenced by DorisMai/Vratin) uses (chain_id, res_id, ins_code) instead, which is an inconsistency that may warrant a future repo-wide follow-up issue, but should not block individual PRs.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: k-chrispens
Repo: diff-use/sampleworks PR: 73
File: src/sampleworks/core/samplers/protocol.py:1-18
Timestamp: 2026-02-02T19:13:51.594Z
Learning: In Python files across the repo, do not suggest or add inline or file-level suppressions for Ruff F722 (jaxtyping dimension strings). Instead rely on a global ignore configured in pyproject.toml (as done by the sampleworks project). Verify that F722 is included in the global ignore, and avoid recommending per-file # ruff: noqa: F722 directives unless explicitly documented as an exception.

Learnt from: marcuscollins
Repo: diff-use/sampleworks PR: 132
File: src/sampleworks/utils/guidance_script_utils.py:585-586
Timestamp: 2026-03-05T16:30:40.514Z
Learning: In Python code, if enums subclassing StrEnum are used (e.g., GuidanceType, StructurePredictor), they serialize to plain strings with json.dump and pickle without special handling. Do not flag these as non-serializable in code reviews. Treat StrEnum values as strings for JSON serialization and ensure tests cover that behavior; no extra pickle handling needed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CodeRabbit go ahead and create the issue. Please indicate in the issue that we need to research when ins_code is used and why. That should inform our decision on what to do.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="diff-use/sampleworks"

body=$(cat <<'EOF'
## Summary

`atomarray_to_gemmi` in `src/sampleworks/eval/synthetic_utils.py` (and residue-identity logic elsewhere in the repo) keys residues on `(chain_id, res_id)` or `(chain_id, res_id, atom_name)`, without incorporating `ins_code`. Biotite `AtomArray` objects do expose an `ins_code` annotation, and CIF inputs can carry `_atom_site.pdbx_PDB_ins_code`, so residues that share a `res_id` but differ by insertion code could currently be merged/misidentified.

Separately, WaterFlow code keys residue identity on `(chain_id, res_id, ins_code)`, which is inconsistent with the convention used in this repo.

## Why this needs research first

Before deciding whether to change the repo-wide convention (or leave it as-is), we need to understand:
- How often, and in what real-world structures/datasets, `ins_code` is actually populated (non-blank) in inputs processed by this codebase.
- Whether any current pipelines (synthetic SF/density generation, reward functions, structure loading utilities) could produce silently incorrect results due to ignoring `ins_code`.
- Why WaterFlow chose to include `ins_code` in its identity key, and whether that reflects a real correctness requirement or just defensive coding.

## Affected areas

- `src/sampleworks/eval/synthetic_utils.py` (`atomarray_to_gemmi`)
- `src/sampleworks/utils/atom_array_utils.py` (residue/atom identity helpers)
- Any other residue-identity keying in the repo that currently omits `ins_code`

## Acceptance criteria

- [ ] Document findings on when/why `ins_code` is populated and relevant in practice (e.g., which structures/datasets), and whether it's already handled by upstream loading/filtering steps.
- [ ] Decide whether to adopt `(chain_id, res_id, ins_code)` repo-wide, keep the current convention, or handle it case-by-case.
- [ ] If a change is warranted, list the specific functions/files to update.

## References

- PR: https://github.com/diff-use/sampleworks/pull/272
- Discussion: https://github.com/diff-use/sampleworks/pull/272#discussion_r3530470906
- Requested by: `@marcuscollins`
EOF
)

gh issue create \
  --repo "$repo" \
  --title "Research: when/why is ins_code needed for residue identity?" \
  --assignee "marcuscollins" \
  --body "$body"

Length of output: 2503


@marcuscollins Done — I've opened #306 to track this, framed as a research task on when/why ins_code is used and why, which should inform whether we adopt (chain_id, res_id, ins_code) repo-wide, keep the current convention, or handle it case-by-case. It includes backlinks to this PR and discussion, the affected files, and acceptance criteria.

(ᵔᴥᵔ)


🧠 Learnings used
Learnt from: DorisMai
Repo: diff-use/sampleworks PR: 272
File: src/sampleworks/eval/synthetic_utils.py:265-276
Timestamp: 2026-07-06T17:02:04.734Z
Learning: In diff-use/sampleworks, treat residue identity as following the repo convention: key residue-related data primarily by (chain_id, res_id) and, when needed, (chain_id, res_id, atom_name)—do not require ins_code to be included in these identity keys. For example, functions like atomarray_to_gemmi in src/sampleworks/eval/synthetic_utils.py should follow this approach. Note that some external/related code (e.g., WaterFlow) uses (chain_id, res_id, ins_code); this inconsistency may justify a future repo-wide follow-up, but it should not be used to block individual PRs that adhere to the existing convention.


assert current_res is not None # created on the first atom / every chain switch
Comment thread
DorisMai marked this conversation as resolved.
atom = gemmi.Atom()
atom.name = str(atom_array.atom_name[i])
atom.element = gemmi.Element(str(atom_array.element[i]))
atom.pos = gemmi.Position(*atom_array.coord[i]) # coord[i]: (3,) float
atom.b_iso = float(atom_array.b_factor[i])
atom.aniso = gemmi.SMat33f(0, 0, 0, 0, 0, 0) # biotite stores no anisotropic B
atom.occ = float(atom_array.occupancy[i])
atom.altloc = atom_altloc[i]
current_res.add_atom(atom)

if current_chain is not None: # flush the trailing residue + chain
assert current_res is not None # non-empty input -> a residue was built
current_chain.add_residue(current_res)
model.add_chain(current_chain)

structure = gemmi.Structure()
structure.add_model(model)
structure.setup_entities() # SFcalculator/PDBParser expects entities assigned
if unit_cell is not None:
structure.cell = unit_cell
if space_group is not None:
structure.spacegroup_hm = space_group
return structure
Loading
Loading