Skip to content

Functions

Auto-generated from docstrings. Only the public API of each package (what's exported via its __init__.py) is shown.

pandora.ingestion

fetch_list_mmcif(entry_ids, provider, output_dir, source_uri=None, fetch_options=None)

Fetch a list of raw mmCIF files from a provider URL, write them to output_dir, return provenance for each.

Calls fetch_mmcif() once per entry ID. If fetch_options.allow_partial is True, entries that fail to fetch (RuntimeError or ValueError) are skipped instead of aborting the whole batch.

Parameters:

Name Type Description Default
entry_ids list[str]

The PDB/PDBe entry identifiers to fetch.

required
provider str

One of "pdbe" or "pdb", used to build the download URL when source_uri is not given.

required
source_uri str | None

A URL/path template overriding provider's default URL template, applied to every entry. Must contain an {id} placeholder (same convention as _PROVIDER_URLS), formatted per entry_id — e.g. "https://example.org/mirror/{id}.cif".

None
output_dir Path

Directory to write the downloaded (or cached) files into; created if missing.

required
fetch_options FetchOptions | None

Caching/decompression options, including allow_partial. Defaults to FetchOptions() when not given.

None

Returns:

Type Description
list[IngestionProvenance]

A list of IngestionProvenance, one per successfully fetched

list[IngestionProvenance]

entry, in the same order as entry_ids (with failed entries

list[IngestionProvenance]

omitted when allow_partial is True).

Raises:

Type Description
ValueError

If provider is invalid and no source_uri was given, and allow_partial is False.

RuntimeError

If a fetch fails and allow_partial is False.

Source code in pandora/ingestion/mmcif.py
def fetch_list_mmcif(
    entry_ids: list[str],
    provider: str,
    output_dir: Path,
    source_uri: str | None = None,
    fetch_options: FetchOptions | None = None,
) -> list[IngestionProvenance]:
    """Fetch a list of raw mmCIF files from a provider URL, write them to
    output_dir, return provenance for each.

    Calls `fetch_mmcif()` once per entry ID. If
    `fetch_options.allow_partial` is True, entries that fail to fetch
    (`RuntimeError` or `ValueError`) are skipped instead of aborting
    the whole batch.

    Args:
        entry_ids: The PDB/PDBe entry identifiers to fetch.
        provider: One of "pdbe" or "pdb", used to build the download
            URL when `source_uri` is not given.
        source_uri: A URL/path template overriding `provider`'s default
            URL template, applied to every entry. Must contain an
            `{id}` placeholder (same convention as `_PROVIDER_URLS`),
            formatted per entry_id — e.g.
            `"https://example.org/mirror/{id}.cif"`.
        output_dir: Directory to write the downloaded (or cached)
            files into; created if missing.
        fetch_options: Caching/decompression options, including
            `allow_partial`. Defaults to `FetchOptions()` when not
            given.

    Returns:
        A list of `IngestionProvenance`, one per successfully fetched
        entry, in the same order as `entry_ids` (with failed entries
        omitted when `allow_partial` is True).

    Raises:
        ValueError: If `provider` is invalid and no `source_uri` was
            given, and `allow_partial` is False.
        RuntimeError: If a fetch fails and `allow_partial` is False.
    """

    fetch_options = fetch_options or FetchOptions()

    provenance_list = []
    for entry_id in entry_ids:
        try:
            provenance = fetch_mmcif(
                entry_id=entry_id,
                provider=provider,
                source_uri=(
                    source_uri.format(id=entry_id) if source_uri else None
                ),
                output_dir=output_dir,
                fetch_options=fetch_options,
            )
        except (RuntimeError, ValueError):
            if fetch_options.allow_partial:
                continue
            raise
        provenance_list.append(provenance)

    return provenance_list

fetch_mmcif(entry_id, provider, source_uri, output_dir, fetch_options=None)

Fetch a raw mmCIF file from a provider URL, write it to output_dir, return provenance.

Resolves the download URL from source_uri or from provider ("pdbe"/"pdb"), reuses a cached file when one is fresh (per fetch_options), otherwise downloads it, optionally decompresses it, and writes it to output_dir.

Parameters:

Name Type Description Default
entry_id str

The PDB/PDBe entry identifier (e.g. "1abc").

required
provider str

One of "pdbe" or "pdb", used to build the download URL when source_uri is not given.

required
source_uri str | None

An explicit URL to fetch from, overriding provider's default URL template.

required
output_dir Path

Directory to write the downloaded (or cached) file into; created if missing.

required
fetch_options FetchOptions | None

Caching/decompression options. Defaults to FetchOptions() when not given.

None

Returns:

Type Description
IngestionProvenance

IngestionProvenance describing the provider, source URL,

IngestionProvenance

retrieval timestamp, whether the result came from cache, and

IngestionProvenance

the entry's own revision date where the provider exposes one.

Raises:

Type Description
ValueError

If provider is not "pdbe"/"pdb" and no source_uri was given.

RuntimeError

If the HTTP request fails, or the response cannot be decompressed/decoded.

Source code in pandora/ingestion/mmcif.py
def fetch_mmcif(
    entry_id: str,
    provider: str,
    source_uri: str | None,
    output_dir: Path,
    fetch_options: FetchOptions | None = None,
) -> IngestionProvenance:
    """Fetch a raw mmCIF file from a provider URL, write it to
    output_dir, return provenance.

    Resolves the download URL from `source_uri` or from `provider`
    ("pdbe"/"pdb"), reuses a cached file when one is fresh (per
    `fetch_options`), otherwise downloads it, optionally decompresses
    it, and writes it to `output_dir`.

    Args:
        entry_id: The PDB/PDBe entry identifier (e.g. "1abc").
        provider: One of "pdbe" or "pdb", used to build the download
            URL when `source_uri` is not given.
        source_uri: An explicit URL to fetch from, overriding
            `provider`'s default URL template.
        output_dir: Directory to write the downloaded (or cached) file
            into; created if missing.
        fetch_options: Caching/decompression options. Defaults
            to `FetchOptions()` when not given.

    Returns:
        `IngestionProvenance` describing the provider, source URL,
        retrieval timestamp, whether the result came from cache, and
        the entry's own revision date where the provider exposes one.

    Raises:
        ValueError: If `provider` is not "pdbe"/"pdb" and no
            `source_uri` was given.
        RuntimeError: If the HTTP request fails, or the response
            cannot be decompressed/decoded.
    """

    fetch_options = fetch_options or FetchOptions()

    if source_uri:
        url = source_uri
    elif provider in _PROVIDER_URLS:
        fmt_id = entry_id.lower() if provider == "pdbe" else entry_id.upper()
        url = _PROVIDER_URLS[provider].format(id=fmt_id)
    else:
        raise ValueError(
            f"provider={provider!r} requires an explicit source_uri "
            f"or one of 'pdbe' or 'pdb'"
        )

    if fetch_options.use_cache:
        cached = resolve_cache_hit(
            entry_id=entry_id,
            output_dir=output_dir,
            max_age_seconds=fetch_options.max_age_seconds,
            stale_behavior=fetch_options.stale_behavior,
        )
        if cached is not None:
            return IngestionProvenance(
                provider=provider,
                source_uri=url,
                retrieved_at=mtime_iso(cached),
                from_cache=True,
                revision_date=(
                    _revision_date_from_cif_file(cached)
                    if provider == "pdb"
                    else None
                ),
            )

    httpx = require("httpx", "ingestion")
    try:
        resp = httpx.get(url, follow_redirects=True, timeout=60.0)
        resp.raise_for_status()
    except httpx.HTTPStatusError as exc:
        raise RuntimeError(
            f"HTTP {exc.response.status_code} fetching {url}"
        ) from exc
    except httpx.RequestError as exc:
        raise RuntimeError(f"Network error fetching {url}: {exc}") from exc

    raw_bytes = resp.content
    is_gzipped = url.endswith(".gz") or raw_bytes[:2] == b"\x1f\x8b"

    if is_gzipped and fetch_options.decompress:
        try:
            raw_bytes = gzip.decompress(raw_bytes)
        except gzip.BadGzipFile as exc:
            raise RuntimeError(
                f"Failed to decompress response from {url}"
            ) from exc
        is_gzipped = False

    output_dir.mkdir(parents=True, exist_ok=True)

    if is_gzipped:
        out_path = output_dir / f"{entry_id.lower()}.cif.gz"
        out_path.write_bytes(raw_bytes)
    else:
        try:
            content = raw_bytes.decode("utf-8")
        except UnicodeDecodeError as exc:
            decompressed = "decompressed " if fetch_options.decompress else ""
            raise RuntimeError(
                f"Failed to decode {decompressed}response from {url} as UTF-8"
            ) from exc
        out_path = output_dir / f"{entry_id.lower()}.cif"
        out_path.write_text(content, encoding="utf-8")

    if provider == "pdb":
        revision_date = _revision_date_from_cif_file(out_path)
    elif provider == "pdbe":
        revision_date = _pdbe_revision_date(entry_id)
    else:
        revision_date = None

    return IngestionProvenance(
        provider=provider,
        source_uri=url,
        retrieved_at=now_iso(),
        from_cache=False,
        revision_date=revision_date,
    )

ingest_local_mmcif(path, source_uri=None)

Describe an already-downloaded mmCIF file as provenance, without fetching or copying anything.

For a file that arrived via some external bulk source — a local rsync mirror of PDB, an archived dated snapshot, a lab file server — rather than fetch_mmcif(). Pandora has no bulk-download step of its own; this just lets such a file's origin be recorded the same way a live fetch's is, so it can still be attached to a ProvenanceBundle.

Parameters:

Name Type Description Default
path Path

Path to the existing mmCIF file on disk. Not modified or copied.

required
source_uri str | None

A label for where this file came from (e.g. "pdb_snapshot_2024-01-29"), stored verbatim. Defaults to str(path) when not given.

None

Returns:

Type Description
IngestionProvenance

IngestionProvenance with provider="local", from_cache=False

IngestionProvenance

(this isn't Pandora's own fetch cache), the file's own mtime as

IngestionProvenance

retrieved_at, and revision_date read from the file's

IngestionProvenance

_pdbx_audit_revision_history category when present.

Raises:

Type Description
ValueError

If path doesn't exist.

Source code in pandora/ingestion/mmcif.py
def ingest_local_mmcif(
    path: Path,
    source_uri: str | None = None,
) -> IngestionProvenance:
    """Describe an already-downloaded mmCIF file as provenance, without
    fetching or copying anything.

    For a file that arrived via some external bulk source — a local
    rsync mirror of PDB, an archived dated snapshot, a lab file server —
    rather than `fetch_mmcif()`. Pandora has no bulk-download step of its
    own; this just lets such a file's origin be recorded the same way a
    live fetch's is, so it can still be attached to a `ProvenanceBundle`.

    Args:
        path: Path to the existing mmCIF file on disk. Not modified or
            copied.
        source_uri: A label for where this file came from (e.g.
            "pdb_snapshot_2024-01-29"), stored verbatim. Defaults to
            `str(path)` when not given.

    Returns:
        `IngestionProvenance` with `provider="local"`, `from_cache=False`
        (this isn't Pandora's own fetch cache), the file's own mtime as
        `retrieved_at`, and `revision_date` read from the file's
        `_pdbx_audit_revision_history` category when present.

    Raises:
        ValueError: If `path` doesn't exist.
    """

    if not path.exists():
        raise ValueError(f"path={path} does not exist")

    return IngestionProvenance(
        provider="local",
        source_uri=source_uri or str(path),
        retrieved_at=mtime_iso(path),
        from_cache=False,
        revision_date=_revision_date_from_cif_file(path),
    )

load_policy(path)

Load a canonicalisationPolicy from a YAML file.

Parameters:

Name Type Description Default
path str

Path to the policy YAML file.

required

Returns:

Type Description
canonicalisationPolicy

The parsed, validated canonicalisationPolicy.

Source code in pandora/ingestion/policy.py
def load_policy(path: str) -> canonicalisationPolicy:
    """Load a `canonicalisationPolicy` from a YAML file.

    Args:
        path: Path to the policy YAML file.

    Returns:
        The parsed, validated `canonicalisationPolicy`.
    """

    with open(path) as stream:
        data = yaml.safe_load(stream)

    return canonicalisationPolicy.model_validate(data)

search_pdbe(query, rows=100, start=0)

Search PDBe's Solr-based search API for matching entry ids.

query is a raw Solr/Lucene query string, passed through verbatim as the q parameter — Pandora doesn't validate or translate it. See https://www.ebi.ac.uk/pdbe/api/doc/search.html for the query syntax and the full list of searchable fields. The returned ids feed directly into fetch_mmcif/fetch_list_mmcif with provider="pdbe".

Unlike RCSB's search API, PDBe's Solr endpoint doesn't reject a malformed query — an unparseable clause is silently dropped rather than erroring, which can make a typo'd query match far more than intended. Sanity-check the returned count against what you expect.

Parameters:

Name Type Description Default
query str

A Solr query string, e.g. 'experimental_method:"X-ray diffraction" AND resolution:[0 TO 1.5]'.

required
rows int

Maximum number of ids to return in this page.

100
start int

Offset into the full result set, for paging through more than rows matches.

0

Returns:

Type Description
list[str]

Matching PDB ids, lowercase (PDBe's own convention), deduplicated

list[str]

— PDBe's Solr index can list the same id more than once (e.g. one

list[str]

row per assembly/entity). Empty list if nothing matches.

Raises:

Type Description
RuntimeError

If the request fails.

Source code in pandora/ingestion/search.py
def search_pdbe(
    query: str,
    rows: int = 100,
    start: int = 0,
) -> list[str]:
    """Search PDBe's Solr-based search API for matching entry ids.

    `query` is a raw Solr/Lucene query string, passed through verbatim as
    the `q` parameter — Pandora doesn't validate or translate it. See
    https://www.ebi.ac.uk/pdbe/api/doc/search.html for the query syntax
    and the full list of searchable fields. The returned ids feed
    directly into `fetch_mmcif`/`fetch_list_mmcif` with
    `provider="pdbe"`.

    Unlike RCSB's search API, PDBe's Solr endpoint doesn't reject a
    malformed query — an unparseable clause is silently dropped rather
    than erroring, which can make a typo'd query match far more than
    intended. Sanity-check the returned count against what you expect.

    Args:
        query: A Solr query string, e.g. `'experimental_method:"X-ray
            diffraction" AND resolution:[0 TO 1.5]'`.
        rows: Maximum number of ids to return in this page.
        start: Offset into the full result set, for paging through more
            than `rows` matches.

    Returns:
        Matching PDB ids, lowercase (PDBe's own convention), deduplicated
        — PDBe's Solr index can list the same id more than once (e.g. one
        row per assembly/entity). Empty list if nothing matches.

    Raises:
        RuntimeError: If the request fails.
    """

    params = {
        "q": query,
        "fl": "pdb_id",
        "rows": rows,
        "start": start,
        "wt": "json",
    }
    httpx = require("httpx", "ingestion")
    try:
        resp = httpx.get(_PDBE_SEARCH_URL, params=params, timeout=60.0)
        resp.raise_for_status()
    except httpx.HTTPStatusError as exc:
        raise RuntimeError(
            f"HTTP {exc.response.status_code} querying PDBe search API: "
            f"{exc.response.text}"
        ) from exc
    except httpx.RequestError as exc:
        raise RuntimeError(
            f"Network error querying PDBe search API: {exc}"
        ) from exc

    docs = resp.json().get("response", {}).get("docs", [])
    # PDBe's Solr index can return the same pdb_id more than once (e.g.
    # once per assembly/entity row); dedupe, preserving result order.
    return list(dict.fromkeys(doc["pdb_id"] for doc in docs if "pdb_id" in doc))

search_rcsb(query, return_type='entry', rows=100, start=0)

Search RCSB's Search API for matching entry ids.

query is RCSB's own query tree, passed through verbatim — Pandora doesn't validate or translate it. See https://search.rcsb.org/#search-api for the query language (terminal/group nodes, services, attributes, operators) and https://search.rcsb.org/rcsbsearch/v2/metadata/schema for the full list of searchable attributes. The returned ids feed directly into fetch_mmcif/fetch_list_mmcif with provider="pdb".

Parameters:

Name Type Description Default
query dict

An RCSB query tree, e.g. {"type": "terminal", "service": "text", "parameters": {"attribute": "exptl.method", "operator": "exact_match", "value": "X-RAY DIFFRACTION"}}.

required
return_type str

What kind of id to return — "entry" for PDB entry ids (the default), or one of RCSB's other result types ("polymer_entity", "assembly", ...).

'entry'
rows int

Maximum number of ids to return in this page.

100
start int

Offset into the full result set, for paging through more than rows matches.

0

Returns:

Type Description
list[str]

Matching identifiers, in RCSB's own casing (e.g. "104M"). Empty

list[str]

list if nothing matches.

Raises:

Type Description
RuntimeError

If the request fails, including a malformed query — RCSB validates the query tree server-side and returns its own error message on a bad attribute/operator.

Source code in pandora/ingestion/search.py
def search_rcsb(
    query: dict,
    return_type: str = "entry",
    rows: int = 100,
    start: int = 0,
) -> list[str]:
    """Search RCSB's Search API for matching entry ids.

    `query` is RCSB's own query tree, passed through verbatim — Pandora
    doesn't validate or translate it. See
    https://search.rcsb.org/#search-api for the query language
    (terminal/group nodes, services, attributes, operators) and
    https://search.rcsb.org/rcsbsearch/v2/metadata/schema for the full
    list of searchable attributes. The returned ids feed directly into
    `fetch_mmcif`/`fetch_list_mmcif` with `provider="pdb"`.

    Args:
        query: An RCSB query tree, e.g. `{"type": "terminal", "service":
            "text", "parameters": {"attribute": "exptl.method",
            "operator": "exact_match", "value": "X-RAY DIFFRACTION"}}`.
        return_type: What kind of id to return — "entry" for PDB entry
            ids (the default), or one of RCSB's other result types
            ("polymer_entity", "assembly", ...).
        rows: Maximum number of ids to return in this page.
        start: Offset into the full result set, for paging through more
            than `rows` matches.

    Returns:
        Matching identifiers, in RCSB's own casing (e.g. "104M"). Empty
        list if nothing matches.

    Raises:
        RuntimeError: If the request fails, including a malformed query
            — RCSB validates the query tree server-side and returns its
            own error message on a bad attribute/operator.
    """

    body = {
        "query": query,
        "return_type": return_type,
        "request_options": {"paginate": {"start": start, "rows": rows}},
    }
    httpx = require("httpx", "ingestion")
    try:
        resp = httpx.post(_RCSB_SEARCH_URL, json=body, timeout=60.0)
        resp.raise_for_status()
    except httpx.HTTPStatusError as exc:
        raise RuntimeError(
            f"HTTP {exc.response.status_code} querying RCSB search API: "
            f"{exc.response.text}"
        ) from exc
    except httpx.RequestError as exc:
        raise RuntimeError(
            f"Network error querying RCSB search API: {exc}"
        ) from exc

    if resp.status_code == 204:
        return []
    return [hit["identifier"] for hit in resp.json().get("result_set", [])]

pandora.parsing

mmcif_to_structure(path_to_mmcif, model_num=1)

Convert raw mmCIF text to a Structure (mmCIF data model).

Parses the mmCIF file with gemmi, selects the requested model (falling back to the first model with a warning if not found), and extracts entities, atoms, asym units, connections, assemblies, and secondary structure into Pandora's typed Structure schema. Any other mmCIF categories are preserved verbatim in Structure.raw.

Parameters:

Name Type Description Default
path_to_mmcif str

Path to the mmCIF file on disk.

required
model_num int

Which model number to select from a multi-model entry.

1

Returns:

Type Description
tuple[Structure | None, DiagnosticBundle, ResultStatus]

A tuple of: - The parsed Structure, or None if parsing failed. - A DiagnosticBundle of warnings/errors encountered. - A ResultStatus: "success", "warning", or "failed".

Source code in pandora/parsing/mmcif.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
def mmcif_to_structure(
    path_to_mmcif: str,
    # entry_id: str = "",
    model_num: int = 1,
) -> tuple[Structure | None, DiagnosticBundle, ResultStatus]:
    """Convert raw mmCIF text to a Structure (mmCIF data model).

    Parses the mmCIF file with gemmi, selects the requested model
    (falling back to the first model with a warning if not found),
    and extracts entities, atoms, asym units, connections, assemblies,
    and secondary structure into Pandora's typed `Structure` schema.
    Any other mmCIF categories are preserved verbatim in `Structure.raw`.

    Args:
        path_to_mmcif: Path to the mmCIF file on disk.
        model_num: Which model number to select from a multi-model
            entry.

    Returns:
        A tuple of:
            - The parsed `Structure`, or `None` if parsing failed.
            - A `DiagnosticBundle` of warnings/errors encountered.
            - A `ResultStatus`: "success", "warning", or "failed".
    """
    diag = DiagnosticBundle()

    if not path_to_mmcif.strip():
        diag.errors.append(
            Diagnostic(
                code="EMPTY_CONTENT",
                severity="error",
                message="mmCIF content is empty",
                # entry_id=entry_id or None,
            )
        )
        return None, diag, "failed"

    try:
        st = gemmi.read_structure(path_to_mmcif, format=gemmi.CoorFormat.Mmcif)
    except (OSError, ValueError, RuntimeError) as exc:
        diag.errors.append(
            Diagnostic(
                code="PARSE_ERROR",
                severity="error",
                message=str(exc),
                # entry_id=entry_id or None,
            )
        )
        return None, diag, "failed"

    try:
        doc = gemmi.cif.read(path_to_mmcif)
        block = doc.sole_block()
    except (OSError, ValueError, RuntimeError) as exc:
        diag.errors.append(
            Diagnostic(
                code="CIF_PARSE_ERROR",
                severity="error",
                message=str(exc),
                # entry_id=entry_id or None,
            )
        )
        return None, diag, "failed"

    if len(st) == 0:
        diag.errors.append(
            Diagnostic(
                code="NO_MODEL",
                severity="error",
                message="Structure contains no models",
                # entry_id=entry_id or None,
            )
        )
        return None, diag, "failed"

    eid = st.name

    # Select model --------------------------
    model = next((m for m in st if m.num == model_num), None)
    if model is None:
        model = st[0]
        diag.warnings.append(
            Diagnostic(
                code="MODEL_NOT_FOUND",
                severity="warning",
                message=(
                    f"Model {model_num} not found; falling back "
                    f"to model {model.num}"
                ),
                entry_id=eid,
            )
        )

    # Entry ------------------------------------------
    raw_title = block.find_value("_struct.title")
    entry = EntryRecord(
        id=eid,
        title=_cs(raw_title) if raw_title else None,
    )

    # Subchain to entity lookup -----------------------
    subchain_to_entity: dict[str, str] = {
        sc: ent.name for ent in st.entities for sc in ent.subchains
    }

    # Entity metadata from cif ------------------------------
    entity_desc_map: dict[str, tuple[str | None, float | None, str | None]] = {}
    try:
        for row in block.find(
            "_entity.",
            ["id", "pdbx_description", "formula_weight", "src_method"],
        ):
            entity_desc_map[_cs(row[0]) or ""] = (
                _cs(row[1]),
                _cf(row[2]),
                _cs(row[3]),
            )
    except _MALFORMED_CIF_ERRORS as exc:
        diag.warnings.append(
            Diagnostic(
                code="ENTITY_METADATA_PARSE_ERROR",
                severity="warning",
                message=str(exc),
                entry_id=eid,
            )
        )

    entity_poly_map: dict[str, EntityPolyRecord] = {}
    try:
        for row in block.find(
            "_entity_poly.",
            [
                "entity_id",
                "type",
                "pdbx_seq_one_letter_code",
                "pdbx_seq_one_letter_code_can",
                "pdbx_strand_id",
            ],
        ):
            entity_poly_map[_cs(row[0]) or ""] = EntityPolyRecord(
                type=_cs(row[1]),
                pdbx_seq_one_letter_code=_cseq(row[2]),
                pdbx_seq_one_letter_code_can=_cseq(row[3]),
                pdbx_strand_id=_cs(row[4]),
            )
    except _MALFORMED_CIF_ERRORS as exc:
        diag.warnings.append(
            Diagnostic(
                code="ENTITY_POLY_PARSE_ERROR",
                severity="warning",
                message=str(exc),
                entry_id=eid,
            )
        )

    entities_out: list[EntityRecord] = []
    for ent in st.entities:
        desc, fw, src = entity_desc_map.get(ent.name, (None, None, None))
        entities_out.append(
            EntityRecord(
                id=ent.name,
                type=_ENTITY_TYPE_MAP.get(ent.entity_type, "non-polymer"),
                pdbx_description=desc,
                formula_weight=fw,
                src_method=src,
                poly=entity_poly_map.get(ent.name),
            )
        )

    # Atoms + asym tracking ----------------------------------------------
    asym_auth_map: dict[str, str] = {}  # label_asym_id → auth_asym_id
    atoms_out: list[AtomSiteRecord] = []
    _serial = 0

    for chain in model:
        for res in chain:
            sc = res.subchain
            if sc and sc not in asym_auth_map:
                asym_auth_map[sc] = chain.name

            is_polymer = res.entity_type == gemmi.EntityType.Polymer
            group = "ATOM" if is_polymer else "HETATM"

            try:
                seq_num: int | None = res.label_seq
                ins = res.seqid.icode
                ins_code: str | None = (
                    ins if ins not in (" ", "\x00", "") else None
                )
            except _MALFORMED_CIF_ERRORS:
                seq_num, ins_code = None, None

            for atom in res:
                _serial += 1
                serial = atom.serial if atom.serial > 0 else _serial
                altloc = (
                    atom.altloc
                    if atom.altloc not in ("\x00", " ", "")
                    else None
                )
                atoms_out.append(
                    AtomSiteRecord(
                        group_PDB=group,
                        id=serial,
                        type_symbol=atom.element.name,
                        label_atom_id=atom.name,
                        label_alt_id=altloc,
                        label_comp_id=res.name,
                        label_asym_id=sc or "",
                        label_entity_id=subchain_to_entity.get(sc or "", ""),
                        label_seq_id=seq_num if is_polymer else None,
                        pdbx_PDB_ins_code=ins_code,
                        Cartn_x=atom.pos.x,
                        Cartn_y=atom.pos.y,
                        Cartn_z=atom.pos.z,
                        occupancy=atom.occ,
                        B_iso_or_equiv=atom.b_iso,
                        auth_seq_id=str(res.seqid.num),
                        auth_comp_id=res.name,
                        auth_asym_id=chain.name,
                        auth_atom_id=atom.name,
                        pdbx_PDB_model_num=model.num,
                    )
                )

    if not atoms_out:
        diag.warnings.append(
            Diagnostic(
                code="MISSING_ATOM_SITE",
                severity="warning",
                message="No atoms found in structure",
                entry_id=eid,
            )
        )

    # Asym units ------------------------------------------------
    seen_asyms: set[str] = set()
    asym_units: list[AsymRecord] = []
    for ent in st.entities:
        for sc in ent.subchains:
            if sc not in seen_asyms:
                seen_asyms.add(sc)
                asym_units.append(
                    AsymRecord(
                        id=sc,
                        entity_id=ent.name,
                        auth_id=asym_auth_map.get(sc),
                    )
                )

    # Connections -------------------------------------------
    connections_out: list[ConnRecord] = []
    try:
        for row in block.find(
            "_struct_conn.",
            [
                "id",
                "conn_type_id",
                "ptnr1_label_asym_id",
                "ptnr1_label_comp_id",
                "ptnr1_label_seq_id",
                "ptnr1_label_atom_id",
                "ptnr1_auth_asym_id",
                "ptnr1_auth_seq_id",
                "ptnr1_PDB_ins_code",
                "ptnr1_symmetry",
                "ptnr2_label_asym_id",
                "ptnr2_label_comp_id",
                "ptnr2_label_seq_id",
                "ptnr2_label_atom_id",
                "ptnr2_auth_asym_id",
                "ptnr2_auth_seq_id",
                "ptnr2_PDB_ins_code",
                "ptnr2_symmetry",
                "pdbx_dist_value",
                "details",
            ],
        ):
            connections_out.append(
                ConnRecord(
                    id=_cs(row[0]) or "",
                    conn_type_id=_cs(row[1]) or "covale",
                    ptnr1=ConnPartner(
                        label_asym_id=_cs(row[2]) or "",
                        label_comp_id=_cs(row[3]) or "",
                        label_seq_id=_ci(row[4]),
                        label_atom_id=_cs(row[5]),
                        auth_asym_id=_cs(row[6]),
                        auth_seq_id=_cs(row[7]),
                        pdbx_PDB_ins_code=_cs(row[8]),
                        symmetry=_cs(row[9]),
                    ),
                    ptnr2=ConnPartner(
                        label_asym_id=_cs(row[10]) or "",
                        label_comp_id=_cs(row[11]) or "",
                        label_seq_id=_ci(row[12]),
                        label_atom_id=_cs(row[13]),
                        auth_asym_id=_cs(row[14]),
                        auth_seq_id=_cs(row[15]),
                        pdbx_PDB_ins_code=_cs(row[16]),
                        symmetry=_cs(row[17]),
                    ),
                    pdbx_dist_value=_cf(row[18]),
                    details=_cs(row[19]),
                )
            )
    except _MALFORMED_CIF_ERRORS as exc:
        diag.warnings.append(
            Diagnostic(
                code="CONN_PARSE_ERROR",
                severity="warning",
                message=str(exc),
                entry_id=eid,
            )
        )

    # Assemblies ------------------------------------
    asm_meta: dict[str, dict] = {}
    try:
        for row in block.find(
            "_pdbx_struct_assembly.",
            [
                "id",
                "details",
                "method_details",
                "oligomeric_details",
                "oligomeric_count",
            ],
        ):
            asm_meta[_cs(row[0]) or ""] = {
                "details": _cs(row[1]),
                "method_details": _cs(row[2]),
                "oligomeric_details": _cs(row[3]),
                "oligomeric_count": _ci(row[4]),
            }
    except _MALFORMED_CIF_ERRORS as exc:
        diag.warnings.append(
            Diagnostic(
                code="ASSEMBLY_METADATA_PARSE_ERROR",
                severity="warning",
                message=str(exc),
                entry_id=eid,
            )
        )

    assemblies_out: list[AssemblyRecord] = []
    for asm in st.assemblies:
        meta = asm_meta.get(asm.name, {})
        gens: list[AssemblyGenRecord] = []
        operators: list[AssemblyOperRecord] = []
        seen_ops: set[str] = set()

        for gen in asm.generators:
            gens.append(
                AssemblyGenRecord(
                    assembly_id=asm.name,
                    oper_expression=",".join(op.name for op in gen.operators),
                    asym_id_list=list(gen.subchains),
                )
            )
            for op in gen.operators:
                if op.name not in seen_ops:
                    seen_ops.add(op.name)
                    mat = op.transform.mat
                    vec = op.transform.vec
                    operators.append(
                        AssemblyOperRecord(
                            id=op.name,
                            type=getattr(op, "type", None) or None,
                            matrix=mat.tolist(),
                            vector=[vec.x, vec.y, vec.z],
                        )
                    )

        assemblies_out.append(
            AssemblyRecord(
                id=asm.name,
                details=meta.get("details"),
                method_details=meta.get("method_details"),
                oligomeric_details=meta.get("oligomeric_details"),
                oligomeric_count=meta.get("oligomeric_count"),
                generators=gens,
                operators=operators,
                author_determined=asm.author_determined,
                software_determined=asm.software_determined,
            )
        )

    # Secondary structure --------------------------------
    conf_records: list[ConfRecord] = []
    try:
        for row in block.find(
            "_struct_conf.",
            [
                "id",
                "conf_type_id",
                "beg_label_asym_id",
                "beg_label_seq_id",
                "end_label_asym_id",
                "end_label_seq_id",
                "beg_auth_asym_id",
                "beg_auth_seq_id",
                "end_auth_asym_id",
                "end_auth_seq_id",
            ],
        ):
            conf_records.append(
                ConfRecord(
                    id=_cs(row[0]) or "",
                    conf_type_id=_cs(row[1]) or "",
                    beg_label_asym_id=_cs(row[2]) or "",
                    beg_label_seq_id=_ci(row[3]),
                    end_label_asym_id=_cs(row[4]) or "",
                    end_label_seq_id=_ci(row[5]),
                    beg_auth_asym_id=_cs(row[6]),
                    beg_auth_seq_id=_cs(row[7]),
                    end_auth_asym_id=_cs(row[8]),
                    end_auth_seq_id=_cs(row[9]),
                )
            )
    except _MALFORMED_CIF_ERRORS as exc:
        diag.warnings.append(
            Diagnostic(
                code="STRUCT_CONF_PARSE_ERROR",
                severity="warning",
                message=str(exc),
                entry_id=eid,
            )
        )

    sheet_strands: list[SheetStrandRecord] = []
    try:
        for row in block.find(
            "_struct_sheet_range.",
            [
                "sheet_id",
                "id",
                "beg_label_asym_id",
                "beg_label_seq_id",
                "end_label_asym_id",
                "end_label_seq_id",
                "beg_auth_asym_id",
                "beg_auth_seq_id",
                "end_auth_asym_id",
                "end_auth_seq_id",
            ],
        ):
            sheet_strands.append(
                SheetStrandRecord(
                    sheet_id=_cs(row[0]) or "",
                    id=_cs(row[1]) or "",
                    beg_label_asym_id=_cs(row[2]) or "",
                    beg_label_seq_id=_ci(row[3]),
                    end_label_asym_id=_cs(row[4]) or "",
                    end_label_seq_id=_ci(row[5]),
                    beg_auth_asym_id=_cs(row[6]),
                    beg_auth_seq_id=_cs(row[7]),
                    end_auth_asym_id=_cs(row[8]),
                    end_auth_seq_id=_cs(row[9]),
                )
            )
    except _MALFORMED_CIF_ERRORS as exc:
        diag.warnings.append(
            Diagnostic(
                code="SHEET_RANGE_PARSE_ERROR",
                severity="warning",
                message=str(exc),
                entry_id=eid,
            )
        )

    return (
        Structure(
            entry_id=eid,
            entry=entry,
            entities=entities_out,
            asym_units=asym_units,
            atoms=atoms_out,
            connections=connections_out,
            assemblies=assemblies_out,
            secondary_structure=SSRecord(
                conf_records=conf_records, sheet_strands=sheet_strands
            ),
            raw=_extract_raw(block),
        ),
        diag,
        "warning" if diag.warnings else "success",
    )

pandora.canonicalisation

canonicalise_structure(structure, policy)

Convert a parsed structure into a canonical structure.

Applies modified-residue mapping, chain ID normalization, assembly normalization, missing-data handling, altloc resolution, residue renumbering, entity normalization, and ligand filtering, in that order, according to the given policy. Each step records its mapping (for reversibility) and, if the step's strategy deviates from "preserve", appends a transform label to the returned provenance.

Parameters:

Name Type Description Default
structure Structure

The parsed input structure to canonicalise.

required
policy canonicalisationPolicy

The canonicalisation policy whose rules govern each normalization step.

required

Returns:

Type Description
tuple[Structure, CanonicalMappings, canonicalisationProvenance]

A tuple of: - The canonicalised Structure. - CanonicalMappings recording original-to-canonical identifier mappings for chains, residues, assemblies, entities, altloc selections, and modified residues. - canonicalisationProvenance describing which policy was applied, which transforms ran, and diagnostic counts.

Source code in pandora/canonicalisation/canonicalise.py
def canonicalise_structure(
    structure: Structure,
    policy: canonicalisationPolicy,
) -> tuple[Structure, CanonicalMappings, canonicalisationProvenance]:
    """Convert a parsed structure into a canonical structure.

    Applies modified-residue mapping, chain ID normalization, assembly
    normalization, missing-data
    handling, altloc resolution, residue renumbering, entity
    normalization, and ligand filtering, in that order, according to the
    given policy. Each
    step records its mapping (for reversibility) and, if the step's
    strategy deviates from "preserve", appends a transform label to the
    returned provenance.

    Args:
        structure: The parsed input structure to canonicalise.
        policy: The canonicalisation policy whose rules govern each
            normalization step.

    Returns:
        A tuple of:
            - The canonicalised `Structure`.
            - `CanonicalMappings` recording original-to-canonical
              identifier mappings for chains, residues, assemblies,
              entities, altloc selections, and modified residues.
            - `canonicalisationProvenance` describing which policy was
              applied, which transforms ran, and diagnostic counts.
    """
    ir = policy.identifier_rules
    mdr = policy.missing_data_rules
    ar = policy.altloc_rules
    asmr = policy.assembly_rules
    er = policy.entity_rules
    lr = policy.ligand_rules
    mrr = policy.modified_residue_rules
    vr = policy.validation_rules
    pr = policy.provenance_rules

    record = pr.record_original_mappings
    transforms: list[str] = []
    diagnostics = DiagnosticBundle()

    original_atoms = structure.atoms
    original_raw = structure.raw
    atoms: list[AtomSiteRecord] = list(structure.atoms)
    asym_units: list[AsymRecord] = list(structure.asym_units)
    entities: list[EntityRecord] = list(structure.entities)
    assemblies: list[AssemblyRecord] = list(structure.assemblies)
    connections: list[ConnRecord] = list(structure.connections)
    secondary_structure: SSRecord = structure.secondary_structure

    # map_modified_residues --------------------------------
    # First, so chain ids and seq ids still match
    # _pdbx_struct_mod_residue.
    atoms, modified_residue_mapping = _map_modified_residues(
        atoms,
        structure.raw.get("_pdbx_struct_mod_residue", []),
        mrr,
        diagnostics,
        structure.entry_id,
    )
    # Bonds and SEQRES name the same residues, so they follow. The bonds
    # are read back from `structure` after the chain map below.
    structure = structure.model_copy(
        update={
            "connections": _map_connections(
                structure.connections, modified_residue_mapping
            )
        }
    )
    original_raw = _map_entity_poly_seq(
        original_raw, modified_residue_mapping, asym_units
    )
    if mrr.strategy != "preserve":
        transforms.append(f"modified_residues:{mrr.strategy}")

    # normalize_chain_ids --------------------------------
    chain_map, chain_id_mapping = _normalize_chain_ids(
        asym_units, ir.chain_id.strategy, record
    )
    atoms, asym_units, structure = _apply_chain_map(
        atoms, asym_units, structure, chain_map
    )
    assemblies = list(structure.assemblies)
    connections = list(structure.connections)
    secondary_structure = structure.secondary_structure
    if ir.chain_id.strategy != "preserve":
        transforms.append(f"chain_id:{ir.chain_id.strategy}")

    # normalize_assemblies --------------------------------
    (
        assemblies,
        atoms,
        asym_units,
        connections,
        secondary_structure,
        assembly_mapping,
    ) = _normalize_assemblies(
        assemblies,
        atoms,
        asym_units,
        connections,
        secondary_structure,
        asmr,
        ir.assembly_id.strategy,
        record,
        diagnostics,
        structure.entry_id,
    )
    if (
        ir.assembly_id.strategy != "preserve"
        or asmr.strategy != "preserve_as_reported"
    ):
        transforms.append(
            f"assembly:{asmr.strategy}/id:{ir.assembly_id.strategy}"
        )

    # handle_missing_atoms ---------------------------------------
    atoms = _handle_missing_atoms(
        atoms, mdr.missing_atoms, diagnostics, structure.entry_id, entities
    )
    if mdr.missing_atoms.strategy not in ("preserve",):
        transforms.append(f"missing_atoms:{mdr.missing_atoms.strategy}")

    # handle_missing_residues ---------------------------------------
    atoms = _handle_missing_residues(
        atoms, mdr.missing_residues, diagnostics, structure.entry_id
    )
    if mdr.missing_residues.strategy not in ("preserve",):
        transforms.append(f"missing_residues:{mdr.missing_residues.strategy}")

    # handle_incomplete_chains -------------------------------------
    atoms, asym_units = _handle_incomplete_chains(
        atoms, asym_units, mdr.incomplete_chains
    )
    if mdr.incomplete_chains.strategy != "preserve":
        transforms.append(f"incomplete_chains:{mdr.incomplete_chains.strategy}")

    # resolve_altlocs ------------------------------
    atoms, altloc_selection_mapping = _resolve_altlocs(atoms, ar)
    if ar.strategy != "preserve":
        transforms.append(f"altloc:{ar.strategy}")

    # normalize_residue_numbering ---------------------------------
    # After the gap checks (renumbering makes every chain contiguous)
    # and altloc resolution (so compositional disorder gets one number).
    atoms, residue_number_mapping = _normalize_residue_numbering(
        atoms,
        chain_map,
        ir.residue_numbering.strategy,
        ir.residue_numbering.preserve_insertion_codes,
        record,
    )
    if ir.residue_numbering.strategy != "preserve":
        transforms.append(f"residue_numbering:{ir.residue_numbering.strategy}")

    # normalize_entities ---------------------------------------
    entities, asym_units, atoms, entity_mapping, entity_id_map = (
        _normalize_entities(entities, asym_units, atoms, er, record)
    )
    if er.strategy != "preserve":
        transforms.append(f"entity:{er.strategy}")

    # filter_ligands ---------------------------------------
    atoms, asym_units = filter_ligands(
        atoms, asym_units, entities, lr, diagnostics, structure.entry_id
    )
    if lr.strategy != "preserve":
        transforms.append(f"ligands:{lr.strategy}")

    # validate_canonical_structure ---------------------------------------
    status = _validate(atoms, asym_units, vr, diagnostics, structure.entry_id)
    if status == "failed":
        raise ValueError(
            f"Validation of canonicalised structure failed for entry "
            f"{structure.entry_id!r}"
        )

    # Verbatim categories that index chains or residues would contradict
    # the new atoms, so they go (and are listed in the provenance).
    raw, dropped_raw_categories = _drop_stale_raw_categories(
        original_raw, original_atoms, atoms
    )
    # Verbatim `entity_id` columns follow the canonical entity ids.
    raw = _remap_raw_entity_ids(raw, entity_id_map)

    canonical = structure.model_copy(
        update={
            "raw": raw,
            "atoms": atoms,
            "asym_units": asym_units,
            "entities": entities,
            "assemblies": assemblies,
            "connections": connections,
            "secondary_structure": secondary_structure,
        }
    )

    mappings = CanonicalMappings(
        chain_id_mapping=chain_id_mapping,
        residue_number_mapping=residue_number_mapping,
        assembly_mapping=assembly_mapping,
        entity_mapping=entity_mapping,
        altloc_selection_mapping=altloc_selection_mapping,
        modified_residue_mapping=modified_residue_mapping,
    )

    provenance = canonicalisationProvenance(
        canonicalised_at=now_iso(),
        policy_id=policy.policy_id,
        policy_name=policy.policy_name,
        policy_version=policy.policy_version,
        transforms=transforms,
        dropped_raw_categories=dropped_raw_categories,
        report=(
            {
                "warnings": len(diagnostics.warnings),
                "errors": len(diagnostics.errors),
            }
            if pr.emit_canonicalisation_report
            else {}
        ),
    )

    return canonical, mappings, provenance

filter_ligands(atoms, asym_units, entities, rules, diagnostics, entry_id)

Filter non-polymer ligands/waters/ions out of atoms/asym_units per the ligand rules.

Categorizes each non-polymer asym unit as water, ion (by keyword match on the entity description), or other non-polymer ligand, and keeps or drops it according to rules.keep_waters/keep_ions/ keep_nonpolymer_ligands. With rules.strategy="annotate_only", every non-polymer asym unit is dropped from the returned atoms/ asym_units (but recorded as a diagnostic) rather than selectively kept.

Parameters:

Name Type Description Default
atoms list[AtomSiteRecord]

The structure's atom records.

required
asym_units list[AsymRecord]

The structure's asym unit records.

required
entities list[EntityRecord]

The structure's entity records, used to classify each asym unit's entity type and description.

required
rules LigandRules

The LigandRules governing which categories are kept. No-op (returns atoms, asym_units unchanged) unless rules.strategy is "filter" or "annotate_only".

required
diagnostics DiagnosticBundle

Bundle to append a warning to for each asym unit excluded under annotate_only.

required
entry_id str

The structure's entry id, attached to any diagnostics raised.

required

Returns:

Type Description
list[AtomSiteRecord]

(atoms, asym_units) with excluded ligand atoms/asym units

list[AsymRecord]

removed; polymer atoms/asym units are always kept.

Source code in pandora/canonicalisation/ligands.py
def filter_ligands(
    atoms: list[AtomSiteRecord],
    asym_units: list[AsymRecord],
    entities: list[EntityRecord],
    rules: LigandRules,
    diagnostics: DiagnosticBundle,
    entry_id: str,
) -> tuple[list[AtomSiteRecord], list[AsymRecord]]:
    """Filter non-polymer ligands/waters/ions out of atoms/asym_units per
    the ligand rules.

    Categorizes each non-polymer asym unit as water, ion (by keyword
    match on the entity description), or other non-polymer ligand, and
    keeps or drops it according to `rules.keep_waters`/`keep_ions`/
    `keep_nonpolymer_ligands`. With `rules.strategy="annotate_only"`,
    every non-polymer asym unit is dropped from the returned atoms/
    asym_units (but recorded as a diagnostic) rather than selectively
    kept.

    Args:
        atoms: The structure's atom records.
        asym_units: The structure's asym unit records.
        entities: The structure's entity records, used to classify each
            asym unit's entity type and description.
        rules: The `LigandRules` governing which categories are kept.
            No-op (returns `atoms, asym_units` unchanged) unless
            `rules.strategy` is "filter" or "annotate_only".
        diagnostics: Bundle to append a warning to for each asym unit
            excluded under `annotate_only`.
        entry_id: The structure's entry id, attached to any diagnostics
            raised.

    Returns:
        `(atoms, asym_units)` with excluded ligand atoms/asym units
        removed; polymer atoms/asym units are always kept.
    """

    if rules.strategy not in ("filter", "annotate_only"):
        return atoms, asym_units

    entity_type: dict[str, str] = {e.id: e.type for e in entities}
    entity_desc: dict[str, str] = {
        e.id: (e.pdbx_description or "").upper() for e in entities
    }
    asym_entity: dict[str, str] = {a.id: a.entity_id for a in asym_units}

    def _keep(asym_id: str) -> bool:
        """Whether the asym unit at asym_id should be kept under the
        ligand rules."""

        eid = asym_entity.get(asym_id)
        if eid is None:
            return True
        etype = entity_type.get(eid, "polymer")
        if etype == "polymer":
            return True
        if rules.strategy == "annotate_only":
            return False
        if etype == "water":
            return rules.keep_waters
        # PDB names ions "<ELEMENT/ANION> ION" (ZINC ION, SULFATE ION).
        # Whole-word match only: "PHOSPHATE" alone would also catch
        # GMP/ATP/NADP, and "ION" as a substring catches glutathione.
        if "ION" in re.findall(r"[A-Z0-9]+", entity_desc.get(eid, "")):
            return rules.keep_ions
        return rules.keep_nonpolymer_ligands

    keep = {a.id for a in asym_units if _keep(a.id)}

    if rules.strategy == "annotate_only":
        for a in asym_units:
            if a.id in keep:
                continue
            eid = asym_entity.get(a.id)
            diagnostics.warnings.append(
                Diagnostic(
                    code="LIGAND_ANNOTATED_ONLY",
                    severity="warning",
                    message=(
                        f"Ligand in asym {a.id} excluded from canonical "
                        "structure (ligand_rules.strategy=annotate_only)"
                    ),
                    entry_id=entry_id,
                    context={
                        "asym_id": a.id,
                        "entity_id": eid,
                        "entity_type": entity_type.get(eid, "polymer"),
                        "description": entity_desc.get(eid, ""),
                    },
                )
            )

    return (
        [a for a in atoms if a.label_asym_id in keep],
        [a for a in asym_units if a.id in keep],
    )

pandora.metadata

annotate_ligand_contacts(structure, distance_cutoff=4.0, include_waters=False)

Compute polymer residues near non-polymer atoms within a cutoff.

Groups HETATM records into ligands (by asym/auth_seq/comp id) and, for each ligand, finds polymer residues with at least one atom within distance_cutoff angstroms of a ligand atom.

Parameters:

Name Type Description Default
structure Structure

The structure to scan for ligand contacts.

required
distance_cutoff float

Contact distance in angstroms.

4.0
include_waters bool

If True, treat water residues as ligands too.

False

Returns:

Type Description
AnnotationLayer

An AnnotationLayer of type "ligand_contacts" whose data

AnnotationLayer

holds the cutoff/flags used and, per ligand, the contacting

AnnotationLayer

polymer residues with their nearest distance.

Source code in pandora/annotations/entry.py
def annotate_ligand_contacts(
    structure: Structure,
    distance_cutoff: float = 4.0,
    include_waters: bool = False,
) -> AnnotationLayer:
    """Compute polymer residues near non-polymer atoms within a cutoff.

    Groups HETATM records into ligands (by asym/auth_seq/comp id) and,
    for each ligand, finds polymer residues with at least one atom
    within `distance_cutoff` angstroms of a ligand atom.

    Args:
        structure: The structure to scan for ligand contacts.
        distance_cutoff: Contact distance in angstroms.
        include_waters: If True, treat water residues as ligands too.

    Returns:
        An `AnnotationLayer` of type "ligand_contacts" whose `data`
        holds the cutoff/flags used and, per ligand, the contacting
        polymer residues with their nearest distance.
    """

    polymer_atoms = [
        atom for atom in structure.atoms if atom.group_PDB == "ATOM"
    ]
    ligand_atoms = [
        atom
        for atom in structure.atoms
        if _is_ligand_atom(atom, include_waters)
    ]

    ligand_groups: dict[tuple[str, str | None, str], list[AtomSiteRecord]]
    ligand_groups = defaultdict(list)
    for atom in ligand_atoms:
        ligand_groups[
            (atom.label_asym_id, atom.auth_seq_id, atom.label_comp_id)
        ].append(atom)

    cutoff_sq = distance_cutoff * distance_cutoff
    polymer_grid = _spatial_grid(polymer_atoms, distance_cutoff)
    contacts = []
    for ligand_key, atoms in ligand_groups.items():
        residue_contacts = _residues_within_cutoff(
            atoms, polymer_grid, distance_cutoff, cutoff_sq
        )
        contacts.append(
            {
                "ligand_asym_id": ligand_key[0],
                "ligand_auth_seq_id": ligand_key[1],
                "ligand_comp_id": ligand_key[2],
                "contact_count": len(residue_contacts),
                "contacts": residue_contacts,
            }
        )

    return AnnotationLayer(
        layer_name="Ligand contacts",
        layer_type="ligand_contacts",
        scope="entry",
        method="pandora.basic.distance_cutoff_contacts.v1",
        target_ids=[structure.entry_id],
        parameters={
            "distance_cutoff": distance_cutoff,
            "include_waters": include_waters,
        },
        data={
            "distance_cutoff": distance_cutoff,
            "include_waters": include_waters,
            "ligands": contacts,
        },
        provenance={"inputs": ["Structure.atoms"]},
    )

annotate_pairwise_sequence_identity(left, right)

Compute a simple ungapped sequence identity between two entries.

Compares every polymer entity in left against every polymer entity in right with a position-wise (ungapped, no alignment) identity, then reports the best-scoring pair.

Parameters:

Name Type Description Default
left Structure

The first structure to compare.

required
right Structure

The second structure to compare.

required

Returns:

Type Description
AnnotationLayer

An AnnotationLayer of type "pairwise_sequence_identity" whose

AnnotationLayer

data holds the best identity score, the best-matching entity

AnnotationLayer

pair, and every entity-pair comparison.

Source code in pandora/annotations/pairwise.py
def annotate_pairwise_sequence_identity(
    left: Structure,
    right: Structure,
) -> AnnotationLayer:
    """Compute a simple ungapped sequence identity between two entries.

    Compares every polymer entity in `left` against every polymer
    entity in `right` with a position-wise (ungapped, no alignment)
    identity, then reports the best-scoring pair.

    Args:
        left: The first structure to compare.
        right: The second structure to compare.

    Returns:
        An `AnnotationLayer` of type "pairwise_sequence_identity" whose
        `data` holds the best identity score, the best-matching entity
        pair, and every entity-pair comparison.
    """

    comparisons = []
    for left_entity_id, left_sequence in _entity_sequences(left):
        for right_entity_id, right_sequence in _entity_sequences(right):
            comparisons.append(
                {
                    "left_entity_id": left_entity_id,
                    "right_entity_id": right_entity_id,
                    **_ungapped_identity(left_sequence, right_sequence),
                }
            )

    best = max(
        comparisons,
        key=lambda item: item["identity"] or 0.0,
        default=None,
    )

    return AnnotationLayer(
        layer_name="Pairwise sequence identity",
        layer_type="pairwise_sequence_identity",
        scope="pair",
        method="pandora.basic.ungapped_entity_identity.v1",
        target_ids=[left.entry_id, right.entry_id],
        data={
            "best_identity": best["identity"] if best else None,
            "best_match": best,
            "comparisons": comparisons,
        },
        provenance={"inputs": ["EntityRecord.poly"]},
    )

annotate_structure_counts(structure)

Compute simple per-entry counts from the canonical structure.

Parameters:

Name Type Description Default
structure Structure

The canonical structure to summarize.

required

Returns:

Type Description
AnnotationLayer

An AnnotationLayer of type "structure_counts" whose data

AnnotationLayer

holds atom/residue/asym/entity/assembly/connection counts,

AnnotationLayer

secondary-structure record counts, entity-type and atom-group

AnnotationLayer

breakdowns, and the altloc atom count.

Source code in pandora/annotations/entry.py
def annotate_structure_counts(structure: Structure) -> AnnotationLayer:
    """Compute simple per-entry counts from the canonical structure.

    Args:
        structure: The canonical structure to summarize.

    Returns:
        An `AnnotationLayer` of type "structure_counts" whose `data`
        holds atom/residue/asym/entity/assembly/connection counts,
        secondary-structure record counts, entity-type and atom-group
        breakdowns, and the altloc atom count.
    """

    residue_keys = {
        (
            atom.label_asym_id,
            atom.label_seq_id,
            atom.auth_seq_id,
            atom.label_comp_id,
        )
        for atom in structure.atoms
    }
    entity_type_counts = Counter(entity.type for entity in structure.entities)
    atom_group_counts = Counter(atom.group_PDB for atom in structure.atoms)
    altloc_atom_count = sum(1 for atom in structure.atoms if atom.label_alt_id)

    return AnnotationLayer(
        layer_name="Structure counts",
        layer_type="structure_counts",
        scope="entry",
        method="pandora.basic.structure_counts.v1",
        target_ids=[structure.entry_id],
        data={
            "atom_count": len(structure.atoms),
            "residue_count": len(residue_keys),
            "asym_unit_count": len(structure.asym_units),
            "entity_count": len(structure.entities),
            "assembly_count": len(structure.assemblies),
            "connection_count": len(structure.connections),
            "secondary_structure": {
                "conf_records": len(structure.secondary_structure.conf_records),
                "sheet_strands": len(
                    structure.secondary_structure.sheet_strands
                ),
            },
            "entity_type_counts": dict(entity_type_counts),
            "atom_group_counts": dict(atom_group_counts),
            "altloc_atom_count": altloc_atom_count,
        },
        provenance={"inputs": ["Structure.atoms", "Structure.entities"]},
    )

collect_metadata(structure)

Collect the basic source-backed metadata supported by Pandora today.

Runs every pandora.metadata.mmcif.extract_* function against the structure and assembles the results into one record.

Parameters:

Name Type Description Default
structure Structure

The parsed structure to collect metadata from.

required

Returns:

Type Description
MetadataRecord

A MetadataRecord combining entry, quality, taxonomy, entity,

MetadataRecord

ligand, and UniProt-mapping metadata, plus the sorted list of

MetadataRecord

raw mmCIF category names present in the structure.

Source code in pandora/metadata/collect.py
def collect_metadata(structure: Structure) -> MetadataRecord:
    """Collect the basic source-backed metadata supported by Pandora today.

    Runs every `pandora.metadata.mmcif.extract_*` function against the
    structure and assembles the results into one record.

    Args:
        structure: The parsed structure to collect metadata from.

    Returns:
        A `MetadataRecord` combining entry, quality, taxonomy, entity,
        ligand, and UniProt-mapping metadata, plus the sorted list of
        raw mmCIF category names present in the structure.
    """

    return MetadataRecord(
        entry_id=structure.entry_id,
        entry=extract_entry_metadata(structure),
        quality=extract_quality(structure),
        taxonomies=extract_taxonomies(structure),
        entities=extract_entity_metadata(structure),
        ligands=extract_ligand_metadata(structure),
        uniprot_mappings=extract_uniprot_mappings(structure),
        raw_categories=sorted(structure.raw.keys()),
    )

extract_entity_metadata(structure)

Extract entity-level descriptions, sequences, and chain membership.

Parameters:

Name Type Description Default
structure Structure

The parsed structure to extract entity metadata from.

required

Returns:

Type Description
list[EntityMetadataRecord]

An EntityMetadataRecord per entity, combining _entity

list[EntityMetadataRecord]

fields (EC number, mutation, fragment), polymer sequence data,

list[EntityMetadataRecord]

and the chain (asym) ids belonging to that entity.

Source code in pandora/metadata/mmcif.py
def extract_entity_metadata(structure: Structure) -> list[EntityMetadataRecord]:
    """Extract entity-level descriptions, sequences, and chain membership.

    Args:
        structure: The parsed structure to extract entity metadata
            from.

    Returns:
        An `EntityMetadataRecord` per entity, combining `_entity`
        fields (EC number, mutation, fragment), polymer sequence data,
        and the chain (asym) ids belonging to that entity.
    """

    entity_rows = {row.get("id"): row for row in raw_rows(structure, "_entity")}
    chain_ids_by_entity: dict[str, list[str]] = defaultdict(list)
    for asym in structure.asym_units:
        chain_ids_by_entity[asym.entity_id].append(asym.id)

    records: list[EntityMetadataRecord] = []
    for entity in structure.entities:
        raw = entity_rows.get(entity.id, {})
        poly = entity.poly
        records.append(
            EntityMetadataRecord(
                entity_id=entity.id,
                entity_type=entity.type,
                description=entity.pdbx_description,
                formula_weight=entity.formula_weight,
                source_method=entity.src_method,
                ec_number=clean(raw.get("pdbx_ec")),
                mutation=clean(raw.get("pdbx_mutation")),
                fragment=clean(raw.get("pdbx_fragment")),
                polymer_type=poly.type if poly else None,
                sequence=(
                    clean_sequence(poly.pdbx_seq_one_letter_code)
                    if poly
                    else None
                ),
                canonical_sequence=(
                    clean_sequence(poly.pdbx_seq_one_letter_code_can)
                    if poly
                    else None
                ),
                chain_ids=chain_ids_by_entity.get(entity.id, []),
                provenance=provenance("_entity", entity.id),
            )
        )
    return records

extract_entry_metadata(structure)

Extract entry-level source-backed metadata from the parsed structure.

Parameters:

Name Type Description Default
structure Structure

The parsed structure to extract metadata from.

required

Returns:

Type Description
EntryMetadataRecord

An EntryMetadataRecord with entry id, title, keywords, and

EntryMetadataRecord

citation details (title, DOI, PubMed id).

Source code in pandora/metadata/mmcif.py
def extract_entry_metadata(structure: Structure) -> EntryMetadataRecord:
    """Extract entry-level source-backed metadata from the parsed structure.

    Args:
        structure: The parsed structure to extract metadata from.

    Returns:
        An `EntryMetadataRecord` with entry id, title, keywords, and
        citation details (title, DOI, PubMed id).
    """

    struct_keywords = first_row(structure, "_struct_keywords")
    citation = first_row(structure, "_citation")

    return EntryMetadataRecord(
        entry_id=structure.entry_id,
        title=structure.entry.title,
        keywords=first_value(
            struct_keywords.get("text"),
            struct_keywords.get("pdbx_keywords"),
        ),
        citation_title=clean(citation.get("title")),
        doi=clean(citation.get("pdbx_database_id_DOI")),
        pubmed_id=clean(citation.get("pdbx_database_id_PubMed")),
        provenance=provenance("_struct"),
    )

extract_ligand_metadata(structure, include_waters=False)

Extract non-polymer ligand metadata from mmCIF categories.

Reads _pdbx_entity_nonpoly (joined with _chem_comp for name and formula) for one record per distinct (entity, ligand) pair. Falls back to deriving ligand records from HETATM records in structure.atoms when neither category is present.

Parameters:

Name Type Description Default
structure Structure

The parsed structure to extract ligands from.

required
include_waters bool

If True, include water molecules as ligands.

False

Returns:

Type Description
list[LigandMetadataRecord]

A LigandMetadataRecord per distinct ligand entity.

Source code in pandora/metadata/mmcif.py
def extract_ligand_metadata(
    structure: Structure,
    include_waters: bool = False,
) -> list[LigandMetadataRecord]:
    """Extract non-polymer ligand metadata from mmCIF categories.

    Reads `_pdbx_entity_nonpoly` (joined with `_chem_comp` for name and
    formula) for one record per distinct (entity, ligand) pair. Falls
    back to deriving ligand records from HETATM records in
    `structure.atoms` when neither category is present.

    Args:
        structure: The parsed structure to extract ligands from.
        include_waters: If True, include water molecules as ligands.

    Returns:
        A `LigandMetadataRecord` per distinct ligand entity.
    """

    chem_comp = {
        row.get("id"): row for row in raw_rows(structure, "_chem_comp")
    }
    nonpoly_rows = raw_rows(structure, "_pdbx_entity_nonpoly")

    chain_ids_by_entity: dict[str, list[str]] = defaultdict(list)
    for asym in structure.asym_units:
        chain_ids_by_entity[asym.entity_id].append(asym.id)

    records: list[LigandMetadataRecord] = []
    seen: set[tuple[str | None, str]] = set()

    for row in nonpoly_rows:
        comp_id = clean(row.get("comp_id"))
        if comp_id is None:
            continue
        if not include_waters and comp_id.upper() in WATER_COMP_IDS:
            continue

        entity_id = clean(row.get("entity_id"))
        key = (entity_id, comp_id)
        if key in seen:
            continue
        seen.add(key)

        chem = chem_comp.get(comp_id, {})
        records.append(
            LigandMetadataRecord(
                entity_id=entity_id,
                comp_id=comp_id,
                name=first_value(row.get("name"), chem.get("name")),
                formula=clean(chem.get("formula")),
                formula_weight=as_float(chem.get("formula_weight")),
                chain_ids=chain_ids_by_entity.get(entity_id or "", []),
                provenance=provenance("_pdbx_entity_nonpoly", entity_id),
            )
        )

    if records:
        return records

    return _ligands_from_atoms(structure, include_waters)

extract_metadata(structure, category, keys=None)

Compatibility wrapper around extract_metadata_category().

Parameters:

Name Type Description Default
structure Structure

The parsed structure to read raw categories from.

required
category str

The mmCIF category name (e.g. "_entity").

required
keys Iterable[str] | None

If given, restrict each row to these column names; otherwise return every column present in the category.

None

Returns:

Type Description
list[RawRow]

A list of rows, each a mapping from column name to its

list[RawRow]

(already null-normalized) string value.

Source code in pandora/metadata/mmcif.py
def extract_metadata(
    structure: Structure,
    category: str,
    keys: Iterable[str] | None = None,
) -> list[RawRow]:
    """Compatibility wrapper around `extract_metadata_category()`.

    Args:
        structure: The parsed structure to read raw categories from.
        category: The mmCIF category name (e.g. "_entity").
        keys: If given, restrict each row to these column names;
            otherwise return every column present in the category.

    Returns:
        A list of rows, each a mapping from column name to its
        (already null-normalized) string value.
    """

    return extract_metadata_category(structure, category, keys)

extract_metadata_category(structure, category, columns=None)

Return raw mmCIF rows for one category.

This is the escape hatch for metadata that Pandora has not promoted to a typed schema yet.

Parameters:

Name Type Description Default
structure Structure

The parsed structure to read raw categories from.

required
category str

The mmCIF category name (e.g. "_entity").

required
columns Iterable[str] | None

If given, restrict each row to these column names (missing columns come back as None); otherwise return every column present in the category.

None

Returns:

Type Description
list[RawRow]

A list of rows, each a mapping from column name to its

list[RawRow]

(already null-normalized) string value.

Source code in pandora/metadata/mmcif.py
def extract_metadata_category(
    structure: Structure,
    category: str,
    columns: Iterable[str] | None = None,
) -> list[RawRow]:
    """Return raw mmCIF rows for one category.

    This is the escape hatch for metadata that Pandora has not promoted to a
    typed schema yet.

    Args:
        structure: The parsed structure to read raw categories from.
        category: The mmCIF category name (e.g. "_entity").
        columns: If given, restrict each row to these column names
            (missing columns come back as `None`); otherwise return
            every column present in the category.

    Returns:
        A list of rows, each a mapping from column name to its
        (already null-normalized) string value.
    """

    rows = raw_rows(structure, category)
    if columns is None:
        return [dict(row) for row in rows]

    selected = list(columns)
    return [{column: row.get(column) for column in selected} for row in rows]

extract_quality(structure)

Extract experimental quality metrics reported in mmCIF.

Reads the _exptl, _refine, _reflns and _em_3d_reconstruction categories for the experimental method, resolution, R-work/R-free, Rsym/Rmerge, reflection counts, and mean B-factor. Resolution comes from _refine, falling back to _em_3d_reconstruction (cryo-EM).

Parameters:

Name Type Description Default
structure Structure

The parsed structure to extract quality data from.

required

Returns:

Type Description
QualityRecord | None

A QualityRecord, or None if none of _exptl, _refine,

QualityRecord | None

_reflns or _em_3d_reconstruction are present in the

QualityRecord | None

structure.

Source code in pandora/metadata/mmcif.py
def extract_quality(structure: Structure) -> QualityRecord | None:
    """Extract experimental quality metrics reported in mmCIF.

    Reads the `_exptl`, `_refine`, `_reflns` and
    `_em_3d_reconstruction` categories for the experimental method,
    resolution, R-work/R-free, Rsym/Rmerge, reflection counts, and mean
    B-factor. Resolution comes from `_refine`, falling back to
    `_em_3d_reconstruction` (cryo-EM).

    Args:
        structure: The parsed structure to extract quality data from.

    Returns:
        A `QualityRecord`, or `None` if none of `_exptl`, `_refine`,
        `_reflns` or `_em_3d_reconstruction` are present in the
        structure.
    """

    exptl_rows = raw_rows(structure, "_exptl")
    refine = first_row(structure, "_refine")
    reflns = first_row(structure, "_reflns")
    em = first_row(structure, "_em_3d_reconstruction")

    if not exptl_rows and not refine and not reflns and not em:
        return None

    methods = [
        method
        for method in (clean(row.get("method")) for row in exptl_rows)
        if method is not None
    ]

    categories = "_exptl,_refine,_reflns"
    resolution = as_float(refine.get("ls_d_res_high"))
    if resolution is None:
        resolution = as_float(em.get("resolution"))
        if resolution is not None:
            categories += ",_em_3d_reconstruction"

    return QualityRecord(
        experimental_method="; ".join(methods) if methods else None,
        resolution=resolution,
        r_work=as_float(refine.get("ls_R_factor_R_work")),
        r_free=as_float(refine.get("ls_R_factor_R_free")),
        r_sym=as_float(reflns.get("pdbx_Rsym_value")),
        r_merge=as_float(reflns.get("pdbx_Rmerge_I_obs")),
        observed_reflections=as_int(reflns.get("number_obs")),
        percent_possible_observed=as_float(reflns.get("percent_possible_obs")),
        mean_b_factor=as_float(refine.get("B_iso_mean")),
        provenance=provenance(categories),
    )

extract_taxonomies(structure)

Extract source organism and expression host metadata.

Reads the _entity_src_gen, _entity_src_nat, and _pdbx_entity_src_syn mmCIF categories, covering genetically manipulated, naturally isolated, and synthetic sources respectively.

Parameters:

Name Type Description Default
structure Structure

The parsed structure to extract taxonomies from.

required

Returns:

Type Description
list[TaxonomyRecord]

A TaxonomyRecord per source row found, in category order

list[TaxonomyRecord]

(generated, then natural, then synthetic sources).

Source code in pandora/metadata/mmcif.py
def extract_taxonomies(structure: Structure) -> list[TaxonomyRecord]:
    """Extract source organism and expression host metadata.

    Reads the `_entity_src_gen`, `_entity_src_nat`, and
    `_pdbx_entity_src_syn` mmCIF categories, covering genetically
    manipulated, naturally isolated, and synthetic sources
    respectively.

    Args:
        structure: The parsed structure to extract taxonomies from.

    Returns:
        A `TaxonomyRecord` per source row found, in category order
        (generated, then natural, then synthetic sources).
    """

    taxonomies = [
        _taxonomy_from_generated_source(row)
        for row in raw_rows(structure, "_entity_src_gen")
    ]
    taxonomies.extend(
        _taxonomy_from_natural_source(row)
        for row in raw_rows(structure, "_entity_src_nat")
    )
    taxonomies.extend(
        _taxonomy_from_synthetic_source(row)
        for row in raw_rows(structure, "_pdbx_entity_src_syn")
    )
    return taxonomies

extract_taxonomy(structure)

Return the first taxonomy record, if present.

Parameters:

Name Type Description Default
structure Structure

The parsed structure to extract taxonomy from.

required

Returns:

Type Description
TaxonomyRecord | None

The first TaxonomyRecord from extract_taxonomies(), or

TaxonomyRecord | None

None if the structure has no source records.

Source code in pandora/metadata/mmcif.py
def extract_taxonomy(structure: Structure) -> TaxonomyRecord | None:
    """Return the first taxonomy record, if present.

    Args:
        structure: The parsed structure to extract taxonomy from.

    Returns:
        The first `TaxonomyRecord` from `extract_taxonomies()`, or
        `None` if the structure has no source records.
    """

    taxonomies = extract_taxonomies(structure)
    return taxonomies[0] if taxonomies else None

extract_uniprot_mappings(structure)

Extract UniProt/SIFTS mappings reported in the mmCIF file.

Reads _struct_ref/_struct_ref_seq and _pdbx_sifts_unp_segments, deduplicating mappings that describe the same entity/asym/accession/range.

Parameters:

Name Type Description Default
structure Structure

The parsed structure to extract mappings from.

required

Returns:

Type Description
list[UniProtMappingRecord]

A UniProtMappingRecord per distinct mapping segment found

list[UniProtMappingRecord]

across both source categories.

Source code in pandora/metadata/mmcif.py
def extract_uniprot_mappings(
    structure: Structure,
) -> list[UniProtMappingRecord]:
    """Extract UniProt/SIFTS mappings reported in the mmCIF file.

    Reads `_struct_ref`/`_struct_ref_seq` and
    `_pdbx_sifts_unp_segments`, deduplicating mappings that describe
    the same entity/asym/accession/range.

    Args:
        structure: The parsed structure to extract mappings from.

    Returns:
        A `UniProtMappingRecord` per distinct mapping segment found
        across both source categories.
    """

    records: list[UniProtMappingRecord] = []
    seen: set[tuple[Any, ...]] = set()

    struct_ref = {
        row.get("id"): row for row in raw_rows(structure, "_struct_ref")
    }

    for row in raw_rows(structure, "_struct_ref_seq"):
        ref = struct_ref.get(row.get("ref_id"), {})
        accession = first_value(
            row.get("pdbx_db_accession"),
            ref.get("pdbx_db_accession"),
        )
        if accession is None:
            continue
        record = UniProtMappingRecord(
            entity_id=clean(ref.get("entity_id")),
            asym_id=clean(row.get("pdbx_strand_id")),
            accession=accession,
            db_name=clean(ref.get("db_name")),
            db_code=clean(ref.get("db_code")),
            seq_id_start=as_int(row.get("seq_align_beg")),
            seq_id_end=as_int(row.get("seq_align_end")),
            uniprot_start=as_int(row.get("db_align_beg")),
            uniprot_end=as_int(row.get("db_align_end")),
            provenance=provenance("_struct_ref_seq", row.get("align_id")),
        )
        _append_mapping(records, seen, record)

    for row in raw_rows(structure, "_pdbx_sifts_unp_segments"):
        accession = clean(row.get("unp_acc"))
        if accession is None:
            continue
        record = UniProtMappingRecord(
            entity_id=clean(row.get("entity_id")),
            asym_id=clean(row.get("asym_id")),
            accession=accession,
            db_name="UNP",
            seq_id_start=as_int(row.get("seq_id_start")),
            seq_id_end=as_int(row.get("seq_id_end")),
            uniprot_start=as_int(row.get("unp_start")),
            uniprot_end=as_int(row.get("unp_end")),
            identity=as_float(row.get("identity")),
            provenance=provenance(
                "_pdbx_sifts_unp_segments", row.get("segment_id")
            ),
        )
        _append_mapping(records, seen, record)

    return records

pandora.annotations

annotate_chain_interfaces(structure, distance_cutoff=4.0, atom_set='heavy', polymer_types=None)

Compute polymer chain pairs with residues in contact within a cutoff.

For each pair of polymer chains, finds residue pairs (one per chain) with at least one pair of selected atoms within distance_cutoff angstroms. Chain pairs whose bounding boxes are further apart than the cutoff are skipped without comparing atoms.

Parameters:

Name Type Description Default
structure Structure

The structure to scan for chain-chain contacts.

required
distance_cutoff float

Contact distance in angstroms.

4.0
atom_set ContactAtomSet

Which atoms count: "all", "heavy" (every atom except H/D), "backbone" (protein N/CA/C/O, nucleic-acid O5'/C5'/C4'/C3'/O3'), or "ca".

'heavy'
polymer_types list[str] | None

Keep only chains whose entity polymer type (e.g. "polypeptide(L)") is listed. None keeps every polymer chain; an empty list keeps none.

None

Returns:

Type Description
AnnotationLayer

An AnnotationLayer of type "chain_interfaces" whose data

AnnotationLayer

holds the settings used and, per chain pair in contact, the

AnnotationLayer

contacting residue ids on each side and the residue pairs.

Raises:

Type Description
ValueError

atom_set is not one of the supported values.

TypeError

polymer_types is a single string, not a list.

Source code in pandora/annotations/entry.py
def annotate_chain_interfaces(
    structure: Structure,
    distance_cutoff: float = 4.0,
    atom_set: ContactAtomSet = "heavy",
    polymer_types: list[str] | None = None,
) -> AnnotationLayer:
    """Compute polymer chain pairs with residues in contact within a cutoff.

    For each pair of polymer chains, finds residue pairs (one per chain)
    with at least one pair of selected atoms within `distance_cutoff`
    angstroms. Chain pairs whose bounding boxes are further apart than
    the cutoff are skipped without comparing atoms.

    Args:
        structure: The structure to scan for chain-chain contacts.
        distance_cutoff: Contact distance in angstroms.
        atom_set: Which atoms count: "all", "heavy" (every atom except
            H/D), "backbone" (protein N/CA/C/O, nucleic-acid
            O5'/C5'/C4'/C3'/O3'), or "ca".
        polymer_types: Keep only chains whose entity polymer type
            (e.g. "polypeptide(L)") is listed. None keeps every
            polymer chain; an empty list keeps none.

    Returns:
        An `AnnotationLayer` of type "chain_interfaces" whose `data`
        holds the settings used and, per chain pair in contact, the
        contacting residue ids on each side and the residue pairs.

    Raises:
        ValueError: `atom_set` is not one of the supported values.
        TypeError: `polymer_types` is a single string, not a list.
    """

    if isinstance(polymer_types, str):
        # A bare string would match by substring here and be recorded
        # character by character, so the recipe would rebuild differently.
        raise TypeError(
            "polymer_types must be a list of polymer types, e.g. "
            f"[{polymer_types!r}], not a single string"
        )
    if atom_set not in CONTACT_ATOM_SETS:
        raise ValueError(
            f"atom_set must be one of {sorted(CONTACT_ATOM_SETS)}, "
            f"got {atom_set!r}"
        )

    selected = _select_contact_atoms(structure, atom_set, polymer_types)
    boxes = {
        chain_id: _bounding_box(atoms) for chain_id, atoms in selected.items()
    }
    grids = {
        chain_id: _spatial_grid(atoms, distance_cutoff)
        for chain_id, atoms in selected.items()
    }
    cutoff_sq = distance_cutoff * distance_cutoff

    interfaces = []
    for chain_a, chain_b in combinations(sorted(selected), 2):
        if _boxes_apart(boxes[chain_a], boxes[chain_b], distance_cutoff):
            continue
        pairs = _chain_pair_contacts(
            selected[chain_a],
            boxes[chain_b],
            grids[chain_b],
            distance_cutoff,
            cutoff_sq,
        )
        if not pairs:
            continue
        residues_a = sorted({residue_a for residue_a, _ in pairs})
        residues_b = sorted({residue_b for _, residue_b in pairs})
        interfaces.append(
            {
                "chain_id_1": chain_a,
                "chain_id_2": chain_b,
                "interface_residues_chain_1": residues_a,
                "interface_residues_chain_2": residues_b,
                "residue_pairs": [list(pair) for pair in sorted(pairs)],
                "contact_count": len(residues_a) + len(residues_b),
            }
        )

    return AnnotationLayer(
        layer_name="Chain-chain interfaces",
        layer_type="chain_interfaces",
        scope="interface",
        method="pandora.basic.distance_cutoff_contacts.v2",
        target_ids=[structure.entry_id],
        parameters=_contact_settings(distance_cutoff, atom_set, polymer_types),
        data={
            **_contact_settings(distance_cutoff, atom_set, polymer_types),
            "interfaces": interfaces,
        },
        provenance={"inputs": ["Structure.atoms", "Structure.entities"]},
    )

annotate_ligand_contacts(structure, distance_cutoff=4.0, include_waters=False)

Compute polymer residues near non-polymer atoms within a cutoff.

Groups HETATM records into ligands (by asym/auth_seq/comp id) and, for each ligand, finds polymer residues with at least one atom within distance_cutoff angstroms of a ligand atom.

Parameters:

Name Type Description Default
structure Structure

The structure to scan for ligand contacts.

required
distance_cutoff float

Contact distance in angstroms.

4.0
include_waters bool

If True, treat water residues as ligands too.

False

Returns:

Type Description
AnnotationLayer

An AnnotationLayer of type "ligand_contacts" whose data

AnnotationLayer

holds the cutoff/flags used and, per ligand, the contacting

AnnotationLayer

polymer residues with their nearest distance.

Source code in pandora/annotations/entry.py
def annotate_ligand_contacts(
    structure: Structure,
    distance_cutoff: float = 4.0,
    include_waters: bool = False,
) -> AnnotationLayer:
    """Compute polymer residues near non-polymer atoms within a cutoff.

    Groups HETATM records into ligands (by asym/auth_seq/comp id) and,
    for each ligand, finds polymer residues with at least one atom
    within `distance_cutoff` angstroms of a ligand atom.

    Args:
        structure: The structure to scan for ligand contacts.
        distance_cutoff: Contact distance in angstroms.
        include_waters: If True, treat water residues as ligands too.

    Returns:
        An `AnnotationLayer` of type "ligand_contacts" whose `data`
        holds the cutoff/flags used and, per ligand, the contacting
        polymer residues with their nearest distance.
    """

    polymer_atoms = [
        atom for atom in structure.atoms if atom.group_PDB == "ATOM"
    ]
    ligand_atoms = [
        atom
        for atom in structure.atoms
        if _is_ligand_atom(atom, include_waters)
    ]

    ligand_groups: dict[tuple[str, str | None, str], list[AtomSiteRecord]]
    ligand_groups = defaultdict(list)
    for atom in ligand_atoms:
        ligand_groups[
            (atom.label_asym_id, atom.auth_seq_id, atom.label_comp_id)
        ].append(atom)

    cutoff_sq = distance_cutoff * distance_cutoff
    polymer_grid = _spatial_grid(polymer_atoms, distance_cutoff)
    contacts = []
    for ligand_key, atoms in ligand_groups.items():
        residue_contacts = _residues_within_cutoff(
            atoms, polymer_grid, distance_cutoff, cutoff_sq
        )
        contacts.append(
            {
                "ligand_asym_id": ligand_key[0],
                "ligand_auth_seq_id": ligand_key[1],
                "ligand_comp_id": ligand_key[2],
                "contact_count": len(residue_contacts),
                "contacts": residue_contacts,
            }
        )

    return AnnotationLayer(
        layer_name="Ligand contacts",
        layer_type="ligand_contacts",
        scope="entry",
        method="pandora.basic.distance_cutoff_contacts.v1",
        target_ids=[structure.entry_id],
        parameters={
            "distance_cutoff": distance_cutoff,
            "include_waters": include_waters,
        },
        data={
            "distance_cutoff": distance_cutoff,
            "include_waters": include_waters,
            "ligands": contacts,
        },
        provenance={"inputs": ["Structure.atoms"]},
    )

annotate_pairwise_sequence_identity(left, right)

Compute a simple ungapped sequence identity between two entries.

Compares every polymer entity in left against every polymer entity in right with a position-wise (ungapped, no alignment) identity, then reports the best-scoring pair.

Parameters:

Name Type Description Default
left Structure

The first structure to compare.

required
right Structure

The second structure to compare.

required

Returns:

Type Description
AnnotationLayer

An AnnotationLayer of type "pairwise_sequence_identity" whose

AnnotationLayer

data holds the best identity score, the best-matching entity

AnnotationLayer

pair, and every entity-pair comparison.

Source code in pandora/annotations/pairwise.py
def annotate_pairwise_sequence_identity(
    left: Structure,
    right: Structure,
) -> AnnotationLayer:
    """Compute a simple ungapped sequence identity between two entries.

    Compares every polymer entity in `left` against every polymer
    entity in `right` with a position-wise (ungapped, no alignment)
    identity, then reports the best-scoring pair.

    Args:
        left: The first structure to compare.
        right: The second structure to compare.

    Returns:
        An `AnnotationLayer` of type "pairwise_sequence_identity" whose
        `data` holds the best identity score, the best-matching entity
        pair, and every entity-pair comparison.
    """

    comparisons = []
    for left_entity_id, left_sequence in _entity_sequences(left):
        for right_entity_id, right_sequence in _entity_sequences(right):
            comparisons.append(
                {
                    "left_entity_id": left_entity_id,
                    "right_entity_id": right_entity_id,
                    **_ungapped_identity(left_sequence, right_sequence),
                }
            )

    best = max(
        comparisons,
        key=lambda item: item["identity"] or 0.0,
        default=None,
    )

    return AnnotationLayer(
        layer_name="Pairwise sequence identity",
        layer_type="pairwise_sequence_identity",
        scope="pair",
        method="pandora.basic.ungapped_entity_identity.v1",
        target_ids=[left.entry_id, right.entry_id],
        data={
            "best_identity": best["identity"] if best else None,
            "best_match": best,
            "comparisons": comparisons,
        },
        provenance={"inputs": ["EntityRecord.poly"]},
    )

annotate_structure_counts(structure)

Compute simple per-entry counts from the canonical structure.

Parameters:

Name Type Description Default
structure Structure

The canonical structure to summarize.

required

Returns:

Type Description
AnnotationLayer

An AnnotationLayer of type "structure_counts" whose data

AnnotationLayer

holds atom/residue/asym/entity/assembly/connection counts,

AnnotationLayer

secondary-structure record counts, entity-type and atom-group

AnnotationLayer

breakdowns, and the altloc atom count.

Source code in pandora/annotations/entry.py
def annotate_structure_counts(structure: Structure) -> AnnotationLayer:
    """Compute simple per-entry counts from the canonical structure.

    Args:
        structure: The canonical structure to summarize.

    Returns:
        An `AnnotationLayer` of type "structure_counts" whose `data`
        holds atom/residue/asym/entity/assembly/connection counts,
        secondary-structure record counts, entity-type and atom-group
        breakdowns, and the altloc atom count.
    """

    residue_keys = {
        (
            atom.label_asym_id,
            atom.label_seq_id,
            atom.auth_seq_id,
            atom.label_comp_id,
        )
        for atom in structure.atoms
    }
    entity_type_counts = Counter(entity.type for entity in structure.entities)
    atom_group_counts = Counter(atom.group_PDB for atom in structure.atoms)
    altloc_atom_count = sum(1 for atom in structure.atoms if atom.label_alt_id)

    return AnnotationLayer(
        layer_name="Structure counts",
        layer_type="structure_counts",
        scope="entry",
        method="pandora.basic.structure_counts.v1",
        target_ids=[structure.entry_id],
        data={
            "atom_count": len(structure.atoms),
            "residue_count": len(residue_keys),
            "asym_unit_count": len(structure.asym_units),
            "entity_count": len(structure.entities),
            "assembly_count": len(structure.assemblies),
            "connection_count": len(structure.connections),
            "secondary_structure": {
                "conf_records": len(structure.secondary_structure.conf_records),
                "sheet_strands": len(
                    structure.secondary_structure.sheet_strands
                ),
            },
            "entity_type_counts": dict(entity_type_counts),
            "atom_group_counts": dict(atom_group_counts),
            "altloc_atom_count": altloc_atom_count,
        },
        provenance={"inputs": ["Structure.atoms", "Structure.entities"]},
    )

pandora.datasets

chain_completeness(structure, definition='incomplete_backbone')

Count the missing residues of each polymer chain against SEQRES.

SEQRES is _entity_poly_seq; a residue lines up with it by label_seq_id. A chain that has no SEQRES, or whose residues don't line up (for example after renumbering), gets no record and a NO_SEQRES or SEQRES_MISMATCH warning instead.

Parameters:

Name Type Description Default
structure Structure

The structure to measure. Its label_seq_ids must still index SEQRES.

required
definition MissingResidueDefinition

"unobserved" counts a residue as missing when it has no atoms; "incomplete_backbone" also when any backbone atom (N, CA, C, O for protein) is missing.

'incomplete_backbone'

Returns:

Type Description
list[ChainCompleteness]

(records, diagnostics): one ChainCompleteness per measured

DiagnosticBundle

polymer chain, and a warning per chain that could not be

tuple[list[ChainCompleteness], DiagnosticBundle]

measured.

Source code in pandora/datasets/completeness.py
def chain_completeness(
    structure: Structure,
    definition: MissingResidueDefinition = "incomplete_backbone",
) -> tuple[list[ChainCompleteness], DiagnosticBundle]:
    """Count the missing residues of each polymer chain against SEQRES.

    SEQRES is `_entity_poly_seq`; a residue lines up with it by
    `label_seq_id`. A chain that has no SEQRES, or whose residues don't
    line up (for example after renumbering), gets no record and a
    `NO_SEQRES` or `SEQRES_MISMATCH` warning instead.

    Args:
        structure: The structure to measure. Its `label_seq_id`s must
            still index SEQRES.
        definition: `"unobserved"` counts a residue as missing when it
            has no atoms; `"incomplete_backbone"` also when any backbone
            atom (N, CA, C, O for protein) is missing.

    Returns:
        `(records, diagnostics)`: one `ChainCompleteness` per measured
        polymer chain, and a warning per chain that could not be
        measured.
    """

    diagnostics = DiagnosticBundle()
    seqres = _seqres_by_entity(structure)
    backbone_by_entity = _backbone_by_entity(structure.entities)
    polymer_entities = {e.id for e in structure.entities if e.type == "polymer"}

    # chain -> seq_id -> (comp_id, atom names)
    residues: dict[str, dict[int, tuple[str, set[str]]]] = defaultdict(dict)
    for a in structure.atoms:
        if a.label_seq_id is None:
            continue
        chain = residues[a.label_asym_id]
        if a.label_seq_id not in chain:
            chain[a.label_seq_id] = (a.label_comp_id, set())
        chain[a.label_seq_id][1].add(a.label_atom_id)

    records: list[ChainCompleteness] = []
    for asym in structure.asym_units:
        if asym.entity_id not in polymer_entities:
            continue
        positions = seqres.get(asym.entity_id, {})
        chain_residues = residues.get(asym.id, {})
        code = None
        if not positions:
            code = "NO_SEQRES"
        elif not _matches_seqres(chain_residues, positions):
            code = "SEQRES_MISMATCH"
        if code is not None:
            diagnostics.warnings.append(
                Diagnostic(
                    code=code,
                    severity="warning",
                    message=f"Chain {asym.id}: completeness not measured "
                    f"({code})",
                    entry_id=structure.entry_id,
                    context={"chain": asym.id},
                )
            )
            continue

        length = len(positions)
        backbone = backbone_by_entity.get(asym.entity_id, _PROTEIN_BACKBONE)
        present = sorted(
            seq_id
            for seq_id, (_, names) in chain_residues.items()
            if definition == "unobserved" or backbone <= names
        )
        if present:
            first, last = present[0], present[-1]
            n_term, c_term = first - 1, length - last
            middle = (last - first + 1) - len(present)
        else:
            n_term, c_term, middle = length, 0, 0
        records.append(
            ChainCompleteness(
                entry_id=structure.entry_id,
                chain_id=asym.id,
                seqres_length=length,
                missing_n_term=n_term,
                missing_c_term=c_term,
                missing_middle=middle,
            )
        )
    return records, diagnostics

curate_structure(structure, metadata, policy)

Apply quality, organism, and content rules to one structure.

Parameters:

Name Type Description Default
structure Structure

The canonical structure to curate.

required
metadata MetadataRecord | None

The structure's MetadataRecord, if available. Without it, quality/organism checks that depend on missing data (e.g. null resolution) apply their configured default rather than being skipped.

required
policy DatasetCurationPolicy

The curation policy governing every rule applied.

required

Returns:

Type Description
Structure | None

(curated_structure, exclusions, provenance). When the entry

list[ExclusionRecord]

is kept, curated_structure has content rules applied and

CurationProvenance

failing chains removed, and exclusions holds one record per

tuple[Structure | None, list[ExclusionRecord], CurationProvenance]

removed chain. When the entry is excluded, curated_structure

tuple[Structure | None, list[ExclusionRecord], CurationProvenance]

is None and the last record in exclusions has

tuple[Structure | None, list[ExclusionRecord], CurationProvenance]

chain_id=None. Provenance (which policy id/version ran, and

tuple[Structure | None, list[ExclusionRecord], CurationProvenance]

when) is always populated. Removing chains or ligands also

tuple[Structure | None, list[ExclusionRecord], CurationProvenance]

removes the Structure.raw categories that refer to chains or

tuple[Structure | None, list[ExclusionRecord], CurationProvenance]

residues by id; they are listed in

tuple[Structure | None, list[ExclusionRecord], CurationProvenance]

provenance.dropped_raw_categories.

Source code in pandora/datasets/curation.py
def curate_structure(
    structure: Structure,
    metadata: MetadataRecord | None,
    policy: DatasetCurationPolicy,
) -> tuple[Structure | None, list[ExclusionRecord], CurationProvenance]:
    """Apply quality, organism, and content rules to one structure.

    Args:
        structure: The canonical structure to curate.
        metadata: The structure's `MetadataRecord`, if available.
            Without it, quality/organism checks that depend on missing
            data (e.g. null resolution) apply their configured default
            rather than being skipped.
        policy: The curation policy governing every rule applied.

    Returns:
        `(curated_structure, exclusions, provenance)`. When the entry
        is kept, `curated_structure` has content rules applied and
        failing chains removed, and `exclusions` holds one record per
        removed chain. When the entry is excluded, `curated_structure`
        is None and the last record in `exclusions` has
        `chain_id=None`. Provenance (which policy id/version ran, and
        when) is always populated. Removing chains or ligands also
        removes the `Structure.raw` categories that refer to chains or
        residues by id; they are listed in
        `provenance.dropped_raw_categories`.
    """

    provenance = CurationProvenance(
        curated_at=now_iso(),
        policy_id=policy.policy_id,
        policy_name=policy.policy_name,
        policy_version=policy.policy_version,
    )

    rules = policy.quality_rules
    exclusion = (
        _check_quality(structure, metadata, rules)
        or _check_organism(structure, metadata, policy.organism_rules)
        or _check_composition(structure, rules)
    )
    if exclusion is not None:
        return None, [exclusion], provenance

    original_raw = structure.raw
    records = _chain_exclusions(structure, rules)
    if records:
        structure = _drop_chains(structure, {r.chain_id for r in records})
    exclusion = _check_chain_counts(structure, rules, bool(records))
    if exclusion is not None:
        return None, [*records, exclusion], provenance

    curated = _apply_content_rules(structure, policy.content_rules)
    dropped = sorted(set(original_raw) - set(curated.raw))
    return (
        curated,
        records,
        provenance.model_copy(update={"dropped_raw_categories": dropped}),
    )

deduplicate_structures(structures, rules)

Remove structures sharing the same Structure.entry_id.

Parameters:

Name Type Description Default
structures list[Structure]

Structures to deduplicate, in order. The first occurrence of each duplicate entry_id is kept.

required
rules DeduplicationRules

Ignored (returns structures unchanged) if rules.enabled is False.

required

Returns:

Type Description
list[Structure]

(retained, removed, provenance) — the deduplicated

list[ExclusionRecord]

structures, an ExclusionRecord (reason_code="DUPLICATE") per

DeduplicationProvenance

structure removed, and a record of how many duplicates were found.

Source code in pandora/datasets/curation.py
def deduplicate_structures(
    structures: list[Structure], rules: DeduplicationRules
) -> tuple[list[Structure], list[ExclusionRecord], DeduplicationProvenance]:
    """Remove structures sharing the same `Structure.entry_id`.

    Args:
        structures: Structures to deduplicate, in order. The first
            occurrence of each duplicate entry_id is kept.
        rules: Ignored (returns `structures` unchanged) if
            `rules.enabled` is False.

    Returns:
        `(retained, removed, provenance)` — the deduplicated
        structures, an `ExclusionRecord` (reason_code="DUPLICATE") per
        structure removed, and a record of how many duplicates were found.
    """

    if not rules.enabled:
        return (
            structures,
            [],
            DeduplicationProvenance(deduplicated_at=now_iso(), enabled=False),
        )

    seen: set[str] = set()
    retained: list[Structure] = []
    removed: list[ExclusionRecord] = []
    for structure in structures:
        if structure.entry_id in seen:
            removed.append(
                ExclusionRecord(
                    entry_id=structure.entry_id,
                    reason_code="DUPLICATE",
                    message="duplicate by entry_id",
                )
            )
            continue
        seen.add(structure.entry_id)
        retained.append(structure)

    provenance = DeduplicationProvenance(
        deduplicated_at=now_iso(),
        enabled=True,
        duplicates_found=len(removed),
    )
    return retained, removed, provenance

entry_sequences(structures)

One representative sequence per entry: its longest polymer chain.

Chain-keyed sequence similarity (e.g. compute_sequence_similarity on ChainRecords) returns relationships keyed like "1abc_A", which won't match entry-keyed item_ids (e.g. for clustering) — this picks one sequence per entry instead.

Parameters:

Name Type Description Default
structures dict[str, Structure]

Structures keyed by entry_id.

required

Returns:

Type Description
dict[str, str]

A mapping of entry_id -> sequence, one entry per structure that

dict[str, str]

has at least one polymer chain with a sequence; entries with

dict[str, str]

none are omitted.

Source code in pandora/datasets/records.py
def entry_sequences(structures: dict[str, Structure]) -> dict[str, str]:
    """One representative sequence per entry: its longest polymer chain.

    Chain-keyed sequence similarity (e.g. `compute_sequence_similarity` on
    `ChainRecord`s) returns relationships keyed like "1abc_A", which won't
    match entry-keyed item_ids (e.g. for clustering) — this picks one
    sequence per entry instead.

    Args:
        structures: Structures keyed by entry_id.

    Returns:
        A mapping of entry_id -> sequence, one entry per structure that
        has at least one polymer chain with a sequence; entries with
        none are omitted.
    """

    sequences: dict[str, str] = {}
    for entry_id, structure in structures.items():
        chains = [c for c in extract_chain_records(structure) if c.sequence]
        if chains:
            sequences[entry_id] = max(
                chains, key=lambda c: len(c.sequence)
            ).sequence
    return sequences

extract_chain_records(structure)

Build one ChainRecord per polymer chain (asym unit) in structure.

Parameters:

Name Type Description Default
structure Structure

The canonical structure to extract chain records from. Non-polymer asym units, and polymer asym units with no atoms, are skipped.

required

Returns:

Type Description
list[ChainRecord]

A ChainRecord per polymer chain, with its sequence (from the

list[ChainRecord]

entity's polymer record, if any), residue count, and atom count.

Source code in pandora/datasets/records.py
def extract_chain_records(structure: Structure) -> list[ChainRecord]:
    """Build one ChainRecord per polymer chain (asym unit) in `structure`.

    Args:
        structure: The canonical structure to extract chain records
            from. Non-polymer asym units, and polymer asym units with
            no atoms, are skipped.

    Returns:
        A `ChainRecord` per polymer chain, with its sequence (from the
        entity's polymer record, if any), residue count, and atom count.
    """

    entities_by_id = {entity.id: entity for entity in structure.entities}
    atoms_by_chain = atoms_by_asym_id(structure)

    records = []
    for asym in structure.asym_units:
        entity = entities_by_id.get(asym.entity_id)
        if entity is None or entity.type != "polymer":
            continue

        chain_atoms = atoms_by_chain.get(asym.id, [])
        if not chain_atoms:
            continue

        residue_count = len(
            {(atom.label_seq_id, atom.auth_seq_id) for atom in chain_atoms}
        )
        sequence = None
        if entity.poly is not None:
            sequence = (
                entity.poly.pdbx_seq_one_letter_code_can
                or entity.poly.pdbx_seq_one_letter_code
            )

        records.append(
            ChainRecord(
                entry_id=structure.entry_id,
                chain_id=asym.id,
                auth_chain_id=asym.auth_id,
                entity_id=asym.entity_id,
                sequence=sequence,
                residue_count=residue_count,
                atom_count=len(chain_atoms),
            )
        )
    return records

extract_interface_records(structure, distance_cutoff=DEFAULT_INTERFACE_CUTOFF, atom_set='heavy', polymer_types=None)

Build one InterfaceRecord per polymer chain pair in contact.

Contact detection itself lives in annotations.entry.annotate_chain_interfaces — this function only reshapes that annotation layer's output into records.

Parameters:

Name Type Description Default
structure Structure

The canonical structure to extract interface records from.

required
distance_cutoff float

Contact distance in angstroms.

DEFAULT_INTERFACE_CUTOFF
atom_set ContactAtomSet

Which atoms count towards contacts; see annotate_chain_interfaces.

'heavy'
polymer_types list[str] | None

Keep only chains of these entity polymer types; None keeps every polymer chain.

None

Returns:

Type Description
list[InterfaceRecord]

An InterfaceRecord per polymer chain pair with at least one

list[InterfaceRecord]

contact within distance_cutoff.

Raises:

Type Description
ValueError

atom_set is not one of the supported values.

TypeError

polymer_types is a single string, not a list.

Source code in pandora/datasets/records.py
def extract_interface_records(
    structure: Structure,
    distance_cutoff: float = DEFAULT_INTERFACE_CUTOFF,
    atom_set: ContactAtomSet = "heavy",
    polymer_types: list[str] | None = None,
) -> list[InterfaceRecord]:
    """Build one InterfaceRecord per polymer chain pair in contact.

    Contact detection itself lives in
    `annotations.entry.annotate_chain_interfaces` — this function only
    reshapes that annotation layer's output into records.

    Args:
        structure: The canonical structure to extract interface
            records from.
        distance_cutoff: Contact distance in angstroms.
        atom_set: Which atoms count towards contacts; see
            `annotate_chain_interfaces`.
        polymer_types: Keep only chains of these entity polymer types;
            None keeps every polymer chain.

    Returns:
        An `InterfaceRecord` per polymer chain pair with at least one
        contact within `distance_cutoff`.

    Raises:
        ValueError: `atom_set` is not one of the supported values.
        TypeError: `polymer_types` is a single string, not a list.
    """

    layer = annotate_chain_interfaces(
        structure, distance_cutoff, atom_set, polymer_types
    )
    return [
        InterfaceRecord(
            entry_id=structure.entry_id,
            chain_id_1=interface["chain_id_1"],
            chain_id_2=interface["chain_id_2"],
            distance_cutoff=distance_cutoff,
            atom_set=atom_set,
            interface_residues_chain_1=interface["interface_residues_chain_1"],
            interface_residues_chain_2=interface["interface_residues_chain_2"],
            contact_count=interface["contact_count"],
            residue_pairs=[tuple(pair) for pair in interface["residue_pairs"]],
        )
        for interface in layer.data["interfaces"]
    ]

extract_residue_records(structure)

Build one ResidueRecord per polymer residue in structure.

Each record carries its full atom list (coordinates, B-factor, etc.) rather than a bare count, so residue-level ML use cases (contact maps, solvent exposure) don't need to go back to the structure.

Parameters:

Name Type Description Default
structure Structure

The canonical structure to extract residue records from. Only atoms belonging to polymer asym units are considered.

required

Returns:

Type Description
list[ResidueRecord]

A ResidueRecord per distinct polymer residue, sorted by chain

list[ResidueRecord]

then sequence id, each carrying every atom belonging to it.

Source code in pandora/datasets/records.py
def extract_residue_records(structure: Structure) -> list[ResidueRecord]:
    """Build one ResidueRecord per polymer residue in `structure`.

    Each record carries its full atom list (coordinates, B-factor, etc.)
    rather than a bare count, so residue-level ML use cases (contact
    maps, solvent exposure) don't need to go back to the structure.

    Args:
        structure: The canonical structure to extract residue records
            from. Only atoms belonging to polymer asym units are
            considered.

    Returns:
        A `ResidueRecord` per distinct polymer residue, sorted by chain
        then sequence id, each carrying every atom belonging to it.
    """

    polymer_ids = polymer_asym_ids(structure)

    residues: dict[
        tuple[str, int | None, str | None, str], list[AtomSiteRecord]
    ] = defaultdict(list)
    for atom in structure.atoms:
        if atom.label_asym_id not in polymer_ids:
            continue
        key = (
            atom.label_asym_id,
            atom.label_seq_id,
            atom.auth_seq_id,
            atom.label_comp_id,
        )
        residues[key].append(atom)

    return [
        ResidueRecord(
            entry_id=structure.entry_id,
            chain_id=chain_id,
            seq_id=seq_id,
            auth_seq_id=auth_seq_id,
            comp_id=comp_id,
            atoms=atoms,
        )
        for (chain_id, seq_id, auth_seq_id, comp_id), atoms in sorted(
            residues.items(), key=lambda item: (item[0][0], item[0][1] or 0)
        )
    ]

pandora.similarity

chain_item_id(entry_id, chain_id)

Item id for one chain's exported file — pair with export_chain_mmcif(structure, chain_id, ...) so the id used for compute_structure_similarity() matches the file it names.

Source code in pandora/similarity/structure.py
def chain_item_id(entry_id: str, chain_id: str) -> str:
    """Item id for one chain's exported file — pair with
    `export_chain_mmcif(structure, chain_id, ...)` so the id used for
    `compute_structure_similarity()` matches the file it names."""

    return f"{entry_id}_{chain_id}"

cluster_edges(item_ids, edges)

Group item ids into connected-component clusters.

Two items land in the same cluster iff connected through a chain of edges. Items with no edges form their own singleton cluster, so every id in item_ids ends up in exactly one cluster. edges is consumed once and never stored, so it can be a stream.

Parameters:

Name Type Description Default
item_ids list[str]

Every item id to place into a cluster.

required
edges Iterable[Sequence[Any]]

Pairs of item ids, as Edges from iter_edges() or any tuples whose first two items are the ids. Duplicates are harmless.

required

Returns:

Type Description
list[SimilarityCluster]

(clusters, provenance) — the clusters (sorted by their

ClusteringProvenance

smallest member), and the edge and cluster counts.

tuple[list[SimilarityCluster], ClusteringProvenance]

provenance.hit_filter and .search are None.

Source code in pandora/similarity/clustering.py
def cluster_edges(
    item_ids: list[str],
    edges: Iterable[Sequence[Any]],
) -> tuple[list[SimilarityCluster], ClusteringProvenance]:
    """Group item ids into connected-component clusters.

    Two items land in the same cluster iff connected through a chain
    of edges. Items with no edges form their own singleton cluster, so
    every id in `item_ids` ends up in exactly one cluster. `edges` is
    consumed once and never stored, so it can be a stream.

    Args:
        item_ids: Every item id to place into a cluster.
        edges: Pairs of item ids, as `Edge`s from `iter_edges()` or any
            tuples whose first two items are the ids. Duplicates are
            harmless.

    Returns:
        `(clusters, provenance)` — the clusters (sorted by their
        smallest member), and the edge and cluster counts.
        `provenance.hit_filter` and `.search` are None.
    """

    parent = {item_id: item_id for item_id in item_ids}

    def find(item_id: str) -> str:
        """Union-find: root id of item_id's cluster, with path compression."""

        while parent[item_id] != item_id:
            parent[item_id] = parent[parent[item_id]]
            item_id = parent[item_id]
        return item_id

    n_edges = 0
    n_unknown = 0
    for edge in edges:
        n_edges += 1
        source_id, target_id = edge[0], edge[1]
        if source_id not in parent or target_id not in parent:
            n_unknown += 1
            continue
        root_source, root_target = find(source_id), find(target_id)
        if root_source != root_target:
            parent[root_target] = root_source

    groups: dict[str, list[str]] = {}
    for item_id in item_ids:
        groups.setdefault(find(item_id), []).append(item_id)

    clusters = [
        SimilarityCluster(components=sorted(members), n_components=len(members))
        for members in sorted(groups.values(), key=lambda members: min(members))
    ]
    provenance = ClusteringProvenance(
        clustered_at=now_iso(),
        n_edges=n_edges,
        n_edges_unknown_ids=n_unknown,
        n_clusters=len(clusters),
    )
    return clusters, provenance

cluster_similar_items(item_ids, search, hit_filter, *, interface_residues=None)

Cluster item ids from a search's hit file, filtered by a policy.

Streams search's hit file through hit_filter (iter_edges()) into cluster_edges(); memory is bounded by the number of items, not hits.

Parameters:

Name Type Description Default
item_ids list[str]

Every item id to place into a cluster.

required
search SimilaritySearch

The search whose hit file to read.

required
hit_filter HitFilter

Which hit rows become edges.

required
interface_residues dict[str, set[int]] | None

Item id -> interface residue positions, needed when hit_filter.min_interface_coverage is set.

None

Returns:

Type Description
list[SimilarityCluster]

(clusters, provenance), with provenance.hit_filter and

ClusteringProvenance

.search recorded so the clustering can be reproduced.

Raises:

Type Description
ValueError

The filter can't apply to the search, or the hit file is malformed (see iter_edges()).

Source code in pandora/similarity/clustering.py
def cluster_similar_items(
    item_ids: list[str],
    search: SimilaritySearch,
    hit_filter: HitFilter,
    *,
    interface_residues: dict[str, set[int]] | None = None,
) -> tuple[list[SimilarityCluster], ClusteringProvenance]:
    """Cluster item ids from a search's hit file, filtered by a policy.

    Streams `search`'s hit file through `hit_filter` (`iter_edges()`)
    into `cluster_edges()`; memory is bounded by the number of items,
    not hits.

    Args:
        item_ids: Every item id to place into a cluster.
        search: The search whose hit file to read.
        hit_filter: Which hit rows become edges.
        interface_residues: Item id -> interface residue positions,
            needed when `hit_filter.min_interface_coverage` is set.

    Returns:
        `(clusters, provenance)`, with `provenance.hit_filter` and
        `.search` recorded so the clustering can be reproduced.

    Raises:
        ValueError: The filter can't apply to the search, or the hit
            file is malformed (see `iter_edges()`).
    """

    clusters, provenance = cluster_edges(
        item_ids,
        iter_edges(search, hit_filter, interface_residues=interface_residues),
    )
    return clusters, provenance.model_copy(
        update={"hit_filter": hit_filter, "search": search}
    )

compute_sequence_similarity(sequences, hits_path, *, mmseqs_bin='mmseqs', sensitivity=5.7, max_seqs=300, tmp_dir=None, mmseqs_options=None)

All-vs-all sequence similarity via MMseqs2 easy-search.

Writes MMseqs2's hits to hits_path as a TSV with the columns in HIT_COLUMNS["MMseqs2"] and keeps it there; nothing is loaded into memory. Read it with iter_edges() / cluster_similar_items().

Parameters:

Name Type Description Default
sequences dict[str, str] | list[ChainRecord] | str | Path

Mapping of item id -> sequence, a list of ChainRecord (keyed as "{entry_id}_{chain_id}", records with no sequence are skipped), or a path to a directory of existing FASTA files to run similarity over directly.

required
hits_path str | Path

Where to write the hit TSV. Parent directories are created.

required
mmseqs_bin str

Path or name of the MMseqs2 binary.

'mmseqs'
sensitivity float

MMseqs2 -s sensitivity value.

5.7
max_seqs int

MMseqs2 --max-seqs: hits kept per query after the prefilter. The default (MMseqs2's own) can miss similar pairs in large families; raise it for leakage control.

300
tmp_dir str | Path | None

Working directory for FASTA/temporary files. None = system temp.

None
mmseqs_options list[str] | None

Additional options passed to MMseqs2.

None

Returns:

Type Description
SimilaritySearch

A SimilaritySearch pointing at hits_path. Its parameters

SimilaritySearch

are this function's keyword arguments, so the search can be

SimilaritySearch

re-run with `compute_sequence_similarity(seqs, path,

SimilaritySearch

**search.parameters)`.

Raises:

Type Description
RuntimeError

mmseqs_bin is not on PATH.

ValueError

sequences is a directory with no FASTA files.

CalledProcessError

MMseqs2 failed.

Source code in pandora/similarity/sequence.py
def compute_sequence_similarity(
    sequences: dict[str, str] | list[ChainRecord] | str | Path,
    hits_path: str | Path,
    *,
    mmseqs_bin: str = "mmseqs",
    sensitivity: float = 5.7,
    max_seqs: int = 300,
    tmp_dir: str | Path | None = None,
    mmseqs_options: list[str] | None = None,
) -> SimilaritySearch:
    """All-vs-all sequence similarity via MMseqs2 `easy-search`.

    Writes MMseqs2's hits to `hits_path` as a TSV with the columns in
    `HIT_COLUMNS["MMseqs2"]` and keeps it there; nothing is loaded into
    memory. Read it with `iter_edges()` / `cluster_similar_items()`.

    Args:
        sequences: Mapping of item id -> sequence, a list of `ChainRecord`
            (keyed as "{entry_id}_{chain_id}", records with no sequence are
            skipped), or a path to a directory of existing FASTA files to
            run similarity over directly.
        hits_path: Where to write the hit TSV. Parent directories are
            created.
        mmseqs_bin: Path or name of the MMseqs2 binary.
        sensitivity: MMseqs2 `-s` sensitivity value.
        max_seqs: MMseqs2 `--max-seqs`: hits kept per query after the
            prefilter. The default (MMseqs2's own) can miss similar
            pairs in large families; raise it for leakage control.
        tmp_dir: Working directory for FASTA/temporary files. None =
            system temp.
        mmseqs_options: Additional options passed to MMseqs2.

    Returns:
        A `SimilaritySearch` pointing at `hits_path`. Its `parameters`
        are this function's keyword arguments, so the search can be
        re-run with `compute_sequence_similarity(seqs, path,
        **search.parameters)`.

    Raises:
        RuntimeError: `mmseqs_bin` is not on PATH.
        ValueError: `sequences` is a directory with no FASTA files.
        subprocess.CalledProcessError: MMseqs2 failed.
    """

    if shutil.which(mmseqs_bin) is None:
        raise RuntimeError(
            f"mmseqs binary {mmseqs_bin!r} not found (required for "
            "sequence similarity)"
        )

    if isinstance(sequences, list):
        sequences = {
            f"{record.entry_id}_{record.chain_id}": record.sequence
            for record in sequences
            if record.sequence is not None
        }

    options = list(mmseqs_options or [])
    hits = Path(hits_path).resolve()
    hits.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory(dir=tmp_dir) as work_dir:
        work = Path(work_dir)
        fasta_path = work / "sequences.fasta"
        if isinstance(sequences, dict):
            _write_fasta(sequences, fasta_path)
        else:
            _concat_fasta_dir(Path(sequences), fasta_path)

        subprocess.run(
            [
                mmseqs_bin,
                "easy-search",
                str(fasta_path),
                str(fasta_path),
                str(hits),
                str(work / "tmp"),
                "-s",
                str(sensitivity),
                "--max-seqs",
                str(max_seqs),
                "--format-output",
                ",".join(HIT_COLUMNS["MMseqs2"]),
                "-v",
                "1",
            ]
            + options,
            check=True,
            capture_output=True,
            text=True,
        )

    return SimilaritySearch(
        engine="MMseqs2",
        version=_mmseqs_version(mmseqs_bin),
        hits_path=str(hits),
        columns=list(HIT_COLUMNS["MMseqs2"]),
        parameters={
            "mmseqs_bin": mmseqs_bin,
            "sensitivity": sensitivity,
            "max_seqs": max_seqs,
            "mmseqs_options": options,
        },
        searched_at=now_iso(),
    )

compute_structure_similarity(structures, hits_path, *, foldseek_bin='foldseek', sensitivity=9.5, alignment_type=2, max_seqs=1000, exhaustive_search=False, tmp_dir=None, foldseek_options=None)

All-vs-all structural similarity via Foldseek easy-search.

Writes Foldseek's hits to hits_path as a TSV with the columns in HIT_COLUMNS["Foldseek"] (including qtmscore/ttmscore and the alignment ranges) and keeps it there; nothing is loaded into memory. Interface-restricted coverage is computed when reading, with iter_edges(..., interface_residues=...).

Parameters:

Name Type Description Default
structures dict[str, str | Path] | str | Path

Mapping of item id -> structure file path (PDB/mmCIF, optionally gzipped), or a path to a directory of existing structure files (ids are then Foldseek's own file-derived names).

required
hits_path str | Path

Where to write the hit TSV. Parent directories are created.

required
foldseek_bin str

Path or name of the Foldseek binary.

'foldseek'
sensitivity float

Foldseek -s sensitivity value.

9.5
alignment_type int

Foldseek --alignment-type (0: 3Di alignment, 1: TM-align, 2: 3Di+AA — Foldseek's own default).

2
max_seqs int

Foldseek --max-seqs: hits kept per query after the prefilter. The default (Foldseek's own) can miss similar pairs in large families; raise it for leakage control.

1000
exhaustive_search bool

Pass --exhaustive-search 1 (skip the prefilter; slower, finds every pair).

False
tmp_dir str | Path | None

Working directory for structure/temporary files. None = system temp.

None
foldseek_options list[str] | None

Additional options passed to Foldseek.

None

Returns:

Type Description
SimilaritySearch

A SimilaritySearch pointing at hits_path. Its parameters

SimilaritySearch

are this function's keyword arguments, so the search can be

SimilaritySearch

re-run with `compute_structure_similarity(structures, path,

SimilaritySearch

**search.parameters)`.

Raises:

Type Description
RuntimeError

foldseek_bin is not on PATH.

CalledProcessError

Foldseek failed.

Source code in pandora/similarity/structure.py
def compute_structure_similarity(
    structures: dict[str, str | Path] | str | Path,
    hits_path: str | Path,
    *,
    foldseek_bin: str = "foldseek",
    sensitivity: float = 9.5,
    alignment_type: int = 2,
    max_seqs: int = 1000,
    exhaustive_search: bool = False,
    tmp_dir: str | Path | None = None,
    foldseek_options: list[str] | None = None,
) -> SimilaritySearch:
    """All-vs-all structural similarity via Foldseek `easy-search`.

    Writes Foldseek's hits to `hits_path` as a TSV with the columns in
    `HIT_COLUMNS["Foldseek"]` (including `qtmscore`/`ttmscore` and the
    alignment ranges) and keeps it there; nothing is loaded into
    memory. Interface-restricted coverage is computed when reading,
    with `iter_edges(..., interface_residues=...)`.

    Args:
        structures: Mapping of item id -> structure file path (PDB/mmCIF,
            optionally gzipped), or a path to a directory of existing
            structure files (ids are then Foldseek's own file-derived
            names).
        hits_path: Where to write the hit TSV. Parent directories are
            created.
        foldseek_bin: Path or name of the Foldseek binary.
        sensitivity: Foldseek `-s` sensitivity value.
        alignment_type: Foldseek `--alignment-type` (0: 3Di alignment,
            1: TM-align, 2: 3Di+AA — Foldseek's own default).
        max_seqs: Foldseek `--max-seqs`: hits kept per query after the
            prefilter. The default (Foldseek's own) can miss similar
            pairs in large families; raise it for leakage control.
        exhaustive_search: Pass `--exhaustive-search 1` (skip the
            prefilter; slower, finds every pair).
        tmp_dir: Working directory for structure/temporary files.
            None = system temp.
        foldseek_options: Additional options passed to Foldseek.

    Returns:
        A `SimilaritySearch` pointing at `hits_path`. Its `parameters`
        are this function's keyword arguments, so the search can be
        re-run with `compute_structure_similarity(structures, path,
        **search.parameters)`.

    Raises:
        RuntimeError: `foldseek_bin` is not on PATH.
        subprocess.CalledProcessError: Foldseek failed.
    """

    if shutil.which(foldseek_bin) is None:
        raise RuntimeError(
            f"foldseek binary {foldseek_bin!r} not found (required for "
            "structure similarity)"
        )

    options = list(foldseek_options or [])
    hits = Path(hits_path).resolve()
    hits.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory(dir=tmp_dir) as work_dir:
        work = Path(work_dir)
        if isinstance(structures, dict):
            struct_dir = work / "structures"
            struct_dir.mkdir()
            _link_structures(structures, struct_dir)
        else:
            struct_dir = Path(structures)

        command = [
            foldseek_bin,
            "easy-search",
            str(struct_dir),
            str(struct_dir),
            str(hits),
            str(work / "tmp"),
            "-s",
            str(sensitivity),
            "--alignment-type",
            str(alignment_type),
            "--max-seqs",
            str(max_seqs),
            "--format-output",
            ",".join(HIT_COLUMNS["Foldseek"]),
            "-v",
            "1",
        ]
        if exhaustive_search:
            command += ["--exhaustive-search", "1"]
        subprocess.run(
            command + options, check=True, capture_output=True, text=True
        )

    return SimilaritySearch(
        engine="Foldseek",
        version=_foldseek_version(foldseek_bin),
        hits_path=str(hits),
        columns=list(HIT_COLUMNS["Foldseek"]),
        parameters={
            "foldseek_bin": foldseek_bin,
            "sensitivity": sensitivity,
            "alignment_type": alignment_type,
            "max_seqs": max_seqs,
            "exhaustive_search": exhaustive_search,
            "foldseek_options": options,
        },
        searched_at=now_iso(),
    )

interface_residues_from_annotation(structures, interface_layers)

Bridge annotate_chain_interfaces() output into the interface_residues mapping iter_edges() expects, for one exported file per chain (export_chain_mmcif()).

Converts each interface's label_asym_id:label_seq_id residue ids to Foldseek-aligned positions via residue_positions(), so callers never have to reason about the numbering mismatch between mmCIF's label_seq_id and Foldseek's own per-file residue order themselves.

Parameters:

Name Type Description Default
structures dict[str, Structure]

entry_id -> Structure, the same structures the layers in interface_layers were computed from.

required
interface_layers dict[str, AnnotationLayer]

entry_id -> its "chain_interfaces" AnnotationLayer (from annotate_chain_interfaces()).

required

Returns:

Type Description
dict[str, set[int]]

{chain_item_id(entry_id, chain_id): {positions...}} — one

dict[str, set[int]]

entry per chain that appears in at least one interface. Pass

dict[str, set[int]]

this to iter_edges() / cluster_similar_items() as

dict[str, set[int]]

interface_residues, for a search run on files written by

dict[str, set[int]]

export_chain_mmcif() with the same ids.

Source code in pandora/similarity/structure.py
def interface_residues_from_annotation(
    structures: dict[str, Structure],
    interface_layers: dict[str, AnnotationLayer],
) -> dict[str, set[int]]:
    """Bridge `annotate_chain_interfaces()` output into the
    `interface_residues` mapping `iter_edges()` expects,
    for one exported file per chain (`export_chain_mmcif()`).

    Converts each interface's `label_asym_id:label_seq_id` residue ids to
    Foldseek-aligned positions via `residue_positions()`, so callers never
    have to reason about the numbering mismatch between mmCIF's
    `label_seq_id` and Foldseek's own per-file residue order themselves.

    Args:
        structures: `entry_id -> Structure`, the same structures the
            layers in `interface_layers` were computed from.
        interface_layers: `entry_id -> its "chain_interfaces"
            AnnotationLayer` (from `annotate_chain_interfaces()`).

    Returns:
        `{chain_item_id(entry_id, chain_id): {positions...}}` — one
        entry per chain that appears in at least one interface. Pass
        this to `iter_edges()` / `cluster_similar_items()` as
        `interface_residues`, for a search run on files written by
        `export_chain_mmcif()` with the same ids.
    """

    result: dict[str, set[int]] = {}
    for entry_id, layer in interface_layers.items():
        structure = structures[entry_id]
        positions_by_chain: dict[str, dict[int, int]] = {}
        for interface in layer.data.get("interfaces", []):
            sides = (
                (
                    interface["chain_id_1"],
                    interface["interface_residues_chain_1"],
                ),
                (
                    interface["chain_id_2"],
                    interface["interface_residues_chain_2"],
                ),
            )
            for chain_id, residue_ids in sides:
                if chain_id not in positions_by_chain:
                    positions_by_chain[chain_id] = residue_positions(
                        structure, chain_id
                    )
                positions = positions_by_chain[chain_id]
                item_id = chain_item_id(entry_id, chain_id)
                for residue_id in residue_ids:
                    label_seq_id = int(residue_id.rsplit(":", 1)[1])
                    if label_seq_id in positions:
                        result.setdefault(item_id, set()).add(
                            positions[label_seq_id]
                        )
    return result

iter_edges(search, hit_filter, *, interface_residues=None)

Stream the hit rows of search that pass hit_filter.

Reads the hit file line by line; memory use does not grow with the file. Self-hits are skipped, each yielded edge has source_id < target_id, and a pair is yielded once per passing row (so possibly more than once).

Parameters:

Name Type Description Default
search SimilaritySearch

The search whose hit file to read.

required
hit_filter HitFilter

Which rows become edges.

required
interface_residues dict[str, set[int]] | None

Item id -> 1-indexed interface residue positions in that item's structure file (Foldseek only). When given, each row's interface coverage is computed.

None

Returns:

Type Description
Iterator[Edge]

An iterator of Edges.

Raises:

Type Description
ValueError

The filter can't apply to this search (tm_normalisation other than "alignment", or interface_residues, on an MMseqs2 search; min_interface_coverage without interface_residues). search.columns lacks a column the engine's hit files have. Raised immediately, before any row is read. While iterating: a row has the wrong column count or a non-numeric value (the message names <path>:<line>).

FileNotFoundError

The hit file doesn't exist (while iterating).

Source code in pandora/similarity/hits.py
def iter_edges(
    search: SimilaritySearch,
    hit_filter: HitFilter,
    *,
    interface_residues: dict[str, set[int]] | None = None,
) -> Iterator[Edge]:
    """Stream the hit rows of `search` that pass `hit_filter`.

    Reads the hit file line by line; memory use does not grow with the
    file. Self-hits are skipped, each yielded edge has
    `source_id < target_id`, and a pair is yielded once per passing row
    (so possibly more than once).

    Args:
        search: The search whose hit file to read.
        hit_filter: Which rows become edges.
        interface_residues: Item id -> 1-indexed interface residue
            positions in that item's structure file (Foldseek only).
            When given, each row's interface coverage is computed.

    Returns:
        An iterator of `Edge`s.

    Raises:
        ValueError: The filter can't apply to this search
            (`tm_normalisation` other than "alignment", or
            `interface_residues`, on an MMseqs2 search;
            `min_interface_coverage` without `interface_residues`).
            `search.columns` lacks a column the engine's hit files have.
            Raised immediately, before any row is read. While iterating:
            a row has the wrong column count or a non-numeric value
            (the message names `<path>:<line>`).
        FileNotFoundError: The hit file doesn't exist (while
            iterating).
    """

    missing = [c for c in HIT_COLUMNS[search.engine] if c not in search.columns]
    if missing:
        raise ValueError(
            f"{search.engine} search is missing columns {missing}; "
            f"expected {HIT_COLUMNS[search.engine]}"
        )
    if search.engine == "MMseqs2" and hit_filter.tm_normalisation != (
        "alignment"
    ):
        raise ValueError(
            "tm_normalisation must be 'alignment' for an MMseqs2 search "
            f"(it has no TM-scores), got {hit_filter.tm_normalisation!r}"
        )
    if search.engine == "MMseqs2" and interface_residues is not None:
        raise ValueError(
            "interface_residues needs Foldseek alignment ranges "
            "(qstart/qend/tstart/tend); an MMseqs2 search has none"
        )
    if (
        hit_filter.min_interface_coverage is not None
        and interface_residues is None
    ):
        raise ValueError(
            "min_interface_coverage is set but no interface_residues were "
            "given, so every edge would be dropped"
        )
    return _iter_edges(search, hit_filter, interface_residues or {})

Wrap a hit file produced outside Pandora as a SimilaritySearch.

The file must be the tool's TSV with Pandora's fixed --format-output columns (HIT_COLUMNS[engine]). Only the first non-empty row is checked here; every row is checked again while streaming.

Parameters:

Name Type Description Default
hits_path str | Path

Path to the existing hit TSV.

required
engine str

"MMseqs2" or "Foldseek".

required
version str | None

The tool version that produced the file, if known.

None
parameters dict[str, Any] | None

The settings the search was run with, if known. Copied, never aliased.

None

Returns:

Type Description
SimilaritySearch

A SimilaritySearch with origin="precomputed".

Raises:

Type Description
ValueError

engine is unknown, or the first row has the wrong number of columns.

FileNotFoundError

hits_path does not exist.

Source code in pandora/similarity/hits.py
def load_similarity_search(
    hits_path: str | Path,
    engine: str,
    *,
    version: str | None = None,
    parameters: dict[str, Any] | None = None,
) -> SimilaritySearch:
    """Wrap a hit file produced outside Pandora as a `SimilaritySearch`.

    The file must be the tool's TSV with Pandora's fixed
    `--format-output` columns (`HIT_COLUMNS[engine]`). Only the first
    non-empty row is checked here; every row is checked again while
    streaming.

    Args:
        hits_path: Path to the existing hit TSV.
        engine: "MMseqs2" or "Foldseek".
        version: The tool version that produced the file, if known.
        parameters: The settings the search was run with, if known.
            Copied, never aliased.

    Returns:
        A `SimilaritySearch` with `origin="precomputed"`.

    Raises:
        ValueError: `engine` is unknown, or the first row has the wrong
            number of columns.
        FileNotFoundError: `hits_path` does not exist.
    """

    if engine not in HIT_COLUMNS:
        raise ValueError(
            f"engine must be one of {sorted(HIT_COLUMNS)}, got {engine!r}"
        )
    path = Path(hits_path).resolve()
    columns = HIT_COLUMNS[engine]
    with path.open() as handle:
        for line_number, line in enumerate(handle, start=1):
            if line.strip():
                _split_row(line, path, line_number, columns)
                break
    return SimilaritySearch(
        engine=engine,
        version=version,
        hits_path=str(path),
        columns=list(columns),
        parameters=dict(parameters or {}),
        origin="precomputed",
    )

pair_cluster_keys(pairs, clusters)

Derive a per-side cluster key for each PPI pair from item-level clusters (e.g. Pinder-style {cluster_id_R, cluster_id_L}).

Each cluster's own lexicographically-smallest component id is used as that cluster's key, so the same item always maps to the same key regardless of clustering order.

Parameters:

Name Type Description Default
pairs list[tuple[str, str]]

(item_id_1, item_id_2) tuples — e.g. the two chain ids of a PPI pair. Both ids must appear in clusters.

required
clusters list[SimilarityCluster]

Item-level clusters, as returned by cluster_similar_items().

required

Returns:

Type Description
dict[tuple[str, str], tuple[str, str]]

{(item_id_1, item_id_2): (cluster_id_1, cluster_id_2)}, one

dict[tuple[str, str], tuple[str, str]]

entry per input pair.

Raises:

Type Description
KeyError

A pair references an item id not present in any cluster.

Source code in pandora/similarity/clustering.py
def pair_cluster_keys(
    pairs: list[tuple[str, str]],
    clusters: list[SimilarityCluster],
) -> dict[tuple[str, str], tuple[str, str]]:
    """Derive a per-side cluster key for each PPI pair from item-level
    clusters (e.g. Pinder-style `{cluster_id_R, cluster_id_L}`).

    Each cluster's own lexicographically-smallest component id is used
    as that cluster's key, so the same item always maps to the same key
    regardless of clustering order.

    Args:
        pairs: `(item_id_1, item_id_2)` tuples — e.g. the two chain ids
            of a PPI pair. Both ids must appear in `clusters`.
        clusters: Item-level clusters, as returned by
            `cluster_similar_items()`.

    Returns:
        `{(item_id_1, item_id_2): (cluster_id_1, cluster_id_2)}`, one
        entry per input pair.

    Raises:
        KeyError: A pair references an item id not present in any
            cluster.
    """

    cluster_of: dict[str, str] = {
        item_id: cluster.components[0]
        for cluster in clusters
        for item_id in cluster.components
    }
    return {
        (item_1, item_2): (cluster_of[item_1], cluster_of[item_2])
        for item_1, item_2 in pairs
    }

partition_dataset(clusters, pct_train=0.6, pct_val=0.2, pct_test=0.2, keep_similar_items=True)

Assign cluster members to train/val/test splits.

keep_similar_items=True (default): each cluster goes to a single split (whichever is most under its target share) — the leakage-safe mode. keep_similar_items=False: each cluster's members are divided proportionally across all three splits.

Parameters:

Name Type Description Default
clusters list[SimilarityCluster]

The similarity clusters to assign, as returned by cluster_similar_items.

required
pct_train float

Target fraction of items assigned to the train split.

0.6
pct_val float

Target fraction of items assigned to the val split.

0.2
pct_test float

Target fraction of items assigned to the test split. pct_train + pct_val + pct_test must equal 1.0.

0.2
keep_similar_items bool

If True (default), each cluster is assigned whole to a single split (leakage-safe). If False, each cluster's members are divided proportionally across splits.

True

Returns:

Type Description
dict[str, list[str]]

(splits, provenance) — the split assignment, and a record of

PartitionProvenance

the fractions/mode applied and the resulting split sizes (for

tuple[dict[str, list[str]], PartitionProvenance]

reporting how a dataset's split was built).

Raises:

Type Description
ValueError

pct_train + pct_val + pct_test does not equal 1.0.

Source code in pandora/similarity/partition.py
def partition_dataset(
    clusters: list[SimilarityCluster],
    pct_train: float = 0.6,
    pct_val: float = 0.2,
    pct_test: float = 0.2,
    keep_similar_items: bool = True,
) -> tuple[dict[str, list[str]], PartitionProvenance]:
    """Assign cluster members to train/val/test splits.

    keep_similar_items=True (default): each cluster goes to a single split
    (whichever is most under its target share) — the leakage-safe mode.
    keep_similar_items=False: each cluster's members are divided
    proportionally across all three splits.

    Args:
        clusters: The similarity clusters to assign, as returned by
            `cluster_similar_items`.
        pct_train: Target fraction of items assigned to the train split.
        pct_val: Target fraction of items assigned to the val split.
        pct_test: Target fraction of items assigned to the test split.
            `pct_train + pct_val + pct_test` must equal 1.0.
        keep_similar_items: If True (default), each cluster is assigned
            whole to a single split (leakage-safe). If False, each
            cluster's members are divided proportionally across splits.

    Returns:
        `(splits, provenance)` — the split assignment, and a record of
        the fractions/mode applied and the resulting split sizes (for
        reporting how a dataset's split was built).

    Raises:
        ValueError: `pct_train + pct_val + pct_test` does not equal 1.0.
    """

    if abs(pct_train + pct_val + pct_test - 1.0) > _FRACTION_TOLERANCE:
        raise ValueError(
            f"pct_train + pct_val + pct_test must equal 1.0, got "
            f"{pct_train + pct_val + pct_test}"
        )

    ordered = sorted(clusters, key=lambda c: c.n_components, reverse=True)
    total = sum(c.n_components for c in ordered)

    targets = {
        "train": round(total * pct_train),
        "val": round(total * pct_val),
        "test": round(total * pct_test),
    }
    splits: dict[str, list[str]] = {"train": [], "val": [], "test": []}

    if keep_similar_items:
        for cluster in ordered:
            split = max(
                splits, key=lambda name: targets[name] - len(splits[name])
            )
            splits[split].extend(cluster.components)
    else:
        # Each item goes to the split furthest below its share of the items
        # seen so far; rounding per cluster sent every singleton to train.
        fractions = {"train": pct_train, "val": pct_val, "test": pct_test}
        seen = 0
        for cluster in ordered:
            for item_id in cluster.components:
                seen += 1
                split = max(
                    splits,
                    key=lambda name: fractions[name] * seen - len(splits[name]),
                )
                splits[split].append(item_id)

    provenance = PartitionProvenance(
        partitioned_at=now_iso(),
        pct_train=pct_train,
        pct_val=pct_val,
        pct_test=pct_test,
        keep_similar_items=keep_similar_items,
        split_sizes={name: len(members) for name, members in splits.items()},
    )
    return splits, provenance

residue_positions(structure, chain_id)

label_seq_id -> 1-indexed position for one chain, in the order export_chain_mmcif()/structure_to_mmcif() write that chain's atoms — i.e. the same numbering Foldseek assigns that chain's residues when it parses the resulting file.

Parameters:

Name Type Description Default
structure Structure

The structure to read chain_id's atoms from.

required
chain_id str

The label_asym_id of the chain.

required

Returns:

Type Description
dict[int, int]

One entry per residue that has atoms for this chain in

dict[int, int]

structure.atoms (first-appearance order). A residue missing

dict[int, int]

from structure.atoms (no resolved density) is absent here too

dict[int, int]

— it's also absent from the exported file, so there's nothing

dict[int, int]

for Foldseek to number.

Source code in pandora/similarity/structure.py
def residue_positions(structure: Structure, chain_id: str) -> dict[int, int]:
    """`label_seq_id -> 1-indexed position` for one chain, in the order
    `export_chain_mmcif()`/`structure_to_mmcif()` write that chain's
    atoms — i.e. the same numbering Foldseek assigns that chain's
    residues when it parses the resulting file.

    Args:
        structure: The structure to read chain_id's atoms from.
        chain_id: The `label_asym_id` of the chain.

    Returns:
        One entry per residue that has atoms for this chain in
        `structure.atoms` (first-appearance order). A residue missing
        from `structure.atoms` (no resolved density) is absent here too
        — it's also absent from the exported file, so there's nothing
        for Foldseek to number.
    """

    positions: dict[int, int] = {}
    for atom in structure.atoms:
        if atom.label_asym_id != chain_id or atom.label_seq_id is None:
            continue
        if atom.label_seq_id not in positions:
            positions[atom.label_seq_id] = len(positions) + 1
    return positions

pandora.provenance

build_dataset_manifest(*, dataset_id, dataset_name, dataset_version, curation_policy=None, canonicalisation_policy=None, excluded=(), deduplication=None, clustering=None, partition=None, splits=None, structures=())

Assemble a single-file report of how a dataset was constructed.

Aggregates whatever provenance the caller already produced by running the dataset-construction stages — this function does not re-derive, recompute, or validate anything itself. Every argument beyond the dataset identity is optional so the manifest can be built with whichever stages actually ran (e.g. curation without clustering).

Parameters:

Name Type Description Default
dataset_id str

Stable identifier for this dataset build.

required
dataset_name str

Human-readable name.

required
dataset_version str

Version of this dataset build.

required
curation_policy DatasetCurationPolicy | None

The policy passed to curate_structure, if run.

None
canonicalisation_policy canonicalisationPolicy | None

The policy passed to canonicalise_structure, if run.

None
excluded Sequence[ExclusionRecord]

ExclusionRecords returned by curate_structure/ deduplicate_structures for every structure left out.

()
deduplication DeduplicationProvenance | None

Provenance from deduplicate_structures, if run.

None
clustering ClusteringProvenance | None

Provenance from cluster_similar_items, if run.

None
partition PartitionProvenance | None

Provenance from partition_dataset, if run.

None
splits dict[str, list[str]] | None

The split assignment returned by partition_dataset, if run.

None
structures Sequence[ProvenanceBundle]

A ProvenanceBundle per structure retained in the dataset (from build_provenance_bundle).

()

Returns:

Type Description
DatasetManifest

A DatasetManifest capturing the policy, exclusions, dedup/

DatasetManifest

clustering/partition parameters, split assignment, and every

DatasetManifest

retained structure's own provenance in one object.

Source code in pandora/provenance/manifest.py
def build_dataset_manifest(
    *,
    dataset_id: str,
    dataset_name: str,
    dataset_version: str,
    curation_policy: DatasetCurationPolicy | None = None,
    canonicalisation_policy: canonicalisationPolicy | None = None,
    excluded: Sequence[ExclusionRecord] = (),
    deduplication: DeduplicationProvenance | None = None,
    clustering: ClusteringProvenance | None = None,
    partition: PartitionProvenance | None = None,
    splits: dict[str, list[str]] | None = None,
    structures: Sequence[ProvenanceBundle] = (),
) -> DatasetManifest:
    """Assemble a single-file report of how a dataset was constructed.

    Aggregates whatever provenance the caller already produced by running
    the dataset-construction stages — this function does not re-derive,
    recompute, or validate anything itself. Every argument beyond the
    dataset identity is optional so the manifest can be built with
    whichever stages actually ran (e.g. curation without clustering).

    Args:
        dataset_id: Stable identifier for this dataset build.
        dataset_name: Human-readable name.
        dataset_version: Version of this dataset build.
        curation_policy: The policy passed to `curate_structure`, if run.
        canonicalisation_policy: The policy passed to
            `canonicalise_structure`, if run.
        excluded: `ExclusionRecord`s returned by `curate_structure`/
            `deduplicate_structures` for every structure left out.
        deduplication: Provenance from `deduplicate_structures`, if run.
        clustering: Provenance from `cluster_similar_items`, if run.
        partition: Provenance from `partition_dataset`, if run.
        splits: The split assignment returned by `partition_dataset`,
            if run.
        structures: A `ProvenanceBundle` per structure retained in the
            dataset (from `build_provenance_bundle`).

    Returns:
        A `DatasetManifest` capturing the policy, exclusions, dedup/
        clustering/partition parameters, split assignment, and every
        retained structure's own provenance in one object.
    """

    return DatasetManifest(
        dataset_id=dataset_id,
        dataset_name=dataset_name,
        dataset_version=dataset_version,
        pandora_version=__version__,
        generated_at=now_iso(),
        curation_policy=curation_policy,
        canonicalisation_policy=canonicalisation_policy,
        excluded=list(excluded),
        deduplication=deduplication,
        clustering=clustering,
        partition=partition,
        splits=splits or {},
        structures=list(structures),
    )

build_provenance_bundle(structure, *, ingestion=None, canonicalisation=None, metadata=None, annotations=())

Assemble a ProvenanceBundle for one (canonicalised) structure.

Aggregates whatever provenance the caller already produced by running the earlier pipeline stages — this function does not re-derive or fetch anything itself. Every argument is optional so the bundle can be built at any point in the pipeline (e.g. right after parsing, with only ingestion set).

Parameters:

Name Type Description Default
structure Structure

The structure to attribute the bundle to.

required
ingestion IngestionProvenance | None

Provenance from pandora.ingestion.fetch_mmcif, if run.

None
canonicalisation canonicalisationProvenance | None

Provenance from canonicalise_structure, if run.

None
metadata MetadataRecord | None

The record from collect_metadata, if run.

None
annotations Sequence[AnnotationLayer]

Any AnnotationLayers produced by annotate_* functions.

()

Returns:

Type Description
ProvenanceBundle

A ProvenanceBundle for structure.

Source code in pandora/provenance/manifest.py
def build_provenance_bundle(
    structure: Structure,
    *,
    ingestion: IngestionProvenance | None = None,
    canonicalisation: canonicalisationProvenance | None = None,
    metadata: MetadataRecord | None = None,
    annotations: Sequence[AnnotationLayer] = (),
) -> ProvenanceBundle:
    """Assemble a `ProvenanceBundle` for one (canonicalised) structure.

    Aggregates whatever provenance the caller already produced by running
    the earlier pipeline stages — this function does not re-derive or
    fetch anything itself. Every argument is optional so the bundle can be
    built at any point in the pipeline (e.g. right after parsing, with
    only `ingestion` set).

    Args:
        structure: The structure to attribute the bundle to.
        ingestion: Provenance from `pandora.ingestion.fetch_mmcif`, if run.
        canonicalisation: Provenance from `canonicalise_structure`, if run.
        metadata: The record from `collect_metadata`, if run.
        annotations: Any `AnnotationLayer`s produced by `annotate_*`
            functions.

    Returns:
        A `ProvenanceBundle` for `structure`.
    """

    return ProvenanceBundle(
        entry_id=structure.entry_id,
        pandora_version=__version__,
        generated_at=now_iso(),
        ingestion=ingestion,
        canonicalisation=canonicalisation,
        metadata_sources=(
            collect_metadata_provenance(metadata) if metadata else []
        ),
        annotations=[
            AnnotationProvenanceRecord(
                layer_name=layer.layer_name,
                layer_type=layer.layer_type,
                method=layer.method,
                target_ids=layer.target_ids,
                parameters=layer.parameters,
                provenance=layer.provenance,
            )
            for layer in annotations
        ],
    )

collect_metadata_provenance(metadata)

Flatten the per-record provenance stamps out of a MetadataRecord.

Parameters:

Name Type Description Default
metadata MetadataRecord

The collected metadata record.

required

Returns:

Type Description
list[MetadataProvenance]

One MetadataProvenance per populated sub-record (entry, quality,

list[MetadataProvenance]

each taxonomy/entity/ligand/UniProt mapping), in record order.

Source code in pandora/provenance/manifest.py
def collect_metadata_provenance(
    metadata: MetadataRecord,
) -> list[MetadataProvenance]:
    """Flatten the per-record provenance stamps out of a `MetadataRecord`.

    Args:
        metadata: The collected metadata record.

    Returns:
        One `MetadataProvenance` per populated sub-record (entry, quality,
        each taxonomy/entity/ligand/UniProt mapping), in record order.
    """

    records = [metadata.entry, metadata.quality]
    records += metadata.taxonomies
    records += metadata.entities
    records += metadata.ligands
    records += metadata.uniprot_mappings
    return [record.provenance for record in records if record is not None]

reproduce_dataset(manifest, output_dir, *, fetch_options=None)

Re-run the pipeline described by a DatasetManifest from scratch.

Re-fetches every structure via its recorded ingestion provenance, then replays canonicalisation, curation, deduplication, similarity/ clustering, partitioning, and annotation exactly as the manifest describes them. Every step is optional in the same way it is in build_dataset_manifest (e.g. no clustering means no similarity network is (re)computed).

This is a best-effort re-run, not a guaranteed byte-identical rebuild: source data can change upstream, and external similarity tools can drift between versions. The returned manifest is the record of what this run actually produced — diff it against the input manifest to see what changed.

Parameters:

Name Type Description Default
manifest DatasetManifest

A DatasetManifest, as returned by build_dataset_manifest.

required
output_dir str | Path

Directory to fetch raw mmCIF files (and, for a Foldseek re-run, write canonicalised structures) into.

required
fetch_options FetchOptions | None

Passed through to every fetch_mmcif call.

None

Returns:

Type Description
dict[str, Structure]

(structures, new_manifest) — the reproduced, retained

DatasetManifest

structures keyed by entry_id, and a fresh DatasetManifest

tuple[dict[str, Structure], DatasetManifest]

describing this run.

Raises:

Type Description
ValueError

A structure's ProvenanceBundle has no ingestion provenance to fetch from; clustering lacks a recorded search or hit_filter; its hit filter uses min_interface_coverage (interface residues aren't recorded); or its precomputed hit file no longer exists.

Source code in pandora/provenance/reproduce.py
def reproduce_dataset(
    manifest: DatasetManifest,
    output_dir: str | Path,
    *,
    fetch_options: FetchOptions | None = None,
) -> tuple[dict[str, Structure], DatasetManifest]:
    """Re-run the pipeline described by a `DatasetManifest` from scratch.

    Re-fetches every structure via its recorded `ingestion` provenance,
    then replays canonicalisation, curation, deduplication, similarity/
    clustering, partitioning, and annotation exactly as the manifest
    describes them. Every step is optional in the same way it is in
    `build_dataset_manifest` (e.g. no `clustering` means no similarity
    network is (re)computed).

    This is a best-effort re-run, not a guaranteed byte-identical
    rebuild: source data can change upstream, and external similarity
    tools can drift between versions. The returned manifest is the
    record of what *this* run actually produced — diff it against the
    input manifest to see what changed.

    Args:
        manifest: A `DatasetManifest`, as returned by
            `build_dataset_manifest`.
        output_dir: Directory to fetch raw mmCIF files (and, for a
            Foldseek re-run, write canonicalised structures) into.
        fetch_options: Passed through to every `fetch_mmcif` call.

    Returns:
        `(structures, new_manifest)` — the reproduced, retained
        structures keyed by entry_id, and a fresh `DatasetManifest`
        describing this run.

    Raises:
        ValueError: A structure's `ProvenanceBundle` has no `ingestion`
            provenance to fetch from; `clustering` lacks a recorded
            `search` or `hit_filter`; its hit filter uses
            `min_interface_coverage` (interface residues aren't
            recorded); or its precomputed hit file no longer exists.
    """

    output_dir = Path(output_dir)
    structures: dict[str, Structure] = {}
    ingestion_provenance: dict[str, IngestionProvenance] = {}
    canonicalisation_provenance: dict[str, canonicalisationProvenance] = {}

    for bundle in manifest.structures:
        if bundle.ingestion is None:
            raise ValueError(
                f"cannot reproduce {bundle.entry_id!r}: its ProvenanceBundle "
                "has no ingestion provenance to fetch from"
            )
        ingestion_prov = fetch_mmcif(
            bundle.entry_id,
            bundle.ingestion.provider,
            bundle.ingestion.source_uri,
            output_dir,
            fetch_options,
        )
        cached_path = find_cached(bundle.entry_id, output_dir)
        structure, _, _ = mmcif_to_structure(str(cached_path))
        if manifest.canonicalisation_policy is not None:
            structure, _, canon_prov = canonicalise_structure(
                structure, manifest.canonicalisation_policy
            )
            canonicalisation_provenance[structure.entry_id] = canon_prov
        # Keyed by structure.entry_id (not bundle.entry_id) from here on,
        # matching curate_structure/deduplicate_structures/ExclusionRecord,
        # which all key by Structure.entry_id too — the two only differ if
        # the source data's own entry_id changed since the manifest was
        # built, which is exactly the kind of drift reproduction tolerates.
        ingestion_provenance[structure.entry_id] = ingestion_prov
        structures[structure.entry_id] = structure

    excluded: list[ExclusionRecord] = []
    if manifest.curation_policy is not None:
        for entry_id in list(structures):
            metadata = collect_metadata(structures[entry_id])
            curated, records, _ = curate_structure(
                structures[entry_id], metadata, manifest.curation_policy
            )
            excluded.extend(records)
            if curated is None:
                del structures[entry_id]
            else:
                structures[entry_id] = curated

    dedup_prov = None
    if manifest.deduplication is not None and manifest.deduplication.enabled:
        retained, removed, dedup_prov = deduplicate_structures(
            list(structures.values()), DeduplicationRules(enabled=True)
        )
        excluded.extend(removed)
        structures = {s.entry_id: s for s in retained}

    cluster_prov = None
    partition_prov = None
    splits: dict[str, list[str]] = {}
    if manifest.clustering is not None:
        recorded = manifest.clustering
        if recorded.search is None or recorded.hit_filter is None:
            raise ValueError(
                "cannot reproduce clustering: the original manifest did "
                "not record which similarity search and hit filter "
                "produced the clusters"
            )
        if recorded.hit_filter.min_interface_coverage is not None:
            raise ValueError(
                "cannot reproduce clustering: its hit filter uses "
                "min_interface_coverage, and the interface residues it "
                "was computed with are not recorded in the manifest"
            )
        search = _reproduce_similarity(recorded.search, structures, output_dir)
        clusters, cluster_prov = cluster_similar_items(
            list(structures), search, recorded.hit_filter
        )
        if manifest.partition is not None:
            splits, partition_prov = partition_dataset(
                clusters,
                manifest.partition.pct_train,
                manifest.partition.pct_val,
                manifest.partition.pct_test,
                keep_similar_items=manifest.partition.keep_similar_items,
            )

    annotation_layers = _reproduce_annotations(manifest, structures)
    new_structures = {
        entry_id: build_provenance_bundle(
            structures[entry_id],
            ingestion=ingestion_provenance.get(entry_id),
            canonicalisation=canonicalisation_provenance.get(entry_id),
            annotations=annotation_layers.get(entry_id, []),
        )
        for entry_id in structures
    }

    new_manifest = build_dataset_manifest(
        dataset_id=manifest.dataset_id,
        dataset_name=manifest.dataset_name,
        dataset_version=manifest.dataset_version,
        curation_policy=manifest.curation_policy,
        canonicalisation_policy=manifest.canonicalisation_policy,
        excluded=excluded,
        deduplication=dedup_prov,
        clustering=cluster_prov,
        partition=partition_prov,
        splits=splits,
        structures=list(new_structures.values()),
    )
    return structures, new_manifest

pandora.export

export_chain_mmcif(structure, chain_id, path)

Write a single chain (label_asym_id) of a Structure out as its own mmCIF file.

Keeps only chain_id's atoms and connections; drops raw (verbatim passthrough categories, e.g. _pdbx_sifts_xref_db, commonly reference asym ids no longer present once other chains are dropped — gemmi refuses to re-parse a dangling reference). entities/ asym_units/assemblies/secondary_structure are written unfiltered, same as structure_to_mmcif — harmless extra rows a structure-only consumer like Foldseek ignores.

Useful for per-chain structural similarity (e.g. PPI interface coverage — see pandora.similarity.structure.residue_positions, which numbers residues to match this file's atom order).

Parameters:

Name Type Description Default
structure Structure

The structure to pull chain_id's atoms from.

required
chain_id str

The label_asym_id of the chain to write.

required
path str | Path

Destination file path; parent directories are created if missing.

required

Returns:

Type Description
Path

The resolved Path the mmCIF file was written to.

Source code in pandora/export/mmcif.py
def export_chain_mmcif(
    structure: Structure, chain_id: str, path: str | Path
) -> Path:
    """Write a single chain (`label_asym_id`) of a Structure out as its
    own mmCIF file.

    Keeps only `chain_id`'s atoms and connections; drops `raw` (verbatim
    passthrough categories, e.g. `_pdbx_sifts_xref_db`, commonly
    reference asym ids no longer present once other chains are dropped —
    gemmi refuses to re-parse a dangling reference). `entities`/
    `asym_units`/`assemblies`/`secondary_structure` are written
    unfiltered, same as `structure_to_mmcif` — harmless extra rows a
    structure-only consumer like Foldseek ignores.

    Useful for per-chain structural similarity (e.g. PPI interface
    coverage — see `pandora.similarity.structure.residue_positions`,
    which numbers residues to match this file's atom order).

    Args:
        structure: The structure to pull chain_id's atoms from.
        chain_id: The `label_asym_id` of the chain to write.
        path: Destination file path; parent directories are created
            if missing.

    Returns:
        The resolved `Path` the mmCIF file was written to.
    """

    chain_only = structure.model_copy(
        update={
            "atoms": [
                atom
                for atom in structure.atoms
                if atom.label_asym_id == chain_id
            ],
            "connections": [
                conn
                for conn in structure.connections
                if conn.ptnr1.label_asym_id == chain_id
                and conn.ptnr2.label_asym_id == chain_id
            ],
            "raw": {},
        }
    )
    return structure_to_mmcif(chain_only, path)

structure_to_mmcif(structure, path)

Write a Structure back out as an mmCIF file.

Rebuilds the standard mmCIF categories from Structure's typed fields (entity/entity_poly/struct_asym/atom_site/struct_conn/ pdbx_struct_assembly*/struct_conf/struct_sheet_range) and re-emits every category preserved verbatim in structure.raw.

This is a lossy round-trip: loop ordering, comments, and any field mmCIF supports but Pandora's schema doesn't carry (e.g. anisotropic B-factors) won't survive.

Parameters:

Name Type Description Default
structure Structure

The structure to write out.

required
path str | Path

Destination file path; parent directories are created if missing.

required

Returns:

Type Description
Path

The resolved Path the mmCIF file was written to.

Source code in pandora/export/mmcif.py
def structure_to_mmcif(structure: Structure, path: str | Path) -> Path:
    """Write a Structure back out as an mmCIF file.

    Rebuilds the standard mmCIF categories from Structure's typed
    fields (entity/entity_poly/struct_asym/atom_site/struct_conn/
    pdbx_struct_assembly*/struct_conf/struct_sheet_range) and re-emits
    every category preserved verbatim in `structure.raw`.

    This is a lossy round-trip: loop ordering, comments, and any field
    mmCIF supports but Pandora's schema doesn't carry (e.g. anisotropic
    B-factors) won't survive.

    Args:
        structure: The structure to write out.
        path: Destination file path; parent directories are created
            if missing.

    Returns:
        The resolved `Path` the mmCIF file was written to.
    """

    doc = gemmi.cif.Document()
    block = doc.add_new_block(structure.entry_id)

    if "_struct" not in structure.raw and structure.entry.title:
        block.set_pair("_struct.title", gemmi.cif.quote(structure.entry.title))

    _write_entities(block, structure)
    _write_struct_asym(block, structure)
    _write_atom_site(block, structure)
    _write_struct_conn(block, structure)
    _write_assemblies(block, structure)
    _write_secondary_structure(block, structure)
    _write_raw(block, structure)

    out_path = Path(path)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    doc.write_file(str(out_path))
    return out_path

write_json(model, path)

Write any Pandora pydantic model to disk as pretty-printed JSON.

Covers every stage that returns a single model — canonicalisation provenance/mappings, MetadataRecord, AnnotationLayer, ProvenanceBundle, etc.

Parameters:

Name Type Description Default
model BaseModel

The pydantic model instance to write.

required
path str | Path

Destination file path; parent directories are created if missing.

required

Returns:

Type Description
Path

The resolved Path the JSON was written to.

Source code in pandora/export/json.py
def write_json(model: BaseModel, path: str | Path) -> Path:
    """Write any Pandora pydantic model to disk as pretty-printed JSON.

    Covers every stage that returns a single model — canonicalisation
    provenance/mappings, MetadataRecord, AnnotationLayer,
    ProvenanceBundle, etc.

    Args:
        model: The pydantic model instance to write.
        path: Destination file path; parent directories are created
            if missing.

    Returns:
        The resolved `Path` the JSON was written to.
    """

    out_path = Path(path)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(model.model_dump_json(indent=2), encoding="utf-8")
    return out_path

write_records(records, path)

Write a list of record models (Chain/Residue/InterfaceRecord, ...).

Dispatches on the file suffix

.parquet — columnar, via pandas/pyarrow (the export extra) .json — a JSON array of the records .jsonl — one JSON object per line

Parameters:

Name Type Description Default
records Sequence[BaseModel]

The record models to write; must be non-empty.

required
path str | Path

Destination file path; its suffix (.json/.jsonl/ .parquet) selects the output format. Parent directories are created if missing.

required

Returns:

Type Description
Path

The resolved Path the records were written to.

Raises:

Type Description
ValueError

records is empty, or the suffix is unsupported.

RuntimeError

.parquet requested without pandas/pyarrow installed.

Source code in pandora/export/records.py
def write_records(records: Sequence[BaseModel], path: str | Path) -> Path:
    """Write a list of record models (Chain/Residue/InterfaceRecord, ...).

    Dispatches on the file suffix:
        .parquet — columnar, via pandas/pyarrow (the `export` extra)
        .json    — a JSON array of the records
        .jsonl   — one JSON object per line

    Args:
        records: The record models to write; must be non-empty.
        path: Destination file path; its suffix (`.json`/`.jsonl`/
            `.parquet`) selects the output format. Parent directories
            are created if missing.

    Returns:
        The resolved `Path` the records were written to.

    Raises:
        ValueError: `records` is empty, or the suffix is unsupported.
        RuntimeError: `.parquet` requested without pandas/pyarrow installed.
    """

    if not records:
        raise ValueError("no records to write")

    out_path = Path(path)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    suffix = out_path.suffix.lower()

    if suffix == ".jsonl":
        lines = (record.model_dump_json() for record in records)
        out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
        return out_path

    if suffix == ".json":
        body = ",".join(record.model_dump_json() for record in records)
        out_path.write_text(f"[{body}]", encoding="utf-8")
        return out_path

    if suffix == ".parquet":
        try:
            import pandas as pd
        except ImportError as exc:
            raise RuntimeError(
                "writing .parquet requires pandas/pyarrow "
                "(install the `export` extra)"
            ) from exc
        pd.DataFrame([record.model_dump() for record in records]).to_parquet(
            out_path
        )
        return out_path

    raise ValueError(f"unsupported record file suffix: {suffix!r}")

pandora.cli