#!/usr/bin/env python3.8
# coding: latin-1
# (c) Massachusetts Institute of Technology 2015-2018
# (c) Brian Teague 2018-2022
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
cytoflow.operations.quad
------------------------
Applies a (2D) quad gate to an `Experiment`. `quad` has two classes:
`QuadOp` -- Applies the gate, given a pair of thresholds
`ScatterplotQuadSelectionView` -- an `IView` that allows you to view the
quadrants and/or interactively set the thresholds on a scatterplot.
`ScatterplotQuadSelectionView` -- an `IView` that allows you to view the
quadrants and/or interactively set the thresholds on a density plot.
"""
from traits.api import (HasStrictTraits, Float, Str, Bool, Instance,
provides, observe, Any, Constant, Dict)
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from matplotlib.widgets import Cursor
import numpy as np
import pandas as pd
import cytoflow.utility as util
from cytoflow.views import ISelectionView, ScatterplotView, DensityView
from .i_operation import IOperation
from .base_op_views import Op2DView
[docs]@provides(IOperation)
class QuadOp(HasStrictTraits):
"""
Apply a quadrant gate to a cytometry experiment.
Creates a new metadata column named `name`, with values
``name_1`` (upper-left quadrant), ``name_2`` (upper-right),
``name_3`` (lower-left), and ``name_4`` (lower-right). This
ordering is arbitrary, and was chosen to match the FACSDiva order.
Attributes
----------
name : Str
The operation name. Used to name the new metadata field in the
experiment that's created by `apply`
xchannel : Str
The name of the first channel to apply the range gate.
xthreshold : Float
The threshold in the xchannel to gate with.
ychannel : Str
The name of the secon channel to apply the range gate.
ythreshold : Float
The threshold in ychannel to gate with.
Examples
--------
Make a little data set.
.. plot::
:context: close-figs
>>> import cytoflow as flow
>>> import_op = flow.ImportOp()
>>> import_op.tubes = [flow.Tube(file = "Plate01/RFP_Well_A3.fcs",
... conditions = {'Dox' : 10.0}),
... flow.Tube(file = "Plate01/CFP_Well_A4.fcs",
... conditions = {'Dox' : 1.0})]
>>> import_op.conditions = {'Dox' : 'float'}
>>> ex = import_op.apply()
Create and parameterize the operation.
.. plot::
:context: close-figs
>>> quad = flow.QuadOp(name = "Quad",
... xchannel = "V2-A",
... xthreshold = 100,
... ychannel = "Y2-A",
... ythreshold = 1000)
Show the default view
.. plot::
:context: close-figs
>>> qv = quad.default_view(huefacet = "Dox",
... xscale = 'log',
... yscale = 'log')
...
>>> qv.plot(ex)
.. note::
If you want to use the interactive default view in a Jupyter notebook,
make sure you say ``%matplotlib notebook`` in the first cell
(instead of ``%matplotlib inline`` or similar). Then call
``default_view()`` with ``interactive = True``::
qv = quad.default_view(huefacet = "Dox",
xscale = 'log',
yscale = 'log',
interactive = True)
qv.plot(ex)
Apply the gate and show the result
.. plot::
:context: close-figs
>>> ex2 = quad.apply(ex)
>>> ex2.data.groupby('Quad').size()
Quad
Quad_1 1783
Quad_2 2584
Quad_3 8236
Quad_4 7397
dtype: int64
"""
# traits
id = Constant('edu.mit.synbio.cytoflow.operations.quad')
friendly_id = Constant("Quadrant Gate")
name = Str
xchannel = Str
xthreshold = Float(None)
ychannel = Str
ythreshold = Float(None)
_selection_view = Instance('_QuadSelection', transient = True)
[docs] def apply(self, experiment):
"""
Applies the quad gate to an experiment.
Parameters
----------
experiment : `Experiment`
the `Experiment` to which this op is applied
Returns
-------
Experiment
a new `Experiment`, the same as the old `Experiment`
but with a new column the same as the operation `name`.
The new column is of type *Category*, with values ``name_1``, ``name_2``,
``name_3``, and ``name_4``, applied to events CLOCKWISE from upper-left.
Raises
------
CytoflowOpError
if for some reason the operation can't be applied to this
experiment. The reason is in the ``args`` attribute of `CytoflowOpError`.
"""
# TODO - the naming scheme (name_1, name_2, etc) is semantically weak.
# Add some (generalizable??) way to rename these populations?
# It's an Enum; should be pretty easy.
if experiment is None:
raise util.CytoflowOpError('experiment',
"No experiment specified")
# make sure name got set!
if not self.name:
raise util.CytoflowOpError('name',
"You have to set the gate's name "
"before applying it!")
if self.name != util.sanitize_identifier(self.name):
raise util.CytoflowOpError('name',
"Name can only contain letters, numbers and underscores."
.format(self.name))
# make sure old_experiment doesn't already have a column named self.name
if(self.name in experiment.data.columns):
raise util.CytoflowOpError('name',
"Experiment already contains a column {0}"
.format(self.name))
if not self.xchannel:
raise util.CytoflowOpError('xchannel', "Must specify xchannel")
if not self.xchannel in experiment.channels:
raise util.CytoflowOpError('xchannel',
"xchannel isn't in the experiment")
if not self.ychannel:
raise util.CytoflowOpError('ychannel', "Must specify ychannel")
if not self.ychannel in experiment.channels:
raise util.CytoflowOpError('ychanel',
"ychannel isn't in the experiment")
if self.xthreshold is None:
raise util.CytoflowOpError('xthreshold', 'xthreshold must be set!')
if self.ythreshold is None:
raise util.CytoflowOpError('ythreshold', 'ythreshold must be set!')
gate = pd.Series([None] * len(experiment))
# perhaps there's some more pythonic way to do this?
# these gate names match FACSDiva. They are ARBITRARY.
# lower-left
ll = np.logical_and(experiment[self.xchannel] < self.xthreshold,
experiment[self.ychannel] < self.ythreshold)
gate.loc[ll] = self.name + '_3'
# upper-left
ul = np.logical_and(experiment[self.xchannel] < self.xthreshold,
experiment[self.ychannel] > self.ythreshold)
gate.loc[ul] = self.name + '_1'
# upper-right
ur = np.logical_and(experiment[self.xchannel] > self.xthreshold,
experiment[self.ychannel] > self.ythreshold)
gate.loc[ur] = self.name + '_2'
# lower-right
lr = np.logical_and(experiment[self.xchannel] > self.xthreshold,
experiment[self.ychannel] < self.ythreshold)
gate.loc[lr] = self.name + '_4'
new_experiment = experiment.clone(deep = False)
new_experiment.add_condition(self.name, "category", gate)
new_experiment.history.append(self.clone_traits(transient = lambda t: True))
return new_experiment
[docs] def default_view(self, **kwargs):
"""
Returns an `IView` that allows a user to view the quad selector or interactively draw it.
Parameters
----------
density : bool, default = False
If `True`, return a density plot instead of a scatterplot.
"""
density = kwargs.pop('density', False)
if density:
self._selection_view = DensityQuadSelectionView(op = self)
else:
self._selection_view = ScatterplotQuadSelectionView(op = self)
self._selection_view.trait_set(**kwargs)
return self._selection_view
class _QuadSelection(Op2DView):
xfacet = Constant(None)
yfacet = Constant(None)
# override the Op2DView
xscale = util.ScaleEnum
yscale = util.ScaleEnum
interactive = Bool(False, transient = True)
# internal state.
_ax = Any(transient = True)
_hline = Instance(Line2D, transient = True)
_vline = Instance(Line2D, transient = True)
_cursor = Instance(Cursor, transient = True)
_line_props = Dict()
def plot(self, experiment, **kwargs):
if experiment is None:
raise util.CytoflowViewError('experiment',
"No experiment specified")
self._line_props = kwargs.pop('line_props',
{'color' : 'black',
'linewidth' : 2})
super().plot(experiment, **kwargs)
self._ax = plt.gca()
self._draw_lines(None)
self._interactive(None)
@observe('[op.xthreshold,op.ythreshold]', post_init = True)
def _draw_lines(self, _):
if not self._ax:
return
if self._hline and self._hline in self._ax.lines:
self._hline.remove()
if self._vline and self._vline in self._ax.lines:
self._vline.remove()
if self.op.xthreshold and self.op.ythreshold:
self._hline = plt.axhline(self.op.ythreshold, **self._line_props)
self._vline = plt.axvline(self.op.xthreshold, **self._line_props)
plt.draw()
@observe('interactive', post_init = True)
def _interactive(self, _):
if self._ax and self.interactive and self._cursor is None:
self._cursor = Cursor(self._ax,
horizOn = True,
vertOn = True,
color = 'blue',
useblit = True)
self._cursor.connect_event('button_press_event', self._onclick)
elif not self.interactive and self._cursor is not None:
self._cursor.disconnect_events()
self._cursor = None
def _onclick(self, event):
"""Update the threshold location"""
self.op.xthreshold = event.xdata
self.op.ythreshold = event.ydata
[docs]@provides(ISelectionView)
class ScatterplotQuadSelectionView(_QuadSelection, ScatterplotView):
"""
Plots, and lets the user interact with, a quadrant gate.
Attributes
----------
interactive : Bool
is this view interactive? Ie, can the user set the threshold with a
mouse click?
Examples
--------
In an Jupyter notebook with ``%matplotlib notebook``
>>> q = flow.QuadOp(name = "Quad",
... xchannel = "V2-A",
... ychannel = "Y2-A"))
>>> qv = q.default_view()
>>> qv.interactive = True
>>> qv.plot(ex2)
"""
id = Constant('edu.mit.synbio.cytoflow.views.quad')
friendly_id = Constant("Quadrant Selection")
[docs] def plot(self, experiment, **kwargs):
"""
Plot the default view, and then draw the quad selection on top of it.
Parameters
----------
line_props : Dict
The properties of the `matplotlib.lines.Line2D` that are drawn
on top of the scatterplot or density view. They're passed
directly to the `matplotlib.lines.Line2D` constructor.
Default: ``{color : 'black', linewidth : 2}``
"""
super().plot(experiment, **kwargs)
util.expand_class_attributes(ScatterplotQuadSelectionView)
util.expand_method_parameters(ScatterplotQuadSelectionView, ScatterplotQuadSelectionView.plot)
[docs]@provides(ISelectionView)
class DensityQuadSelectionView(_QuadSelection, DensityView):
"""
Plots, and lets the user interact with, a quadrant gate on a density view
Attributes
----------
interactive : Bool
is this view interactive? Ie, can the user set the threshold with a
mouse click?
Examples
--------
In an Jupyter notebook with ``%matplotlib notebook``
>>> q = flow.QuadOp(name = "Quad",
... xchannel = "V2-A",
... ychannel = "Y2-A"))
>>> qv = q.default_view(density = True)
>>> qv.interactive = True
>>> qv.plot(ex2)
"""
id = Constant('edu.mit.synbio.cytoflow.views.quad_density')
friendly_id = Constant("Quadrant Selection (Density Plot)")
[docs] def plot(self, experiment, **kwargs):
"""
Plot the default view, and then draw the quad selection on top of it.
Parameters
----------
line_props : Dict
The properties of the `matplotlib.lines.Line2D` that are drawn
on top of the scatterplot or density view. They're passed
directly to the `matplotlib.lines.Line2D` constructor.
Default: ``{color : 'black', linewidth : 2}``
"""
super().plot(experiment, **kwargs)
util.expand_class_attributes(DensityQuadSelectionView)
util.expand_method_parameters(DensityQuadSelectionView, DensityQuadSelectionView.plot)
if __name__ == '__main__':
import cytoflow as flow
tube1 = flow.Tube(file = '../../cytoflow/tests/data/Plate01/RFP_Well_A3.fcs',
conditions = {"Dox" : 10.0})
tube2 = flow.Tube(file = '../../cytoflow/tests/data/Plate01/CFP_Well_A4.fcs',
conditions = {"Dox" : 1.0})
ex = flow.ImportOp(conditions = {"Dox" : "float"}, tubes = [tube1, tube2])
r = flow.QuadOp(name = "Quad",
xchannel = "V2-A",
ychannel = "Y2-A")
rv = r.default_view(xscale = "logicle", yscale = "logicle")
plt.ioff()
rv.plot(ex)
rv.interactive = True
plt.show()
print("x:{0} y:{1}".format(r.xthreshold, r.ythreshold))
ex2 = r.apply(ex)
flow.ScatterplotView(xchannel = "V2-A",
ychannel = "Y2-A",
xscale = "logicle",
yscale = "logicle",
huefacet = "Quad").plot(ex2)
plt.show()