Skip to content

Commit

Permalink
Squashed 'modules/core/dependency/python-ihm/' changes from 5c7c382a4…
Browse files Browse the repository at this point in the history
…1..64b600104c

64b600104c Test API against python-modelcif before release
10347c6a2b Link to GitHub issue
41e51f3b00 Prepare for 1.6 release
ffd5cc3e53 Note that modelcif Model is not a subclass
f044bc8f83 Don't require not_modeled list on output
cb4af616a2 Use Entity member for range-check control
dc6442c803 Preserve unknown auth_seq_num in poly_seq_scheme
1d312daec1 Move range-check control to Entity member
296ce06af2 Auto-add not-modeled residues in make_mmcif
36e9d7b7de Exclude non-modeled residues from scheme table
e2b1fd3dd0 Check non-modeled ranges for validity
51120d2e2b Support non-modeled residue ranges
4e8462b943 Add reminder of sources for COPR and PPA

git-subtree-dir: modules/core/dependency/python-ihm
git-subtree-split: 64b600104c9688d3a6bc279358f0fffcde516be1
  • Loading branch information
benmwebb committed Sep 27, 2024
1 parent cdee423 commit 7b6753f
Show file tree
Hide file tree
Showing 20 changed files with 736 additions and 63 deletions.
16 changes: 16 additions & 0 deletions modules/core/dependency/python-ihm/ChangeLog.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
1.6 - 2024-09-27
================
- The new class :class:`ihm.model.NotModeledResidueRange` allows for
the annotation of residue ranges that were explicitly not modeled.
These are written to the ``_ihm_residues_not_modeled`` mmCIF table,
and any residue marked as not-modeled in all models will also be
excluded from the ``pdbx_poly_seq_scheme`` table.
- The ``make_mmcif`` utility script will now automatically add any
missing :class:`ihm.model.NotModeledResidueRange` objects for
not-modeled residue ranges (#150).
- Bugfix: the residue range checks introduced in version 1.5 broke the
API used by python-modelcif. They have been reimplemented using the
original API.
- Bugfix: an unknown (?) value for ``pdbx_poly_seq_scheme.auth_seq_num``
is now preserved, not silently removed, when reading an mmCIF file.

1.5 - 2024-09-06
================
- Trying to create a :class:`ihm.Residue`, :class:`ihm.EntityRange`, or
Expand Down
2 changes: 1 addition & 1 deletion modules/core/dependency/python-ihm/MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ include examples/*
include util/make-mmcif.py
include src/ihm_format.h
include src/ihm_format.i
include src/ihm_format_wrap_1.5.c
include src/ihm_format_wrap_1.6.c
3 changes: 3 additions & 0 deletions modules/core/dependency/python-ihm/docs/model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ The :mod:`ihm.model` Python module
.. autoclass:: Ensemble
:members:

.. autoclass:: NotModeledResidueRange
:members:

.. autoclass:: OrderedProcess
:members:

Expand Down
36 changes: 18 additions & 18 deletions modules/core/dependency/python-ihm/ihm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import json
from . import util

__version__ = '1.5'
__version__ = '1.6'


class __UnknownValue(object):
Expand Down Expand Up @@ -1235,13 +1235,12 @@ class EntityRange(object):
entity = ihm.Entity(sequence=...)
rng = entity(4,7)
"""
def __init__(self, entity, seq_id_begin, seq_id_end, _check=True):
def __init__(self, entity, seq_id_begin, seq_id_end):
if not entity.is_polymeric():
raise TypeError("Can only create ranges for polymeric entities")
self.entity = entity
self.seq_id_range = (seq_id_begin, seq_id_end)
if _check:
util._check_residue_range(self)
util._check_residue_range(self.seq_id_range, self.entity)

def __eq__(self, other):
try:
Expand Down Expand Up @@ -1283,14 +1282,13 @@ class Residue(object):

__slots__ = ['entity', 'asym', 'seq_id', '_range_id']

def __init__(self, seq_id, entity=None, asym=None, _check=True):
def __init__(self, seq_id, entity=None, asym=None):
self.entity = entity
self.asym = asym
if entity is None and asym:
self.entity = asym.entity
self.seq_id = seq_id
if _check:
util._check_residue(self)
util._check_residue(self)

def atom(self, atom_id):
"""Get a :class:`~ihm.Atom` in this residue with the given name."""
Expand Down Expand Up @@ -1370,6 +1368,9 @@ class Entity(object):

_force_polymer = None
_hint_branched = None
# Set to False to allow invalid seq_ids for residue or residue_range;
# this is done, for example, when reading a file.
_range_check = True

def __get_type(self):
if self.is_polymeric():
Expand Down Expand Up @@ -1449,9 +1450,9 @@ def is_branched(self):
and isinstance(self.sequence[0], SaccharideChemComp)) or
(len(self.sequence) == 0 and self._hint_branched))

def residue(self, seq_id, _check=True):
def residue(self, seq_id):
"""Get a :class:`Residue` at the given sequence position"""
return Residue(entity=self, seq_id=seq_id, _check=_check)
return Residue(entity=self, seq_id=seq_id)

# Entities are considered identical if they have the same sequence,
# unless they are branched
Expand All @@ -1469,8 +1470,8 @@ def __hash__(self):
else:
return hash(self.sequence)

def __call__(self, seq_id_begin, seq_id_end, _check=True):
return EntityRange(self, seq_id_begin, seq_id_end, _check=_check)
def __call__(self, seq_id_begin, seq_id_end):
return EntityRange(self, seq_id_begin, seq_id_end)

def __get_seq_id_range(self):
if self.is_polymeric() or self.is_branched():
Expand All @@ -1489,13 +1490,12 @@ class AsymUnitRange(object):
asym = ihm.AsymUnit(entity)
rng = asym(4,7)
"""
def __init__(self, asym, seq_id_begin, seq_id_end, _check=True):
def __init__(self, asym, seq_id_begin, seq_id_end):
if asym.entity is not None and not asym.entity.is_polymeric():
raise TypeError("Can only create ranges for polymeric entities")
self.asym = asym
self.seq_id_range = (seq_id_begin, seq_id_end)
if _check:
util._check_residue_range(self)
util._check_residue_range(self.seq_id_range, self.entity)

def __eq__(self, other):
try:
Expand Down Expand Up @@ -1616,12 +1616,12 @@ def _get_pdb_auth_seq_id_ins_code(self, seq_id):
auth_seq_num = self.orig_auth_seq_id_map.get(seq_id, pdb_seq_num)
return pdb_seq_num, auth_seq_num, ins_code

def __call__(self, seq_id_begin, seq_id_end, _check=True):
return AsymUnitRange(self, seq_id_begin, seq_id_end, _check=_check)
def __call__(self, seq_id_begin, seq_id_end):
return AsymUnitRange(self, seq_id_begin, seq_id_end)

def residue(self, seq_id, _check=True):
def residue(self, seq_id):
"""Get a :class:`Residue` at the given sequence position"""
return Residue(asym=self, seq_id=seq_id, _check=_check)
return Residue(asym=self, seq_id=seq_id)

def segment(self, gapped_sequence, seq_id_begin, seq_id_end):
"""Get an object representing the alignment of part of this sequence.
Expand Down
91 changes: 79 additions & 12 deletions modules/core/dependency/python-ihm/ihm/dumper.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ def dump(self, system, writer):
for rng in self._ranges_by_id:
if hasattr(rng, 'entity'):
entity = rng.entity
util._check_residue_range(rng)
util._check_residue_range(rng.seq_id_range, entity)
else:
entity = rng
lp.write(
Expand Down Expand Up @@ -731,16 +731,59 @@ def dump(self, system, writer):
entity = asym.entity
if not entity.is_polymeric():
continue
for num, comp in enumerate(entity.sequence):
pdb_seq_num, auth_seq_num, ins = \
asym._get_pdb_auth_seq_id_ins_code(num + 1)
lp.write(asym_id=asym._id, pdb_strand_id=asym.strand_id,
entity_id=entity._id,
seq_id=num + 1, pdb_seq_num=pdb_seq_num,
auth_seq_num=auth_seq_num,
mon_id=comp.id, pdb_mon_id=comp.id,
auth_mon_id=comp.id,
pdb_ins_code=ins)
for start, end, modeled in self._get_ranges(system, asym):
for num in range(start, end + 1):
comp = entity.sequence[num - 1]
auth_comp_id = comp.id
pdb_seq_num, auth_seq_num, ins = \
asym._get_pdb_auth_seq_id_ins_code(num)
if not modeled:
# If a residue wasn't modeled, PDB convention is
# to state ? for auth_seq_num, pdb_mon_id,
# auth_mon_id.
# See, e.g., https://files.rcsb.org/view/8QB4.cif
auth_comp_id = ihm.unknown
auth_seq_num = ihm.unknown
elif auth_seq_num is ihm.unknown:
# If we don't know the seq num, we can't know
# the component ID either
auth_comp_id = ihm.unknown
lp.write(asym_id=asym._id,
pdb_strand_id=asym.strand_id,
entity_id=entity._id, seq_id=num,
pdb_seq_num=pdb_seq_num,
auth_seq_num=auth_seq_num, mon_id=comp.id,
pdb_mon_id=auth_comp_id,
auth_mon_id=auth_comp_id, pdb_ins_code=ins)

def _get_ranges(self, system, asym):
"""Get a list of (seq_id_begin, seq_id_end, modeled) residue ranges
for the given asym. The list is guaranteed to be sorted and to cover
all residues in the asym. `modeled` is True if no Model has any
residue in that range in a NotModeledResidueRange."""
_all_modeled = []
num_models = 0
for group, model in system._all_models():
num_models += 1
# Handle Model-like objects with no not-modeled member (e.g.
# older versions of python-modelcif)
if hasattr(model, 'not_modeled_residue_ranges'):
ranges = model.not_modeled_residue_ranges
else:
ranges = []
# Get a sorted non-overlapping list of all not-modeled ranges
_all_not_modeled = util._combine_ranges(
(rr.seq_id_begin, rr.seq_id_end)
for rr in ranges if rr.asym_unit is asym)
# Invert to get a list of modeled ranges for this model
_all_modeled.extend(util._invert_ranges(_all_not_modeled,
len(asym.entity.sequence)))
# If no models, there are no "not modeled residues", so say everything
# was modeled
if num_models == 0:
_all_modeled = [(1, len(asym.entity.sequence))]
return util._pred_ranges(util._combine_ranges(_all_modeled),
len(asym.entity.sequence))


class _NonPolySchemeDumper(Dumper):
Expand Down Expand Up @@ -1750,6 +1793,29 @@ def dump_spheres(self, system, writer):
rmsf=sphere.rmsf, model_id=model._id)


class _NotModeledResidueRangeDumper(Dumper):
def dump(self, system, writer):
ordinal = itertools.count(1)
with writer.loop("_ihm_residues_not_modeled",
["id", "model_id", "entity_description",
"entity_id", "asym_id", "seq_id_begin", "seq_id_end",
"comp_id_begin", "comp_id_end", "reason"]) as lp:
for group, model in system._all_models():
for rr in model.not_modeled_residue_ranges:
e = rr.asym_unit.entity
util._check_residue_range(
(rr.seq_id_begin, rr.seq_id_end), e)
lp.write(id=next(ordinal), model_id=model._id,
entity_description=e.description,
entity_id=e._id,
asym_id=rr.asym_unit._id,
seq_id_begin=rr.seq_id_begin,
seq_id_end=rr.seq_id_end,
comp_id_begin=e.sequence[rr.seq_id_begin - 1].id,
comp_id_end=e.sequence[rr.seq_id_end - 1].id,
reason=rr.reason)


class _EnsembleDumper(Dumper):
def finalize(self, system):
# Assign IDs
Expand Down Expand Up @@ -3677,7 +3743,8 @@ class IHMVariant(Variant):
_GeometricRestraintDumper, _DerivedDistanceRestraintDumper,
_HDXRestraintDumper,
_PredictedContactRestraintDumper, _EM3DDumper, _EM2DDumper, _SASDumper,
_ModelDumper, _EnsembleDumper, _DensityDumper, _MultiStateDumper,
_ModelDumper, _NotModeledResidueRangeDumper,
_EnsembleDumper, _DensityDumper, _MultiStateDumper,
_OrderedDumper,
_MultiStateSchemeDumper, _MultiStateSchemeConnectivityDumper,
_RelaxationTimeDumper, _KineticRateDumper]
Expand Down
33 changes: 32 additions & 1 deletion modules/core/dependency/python-ihm/ihm/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import struct
import itertools
from ihm.util import _text_choice_property
from ihm.util import _text_choice_property, _check_residue_range


class Sphere(object):
Expand Down Expand Up @@ -94,11 +94,18 @@ class Model(object):
:param str name: Descriptive name for this model.
"""
def __init__(self, assembly, protocol, representation, name=None):
# Note that a similar Model class is used in python-modelcif but it
# is not a subclass. So be careful when modifying this class to not
# break the API (e.g. by adding new members).
self.assembly, self.protocol = assembly, protocol
self.representation, self.name = representation, name
self._atoms = []
self._spheres = []

#: List of residue ranges that were explicitly not modeled. See
#: :class:`NotModeledResidueRange`.
self.not_modeled_residue_ranges = []

def get_spheres(self):
"""Yield :class:`Sphere` objects that represent this model.
Expand Down Expand Up @@ -263,6 +270,30 @@ def _get_num_deposited(self):
doc="The feature used for clustering the models, if applicable")


class NotModeledResidueRange(object):
"""A range of residues that were explicitly not modeled.
See :attr:`Model.not_modeled_residue_ranges`.
:param asym_unit: The asymmetric unit to which the residues belong.
:type asym_unit: :class:`~ihm.AsymUnit`
:param int seq_id_begin: Starting residue in the range.
:param int seq_id_end: Ending residue in the range.
:param str reason: Optional text describing why the residues were
not modeled.
"""
def __init__(self, asym_unit, seq_id_begin, seq_id_end, reason=None):
self.asym_unit = asym_unit
self.seq_id_begin, self.seq_id_end = seq_id_begin, seq_id_end
self.reason = reason
_check_residue_range((seq_id_begin, seq_id_end), asym_unit.entity)

reason = _text_choice_property(
"reason",
["Highly variable models with poor precision",
"Models do not adequately satisfy input data", "Other"],
doc="Reason why the residues were not modeled.")


class OrderedProcess(object):
"""Details about a process that orders two or more model groups.
Expand Down
Loading

0 comments on commit 7b6753f

Please sign in to comment.