Skip to content

Commit d0690ca

Browse files
committed
Support pdbx_poly_seq_scheme heterogeneous composition
The _pdbx_poly_seq_scheme table now requires a new ihm_model_id_list data item that lists all models that have coordinates for a particular residue. This is to enable PDB tools to fully support heterogeneous composition. Use our existing not-modeled-residue ranges to populate this data item on output.
1 parent a781095 commit d0690ca

5 files changed

Lines changed: 163 additions & 65 deletions

File tree

ihm/dumper.py

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -914,21 +914,22 @@ def dump(self, system, writer):
914914
["asym_id", "entity_id", "seq_id", "mon_id",
915915
"pdb_seq_num", "auth_seq_num", "pdb_mon_id",
916916
"auth_mon_id", "pdb_strand_id",
917-
"pdb_ins_code"]) as lp:
917+
"pdb_ins_code", "ihm_model_id_list"]) as lp:
918918
for asym in system.asym_units:
919919
entity = asym.entity
920920
if not entity.is_polymeric():
921921
continue
922-
for start, end, modeled in self._get_ranges(system, asym):
922+
for (start, end, modeled,
923+
model_ids) in self._get_ranges(system, asym):
923924
for num in range(start, end + 1):
924925
comp = entity.sequence[num - 1]
925926
auth_comp_id = comp.id
926927
pdb_seq_num, auth_seq_num, ins = \
927928
asym._get_pdb_auth_seq_id_ins_code(num)
928929
if not modeled:
929-
# If a residue wasn't modeled, PDB convention is
930-
# to state ? for auth_seq_num, pdb_mon_id,
931-
# auth_mon_id.
930+
# If a residue wasn't modeled in any models,
931+
# PDB convention is to state ? for auth_seq_num,
932+
# pdb_mon_id, auth_mon_id.
932933
# See, e.g., https://files.rcsb.org/view/8QB4.cif
933934
auth_comp_id = ihm.unknown
934935
auth_seq_num = ihm.unknown
@@ -942,13 +943,16 @@ def dump(self, system, writer):
942943
pdb_seq_num=pdb_seq_num,
943944
auth_seq_num=auth_seq_num, mon_id=comp.id,
944945
pdb_mon_id=auth_comp_id,
945-
auth_mon_id=auth_comp_id, pdb_ins_code=ins)
946+
auth_mon_id=auth_comp_id, pdb_ins_code=ins,
947+
ihm_model_id_list=model_ids)
946948

947949
def _get_ranges(self, system, asym):
948-
"""Get a list of (seq_id_begin, seq_id_end, modeled) residue ranges
949-
for the given asym. The list is guaranteed to be sorted and to cover
950-
all residues in the asym. `modeled` is True if no Model has any
951-
residue in that range in a NotModeledResidueRange."""
950+
"""Get a list of (seq_id_begin, seq_id_end, modeled, model_ids)
951+
residue ranges for the given asym. The list is guaranteed to be
952+
sorted and to cover all residues in the asym. `model_ids` is a list
953+
of all Models (as a comma-separated string of model IDs) that have
954+
no residue in that range in a NotModeledResidueRange, or None if
955+
the residues are not modeled in any Model."""
952956
_all_modeled = []
953957
num_models = 0
954958
for group, model in system._all_models():
@@ -964,14 +968,15 @@ def _get_ranges(self, system, asym):
964968
(rr.seq_id_begin, rr.seq_id_end)
965969
for rr in ranges if rr.asym_unit is asym)
966970
# Invert to get a list of modeled ranges for this model
967-
_all_modeled.extend(util._invert_ranges(_all_not_modeled,
968-
len(asym.entity.sequence)))
969-
# If no models, there are no "not modeled residues", so say everything
970-
# was modeled
971+
for rng in util._invert_ranges(_all_not_modeled,
972+
len(asym.entity.sequence)):
973+
_all_modeled.append((rng[0], rng[1], model._id))
974+
# If no models, there are no explicitly "not modeled residues",
975+
# so say everything was modeled (even though there are no model IDs)
971976
if num_models == 0:
972-
_all_modeled = [(1, len(asym.entity.sequence))]
973-
return util._pred_ranges(util._combine_ranges(_all_modeled),
974-
len(asym.entity.sequence))
977+
return [(1, len(asym.entity.sequence), True, None)]
978+
return util._pred_id_ranges(util._combine_id_ranges(_all_modeled),
979+
len(asym.entity.sequence))
975980

976981

977982
class _NonPolySchemeDumper(Dumper):

ihm/util/__init__.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -223,20 +223,22 @@ def _invert_ranges(ranges, end, start=1):
223223
yield (start, end)
224224

225225

226-
def _pred_ranges(ranges, end):
227-
"""Given a sorted list of non-overlapping ranges, yield a new list which
228-
covers the range 1-end. Each element in the new list contains a new
229-
third bool member which is True iff the element was in the original
230-
list. For example, if end=4,
231-
[(2, 3)] -> [(1, 1, False), (2, 3, True), (4, 4, False)]"""
226+
def _pred_id_ranges(id_ranges, end):
227+
"""Given a sorted list of ranges with IDs, yield a new list which covers
228+
the range 1-end. The IDs in the new list are the IDs from the original
229+
list, or None. In addition, a new bool item is returned, True only if
230+
there is at least one model ID.
231+
For example, if end=4,
232+
[(2, 3, 'x')] -> [(1, 1, False, None), (2, 3, True, 'x'),
233+
(4, 4, False, None)]"""
232234
start = 1
233-
for r in ranges:
235+
for r in id_ranges:
234236
if r[0] > start:
235-
yield (start, r[0] - 1, False)
236-
yield (r[0], r[1], True)
237+
yield (start, r[0] - 1, False, None)
238+
yield (r[0], r[1], True, r[2])
237239
start = r[1] + 1
238240
if end >= start:
239-
yield (start, end, False)
241+
yield (start, end, False, None)
240242

241243

242244
def _combine_ranges(ranges):
@@ -255,6 +257,58 @@ def _combine_ranges(ranges):
255257
yield current
256258

257259

260+
def _sorted_nonoverlap_id_ranges(id_ranges):
261+
"""Sort the input ranges with IDs and remove any overlaps with the
262+
same ID; yield the result.
263+
For example, [(8, 10, 'x'), (1, 2, 'y'), (3, 4, 'y'), (5, 6, 'z')]
264+
-> [(1, 4, 'y'), (5, 6, 'z'), (8, 10, 'x')]"""
265+
id_ranges = sorted(id_ranges)
266+
if not id_ranges:
267+
return
268+
current = id_ranges[0]
269+
for r in id_ranges[1:]:
270+
if current[1] + 1 >= r[0] and r[2] == current[2]:
271+
current = (current[0], max(r[1], current[1]), current[2])
272+
else:
273+
yield current
274+
current = r
275+
yield current
276+
277+
278+
def _combine_id_ranges(id_ranges):
279+
"""Sort the input ranges with IDs into non-overlapping ranges per ID;
280+
yield the result.
281+
For example, [(8, 10, 'x'), (1, 2, 'y'), (3, 4, 'z'), (2, 3, 'z')]
282+
-> [(1, 1, 'y'), (2, 2, 'y,z'), (3, 4, 'z'),
283+
(8, 10, 'x')]"""
284+
id_ranges = list(_sorted_nonoverlap_id_ranges(id_ranges))
285+
if not id_ranges:
286+
return
287+
yield_end = 0
288+
for i_r, r in enumerate(id_ranges):
289+
start, end, r_id = r
290+
start = max(start, yield_end + 1)
291+
# Get subranges of r and their IDs
292+
while start <= end:
293+
ids = set([r_id])
294+
new_end = end
295+
for r2 in id_ranges[i_r + 1:]:
296+
start2, end2, id2 = r2
297+
# If r2 has same start as r1, adjust end to cover overlap
298+
if start2 <= start:
299+
if end2 >= start:
300+
new_end = min(new_end, end2)
301+
ids.add(id2)
302+
# If only partial overlap, exclude this range; we will
303+
# look at it again later
304+
elif start2 <= new_end:
305+
new_end = start2 - 1
306+
# Otherwise, no overlap
307+
yield start, new_end, ",".join("%s" % d for d in sorted(ids))
308+
yield_end = new_end
309+
start = new_end + 1
310+
311+
258312
def _make_range_from_list(rr):
259313
"""Yield a list of ranges given a sorted list of values.
260314
For example, [1, 2, 5, 6] -> [[1, 2], [5, 6]]"""

test/test_dumper.py

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,17 +1303,18 @@ def test_poly_seq_scheme_dumper(self):
13031303
_pdbx_poly_seq_scheme.auth_mon_id
13041304
_pdbx_poly_seq_scheme.pdb_strand_id
13051305
_pdbx_poly_seq_scheme.pdb_ins_code
1306-
A 1 1 ALA 1 1 ALA ALA A .
1307-
A 1 2 CYS 2 2 CYS CYS A .
1308-
A 1 3 GLY 3 3 GLY GLY A .
1309-
A 1 4 THR 4 4 THR THR A .
1310-
B 2 1 ALA 6 6 ALA ALA B .
1311-
B 2 2 CYS 7 7 CYS CYS B .
1312-
B 2 3 CYS 8 8 CYS CYS B .
1313-
C 3 1 A 1 1 A A C .
1314-
C 3 2 C 2 2 C C C .
1315-
D 4 1 DA 1 1 DA DA X A
1316-
D 4 2 DC 1 1 DC DC X B
1306+
_pdbx_poly_seq_scheme.ihm_model_id_list
1307+
A 1 1 ALA 1 1 ALA ALA A . .
1308+
A 1 2 CYS 2 2 CYS CYS A . .
1309+
A 1 3 GLY 3 3 GLY GLY A . .
1310+
A 1 4 THR 4 4 THR THR A . .
1311+
B 2 1 ALA 6 6 ALA ALA B . .
1312+
B 2 2 CYS 7 7 CYS CYS B . .
1313+
B 2 3 CYS 8 8 CYS CYS B . .
1314+
C 3 1 A 1 1 A A C . .
1315+
C 3 2 C 2 2 C C C . .
1316+
D 4 1 DA 1 1 DA DA X A .
1317+
D 4 2 DC 1 1 DC DC X B .
13171318
#
13181319
""")
13191320

@@ -1344,29 +1345,33 @@ def test_poly_seq_scheme_unknown_auth_seq(self):
13441345
_pdbx_poly_seq_scheme.auth_mon_id
13451346
_pdbx_poly_seq_scheme.pdb_strand_id
13461347
_pdbx_poly_seq_scheme.pdb_ins_code
1347-
A 1 1 ALA 1 3 ALA ALA A .
1348-
A 1 2 CYS 2 4 CYS CYS A .
1349-
A 1 3 GLY 3 ? ? ? A .
1350-
A 1 4 THR 4 6 THR THR A .
1348+
_pdbx_poly_seq_scheme.ihm_model_id_list
1349+
A 1 1 ALA 1 3 ALA ALA A . .
1350+
A 1 2 CYS 2 4 CYS CYS A . .
1351+
A 1 3 GLY 3 ? ? ? A . .
1352+
A 1 4 THR 4 6 THR THR A . .
13511353
#
13521354
""")
13531355

13541356
def test_poly_seq_scheme_dumper_not_modeled(self):
13551357
"""Test PolySeqSchemeDumper with not-modeled residues"""
13561358
system, m1, asym = self._make_test_model()
1359+
m1._id = 1
13571360
del asym.entity._id
13581361
rr = ihm.model.NotModeledResidueRange(asym, 1, 2)
13591362
m1.not_modeled_residue_ranges.append(rr)
13601363

13611364
m2 = ihm.model.Model(assembly=m1.assembly, protocol=m1.protocol,
13621365
representation=m1.representation,
13631366
name='2nd test model')
1367+
m2._id = 2
13641368
rr = ihm.model.NotModeledResidueRange(asym, 2, 4)
13651369
m2.not_modeled_residue_ranges.append(rr)
13661370

13671371
m3 = ihm.model.Model(assembly=m1.assembly, protocol=m1.protocol,
13681372
representation=m1.representation,
13691373
name='3rd test model')
1374+
m3._id = 3
13701375
rr = ihm.model.NotModeledResidueRange(asym, 2, 3)
13711376
m3.not_modeled_residue_ranges.append(rr)
13721377

@@ -1390,10 +1395,11 @@ def test_poly_seq_scheme_dumper_not_modeled(self):
13901395
_pdbx_poly_seq_scheme.auth_mon_id
13911396
_pdbx_poly_seq_scheme.pdb_strand_id
13921397
_pdbx_poly_seq_scheme.pdb_ins_code
1393-
A 1 1 ALA 1 1 ALA ALA A .
1394-
A 1 2 CYS 2 ? ? ? A .
1395-
A 1 3 GLY 3 3 GLY GLY A .
1396-
A 1 4 THR 4 4 THR THR A .
1398+
_pdbx_poly_seq_scheme.ihm_model_id_list
1399+
A 1 1 ALA 1 1 ALA ALA A . 2,3
1400+
A 1 2 CYS 2 ? ? ? A . .
1401+
A 1 3 GLY 3 3 GLY GLY A . 1
1402+
A 1 4 THR 4 4 THR THR A . 1,3
13971403
#
13981404
""")
13991405

@@ -1404,6 +1410,7 @@ def test_poly_seq_scheme_dumper_no_not_modeled(self):
14041410
system, m1, asym = self._make_test_model()
14051411
del asym.entity._id
14061412
del m1.not_modeled_residue_ranges
1413+
m1._id = 1
14071414

14081415
mg = system.state_groups[0][0][0]
14091416
mg.append(m1)
@@ -1425,10 +1432,11 @@ def test_poly_seq_scheme_dumper_no_not_modeled(self):
14251432
_pdbx_poly_seq_scheme.auth_mon_id
14261433
_pdbx_poly_seq_scheme.pdb_strand_id
14271434
_pdbx_poly_seq_scheme.pdb_ins_code
1428-
A 1 1 ALA 1 1 ALA ALA A .
1429-
A 1 2 CYS 2 2 CYS CYS A .
1430-
A 1 3 GLY 3 3 GLY GLY A .
1431-
A 1 4 THR 4 4 THR THR A .
1435+
_pdbx_poly_seq_scheme.ihm_model_id_list
1436+
A 1 1 ALA 1 1 ALA ALA A . 1
1437+
A 1 2 CYS 2 2 CYS CYS A . 1
1438+
A 1 3 GLY 3 3 GLY GLY A . 1
1439+
A 1 4 THR 4 4 THR THR A . 1
14321440
#
14331441
""")
14341442

test/test_examples.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def test_simple_docking_example(self):
4141
# can read it
4242
with open(os.path.join(tmpdir, 'output.cif')) as fh:
4343
contents = fh.readlines()
44-
self.assertEqual(len(contents), 321)
44+
self.assertEqual(len(contents), 322)
4545
with open(os.path.join(tmpdir, 'output.cif')) as fh:
4646
s, = ihm.reader.read(fh)
4747

@@ -70,7 +70,7 @@ def test_ligands_water_example(self):
7070
# can read it
7171
with open(out) as fh:
7272
contents = fh.readlines()
73-
self.assertEqual(len(contents), 255)
73+
self.assertEqual(len(contents), 256)
7474
with open(out) as fh:
7575
s, = ihm.reader.read(fh)
7676
# Make sure that resulting Python objects are picklable

test/test_util.py

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -199,19 +199,20 @@ def test_invert_ranges(self):
199199
self.assertEqual(list(ihm.util._invert_ranges(inrng, 4, start=1)),
200200
[(1, 4)])
201201

202-
def test_pred_ranges(self):
203-
"""Test _pred_ranges function"""
204-
inrng = [(2, 3)]
205-
self.assertEqual(list(ihm.util._pred_ranges(inrng, 4)),
206-
[(1, 1, False), (2, 3, True), (4, 4, False)])
207-
inrng = [(1, 1), (4, 7)]
208-
self.assertEqual(list(ihm.util._pred_ranges(inrng, 8)),
209-
[(1, 1, True), (2, 3, False), (4, 7, True),
210-
(8, 8, False)])
211-
inrng = [(2, 2), (4, 7)]
212-
self.assertEqual(list(ihm.util._pred_ranges(inrng, 7)),
213-
[(1, 1, False), (2, 2, True), (3, 3, False),
214-
(4, 7, True)])
202+
def test_pred_id_ranges(self):
203+
"""Test _pred_id_ranges function"""
204+
inrng = [(2, 3, 'a')]
205+
self.assertEqual(list(ihm.util._pred_id_ranges(inrng, 4)),
206+
[(1, 1, False, None), (2, 3, True, 'a'),
207+
(4, 4, False, None)])
208+
inrng = [(1, 1, 'a'), (4, 7, 'b')]
209+
self.assertEqual(list(ihm.util._pred_id_ranges(inrng, 8)),
210+
[(1, 1, True, 'a'), (2, 3, False, None),
211+
(4, 7, True, 'b'), (8, 8, False, None)])
212+
inrng = [(2, 2, 'a'), (4, 7, 'b')]
213+
self.assertEqual(list(ihm.util._pred_id_ranges(inrng, 7)),
214+
[(1, 1, False, None), (2, 2, True, 'a'),
215+
(3, 3, False, None), (4, 7, True, 'b')])
215216

216217
def test_combine_ranges(self):
217218
"""Test _combine_ranges function"""
@@ -227,6 +228,36 @@ def test_combine_ranges(self):
227228
[(1, 2), (4, 4)])
228229
self.assertEqual(list(ihm.util._combine_ranges([])), [])
229230

231+
def test_sorted_nonoverlap_id_ranges(self):
232+
"""Test _sorted_nonoverlap_id_ranges function"""
233+
inrng = [(8, 10, 'a'), (1, 2, 'a'), (3, 4, 'a')]
234+
self.assertEqual(list(ihm.util._sorted_nonoverlap_id_ranges(inrng)),
235+
[(1, 4, 'a'), (8, 10, 'a')])
236+
inrng = [(8, 10, 'a'), (1, 2, 'a'), (3, 4, 'b')]
237+
self.assertEqual(list(ihm.util._sorted_nonoverlap_id_ranges(inrng)),
238+
[(1, 2, 'a'), (3, 4, 'b'), (8, 10, 'a')])
239+
inrng = [(1, 10, 'a'), (3, 4, 'a')]
240+
self.assertEqual(list(ihm.util._sorted_nonoverlap_id_ranges(inrng)),
241+
[(1, 10, 'a')])
242+
inrng = [(1, 10, 'a'), (3, 4, 'b')]
243+
self.assertEqual(list(ihm.util._sorted_nonoverlap_id_ranges(inrng)),
244+
[(1, 10, 'a'), (3, 4, 'b')])
245+
self.assertEqual(list(ihm.util._sorted_nonoverlap_id_ranges([])), [])
246+
247+
def test_combine_id_ranges(self):
248+
"""Test _combine_id_ranges function"""
249+
inrng = [(1, 30, 'd'), (1, 50, 'a'), (1, 60, 'c'), (2, 40, 'b'),
250+
(80, 100, 'e')]
251+
self.assertEqual(list(ihm.util._combine_id_ranges(inrng)),
252+
[(1, 1, 'a,c,d'), (2, 30, 'a,b,c,d'),
253+
(31, 40, 'a,b,c'), (41, 50, 'a,c'),
254+
(51, 60, 'c'), (80, 100, 'e')])
255+
inrng = [(8, 10, 'x'), (1, 2, 'y'), (3, 4, 'z'), (2, 3, 'z')]
256+
self.assertEqual(list(ihm.util._combine_id_ranges(inrng)),
257+
[(1, 1, 'y'), (2, 2, 'y,z'),
258+
(3, 4, 'z'), (8, 10, 'x')])
259+
self.assertEqual(list(ihm.util._combine_id_ranges([])), [])
260+
230261
def test_make_range_from_list(self):
231262
"""Test _make_range_from_list function"""
232263
rr = []

0 commit comments

Comments
 (0)