diff --git a/contact_map/__init__.py b/contact_map/__init__.py
index 33de73c..0d73c2e 100644
--- a/contact_map/__init__.py
+++ b/contact_map/__init__.py
@@ -11,4 +11,6 @@
from .min_dist import NearestAtoms, MinimumDistanceCounter
+from .dask_runner import DaskContactFrequency
+
# import concurrence
diff --git a/contact_map/contact_map.py b/contact_map/contact_map.py
index b8a61fc..5f8c80c 100644
--- a/contact_map/contact_map.py
+++ b/contact_map/contact_map.py
@@ -724,6 +724,23 @@ def __init__(self, trajectory, query=None, haystack=None, cutoff=0.45,
contacts = self._build_contact_map(trajectory)
(self._atom_contacts, self._residue_contacts) = contacts
+ def __hash__(self):
+ return hash((super(ContactFrequency, self).__hash__(),
+ tuple(self._atom_contacts.items()),
+ tuple(self._residue_contacts.items()),
+ self.n_frames))
+
+ def __eq__(self, other):
+ is_equal = (super(ContactFrequency, self).__eq__(other)
+ and self._atom_contacts == other._atom_contacts
+ and self._residue_contacts == other._residue_contacts
+ and self.n_frames == other.n_frames)
+ return is_equal
+
+ def to_dict(self):
+ dct = super(ContactFrequency, self).to_dict()
+ dct.update({'n_frames': self.n_frames})
+ return dct
def _build_contact_map(self, trajectory):
# We actually build the contact map on a per-residue basis, although
diff --git a/contact_map/dask_runner.py b/contact_map/dask_runner.py
new file mode 100644
index 0000000..27742f9
--- /dev/null
+++ b/contact_map/dask_runner.py
@@ -0,0 +1,73 @@
+"""
+Implementation of ContactFrequency parallelization using dask.distributed
+"""
+
+from . import frequency_task
+from .contact_map import ContactFrequency, ContactObject
+import mdtraj as md
+
+
+def dask_run(trajectory, client, run_info):
+ """
+ Runs dask version of ContactFrequency. Note that this API on this will
+ definitely change before the release.
+
+ Parameters
+ ----------
+ trajectory : mdtraj.trajectory
+ client : dask.distributed.Client
+ path to dask scheduler file
+ run_info : dict
+ keys are 'trajectory_file' (trajectory filename), 'load_kwargs'
+ (additional kwargs passed to md.load), and 'parameters' (dict of
+ kwargs for the ContactFrequency object)
+
+ Returns
+ -------
+ :class:`.ContactFrequency` :
+ total contact frequency for the trajectory
+ """
+ slices = frequency_task.default_slices(n_total=len(trajectory),
+ n_workers=len(client.ncores()))
+
+ subtrajs = client.map(frequency_task.load_trajectory_task, slices,
+ file_name=run_info['trajectory_file'],
+ **run_info['load_kwargs'])
+ maps = client.map(frequency_task.map_task, subtrajs,
+ parameters=run_info['parameters'])
+ freq = client.submit(frequency_task.reduce_all_results, maps)
+
+ return freq.result()
+
+class DaskContactFrequency(ContactFrequency):
+ def __init__(self, client, filename, query=None, haystack=None,
+ cutoff=0.45, n_neighbors_ignored=2, **kwargs):
+ self.client = client
+ self.filename = filename
+ trajectory = md.load(filename, **kwargs)
+
+ self.frames = range(len(trajectory))
+ self.kwargs = kwargs
+
+ ContactObject.__init__(self, trajectory.topology, query, haystack,
+ cutoff, n_neighbors_ignored)
+
+ freq = dask_run(trajectory, client, self.run_info)
+ self._n_frames = freq.n_frames
+ self._atom_contacts = freq._atom_contacts
+ self._residue_contacts = freq._residue_contacts
+
+ @property
+ def parameters(self):
+ return {'query': self.query,
+ 'haystack': self.haystack,
+ 'cutoff': self.cutoff,
+ 'n_neighbors_ignored': self.n_neighbors_ignored}
+
+ @property
+ def run_info(self):
+ return {'parameters': self.parameters,
+ 'trajectory_file': self.filename,
+ 'load_kwargs': self.kwargs}
+
+
diff --git a/contact_map/frequency_task.py b/contact_map/frequency_task.py
new file mode 100644
index 0000000..0866a81
--- /dev/null
+++ b/contact_map/frequency_task.py
@@ -0,0 +1,132 @@
+"""
+Task-based implementation of :class:`.ContactFrequency`.
+
+The overall algorithm is:
+
+1. Identify how we're going to slice up the trajectory into task-based
+ chunks (:meth:`block_slices`, :meth:`default_slices`)
+2. On each node
+ a. Load the trajectory segment (:meth:`load_trajectory_task`)
+ b. Run the analysis on the segment (:meth:`map_task`)
+3. Once all the results have been collected, combine them
+ (:meth:`reduce_all_results`)
+
+Notes
+-----
+Includes versions where messages are Python objects and versions (labelled
+with _json) where messages have been JSON-serialized. However, we don't yet
+have a solution for JSON serialization of MDTraj objects, so if JSON
+serialization is the communication method, the loading of the trajectory and
+the calculation of the contacts must be combined into a single task.
+"""
+
+import mdtraj as md
+from contact_map import ContactFrequency
+
+def block_slices(n_total, n_per_block):
+ """Determine slices for splitting the input array.
+
+ Parameters
+ ----------
+ n_total : int
+ total length of array
+ n_per_block : int
+ maximum number of items per block
+
+ Returns
+ -------
+ list of slice
+ slices to be applied to the array
+ """
+ n_full_blocks = n_total // n_per_block
+ slices = [slice(i*n_per_block, (i+1)*n_per_block)
+ for i in range(n_full_blocks)]
+ if n_total % n_per_block:
+ slices.append(slice(n_full_blocks*n_per_block, n_total))
+ return slices
+
+def default_slices(n_total, n_workers):
+ """Calculate default slices from number of workers.
+
+ Default behavior is (approximately) one task per worker.
+
+ Parameters
+ ----------
+ n_total : int
+ total number of items in array
+ n_workers : int
+ number of workers
+
+ Returns
+ -------
+ list of slice
+ slices to be applied to the array
+ """
+ n_frames_per_task = max(1, n_total // n_workers)
+ return block_slices(n_total, n_frames_per_task)
+
+
+def load_trajectory_task(subslice, file_name, **kwargs):
+ """
+ Task for loading file. Reordered for to take per-task variable first.
+
+ Parameters
+ ----------
+ subslice : slice
+ the slice of the trajectory to use
+ file_name : str
+ trajectory file name
+ kwargs :
+ other parameters to mdtraj.load
+
+ Returns
+ -------
+ md.Trajectory :
+ subtrajectory for this slice
+ """
+ return md.load(file_name, **kwargs)[subslice]
+
+def map_task(subtrajectory, parameters):
+ """Task to be mapped to all subtrajectories. Run ContactFrequency
+
+ Parameters
+ ----------
+ subtrajectory : mdtraj.Trajectory
+ single trajectory segment to calculate ContactFrequency for
+ parameters : dict
+ kwargs-style dict for the :class:`.ContactFrequency` object
+
+ Returns
+ -------
+ :class:`.ContactFrequency` :
+ contact frequency for the subtrajectory
+ """
+ return ContactFrequency(subtrajectory, **parameters)
+
+def reduce_all_results(contacts):
+ """Combine multiple :class:`.ContactFrequency` objects into one
+
+ Parameters
+ ----------
+ contacts : iterable of :class:`.ContactFrequency`
+ the individual (partial) contact frequencies
+
+ Returns
+ -------
+ :class:`.ContactFrequency` :
+ total of all input contact frequencies (summing them)
+ """
+ accumulator = contacts[0]
+ for contact in contacts[1:]:
+ accumulator.add_contact_frequency(contact)
+ return accumulator
+
+
+def map_task_json(subtrajectory, parameters):
+ """JSON-serialized version of :meth:`map_task`"""
+ return map_task(subtrajectory, parameters).to_json()
+
+def reduce_all_results_json(results_of_map):
+ """JSON-serialized version of :meth:`reduce_all_results`"""
+ contacts = [ContactFrequency.from_json(res) for res in results_of_map]
+ return reduce_all_results(contacts)
diff --git a/contact_map/tests/test_contact_map.py b/contact_map/tests/test_contact_map.py
index 1919b06..6bd1f0e 100644
--- a/contact_map/tests/test_contact_map.py
+++ b/contact_map/tests/test_contact_map.py
@@ -329,6 +329,17 @@ def test_frames_parameter(self):
}
assert contacts.residue_contacts.counter == expected_residue_count
+ def test_hash(self):
+ map2 = ContactFrequency(trajectory=traj,
+ cutoff=0.075,
+ n_neighbors_ignored=0)
+ map3 = ContactFrequency(trajectory=traj[:2],
+ cutoff=0.075,
+ n_neighbors_ignored=0)
+
+ assert hash(self.map) == hash(map2)
+ assert hash(self.map) != hash(map3)
+
def test_saving(self):
m = self.map
m.save_to_file(test_file)
diff --git a/contact_map/tests/test_dask_runner.py b/contact_map/tests/test_dask_runner.py
new file mode 100644
index 0000000..0566dcc
--- /dev/null
+++ b/contact_map/tests/test_dask_runner.py
@@ -0,0 +1,17 @@
+from .utils import *
+
+from contact_map.dask_runner import *
+
+class TestDaskContactFrequency(object):
+ def test_dask_integration(self):
+ # this is an integration test to check that dask works
+ dask = pytest.importorskip('dask')
+ distributed = pytest.importorskip('dask.distributed')
+
+ client = distributed.Client()
+ filename = find_testfile("trajectory.pdb")
+
+ dask_freq = DaskContactFrequency(client, filename, cutoff=0.075,
+ n_neighbors_ignored=0)
+ client.close()
+ assert dask_freq.n_frames == 5
diff --git a/contact_map/tests/test_frequency_task.py b/contact_map/tests/test_frequency_task.py
new file mode 100644
index 0000000..6a79038
--- /dev/null
+++ b/contact_map/tests/test_frequency_task.py
@@ -0,0 +1,93 @@
+import os
+import collections
+
+from .utils import *
+from .test_contact_map import traj
+
+from contact_map.frequency_task import *
+from contact_map import ContactFrequency
+
+class TestSlicing(object):
+ # tests for block_slices and default_slices
+ @pytest.mark.parametrize("inputs, results", [
+ ((100, 25),
+ [slice(0, 25), slice(25, 50), slice(50, 75), slice(75, 100)]),
+ ((85, 25),
+ [slice(0, 25), slice(25, 50), slice(50, 75), slice(75, 85)])
+ ])
+ def test_block_slices(self, inputs, results):
+ n_total, n_per_block = inputs
+ assert block_slices(n_total, n_per_block) == results
+
+ @pytest.mark.parametrize("inputs, results", [
+ ((100, 4),
+ [slice(0, 25), slice(25, 50), slice(50, 75), slice(75, 100)]),
+ ((77, 3),
+ [slice(0, 25), slice(25, 50), slice(50, 75), slice(75, 77)]),
+ ((2, 20),
+ [slice(0, 1), slice(1, 2)])
+ ])
+ def test_default_slice_even_split(self, inputs, results):
+ n_total, n_workers = inputs
+ assert default_slices(n_total, n_workers) == results
+
+class TestTasks(object):
+ def setup(self):
+ self.contact_freq_0_4 = ContactFrequency(traj, cutoff=0.075,
+ n_neighbors_ignored=0,
+ frames=range(4))
+ self.contact_freq_4 = ContactFrequency(traj, cutoff=0.075,
+ n_neighbors_ignored=0,
+ frames=[4])
+ self.total_contact_freq = ContactFrequency(traj, cutoff=0.075,
+ n_neighbors_ignored=0)
+ self.parameters = {'cutoff': 0.075, 'n_neighbors_ignored': 0}
+
+ def test_load_trajectory_task(self):
+ subslice = slice(0, 4)
+ file_name = find_testfile("trajectory.pdb")
+ trajectory = load_trajectory_task(subslice, file_name)
+ assert trajectory.xyz.shape == (4, 10, 3)
+
+ def test_map_task(self):
+ trajectory = traj[:4]
+ mapped = map_task(trajectory, parameters=self.parameters)
+ assert mapped == self.contact_freq_0_4
+
+ def test_reduce_task(self):
+ reduced = reduce_all_results([self.contact_freq_0_4,
+ self.contact_freq_4])
+ assert reduced == self.total_contact_freq
+
+ def test_map_task_json(self):
+ # check the json objects by converting them back to full objects
+ trajectory = traj[:4]
+ mapped = map_task_json(trajectory, parameters=self.parameters)
+ assert ContactFrequency.from_json(mapped) == self.contact_freq_0_4
+
+ def test_reduce_all_results_json(self):
+ reduced = reduce_all_results_json([self.contact_freq_0_4.to_json(),
+ self.contact_freq_4.to_json()])
+ assert reduced == self.total_contact_freq
+
+ def test_integration_object_based(self):
+ file_name = find_testfile("trajectory.pdb")
+ slices = default_slices(len(traj), n_workers=3)
+ trajs = [load_trajectory_task(subslice=sl,
+ file_name=file_name)
+ for sl in slices]
+ mapped = [map_task(subtraj, self.parameters) for subtraj in trajs]
+ result = reduce_all_results(mapped)
+ assert result == self.total_contact_freq
+
+ def test_integration_json_based(self):
+ file_name = find_testfile("trajectory.pdb")
+ slices = default_slices(len(traj), n_workers=3)
+ trajs = [load_trajectory_task(subslice=sl,
+ file_name=file_name)
+ for sl in slices]
+ mapped = [map_task_json(subtraj, self.parameters)
+ for subtraj in trajs]
+ result = reduce_all_results_json(mapped)
+ assert result == self.total_contact_freq
+
diff --git a/docs/api.rst b/docs/api.rst
index 7aaabf8..aa3634d 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -28,3 +28,12 @@ Minimum Distance (and related)
MinimumDistanceCounter
NearestAtoms
+
+Parallelization of ``ContactFrequency``
+---------------------------------------
+
+.. autosummary::
+ :toctree: api/generated/
+
+ frequency_task
+ dask_runner
diff --git a/examples/dask_contact_frequency.ipynb b/examples/dask_contact_frequency.ipynb
new file mode 100644
index 0000000..bc291c3
--- /dev/null
+++ b/examples/dask_contact_frequency.ipynb
@@ -0,0 +1,232 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Parallel `ContactFrequency` with Dask\n",
+ "\n",
+ "In principle, each frame that makes up a `ContactFrequency` can have its contact map calculated in parallel. This shows how to use [`dask.distributed`](https://distributed.readthedocs.io/) to do this.\n",
+ "\n",
+ "This will use the same example data as the main contact maps example (data from https://figshare.com/s/453b1b215cf2f9270769). See that example, `contact_map.ipynb`, for details."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "%matplotlib inline\n",
+ "import mdtraj as md\n",
+ "# dask and distributed are extra installs\n",
+ "from dask.distributed import Client, LocalCluster\n",
+ "from contact_map.dask_runner import dask_run"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "trajectory = md.load(\"5550217/kras.xtc\", top=\"5550217/kras.pdb\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# TODO: we need a more user-friendly approach for this than what we see here\n",
+ "run_info = {\n",
+ " 'trajectory_file': \"5550217/kras.xtc\",\n",
+ " 'load_kwargs': {'top': \"5550217/kras.pdb\"},\n",
+ " 'parameters': {}\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Next we need to connect a client to a dask network.\n",
+ "\n",
+ "Note that there are several ways to set up the dask computer network and then connect a client to it. See https://distributed.readthedocs.io/en/latest/setup.html. The approach used here creates a `LocalCluster`. Large scale simulations would need other approaches. For clusters, you can manually run a `dask-scheduler` and multiple `dask-worker` commands. By using the same `sched.json`, it is easy to have different workers in different jobs on the cluster's scheduling system."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "c = LocalCluster()\n",
+ "client = Client(c)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# if you started on a cluster and the scheduler file is called sched.json\n",
+ "#client = Client(scheduler_file=\"./sched.json\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "\n",
+ "Client\n",
+ "\n",
+ " | \n",
+ "\n",
+ "Cluster\n",
+ "\n",
+ " - Workers: 4
\n",
+ " - Cores: 4
\n",
+ " - Memory: 17.18 GB
\n",
+ " \n",
+ " | \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "client"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "CPU times: user 329 ms, sys: 226 ms, total: 554 ms\n",
+ "Wall time: 4.88 s\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%time\n",
+ "freq = dask_run(trajectory=trajectory, client=client, run_info=run_info)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Note that on a single machine (shared memory) this may not improve performance. That is because the single-frame aspect of this calculation is already parallelized with OpenMP, and will therefore use all cores on the machine.\n",
+ "\n",
+ "Next we check that we're still getting the same results:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "101"
+ ]
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# did it add up to give us the right number of frames?\n",
+ "freq.n_frames"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAAAXoAAAD8CAYAAAB5Pm/hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlz\nAAALEgAACxIB0t1+/AAAADl0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uIDIuMS4wLCBo\ndHRwOi8vbWF0cGxvdGxpYi5vcmcvpW3flQAAIABJREFUeJztnX+0HUWV77+buuONJkgiNyYZIMYf\nCChKAlHJCBIfMIIrGpxBMW/eDCAQ45vMQgVGMOZd72SyYARkMcM8GFB++JYDiI6AecKIPKNmPRAT\nCQKTIEHjcCEJBPl1oze8e6j3R1edU6dvV3d1n/5R3Wd/1jrr9KlTXV1dXb17965du0hKCYZhGKa5\n7FN1BRiGYZhiYUHPMAzTcFjQMwzDNBwW9AzDMA2HBT3DMEzDYUHPMAzTcFjQMwzDNBwW9AzDMA2H\nBT3DMEzDGai6AgAwNDQk582dC+zTw3PHnOFL1HulykLXW8rezr/X4wP+t5uUyXXU5+P7ubjicj55\nn3MVbRh1zPCsff1fuB8kze7Peh5SdpeddH9W0G6bNm3aLaWcmZTPC0E/b948bHzggaqrUS0TE8CA\nw+WYmAi+XfLmhT5m+Li29F6PU+a5+XT8NIyPB99TplRbj7IYH++cq9nvzN9mW5jX0sxvy6uvue0+\nNI+vf4fLqQAS4rcu+WrQo/sEV+FShUC15ctbICbdbGmJOs+4susg4DVNE/C2a6UxBbZNMOv/x8eB\nadMmlzNlSnT5ZpqtD2Rt77z6co9UXwOmfDzoeLHkVb+ocqp4E3I9Zp3eKIpEa8tAtJYelQZ02i0s\nlPXvsbHu/8IPCDMtCvNB4yr4PbmWftSin8nb/MH4Q9rr2fTrHzahRAlLmxZvy2P+1u33wgvA0NDk\n/cJCPurBappoxsft2n7NHsp+1dKT1xwA6S5kL/X25XyZelEnQeNiIokyr4TvK9s5h38PDQXaOxCY\ncJKUKfM4U6ZEvzmMj3eXUzPTmV+9xKdO6/vAqE9t1TR8UjjKpohzTxKuQPdAq04Pa9027T7qnjBt\n9GY9osqw1cssu2aCPUyf9uacsN0QRQrjfhVAZZJ3G5clPH0uN+k4pv07Kk/Y1BP3lmAT5nGeO1F1\nSbq/4/J4Rj1qWTdqcvG9Jq3HjM/Usc5FE76WSUI6rN1nadM4byvTXVJvhx8unrhUZoF7IJOdst9c\n6iYw2cxmJ9wmSUI07iEf3tc20Bo23USZjkytX9v59fHNt4Fw+S7eOhX2Az964Kuvdka4m3ZTeHCR\nY+mlfr6eky80qX3yfpsKC9yksk2tW/92KdvcJ1xelF++ORgb9sWPegOpyTX2o5b77FPL1yEnfO4I\nSQNTTH64PFB9VgrKGmNwmaVqPhii3DTDphjbgK4prHX+8ENn9+7gO8pd0wVPzI3V16CJRNn7fHyQ\nedABU+HJTZOJsuvdS1vl9cBxHdQMC9eokAVp7eW2SVUus2vNbVPA84QppguzE0S5jTWFsjXQprVf\nmDzPr5ey8qqHza1S/xc3OBrOHxbycXUMe9dEzYZ1qastTw37Yf1qXFdq2DkSaeI5McWSpN2HBz1t\n3jhJ5p2ossN2+SitfGysY5uP8/jKQ8kp0U2T79QiyUOLr6HPbi3hds6XrH3fxZ02yWPHFurANhnK\nNtHKFnMnD7K2T8b9EgOgE9FBRPQjItpCRI8S0bkq/Q1EdA8RPa6+Z6h0IqJ/JKJtRPRLIjoyda00\n5sWoI0kDby7npl9xXbSWOrdV1bi2M+NGnOZuCt44U05YYOtPuJ9PmdIZdI26D5LKHhsLhLr+6DIG\nBjplm+n6vyrMYxn3c1npYgLAeVLKwwAcDeCviegdAC4EcK+U8mAA96rfAHAygIPVZzmAqzPVDPDj\nxkuaQZdVuOZ9bj60FcMkYQprs89qIav/i7uvwnnMfc3yww+U8P2h806b1hHoUS6UZt6o8mtAoqCX\nUu6QUv5Cbb8MYAuAAwAsBXCTynYTgFPU9lIA35AB9wOYTkRzcq95WcQJz7TCtWado+8o4/rYyu+3\nfhGlYZuukEC3Bh3Wps084fvQNLmY/7v614fdN3UZ+oFi5quSFH0mVU2JaB6ABQB+BmCWlHIHEDwM\niOiNKtsBAJ40dhtVaTtCZS1HoPFj7ty5aarhD7YBGVt6Hvb6qjtXkymjbXvx+mgyUQOm4e0okjx0\nbMcJb9vKNt84fHORTtFnnBcpJaJpAL4D4LNSypfiskakTVrUUUp5rZRyoZRy4cyZiUse+ontqZ73\n09726skUQ121617qXcTbTFyZcelR94/N7h51T4Tt6eY+Gm13j9LQo8ot0lxTQn9zkhxE9EcIhPw3\npZT/ppJ3EdEcpc3PAfCMSh8FcJCx+4EAns6rwt5ie6Uz42JnJY0rmY20bx+2Mpr8sKmrgNdUMTiY\ntcyBAWD79mB72rTomadp+1taP3dbKOO4sl2wuYVGvX1E5Y9z48zoCZRYeyIiAF8HsEVK+VXjrzsB\nnA7gEvV9h5G+kohuAfA+AC9qE0+jCF9A20y8pNCqaUjy4onLk0cHbqKQNx/QZU8A6xei2hjoCHdb\n2+dlEnWZpJUncX3JbAszfxTmxLAeZ9i7nPX7AfwlgIeJaLNK+yICAf8tIjoLwH8C+Lj67/sAPgxg\nG4DfAzgzU82yEn4iVhmCQA/g6O0ibfRZy16/PvhevDjb/nXHZdakj5T9UOrlbc62TusttwTbS5bY\nY8nkIaT1fuaasXm1m1k/F1lje+uw7ZtTPUnKSebz0lm4cKHc+MADQaONjnb+0Bc/jdmjCPNC1E0V\n1ujrqA0W0Vb6dXzevHzLzQuXc/Zp8lQd+5UNF5NG3TBdOKPCGIeDqpkkKYGmRm/xKqKpUzdJKRcm\nVdOvFh4Y6F1AlGVrNJ/gJjYbm9kRspK3YC6irYoU8C6eD1lNWOEyfBE+rrZbHwnX0VSIxsd7G7fy\nhahzc/HW2b3bPi4R9ZAwMWPnu1bTOafvVNHxXQR32gEil7Lyom5aVZr2zkLZttymE9bew14ved2z\nWcop4mFue+OPSjOF/NhYZ99wH487txT1b15vzusCJnUe23GKHCPIw+sG6H74RHkF2bRHc9+64nLd\nkqIjVkHaAUgfCN9DZj/Scd6BQOilPZ80D+Uo3/kiY9fYnDFsg8vTpnUvX2iW42Lvd8C/3mJewDSu\niWU8nV2OEd7PJlSydLi44yfFwLft6+JillZTSrtPmcSZdOruXumTyQmI70euwt02PpblPIt4U0ua\nJxCWY0nmHVM22AZ4M9xjHvUKTL6AvtjwbK9NWWbt6fQsHS6ug0fFwK8CnwRNWkxNqldvqSqErm9t\nH3d/uLZPjyaLTPmzlG1zzgCi5Zj51hi3QJH5ANBlZzgfv3qGTavyrQNr0tSrF5NLnnb+ovFdow+T\n1j0uTXlJeUyyvkGW5WWWpYy4/evSP5JI0uht26ZwHxiYLNA1toek7T8L/rV2nTqArcHzFHZ1ag+g\n2PpGjRX06o1i5ivqeoVv9KgHd9YHumveNA+xstvBBd/MUhoXTTztG79J1DhH7TX6OmG+ermYZZo0\noFkVLq/xebw5FenG6nLTF0EV5rwo98qsVHHPuPQDbZbR8XWA7nDH4fQoE4xNyPeiwISLyrQXE+8e\ntllNID7wwM4EooWJcxryx+xAWvMI2wv1BLUDDyynTr6St2ZfNHUwkSWZbq65Jtg+/HDgmGM6/2U9\nN3O/XtrHpqVHla+xKXg2X/g4E43N7JNUvxg87iUpqSLUga0TzZ/f2Z4+vZy6ROEysF1l/XwgSiBs\n3w7Mnt1J99EGHrVvXqF005okbelx9+Tu3R3h/ra3Za+rSV4Pa9u+prIU1Qa2B0FUvcIPI71tulTa\nFLW+Nt2U/Wpqm56s/9OYvrImvmhkvsXYLpuo9s9jdm+cqa6oa57XtUxrXnLxBAszNNRRMmztk8WO\nH1efNGVEoe/hqHrZxlviHgC2QfmovmOaiPpa0JdNXCeO8nkNd46qBTzg7wCXjSIWf4jyujHJerwy\n29VXT7W4etlCAETldaUs5web7TzqQWPT1sP7umKLkptUZfcjVIAvWm8UcULSvAA7dwbfs2d3v8rm\nfW5JE0tsk6g2bgy2qxhDSEsaoRt+KCS9Spt5xsfradLy7T6Jq8+0acC6dcH2woUdUxmQfG/0oqCU\nIVNclLqwM0dUnrGxaJNrhol9nvUM+C3cXTHrbnbgrCFxXdokySPFpknkLeCL9MFOc4NnCfeqb6q8\nJuqV1Zd9v1dsmu6SJdH5k87HJ++dKI0+rMhFLRYSzhMlvKdN64SK0IqHGQyu1hp9ji5FhWLTnIHo\nKc/hPFlCH7i8RaR99cx7ELvI1+e0D8detb60xwzjY7/1hfFx4IUXgm1TGXId6E1DXt44UZj3pu0e\nsqUnvVkCk81bRS08QkTXA1gC4Bkp5eEq7VYAh6gs0wG8IKWcrxYP3wLgMfXf/VLKFZlqBvgxUOgi\nMML/mxphWStMZS3ThzZ2JY3wZgHtD1HtOW1aeTGsso6NuZpnw2mm0I8bV4rS9MODt1ErUkWlJeCS\n80YAVwH4hk6QUp6mt4nocgAvGvmfkFIa/oU1J08bej8IkCLdXItuP5/fIJtGHSYQplGsbN41trcI\nl5nQNg+dpP2iqpuUQUr5E6WpT0KtJ/sJAP/F+YhNIqu5pMkU+YbQLw/LrPjWPnH3R9p6lnluWR/4\nSW8O5sPANpHK3DeqvAwDsUDvNvpjAeySUj5upL2ZiB4E8BKAL0kpf9rjMfwly4Bq2v2KxDfBUDV1\nbwvf6h8WTnHjWq5ludLL7Nos7Rg+N9uC6DYTjVlOnDAP++i7Vs85ZzTLANxs/N4BYK6U8jkiOgrA\n7UT0TinlS+EdiWg5gOUAMHfu3B6rUQPyGkzKE98EQxJF1LfIgbq0x0/KV7frpclDo8/jmHntF+cV\npsuIcqCwDbiG942z/5tlpyBzaxPRAIA/A3CUTpNS7gWwV21vIqInALwdwMbw/lLKawFcCwSLg2et\nh5cUObJf15u9SHpp76rbs1c327CGV/X5JGEOQGrvtOnTy9PY8yijF89A20IiNu+dcPnhtwXXKjvn\nnMwJALZKKUd1AhHNBPA7KWWLiN4C4GAAv+7hGOnJu7O7lhc1cJKXlpjhwjphMyf5KjBs9bJpV1lv\nYJdZm2Xj4nLq2zWMWiHONF1EhfJ1rXeSRu1C2n4TV36SvT2qbNtga9J52GLXx5DYIkR0M4DFAIaI\naBTAsJTy6wA+iW6zDQB8AMDfEdEEgBaAFVLK3znXJg+qMm/YBk70djhGRZp6FjXA6SI80lKk+aNI\nm66JzX7qKzZbb97HSFtmnPukKejzqncWAe1aRvg/29tVmHBMLNu8mijM8npU9kjK6q0mCxculBsf\neKDqahRPlgFZHzSzJhGnVVXRzlHCIa83FJ8Je6AUUT5Q3rUOX8e0Dx1b3cLxl0JtRUJsklImTm9v\nUM+pGBetoUgbcp000CqJe4vJUxD0YoLImq9OD4O0JtFe7Ohxab2S5Dtv5jEXJIlTOMz6Rg3kZqAm\nvcJTbCPhUa9ZeXSyuJsjS/l1EgxFEL4ZzZuwV7IMLvZ6LepwLfW9ER50tNHrYHURRF2rsJnWZSlB\n04wTlT9uFbtwWgI16Bk9ooICPTdrFvZvtfItO+oCjo11x/Aw40gXcbwY/iAEXht3znUQDEVSxBhF\nVvrlWqQ105hKU5o2MqPGmmXpaK3milZhbA8MHWBs2rTOPR41aO8SvNCmBJrbSW1VkteNN/xBCGgx\nOoDOSe1j5JkGYK8QAIDBXgT+ChW6Ry+DBgBjY3huv/0AAPvfcUcnfckS4KSTsh+rR167YEFypqOP\nDr7vv7/YyjQZFVlwx8svY47qW78VAq+ovw/OW8HohfFx7Jg6tStpAsBBvtQxPBCut8fGooWqqQFr\n4X7ggR2BvmRJp4xt24BHHgm24wS9FuhDQ93asw6rfOqpHY8im3dWki0+L488R3gwlmFCrBMCB6jt\nUQAf8UUI1ox1SrHSrbc0rh214DQ1d5t/uc0dU5dhhhcwHxCm26ztgTIw0C3obX7vYRfHLK6tSfb9\ngYHE2FE8GMswGVmyZ0/7xlqQh8mtCkZHK13wfa0Q0BbkYZcHpRaMNrOHbaDc3DbdOaPKM7XvcBnm\nbzNflAto0qC9TVsPPwDSbvfAPslZSkQ/SQFcrLSBfuViIXCxEPiO0Q4jQgQ2x9mzcVWft0+hmMKh\nTmGcTSoS8iNCYEQIvIpAwDsJeSBoZ9flOdPQy8C6fgBk8ciKGlOocBzGL43eeJK+ArQb98rBQZzb\nZ6/PF0Wcr3nTrCyzMim4ST2AdgM4rwHXbI0QWN2A8yiDEUP5SGqzO4SIN+XEYTONZBHGcenmcbKY\nZnolx+P4JegNTKHWb0K+zpzehGtl3GBDAP5FCbBPqL9n+HqOGza0B4YvPeIIXFBSPU0Bn6TB67xH\nxeYyiAqjYDONJAnFOLdHF3u5ppfFhPKapZsSbwV93/PZzwIAbvunf8LHfRUsTcW4wT5Tp7Y3PEkK\nEfLnnx98X3ZZOymNkF8jRFvgLHGtny2MQpaJZnH2/6w28qT/48ouEW+9bq4TAufoznDMMYG20kdc\nqm4g2w37oBBYoP5bKwRW1UkgMbUnixbvkpdJh6vXjbeC3oTtpPXjUiHwe+N3XW/w64TAB9T2Leq7\nrueSF66CeyTkMNDv7VYEjXKvZCFfP8qyDxfNOcZ5vK5Gnk6XCtG+Bo8LkcukLS2490HyPemlFt/H\nIT/686xtXHIJcOGF5R5Tm6SOOQY4++xge9s2rPlpsALjJwAcsndvkG7rpOvWBTMAmUKp08PLrGse\nQn6to0eNlwJe06dCHqiJ6cbUTn6mOtL7euhEa4TA29X2aUY5FwsR6dZYCdu3B9/z5iXnPfVU4Nvf\nBgBcJQRW+nIOCh5DqC8/Uvfb25AcJsFrId9QGmWjz53Fi4H168s7Xp9zhRD4XANu/GeVIJvZauEh\ntX1EDc6r60GbYsbs1cY4S9yciDWqLfQkKaY8XAV94sxYIrqeiJ4hokeMtC8T0VNEtFl9Pmz8dxER\nbSOix4joQ9lPoUBYyJdKE4Q8EAj4mepcjmi1aiHkAaDLqJdixuw4AgEfJ+T1LNhJQl67YvpEHuGn\na4pLCIQbAUSFYLxCSjlffb4PAET0DgRLDL5T7fM/icj/EayxseCjIzkyTINI9UA69dT2ZtwD+kkh\n8KTyi48MdWD42mcmb8Gcx2IyedYp7/JiSDxzKeVPiGieY3lLAdwipdwL4DdEtA3AewHcl7mGBRB2\n17xJhRg+/aGHqqoSU2eiZm96yhVCQNW2ffOb41JrvvtdrE4o43IhcKTaLnTsxbfB07zrU+L59XKk\nlUT0VwA2AjhPSvk8gAMAmIHNR1WaV4S9BhoxbZ+pDs8F/M1CYNnqQHy/hHg7epxHzRXKFv86AB/k\ne6ZWZBX0VwNYA0Cq78sBfAoAReSNHO0louUAlgPA3LlzM1aDYfziPiUMFz3xhJvHVAksa7W6tEft\nHTOc5LYbovZjLXHB0MrSrvNcbCSF2SdTmGIp5S4pZUtK+SqA6xCYZ4BAgz/IyHoggKctZVwrpVwo\npVw4c+bMLNXIjbVCBH7CZsOdcQZwxhm4WQg8rz4Mk8SiVguLWi1vhHwbZQ82tfmRwUGMDA7G7rZG\niMCrxrDd1xYflo50WRLR1XZf9FKCRDRHSrlD/fwYAO2RcyeAfyWirwL4YwAHA/B+6ahVu3YFG2bD\n3XgjAGDZjTe2t7swI+ExjKZGobVdXCF5VnoCRSwHWMCDJ7FEIroZwGIAQ0Q0CmAYwGIimo/ALLMd\nwKcBQEr5KBF9C8B/IFiK8q+llP73lEMPDb6NhU/aTEy0JyPhjDPwPcNneKl+9QUmLy3G9CfqJn1v\nQrYq8cXX/Ul1L3mzXm0WfBswttDsCVOhiVHaNvlGdIef1RM+rNqL8dS+V+U9vs6dk8kVvRqaN7Oq\ne2BEiC7bfZHntkOVPacB7VYVjQpqlpnQxCibJpP4emo8tVnAM2H0O9xVQuA5I90XzdkFM3yBttsP\nt1rg99Nm0GxBb8NcDZ5hMqCDfJmCsK5CHuiurxb6I0Lg9VGZc1p4fM6ePT2XwbjRn4Kehbw31NXs\noScKjQjRJSSvTPDO6sq/cSPwwx8G6atWefNwGCblJf2lLwFf/vLkDHktPF61M8P4uB/1KIFm2+gZ\nJke0TfnaULovAjpvbhYi8MFX6EBu69A9I/ZxlZ5HOGQmHWyjZ7xFmz3qFrp4jnbDnTULwzNmAADW\nPP98LmVHhXK2hf0Nv0UUxbLQMXTMnCNC+VjA+w9r9AyTgJ76/5KRVqUW/wch8NqUs1qLxLa+cWT6\n6Gjw7Wr+KcJPvUpyPh/W6Jn+ZMWK4Puaa3IrUgv44WOPBQBcrlb/Kh0lJL4CYHjx4iBNr1BWIbaV\nty6IChKY1r6fR8RJnx4SFdXFoxZgmBzIUcBrwtr7ebkfwREzXs19QUDY4arq4sILL6TfxzUeTRbN\n2GU2e9oHQ03eOPyuHcNYuEOZBZ4EupZObAfsytO0En5L2Lq1M5u6RGq3VN8xx6TfJyzM9e+wIHWJ\nFxPGxbsmrcCuUsCnmIXPNnqmWdREw0pL7YR83hRpgvHNvJMCttEz/UlNb1gAwM6dwffs2e2kNAJ+\nRAgML1oU/Fi/vt5tESavc4lSBGxlmxrzwEB5SkT4uGEy+P83qCcwTDmsEwJLitCqDQEPuAv5rvAF\nynb/t4OD+IrDvn1HGiGd1lyUF0nHyTDBiwU94z2RQecOPxwAILdsAZUsyJbsu2+h5afV4rvyGlqn\n1wO1aamxecUHuOUY74kMOvdIsARC1JJmhZPFm8SBkVD4BFctvitfE4ShzURS9vhLkgmlRtS79gzT\nEHrS4hPy1c50EyVUqxC0Ng8gX0jhdeNZzZl+IK0A8i3w2R1CYGkedTnpJIzcc0/7Z14CnikA34Q8\nkO9SgkR0PYAlAJ6RUh6u0i4F8BEArwB4AsCZUsoXiGgegC0AHlO73y+lXJGm7kzzSSukirWIp6dX\nIb9XCexnjbQihDw/DBzxUVvPmUQ/eiL6AIAxAN8wBP2fAvg/UsoJIvoHAJBSfkEJ+nU6nyvsR88w\n0VgFfIQrZmNo6FwIANnt/pY2cfWj3ycpg5TyJwB+F0r7gZRS1/h+ADkFqGaYZhMecHXNO0k7nz07\nUcivFaIdKXREiFTHLp2JifLXW877mC7lDQx0PmnIso+5e+Y9O3wKwK3G7zcT0YMIYkF9SUpZUQQo\nhvGPP3bIk5ct3gx7PLx2beZySsFFiOWt6ety8jLdePwG0lPNiGgVgtXUvqmSdgCYK6V8joiOAnA7\nEb1TSvlSxL7LASwHgLlz5/ZSDaZmaEH2UQB3qrThPXvaGuo9L7+ME9Ms3l4jznE4h7PU94Ennpjf\ngU84Ib+yisYmMD0WpL7jFOsmyvZORKcDWAHgeCnl7y37rQdwvpRyY1z5bKPvUyYm2gtRh+nngUQ9\nWDvYx21QCLYImEBtHyKFxrohopMAfAHAcaaQJ6KZAH4npWwR0VsAHAzg11mOwfQBxs11PoCpDRBs\nerm9I1qt9qQuPYs3iutU/qfRebgVIeBr61OfJ7YJWHmtGevxILKL183NABYDGAKwC0EI7IsADKKz\n8P39UsoVRPTnAP4OgTmnBWBYSvm9pEqwRt/HqIUz1hx3XCNMM0yNqFIw5zQu4KrRc5hiplrUzbZl\ncBCHsaBnysRHDTzlAyA390qGKZINg4PYMDiIb1ddkRxwcl/U5hwGjwuBx4tw+Qy7ONrcHnt0WcwN\nl7GCHl1BPThLpp85RmnxGdYi8g4n+3eMvb4swvb6+4SAfp8+t9UCtm8PfsybV2g9XhOVmIdJI8v+\nVWr3LsfssV4s6JlKqf0gocs6pAYPCREM1JbMlaqdo+JuLmq1sMjQFu9761vb6UXypqjyixC0LssO\n+qDZF0jjzs58ff4ogAV1FSB9wMXGtXpYCLyrjtcqRshHPcSqEPKA0tRVnfaPymAIuqIFvGaHap85\nVV/3hgt5oIGCfrjVat9gdwJYsHVr8Ie5mPPoKHAgR22ogouFwCsR6e/as6f0uhSNr28pz6tvp6ig\nY2PB97RpuddjTsELuDAdGifoAYcbjIV8ZVxkPIiHWy08qbX6vHyZqybDep5FoWcUvxpKT+XGWoCA\nb1PQAi7MZBop6JN4TAgc4qm21UTWCgGbv8BBTbsOHgh4zeoZMwAAI88/31Z+1ubg5VLVOAOTnb4U\n9Czky2XVnj0YmToVADC8axfWzJqVroAI80GT4t/YmLTAyfTpAIB1L7/cvTi5YZ68XLXLmKXMVTm0\nFwv5+sETppjCiPIr99Vu3RgWLwYAjPz0pxjetQsAsGPWrFwHPJ8VAjON8i5V1/n36L6+z6v0GXzN\nC6PQWDcMk4bhvXtxsSV4WWZOOSX4vv32TtrYGNbutx+Abs31CiHwOT3Y65FpJTdWqEXcrrkGIz8N\nooK/HgCGhgDk79UyM1TeBer3Y6EH+7fU96dzPTqTBdbomVxhLb5/uU6IrjDMV6u+sFv9Xt1qtQer\nvzd1Kj5Sdr+I85f3MRyCA6zRM5Uw/OKLwcbAAC5XdvlS2a3EitJmgWAA8o1quyse/NBQO/9dQuBk\nfiD1RDjW/mei2lO9UTn5vaWdyGQKa709MdF5i8si6BsQyhhgQc/kjTFgel4VglNP3zcE/apWC/dG\neZvohwLAQr4IooSn0uifArAgKq+Zf/fuzrZxPZ3Qrpume+jOnZ2wDjahr91jdT2izsEWNycvCni7\nYEHPNAsdSyZ0I/9f9X08T5YrjyhBpbTrJeEHq7msn8ZcEzf8IIh6MJjHixqLMWP3uGj9ZrqJmSfq\nARBOy7I+bBwZHgQcvZJpFlOmBJ/QTfCq+nQN3sZwlxC4y+fFtJuGjs6oBXBYaw4vqp20yPa0acHH\nNviujzUw0J3HPH7ckoZRxy4rGmaG47CgZ7IzNtbxca8Jl557rlO+k1stNudUQVwo3iyhes38Nnu7\n7aGSpY5hPLHrOwl6IrqeiJ4hokeMtDcQ0T1E9Lj6nqHSiYj+kYi2EdEviejIoirPVEdLCKzZbz+s\n2W8/YPNmPCZE4F534YXBBwgt2H/bAAAe/0lEQVRcILUbZBXs3Ans3IlnDc38AlfhPTSU3i7M9E6c\npp5FYzbz57XoeA1ddF0XB/8Agsl239ALhBPRVxCsD3sJEV0IYIaU8gtE9GEAfwPgwwDeB+BKKeX7\n4spn90q/0SFuzzWE5IgQbS3BOju14rgvepboGQD2Z+2caSC5uldKKX9CRPNCyUsRrCULADcBWI9g\nwfClCB4IEsD9RDSdiOZIKXe4VZ3xjXP37p2U5uQbX5aAP/vs4PtrX2snmQ+iOCGvZ3VecOWVnbcP\nHqytljy8TnopI69B1SLIWI9eaj5LC28p5Q4i0q7KBwB40sg3qtJY0NcVHzp4HIaAv0IJ7n2QHAfH\nnNw1cu652EfZ7/8EwK9V+jnGJJ86vrLXkqr7m81s5AMZ61FE7SkibZJ9iIiWA1gOAHPnzk19EI5A\nWS23CYGPOwrS4VYLOEYtFrhhQ2F1MgV33BvHcyrff8fk6fya+42yrlATvz7H/a0++CKYPaGX1til\nTTJENAfAMyp9FMBBRr4DATwd3llKeS2Aa4HARp/24CzkqyVJyAMhYZu3gFda9qVTp+L3UccLYcZm\nt+YzXtnNWDks4CskjQnGzMtvYV30IujvBHA6gEvU9x1G+koiugXBYOyLbJ9nMDoafOdk/9Zhj1+H\n5PGCESGCIF9IENpaUGze3JlcMzTUjuGeR4jf3JmYwIgKGDfcanUFOGsEaTTzpAlTYVweIj7HwEnh\n5unqdXMzgoHXIQC7AAwDuB1BgLq5AP4TwMellL8jIgJwFYCTEEQuPVNKuTGu/CxeN/8iBHYav4f1\ngKGPF8QHlEY9ctxxeI1Kuuicc5IFgm3w55prOkLFBe2quHVrOrfF0PHDQdPihLyrKSeRApfTY5he\ncPW64eiVfYgesKydSWLhQow8+CAANy1e05X37LO7Bm+ZmuCL14tncPRKxkrtBLxaTGPNgw8mCnjt\nLvk6WCZH1VjIS3VuFDqvq1T6yrpd1zQUIeR9NsvkTH3PsCHhQ/uFW5UwehrAH6u00xwEUxrzS1de\nvdBIHoQWOVkjRCVLGNJhh0WmHxSZyiTSR3KjGaabcDQ7Jh619ujzL7/s7TJvWmgPIHkQNDdbPNNf\nNECj7y/TDQv5dKhY3TMqroYNU3AnCfk1QmB4gYpsvjF2zJ+pI0nx4KNixpv2/PHx3lws83bTTFrg\nJMtCKw40Q9AztaJrIlVEetR/1jKOPRZYvz7fCiYxNpaLB47NBKRt7i8DuMj4X88FqMJsVBkus1Tj\nfscJaBehmrcffl6B1VLu0whBf7EQXTfExeqGuKifboiasMEMOyBEpECPE/JrQ+6VXUK+LM+MnNws\nbQLbNqjaVwKeyZVm2Ohz0rD6hm9/O/g+++zOkms1gG3xTGPJOF7QXzZ6FvLpOPXU7u+q2Lmze7k4\nC00T8Pp8/gTAiQ04H++oo8+9a30znltzVpjavbuz2PP4ePciv4yXjBxwQHKehgl5kxP33bfqKjST\nugn5NNiWWkzaraDqlI85rZ4DGdUCF8FdC+GulIqHp07FuyIGT08FoIxl+Ly5304ziAfDOFJyPHqG\n6XueFALXG7//zfAo+qBKO2zvXqy+/34AQawhzcjUqaU9yNhBob9pnqAfH29HNqyFNsi0abtMPvVU\n23Zv88zxhYNaLUDV+zXoFqTHmPVW8fiHW632a/eDKupkGbCA72+aJ+jZbFNbbAI9yu9eh1RwCaOQ\nN2uEwKsR6c7CVL163wlgQUy2Xh9yXodXZkqldoJev4K+YqQtAXBUVGfeuRP3qQG/Rcb/VwnR7ABQ\nDeJ4hDRjxaSVbIBgMD5NCOSMrN61CyOzZgEIHj4Xh337HUkS4r2+ybCAZzS1E/RaaxoRohO4KqTF\nmzfIoojOzkK+PkQJeaATGK2LQw8NFg0Buhc4OeaYnla4ilIuTNgswvhOPQS9YXfvIqOZ5goh6heq\nl+ki0mSj3WvD9LiM4UVqUZv2Sk579mBtVH/sE64Qor18o/nW4Pt4Sj+TWdAT0SEAbjWS3gLgfwCY\nDuAcAM+q9C9KKb+fuYZAlzuR7kijGV+XgRrGY/eJv/97AMCa4eGuKfk+L2YySQDpORaf/GQ79HCY\ny9X5jEX9OWVKX5tFfLzGTDy5hEAgIgHgKQRrxJ4JYExKeZnr/rYQCOFl4wD2pGFKQj0MynSBrDuX\nChG52It+aJ7H7Zg7riEQ8poZezyAJ6SUv+21oCeFwJNC4HuhRSSGWy2+4Zh8ufBC+38ZZh/2O5Er\neiFYaDrTELlL+0fNgE973cz8tn23b09XpmfkpdFfD+AXUsqriOjLAM4A8BKAjQDOk1I+H7HPcgDL\nAWDu3LlH/fY3v5lUrulW1xfLpeVFUQsqTExgjbJTLwRwcsK18NmcAwCPCYFDPK0bw7hQ2uLgRPQa\nBN5u75RS7iKiWQB2A5AA1gCYI6X8VFwZNtONXv/zglYr/wUAmozWPubNy7XYxsWd2bwZmD+/6low\nTDoMRa7M6JUnI9DmdwGA/gYAIroOwLqsBXe9CrKAd6dXAR+KkNc0Ab/GOJ/VL74YbHAEVKZobJEn\nS4i2mYeNfhmAm/UPIppj/PcxAI/kcAzg8MODj+KqHrxu+omHhMBDQqSzOYeE/D4IOkqckL9LCNwV\nuiZrhOgSqoVzzTVdP68UAlcKgdtCdXhVfb4IBAKehTxTBnmtLjUwkHqfnh4jRPQ6ACcC+LSR/BUi\nmo/AdLM99F86DBPE2i1bAACr1F+1sdWbJqcx5axXhGAxXufaYxuPPtp5krt0DO2HPjTUpcUnrWyk\nHwZA8HrX3m9kJPmYebJiRdfPcy31bsJbCcOkwe8VphqwSnstueUWjPzFXwCIF4pdJh01qYivFVN7\nXOROlbKpIht9cbDQKI/x8c4b1GWXJWq9a+pgt9eToU45pZM2MYErleeQTeNnmERqJpvqVVsmNfcq\ngfwLI22u+j6t1Wp7Nr0egF4Gw1mL901Qhge1tm2bnGdggAU8E4/vQjxD/fw23TDZGB0NvnNedctr\nIc8wfUgzTDdMNszIjXmwMOhHw9/8ZhAfRsNjKEzRVNHHGtivm3MmvlJ1p9F295Urg+/bb09VlxEh\nMPzoo8GPQw/t8sxp0o3AeEoVfayB/bp5Z+QbVXcaPXlKLWWXltlmGUApC3swDONAipg+zRH0ejLV\n5s3VC1efUJ3hO6uCGQj/sWpVol+8yUsAz0pmiuHoo4NvtXB6JGneiM05Kxs3BtsLF3bKGB8HXngh\n2I4zb+r5LlOmdB93/frge/Hi3t/UTSFdgryq92CsbqzDD+9cwJ077fmZ+jB9evCtr6vieTUgPCPq\nYbVixaTZsUxNsAnOqPQ0IQN6CS9g7pt3mIKcyuuPwVjdUBs38jT2grhViEoW4MbXvhaZPGPffSel\n6SiZ4wAuMgX9SScF33ffnXftmLxJEx4gSUCaQrQXYWrum7fWncfDJ83hsh3ND7S732IAb1dpc0JC\n6T6VJ2rtWKYz8Wm1ntkKdHWk/yy7QppTT41OD2n4QCcM8qSFaljA9ye9CuWqHSjiyFgnD8/Eke3b\nofQ1vC9GiL+xnNrY8bnTAHiH3rjsss7aquscAo6WEHEvLcM//nHVVWB8wRbWPK94U57f12HqUUtM\nXsPz9QA+4LDfv6rv1QXUqQn8uRmjxlxFR90oR4byP6yuw6sAjvDtLSmjZxHTQGwOBC6OBS7CuyYC\nXlOb2p4Xej1Pu2rRGiG6vE3KMulcp+KqvA3AB/fsCRJfeAGYPbvQ4zpjdtiIOPbHh9rnXaZPPcP0\ngpqI94cHH8Rr874Pa6ZxF43frfDDHwIARj70ocxF2FwJF515ZuYy03COPv6GDbhu6tTuNJ/RE6NC\nbmgj73wngFAIhO3bO/n4xmJcUS6Qry2i7Kyx38fHG+lO7PddecIJ7c22YMlJk1xzww0AgNWGd8fl\nQuC8r389+HHGGbkcp820adCesyNCYHjt2uCHnoB09tn5Hq9XLH7GkTFucl6ykPGfxq7h3EAhD+Sz\nZux2AC8DaAGYkFIuJKI3ALgVwDwEi498ImqBcI3pR79FdaBvhfI0IoiW8vEfOeCAdlLtzotfiZki\n6dXvHQj2z9pPXTR6lzqWdJ+4+tHnsZQgAHxQSjnfOOCFAO6VUh4M4F7124nD9u7FYWqAcLjVwnCr\nhdfnVMkk9hS97N3s2cDs2e0wwUCg3Y8IAYyP41Ih2mGDsX59ZyaeT2RYxoxpIKeeGnxCHlprhcDa\nXu6jXv3eTf/5LGXlOVjr0X2Sl0a/UEq520h7DMBiKeUOtYbseinlIbYyDiKSn4eabm9QO203BWEv\notcAuIhXaWIYJgVlavQSwA+IaBMRLVdps6SUOwBAfce6s+8DQD9Hy9biq+K8VgvntVqYi2AhkFcA\njAwOYmRwEBgfx61C4FZeAJ2pGQ8KgQct/fYq7s+VkYfq+H4p5dNE9EYA9xDRVped1ENhOQDMQDB9\n/b8Z/6d1n6wrZ6rzvFkI/EqljUyd2lmDlWFqxIKY+3blzJkl1qTGJNn3M9j/e9bopZRPq+9nAHwX\nwHsB7FImG6jvZyL2u1ZKuVBKufAt7343PvfUU3ir9ngBOjPY+oRlrRamAdDz9draPcM0hJFnn7X/\nOTERHXZXp7uE5HXNl7Rv2m39Oy+S7PsZ7P892eiJaCqAfaSUL6vtewD8HYDjATwnpbyEiC4E8AYp\n5d/ayuGlBLu5XIj2BDGG8ZqiQ2GYoQxcNdkyw3O4aN8F1qUsG/0sABuI6CEADwD431LKuwFcAuBE\nInocwInqN+MIC3mmLjxmeeu8LcIe/x1XG72pLU+Z0vGEcdVk0wrWXrRxz7xrbPRUQynlrwEcEZH+\nHAKtnsmRESHatvtbBwc74YPXrw8WQ2CYslBxkb6F7jhS2j34gr/5m0m7/GpSioWyBWeRx/PkIVDv\nhUf6hLArJgAM79nT2Fl8TI1YvNjP+R59QtkTpprBI49UXYNItCumGVh1ZOrU9ivupey2xlTF295W\ndQ0YB7zQ6N9KJC8B8HG2TSdysxLqv0IwyQoALmLtnmGaiW0pRfVNU6fWR6OfcdRRXgj5q4TADerj\nK8taLSxTE61eUZ8RFRUTgHUJPoYpBL1YDVMeegA4hf3fj5ECAz0b9LRWK1j1CADOP7+UY69stTrr\njHrOma1Wl+1ex+lvR8VkmDLwdbGXrAHO8gpTnFdQsyR/eke8MN2UMhi7c2fXYh96OvZedLsztgXm\npk1Bwvz5yWWbvrJmR9mwoR2x0roGag7cIETX2q7tGEGhc2aYWmC6O6YRlLblA9Me2xNPGRdcB2P7\nR9A3HfVAufqAA9qvaecUYbvPa81NhrGRJOjDwticrQoEfT5J6NvKcBHytodBBSG8XQV9fR5deaNW\nt8H8+flemKo0AqW5f2bTprYw/tHUqfhg3mMfLOCZojHfjtPMgtXC3Uzr9X6MemBkXb2qwreFvhH0\ndwmBk02hp9arxNhYvsLL5WJ++9vFmXIMU9M2AB8Mz/qLq5uLRpLH6zHDAMAppwTfd9/deVM0+54t\n7IHLtjaZzp7dKXvKFGDz5mB7YUgJNh8M5puBWabu+wMDne00ssPcL01IhxzoG0F/8m9/G/3H6Gjv\nyxOatnAXAViS3Tz12rQuHY4FPJMXt98efNtCEKTVgM2+qe+x8GIkLste2rR3nT4x4S7gw+fg8mZQ\nAH0j6G1roOayBq1+Srviq6cCw5SJXp1q/vzO2slhDd3UopMEo8v40eho8D00lPy2ECYuJo7toRRO\nSzt2kBP9I+g3b+404OGHt5O3CIHDHDXfu4TA0Wp7hrlPnJYQ1VEbutI8w2Ri+nT7/WBq40mYAt6m\nRZtedC7x3s3tuMFbV+GcdN8XpOX3j6C3uEm6CnkA2BfAL9X2cbNnd+yAcURdWBbyjE+YAq1Enlu6\nFACwv3kPljVgGXecNJ4+NXHFrEcti2TDhmhTijmYo7afAnDasccG6XGBnFRkP0yZEm2Pr5mvLtNw\n0poec2L/F1+cnBi+L9LcJ0UK33CZZShrOcoJljY2e7kW0OvX477jg4jLU9TvRJIGfFjIM0y3qSWt\nkE5jX48yxdjSw2XY3nSi8uXl0lnAA4slDhAfOGjx4ra55tNxZh79YHAx5zCMR9wxOIile/YEP6oy\nK6YVaq6DpjY7e3iczBTSZprpaeNanyLDHmQtMuuORHQQgG8AmA3gVQDXSimvJKIvAzgHgF4g8otS\nyu/3WtE8+J4Kb/ARQ2DfJgReVdunhW2FADAwEC/gAWDxYvxYrYf5biG6B2oZxlPuVffDPkA1An7F\niuD7mmvyLdfFzJI0+OuaXhN6qf0EgPOklL8gon0BbCKie9R/V0gpL+u9evnyEYsAPu097+n8UAL+\nBhUR8swYoX2HulHGARyg0u4HcHKvFWWYEji+aoUkbwHPWMks6KWUOwDsUNsvE9EWdORdbfj43r24\nV617eTzQfnLHCXgA+Bch8G61veippzh4GFM/9FvrKae0Z2o/f9ZZ/f1GGhfHpsZafS41J6J5ABYA\n+BmA9wNYSUR/BWAjAq3/+TyOUwgDA3hDht0+3Wp1BmZZyDN1RAuu229vb9991llYVmGVJuEyMJnn\n4GUephsPHwo9LzxCRNMAfAfAZ6WULwG4GsBbAcxHoPFfbtlvORFtJKKNzz77bFSWUhgRAusArHPI\ne4MQwSDO+HhwMRcvDj5pB2B1vA2G8QFDKC37y7+ssCIRuMyITbkIRyJmvJss5FWXHOc29BSmmIj+\nCIGM/Hcp5Vcj/p8HYJ2U8vDwfyZVhCleq+zrEzDityeR05N6RAgMn3giAOCGe4JhjSRTkTM33gic\ncUY+ZTEM4zWFLw5ORATg6wC2mEKeiOYY2T4GwLsVt282lgp0FvJAbk/q4VYriNh3993YA2CP/uOR\nRzItUL7HOJ/LzzorlzoyfUpYi9R98uiju5IvFgIXCwFcckmJlWOyklmjJ6JjAPwUwMNA20PxiwCW\nITDbSADbAXxaDdxaKUujHzHcyVYnCPg1Ku8SAL9Tace/5z249ec/BwCctnevd3Y4hkmD7uOrZ8wA\ntm4FAKydNQurjHvjJpXn9Bdf5LUIPKTwhUeklBsAUMRfXvjMhxkxtN44IT8SWhh8QavV1nJuGxzs\nvAKZQt4Io9AVJG3r1k7UTCO29eVCdC1fyDBVEHUfrAqlnc79tBHUTiXVtvVwh7QxksJMs1aIdoN0\nla8E9Mdt+xthFLqCpJkhkDdubC92MJhc7cysEQKrzzwTAHDfDTdgEd+oTAHcqu6r07h/1YLaCfqT\nUuQdEaKtgcdp8foV9m0AlhXVcY0VbVYWeHOY57noa18r7DhpuEIIfE7V6yohCj3/snhM9ZlDWq1O\njHPbmgc+sXt3O/b71eocPuNwb3TdP4sX41fF1ZApgNoJ+qNSCokkW7yp8bsI+agwCl088khXvHsG\nbSEPFPuQK5NDzPOog4DX6AU+ADzjkD3y/lm/HqtzqMpedS8NNqRP+EztBH0a4kw1aUw6JlYBrzG9\nFkZHcdeb3gQA3evVmksPFsgaIRIfdEz/ksrjrECeE6I7Jj2TO7UT9FpAfwDAT4z0t6vvZeeckxhD\nY01W90oXzAVODjywW8BrSpxJq8/1v6rfzwCV2u3vE6J54wbKYyWXZSn7kP137aq6Cm4kLQPoMT1N\nmMqLst0rAX+0mb6jpLeZMtH96qNQXlp1YfHi4NtljYUCYNNN7xTuXukDm1RHWQc3wc3CvXpGDuiO\ne9d1TSI0pouFwEU1uW53Igj4VBsqEvCaV5OzMDlRa0GvB2aPistUVMzrplDy2peRgl0T8UpcByGv\nzyk8B4OJxymSSxVrs8YtRFR2XeJIEQun8aYbHe6gMLfJmrFDCMzRbTF7dscLI0PohdzZvTv4NjxD\nUuFh1EDGzvPq3iwtLHJ4VSkbPgr1KCYmQIODxca68ZkRIdra1a/Uhwn4FQCMjQWf6dMzx9fJyqY4\nrXdoqCPkVR0vDuePiVQ4MljkVDQmb6aoT3kHdDya7mM+C3kgVf08P5NsLKm6Ah6hBavWUdYDOE7H\nLClTi1dmmnVIMLVpVB0nmW60tlXFKz2TK6/lt+zSaORdYk6q+kCF9fAB7YL6uSeeAAC8b968zp89\nCEmXGa6j6iHz9cxHScckXx71MHhscLB7ghPD9BmNFPQmWtB90EjbJATuVtuuMXNqw6GHYuTxxyen\nmwI+B1xmuB6o8wjRHrCcZIrJSsRDatIi7ioPC/n8+Zm6ju/jtq0FjRf0US6VR7VabuaDOrJ1K6Bu\nwuFWC5fn7AnSDip32GEd08/4OK5Si6mvTPBAqYMXDZMMC/h60XhB3w9cIQResvyXdzjkyDegKVM6\nGr7hMjm8d2+wkeOSaIwfXKoe5BcY/WGNEFi9aFHwY8OGdvqVKu+5/HCoDBb0VXHZZcH3+ef3XNTn\nWq22Bj3cagE//GHPZUYyMdGJ1GiYgi4VAr+Pyq/NKzxg2jguiBDatrhK5+oHftFoJcPsby59z3TL\nHRuLXmDFlh5XpuvxS6CwWhDRSQCuBCAAfE1KyWuOmWQR8EZni9PiccIJmasVy8BApK3fvOkTo3sy\njeY6df3PMa6/dnt9DQo23eURg8YmzHfvTifoPRHwmkJqQ0QCwD8DOBHAKICfE9GdUsr/KOJ4dSTT\nwg1GR+vS4vfuxRZPfMjnJ2dhGsw5Ef259NAjvUyca+iku6ImTL0XwDYp5a+llK8AuAXA0oKOVUtO\na7XSr85z4414Qgg8YUwIAwAMDOCwVqt7dauKOKjVwkEe1IPxED3zuWjSCuqBgUDAxwn5ItYc0MeM\n+z8nihL0BwB40vg9qtIYC7e5eMecdBLuRBA8640AXq8+DFMLsoa2KIOkmbBFaPlJgj7pmOFYUTEU\n9Y4StWh4V1AdIloOYLn6uZeE8CDYSiRDAMpRRdK5Qrbr9Xm/gmmV117p4Hqlx9e6cb06vMklU1GC\nfhTAQcbvAwE8bWaQUl4L4FoAIKKNLoF5qsDXunG90sH1So+vdeN6paco083PARxMRG8motcA+CQC\niwPDMAxTMoVo9FLKCSJaCeDfEbhXXi+lfLSIYzEMwzDxFOZHJKX8PoDvO2a/tqh65ICvdeN6pYPr\nlR5f68b1SokXC48wDMMwxdHIhUcYhmGYDpULeiI6iYgeI6JtRHRhhfU4iIh+RERbiOhRIjpXpX+Z\niJ4ios3q8+EK6radiB5Wx9+o0t5ARPcQ0ePqe0bJdTrEaJPNRPQSEX22qvYiouuJ6BkiesRIi2wj\nCvhH1ed+SURHllyvS4loqzr2d4loukqfR0R/MNqusIWOLfWyXjsiuki112NE9KGS63WrUaftRLRZ\npZfZXjb5UHkfc0JKWdkHwUDtEwDegiAUxkMA3lFRXeYAOFJt74tg1b13APgygPMrbqftAIZCaV8B\ncKHavhDAP1R8HXci8OmtpL0QrDFzJIBHktoIwIcB3IVgvsfRAH5Wcr3+FMCA2v4Ho17zzHwVtFfk\ntVP3wUMABgG8Wd2zoqx6hf6/HMD/qKC9bPKh8j7m8qlao/cmVIKUcoeU8hdq+2UAW+D3bN6lAG5S\n2zcBOKXCuhwP4Akp5W+rqoCU8icAfhdKtrXRUgDfkAH3A5hORHPKqpeU8gdSSj0l8n4E80xKxdJe\nNpYCuEVKuVdK+RsA2xDcu6XWi4gIwCcA3FzEseOIkQ+V9zEXqhb0XoZKIKJ5ABYA+JlKWqlev64v\n20SikAB+QESbKJhRDACzpJQ7gKATIoiKUBWfRPfNV3V7aWxt5FO/+xQCzU/zZiJ6kIh+TETHVlCf\nqGvnS3sdC2CXlNJcQq309grJhzr0scoFfWKohLIhomkAvgPgs1LKlwBcDeCtCAIz7kDw6lg275dS\nHgngZAB/TUTeLIVLwYS4jwK4TSX50F5JeNHviGgVgnXbv6mSdgCYK6VcAODzAP6ViMoMZ2S7dl60\nF4Bl6FYoSm+vCPlgzRqRVplsq1rQJ4ZKKBMi+iMEF/GbUsp/AwAp5S4pZUtK+SqA61DQK2scUsqn\n1fczAL6r6rBLvwqq72fKrpfiZAC/kFLuUnWsvL0MbG1Ueb8jotMBLAHwF1IZdZVp5Dm1vQmBLfzt\nZdUp5tr50F4DAP4MwK06rez2ipIP8LiPmVQt6L0JlaDsf18HsEVK+VUj3bSrfQxAqcHXiGgqEe2r\ntxEM5D2CoJ1OV9lOB3BHmfUy6NKyqm6vELY2uhPAXynPiKMBvKhfv8uAgkV5vgDgo1LK3xvpMylY\nywFE9BYABwP4dYn1sl27OwF8kogGiejNql4PlFUvxQkAtkopR3VCme1lkw/wtI9NosqRYNkZnf4V\ngqfxqgrrcQyCV6tfAtisPh8G8L8APKzS7wQwp+R6vQWBx8NDAB7VbQRgfwD3Anhcfb+hgjZ7HYDn\nAOxnpFXSXggeNjsA/D8E2tRZtjZC8Fr9z6rPPQxgYcn12obAfqv72TUq75+ra/wQgF8A+EjJ9bJe\nOwCrVHs9BuDkMuul0m8EsCKUt8z2ssmHyvuYy4dnxjIMwzScqk03DMMwTMGwoGcYhmk4LOgZhmEa\nDgt6hmGYhsOCnmEYpuGwoGcYhmk4LOgZhmEaDgt6hmGYhvP/AXqsjClbQPFoAAAAAElFTkSuQmCC\n",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# do we get a familiar-looking residue map?\n",
+ "fig, ax = freq.residue_contacts.plot()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Something like this is supposed to shut down the workers and the scheduler\n",
+ "# I get it to shut down workers, but not scheduler... and does it all with lots of warnings\n",
+ "#client.loop.add_callback(client.scheduler.retire_workers, close_workers=True)\n",
+ "#client.loop.add_callback(client.scheduler.terminate)\n",
+ "#client.run_on_scheduler(lambda dask_scheduler: dask_scheduler.loop.stop())"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 2",
+ "language": "python",
+ "name": "python2"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 2
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython2",
+ "version": "2.7.14"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/optional_installs.txt b/optional_installs.txt
index 6ccafc3..9dba780 100644
--- a/optional_installs.txt
+++ b/optional_installs.txt
@@ -1 +1,3 @@
matplotlib
+dask
+distributed