Skip to content

Annotator

add_common_output_arguments(parser)

Add common output and formatting arguments to an ArgumentParser.

These options control where BPSEQ/CSV/JSON/DOT/PyMOL outputs are written and enable extra CSV exports for stem parameters.

Source code in src/rnapolis/annotator.py
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
def add_common_output_arguments(parser: argparse.ArgumentParser):
    """
    Add common output and formatting arguments to an ArgumentParser.

    These options control where BPSEQ/CSV/JSON/DOT/PyMOL outputs are written
    and enable extra CSV exports for stem parameters.
    """
    parser.add_argument("-b", "--bpseq", help="(optional) path to output BPSEQ file")
    parser.add_argument("-c", "--csv", help="(optional) path to output CSV file")
    parser.add_argument(
        "-j",
        "--json",
        help="(optional) path to output JSON file",
    )
    parser.add_argument(
        "-e",
        "--extended",
        action="store_true",
        help=(
            "(optional) if set, the program will print extended secondary "
            "structure to the standard output"
        ),
    )
    parser.add_argument("-d", "--dot", help="(optional) path to output DOT file")
    parser.add_argument(
        "-p", "--pml", help="(optional) path to output PyMOL PML script for stems"
    )
    parser.add_argument(
        "--inter-stem-csv",
        help="(optional) path to output CSV file for inter-stem parameters",
    )
    parser.add_argument(
        "--stems-csv",
        help="(optional) path to output CSV file for stem details",
    )
    parser.add_argument(
        "--decompose-pseudoknot-free",
        action="store_true",
        help=(
            "(optional) if set, structural elements (stems, hairpins, loops, "
            "single strands) are decomposed from the pseudoknot-free structure, "
            "but dot-bracket strings retain pseudoknot characters; pseudoknotted "
            "stems are reported separately in the JSON output"
        ),
    )

angle_between_vectors(v1, v2)

Compute the angle in radians between two vectors.

The angle is calculated using the dot product and vector norms.

Parameters:

Name Type Description Default
v1 NDArray[floating]

First vector.

required
v2 NDArray[floating]

Second vector.

required

Returns:

Name Type Description
float float

Angle between the two vectors in radians.

Source code in src/rnapolis/annotator.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
def angle_between_vectors(
    v1: numpy.typing.NDArray[numpy.floating],
    v2: numpy.typing.NDArray[numpy.floating],
) -> float:
    """
    Compute the angle in radians between two vectors.

    The angle is calculated using the dot product and vector norms.

    Args:
        v1: First vector.
        v2: Second vector.

    Returns:
        float: Angle between the two vectors in radians.
    """
    return math.acos(numpy.dot(v1, v2) / numpy.linalg.norm(v1) / numpy.linalg.norm(v2))

detect_bph_br_classification(donor_residue, donor, acceptor)

Classify base–phosphate or base–ribose interactions.

The classification is based on donor and acceptor atom names, residue type and a torsion angle around selected atoms.

Parameters:

Name Type Description Default
donor_residue Residue3D

Residue that contains the donor atom.

required
donor Atom

Donor atom in the interaction.

required
acceptor Atom

Acceptor atom in the interaction.

required

Returns:

Type Description
Optional[int]

Integer code describing the interaction type, or None if the interaction does not match any known class.

Source code in src/rnapolis/annotator.py
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
def detect_bph_br_classification(
    donor_residue: Residue3D, donor: Atom, acceptor: Atom
) -> Optional[int]:
    """
    Classify base–phosphate or base–ribose interactions.

    The classification is based on donor and acceptor atom names, residue
    type and a torsion angle around selected atoms.

    Args:
        donor_residue: Residue that contains the donor atom.
        donor: Donor atom in the interaction.
        acceptor: Acceptor atom in the interaction.

    Returns:
        Integer code describing the interaction type, or ``None`` if the interaction does not match any known class.
    """
    # source: Classification and energetics of the base-phosphate interactions
    # in RNA. Craig L. Zirbel, Judit E. Sponer, Jiri Sponer, Jesse Stombaugh
    # and Neocles B. Leontis
    if donor_residue.one_letter_name == "A":
        if donor.name == "C2":
            return 2
        if donor.name == "N6":
            n1 = donor_residue.find_atom("N1")
            c6 = donor_residue.find_atom("C6")
            if n1 is not None and c6 is not None:
                torsion = math.degrees(torsion_angle(n1, c6, donor, acceptor))
                return 6 if -90.0 < torsion < 90.0 else 7
        if donor.name == "C8":
            return 0

    if donor_residue.one_letter_name == "G":
        if donor.name == "N1":
            return 5
        if donor.name == "N2":
            n3 = donor_residue.find_atom("N3")
            c2 = donor_residue.find_atom("C2")
            if n3 is not None and c2 is not None:
                torsion = math.degrees(torsion_angle(n3, c2, donor, acceptor))
                return 1 if -90.0 < torsion < 90.0 else 3
        if donor.name == "C8":
            return 0

    if donor_residue.one_letter_name == "C":
        if donor.name == "N4":
            n3 = donor_residue.find_atom("N3")
            c4 = donor_residue.find_atom("C4")
            if n3 is not None and c4 is not None:
                torsion = math.degrees(torsion_angle(n3, c4, donor, acceptor))
                return 6 if -90.0 < torsion < 90.0 else 7
        if donor.name == "C5":
            return 9
        if donor.name == "C6":
            return 0

    if donor_residue.one_letter_name == "U":
        if donor.name == "N3":
            return 5
        if donor.name == "C5":
            return 9
        if donor.name == "C6":
            return 0

    if donor_residue.one_letter_name == "T":
        if donor.name == "N3":
            return 5
        if donor.name == "C6":
            return 0
        if donor.name == "C7":
            return 9

    return None

detect_cis_trans(residue_i, residue_j)

Classify a base pair as cis or trans based on a torsion angle.

The function uses C1'–N1/N9 atoms of both residues to compute a torsion angle and classifies the pair as cis or trans.

Parameters:

Name Type Description Default
residue_i Residue3D

First residue in the pair.

required
residue_j Residue3D

Second residue in the pair.

required

Returns:

Type Description
Optional[str]

"c" for cis, "t" for trans, or None if the required atoms are missing.

Source code in src/rnapolis/annotator.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def detect_cis_trans(residue_i: Residue3D, residue_j: Residue3D) -> Optional[str]:
    """
    Classify a base pair as cis or trans based on a torsion angle.

    The function uses C1'–N1/N9 atoms of both residues to compute a torsion
    angle and classifies the pair as cis or trans.

    Args:
        residue_i: First residue in the pair.
        residue_j: Second residue in the pair.

    Returns:
        ``"c"`` for cis, ``"t"`` for trans, or ``None`` if the required atoms are missing.
    """
    c1p_i = residue_i.find_atom("C1'")
    c1p_j = residue_j.find_atom("C1'")

    if residue_i.one_letter_name in "AG":
        n9n1_i = residue_i.find_atom("N9")
    else:
        n9n1_i = residue_i.find_atom("N1")

    if residue_j.one_letter_name in "AG":
        n9n1_j = residue_j.find_atom("N9")
    else:
        n9n1_j = residue_j.find_atom("N1")

    if c1p_i is None or c1p_j is None or n9n1_i is None or n9n1_j is None:
        return None

    torsion = math.degrees(torsion_angle(c1p_i, n9n1_i, n9n1_j, c1p_j))
    return "c" if -90.0 < torsion < 90.0 else "t"

extract_base_interactions(tertiary_structure, model=None)

Extract all base-level interactions from a 3D structure.

This includes base pairs, base–phosphate, base–ribose and stacking interactions and returns them wrapped in a BaseInteractions object.

Parameters:

Name Type Description Default
tertiary_structure Structure3D

Input 3D structure.

required
model Optional[int]

Optional model number to analyse.

None

Returns:

Name Type Description
BaseInteractions BaseInteractions

Container with all detected interactions.

Source code in src/rnapolis/annotator.py
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
def extract_base_interactions(
    tertiary_structure: Structure3D, model: Optional[int] = None
) -> BaseInteractions:
    """
    Extract all base-level interactions from a 3D structure.

    This includes base pairs, base–phosphate, base–ribose and stacking
    interactions and returns them wrapped in a BaseInteractions object.

    Args:
        tertiary_structure: Input 3D structure.
        model: Optional model number to analyse.

    Returns:
        BaseInteractions: Container with all detected interactions.
    """
    base_pairs, base_phosphate, base_ribose = find_pairs(tertiary_structure, model)
    stackings = find_stackings(tertiary_structure, model)
    return BaseInteractions.from_structure3d(
        tertiary_structure, base_pairs, stackings, base_ribose, base_phosphate, []
    )

find_pairs(structure, model=None)

Find base–base, base–phosphate and base–ribose interactions.

The function scans all residues in the structure, builds a KDTree of donors and acceptors and applies geometric filters to detect hydrogen bonds and classify them into interaction types.

Parameters:

Name Type Description Default
structure Structure3D

3D structure to analyse.

required
model Optional[int]

Optional model number to restrict the search to a single model.

None

Returns:

Type Description
Tuple[List[BasePair], List[BasePhosphate], List[BaseRibose]]

Lists of base pairs, base–phosphate and base–ribose interactions.

Source code in src/rnapolis/annotator.py
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
def find_pairs(
    structure: Structure3D, model: Optional[int] = None
) -> Tuple[List[BasePair], List[BasePhosphate], List[BaseRibose]]:
    """
    Find base–base, base–phosphate and base–ribose interactions.

    The function scans all residues in the structure, builds a KDTree of
    donors and acceptors and applies geometric filters to detect hydrogen
    bonds and classify them into interaction types.

    Args:
        structure: 3D structure to analyse.
        model: Optional model number to restrict the search to a single model.

    Returns:
        Lists of base pairs, base–phosphate and base–ribose interactions.
    """
    # put all donors and acceptors into a KDTree
    coordinates = []
    coordinates_atom_map: Dict[Tuple[float, float, float], Atom] = {}
    coordinates_type_map: Dict[Tuple[float, float, float], str] = {}
    coordinates_residue_map: Dict[Tuple[float, float, float], Residue3D] = {}
    for residue in structure.residues:
        if model is not None and residue.model != model:
            continue
        acceptors = (
            BASE_ACCEPTORS.get(residue.one_letter_name, [])
            + RIBOSE_ACCEPTORS
            + PHOSPHATE_ACCEPTORS
        )
        donors = BASE_DONORS.get(residue.one_letter_name, [])
        for atom_name in acceptors + donors:
            atom = residue.find_atom(atom_name)
            if atom:
                xyz = (atom.x, atom.y, atom.z)
                coordinates.append(xyz)
                coordinates_atom_map[xyz] = atom
                coordinates_type_map[xyz] = (
                    "acceptor" if atom_name in acceptors else "donor"
                )
                coordinates_residue_map[xyz] = residue

    if len(coordinates) < 2:
        return [], [], []

    kdtree = KDTree(coordinates)

    # find all hydrogen bonds
    hydrogen_bonds = []
    base_phosphate_pairs = []
    base_ribose_pairs = []
    used_atoms: Set[Atom] = set()
    for i, j in kdtree.query_pairs(HYDROGEN_BOND_MAX_DISTANCE):
        type_i = coordinates_type_map[coordinates[i]]
        type_j = coordinates_type_map[coordinates[j]]

        # process only acceptor/donor pairs, not acceptor/acceptor or donor/donor
        if type_i == type_j:
            continue

        atom_i = coordinates_atom_map[coordinates[i]]
        atom_j = coordinates_atom_map[coordinates[j]]

        # skip spurious hydrogen bonds in the same residue
        if (
            atom_i.label is not None
            and atom_i.label is not None
            and atom_i.label == atom_j.label
        ):
            continue
        if (
            atom_i.auth is not None
            and atom_i.auth is not None
            and atom_i.auth == atom_j.auth
        ):
            continue

        residue_i = coordinates_residue_map[coordinates[i]]
        residue_j = coordinates_residue_map[coordinates[j]]
        logging.debug(
            f"Checking pair {residue_i.full_name} {atom_i.name} - "
            f"{residue_j.full_name} {atom_j.name}"
        )

        # check for base-phosphate contacts
        if (
            (atom_i.name in PHOSPHATE_ACCEPTORS or atom_j.name in PHOSPHATE_ACCEPTORS)
            and atom_i not in used_atoms
            and atom_j not in used_atoms
        ):
            logging.debug("Checking base-phosphate interaction")
            if type_i == "donor":
                donor_residue, acceptor_residue = residue_i, residue_j
                donor_atom, acceptor_atom = atom_i, atom_j
            else:
                donor_residue, acceptor_residue = residue_j, residue_i
                donor_atom, acceptor_atom = atom_j, atom_i
            bph = detect_bph_br_classification(donor_residue, donor_atom, acceptor_atom)
            if bph is not None:
                used_atoms.add(atom_i)
                used_atoms.add(atom_j)
                base_phosphate_pairs.append((donor_residue, acceptor_residue, bph))
            continue

        # check for base-ribose contacts
        if (
            (atom_i.name in RIBOSE_ACCEPTORS or atom_j.name in RIBOSE_ACCEPTORS)
            and atom_i not in used_atoms
            and atom_j not in used_atoms
        ):
            logging.debug("Checking base-ribose interaction")
            if type_i == "donor":
                donor_residue, acceptor_residue = residue_i, residue_j
                donor_atom, acceptor_atom = atom_i, atom_j
            else:
                donor_residue, acceptor_residue = residue_j, residue_i
                donor_atom, acceptor_atom = atom_j, atom_i
            br = detect_bph_br_classification(donor_residue, donor_atom, acceptor_atom)
            if br is not None:
                used_atoms.add(atom_i)
                used_atoms.add(atom_j)
                base_ribose_pairs.append((donor_residue, acceptor_residue, br))
            continue

        # check for base-base contacts
        if residue_i.base_normal_vector is None or residue_j.base_normal_vector is None:
            continue

        logging.debug("Checking base-base interaction")
        vector = atom_i.coordinates - atom_j.coordinates
        angle1 = math.degrees(
            angle_between_vectors(residue_i.base_normal_vector, vector)
        )
        angle2 = math.degrees(
            angle_between_vectors(residue_j.base_normal_vector, vector)
        )
        logging.debug(
            f"Angles between normals and hydrogen bond: {angle1:.2f} and {angle2:.2f}"
        )
        if (
            HYDROGEN_BOND_ANGLE_RANGE[0] < angle1 < HYDROGEN_BOND_ANGLE_RANGE[1]
            and HYDROGEN_BOND_ANGLE_RANGE[0] < angle2 < HYDROGEN_BOND_ANGLE_RANGE[1]
        ):
            hydrogen_bonds.append((atom_i, atom_j, residue_i, residue_j))

    # match hydrogen bonds with base edges
    labels = []
    for atom_i, atom_j, residue_i, residue_j in hydrogen_bonds:
        edges_i = BASE_EDGES.get(residue_i.one_letter_name, dict()).get(
            atom_i.name, None
        )
        edges_j = BASE_EDGES.get(residue_j.one_letter_name, dict()).get(
            atom_j.name, None
        )
        if edges_i is None or edges_j is None:
            continue

        # detect cis/trans
        cis_trans = detect_cis_trans(residue_i, residue_j)
        if cis_trans is None:
            continue

        logging.debug(
            f"Matched {residue_i.full_name} with {residue_j.full_name} as "
            f"{cis_trans} {edges_i} {edges_j}"
        )

        if residue_i < residue_j:
            for edge_i in edges_i:
                for edge_j in edges_j:
                    labels.append((residue_i, residue_j, cis_trans, edge_i, edge_j))
        else:
            for edge_i in edges_i:
                for edge_j in edges_j:
                    labels.append((residue_j, residue_i, cis_trans, edge_j, edge_i))

    # create a list of base pairs
    base_base_pairs = []
    occupied = set()

    counter = Counter(labels)
    for interaction, hydrogen_bond_count in counter.most_common():
        if hydrogen_bond_count < 2:
            continue

        residue_i, residue_j, cis_trans, edge_i, edge_j = interaction

        if (residue_i, edge_i) in occupied:
            continue
        if (residue_j, edge_j) in occupied:
            continue

        occupied.add((residue_i, edge_i))
        occupied.add((residue_j, edge_j))

        lw = LeontisWesthof[f"{cis_trans}{edge_i}{edge_j}"]
        base_base_pairs.append((residue_i, residue_j, lw))

    base_pairs = []
    for residue_i, residue_j, lw in sorted(base_base_pairs):
        base_pairs.append(
            BasePair(
                Residue(residue_i.label, residue_i.auth),
                Residue(residue_j.label, residue_j.auth),
                lw,
                Saenger.from_leontis_westhof(
                    residue_i.one_letter_name, residue_j.one_letter_name, lw
                ),
            )
        )

    bph_map = merge_and_clean_bph_br(sorted(base_phosphate_pairs))
    base_phosphates = []
    for pair, bphs in bph_map.items():
        residue_i, residue_j = pair
        for bph in bphs:
            base_phosphates.append(
                BasePhosphate(
                    Residue(residue_i.label, residue_i.auth),
                    Residue(residue_j.label, residue_j.auth),
                    BPh[f"_{bph}"],
                )
            )

    br_map = merge_and_clean_bph_br(sorted(base_ribose_pairs))
    base_riboses = []
    for pair, brs in br_map.items():
        residue_i, residue_j = pair
        for br in brs:
            base_riboses.append(
                BaseRibose(
                    Residue(residue_i.label, residue_i.auth),
                    Residue(residue_j.label, residue_j.auth),
                    BR[f"_{br}"],
                )
            )

    return base_pairs, base_phosphates, base_riboses

find_stackings(structure, model=None)

Find base stacking interactions in a 3D structure.

Stacking is detected using geometric centres of bases, distance thresholds and angles between base normals and connecting vectors.

Parameters:

Name Type Description Default
structure Structure3D

3D structure to analyse.

required
model Optional[int]

Optional model number to restrict the search.

None

Returns:

Type Description
List[Stacking]

List of detected stacking interactions.

Source code in src/rnapolis/annotator.py
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
def find_stackings(
    structure: Structure3D, model: Optional[int] = None
) -> List[Stacking]:
    """
    Find base stacking interactions in a 3D structure.

    Stacking is detected using geometric centres of bases, distance
    thresholds and angles between base normals and connecting vectors.

    Args:
        structure: 3D structure to analyse.
        model: Optional model number to restrict the search.

    Returns:
        List of detected stacking interactions.
    """
    # put all nitrogen ring centers into a KDTree
    coordinates = []
    coordinates_residue_map: Dict[Tuple[float, float, float], Residue3D] = {}
    for residue in structure.residues:
        if model is not None and residue.model != model:
            continue
        base_atoms = BASE_ATOMS.get(residue.one_letter_name, [])
        xs, ys, zs = [], [], []
        for atom_name in base_atoms:
            atom = residue.find_atom(atom_name)
            if atom is not None:
                xs.append(atom.x)
                ys.append(atom.y)
                zs.append(atom.z)
        if len(xs) > 0:
            geometric_center = (sum(xs) / len(xs), sum(ys) / len(ys), sum(zs) / len(zs))
            coordinates.append(geometric_center)
            coordinates_residue_map[geometric_center] = residue

    if len(coordinates) < 2:
        return []

    kdtree = KDTree(coordinates)

    # find all stacking interaction
    pairs = []
    for i, j in kdtree.query_pairs(STACKING_MAX_DISTANCE):
        residue_i = coordinates_residue_map[coordinates[i]]
        residue_j = coordinates_residue_map[coordinates[j]]

        # check angle between normals
        normal_i = residue_i.base_normal_vector
        normal_j = residue_j.base_normal_vector
        if normal_i is None or normal_j is None:
            continue

        angle = min(
            [
                angle_between_vectors(normal_i, normal_j),
                angle_between_vectors(-normal_i, normal_j),
            ]
        )
        if math.degrees(angle) > STACKING_MAX_ANGLE_BETWEEN_NORMALS:
            continue

        vector = numpy.array([coordinates[i][k] - coordinates[j][k] for k in (0, 1, 2)])
        angle = min(
            angle_between_vectors(vector, normal_i),
            angle_between_vectors(vector, normal_j),
        )
        if math.degrees(angle) > STACKING_MAX_ANGLE_BETWEEN_VECTOR_AND_NORMAL:
            continue

        same_direction = True if numpy.dot(normal_i, normal_j) > 0.0 else False

        if residue_i < residue_j:
            if same_direction:
                pairs.append((residue_i, residue_j, "upward"))
            else:
                pairs.append((residue_i, residue_j, "inward"))
        else:
            if same_direction:
                pairs.append((residue_j, residue_i, "downward"))
            else:
                pairs.append((residue_j, residue_i, "outward"))

    stackings = []
    for residue_i, residue_j, topology in sorted(pairs):
        nt1 = Residue(residue_i.label, residue_i.auth)
        nt2 = Residue(residue_j.label, residue_j.auth)
        stackings.append(Stacking(nt1, nt2, StackingTopology[topology]))

    return stackings

generate_pymol_script(mapping, stems)

Generate a PyMOL script to visualise stems as cylinders and dihedrals.

The script creates selections, pseudoatoms on stem centroids and CGO cylinders between them, plus optional dihedral measurements.

Parameters:

Name Type Description Default
mapping Mapping2D3D

Mapping between 2D indices and 3D residues.

required
stems List[Stem]

List of stems to visualise.

required

Returns:

Name Type Description
str str

Multi-line PyMOL script.

Source code in src/rnapolis/annotator.py
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
def generate_pymol_script(mapping: Mapping2D3D, stems: List[Stem]) -> str:
    """
    Generate a PyMOL script to visualise stems as cylinders and dihedrals.

    The script creates selections, pseudoatoms on stem centroids and CGO
    cylinders between them, plus optional dihedral measurements.

    Args:
        mapping: Mapping between 2D indices and 3D residues.
        stems: List of stems to visualise.

    Returns:
        str: Multi-line PyMOL script.
    """
    pymol_commands = []
    radius = 0.5
    r, g, b = 1.0, 0.0, 0.0  # Red color

    for stem_idx, stem in enumerate(stems):
        # Get residues for selection string
        try:
            res5p_first = mapping.bpseq_index_to_residue_map[stem.strand5p.first]
            res5p_last = mapping.bpseq_index_to_residue_map[stem.strand5p.last]
            res3p_first = mapping.bpseq_index_to_residue_map[stem.strand3p.first]
            res3p_last = mapping.bpseq_index_to_residue_map[stem.strand3p.last]

            # Prefer auth chain/number if available
            chain5p = (
                res5p_first.auth.chain if res5p_first.auth else res5p_first.label.chain
            )
            num5p_first = (
                res5p_first.auth.number
                if res5p_first.auth
                else res5p_first.label.number
            )
            num5p_last = (
                res5p_last.auth.number if res5p_last.auth else res5p_last.label.number
            )

            chain3p = (
                res3p_first.auth.chain if res3p_first.auth else res3p_first.label.chain
            )
            num3p_first = (
                res3p_first.auth.number
                if res3p_first.auth
                else res3p_first.label.number
            )
            num3p_last = (
                res3p_last.auth.number if res3p_last.auth else res3p_last.label.number
            )

            # Format selection string: select stem0, A/1-5/ or A/10-15/
            selection_str = (
                f"{chain5p}/{num5p_first}-{num5p_last}/ or "
                f"{chain3p}/{num3p_first}-{num3p_last}/"
            )
            pymol_commands.append(f"select stem{stem_idx}, {selection_str}")

        except (KeyError, AttributeError) as e:
            logging.warning(
                f"Could not generate selection string for stem {stem_idx}: "
                f"Missing residue data ({e})"
            )

        centroids = mapping.get_stem_coordinates(stem)

        # Need at least 2 centroids to draw a segment
        if len(centroids) < 2:
            # Removed warning log for stems with < 2 base pairs
            continue

        # Create pseudoatoms for each centroid
        for centroid_idx, centroid in enumerate(centroids):
            x, y, z = centroid
            pseudoatom_name = f"stem{stem_idx}_centroid{centroid_idx}"
            pymol_commands.append(
                f"pseudoatom {pseudoatom_name}, pos=[{x:.3f}, {y:.3f}, {z:.3f}]"
            )

        # Draw cylinders between consecutive centroids
        for seg_idx in range(len(centroids) - 1):
            p1 = centroids[seg_idx]
            p2 = centroids[seg_idx + 1]
            x1, y1, z1 = p1
            x2, y2, z2 = p2
            # Format: [CYLINDER, x1, y1, z1, x2, y2, z2, radius, r1, g1, b1, r2, g2, b2]
            # Use 9.0 for CYLINDER code
            # Use same color for both ends
            cgo_object = (
                f"[ 9.0, {x1:.3f}, {y1:.3f}, {z1:.3f}, "
                f"{x2:.3f}, {y2:.3f}, {z2:.3f}, {radius}, "
                f"{r}, {g}, {b}, {r}, {g}, {b} ]"
            )
            pymol_commands.append(
                f'cmd.load_cgo({cgo_object}, "stem_{stem_idx}_seg_{seg_idx}")'
            )

        # Calculate and display dihedral angles between consecutive centroids
        if len(centroids) >= 4:
            for i in range(len(centroids) - 3):
                pa1 = f"stem{stem_idx}_centroid{i}"
                pa2 = f"stem{stem_idx}_centroid{i + 1}"
                pa3 = f"stem{stem_idx}_centroid{i + 2}"
                pa4 = f"stem{stem_idx}_centroid{i + 3}"
                dihedral_name = f"stem{stem_idx}_dihedral{i}"
                pymol_commands.append(
                    f"dihedral {dihedral_name}, {pa1}, {pa2}, {pa3}, {pa4}"
                )

    return "\n".join(pymol_commands)

handle_output_arguments(args, structure2d, mapping, input_filename)

Handle all output options based on parsed CLI arguments.

Depending on the flags, this function writes BPSEQ, CSV, JSON, DOT, PyMOL scripts and optional CSV summaries for stems and inter-stem parameters.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments.

required
structure2d Structure2D

Secondary structure produced by the annotator.

required
mapping Mapping2D3D

Mapping between 2D indices and 3D residues.

required
input_filename str

Name of the input PDB/mmCIF file.

required
Source code in src/rnapolis/annotator.py
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
def handle_output_arguments(
    args: argparse.Namespace,
    structure2d: Structure2D,
    mapping: Mapping2D3D,
    input_filename: str,
):
    """
    Handle all output options based on parsed CLI arguments.

    Depending on the flags, this function writes BPSEQ, CSV, JSON, DOT,
    PyMOL scripts and optional CSV summaries for stems and inter-stem
    parameters.

    Args:
        args: Parsed command-line arguments.
        structure2d: Secondary structure produced by the annotator.
        mapping: Mapping between 2D indices and 3D residues.
        input_filename: Name of the input PDB/mmCIF file.
    """
    input_basename = os.path.basename(input_filename)
    if args.csv:
        write_csv(args.csv, structure2d)

    if args.json:
        write_json(args.json, structure2d)

    if args.bpseq:
        write_bpseq(args.bpseq, structure2d.bpseq)

    if args.extended:
        print(structure2d.extended_dot_bracket)
    else:
        print(structure2d.dot_bracket)

    if args.dot:
        print(structure2d.bpseq.graphviz)

    if args.pml:
        pml_script = generate_pymol_script(mapping, structure2d.stems)
        with open(args.pml, "w") as f:
            f.write(pml_script)

    if args.inter_stem_csv:
        if structure2d.inter_stem_parameters:
            # Convert list of dataclasses to list of dicts
            params_list = [
                {
                    "stem1_idx": p.stem1_idx,
                    "stem2_idx": p.stem2_idx,
                    "type": p.type,
                    "torsion": p.torsion,
                    "min_endpoint_distance": p.min_endpoint_distance,
                    "torsion_angle_pdf": p.torsion_angle_pdf,
                    "min_endpoint_distance_pdf": p.min_endpoint_distance_pdf,
                    "coaxial_probability": p.coaxial_probability,
                }
                for p in structure2d.interStemParameters
            ]
            df = pd.DataFrame(params_list)
            df["input_basename"] = input_basename
            # Reorder columns to put input_basename first
            cols = ["input_basename"] + [
                col for col in df.columns if col != "input_basename"
            ]
            df = df[cols]
            df.to_csv(args.inter_stem_csv, index=False)
        else:
            logging.warning(
                f"No inter-stem parameters calculated for {input_basename}, "
                f"CSV file '{args.inter_stem_csv}' will be empty or not created."
            )

    if args.stems_csv:
        if structure2d.stems:
            stems_data = []
            for i, stem in enumerate(structure2d.stems):
                try:
                    res5p_first = mapping.bpseq_index_to_residue_map.get(
                        stem.strand5p.first
                    )
                    res5p_last = mapping.bpseq_index_to_residue_map.get(
                        stem.strand5p.last
                    )
                    res3p_first = mapping.bpseq_index_to_residue_map.get(
                        stem.strand3p.first
                    )
                    res3p_last = mapping.bpseq_index_to_residue_map.get(
                        stem.strand3p.last
                    )

                    stems_data.append(
                        {
                            "stem_idx": i,
                            "strand5p_first_nt_id": res5p_first.full_name
                            if res5p_first
                            else None,
                            "strand5p_last_nt_id": res5p_last.full_name
                            if res5p_last
                            else None,
                            "strand3p_first_nt_id": res3p_first.full_name
                            if res3p_first
                            else None,
                            "strand3p_last_nt_id": res3p_last.full_name
                            if res3p_last
                            else None,
                            "strand5p_sequence": stem.strand5p.sequence,
                            "strand3p_sequence": stem.strand3p.sequence,
                        }
                    )
                except KeyError as e:
                    logging.warning(
                        f"Could not find residue for stem {i} (index {e}), "
                        "skipping stem details."
                    )
                    continue

            if stems_data:
                df_stems = pd.DataFrame(stems_data)
                df_stems["input_basename"] = input_basename
                # Reorder columns
                stem_cols = ["input_basename", "stem_idx"] + [
                    col
                    for col in df_stems.columns
                    if col not in ["input_basename", "stem_idx"]
                ]
                df_stems = df_stems[stem_cols]
                df_stems.to_csv(args.stems_csv, index=False)
            else:
                logging.warning(
                    f"No valid stem data generated for {input_basename}, "
                    f"CSV file '{args.stems_csv}' will be empty or not created."
                )
        else:
            logging.warning(
                f"No stems found for {input_basename}, CSV file "
                f"'{args.stems_csv}' will be empty or not created."
            )

main()

Command-line entry point for the annotator tool.

It reads a 3D structure, extracts all base-level interactions, builds a secondary structure and writes outputs requested by the user.

Source code in src/rnapolis/annotator.py
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
def main():
    """
    Command-line entry point for the annotator tool.

    It reads a 3D structure, extracts all base-level interactions,
    builds a secondary structure and writes outputs requested by the user.
    """
    parser = argparse.ArgumentParser()
    parser.add_argument("input", help="Path to PDB or mmCIF file")
    parser.add_argument(
        "-f",
        "--find-gaps",
        action="store_true",
        help=(
            "(optional) if set, the program will detect gaps and break the PDB "
            "chain into two or more strands; the gap is defined as O3'-P "
            f"distance greater then {1.5 * AVERAGE_OXYGEN_PHOSPHORUS_DISTANCE_COVALENT}"
        ),
    )
    add_common_output_arguments(parser)
    args = parser.parse_args()

    file = handle_input_file(args.input)
    structure3d = read_3d_structure(file, None)
    base_interactions = extract_base_interactions(structure3d)
    structure2d, mapping = structure3d.extract_secondary_structure(
        base_interactions, args.find_gaps, args.decompose_pseudoknot_free
    )

    handle_output_arguments(args, structure2d, mapping, args.input)

merge_and_clean_bph_br(pairs)

Merge and simplify base–phosphate and base–ribose classifications.

Multiple classifications for the same residue pair are merged into a set and then cleaned according to simple rules (for example 3+5 → 4).

Parameters:

Name Type Description Default
pairs List[Tuple[Residue3D, Residue3D, int]]

List of residue pairs with their classification codes.

required

Returns:

Type Description
Dict[Tuple[Residue3D, Residue3D], OrderedSet[int]]

Mapping from residue pairs to a cleaned set of classification codes.

Source code in src/rnapolis/annotator.py
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
def merge_and_clean_bph_br(
    pairs: List[Tuple[Residue3D, Residue3D, int]],
) -> Dict[Tuple[Residue3D, Residue3D], OrderedSet[int]]:
    """
    Merge and simplify base–phosphate and base–ribose classifications.

    Multiple classifications for the same residue pair are merged into a set
    and then cleaned according to simple rules (for example 3+5 → 4).

    Args:
        pairs: List of residue pairs with their classification codes.

    Returns:
        Mapping from residue pairs to a cleaned set of classification codes.
    """
    bph_br_map: Dict[Tuple[Residue3D, Residue3D], OrderedSet[int]] = defaultdict(
        OrderedSet
    )
    for residue_i, residue_j, classification in pairs:
        bph_br_map[(residue_i, residue_j)].add(classification)
    for bphs_brs in bph_br_map.values():
        # 3BPh and 5BPh simultanously means that it is actually 4BPh
        if 3 in bphs_brs and 5 in bphs_brs:
            bphs_brs.remove(3)
            bphs_brs.remove(5)
            bphs_brs.add(4)
        # 7BPh and 9BPh simultanously means that it is actually 8BPh
        if 7 in bphs_brs and 9 in bphs_brs:
            bphs_brs.remove(7)
            bphs_brs.remove(9)
            bphs_brs.add(8)
    for key, bphs_brs in bph_br_map.items():
        if len(bphs_brs) > 1:
            bph_br_map[key] = OrderedSet([bphs_brs[0]])
    return bph_br_map

write_bpseq(path, bpseq)

Write a BpSeq object to a plain text file.

The BpSeq object is converted to a string using its __str__ method.

Source code in src/rnapolis/annotator.py
781
782
783
784
785
786
787
788
def write_bpseq(path: str, bpseq: BpSeq):
    """
    Write a BpSeq object to a plain text file.

    The BpSeq object is converted to a string using its ``__str__`` method.
    """
    with open(path, "w") as f:
        f.write(str(bpseq))

write_csv(path, structure2d)

Write base interactions from Structure2D to a CSV file.

The CSV contains base pairs, stacking, base–phosphate and base–ribose interactions in a flat table.

Source code in src/rnapolis/annotator.py
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
def write_csv(path: str, structure2d: Structure2D):
    """
    Write base interactions from Structure2D to a CSV file.

    The CSV contains base pairs, stacking, base–phosphate and base–ribose
    interactions in a flat table.
    """
    with open(path, "w") as f:
        writer = csv.writer(f)
        writer.writerow(["nt1", "nt2", "type", "classification-1", "classification-2"])
        for base_pair in structure2d.base_pairs:
            writer.writerow(
                [
                    base_pair.nt1.full_name,
                    base_pair.nt2.full_name,
                    "base pair",
                    base_pair.lw.value,
                    (
                        base_pair.saenger.value or ""
                        if base_pair.saenger is not None
                        else ""
                    ),
                ]
            )
        for stacking in structure2d.stackings:
            writer.writerow(
                [
                    stacking.nt1.full_name,
                    stacking.nt2.full_name,
                    "stacking",
                    stacking.topology.value if stacking.topology is not None else "",
                    "",
                ]
            )
        for base_phosphate in structure2d.base_phosphate_interactions:
            writer.writerow(
                [
                    base_phosphate.nt1.full_name,
                    base_phosphate.nt2.full_name,
                    "base-phosphate interaction",
                    base_phosphate.bph.value if base_phosphate.bph is not None else "",
                    "",
                ]
            )
        for base_ribose in structure2d.base_ribose_interactions:
            writer.writerow(
                [
                    base_ribose.nt1.full_name,
                    base_ribose.nt2.full_name,
                    "base-ribose interaction",
                    base_ribose.br.value if base_ribose.br is not None else "",
                    "",
                ]
            )
        for other in structure2d.other_interactions:
            writer.writerow(
                [
                    other.nt1.full_name,
                    other.nt2.full_name,
                    "other interaction",
                    "",
                    "",
                ]
            )

write_json(path, structure2d)

Write a Structure2D object to a JSON file using orjson.

The function copies the object, replaces residues with simpler Residue objects and serialises everything with options that handle numpy types and non-string dictionary keys.

Source code in src/rnapolis/annotator.py
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
def write_json(path: str, structure2d: Structure2D):
    """
    Write a Structure2D object to a JSON file using orjson.

    The function copies the object, replaces residues with simpler Residue
    objects and serialises everything with options that handle numpy types
    and non-string dictionary keys.
    """
    processed = copy.deepcopy(structure2d)
    processed.bpseq_index = {
        k: Residue(v.label, v.auth) for k, v in structure2d.bpseq_index.items()
    }

    with open(path, "wb") as f:
        # Add OPT_SERIALIZE_NUMPY to handle numpy types like float64
        # Add OPT_NON_STR_KEYS to preserve integer keys in dictionaries
        f.write(
            orjson.dumps(
                processed, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS
            )
        )