Skip to content

Commit ddf210d

Browse files
authored
Merge branch 'main' into development_v1.2
2 parents dc092ef + e052bbe commit ddf210d

6 files changed

Lines changed: 97 additions & 27 deletions

File tree

.github/workflows/pylint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
matrix:
1010
python-version: ["3.10", "3.11", "3.12"]
1111
steps:
12-
- uses: actions/checkout@v5
12+
- uses: actions/checkout@v6
1313
- name: Set up Python ${{ matrix.python-version }}
1414
uses: actions/setup-python@v6
1515
with:

.github/workflows/python-publish.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
# included in the sdist (unless explicitly excluded)
2424
runs-on: ubuntu-latest
2525
steps:
26-
- uses: actions/checkout@v5
26+
- uses: actions/checkout@v6
2727
- run: pipx run check-manifest
2828

2929
test:
@@ -36,7 +36,7 @@ jobs:
3636
platform: [ubuntu-latest, macos-latest]
3737

3838
steps:
39-
- uses: actions/checkout@v5
39+
- uses: actions/checkout@v6
4040

4141
- name: 🐍 Set up Python ${{ matrix.python-version }}
4242
uses: actions/setup-python@v6
@@ -85,7 +85,7 @@ jobs:
8585
contents: write
8686

8787
steps:
88-
- uses: actions/checkout@v5
88+
- uses: actions/checkout@v6
8989
with:
9090
fetch-depth: 0
9191

docs/programs/constrained_search.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ particle_stack_constrained: # This is from the constrained particles
5555
5656
We must specify what angular space to perform the orientation search over.
5757
The first thing we must specify is the primary rotation axis, which we set as the Z axis.
58-
If unknown, this can be calculated using two PDB models (one rotated and one unrotated) using the script [get_rot_axis.py](https://raw.githubusercontent.com/Lucaslab-Berkeley/Leopard-EM/refs/heads/main/programs/constrained_search/utils/get_center_vector.py).
58+
If unknown, this can be calculated using two PDB models (one rotated and one unrotated) using the script [get_rot_axis.py](https://raw.githubusercontent.com/Lucaslab-Berkeley/Leopard-EM/refs/heads/main/programs/constrained_search/utils/get_rot_axis.py).
5959
6060
As well as searching over one rotation axis, we can search over a second axis, which by default is the y axis.
6161
This can be changed to any axis orthogonal to the primary axis by specifying a `roll_axis` and using the `base_grid_method: roll`.

programs/constrained_search/utils/get_rot_axis.py

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
"""Calculate the rotation axis for a pair of PDB structures."""
1+
"""Calculate the rotation axis for a pair of PDB structures.
22
3+
This script calculates the rotation axis between two PDB structures using rigid
4+
point registration. Important note:
5+
6+
This script only works for PDB structures which match (same atoms, otherwise
7+
registration may be non-sensical). This requires
8+
the same number of atoms in both structures.
9+
10+
The script can filter for only CA atoms in case not all sidechains are modeled
11+
and/or additional ligands are in one model. Use the --filter-ca-only flag to
12+
enable this filtering.
13+
"""
14+
15+
import argparse
316
import math
4-
import sys
17+
import warnings
518

619
import mmdf
720
import roma
@@ -98,18 +111,76 @@ def calculate_axis_euler_angles(axis: torch.Tensor) -> tuple[float, float]:
98111

99112
def main() -> None:
100113
"""Calculate rotation axis for a pair of PDB structures."""
101-
if len(sys.argv) != 4:
102-
print(f"Usage: {sys.argv[0]} <pdb_file1> <pdb_file2> <output_file>")
103-
sys.exit(1)
114+
parser = argparse.ArgumentParser(
115+
description="Calculate the rotation axis for a pair of PDB structures."
116+
)
117+
parser.add_argument("pdb_file1", help="First PDB file")
118+
parser.add_argument("pdb_file2", help="Second PDB file")
119+
parser.add_argument("output_file", help="Output file for rotation analysis")
120+
parser.add_argument(
121+
"--filter-ca-only",
122+
action="store_true",
123+
help="Filter to only CA (alpha carbon) atoms if atom counts don't match",
124+
)
125+
args = parser.parse_args()
104126

105-
pdb_file1 = sys.argv[1]
106-
pdb_file2 = sys.argv[2]
107-
output_file = sys.argv[3]
127+
pdb_file1 = args.pdb_file1
128+
pdb_file2 = args.pdb_file2
129+
output_file = args.output_file
130+
filter_ca_only = args.filter_ca_only
108131

109132
# Parse PDB files
110133
df1 = mmdf.read(pdb_file1)
111134
df2 = mmdf.read(pdb_file2)
112135

136+
# Check if atom counts match
137+
if len(df1) != len(df2):
138+
error_msg = (
139+
f"PDB files have different numbers of atoms:\n"
140+
f" {pdb_file1}: {len(df1)} atoms\n"
141+
f" {pdb_file2}: {len(df2)} atoms\n\n"
142+
"Rigid registration requires matching atom counts. "
143+
"If not all sidechains are modeled and/or additional ligands are "
144+
"present in one model, try rerunning with --filter-ca-only to "
145+
"filter to only CA (alpha carbon) atoms."
146+
)
147+
148+
if not filter_ca_only:
149+
raise ValueError(error_msg)
150+
151+
# Filter to CA atoms if flag is set
152+
warnings.warn(
153+
f"PDB files have different numbers of atoms: "
154+
f"{pdb_file1} has {len(df1)} atoms, "
155+
f"{pdb_file2} has {len(df2)} atoms. "
156+
"Filtering to CA (alpha carbon) atoms for alignment.",
157+
UserWarning,
158+
stacklevel=2,
159+
)
160+
161+
if "atom_name" not in df1.columns or "atom_name" not in df2.columns:
162+
raise ValueError(
163+
"Cannot filter atoms - 'atom_name' column not found in PDB data."
164+
)
165+
166+
df1_ca = df1[df1["atom_name"] == "CA"].copy()
167+
df2_ca = df2[df2["atom_name"] == "CA"].copy()
168+
169+
if len(df1_ca) == 0 or len(df2_ca) == 0:
170+
raise ValueError("No CA atoms found in one or both PDB files.")
171+
172+
if len(df1_ca) != len(df2_ca):
173+
raise ValueError(
174+
f"Even after filtering to CA atoms, counts don't match:\n"
175+
f" {pdb_file1}: {len(df1_ca)} CA atoms\n"
176+
f" {pdb_file2}: {len(df2_ca)} CA atoms\n\n"
177+
"Cannot perform rigid registration with mismatched atom counts."
178+
)
179+
180+
print(f"Using {len(df1_ca)} CA atoms for alignment.")
181+
df1 = df1_ca
182+
df2 = df2_ca
183+
113184
# Extract coordinates
114185
coords1 = torch.tensor(df1[["x", "y", "z"]].values, dtype=torch.float32)
115186
coords2 = torch.tensor(df2[["x", "y", "z"]].values, dtype=torch.float32)

programs/match_template/match_template_example_config.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ orientation_search_config:
3535
base_grid_method: uniform
3636
psi_step: 1.5 # in degrees
3737
theta_step: 2.5 # in degrees
38+
symmetry: C1 # Symmetry of structure being searched (default C1)
3839
preprocessing_filters:
3940
whitening_filter:
4041
enabled: true
@@ -47,6 +48,6 @@ preprocessing_filters:
4748
high_freq_cutoff: null # Both in terms of Nyquist frequency (e.g. low-pass to 3 Å @ 1.06 Å/px would be high_freq_cutoff=0.353)
4849
low_freq_cutoff: null
4950
computational_config:
50-
gpu_ids:
51+
gpu_ids: # Use "all" for all GPUs
5152
- 0
5253
num_cpus: 1

src/leopard_em/pydantic_models/data_structures/particle_stack.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -546,12 +546,8 @@ def construct_image_stack(
546546
f"indices ({len(indices)})."
547547
)
548548

549-
# Loop over each image and its corresponding indexes
550-
for i, indexes in enumerate(indices):
551-
img = images[i]
552-
# Get the positions as numpy arrays for indexing
553-
pos_y = self._df.loc[indexes, y_col].to_numpy()
554-
pos_x = self._df.loc[indexes, x_col].to_numpy()
549+
pos_y = self._df.loc[indexes, y_col].to_numpy().copy()
550+
pos_x = self._df.loc[indexes, x_col].to_numpy().copy()
555551

556552
# If the position reference is "center", shift (x, y) by half the original
557553
# template width/height so reference is now the top-left corner
@@ -739,7 +735,7 @@ def get_relative_defocus(
739735
else:
740736
rel_defocus_col = "refined_relative_defocus"
741737

742-
return torch.tensor(self._df[rel_defocus_col].to_numpy())
738+
return torch.tensor(self._df[rel_defocus_col].to_numpy().copy())
743739

744740
def get_absolute_defocus(
745741
self, prefer_refined_defocus: bool = True
@@ -764,8 +760,10 @@ def get_absolute_defocus(
764760
Angstroms.
765761
"""
766762
particle_defocus = self.get_relative_defocus(prefer_refined_defocus)
767-
defocus_u = torch.tensor(self._df["defocus_u"].to_numpy()) + particle_defocus
768-
defocus_v = torch.tensor(self._df["defocus_v"].to_numpy()) + particle_defocus
763+
defocus_u = torch.tensor(self._df["defocus_u"].to_numpy().copy())
764+
defocus_v = torch.tensor(self._df["defocus_v"].to_numpy().copy())
765+
defocus_u = defocus_u + particle_defocus
766+
defocus_v = defocus_v + particle_defocus
769767

770768
return defocus_u, defocus_v
771769

@@ -808,7 +806,7 @@ def get_pixel_size(
808806
else:
809807
pixel_size_col = "refined_pixel_size"
810808

811-
return torch.tensor(self._df[pixel_size_col].to_numpy())
809+
return torch.tensor(self._df[pixel_size_col].to_numpy().copy())
812810

813811
def get_euler_angles(self, prefer_refined_angles: bool = True) -> torch.Tensor:
814812
"""Return the Euler angles (phi, theta, psi) of all particles as a tensor.
@@ -844,9 +842,9 @@ def get_euler_angles(self, prefer_refined_angles: bool = True) -> torch.Tensor:
844842
psi_col = "refined_psi"
845843

846844
# Get the angles from the DataFrame
847-
phi = torch.tensor(self._df[phi_col].to_numpy())
848-
theta = torch.tensor(self._df[theta_col].to_numpy())
849-
psi = torch.tensor(self._df[psi_col].to_numpy())
845+
phi = torch.tensor(self._df[phi_col].to_numpy().copy())
846+
theta = torch.tensor(self._df[theta_col].to_numpy().copy())
847+
psi = torch.tensor(self._df[psi_col].to_numpy().copy())
850848

851849
return torch.stack((phi, theta, psi), dim=-1)
852850

0 commit comments

Comments
 (0)