示例#1
0
 def save(self, output, format_name="fasta"):
     import importlib
     mod = importlib.import_module(".io.save%s" % format_name.upper(),
                                   "chimerax.seqalign")
     from chimerax import io
     with io.open_output(output, 'utf-8') as stream:
         mod.save(self.session, self, stream)
示例#2
0
def distance_save(session, save_file_name):
    from chimerax.io import open_output
    save_file = open_output(save_file_name, 'utf-8')
    from chimerax.atomic import Structure
    for model in session.models:
        if not isinstance(model, Structure):
            continue
        print("Model", model.id_string, "is", model.name, file=save_file)

    print("\nDistance information:", file=save_file)
    grp = session.pb_manager.get_group("distances", create=False)
    if grp:
        pbs = list(grp.pseudobonds)
        pbs.sort(key=lambda pb: pb.length)
        fmt = "%s <-> %s:  " + session.pb_dist_monitor.distance_format
        for pb in pbs:
            a1, a2 = pb.atoms
            d_string = fmt % (a1, a2.string(relative_to=a1), pb.length)
            # drop angstrom symbol...
            if not d_string[-1].isdigit():
                d_string = d_string[:-1]
            print(d_string, file=save_file)
    if save_file_name != save_file:
        # Wasn't a stream that was passed in...
        save_file.close()
示例#3
0
 def _save(self, session, file_name):
     from chimerax.io import open_output
     with open_output(file_name, encoding="utf-8") as f:
         if hasattr(self, 'save_file_preamble'):
             print(self.save_file_preamble, file=f)
         print("%s header for %s" % (self.name, self.alignment), file=f)
         for i, val in enumerate(self):
             print("%d:" % (i+1), val, file=f)
示例#4
0
def _file_output(file_name, info, naming_style):
    overlap_cutoff, hbond_allowance, bond_separation, intra_res, intra_mol, \
                        clashes, output_grouping, test_type, res_separation = info
    from chimerax.io import open_output
    out_file = open_output(file_name, 'utf-8')
    if test_type != "distances":
        print("Allowed overlap: %g" % overlap_cutoff, file=out_file)
        print("H-bond overlap reduction: %g" % hbond_allowance, file=out_file)
    print("Ignore %s between atoms separated by %d bonds or less" %
          (test_type, bond_separation),
          file=out_file)
    if res_separation:
        print(
            "Ignore %s between atoms in residues less than %d apart in sequence"
            % (test_type, res_separation),
            file=out_file)
    print("Detect intra-residue %s:" % test_type, intra_res, file=out_file)
    print("Detect intra-molecule %s:" % test_type, intra_mol, file=out_file)
    seen = set()
    data = []
    from chimerax.geometry import distance
    for a, aclashes in clashes.items():
        for c, val in aclashes.items():
            if (c, a) in seen:
                continue
            seen.add((a, c))
            if a in output_grouping:
                out1, out2 = a, c
            else:
                out1, out2 = c, a
            l1, l2 = out1.string(style=naming_style), out2.string(
                style=naming_style)
            data.append(
                (val, l1, l2, distance(out1.scene_coord, out2.scene_coord)))
    data.sort()
    data.reverse()
    print("\n%d %s" % (len(data), test_type), file=out_file)
    field_width1 = max([len(l1) for v, l1, l2, d in data] + [5])
    field_width2 = max([len(l2) for v, l1, l2, d in data] + [5])
    #print("%*s  %*s  overlap  distance" % (0-field_width1, "atom1", 0-field_width2, "atom2"),
    print(
        f"{'atom1':^{field_width1}}  {'atom2':^{field_width2}}  overlap  distance",
        file=out_file)
    for v, l1, l2, d in data:
        print(f"%*s  %*s   %5.3f    %5.3f" %
              (0 - field_width1, l1, 0 - field_width2, l2, v, d),
              file=out_file)
    if file_name != out_file:
        # only close file if we opened it...
        out_file.close()
示例#5
0
def save_xyz(session, path, structures=None):
    """Write an XYZ file from given models, or all models if None.
    """
    # Open path with proper encoding; 'open_output' automatically
    # handles compression if the file name also has a compression
    # suffix (e.g. .gz)
    from chimerax.io import open_output
    f = open_output(path, session.data_formats['XYZ'].encoding)

    # If no models were given, use all atomic structures
    if structures is None:
        from chimerax.atomic import AtomicStructure
        structures = session.models.list(type=AtomicStructure)
    num_atoms = 0

    # Loop through structures and print atoms
    for s in structures:
        # We get the list of atoms and transformed atomic coordinates
        # as arrays so that we can limit the number of accesses to
        # molecular data, which is slower than accessing arrays directly
        atoms = s.atoms
        coords = atoms.scene_coords

        # First line for a structure is the number of atoms
        print(str(s.num_atoms), file=f)
        # Second line is a comment
        print(getattr(s, "name", "unnamed"), file=f)
        # One line per atom thereafter
        for i in range(len(atoms)):
            a = atoms[i]
            c = coords[i]
            print("%s %.3f %.3f %.3f" % (a.element, c[0], c[1], c[2]), file=f)
        num_atoms += s.num_atoms
    f.close()

    # Notify user that file was saved
    session.logger.status(
        "Saved XYZ file containing %d structures (%d atoms)" %
        (len(structures), num_atoms))
示例#6
0
def write_mol2(session,
               file_name,
               *,
               models=None,
               atoms=None,
               status=None,
               anchor=None,
               rel_model=None,
               sybyl_hyd_naming=True,
               combine_models=False,
               skip_atoms=None,
               res_num=False,
               gaff_type=False,
               gaff_fail_error=None):
    """Write a Mol2 file.

    Parameters
    ----------

    file_name : str, or file object open for writing
        Output file.

    models : a list/tuple/set of models (:py:class:`~chimerax.atomic.Structure`s)
        or a single :py:class:`~chimerax.atomic.Structure`
        The structure(s) to write out. If None (and 'atoms' is also None) then
        write out all structures.

    atoms : an :py:class:`~chimerax.atomic.Atoms` collection or None.  If not None,
        then 'models' must be None.

    status : function or None
        If not None, a function that takes a string -- used to report the progress of the write.

    anchor : :py:class:`~chimerax.atomic.Atoms` collection
        Atoms (and their implied internal bonds) that should be written out to the
        @SET section of the file as the rigid framework for flexible ligand docking.

    rel_model : Model whose coordinate system the coordinates should be written out reletive to,
        i.e. take the output atoms' coordinates and apply the inverse of the rel_model's transform.

    sybyl_hyd_naming : bool
        Controls whether hydrogen names should be "Sybyl-like" or "PDB-like" -- e.g.  HG21 vs. 1HG2.

    combine_models : bool
        Controls whether multiple structures will be combined into a single @MOLECULE
        section (value: True) or each given its own section (value: False).

    skip_atoms : list/set of :py:class:`~chimerax.atomic.Atom`s or an :py:class:`~chimerax.atomic.Atoms` collection or None
       Atoms to not output

    res_num : bool
        Controls whether residue sequence numbers are included in the substructure name.
        Since Sybyl Mol2 files include them, this defaults to True.

    gaff_type : bool
       If 'gaff_type' is True, outout GAFF atom types instead of Sybyl atom types.
       `gaff_fail_error`, if specified, is the type of error to throw (e.g. UserError)
       if there is no gaff_type attribute for an atom, otherwise throw the standard AttributeError.
    """

    if status:
        status("Writing Mol2 file %s" % file_name)

    from chimerax import io
    f = io.open_output(file_name, "utf-8")

    sort_key_func = serial_sort_key = lambda a, ri={}: write_mol2_sort_key(
        a, res_indices=ri)

    from chimerax.atomic import Structure, Atoms, Residue

    class JPBGroup:
        def __init__(self, atoms):
            atom_set = set(atoms)
            pbs = []
            for s in atoms.unique_structures:
                pbg = s.pbg_map.get(s.PBG_METAL_COORDINATION, None)
                if not pbg:
                    continue
                for pb in pbg.pseudobonds:
                    if pb.atoms[0] in atom_set and pb.atoms[1] in atom_set:
                        pbs.append(pb)
            self._pbs = pbs

        @property
        def pseudobonds(self):
            return self._pbs

    if models is None:
        if atoms is None:
            structures = session.models.list(type=Structure)
        else:
            structures = atoms
    else:
        if atoms is None:
            if isinstance(models, Structure):
                structures = [models]
            else:
                structures = [m for m in models if isinstance(m, Structure)]
        else:
            raise ValueError(
                "Cannot specify both 'models' and 'atoms' keywords")

    if isinstance(structures, Atoms):

        class Jumbo:
            def __init__(self, atoms):
                self.atoms = atoms
                self.residues = atoms.unique_residues
                self.bonds = atoms.intra_bonds
                self.name = "(selection)"
                self.pbg_map = {
                    Structure.PBG_METAL_COORDINATION: JPBGroup(atoms)
                }

        structures = [Jumbo(structures)]
        sort_key_func = lambda a: (a.structure.id, ) + serial_sort_key(a)
        combine_models = False

    # transform...
    if rel_model is None:
        from chimerax.geometry import identity
        xform = identity()
    else:
        xform = rel_model.scene_position.inverse()

    # need to find amide moieties since Sybyl has an explicit amide type
    if status:
        status("Finding amides")
    from chimerax.chem_group import find_group
    amides = find_group("amide", structures)
    amide_Ns = set([amide[2] for amide in amides])
    amide_CNs = set([amide[0] for amide in amides])
    amide_CNs.update(amide_Ns)
    amide_Os = set([amide[1] for amide in amides])

    substructure_names = None
    if combine_models and len(structures) > 1:
        # create a fictitious jumbo model
        class Jumbo:
            def __init__(self, structures):
                self.name = structures[0].name + " (combined)"
                from chimerax.atomic import concatenate
                self.atoms = concatenate([s.atoms for s in structures])
                self.bonds = concatenate([s.bonds for s in structures])
                self.residues = concatenate([s.residues for s in structures])
                self.pbg_map = {
                    Structure.PBG_METAL_COORDINATION: JPBGroup(self.atoms)
                }
                # if combining single-residue structures,
                # can be more informative to use model name
                # instead of residue type for substructure
                if len(structures) == len(self.residues):
                    rnames = self.residues.names
                    if len(set(rnames)) < len(rnames):
                        snames = [s.name for s in structures]
                        if len(set(snames)) == len(snames):
                            self.substructure_names = dict(
                                zip(self.residues, snames))

        structures = [Jumbo(structures)]
        if hasattr(structures[-1], 'substructure_names'):
            substructure_names = structures[-1].substructure_names
            delattr(structures[-1], 'substructure_names')
        sort_key_func = lambda a: (a.structure.id, ) + serial_sort(a)

    # write out structures
    for struct in structures:
        if hasattr(struct, 'mol2_comments'):
            for m2c in struct.mol2_comments:
                print(m2c, file=f)
        if hasattr(struct, 'solvent_info'):
            print(struct.solvent_info, file=f)

        # molecule section header
        print("%s" % MOLECULE_HEADER, file=f)

        # molecule name
        print("%s" % struct.name, file=f)

        atoms = list(struct.atoms)
        bonds = list(struct.bonds)
        # add metal-coordination bonds
        coord_grp = struct.pbg_map.get(Structure.PBG_METAL_COORDINATION, None)
        if coord_grp:
            bonds.extend(list(coord_grp.pseudobonds))
        if skip_atoms:
            skip_atoms = set(skip_atoms)
            atoms = [a for a in atoms if a not in skip_atoms]
            bonds = [
                b for b in bonds if b.atoms[0] not in skip_atoms
                and b.atoms[1] not in skip_atoms
            ]
        residues = struct.residues

        # Put the atoms in the order we want for output
        if status:
            status("Putting atoms in input order")
        atoms.sort(key=sort_key_func)

        # if anchor is not None, then there will be two entries in
        # the @SET section of the file...
        if anchor:
            sets = 2
        else:
            sets = 0
        # number of entries for various sections...
        print("%d %d %d 0 %d" % (len(atoms), len(bonds), len(residues), sets),
              file=f)

        # type of molecule
        if hasattr(struct, "mol2_type"):
            mtype = struct.mol2_type
        else:
            mtype = "SMALL"
            from chimerax.atomic import Sequence
            for r in struct.residues:
                if Sequence.protein3to1(r.name) != 'X':
                    mtype = "PROTEIN"
                    break
                if Sequence.nucleic3to1(r.name) != 'X':
                    mtype = "NUCLEIC_ACID"
                    break
        print(mtype, file=f)

        # indicate type of charge information
        if hasattr(struct, 'charge_model'):
            print(struct.charge_model, file=f)
        else:
            print("NO_CHARGES", file=f)

        if hasattr(struct, 'mol2_comment'):
            print("\n%s" % struct.mol2_comment, file=f)
        else:
            print("\n", file=f)

        if status:
            status("writing atoms")
        # atom section header
        print("%s" % ATOM_HEADER, file=f)

        # make a dictionary of residue indices so that we can do quick look ups
        res_indices = {}
        for i, r in enumerate(residues):
            res_indices[r] = i + 1
        for i, atom in enumerate(atoms):
            # atom ID, starting from 1
            print("%7d" % (i + 1), end=" ", file=f)

            # atom name, possibly rearranged if it's a hydrogen
            if sybyl_hyd_naming and not atom.name[0].isalpha():
                atom_name = atom.name[1:] + atom.name[0]
            else:
                atom_name = atom.name
            print("%-8s" % atom_name, end=" ", file=f)

            # use correct relative coordinate position
            coord = xform * atom.scene_coord
            print("%9.4f %9.4f %9.4f" % tuple(coord), end=" ", file=f)

            # atom type
            if gaff_type:
                try:
                    atom_type = atom.gaff_type
                except AttributeError:
                    if not gaff_fail_error:
                        raise
                    raise gaff_fail_error(
                        "%s has no Amber/GAFF type assigned.\n"
                        "Use the AddCharge tool to assign Amber/GAFF types." %
                        atom)
            elif hasattr(atom, 'mol2_type'):
                atom_type = atom.mol2_type
            elif atom in amide_Ns:
                atom_type = "N.am"
            elif atom.structure_category == "solvent" \
            and atom.residue.name in Residue.water_res_names:
                if atom.element.name == "O":
                    atom_type = "O.t3p"
                else:
                    atom_type = "H.t3p"
            elif atom.element.name == "N" and len(
                [r for r in atom.rings() if r.aromatic]) > 0:
                atom_type = "N.ar"
            elif atom.idatm_type == "C2" and len(
                [nb for nb in atom.neighbors if nb.idatm_type == "Ng+"]) > 2:
                atom_type = "C.cat"
            elif sulfur_oxygen(atom):
                atom_type = "O.2"
            else:
                try:
                    atom_type = chimera_to_sybyl[atom.idatm_type]
                except KeyError:
                    session.logger.warning(
                        "Atom whose IDATM type has no equivalent"
                        " Sybyl type: %s (type: %s)" % (atom, atom.idatm_type))
                    atom_type = str(atom.element)
            print("%-5s" % atom_type, end=" ", file=f)

            # residue-related info
            res = atom.residue

            # residue index
            print("%5d" % res_indices[res], end=" ", file=f)

            # substructure identifier and charge
            if hasattr(atom, 'charge') and atom.charge is not None:
                charge = atom.charge
            else:
                charge = 0.0
            if substructure_names:
                rname = substructure_names[res]
            elif res_num:
                rname = "%3s%-5d" % (res.name, res.number)
            else:
                rname = "%3s" % res.name
            print("%s %9.4f" % (rname, charge), file=f)

        if status:
            status("writing bonds")
        # bond section header
        print("%s" % BOND_HEADER, file=f)

        # make an atom-index dictionary to speed lookups
        atom_indices = {}
        for i, a in enumerate(atoms):
            atom_indices[a] = i + 1
        for i, bond in enumerate(bonds):
            a1, a2 = bond.atoms

            # ID
            print("%6d" % (i + 1), end=" ", file=f)

            # atom IDs
            print("%4d %4d" % (atom_indices[a1], atom_indices[a2]),
                  end=" ",
                  file=f)

            # bond order; give it our best shot...
            if hasattr(bond, 'mol2_type'):
                print(bond.mol2_type, file=f)
                continue
            amide_A1 = a1 in amide_CNs
            amide_A2 = a2 in amide_CNs
            if amide_A1 and amide_A2:
                print("am", file=f)
                continue
            if amide_A1 or amide_A2:
                if a1 in amide_Os or a2 in amide_Os:
                    print("2", file=f)
                else:
                    print("1", file=f)
                continue

            aromatic = False
            # 'bond' might be a metal-coordination bond so do a test for rings
            if hasattr(bond, 'rings'):
                for ring in bond.rings():
                    if ring.aromatic:
                        aromatic = True
                        break
            if aromatic:
                print("ar", file=f)
                continue

            try:
                geom1 = idatm_info[a1.idatm_type].geometry
            except KeyError:
                print("1", file=f)
                continue
            try:
                geom2 = idatm_info[a2.idatm_type].geometry
            except KeyError:
                print("1", file=f)
                continue
            # sulfone/sulfoxide is classically depicted as double-
            # bonded despite the high dipolar character of the
            # bond making it have single-bond character.  For
            # output, use the classical values.
            if sulfur_oxygen(a1) or sulfur_oxygen(a2):
                print("2", file=f)
                continue
            if geom1 not in [2, 3] or geom2 not in [2, 3]:
                print("1", file=f)
                continue
            # if either endpoint atom is in an aromatic ring and
            # the bond isn't, it's a single bond...
            for endp in [a1, a2]:
                aromatic = False
                for ring in endp.rings():
                    if ring.aromatic:
                        aromatic = True
                        break
                if aromatic:
                    break
            else:
                # neither endpoint in aromatic ring
                if geom1 == 2 and geom2 == 2:
                    print("3", file=f)
                else:
                    print("2", file=f)
                continue
            print("1", file=f)

        if status:
            status("writing residues")
        # residue section header
        print("%s" % SUBSTR_HEADER, file=f)

        for i, res in enumerate(residues):
            # residue id field
            print("%6d" % (i + 1), end=" ", file=f)

            # residue name field
            if substructure_names:
                rname = substructure_names[res]
            elif res_num:
                rname = "%3s%-4d" % (res.name, res.number)
            else:
                rname = "%3s" % res.name
            print(rname, end=" ", file=f)

            # ID of the root atom of the residue
            chain_atom = res.principal_atom
            if chain_atom is None:
                # if writing out a selection, not all residue atoms
                # might be in atom_indices...
                for chain_atom in res.atoms:
                    if chain_atom in atom_indices:
                        break
            print("%5d" % atom_indices[chain_atom], end=" ", file=f)

            print("RESIDUE           4", end=" ", file=f)

            # Sybyl seems to use chain 'A' when chain ID is blank,
            # so run with that
            chain_id = res.chain_id
            if not chain_id.strip():
                chain_id = 'A'
            print("%-4s  %3s" % (chain_id, res.name), end=" ", file=f)

            # number of out-of-substructure bonds
            cross_res_bonds = 0
            for a in res.atoms:
                for nb in a.neighbors:
                    if nb.residue != res:
                        cross_res_bonds += 1
            print("%5d" % cross_res_bonds, end="", file=f)
            # print "ROOT" if first or only residue of a chain
            if not res.chain or res.chain.existing_residues[0] == res:
                print(" ROOT", file=f)
            else:
                print(file=f)

        # write flexible ligand docking info
        if anchor:
            if status:
                status("writing anchor info")
            print("%s" % SET_HEADER, file=f)
            atom_indices = {}
            for i, a in enumerate(atoms):
                atom_indices[a] = i + 1
            bond_indices = {}
            for i, b in enumerate(bonds):
                bond_indices[b] = i + 1
            print(
                "ANCHOR          STATIC     ATOMS    <user>   **** Anchor Atom Set",
                file=f)
            print(len(anchor), end=" ", file=f)
            for a in anchor:
                if a in atom_indices:
                    print(atom_indices[a], end=" ", file=f)
            print(file=f)

            print(
                "RIGID           STATIC     BONDS    <user>   **** Rigid Bond Set",
                file=f)
            bonds = anchor.intra_bonds
            print(len(bonds), end=" ", file=f)
            for b in bonds:
                if b in bond_indices:
                    print(bond_indices[b], end=" ", file=f)
            print(file=f)

    if file_name != f:
        f.close()

    if status:
        status("Wrote Mol2 file %s" % file_name)
示例#7
0
 def button_clicked(self, label):
     session = self.controller.session
     if label == self.record_label:
         from chimerax.ui.open_save import SaveDialog
         if self._record_dialog is None:
             fmt = session.data_formats["ChimeraX commands"]
             self._record_dialog = dlg = SaveDialog(session,
                                                    self.window.ui_area,
                                                    "Save Commands",
                                                    data_formats=[fmt])
             from PyQt5.QtWidgets import QFrame, QLabel, QHBoxLayout, QVBoxLayout, QComboBox
             from PyQt5.QtWidgets import QCheckBox
             from PyQt5.QtCore import Qt
             options_frame = dlg.custom_area
             options_layout = QVBoxLayout(options_frame)
             options_frame.setLayout(options_layout)
             amount_frame = QFrame(options_frame)
             options_layout.addWidget(amount_frame, Qt.AlignCenter)
             amount_layout = QHBoxLayout(amount_frame)
             amount_layout.addWidget(QLabel("Save", amount_frame))
             self.save_amount_widget = saw = QComboBox(amount_frame)
             saw.addItems(["all", "selected"])
             amount_layout.addWidget(saw)
             amount_layout.addWidget(QLabel("commands", amount_frame))
             amount_frame.setLayout(amount_layout)
             self.append_checkbox = QCheckBox("Append to file",
                                              options_frame)
             self.append_checkbox.stateChanged.connect(self.append_changed)
             options_layout.addWidget(self.append_checkbox, Qt.AlignCenter)
             self.overwrite_disclaimer = disclaimer = QLabel(
                 "<small><i>(ignore overwrite warning)</i></small>",
                 options_frame)
             options_layout.addWidget(disclaimer, Qt.AlignCenter)
             disclaimer.hide()
         else:
             dlg = self._record_dialog
         if not dlg.exec():
             return
         path = dlg.selectedFiles()[0]
         if not path:
             from chimerax.core.errors import UserError
             raise UserError("No file specified for saving command history")
         if self.save_amount_widget.currentText() == "all":
             cmds = [cmd for cmd in self.history()]
         else:
             # listbox.selectedItems() may not be in order, so...
             items = [
                 self.listbox.item(i) for i in range(self.listbox.count())
                 if self.listbox.item(i).isSelected()
             ]
             cmds = [item.text() for item in items]
         from chimerax.io import open_output
         f = open_output(path,
                         encoding='utf-8',
                         append=self.append_checkbox.isChecked())
         for cmd in cmds:
             print(cmd, file=f)
         f.close()
         return
     if label == self.execute_label:
         for item in self.listbox.selectedItems():
             self.controller.cmd_replace(item.text())
             self.controller.execute()
         return
     if label == "Delete":
         retain = []
         listbox_index = 0
         for h_item in self._history:
             if self.typed_only and not h_item[1]:
                 retain.append(h_item)
                 continue
             if not self.listbox.item(listbox_index).isSelected():
                 # not selected for deletion
                 retain.append(h_item)
             listbox_index += 1
         self._history.replace(retain)
         self.populate()
         return
     if label == "Copy":
         clipboard = session.ui.clipboard()
         clipboard.setText("\n".join(
             [item.text() for item in self.listbox.selectedItems()]))
         return
     if label == "Help":
         from chimerax.core.commands import run
         run(session, 'help help:user/tools/cli.html#history')
         return
示例#8
0
def _file_output(file_name, output_info, naming_style):
    inter_model, intra_model, relax_constraints, \
            dist_slop, angle_slop, structures, hbond_info, cs_ids = output_info
    from chimerax.io import open_output
    out_file = open_output(file_name, 'utf-8')
    if inter_model:
        out_file.write("Finding intermodel H-bonds\n")
    if intra_model:
        out_file.write("Finding intramodel H-bonds\n")
    if relax_constraints:
        out_file.write("Constraints relaxed by %g angstroms"
                       " and %d degrees\n" % (dist_slop, angle_slop))
    else:
        out_file.write("Using precise constraint criteria\n")
    out_file.write("Models used:\n")
    for s in structures:
        out_file.write("\t%s %s\n" % (s.id_string, s.name))
    if cs_ids is None:
        hbond_lists = [hbond_info]
    else:
        hbond_lists = hbond_info

    for i, hbonds in enumerate(hbond_lists):
        if cs_ids is None:
            cs_id = None
        else:
            cs_id = cs_ids[i]
            out_file.write("\nCoordinate set %d" % cs_id)
        out_file.write("\n%d H-bonds" % len(hbonds))
        out_file.write(
            "\nH-bonds (donor, acceptor, hydrogen, D..A dist, D-H..A dist):\n")
        # want the bonds listed in some kind of consistent order...
        hbonds.sort()

        # figure out field widths to make things line up
        dwidth = awidth = hwidth = 0
        labels = {}
        from chimerax.geometry import distance
        for don, acc in hbonds:
            if cs_id is None:
                don_coord = don.scene_coord
                acc_coord = acc.scene_coord
            else:
                don_coord = don.get_coordset_coord(cs_id)
                acc_coord = acc.get_coordset_coord(cs_id)
            labels[don] = don.string(style=naming_style)
            labels[acc] = acc.string(style=naming_style)
            dwidth = max(dwidth, len(labels[don]))
            awidth = max(awidth, len(labels[acc]))
            da = distance(don_coord, acc_coord)
            dha, hyd = donor_hyd(don, cs_id, acc_coord)
            if dha is None:
                dha_out = "N/A"
                hyd_out = "no hydrogen"
            else:
                dha_out = "%5.3f" % dha
                hyd_out = hyd.string(style=naming_style)
            hwidth = max(hwidth, len(hyd_out))
            labels[(don, acc)] = (hyd_out, da, dha_out)
        for don, acc in hbonds:
            hyd_out, da, dha_out = labels[(don, acc)]
            out_file.write("%*s  %*s  %*s  %5.3f  %s\n" %
                           (0 - dwidth, labels[don], 0 - awidth, labels[acc],
                            0 - hwidth, hyd_out, da, dha_out))
    if out_file != file_name:
        # we opened it, so close it...
        out_file.close()