diff --git a/src/bundles/Makefile b/src/bundles/Makefile index 9ce0f10917..a87640a7a7 100644 --- a/src/bundles/Makefile +++ b/src/bundles/Makefile @@ -26,7 +26,7 @@ REST_SUBDIRS = add_charge addh alignment_algs alignment_headers \ clashes cmd_line color_actions \ color_globe color_key connect_structure core_formats \ coulombic crosslinks crystal crystal_contacts data_formats \ - dicom diffplot dist_monitor dock_prep dssp \ + deep_mutational_scan dicom diffplot dist_monitor dock_prep dssp \ emdb_sff esmfold file_history function_key \ geometry gltf graphics \ hbonds help_viewer hkcage ihm image_formats imod \ diff --git a/src/bundles/Makefile.bundle b/src/bundles/Makefile.bundle index fee1d4f9ce..2552da70ff 100644 --- a/src/bundles/Makefile.bundle +++ b/src/bundles/Makefile.bundle @@ -36,17 +36,17 @@ OS=Windows endif endif ifeq ($(OS),Windows) -APP_PYTHON_EXE = $(CHIMERAX_APP)/bin/python.exe +APP_PYTHON_EXE = $(wildcard $(CHIMERAX_APP)/bin/python.exe) CX_BIN = $(CHIMERAX_APP)/bin/ChimeraX-console.exe PYMOD_EXT = pyd endif ifeq ($(OS),Darwin) -APP_PYTHON_EXE = $(CHIMERAX_APP)/Contents/bin/python3.11 +APP_PYTHON_EXE = $(wildcard $(CHIMERAX_APP)/Contents/bin/python3.11) CX_BIN = $(CHIMERAX_APP)/Contents/bin/ChimeraX PYMOD_EXT = so endif ifeq ($(OS),Linux) -APP_PYTHON_EXE = $(CHIMERAX_APP)/bin/python3.11 +APP_PYTHON_EXE = $(wildcard $(CHIMERAX_APP)/bin/python3.11) CX_BIN = $(CHIMERAX_APP)/bin/chimerax PYMOD_EXT = so endif @@ -54,9 +54,6 @@ PYTHON ?= $(APP_PYTHON_EXE) -I RUN = PYTHONNOUSERSITE=1 $(PYTHON) -m chimerax.core --nogui --exit RUN_SAFE = $(RUN) --safemode RUN_CMD = $(RUN_SAFE) --cmd -ifeq (,$(wildcard $(APP_PYTHON_EXE))) -$(error missing ChimeraX application's python) -endif ifdef INSTALL_TO_VENV WHEEL = $(wildcard dist/*.whl) @@ -67,14 +64,26 @@ PYSRCS = $(wildcard src/*.py) .SECONDEXPANSION: wheel: $$(PYSRCS) - $(RUN_CMD) "devel build . exit true $(DEBUG_ARG) $(BUILD_ARGS)" + if [ -z "$(APP_PYTHON_EXE)" ]; then \ + echo "missing ChimeraX application's python"; exit 1; \ + else \ + $(RUN_CMD) "devel build . exit true $(DEBUG_ARG) $(BUILD_ARGS)"; \ + fi # The space means install and app-install are the same install app-install: $$(PYSRCS) - $(RUN_CMD) "devel install . user false exit true $(DEBUG_ARG) $(INSTALL_ARGS)" + if [ -z "$(APP_PYTHON_EXE)" ]; then \ + echo "missing ChimeraX application's python"; exit 1; \ + else \ + $(RUN_CMD) "devel install . user false exit true $(DEBUG_ARG) $(INSTALL_ARGS)"; \ + fi install-editable: clean - $(RUN_CMD) "devel install . user false editable true exit true $(DEBUG_ARG) $(INSTALL_ARGS)" + if [ -z "$(APP_PYTHON_EXE)" ]; then \ + echo "missing ChimeraX application's python"; exit 1; \ + else \ + $(RUN_CMD) "devel install . user false editable true exit true $(DEBUG_ARG) $(INSTALL_ARGS)"; \ + fi venv-install: ifndef VIRTUAL_ENV @@ -116,7 +125,7 @@ debug: $(CX_BIN) --debug clean: - if [ -x $(CX_BIN) -a -e bundle_info.xml ]; then \ + if [ -x "$(APP_PYTHON_EXE)" -a -e bundle_info.xml ]; then \ $(RUN_CMD) "devel clean . exit true" ; \ else \ rm -rf $(CLEAN) ; \ diff --git a/src/bundles/atomic/bundle_info.xml b/src/bundles/atomic/bundle_info.xml index de31199d33..c06eedfedd 100644 --- a/src/bundles/atomic/bundle_info.xml +++ b/src/bundles/atomic/bundle_info.xml @@ -1,5 +1,5 @@ - -::~PythonInstance() { if (i == _pyinstance_object_map.end()) return; PyObject* py_inst = (*i).second; - AcquireGIL gil; // Py_DECREF can cause code to run - PyObject_DelAttrString(py_inst, "_c_pointer"); - PyObject_DelAttrString(py_inst, "_c_pointer_ref"); - Py_DECREF(py_inst); + if (!PyObject_GC_IsFinalized(py_inst)) { + AcquireGIL gil; // Py_DECREF can cause code to run + PyObject_DelAttrString(py_inst, "_c_pointer"); + PyObject_DelAttrString(py_inst, "_c_pointer_ref"); + Py_DECREF(py_inst); + } _pyinstance_object_map.erase(i); } diff --git a/src/bundles/atomic_lib/bundle_info.xml b/src/bundles/atomic_lib/bundle_info.xml index d908d44c2d..e817495cab 100644 --- a/src/bundles/atomic_lib/bundle_info.xml +++ b/src/bundles/atomic_lib/bundle_info.xml @@ -1,4 +1,4 @@ - + + UCSF RBVI + chimerax@cgl.ucsf.edu + https://www.rbvi.ucsf.edu/chimerax/ + + Visualize deep mutational scanning data + Visualize deep mutational scanning data. + + + + + + + + + + + + + + + + + + + + + Development Status :: 2 - Pre-Alpha + License :: Free for non-commercial use + Command :: dms label :: Molecular structure :: Label residues with deep mutational scan values + Command :: dms attribute :: Molecular structure :: Assign a residue attribute computed from deep mutational scan values + Command :: dms scatterplot :: Molecular structure :: Show a scatter plot for two phenotypes from deep mutational scan data + Command :: dms statistics :: Molecular structure :: Report mean and standard deviation of deep mutational scan scores + Command :: dms histogram :: Molecular structure :: Show histogram of deep mutational scan scores + Command :: dms umap :: Molecular structure :: Show umap plot of residue deep mutational scan scores + + + diff --git a/src/bundles/deep_mutational_scan/src/__init__.py b/src/bundles/deep_mutational_scan/src/__init__.py new file mode 100644 index 0000000000..b676f44bb6 --- /dev/null +++ b/src/bundles/deep_mutational_scan/src/__init__.py @@ -0,0 +1,63 @@ +# vim: set expandtab ts=4 sw=4: + +# === UCSF ChimeraX Copyright === +# Copyright 2022 Regents of the University of California. All rights reserved. +# The ChimeraX application is provided pursuant to the ChimeraX license +# agreement, which covers academic and commercial uses. For more details, see +# +# +# This particular file is part of the ChimeraX library. You can also +# redistribute and/or modify it under the terms of the GNU Lesser General +# Public License version 2.1 as published by the Free Software Foundation. +# For more details, see +# +# +# THIS SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER +# EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. ADDITIONAL LIABILITY +# LIMITATIONS ARE DESCRIBED IN THE GNU LESSER GENERAL PUBLIC LICENSE +# VERSION 2.1 +# +# This notice must be embedded in or attached to all copies, including partial +# copies, of the software or any revisions or derivations thereof. +# === UCSF ChimeraX Copyright === + +from chimerax.core.toolshed import BundleAPI + +class _DeepMutationalScanAPI(BundleAPI): + + @staticmethod + def register_command(command_name, logger): + # 'register_command' is called by the toolshed on start up + from . import dms_label + dms_label.register_command(logger) + from . import dms_attribute + dms_attribute.register_command(logger) + from . import dms_scatter_plot + dms_scatter_plot.register_command(logger) + from . import dms_stats + dms_stats.register_command(logger) + from . import dms_histogram + dms_histogram.register_command(logger) + from . import dms_umap + dms_umap.register_command(logger) + + @staticmethod + def run_provider(session, name, mgr): + if mgr == session.open_command: + if name == 'Deep mutational scan': + from chimerax.open_command import OpenerInfo + class DeepMutationalScanInfo(OpenerInfo): + def open(self, session, path, file_name, **kw): + from .dms_data import open_deep_mutational_scan_csv + dms_data, message = open_deep_mutational_scan_csv(session, path, **kw) + return [], message + + @property + def open_args(self): + from chimerax.atomic import ChainArg + return {'Chain': ChainArg} + + return DeepMutationalScanInfo() + +bundle_api = _DeepMutationalScanAPI() diff --git a/src/bundles/deep_mutational_scan/src/dms_attribute.py b/src/bundles/deep_mutational_scan/src/dms_attribute.py new file mode 100644 index 0000000000..4ad2eb9dc3 --- /dev/null +++ b/src/bundles/deep_mutational_scan/src/dms_attribute.py @@ -0,0 +1,50 @@ +# Assign a residue attribute from deep mutational scan scores. +def dms_attribute(session, chain, column_name, subtract_fit = None, + name = None, type = 'sum_absolute', above = None, below = None): + from .dms_data import dms_data + data = dms_data(chain) + if data is None: + from chimerax.core.errors import UserError + raise UserError(f'No deep mutation scan data associated with chain {chain}') + scores = data.column_values(column_name, subtract_fit = subtract_fit) + + attr_name = _attribute_name(column_name, type, above, below) if name is None else name + from chimerax.atomic import Residue + Residue.register_attr(session, attr_name, "Deep Mutational Scan", attr_type=float) + + residues = chain.existing_residues + count = 0 + for res in residues: + value = scores.residue_value(res.number, value_type=type, above=above, below=below) + if value is not None: + setattr(res, attr_name, value) + count += 1 + + message = f'Set attribute {attr_name} for {count} residues of chain {chain}' + session.logger.info(message) + +def _attribute_name(column_name, type, above, below): + attr_name = f'{column_name}_{type}' + if above is not None: + attr_name += f'_ge_{"%.3g"%above}' + if below is not None: + attr_name += f'_le_{"%.3g"%below}' + return attr_name + +def register_command(logger): + from chimerax.core.commands import CmdDesc, register, StringArg, EnumOf, FloatArg + from chimerax.atomic import ChainArg + from .dms_data import ColumnValues + desc = CmdDesc( + required = [('chain', ChainArg)], + keyword = [('column_name', StringArg), + ('subtract_fit', StringArg), + ('name', StringArg), + ('type', EnumOf(ColumnValues.residue_value_types)), + ('above', FloatArg), + ('below', FloatArg), + ], + required_arguments = ['column_name'], + synopsis = 'Assign a residue attribute using deep mutation scan scores' + ) + register('dms attribute', desc, dms_attribute, logger=logger) diff --git a/src/bundles/deep_mutational_scan/src/dms_data.py b/src/bundles/deep_mutational_scan/src/dms_data.py new file mode 100644 index 0000000000..ec4898d2b4 --- /dev/null +++ b/src/bundles/deep_mutational_scan/src/dms_data.py @@ -0,0 +1,181 @@ +def open_deep_mutational_scan_csv(session, path, chain = None): + dms_data = DeepMutationScores(path) + + if chain is None: + chain = _matching_chain(session, dms_data) + if chain is None: + from chimerax.core.errors import UserError + raise UserError(f'Opening a deep mutational scan data requires specifying the structure to associate. If there is one open structure it will be associated, otherwise use the open command structure option, for example\n\n\topen {path} format dms chain #2/B') + + chain._deep_mutation_data = dms_data + + cres = chain.existing_residues + sresnums = set(r.number for r in cres) + dresnums = set(dms_data.scores.keys()) + score_names = ', '.join(dms_data.score_column_names) + nmut = sum(len(mscores) for mscores in dms_data.scores.values()) + mres = len(dresnums - sresnums) + missing_res = f'found data for {mres} residues not present in atomic model, ' if mres > 0 else '' + message = f'Opened deep mutational scan data for {nmut} mutations of {len(dresnums)} residues, assigned to {len(sresnums & dresnums)} of {len(cres)} residues of chain {chain}, {missing_res} score column names {score_names}.' + + return dms_data, message + +def _matching_chain(session, dms_data): + from chimerax.atomic import AtomicStructure + structs = session.models.list(type = AtomicStructure) + for s in structs: + for c in s.chains: + matches, mismatches = dms_data.residue_type_matches(c.existing_residues) + if mismatches == 0 and matches > 0: + return c + return None + +class DeepMutationScores: + def __init__(self, csv_path): + self.path = csv_path + from os.path import basename + self.name = basename(csv_path) + self.headings, self.scores, self.res_types = self.read_deep_mutation_csv(csv_path) + + def read_deep_mutation_csv(self, path): + with open(path, 'r') as f: + lines = f.readlines() + headings = [h.strip() for h in lines[0].split(',')] + scores = {} + res_types = {} + for i, line in enumerate(lines[1:]): + if line.strip() == '': + continue # Ignore blank lines + fields = line.split(',') + if len(fields) != len(headings): + from chimerax.core.errors import UserError + raise UserError(f'Line {i+2} has wrong number of comma-separated fields, got {len(fields)}, but there are {len(headings)} headings') + hgvs = fields[0] + if not hgvs.startswith('p.(') or not hgvs.endswith(')'): + from chimerax.core.errors import UserError + raise UserError(f'Line {i+2} has hgvs field "{hgvs}" not starting with "p.(" and ending with ")"') + if 'del' in hgvs or 'ins' in hgvs or '_' in hgvs: + continue + res_type = hgvs[3] + res_num = int(hgvs[4:-2]) + res_type2 = hgvs[-2] + if res_num not in scores: + scores[res_num] = {} + if res_type2 in scores[res_num]: + from chimerax.core.errors import UserError + raise UserError(f'Duplicated hgvs "{hgvs}" at line {i+2}') + scores[res_num][res_type2] = fields + res_types[res_num] = res_type + return headings, scores, res_types + + def column_values(self, column_name, subtract_fit = None): + cvalues = self._column_value_list(column_name) + + if subtract_fit is not None: + svalues = self._column_value_list(subtract_fit) + cvalues = _subtract_fit_values(cvalues, svalues) + + return ColumnValues(cvalues) + + def _column_value_list(self, column_name): + cvalues = [] + c = self.column_index(column_name) + for res_num, rscores in self.scores.items(): + for res_type, fields in rscores.items(): + if fields[c] != 'NA': + cvalues.append((res_num, self.res_types[res_num], res_type, float(fields[c]))) + return cvalues + + def column_index(self, column_name): + for i,h in enumerate(self.headings): + if column_name == h: + return i + return None + + def residue_type_matches(self, residues): + matches = mismatches = 0 + rtypes = self.res_types + for r in residues: + rtype = rtypes.get(r.number) + if rtype is not None: + if r.one_letter_code == rtype: + matches += 1 + else: + mismatches += 1 + return matches, mismatches + + @property + def score_column_names(self): + return [h for h in self.headings if 'score' in h] + +class ColumnValues: + def __init__(self, mutation_values): + self._mutation_values = mutation_values # List of (res_num, from_aa, to_aa, value) + self._values_by_residue_number = None # res_num -> (from_aa, to_aa, value) + + def all_values(self): + return self._mutation_values + + def residue_numbers(self): + return tuple(self.values_by_residue_number.keys()) + + # Allowed value_type in residue_value() function. + residue_value_types = ('sum', 'sum_absolute', 'synonymous') + + def residue_value(self, residue_number, value_type='sum_absolute', above=None, below=None): + value = None + dms_values = self.mutation_values(residue_number) + if dms_values: + if value_type == 'sum_absolute' or value_type == 'sum': + values = [value for from_aa, to_aa, value in dms_values + if ((above is None and below is None) + or (above is not None and value >= above) + or (below is not None and value <= below))] + if value_type == 'sum_absolute': + values = [abs(v) for v in values] + value = sum(values) if values else None + elif value_type == 'synonymous': + values = [value for from_aa, to_aa, value in dms_values if to_aa == from_aa] + if values: + value = values[0] + return value + + def mutation_values(self, residue_number): + '''Return list of (from_aa, to_aa, value).''' + res_values = self.values_by_residue_number.get(residue_number, {}) + return res_values + + @property + def values_by_residue_number(self): + if self._values_by_residue_number is None: + self._values_by_residue_number = vbrn = {} + for val in self._mutation_values: + if val[0] in vbrn: + vbrn[val[0]].append(val[1:]) + else: + vbrn[val[0]] = [val[1:]] + return self._values_by_residue_number + + def value_range(self): + values = [val[3] for val in self._mutation_values] + return min(values), max(values) + +def _subtract_fit_values(cvalues, svalues): + smap = {(res_num,from_aa,to_aa):value for res_num, from_aa, to_aa, value in svalues} + x = [] + y = [] + for res_num, from_aa, to_aa, value in cvalues: + svalue = smap.get((res_num,from_aa,to_aa)) + if svalue is not None: + x.append(svalue) + y.append(value) + from numpy import polyfit + degree = 1 + m,b = polyfit(x, y, degree) + sfvalues = [(res_num, from_aa, to_aa, value - (m*smap[(res_num,from_aa,to_aa)] + b)) + for res_num, from_aa, to_aa, value in cvalues + if (res_num,from_aa,to_aa) in smap] + return sfvalues + +def dms_data(chain): + return getattr(chain, '_deep_mutation_data', None) diff --git a/src/bundles/deep_mutational_scan/src/dms_histogram.py b/src/bundles/deep_mutational_scan/src/dms_histogram.py new file mode 100644 index 0000000000..51785362af --- /dev/null +++ b/src/bundles/deep_mutational_scan/src/dms_histogram.py @@ -0,0 +1,134 @@ +# Plot a histogram of deep mutational scan scores. +def dms_histogram(session, chain, column_name, subtract_fit = None, + bins = 20, curve = True, smooth_width = None, + type = 'all_mutations', above = None, below = None, replace = True): + from .dms_data import dms_data + data = dms_data(chain) + if data is None: + from chimerax.core.errors import UserError + raise UserError(f'No deep mutation scan data associated with chain {chain}') + scores = data.column_values(column_name, subtract_fit = subtract_fit) + + res_nums = [] + res_scores = [] + score_names = [] + if type == 'all_mutations': + for res_num, from_aa, to_aa, value in scores.all_values(): + res_nums.append(res_num) + res_scores.append(value) + score_names.append(f'{from_aa}{res_num}{to_aa}') + else: + for res_num in scores.residue_numbers(): + value = scores.residue_value(res_num, value_type = type, above = above, below = below) + if value is not None: + res_nums.append(res_num) + res_scores.append(value) + score_names.append(f'{res_num}') + + resnum_to_res = {r.number:r for r in chain.existing_residues} + residues = [resnum_to_res.get(res_num) for res_num in res_nums] + + from numpy import array, float32 + scores = array(res_scores, float32) + + if replace and hasattr(chain, '_last_dms_histogram') and chain._last_dms_histogram.tool_window.ui_area is not None: + plot = chain._last_dms_histogram + else: + chain._last_dms_histogram = plot = Histogram(session, title = 'Deep mutational scan histogram') + plot.set_values(res_scores, residues, score_names=score_names, + title=data.name, x_label=column_name, bins=bins, + smooth_curve=curve, smooth_width=smooth_width) + + message = f'Plotted {len(res_scores)} scores of chain {chain} for {column_name}' + session.logger.info(message) + +# TODO: Draw smooth curve by gaussian smoothing 1d bin array with map_filter code. +# TODO: Make mouse click select residues for histogram bar. +# TODO: Make mouse hover show mutation names for histogram bar in popup. +from chimerax.interfaces.graph import Plot +class Histogram(Plot): + + def __init__(self, session, title = 'Histogram'): + Plot.__init__(self, session, tool_name = 'Deep Mutational Scan') + self.tool_window.title = title + self._highlight_color = (0,255,0,255) + self._unhighlight_color = (150,150,150,255) + + def set_values(self, scores, residues, score_names = None, title = '', x_label = '', bins = 20, + smooth_curve = False, smooth_width = None, smooth_bins = 200): + a = self.axes + a.clear() + a.hist(scores, bins=bins) + a.set_title(title) + a.set_xlabel(x_label) + a.set_ylabel('Count') + if smooth_curve: + x, y = gaussian_histogram(scores, sdev = smooth_width, bins = smooth_bins) + y *= (smooth_bins / bins) # Scale to match histogram bar height + a.plot(x, y) + self.canvas.draw() + + def tight_layout(self): + # Don't hide axes and reduce padding + pass + + # TODO: Hook up context menu + def fill_context_menu(self, menu, item): + if item is not None: + r = item.residue + name = item.description + self.add_menu_entry(menu, f'Select {name}', lambda self=self, r=r: self._select_residue(r)) + self.add_menu_entry(menu, f'Color {name}', lambda self=self, r=r: self._highlight_residue(r)) + self.add_menu_entry(menu, f'Zoom to {name}', lambda self=self, r=r: self._zoom_to_residue(r)) + else: + self.add_menu_entry(menu, 'Save Plot As...', self.save_plot_as) + + def _select_residue(self, r): + self._run_residue_command(r, 'select %s') + def _highlight_residue(self, r): + self._run_residue_command(r, 'color %s lime') + def _zoom_to_residue(self, r): + self._run_residue_command(r, 'view %s') + def _run_residue_command(self, r, command): + self._run_command(command % r.string(style = 'command')) + def _run_command(self, command): + from chimerax.core.commands import run + run(self.session, command) + +def gaussian_histogram(values, sdev = None, pad = 5, bins = 256): + '''Make a smooth curve approximating a histogram by convolution with a Gaussian.''' + if sdev is None: + from numpy import std + sdev = 0.1 * std(values) + from numpy import max, min + vmin, vmax = min(values), max(values) + hrange = (vmin - pad*sdev, vmax + pad*sdev) + from numpy import histogram, float32 + hist, bin_edges = histogram(values, bins, hrange) + + ijk_sdev = (0, 0, bins * sdev / (hrange[1] - hrange[0])) + from chimerax.map_filter.gaussian import gaussian_convolution + y = gaussian_convolution(hist.reshape((bins,1,1)).astype(float32), ijk_sdev).reshape((bins,)) + x = 0.5 * (bin_edges[1:] + bin_edges[:-1]) + return x, y + +def register_command(logger): + from chimerax.core.commands import CmdDesc, register, StringArg, EnumOf, BoolArg, FloatArg, IntArg + from chimerax.atomic import ChainArg + from .dms_data import ColumnValues + desc = CmdDesc( + required = [('chain', ChainArg)], + keyword = [('column_name', StringArg), + ('subtract_fit', StringArg), + ('bins', IntArg), + ('curve', BoolArg), + ('smooth_width', FloatArg), + ('type', EnumOf(('all_mutations',) + ColumnValues.residue_value_types)), + ('above', FloatArg), + ('below', FloatArg), + ('replace', BoolArg), + ], + required_arguments = ['column_name'], + synopsis = 'Show histogram of deep mutational scan scores' + ) + register('dms histogram', desc, dms_histogram, logger=logger) diff --git a/src/bundles/deep_mutational_scan/src/dms_label.py b/src/bundles/deep_mutational_scan/src/dms_label.py new file mode 100644 index 0000000000..5654cc23b3 --- /dev/null +++ b/src/bundles/deep_mutational_scan/src/dms_label.py @@ -0,0 +1,122 @@ +# Try to replace simple text label with a custom image. +def dms_label(session, residues, column_name, subtract_fit = None, + range = None, palette = None, no_data_color = (180,180,180,255), + height = 1.5, offset = (0,0,3), on_top = False): + + messages = [] + for chain, cresidues in _residues_by_chain(residues): + from .dms_data import dms_data + data = dms_data(chain) + scores = data.column_values(column_name, subtract_fit = subtract_fit) + from chimerax.surface.colorvol import _use_full_range, _colormap_with_range + vrange = scores.value_range() + r = vrange if _use_full_range(range, palette) else range + colormap = _colormap_with_range(palette, r) + count = 0 + for res in cresidues: + mut_colors = {to_aa:colormap.interpolated_rgba8([value])[0] + for from_aa, to_aa, value in scores.mutation_values(res.number)} + if mut_colors: + label_residue(res, mut_colors, no_data_color, height = height, offset = offset, on_top = on_top) + count += 1 + + message = f'Added {count} residue labels to chain {chain} for {column_name} ({"%.3g"%vrange[0]} - {"%.3g"%vrange[1]}), no DMS data for {len(residues) - count} residues' + session.logger.info(message) + +def _residues_by_chain(residues): + cres = {} + for r in residues: + c = r.chain + if c is None: + continue + if c in cres: + cres[c].append(r) + else: + cres[c] = [r] + from chimerax.atomic import Residues + return [(c, Residues(res)) for c,res in cres.items()] + +def label_residue(residue, mutation_colors, no_data_color, height = 1.5, offset = (0,0,3), on_top = False): + # Replace _label_image method of ObjectLabel to supply my own RGBA array + from chimerax.label.label3d import labels_model, ResidueLabel + view = residue.structure.session.main_view + lm = labels_model(residue.structure, create = True) + settings = {'height':height, 'offset':offset} + lm.add_labels([residue], ResidueLabel, view, settings, on_top) + ol = lm.labels([residue])[0] + title = f'{residue.one_letter_code}{residue.number}' + def label_image(self, title = title, mutation_colors = mutation_colors, no_data_color = no_data_color): + rgba = label_rgba(title, mutation_colors, no_data_color) + h,w = rgba.shape[:2] + self._label_size = w,h + return rgba + from types import MethodType + ol._label_image = MethodType(label_image, ol) + lm.update_labels() + +def label_rgba(title, mutation_colors, no_data_color): + amino_acids = 'PRKHDEFWYNQCSTILVMGA' + colors = [mutation_colors.get(r, no_data_color) for r in amino_acids] + from Qt.QtGui import QImage, QPainter, QFont, QColor, QBrush, QPen + wc,hc = 40,40 # Cell size in pixels + font_size = 40 + xpad, ypad = 5, 5 # Font offset pixels + rows, columns = 4, 5 + w,h = columns*wc, (rows+1)*hc + text_color = (0,0,0,255) + font = 'Helvetica' + p = QPainter() + ti = QImage(w, h, QImage.Format.Format_ARGB32) + p.begin(ti) + p.setCompositionMode(p.CompositionMode_Source) + from Qt.QtCore import Qt + pbr = QBrush(Qt.SolidPattern) + p.setBrush(pbr) + ppen = QPen(Qt.NoPen) + p.setPen(ppen) + # Title color + pbr.setColor(QColor(*no_data_color)) + p.fillRect(0,0,w,hc,pbr) + # Grid colors + for r in range(rows): + for c in range(columns): + x,y = c*wc, (r+1)*hc + rgba8 = tuple(colors[c + r*columns]) + pbr.setColor(QColor(*rgba8)) + p.fillRect(x,y,wc,hc,pbr) + f = QFont(font) + f.setPixelSize(font_size) + p.setFont(f) + c = QColor(*text_color) + p.setPen(c) + p.drawText(wc+xpad, hc-ypad, title) # Title text + # Grid letters + for r in range(rows): + for c in range(columns): + x,y = c*wc + xpad, (r+1)*hc - ypad + p.drawText(x, y+hc, amino_acids[c + r*columns]) + + # Convert to numpy rgba array. + from chimerax.graphics import qimage_to_numpy + rgba = qimage_to_numpy(ti) + p.end() + return rgba + +def register_command(logger): + from chimerax.core.commands import CmdDesc, register, StringArg, FloatArg, Float3Arg, BoolArg + from chimerax.core.commands import ColormapArg, ColormapRangeArg, Color8Arg + from chimerax.atomic import ResiduesArg + desc = CmdDesc( + required = [('residues', ResiduesArg)], + keyword = [('column_name', StringArg), + ('subtract_fit', StringArg), + ('range', ColormapRangeArg), + ('palette', ColormapArg), + ('no_data_color', Color8Arg), + ('height', FloatArg), + ('offset', Float3Arg), + ('on_top', BoolArg)], + required_arguments = ['column_name'], + synopsis = 'Show color-coded labels for deep mutation scan scores' + ) + register('dms label', desc, dms_label, logger=logger) diff --git a/src/bundles/deep_mutational_scan/src/dms_scatter_plot.py b/src/bundles/deep_mutational_scan/src/dms_scatter_plot.py new file mode 100644 index 0000000000..d8db907429 --- /dev/null +++ b/src/bundles/deep_mutational_scan/src/dms_scatter_plot.py @@ -0,0 +1,177 @@ +# Make a scatter plot for residues using two phenotype scores from deep mutational scan data. +def dms_scatter_plot(session, chain, x_column_name, y_column_name, + correlation = False, subtract_x_fit = None, subtract_y_fit = None, + type = 'all_mutations', above = None, below = None, replace = True): + from .dms_data import dms_data + data = dms_data(chain) + if data is None: + from chimerax.core.errors import UserError + raise UserError(f'No deep mutation scan data associated with chain {chain}') + x_scores = data.column_values(x_column_name, subtract_fit = subtract_x_fit) + y_scores = data.column_values(y_column_name, subtract_fit = subtract_y_fit) + + res_nums = [] + points = [] + point_names = [] + if type == 'all_mutations': + y_values = {(res_num,from_aa,to_aa):y_value for res_num, from_aa, to_aa, y_value in y_scores.all_values()} + for res_num, from_aa, to_aa, x_value in x_scores.all_values(): + y_value = y_values.get((res_num, from_aa, to_aa)) + if y_value is not None: + res_nums.append(res_num) + points.append((x_value, y_value)) + point_names.append(f'{from_aa}{res_num}{to_aa}') + else: + for res_num in x_scores.residue_numbers(): + x_value = x_scores.residue_value(res_num, value_type = type, above = above, below = below) + y_value = y_scores.residue_value(res_num, value_type = type, above = above, below = below) + if x_value is not None and y_value is not None: + res_nums.append(res_num) + points.append((x_value, y_value)) + point_names.append(f'{res_num}') + + resnum_to_res = {r.number:r for r in chain.existing_residues} + residues = [resnum_to_res.get(res_num) for res_num in res_nums] + + from numpy import array, float32 + xy = array(points, float32) + + if replace and hasattr(chain, '_last_dms_plot') and chain._last_dms_plot.tool_window.ui_area is not None: + plot = chain._last_dms_plot + else: + chain._last_dms_plot = plot = ResidueScatterPlot(session) + title = f'File {data.name}' + label_nodes, node_area = (False, 20) if type == 'all_mutations' else (True, 200) + plot.set_nodes(xy, residues, point_names=point_names, correlation=correlation, + title=title, x_label=x_column_name, y_label=y_column_name, + node_area = node_area, label_nodes = label_nodes) + + message = f'Plotted {len(points)} mutations in chain {chain} with {x_column_name} on x-axis and {y_column_name} on y-axis' + if correlation: + message += f', least squares fit slope {"%.3g" % plot.slope}, intercept {"%.3g" % plot.intercept}, R squared {"%.3g" % plot.r_squared}' + session.logger.info(message) + +from chimerax.interfaces.graph import Graph +class ResidueScatterPlot(Graph): + + def __init__(self, session): + nodes = edges = [] + Graph.__init__(self, session, nodes, edges, tool_name = 'DeepMutationalScan', + title = 'Deep mutational scan scatter plot', hide_ticks = False) + self._highlight_color = (0,255,0,255) + self._unhighlight_color = (150,150,150,255) + + def set_nodes(self, xy, residues, point_names = None, colors = None, correlation = False, + title = '', x_label = '', y_label = '', + node_font_size = 5, node_area = 200, label_nodes = True): + self.font_size = node_font_size # Override graph default value of 12 points + self.nodes = self._make_nodes(xy, residues, point_names=point_names, colors=colors, + node_area=node_area, label_nodes=label_nodes) + self.graph = self._make_graph() + a = self.axes + a.clear() + self.draw_graph() + a.set_title(title) + a.set_xlabel(x_label) + a.set_ylabel(y_label) + if correlation: + self.show_least_squares_fit(xy) + self.canvas.draw() + + def show_least_squares_fit(self, xy): + x, y = xy[:,0], xy[:,1] + degree = 1 + from numpy import polyfit + p, ss_r = polyfit(x, y, degree, full=True)[:2] + fx = (min(x), max(x)) + fy = tuple(p[0]*x + p[1] for x in fx) + self.axes.plot(fx, fy) + self.slope, self.intercept, self.r_squared = p[0], p[1], self._r_squared(p, x, y, ss_r) + + def _r_squared(self, p, x, y, ss_r): + r_ys = x*p[0] + p[1] + from numpy import sum, mean + ss_tot = sum((y - mean(r_ys)) ** 2) + r_squared = 1 - (ss_r / ss_tot) + return r_squared + + def tight_layout(self): + # Don't hide axes and reduce padding + pass + + def _make_nodes(self, xy, residues, point_names = None, colors = None, node_area = 200, label_nodes = True): + from chimerax.interfaces.graph import Node + nodes = [] + for i, (res, (x,y)) in enumerate(zip(residues, xy)): + n = Node() + if point_names: + n.description = point_names[i] + if label_nodes: + n.name = point_names[i] + n.position = (x, y, 0) + n.size = node_area + if colors is not None: + n.color = tuple(r/255 for r in colors[i]) + n.residue = res + nodes.append(n) + return nodes + + def layout_projection(self): + from chimerax.geometry import identity + return identity() + + def mouse_click(self, node, event): + '''Ctrl click handler.''' + if node is None: + return + r = node.residue + if r is not None and not r.deleted: + r.chain.existing_residues.ribbon_colors = self._unhighlight_color + r.ribbon_color = self._highlight_color + r.atoms.colors = self._highlight_color + + def fill_context_menu(self, menu, item): + if item is not None: + r = item.residue + name = item.description + if r is None or r.deleted: + self.add_menu_entry(menu, f'{name} residue not in structure', lambda: None) + else: + self.add_menu_entry(menu, f'Select {name}', lambda self=self, r=r: self._select_residue(r)) + self.add_menu_entry(menu, f'Color {name}', lambda self=self, r=r: self._highlight_residue(r)) + self.add_menu_entry(menu, f'Zoom to {name}', lambda self=self, r=r: self._zoom_to_residue(r)) + else: + self.add_menu_entry(menu, 'Save Plot As...', self.save_plot_as) + + def _select_residue(self, r): + self._run_residue_command(r, 'select %s') + def _highlight_residue(self, r): + self._run_residue_command(r, 'color %s lime') + def _zoom_to_residue(self, r): + self._run_residue_command(r, 'view %s') + def _run_residue_command(self, r, command): + self._run_command(command % r.string(style = 'command')) + def _run_command(self, command): + from chimerax.core.commands import run + run(self.session, command) + +def register_command(logger): + from chimerax.core.commands import CmdDesc, register, StringArg, EnumOf, BoolArg, FloatArg + from chimerax.atomic import ChainArg + from .dms_data import ColumnValues + desc = CmdDesc( + required = [('chain', ChainArg)], + keyword = [('x_column_name', StringArg), + ('y_column_name', StringArg), + ('correlation', BoolArg), + ('subtract_x_fit', StringArg), + ('subtract_y_fit', StringArg), + ('type', EnumOf(('all_mutations',) + ColumnValues.residue_value_types)), + ('above', FloatArg), + ('below', FloatArg), + ('replace', BoolArg), + ], + required_arguments = ['x_column_name', 'y_column_name'], + synopsis = 'Show scatter plot of residues using two phenotype deep mutational scan scores' + ) + register('dms scatterplot', desc, dms_scatter_plot, logger=logger) diff --git a/src/bundles/deep_mutational_scan/src/dms_stats.py b/src/bundles/deep_mutational_scan/src/dms_stats.py new file mode 100644 index 0000000000..c91de0ae1d --- /dev/null +++ b/src/bundles/deep_mutational_scan/src/dms_stats.py @@ -0,0 +1,40 @@ +# Assign a residue attribute from deep mutational scan scores. +def dms_statistics(session, chain, column_name, subtract_fit = None, type = 'synonymous'): + from .dms_data import dms_data + data = dms_data(chain) + if data is None: + from chimerax.core.errors import UserError + raise UserError(f'No deep mutation scan data associated with chain {chain}') + scores = data.column_values(column_name, subtract_fit = subtract_fit) + + values = [] + wild_type_res = data.res_types + for res_num, from_aa, to_aa, value in scores.all_values(): + if type == 'synonymous': + if to_aa == from_aa: + values.append(value) + else: + values.append(value) + + import numpy + mean = numpy.mean(values) + std = numpy.std(values) + + message = f'Column {column_name}, {len(values)} {type} mutations, mean = {"%.3g"%mean}, standard deviation = {"%.3g"%std}, mean -/+ 2*SD = {"%.3g"%(mean-2*std)} to {"%.3g"%(mean+2*std)}' + session.logger.info(message) + + return mean, std + +def register_command(logger): + from chimerax.core.commands import CmdDesc, register, StringArg, EnumOf + from chimerax.atomic import ChainArg + desc = CmdDesc( + required = [('chain', ChainArg)], + keyword = [('column_name', StringArg), + ('subtract_fit', StringArg), + ('type', EnumOf(['synonymous', 'all'])), + ], + required_arguments = ['column_name'], + synopsis = 'Compute mean and standard deviation of deep mutation scan scores' + ) + register('dms statistics', desc, dms_statistics, logger=logger) diff --git a/src/bundles/deep_mutational_scan/src/dms_umap.py b/src/bundles/deep_mutational_scan/src/dms_umap.py new file mode 100644 index 0000000000..688b91b0df --- /dev/null +++ b/src/bundles/deep_mutational_scan/src/dms_umap.py @@ -0,0 +1,49 @@ +# Assign a residue attribute from deep mutational scan scores. +def dms_umap(session, chain, column_name, subtract_fit = None): + from .dms_data import dms_data + data = dms_data(chain) + if data is None: + from chimerax.core.errors import UserError + raise UserError(f'No deep mutation scan data associated with chain {chain}') + scores = data.column_values(column_name, subtract_fit = subtract_fit) + + amino_acids = 'PRKHDEFWYNQCSTILVMGA' + aa_index = {c:i for i,c in enumerate(amino_acids)} + from chimerax.core.colors import random_colors + aa_colors = random_colors(20) + count = 0 + rscores = [] + res_names = [] + colors = [] + from numpy import zeros, float32, array + for res_num in scores.residue_numbers(): + values = scores.mutation_values(res_num) + if len(values) == 20: + count += 1 + va = zeros((20,), float32) + for from_aa, to_aa, value in values: + va[aa_index[to_aa]] = value + rscores.append(va) + res_names.append(f'{from_aa}{res_num}') + colors.append(aa_colors[aa_index[from_aa]]) + + from chimerax.diffplot.diffplot import _install_umap, _umap_embed, StructurePlot + _install_umap(session) + umap_xy = _umap_embed(array(rscores, float32)) + StructurePlot(session, res_names, umap_xy, colors) + + message = f'{count} of {len(scores.residue_numbers())} have 20 mutations' + session.logger.info(message) + +def register_command(logger): + from chimerax.core.commands import CmdDesc, register, StringArg, EnumOf, FloatArg + from chimerax.atomic import ChainArg + desc = CmdDesc( + required = [('chain', ChainArg)], + keyword = [('column_name', StringArg), + ('subtract_fit', StringArg), + ], + required_arguments = ['column_name'], + synopsis = 'Project residues in umap plot according to mutation scores' + ) + register('dms umap', desc, dms_umap, logger=logger) diff --git a/src/bundles/interfaces/src/graph.py b/src/bundles/interfaces/src/graph.py index 55201b1ee2..de1895bd6a 100644 --- a/src/bundles/interfaces/src/graph.py +++ b/src/bundles/interfaces/src/graph.py @@ -166,7 +166,7 @@ class Graph(Plot): Middle and right mouse drags move the plotted objects. ''' - def __init__(self, session, nodes, edges, tool_name, title): + def __init__(self, session, nodes, edges, tool_name, title, hide_ticks = True): # Create matplotlib panel Plot.__init__(self, session, tool_name, title = title) @@ -179,6 +179,8 @@ def __init__(self, session, nodes, edges, tool_name, title): self.font_size = 12 self.font_family = 'sans-serif' + self.hide_ticks = hide_ticks + # Create graph self.graph = self._make_graph() @@ -237,7 +239,8 @@ def _draw_nodes(self): node_colors = tuple(n.color for n in nodes) import networkx as nx na = nx.draw_networkx_nodes(G, node_pos, nodelist = nodes, - node_size=node_sizes, node_color=node_colors, ax=self.axes) + node_size=node_sizes, node_color=node_colors, ax=self.axes, + hide_ticks = self.hide_ticks) na.set_picker(True) # Generate mouse pick events for clicks on nodes if self._node_artist: self._node_artist.remove() @@ -250,7 +253,8 @@ def _draw_nodes(self): ba = nx.draw_networkx_nodes(G, node_pos, nodelist = bnodes, node_size=tuple(n.size for n in bnodes), node_color=tuple(n.color for n in bnodes), - linewidths=0, ax=self.axes) + linewidths=0, ax=self.axes, + hide_ticks = self.hide_ticks) ba.set_zorder(-10) return node_pos @@ -299,7 +303,7 @@ def _draw_edges(self, node_pos): return import networkx as nx ea = nx.draw_networkx_edges(G, node_pos, edgelist=edges, width=widths, - style=styles, ax=self.axes) + style=styles, ax=self.axes, hide_ticks = self.hide_ticks) ea.set_picker(True) if self._edge_artist: self._edge_artist.remove() @@ -310,7 +314,8 @@ def _draw_labels(self, node_pos): import networkx as nx labels = nx.draw_networkx_labels(self.graph, node_pos, labels=node_names, font_size=self.font_size, - font_family=self.font_family, ax=self.axes) + font_family=self.font_family, ax=self.axes, + hide_ticks = self.hide_ticks) elabel = [e for e in self.edges if e.label] if elabel: @@ -318,7 +323,8 @@ def _draw_labels(self, node_pos): elab = nx.draw_networkx_edge_labels(self.graph, node_pos, edge_labels=ed, font_size=self.font_size, font_family=self.font_family, - rotate = False, ax=self.axes) + rotate = False, ax=self.axes, + hide_ticks = self.hide_ticks) labels.update(elab) if self._labels: diff --git a/src/bundles/nih_presets/bundle_info.xml b/src/bundles/nih_presets/bundle_info.xml index fd84c5df3b..9338c7e098 100644 --- a/src/bundles/nih_presets/bundle_info.xml +++ b/src/bundles/nih_presets/bundle_info.xml @@ -49,10 +49,10 @@ - - - - + + + + diff --git a/src/bundles/phenix_ui/bundle_info.xml b/src/bundles/phenix_ui/bundle_info.xml index 157243eb9f..0d34a3e392 100644 --- a/src/bundles/phenix_ui/bundle_info.xml +++ b/src/bundles/phenix_ui/bundle_info.xml @@ -1,4 +1,4 @@ - @@ -18,7 +18,7 @@ - + diff --git a/src/bundles/segmentations/src/__init__.py b/src/bundles/segmentations/src/__init__.py index 9c3ada674d..b9783701ab 100644 --- a/src/bundles/segmentations/src/__init__.py +++ b/src/bundles/segmentations/src/__init__.py @@ -10,7 +10,7 @@ # including partial copies, of the software or any revisions # or derivations thereof. # === UCSF ChimeraX Copyright === -__version__ = "3.1.4" +__version__ = "3.1.5" from chimerax.core.toolshed import BundleAPI from .segmentation import Segmentation, open_grids_as_segmentation diff --git a/src/bundles/segmentations/src/ui/orthoplanes.py b/src/bundles/segmentations/src/ui/orthoplanes.py index f224816950..11baebf931 100644 --- a/src/bundles/segmentations/src/ui/orthoplanes.py +++ b/src/bundles/segmentations/src/ui/orthoplanes.py @@ -759,14 +759,14 @@ def render(self): def toggle_guidelines(self): from chimerax.segmentations.settings import get_settings + settings = get_settings(self.session) settings.display_guidelines = not settings.display_guidelines - chimerax.segmentations.triggers.activate_trigger( - GUIDELINES_VISIBILITY_CHANGED - ) + chimerax.segmentations.triggers.activate_trigger(GUIDELINES_VISIBILITY_CHANGED) def _on_guideline_visibility_changed(self, _, __): from chimerax.segmentations.settings import get_settings + settings = get_settings(self.session) self.setGuidelineVisibility(settings.display_guidelines) @@ -917,6 +917,7 @@ def enterEvent(self): if self.segmentation_tool: self.enableSegmentationOverlays() self.resize3DSegmentationCursor() + self.render() def leaveEvent(self): chimerax.segmentations.triggers.activate_trigger(LEAVE_EVENTS[self.axis]) @@ -924,6 +925,7 @@ def leaveEvent(self): self.disableSegmentationOverlays() self.level_label.hide() self.mouse_move_timer.stop() + self.render() def shouldOpenContextMenu(self): return ( diff --git a/src/bundles/seq_view/bundle_info.xml b/src/bundles/seq_view/bundle_info.xml index d6a4de90d5..646079afbd 100644 --- a/src/bundles/seq_view/bundle_info.xml +++ b/src/bundles/seq_view/bundle_info.xml @@ -1,4 +1,4 @@ - @@ -16,7 +16,7 @@ - + diff --git a/src/bundles/seq_view/src/tool.py b/src/bundles/seq_view/src/tool.py index eb98c8b688..7c6f5d9acb 100644 --- a/src/bundles/seq_view/src/tool.py +++ b/src/bundles/seq_view/src/tool.py @@ -459,8 +459,9 @@ def alignment_notification(self, note_name, note_data): self._update_errors_gaps(aseq) if self.alignment.intrinsic: self.show_ss(True) - if hasattr(self, 'associations_tool'): - self.associations_tool._assoc_mod(note_data) + for opt_tool in ['associations_tool', 'transfer_seq_tool']: + if hasattr(self, opt_tool): + getattr(self, opt_tool)._assoc_mod(note_data) elif note_name == alignment.NOTE_PRE_DEL_SEQS: self.region_manager._pre_remove_lines(note_data) for seq in note_data: @@ -563,6 +564,10 @@ def fill_context_menu(self, menu, x, y): else: assoc_action.setEnabled(False) structure_menu.addAction(assoc_action) + xfer_action = QAction("Update Chain Sequence...", structure_menu) + xfer_action.triggered.connect(self.show_transfer_seq_dialog) + xfer_action.setEnabled(bool(self.alignment.associations)) + structure_menu.addAction(xfer_action) view_targets = [] # bounded_by expects the scene coordinate system... from Qt.QtCore import QPointF @@ -637,14 +642,14 @@ def fill_context_menu(self, menu, x, y): action = QAction("No Reference Sequence", refseq_menu) action.setCheckable(True) action.setChecked(self.alignment.reference_seq is None) - action.triggered.connect(lambda*, align_arg=align_arg, action=action, self=self: + action.triggered.connect(lambda*, align_arg=align_arg[:-1], action=action, self=self: run(self.session, "seq ref " + align_arg) if action.isChecked() else None) refseq_menu.addAction(action) for seq in self.alignment.seqs: action = QAction(seq.name, refseq_menu) action.setCheckable(True) action.setChecked(self.alignment.reference_seq is seq) - action.triggered.connect(lambda*, seq_arg=StringArg.unparse(align_arg + ':' + seq.name), + action.triggered.connect(lambda*, seq_arg=StringArg.unparse(align_arg[:-1] + ':' + seq.name), action=action: run(self.session, "seq ref " + seq_arg) if action.isChecked() else None) refseq_menu.addAction(action) numberings_menu.addSeparator() @@ -796,6 +801,14 @@ def show_percent_identity_dialog(self): self.percent_identity_dialog.tool_window.manage(None) self.percent_identity_dialog.tool_window.shown = True + def show_transfer_seq_dialog(self): + if not hasattr(self, "transfer_seq_dialog"): + from .transfer_seq_tool import TransferSeqTool + self.transfer_seq_dialog = TransferSeqTool(self, + self.tool_window.create_child_window("Update Chain Sequence", close_destroys=False)) + self.transfer_seq_dialog.tool_window.manage(None) + self.transfer_seq_dialog.tool_window.shown = True + def show_settings(self): if not hasattr(self, "settings_tool"): from .settings_tool import SettingsTool diff --git a/src/bundles/seq_view/src/transfer_seq_tool.py b/src/bundles/seq_view/src/transfer_seq_tool.py new file mode 100644 index 0000000000..0625a49b36 --- /dev/null +++ b/src/bundles/seq_view/src/transfer_seq_tool.py @@ -0,0 +1,72 @@ +# vim: set expandtab ts=4 sw=4: + +# === UCSF ChimeraX Copyright === +# Copyright 2022 Regents of the University of California. All rights reserved. +# The ChimeraX application is provided pursuant to the ChimeraX license +# agreement, which covers academic and commercial uses. For more details, see +# +# +# This particular file is part of the ChimeraX library. You can also +# redistribute and/or modify it under the terms of the GNU Lesser General +# Public License version 2.1 as published by the Free Software Foundation. +# For more details, see +# +# +# THIS SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER +# EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. ADDITIONAL LIABILITY +# LIMITATIONS ARE DESCRIBED IN THE GNU LESSER GENERAL PUBLIC LICENSE +# VERSION 2.1 +# +# This notice must be embedded in or attached to all copies, including partial +# copies, of the software or any revisions or derivations thereof. +# === UCSF ChimeraX Copyright === + +class TransferSeqTool: + + def __init__(self, sv, tool_window): + self.sv = sv + self.tool_window = tool_window + tool_window.help = "help:user/tools/sequenceviewer.html#association" + + from Qt.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QPushButton + from Qt.QtCore import Qt + layout = QVBoxLayout() + + layout.addWidget(QLabel("Replace structure sequence with alignment sequence for chosen chains")) + + from chimerax.atomic.widgets import ChainListWidget + self.chain_list = ChainListWidget(sv.session, autoselect=ChainListWidget.AUTOSELECT_SINGLE, + filter_func=lambda chain, sv=sv: chain in sv.alignment.associations, trigger_info=None) + layout.addWidget(self.chain_list) + + from Qt.QtWidgets import QDialogButtonBox as qbbox + bbox = qbbox(qbbox.Ok | qbbox.Close | qbbox.Help) + bbox.accepted.connect(self.transfer_seq) + hide_self = lambda *args, tw=tool_window: setattr(tool_window, 'shown', False) + bbox.rejected.connect(hide_self) + from chimerax.core.commands import run + bbox.helpRequested.connect(lambda *, run=run, ses=self.sv.session, help=tool_window.help: + run(ses, "help " + help)) + layout.addWidget(bbox) + + tool_window.ui_area.setLayout(layout) + + def transfer_seq(self): + chains = self.chain_list.value + if not chains: + from chimerax.core.errors import UserError + raise UserError("No chains chosen from list") + from chimerax.core.commands import run + align_arg = "" if len(self.sv.session.alignments) == 1 else " alignment " + self.sv.alignment.ident + run(self.sv.session, "seq update " + ''.join([chain.atomspec for chain in chains]) + align_arg) + self.tool_window.shown = False + + def _align_arg(self): + if len(self.sv.session.alignments) > 1: + return ' ' + self.sv.alignment.ident + return '' + + def _assoc_mod(self, note_data): + # called from sequence viewer if associations modified + self.chain_list.refresh() diff --git a/src/bundles/seqalign/bundle_info.xml b/src/bundles/seqalign/bundle_info.xml index 552eaf352a..bae4004f0a 100644 --- a/src/bundles/seqalign/bundle_info.xml +++ b/src/bundles/seqalign/bundle_info.xml @@ -1,4 +1,4 @@ - diff --git a/src/bundles/seqalign/src/cmd.py b/src/bundles/seqalign/src/cmd.py index 9ade52e6ad..f0c4533216 100644 --- a/src/bundles/seqalign/src/cmd.py +++ b/src/bundles/seqalign/src/cmd.py @@ -348,6 +348,40 @@ def seqalign_refseq(session, ref_seq_info): aln, ref_seq = ref_seq_info, None aln.reference_seq = ref_seq +def seqalign_update(session, chains, *, alignment=None): + if alignment is None: + alignments = session.alignments.alignments + else: + alignments = [alignment] + + for chain in chains: + did_xfer = False + for aln in alignments: + if chain in aln.associations: + did_xfer = True + aseq = aln.associations[chain] + match_map = aseq.match_maps[chain] + residues = set() + seq_residues = [] + ungapped = aseq.ungapped() + for i in range(len(ungapped)): + try: + r = match_map[i] + except KeyError: + seq_residues.append(None) + else: + seq_residues.append(r) + residues.add(r) + cur_residues = set(chain.existing_residues) + if len(cur_residues) > len(residues): + raise UserError("Alignment sequence does not cover all chain residues (e.g. %s)" + % ((cur_residues - residues).pop())) + chain.bulk_set(seq_residues, ungapped) + chain.from_seqres = True + if not did_xfer: + session.logger.warning("%s not associated with %s" + % (chain, " any alignment" if alignment is None else "alignment %s" % alignment.ident)) + MUSCLE = "MUSCLE" CLUSTAL_OMEGA = "Clustal Omega" alignment_program_name_args = { 'muscle': MUSCLE, 'omega': CLUSTAL_OMEGA, 'clustalOmega': CLUSTAL_OMEGA } @@ -431,5 +465,12 @@ def register_seqalign_command(logger): ) register('sequence refseq', desc, seqalign_refseq, logger=logger) + desc = CmdDesc( + required = [('chains', UniqueChainsArg)], + keyword = [('alignment', AlignmentArg)], + synopsis = 'transfer alignment sequences to associated chains' + ) + register('sequence update', desc, seqalign_update, logger=logger) + from . import manager manager._register_viewer_subcommands(logger)