Skip to content
Closed
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
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ typecheck:
check:
uv run pre-commit run --all-files

# Only the container-free SQLite tier for now; the pre-refactor tests are not
# yet ported (see `sqlite` marker in pyproject.toml).
test:
uv run pytest
uv run pytest -m sqlite

ci: check test

Expand Down
11 changes: 5 additions & 6 deletions hippo/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
import mrich
from django.conf import settings

from .ta_auth_connector import get_auth_target_access

# fix path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
Expand Down Expand Up @@ -80,11 +78,12 @@ def load_hippo(
mrich.bold('Creating HIPPO animal')
mrich.var('target_name', target_name, color='arg')

tas_list = get_auth_target_access(username)
# TODO: disabled because of STFC downtime on 03-07-2026. re-enable when done
# tas_list = get_auth_target_access(username)

if target_access_string not in tas_list:
mrich.error(f'User {username} does not have access to {target_access_string}')
return
# if target_access_string not in tas_list:
# mrich.error(f'User {username} does not have access to {target_access_string}')
# return

if db is None:
# populate from env
Expand Down
19 changes: 11 additions & 8 deletions hippo/designdb/animal.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@
'metadata_info',
)

# When True, HIPPO.__init__ downloads this target's apo_desolv protein PDBs from
# Fragalysis (see HIPPO._ensure_apo_desolv_files). Toggle this module-level flag
# to enable/disable download-on-init -- deliberately NOT read from the
# environment. Currently False because the Fragalysis download/auth services are
# down for maintenance; set True to re-enable.
DOWNLOAD_APO_DESOLV_ON_INIT = False


class HIPPO:
"""Entry-point class of the xchem-hippo package.
Expand Down Expand Up @@ -99,14 +106,10 @@ def __init__(
self._apo_desolv_path: Path | None = None
self._apo_desolv_downloaded_at: datetime | None = None

# Download policy: a first run downloads nothing here (the first add_hits
# fetches the full data). On re-instantiation (a persisted download already
# on disk) only the apo_desolv proteins are refreshed, so a new session has
# current PDBs.
target_dir = DOWNLOADS_DIR / project.project_name / target_name
if (target_dir / 'metadata.csv').is_file() and (
target_dir / 'aligned_files'
).is_dir():
# Optionally download this target's apo_desolv protein PDBs on init,
# gated only by the DOWNLOAD_APO_DESOLV_ON_INIT flag (no longer requires a
# previously downloaded aligned_files directory to be present).
if DOWNLOAD_APO_DESOLV_ON_INIT:
try:
self._ensure_apo_desolv_files(
auth_token=self._auth_token, stack=self._stack
Expand Down
821 changes: 821 additions & 0 deletions hippo/designdb/migrations/0001_initial.py

Large diffs are not rendered by default.

Empty file.
2 changes: 1 addition & 1 deletion hippo/designdb/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,7 @@ class Meta(BaseModel.Meta):


class CataloguePriceCompoundJunctionModel(BaseModel):
ipk = models.CompositePrimaryKey('compound_id', 'catalogue_price_id')
pk = models.CompositePrimaryKey('compound_id', 'catalogue_price_id')
catalogue_price = models.ForeignKey(
CataloguePriceModel,
on_delete=models.CASCADE,
Expand Down
27 changes: 17 additions & 10 deletions hippo/designdb/services/compound.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@
sanitise_smiles,
superparent,
)
from django.conf import settings

# from mypackage.services.compound import CompoundService
from rdkit import Chem

# from rdkit.Chem import inchi

from rdkit.Chem.inchi import MolToInchiKey

# from .validation.compound import ValidationError, validate_compound_data

Expand Down Expand Up @@ -59,15 +58,23 @@ def create(

h = registration_hash_tautomer_insensitive(sp)

defaults = {
'compound_smiles': smiles,
'rdkit_version': rdkit.__version__,
'inchi_version': Chem.inchi.GetInchiVersion(),
}

# In SQLite mode there is no cartridge, so populate compound_mol (CTAB)
# and compound_inchikey in Python. In Postgres the BEFORE INSERT trigger
# (populate_compound_cartridge_from_smiles) fills these from the cartridge
# and stays authoritative, so we leave them unset here.
if settings.MANAGE_MODELS:
defaults['compound_mol'] = Chem.MolToMolBlock(mol)
defaults['compound_inchikey'] = MolToInchiKey(mol)

compound, created = CompoundModel.objects.get_or_create(
compound_hash=h,
defaults={
# 'compound_mol': mol,
# 'compound_inchikey': inchikey,
'compound_smiles': smiles,
'rdkit_version': rdkit.__version__,
'inchi_version': Chem.inchi.GetInchiVersion(),
},
defaults=defaults,
)
if not created and logger.level == logging.DEBUG:
mrich.warning(f'Skipping compound {h}, duplicate of {compound.pk}')
Expand Down
13 changes: 9 additions & 4 deletions hippo/designdb/services/ingestion.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import os
import re
from dataclasses import dataclass
from pathlib import Path
Expand Down Expand Up @@ -93,18 +94,22 @@ def parse_pdb_mp(pdb_path: Path, residue: int, chain: str) -> str:
logger.debug('Reading %s', pdb_path)
pdb = mp.parse(pdb_path, verbosity=0)

# protein_link is stored relative to the current working directory (e.g.
# data/downloads/...) so the database stays portable across machines
rel_pdb = os.path.relpath(pdb_path)

# create the single ligand bound pdb
lig_residues = pdb.residues['LIG']
if len(lig_residues) > 1 or any(r.contains_alternative_sites for r in lig_residues):
pdb = remove_other_ligands(pdb, residue, chain)
pdb.prune_alternative_sites('A', verbosity=0)
pose_path = str(pdb_path.resolve()).replace('.pdb', '_hippo.pdb')
pose_path = rel_pdb.replace('.pdb', '_hippo.pdb')
# side effect: writes pdb into file
mp.write(
pose_path, pdb, shift_name=True, verbosity=logger.level == logging.DEBUG
)
else:
pose_path = str(pdb_path.resolve())
pose_path = rel_pdb

return pose_path

Expand Down Expand Up @@ -532,7 +537,7 @@ def ingest_sdf(
field_warning=field_warning,
)

pose_path = (output_directory / f'{r[name_col]}.fake.mol').resolve()
pose_path = os.path.relpath(output_directory / f'{r[name_col]}.fake.mol')
pose, pose_created = PoseService.create(
compound=compound,
target=target,
Expand Down Expand Up @@ -1165,7 +1170,7 @@ def ingest_syndirella_elabs(
pose_ids = []
scorer = ScoreService()
for _, row in ok.iterrows():
path = Path(row.path_to_mol).resolve()
path = Path(os.path.relpath(row.path_to_mol))
print('comp id in row', row[f'{num_steps}_product_compound_id'])

# closed for testing
Expand Down
18 changes: 12 additions & 6 deletions hippo/designdb/services/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ def from_compounds(
reactions on the fly
"""

from designdb.recipe import Route
from designdb.recipe import Recipe, Route

assert isinstance(compounds, CompoundSet)

Expand Down Expand Up @@ -425,11 +425,17 @@ def from_compounds(
if not combo:
continue

solution = combo[0]
for i, recipe in enumerate(combo[1:]):
if debug:
mrich.debug(i + 1)
solution += recipe
# Combine the whole combination in one pass. Repeated `solution +=
# recipe` was O(n^2) -- each Recipe.__add__ copies the growing sets and
# IngredientSet.add re-concats -- so batch-merge the underlying sets
# instead (see IngredientSet.sum_sets / ReactionSet.union).
solution = Recipe(
products=IngredientSet.sum_sets([r.products for r in combo]),
reactants=IngredientSet.sum_sets([r.reactants for r in combo]),
intermediates=IngredientSet.sum_sets([r.intermediates for r in combo]),
compounds=IngredientSet.sum_sets([r.compounds for r in combo]),
reactions=ReactionSet.union([r.reactions for r in combo]),
)

solutions.append(solution)
ok += 1
Expand Down
107 changes: 39 additions & 68 deletions hippo/designdb/sets/compound.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,21 @@ def __getitem__(
"""
match key:
case int():
index = self.indices[key]
try:
return CompoundModel.objects.get(id=index)
except CompoundModel.DoesNotExist as exc:
raise CompoundModel.DoesNotExist from exc
# index by position in the (ordered) set; support negative
# indices (e.g. cset[-1] -> last compound)
n = len(self)
idx = key + n if key < 0 else key
if not 0 <= idx < n:
raise IndexError(f'CompoundSet index out of range: {key}')
return self._queryset[idx]

case slice():
return CompoundSet(CompoundModel.objects.filter(pk__in=key))
# positional slice of the ordered members. Slice `.all()` (a
# fresh, unevaluated clone) so this returns a queryset (LIMIT/
# OFFSET) even when self._queryset is already evaluated -- an
# evaluated queryset would otherwise slice to a list of model
# instances. sort=False: a queryset can't be re-ordered once sliced.
return CompoundSet(self._queryset.all()[key], sort=False)

case _:
raise NotImplementedError
Expand All @@ -154,21 +161,20 @@ def __sub__(
multiple at once when ``other`` is a :class:`.CompoundSet` or
:class:`.IngredientSet`"""

# local import to avoid the IngredientSet <-> CompoundSet cycle
from designdb.sets.ingredient import IngredientSet

# materialise pks so set ops stay a single flat query instead of nesting
# pk__in subqueries, which recurses under repeated accumulation
ids = set(self._queryset.values_list('pk', flat=True))
match other:
case CompoundSet():
return CompoundSet(
CompoundModel.objects.filter(
Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset)
),
sort=False,
)
case CompoundSet() | IngredientSet():
ids -= set(other.ids)
case int():
return CompoundSet(
CompoundModel.objects.filter(
Q(pk__in=self._queryset) & ~Q(pk=other.pk)
),
sort=False,
)
ids.discard(other)
case _:
raise NotImplementedError
return CompoundSet(CompoundModel.objects.filter(pk__in=ids), sort=False)

def __add__(
self,
Expand All @@ -180,53 +186,27 @@ def __add__(
# local import to avoid the IngredientSet <-> CompoundSet cycle
from designdb.sets.ingredient import IngredientSet

# materialise pks: keep a single flat query, avoiding nested pk__in
# subqueries that recurse when accumulated (e.g. combining many recipes)
ids = set(self._queryset.values_list('pk', flat=True))
match other:
case CompoundModel():
return CompoundSet(
CompoundModel.objects.filter(
Q(pk__in=self._queryset) | Q(pk__in=other._queryset)
),
sort=False,
)

ids.add(other.pk)
case int():
return CompoundSet(
CompoundModel.objects.filter(
Q(pk__in=self._queryset) | Q(pk__in=other._queryset)
),
sort=False,
)

case CompoundSet():
return CompoundSet(
CompoundModel.objects.filter(
Q(pk__in=self._queryset) | Q(pk__in=other._queryset)
),
sort=False,
)

case IngredientSet():
return CompoundSet(
CompoundModel.objects.filter(
Q(pk__in=self._queryset) | Q(pk__in=other._queryset)
),
sort=False,
)

ids.add(other)
case CompoundSet() | IngredientSet():
ids |= set(other.ids)
case _:
raise NotImplementedError
return CompoundSet(CompoundModel.objects.filter(pk__in=ids), sort=False)

def __and__(self, other: 'CompoundSet'):
"""AND set operation, returns only compounds in both sets"""

match other:
case CompoundSet():
return CompoundSet(
CompoundModel.objects.filter(
Q(pk__in=self._queryset) & Q(pk__in=other.queryset)
),
sort=False,
)
ids = set(self._queryset.values_list('pk', flat=True)) & set(other.ids)
return CompoundSet(CompoundModel.objects.filter(pk__in=ids), sort=False)

case _:
raise NotImplementedError
Expand All @@ -236,12 +216,8 @@ def __or__(self, other: 'CompoundSet'):

match other:
case CompoundSet():
return CompoundSet(
CompoundModel.objects.filter(
Q(pk__in=self._queryset) | Q(pk__in=other.queryset)
),
sort=False,
)
ids = set(self._queryset.values_list('pk', flat=True)) | set(other.ids)
return CompoundSet(CompoundModel.objects.filter(pk__in=ids), sort=False)

case _:
raise NotImplementedError
Expand All @@ -252,13 +228,8 @@ def __xor__(self, other: 'CompoundSet'):

match other:
case CompoundSet():
return CompoundSet(
CompoundModel.objects.filter(
Q(Q(pk__in=self._queryset) | Q(pk__in=other.queryset))
& ~Q(Q(pk__in=self._queryset) & Q(pk__in=other.queryset))
),
sort=False,
)
ids = set(self._queryset.values_list('pk', flat=True)) ^ set(other.ids)
return CompoundSet(CompoundModel.objects.filter(pk__in=ids), sort=False)

case _:
raise NotImplementedError
Expand Down
Loading
Loading