├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── tests.yml │ └── wheels.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── codecov.yml ├── demo-anti-aliasing.ipynb ├── demo-numpy.ipynb ├── demo-pydata-sparse.ipynb ├── demo-python-graphblas.ipynb ├── demo.ipynb ├── doc ├── images │ ├── sparkline_aa.png │ ├── spy.png │ └── triple_product.png └── matrices │ └── email-Eu-core.mtx.gz ├── matspy ├── __init__.py ├── adapters │ ├── __init__.py │ ├── graphblas_driver.py │ ├── graphblas_impl.py │ ├── numpy_driver.py │ ├── numpy_impl.py │ ├── scipy_driver.py │ ├── scipy_impl.py │ ├── sparse_driver.py │ └── sparse_impl.py └── spy_renderer.py ├── pyproject.toml ├── requirements-dev.txt ├── tests ├── __init__.py ├── test_basic.py ├── test_graphblas.py ├── test_heatmap.py ├── test_numpy.py ├── test_scipy.py ├── test_sparkline.py └── test_sparse.py └── tools └── make_readme_links_absolute.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | # Ignore demo notebooks from the GitHub language bar 2 | demo* linguist-vendored 3 | 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | # Check for updates to GitHub Actions every week 7 | # See https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot 8 | interval: "weekly" -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | name: Tests on ${{ matrix.os }} - ${{ matrix.python-version }} 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | python-version: ['3.7', '3.11', 'pypy-3.9'] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install base dependencies 26 | run: pip install numpy matplotlib pytest pytest-subtests html5lib 27 | 28 | - name: Test minimums 29 | run: pytest 30 | 31 | - name: Install optional dependencies 32 | # --only-binary disables compiling the package from source if a binary wheel is not available, such as old Python or PyPy 33 | run: | 34 | echo "" 35 | echo "=== Install SciPy =============================" 36 | pip install --only-binary ":all:" scipy || true 37 | echo "" 38 | echo "=== Install python-graphblas ==================" 39 | pip install --only-binary ":all:" python-graphblas || true 40 | echo "" 41 | echo "=== Install PyData/Sparse =====================" 42 | pip install --only-binary ":all:" sparse || true 43 | 44 | - name: Test without Jupyter 45 | run: pytest 46 | 47 | - name: Install Jupyter 48 | if: ${{ !contains(matrix.python-version, 'pypy') }} 49 | run: pip install jupyter 50 | 51 | - name: Test with Jupyter 52 | if: ${{ !contains(matrix.python-version, 'pypy') }} 53 | run: pytest 54 | 55 | - name: Test with Coverage 56 | if: ${{ contains(matrix.os, 'ubuntu') }} 57 | run: | 58 | pip install pytest-cov 59 | pytest --cov=matspy --cov-report term --cov-report=xml 60 | 61 | - name: Upload Coverage to Codecov 62 | if: ${{ contains(matrix.os, 'ubuntu') }} 63 | uses: codecov/codecov-action@v4 64 | with: 65 | fail_ci_if_error: true 66 | verbose: true 67 | token: ${{ secrets.CODECOV_TOKEN }} 68 | -------------------------------------------------------------------------------- /.github/workflows/wheels.yml: -------------------------------------------------------------------------------- 1 | name: wheels 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | # Manual dispatch allows optional upload of wheels to PyPI 7 | upload_dest: 8 | type: choice 9 | description: Upload wheels to 10 | options: 11 | - No Upload 12 | - PyPI 13 | - Test PyPI 14 | release: 15 | types: 16 | - published 17 | 18 | concurrency: 19 | group: ${{ github.workflow }}-${{ github.ref }} 20 | cancel-in-progress: true 21 | 22 | permissions: 23 | # For PyPI Trusted Publisher 24 | id-token: write 25 | 26 | jobs: 27 | publish_PyPI: 28 | name: Publish to PyPI 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | - name: Make README links absolute 34 | run: | 35 | tools/make_readme_links_absolute.sh > README-PyPI.md 36 | rm README.md 37 | mv README-PyPI.md README.md 38 | 39 | - name: Build 40 | run: pipx run build 41 | 42 | - name: Check 43 | run: pipx run twine check dist/* 44 | 45 | - uses: pypa/gh-action-pypi-publish@release/v1 46 | name: Upload to PyPI 47 | if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.upload_dest == 'PyPI') 48 | with: 49 | verbose: true 50 | 51 | - uses: pypa/gh-action-pypi-publish@release/v1 52 | name: Upload to Test PyPI 53 | if: github.event_name == 'workflow_dispatch' && github.event.inputs.upload_dest == 'Test PyPI' 54 | with: 55 | verbose: true 56 | repository-url: https://test.pypi.org/legacy/ 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2023 Adam Lugowski 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![tests](https://github.com/alugowski/matspy/actions/workflows/tests.yml/badge.svg)](https://github.com/alugowski/matspy/actions/workflows/tests.yml) 2 | [![codecov](https://codecov.io/gh/alugowski/matspy/graph/badge.svg?token=m2xJcl5iAQ)](https://codecov.io/gh/alugowski/matspy) 3 | [![PyPI version](https://badge.fury.io/py/matspy.svg)](https://pypi.org/project/matspy/) 4 | [![Conda Version](https://img.shields.io/conda/vn/conda-forge/matspy.svg)](https://anaconda.org/conda-forge/matspy) 5 | 6 | # MatSpy 7 | 8 | Sparse matrix spy plot and sparkline renderer. 9 | 10 | ```python 11 | from matspy import spy 12 | 13 | spy(A) 14 | ``` 15 | 16 | Spy Plot 17 | 18 | Supports: 19 | * **SciPy** - sparse matrices and arrays like `csr_matrix` and `coo_array` [(demo)](demo.ipynb) 20 | * **NumPy** - `ndarray` [(demo)](demo-numpy.ipynb) 21 | * **[Python-graphblas](https://github.com/python-graphblas/python-graphblas)** - `gb.Matrix` [(demo)](demo-python-graphblas.ipynb) 22 | * **[PyData/Sparse](https://sparse.pydata.org/)** - `COO`, `DOK`, `GCXS` [(demo)](demo-pydata-sparse.ipynb) 23 | 24 | Features: 25 | * Simple `spy()` method plots non-zero structure of a matrix, similar to MatLAB's spy. 26 | * Sparklines: `to_sparkline()` creates small self-contained spy plots for inline HTML visuals. 27 | * FAST and handles very large matrices. 28 | 29 | See a [Jupyter notebook demo](demo.ipynb). 30 | 31 | ```shell 32 | pip install matspy 33 | ``` 34 | ```shell 35 | conda install matspy 36 | ``` 37 | 38 | ## Methods 39 | * `spy(A)`: Plot the sparsity pattern (location of nonzero values) of sparse matrix `A`. 40 | * `to_sparkline(A)`: Return a small spy plot as a self-contained HTML string. Multiple sparklines can be automatically to-scale with each other using the `retscale` and `scale` arguments. 41 | * `spy_to_mpl(A)`: Same as `spy()` but returns the matplotlib Figure without showing it. 42 | * `to_spy_heatmap(A)`: Return the raw 2D array for spy plots. 43 | 44 | ## Examples 45 | 46 | See the [demo notebook](demo.ipynb) for more. 47 | 48 | #### Save spy plot as a PNG image 49 | 50 | ```python 51 | fig, ax = matspy.spy_to_mpl(A) 52 | fig.savefig("spy.png", bbox_inches='tight') 53 | ``` 54 | 55 | ## Arguments 56 | 57 | All methods take the same arguments. Apart from the matrix itself: 58 | 59 | * `title`: string label. If `True`, then a matrix description is auto generated. 60 | * `indices`: Whether to show matrix indices. 61 | * `figsize`, `sparkline_size`: size of the plot, in inches 62 | * `shading`: `binary`, `relative`, `absolute`. 63 | * `buckets`: spy plot pixels (longest side). 64 | * `dpi`: determine `buckets` relative to figure size. 65 | * `precision`: For numpy arrays, only plot values with magnitude greater than `precision`. Like [matplotlib.pyplot.spy()](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.spy.html)'s `precision`. 66 | 67 | ### Overriding defaults 68 | `matspy.params` contains the default values for all arguments. 69 | 70 | For example, to default to binary shading, no title, and no indices: 71 | 72 | ```python 73 | matspy.params.shading = 'binary' 74 | matspy.params.title = False 75 | matspy.params.indices = False 76 | ``` 77 | 78 | ## Jupyter 79 | 80 | `spy()` simply shows a matplotlib figure and works well within Jupyter. 81 | 82 | `to_sparkline()` creates small spy plots that work anywhere HTML is displayed. 83 | 84 | # Fast 85 | All operations work with very large matrices. 86 | A spy plot of tens of millions of elements takes less than half a second. 87 | 88 | Large matrices are downscaled using two native matrix multiplies. The final dense 2D image is small. 89 | 90 | triple product 91 | 92 | Note: the spy plots in this image were created with `to_sparkline()`. Code in the [demo notebook](demo.ipynb). 93 | 94 | # Spy Plot Anti-Aliasing 95 | One application of spy plots is to quickly see if a matrix has a noticeable structure. 96 | Aliasing artifacts can give the false impression of structure where none exists, 97 | such as moiré or even a false grid pattern. 98 | 99 | MatSpy employs some simple methods to help eliminate these effects in most cases. 100 | 101 | ![sparkline AA](doc/images/sparkline_aa.png) 102 | 103 | See the [Anti-Aliasing demo](demo-anti-aliasing.ipynb) for more. 104 | 105 | # How to support more packages 106 | 107 | Each package that MatSpy supports implements two classes: 108 | 109 | * `Driver`: Declares what types are supported and supplies an adapter. 110 | * `get_supported_type_prefixes`: This declares what types are supported, as strings to avoid unnecessary imports. 111 | * `adapt_spy(A)`: Returns a `MatrixSpyAdapter` for a matrix that this driver supports. 112 | * `MatrixSpyAdapter`. A common interface for extracting spy data. 113 | * `describe()`: Describes the adapted matrix. This description serves as the plot title. 114 | * `get_shape()`: Returns the adapted matrix's shape. 115 | * `get_spy()`: Returns spy plot data as a dense 2D numpy array. 116 | 117 | See [matspy/adapters](matspy/adapters) for details. 118 | 119 | You may use `matspy.register_driver` to register a `Driver` for your own matrix class. -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 70..90 3 | status: 4 | project: 5 | default: 6 | informational: true 7 | patch: 8 | default: 9 | informational: true 10 | -------------------------------------------------------------------------------- /demo-numpy.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": { 7 | "ExecuteTime": { 8 | "end_time": "2023-08-31T00:41:41.137570Z", 9 | "start_time": "2023-08-31T00:41:41.017838Z" 10 | }, 11 | "jupyter": { 12 | "source_hidden": true 13 | } 14 | }, 15 | "outputs": [], 16 | "source": [ 17 | "import numpy as np" 18 | ] 19 | }, 20 | { 21 | "cell_type": "code", 22 | "execution_count": 2, 23 | "metadata": { 24 | "ExecuteTime": { 25 | "end_time": "2023-08-31T00:41:41.322466Z", 26 | "start_time": "2023-08-31T00:41:41.135934Z" 27 | }, 28 | "jupyter": { 29 | "source_hidden": true 30 | } 31 | }, 32 | "outputs": [], 33 | "source": [ 34 | "import scipy\n", 35 | "A = scipy.io.mmread(\"doc/matrices/email-Eu-core.mtx.gz\").todense()" 36 | ] 37 | }, 38 | { 39 | "cell_type": "markdown", 40 | "metadata": { 41 | "ExecuteTime": { 42 | "end_time": "2023-08-22T23:04:28.653403Z", 43 | "start_time": "2023-08-22T23:04:28.580379Z" 44 | } 45 | }, 46 | "source": [ 47 | "\n", 48 | "Now view the entire matrix as a spy plot:" 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": 3, 54 | "metadata": { 55 | "ExecuteTime": { 56 | "end_time": "2023-08-31T00:41:41.608106Z", 57 | "start_time": "2023-08-31T00:41:41.519592Z" 58 | } 59 | }, 60 | "outputs": [ 61 | { 62 | "data": { 63 | "image/png": "iVBORw0KGgoAAAANSUhEUgAAAWYAAAFgCAYAAACIf9MLAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8pXeV/AAAACXBIWXMAAA9hAAAPYQGoP6dpAAB1G0lEQVR4nO2de5xN9f7/X+M2Y1xGg5mhhpBcivhRmihC5PYtOaeUMjlRTRQphCLJJU5FN93OUaejy9GJ4lQI6SYkciunpFCGE82MUW4z6/fH9trNfs98fNbae8/M3ub9fDw8lr3WZ31ua89er/Ve78/7HeM4jgNFURQlYihX2h1QFEVRAtEfZkVRlAhDf5gVRVEiDP1hVhRFiTD0h1lRFCXC0B9mRVGUCEN/mBVFUSIM/WFWFEWJMPSHWVEUJcLQH2YlrOTm5mLw4MFISUlBTEwMRowYgR9++AExMTF46aWXSrt7EcvNN9+Ms88+u7S7oUQIEfPDnJubi4kTJ+LKK69EYmKi9Q/566+/xpVXXomqVasiMTERN910E/73v/8VKpefn48ZM2agQYMGiIuLQ8uWLfHaa68VKnfzzTcjJiam0L+mTZvq+AzcfPPN6NSpU8C+qVOn4qWXXkJGRgZeeeUV3HTTTUHX74Vt27bhwQcfxA8//FDk8fz8fMyZMwetWrVC5cqVUbNmTXTu3BlfffWVsc558+YhJiYGVatWLXSsU6dOuPnmm8PU+9OPZ555Rm/EIVChtDtAfvnlFzz00EOoV68eLrjgAnz44YfGsnv27MFll12GhIQETJ06Fbm5ufjrX/+KzZs3Y+3atahUqZK/7Pjx4zF9+nQMGTIEF154Id5++23ccMMNiImJQf/+/QPqjY2NxYsvvhiwLyEhQcfngRUrVuDiiy/GxIkT/ftMP5bhZNu2bZg0aRI6depUpPL8y1/+gnnz5mHgwIEYNmwYDh8+jA0bNmD//v1F1pebm4vRo0ejSpUqxdzz05NnnnkGtWrV0ptXsDgRwpEjR5y9e/c6juM469atcwA4c+fOLbJsRkaGU7lyZefHH3/071u2bJkDwHnuuef8+/bs2eNUrFjRGTp0qH9ffn6+c+mllzpnnXWWc+LECf/+9PR0p0qVKq77+o9//MN4/J133nEyMzOjdnxuSU9Pdzp27Biwr0GDBk6vXr0C9u3cufOU4w0H8+fPdwA4K1euLHTsjTfecAA4b731luv6xowZ4zRp0sQZMGBAkfPWsWNHJz09PYQeB5Kenu7Ur18/bPWVNuedd16h70YkkZ+f7/z2229FHvv999+dvLy8Eu5RIBFjyoiNjUVKSoqrsv/+97/Ru3dv1KtXz7+va9euOPfcc/Gvf/3Lv+/tt9/G8ePHcccdd/j3xcTEICMjA3v27MHq1asL1Z2Xl4ecnJxTtv/mm29i4MCBeOKJJwodW7JkCfr164dHH300ascXDB9++CFiYmKwc+dO/Oc///GbSk6lllesWIFLL70UVapUQY0aNXDVVVfh66+/Dijz448/4o477kCTJk38Jog///nPAfW+9NJL+POf/wwAuPzyy/1t86nksccew0UXXYS+ffsiPz8fhw8fPuVYvv32Wzz++ON47LHHUKFC6A+V//znP9GmTRtUrlwZiYmJ6N+/P3bv3m09Lz8/H7NmzcJ5552HuLg4JCcn47bbbsOvv/4aUO7ss89G79698eGHH6Jt27aoXLkyWrRo4R//W2+9hRYtWiAuLg5t2rTBhg0bCrX1zTff4E9/+hMSExMRFxeHtm3b4p133gko89JLLyEmJgaffvopRo4cidq1a6NKlSro27dvgJnt7LPPxtatW7Fq1Sr/taDJ6/jx45g0aRIaN26MuLg41KxZEx06dMCyZcs8zqqPuXPnonPnzkhKSkJsbCyaN2+OOXPmFCrHOVqyZIl/jp577jn/9/b111/H/fffjzPPPBPx8fHIycnBwYMHce+996JFixaoWrUqqlevjh49egSYv3Jzc1GlShUMHz68UJt79uxB+fLlMW3aNM/jipgfZrf89NNP2L9/P9q2bVvo2EUXXRTwpduwYQOqVKmCZs2aFSrH4wX57bffUL16dSQkJCAxMRFDhw5Fbm5uoXYGDBiAO+64AyNGjMA//vEP//7PPvsM11xzDS699FJMnjw5ascXDM2aNcMrr7yCWrVqoVWrVnjllVfwyiuvoHbt2kWW/+CDD9C9e3fs378fDz74IEaOHInPPvsM7du3D/jRXbduHT777DP0798fTzzxBG6//XYsX74cnTp1wm+//QYAuOyyy3DXXXcBAMaNG+dvu1mzZsjJycHatWtx4YUXYty4cUhISEDVqlXRsGHDgJtcQUaMGIHLL78cPXv2DHlepkyZgoEDB6Jx48Z47LHHMGLECCxfvhyXXXYZsrKyTnnubbfdhlGjRqF9+/aYPXs2Bg0ahHnz5qF79+44fvx4QNnvvvsON9xwA/r06YNp06bh119/RZ8+fTBv3jzcfffduPHGGzFp0iTs2LED1157LfLz8/3nbt26FRdffDG+/vpr3HfffXj00UdRpUoVXH311ViwYEGhft1555346quvMHHiRGRkZGDRokUYNmyY//isWbNw1llnoWnTpv5rMX78eADAgw8+iEmTJuHyyy/HU089hfHjx6NevXr48ssvg5rfOXPmoH79+hg3bhweffRRpKam4o477sDTTz9dqOz27dtx/fXX44orrsDs2bPRqlUr/7HJkyfjP//5D+69915MnToVlSpVwvfff4+FCxeid+/eeOyxxzBq1Chs3rwZHTt2xM8//wwAqFq1Kvr27Ys33ngDeXl5Ae299tprcBwHAwYM8D6wUtXrBk71qM9jRZkSRo0a5QBwjhw54jiO4/Tq1ctp2LBhoXKHDx92ADj33Xeff999993njBkzxnnjjTec1157zUlPT3cAOO3bt3eOHz9eqI68vDzn+uuvdypUqOC8/fbbzqZNm5wzzjjDufDCC51Dhw5F/fiCpX79+q5MGa1atXKSkpKcAwcO+Pd99dVXTrly5ZyBAwf69xX1uLl69epCc2QyZXz55ZcOAKdmzZpOcnKy88wzzzjz5s1zLrroIicmJsZ57733AsovXrzYqVChgrN161bHcUIzAf3www9O+fLlnSlTpgTs37x5s1OhQoWA/dKU8fHHHzsAnHnz5gWc+/777xfaX79+fQeA89lnn/n3LVmyxAFQyCT23HPPFZqnLl26OC1atPB/rxzH96h/ySWXOI0bN/bvmzt3rgPA6dq1q5Ofn+/ff/fddzvly5d3srKy/PtMpowLLrig0PcjFIr6fnTv3r3Q3wXn6P333w/Yv3LlSgeA07Bhw0J1HTlypJBJY+fOnU5sbKzz0EMP+fdxruV3qWXLlkGbc6JOMf/+++8AfKYBSVxcXECZ33//3VU5AJg2bRqmT5+Oa6+9Fv3798dLL72EKVOm4NNPP8Wbb75ZqI5y5crh5ZdfxhVXXIHrrrsOXbt2RUpKCt57770i3+JH2/iKk71792Ljxo24+eabkZiY6N/fsmVLXHHFFXj33Xf9+ypXruz///Hjx3HgwAGcc845qFGjhiuVxSeCAwcO4O2330ZGRgZuuOEGLF++HDVr1sTDDz/sL3vs2DHcfffduP3229G8efOQx/nWW28hPz8f1157LX755Rf/v5SUFDRu3BgrV640njt//nwkJCTgiiuuCDi3TZs2qFq1aqFzmzdvjrS0NP/ndu3aAQA6d+4cYBLj/u+//x4AcPDgQaxYsQLXXnstDh065G/nwIED6N69O7799lv89NNPAW3deuutiImJ8X++9NJLkZeXhx9//NE6JzVq1MDWrVvx7bffWsu6oeD3Izs7G7/88gs6duyI77//HtnZ2QFlGzRogO7duxdZT3p6ekBdgO9vsFw5309kXl4eDhw4gKpVq6JJkyYB372uXbuibt26mDdvnn/fli1bsGnTJtx4441BjSvqfpg5eUePHi107MiRIwFlKleu7KqcibvvvhvlypXDBx98UOTxihUrYvbs2Th+/Dj279+PKVOmoGbNmu4HUwSRNL7ign/ATZo0KXSsWbNm+OWXX/x24N9//x0TJkxAamoqYmNjUatWLdSuXRtZWVmF/vCKgnPQoEED/48S4HsE7dOnD9auXYsTJ04AAB5//HH88ssvmDRpUshjBHy2asdx0LhxY9SuXTvg39dff230COG52dnZSEpKKnRubm5uoXML/vgCf3jbpKamFrmfdurvvvsOjuPggQceKNQOPWtsbZ1xxhkBdZ6Khx56CFlZWTj33HPRokULjBo1Cps2bbKeZ+LTTz9F165d/e8pateujXHjxgFAkT/MJoo6lp+fj8cffxyNGzcO+O5t2rQpoO5y5cphwIABWLhwod+8Nm/ePMTFxfnffXglYtzl3FKnTh0APtUl2bt3LxITE/0qsk6dOli5ciUcxwm4w/PcunXrnrItvmw6ePBgkccPHDiAq6++GrVq1UJKSgqGDBmCJk2ahKS2Iml8kcCdd96JuXPnYsSIEUhLS0NCQoLfFbCgndQE5yA5ObnQsaSkJBw/ftx/E3j44Ydxxx13ICcnx/+CNDc3F47j4IcffkB8fDySkpJc9z0/Px8xMTF47733UL58+ULHT/VklZ+fj6SkpAAVVhBpuy+q/lPtd05mlOMc3nvvvUY1ec4553iq81Rcdtll2LFjB95++20sXboUL774Ih5//HE8++yzGDx4sPX8guzYsQNdunRB06ZN8dhjjyE1NRWVKlXCu+++i8cff7zQ9+NUQqWoY1OnTsUDDzyAv/zlL5g8eTISExNRrlw5jBgxolDdAwcOxMyZM7Fw4UJcf/31ePXVV9G7d++g3VGj7of5zDPPRO3atfHFF18UOrZ27doAg36rVq3w4osv4uuvvw74sVyzZo3/+Kngo11RL7Byc3PRs2dP/Pzzz1i1ahXq1KmDDh064IorrsCnn34a9CquSBlfcVK/fn0Avpcxkm+++Qa1atXy+w+/+eabSE9PD/ByOXLkSKEXZwVvTAWpW7cuUlJSCj2OA8DPP/+MuLg4VKtWDbt27UJubi5mzJiBGTNmFCrboEEDXHXVVVi4cKHbYaJRo0ZwHAcNGjTAueee6/o8nvvBBx+gffv21iefUGjYsCEA39Nf165dw1av6XoAQGJiIgYNGoRBgwYhNzcXl112GR588EHPP8yLFi3C0aNH8c477wSo+FOZiLzw5ptv4vLLL8ff/va3gP1ZWVmoVatWwL7zzz8frVu3xrx583DWWWdh165dePLJJ4NuO+pMGQDQr18/LF68OMDlaPny5fjvf/8b8Ohw1VVXoWLFinjmmWf8+xzHwbPPPoszzzwTl1xyCQDfH/qhQ4cKtTN58mQ4joMrr7wyYP/Ro0dx1VVXYevWrXj33XfRsmVL1K5dG8uWLUP58uVxxRVXIDMzM2rHV9zUqVMHrVq1wssvvxzwA7tlyxYsXbo0wBuifPnyhZTYk08+WegNOH/Ii/J0uO6667B79+4Al6xffvkFb7/9Njp37oxy5cohKSkJCxYsKPTv8ssvR1xcHBYsWICxY8d6Guc111yD8uXLY9KkSYXG4DgODhw4YDz32muvRV5eXpHePSdOnLB6dLglKSkJnTp1wnPPPVfkU1pRq03dUKVKlSL7KMdctWpVnHPOOUWa5GxQuRec2+zsbMydO9dzXab65XWbP39+kTd5ALjpppuwdOlSzJo1CzVr1kSPHj2CbjuiFPNTTz2FrKwsvyvKokWLsGfPHgC+R1o+FowbNw7z58/H5ZdfjuHDhyM3NxczZ85EixYtMGjQIH99Z511FkaMGIGZM2fi+PHjuPDCC7Fw4UJ8/PHHmDdvnv/CZmZmonXr1rj++uv9S5SXLFmCd999F1deeSWuuuqqgH6++eab+OSTT7Bo0aKAFy716tXD0qVLcemll+Kxxx4rpLyiZXxU+8W5Ym/mzJno0aMH0tLScMstt+D333/Hk08+iYSEBDz44IP+cr1798Yrr7yChIQENG/eHKtXr8YHH3xQyJbfqlUrlC9fHo888giys7MRGxvr928dO3Ys/vWvf6Ffv34YOXIkEhIS8Oyzz+L48eOYOnUqACA+Ph5XX311oX4uXLgQa9euLfKYjUaNGuHhhx/G2LFj8cMPP+Dqq69GtWrVsHPnTixYsAC33nor7r333iLP7dixI2677TZMmzYNGzduRLdu3VCxYkV8++23mD9/PmbPno0//elPnvtUFE8//TQ6dOiAFi1aYMiQIWjYsCH27duH1atXY8+ePadctm6iTZs2mDNnDh5++GGcc845SEpKQufOndG8eXN06tQJbdq0QWJiIr744gu8+eabAe52P/zwAxo0aID09PRTLuvu1q0bKlWqhD59+uC2225Dbm4uXnjhBSQlJRV5k/FK79698dBDD2HQoEG45JJLsHnzZsybN8//lCG54YYbMHr0aCxYsAAZGRmoWLFi8I0H5ctRTNClpah/O3fuDCi7ZcsWp1u3bk58fLxTo0YNZ8CAAYVW2zmOz61t6tSpTv369Z1KlSo55513nvPPf/4zoMyvv/7q3Hjjjc4555zjxMfHO7Gxsc55553nTJ061Tl27FiRfd2wYYNxHNu2bQtwPYq28dWqVcu5+OKLjeM7FW7d5RzHcT744AOnffv2TuXKlZ3q1as7ffr0cbZt21ao74MGDXJq1arlVK1a1enevbvzzTffOPXr1y+08u6FF15wGjZs6JQvX76QS9iOHTucvn37OtWrV3cqV67sdO7c2Vm7dq11POFYMfnvf//b6dChg1OlShWnSpUqTtOmTZ2hQ4c627dvD2inqJV/zz//vNOmTRuncuXKTrVq1ZwWLVo4o0ePdn7++Wd/maLm3HEcB0DAqlDH+eNazJw5M2D/jh07nIEDBzopKSlOxYoVnTPPPNPp3bu38+abb/rL0F1u3bp1AefS5azgfGdmZjq9evVyqlWr5gDwu409/PDDzkUXXeTUqFHDqVy5stO0aVNnypQpAd/DzZs3F3L3NPHOO+84LVu2dOLi4pyzzz7beeSRR5y///3vhf6mTHPEvs+fP7/QsSNHjjj33HOPU6dOHady5cpO+/btndWrVzsdO3Y0usH17NmzkOtiMMQ4jguLvVJm2LZtG8477zwsXrwYvXr1Ku3uKGWQZ555BqNHj8aOHTuKfGkbyfTt2xebN2/Gd999F1I9UWljVoqPlStXIi0tTX+UlVJj5cqVuOuuu6LuR3nv3r34z3/+E5aIiqqYFUVRQmDnzp349NNP8eKLL2LdunXYsWOH67g4JlQxK4qihMCqVatw0003YefOnXj55ZdD/lEGSlExP/3005g5cyYyMzNxwQUX4Mknn/QH31EURSnLlIpifuONNzBy5EhMnDgRX375JS644AJ/pDFFUZSyTqko5nbt2uHCCy/EU089BcC3LDQ1NRV33nkn7rvvvpLujqIoSkRR4gtMjh07hvXr1wesoipXrhy6du1aZGB3wLfSruDKoPz8fBw8eBA1a9Y85dJPRVGUcOE4Dg4dOoS6dev6o84VFyX+w/zLL78gLy+vkCtMcnIyvvnmmyLPmTZtWtgifimKooTC7t27cdZZZxVrGxG1JNvE2LFjMXLkSP/n7Oxs1KtXD7ffvhvPPlsdAMCIjswaxM/79gXW1aKFb3syOh927PBtGcTrttt8Wyb2YLkuXXxbmsF5nMHGNm/2bXm/2bkz8LyT4W/9/WFgMUYbZL0yeBnbf/tt33bIEN+WK1inTw9sj7DeFSsC93fuHFie/Y2PDyy/dKlve9LahFmzAsfD/nI+OR+M93LLLb4tx/nQQ4H1mfrPfi9aFNge4Tyzv2yf/XnsMd92woTA8zivJxNp+M8H/phTOQcXXxzYd1knvzssz+xCHDPnhNY5nm8KUMc5kXPEz3yglO69co5lPyUsx7mXQe44l7I+t/2W8Jo991zR5Xg+22G7NkzzJOuVcNzyO2Or/69/zcHkyamoVq2auw6GQIn/MNeqVQvly5fHPvGLuW/fPqObSWxsbJEB4WNjqwPw/TAzNRsjEjKRtFyufjKGPBgDh8erVy/6PNbL89iNY8cC97M8z5fnyXr5WR7nZ8J+8smJx9lfeb4cp0xZJ8tzPLK8rJ/ty/HJ+SCyflmfqf/8zOsoLzvb5TzK81mvPE/Oa0ELmGkOZN9lnewD6+L53J4M81zofNkXYpsj+d2R/Tf109aO7I+pPrf9lshrI8vJdvjZhmmeZL2m82S/3NZfEubTUnv5d9FFF/nD4uXn56NevXoYNmyYq5d/OTk5SEhIwPDh2di1yzdrjLPCkLIihCzeesu3bd/et5UJFHgTXLIk8HyWJzzOdlgPlXqjRr4t1RTPpwLlSs0LLvBteS9iMDoZEpfleZwp2Lp1C2yPAebGjAksfzLoGhiLnOcxvhL7xwihPI/jvOYa35bzwyB1HO/LL/u2VDkcN7c8n+qR57FfGzf6th06BJZjvZwnts/zJJxHWT9hOxxnwXysLMuxsy6ew+8O55blqTSpMR55JLBNk4K1KVt5XCo6zhHbL/AwGXA+sSXUkefL9ng81PGY+mtqj/tt7ZvaYzleN14fU3s2+LuTnZ2N6m7vHkFSKqaMkSNHIj09HW3btsVFF12EWbNm4fDhwwGR0xRFUcoqpfLDfN111+F///sfJkyYgMzMTLRq1Qrvv/9+SGvjGQGxiBjnAICMDN/W1ARVEBUrVRMVMVUTFaNUYFRbUilTmfFzy5a+LRUpt3fcUXS/qISpPEePDuyvST3wONuXynXoUN9WzgfHxf5I1UHlKq1OVENsl1u2Z1NTUgXKJx/OP8dBlcN+cZwcj6yP/S2qH1RQHJu0dbINYlNsrI99dlueY2KfuV8qclmesJ+cA5MylXMjMSlIqeBt19Ztvbb9bE/21zQPsj+mv3k+jbpV9L/8UnQ9xUGpvfwbNmxYQAxWRVEUxUdUeGWYiI//Q5WY3qwSqiHaoqmgpLqQylVmZ5L18LhUioSKu3Fj35bKTtrA2R+pEghtsFIpmpAvjk31SnVAdSZVB9Ua1QVt5rIcx3cqhVoQqYKoYgragAGAcc+p0oi8fib7bIEkL4Vwa2OUysuk2Pj56aeLrkeOzXS+bMd0nEgbt0R+d9wqXTlOt/NFQkgaD8Cs8E3zZOuf/K6YlLIsn5MDTJtm72840CBGiqIoEUZUK2agsL3LxKef+rb0SjDVI226JpdFKmMqYipEKmNpm6Xaog2XytRkP6Ptmcqc3heE9UmVQBu4KcCVSTnLeolUoOwnbeUSelG4jRMu1Qv7LVUM54O2fHndeT7VqPRMcPtGvyDsA+uUPvESOaf87shrFGqYYbfKMFjkXJm8JGz9sj3FmtqTmBS3qX4+9fJv2dTfUJV8caKKWVEUJcKIykD59CccOzYb33xzaj9m2qCpmLmyW/q7mpJaU/XQfkebMu+29LZg/VS2VK4sT28N9pP5HNm/9HTflopTqg56m0gbuLzrf/KJb0vlTqRfM+E8UeFK27BUolT8HLdJ6UtvFfolSzhu2S+2x3njvMrrLL1mpFJmPdLfvOC7A2mblYpL7uecyac0qfhsCtKk5ORTlsRruyZM5UxK1K1Ct51v8k+22Xpl/TYbs8l7xK3ftKQk/ZhVMSuKokQYUa2Ye/fOxqJFvjuXvOtKBUiogqjQqNgIFd5nn/m2ciUeoSKmevrHP4ouT9XD/lCBSzUkVRCVqVS+hCpPqju3q7ZoQ5deLSbl7BbZPu2ycrzcz+tAZUxlLX14iclHV47PpMJOpYpM3yHOrVtl51UxmvohFZ7pWto+m+q17TdhU5zy2kof8+Kyjcv+2Z5IbF4skqlTczB+vCpmRVGUMklUK2YgGxkZvjsXla6MhSBtl1SItFXSpjtwoG9L31NGg+vbN/B8GbOBn6n0qHz79fNtqazp98v9tMVKf2raqqliWI7nP/poYDssz3rYnrTFylgeEyf6trS5sx62wxWGrE/G4pDeH6YYGHzyYDnprcEViJw/PsGwfhmjQ8YWIfJJhLZ2+n9Txcl4GMAfyoll+TQhvyPsO5+STApYvi+QTw02X3qJacWebI9zKBWhVPoSk+ePzW/Y1E/Wx2smr4FpdSavmXyPI8+z9cP2ZGNSzDbb9eDBamNWFEUps0S1HzNjCwN/KCZpzyIyqhuPUyHy7k01xHqonKk833wzsBxVBj/LmLVUC0TavC+5xLdl9Dd6O1Aps36pvqhMWR8/szznw+SHTa8Q6fXA/ayP80QVaRoX65GKVPqD88mE883ynAcZc4TnU9FLW7pNjfI41RvnkeWAP+z0nHs5x4R9kzZnflfYJ86ltAnbVicSk8Jl+/JpQX6nTLZTU8wNzonEZMc32e0J67OtLeC8sT6plHm+LfaHnFeJyY/cZluX4zLFmy4OVDEriqJEGFFtYx4+PBuzZvlsPbSzUa3IN9RF2RYLQhVE9SSjx7E+2Y5JRZli9UovCqm6TGqHdjEqaaoS6fcsYwabxmGL8Uu/aSpW9tc0z8RmD5XYVI/sv6zP5PNriv9MCvbbq1+s1/jAEq8r98Lll+z2fOL2GhYXxbXCMdjrpn7MiqIoZZioVsy33JKNSpV8dy6+/aeNVMYNpo2SMR5ow6SSoiKjtwLtjVSGrIdKkgqNtmUqO8bioA2T/aDqYD94PhUovUF4Pv2XaRuln7SM88z+SQUvbdaEx1kf25O2aul9YlpBR9s736gT2uYJbcuE8yNt5XKFJG3PJm8MGalNwvrpjcMnFrYDFLbfSw8QXjO2xe8Qr73Jb9fkx2vKeEJsTwGEis/0FGbza5ZPNyZ/Y69PCiZbr82W69brxYSpnGl+pC3bpqBVMSuKopRhotorY98+YPfuwH0m5SW9EKSPKhWV9CeW9RKqIaonxguWXhAys4dU4lRnVK7yfKkI+Vl6f0glSfXB/lFB02bMccuYFnPm+Lb035bzKb0+qE5Mngzsr1QxfFKQMTikMpZbmU1cPhnRBk+lzfo5Xl6PgvMlfcBZJ8vIPI6ESktmk5HxTFgvv1v8LP1+6Ycs51B6sMjvIhWmLbedSYlzTuR7EZv/swmZx5HfRZMNW0ZiDFUpS48d+Tdles9iqq80UMWsKIoSYUS1Yk5OBhITff+XMRf4mUqZNluToqOS5GcqZqmc5UpA2rapWqiGmINQ2hkJ65X+wbSDUaXxrk8brYxhIb0wCFUb25E2YKlw2R7HJ7NWS0VOZUp1JtWcySZMqA7ZT6laOS+cV/afTxym1WO83kS+I5D9L9gHU9Q4zpGMNmdSanKVpPS9lu3IyHtSofKzyfZKpM3alDPPFp2OmPyrTTE8iMwqLrPzyPq5lSsV3eYYlOPxaqt2m5Nx8OBTlwsnqpgVRVEijKj2yigYXc6rPUrGLZZ3a5NyZgwJKjaZvdmEKW+ZVK4mn1Hbm3XbqjKbb6tUjXI/58MUV1lGq2M/pB8yn2ikPZfKVqo6r/EaTLiJKud1zky49ZM11ef2WodanzyPuI0tYSPUuM7hwhRf2yvqlaEoilKGiXrF3Ly5784l38ZLaAumrZaKmfup+OiVQC8JKlkqa/ox8ziVINuXmTWoNLmVSpA2U8bgkLZpGR/aFNeAipPt0C+aakkqc/pr8wlAekVIv2bpHSHjRPN86X9Mf2bOu1yxxycRzgOVtHxXIPsllbj036bXBm3l9NvmeQXjcJuiwEn7Ouvmd8eWIcMUE9y2ks4WHU7GiXZ7ng1TLA3i1t9XKnfTexBZj02Z21Ziuu2P3E9s9ahiVhRFKcNEvVeGjH7GaHFURoTKSvo2ygwm8q2+jPNLdcV2ZBQ2kwKk2pJR7ai8ZdQ7aePlcalkqZCpPjhOKlQqeW6pouh9wfbYXypVqhb5Zp3qkU8OfN6StmgqZSpnqXKkKpMxfPmZip7jow1cjk+ucGQ56cdOCtrKpVKT8UBYl8zQbVJYNts02zMhM6DLa+w2w7lJ2driEZuUttvYEialbFspaMseTsUun1SISZnLcbjNcCJXgI4Ycer+hRNVzIqiKBFGVCvmw4f/UMBSqZr8V+XqK8K7bJ06gedT6f77374tM26wHmn7Zfu0MVNxyxgdciUiy8kMH3LVEuuljZefqQJo46a6Y3+kypI2bxnfgeWljVnGVabakbE5pK2YSDUl/Z85j1zhx/mQ885+y9gh8noXFX/ZBMciPUT4HbP549riFxPOqclbwrSK0q23hsk2TGzZt6VnjC0eialfJlu1RMb0CLaczTZNbNfP9CRRkqhiVhRFiTCi2ivj/POz0bOn7+0obcrSi0LammXWZcJyVJwsR2XLt/v0dqDdkSqKb/0zMgL3E5kphaqENmaZFZtKmIqc51Gpsn355l96T8ioaixHrwz2V+bWY/0mpMeC7A/3yyh0hOqE7wZkND0ZPY/KW3qlmDwpCOuRuQsLYlKYUilKm6MJGaVN9knG8A5XHGX5XkKO1a2XRqhxomVMDlNc51DjW7vNCC/7Y8rcbvP2UK8MRVGUMkxUK+batbMxaJDvzkU1I7NlS+VGRU2Fy6zVUgXJlWkyAwkVHG2cVLwLFvi2coUgbZzS1kklKVf+ERkj2BavQa5cNKk2uWJRZirmE4Ipti29IWj7lWpNKnDTqiu5YtKU5VzGg5aZl+Wbf1OktVOpQVP0NdNTlim2Q7Dxfm2YFGKwK/O8tuvWf1hGd7N5ZRBTTkG34zPNT6jR4jRLtqIoiuJdMX/00UeYOXMm1q9fj71792LBggW4+uqr/cdvvvlmvCwCKnTv3h3vv/++//PBgwdx5513YtGiRShXrhz69euH2bNno6rLJUoFbcx16/ruXFSi9EGVb7SpEKXXAhWdXCHG/axPKkppC6biplI0ZSTh1LAfMksGFaRUWdxvivoms2lQwVPRs362K7NwS68GqTRldDoZY4SqRnqjmHIDmpS9fEIwxYGW9l5pS5beLHJ/Qa8cU+ZttyrcVp6YMpq4jX1h8t/1apM1eW8QW9wVrysApc3epHy9xrRwOx4ix+U1DsvUqTkYPz5CFfPhw4dxwQUX4Gn+8hTBlVdeib179/r/vfbaawHHBwwYgK1bt2LZsmVYvHgxPvroI9x6663ee68oinIa4tmPuUePHujRo8cpy8TGxiLF8Or666+/xvvvv49169ahbdu2AIAnn3wSPXv2xF//+lfUrVvXdV/69AFq1fL9X65cI1Qp9GaQ9kKpQOmFQQVuimEr/XqlFwPbY+wNehNQsVIBUslSWcs4CFQBMmMI7/qmaHC01RL5mZdH9ttkT5UxdmXOQrlyUSpyeZ6M0yxVKJW3KRs54TzwOpvsmTLbd0FMcY3lftNbfFs9El4j20o3WT7U6Giyvyalyc9yDkONCmezEcunHBum8Uik33OwTxjDhgHjx7vvXygUi435ww8/RFJSEpo0aYKMjAwcOHDAf2z16tWoUaOG/0cZALp27Ypy5cphzZo1RdZ39OhR5OTkBPxTFEU5XQn7yr8rr7wS11xzDRo0aIAdO3Zg3Lhx6NGjB1avXo3y5csjMzMTSSJhXYUKFZCYmIhMQ7qLadOmYdKkSYX2L1oEXHqp7/9ceUaFJW3MtJHK3HBU2DxORcsVgNIvV670oxJl7AjulzE0qMD5me0yV6BUqlSY7Cfbp/LjOKjI77nHt6VSl14NhLZVrmTkfEl/byph9lv6S9MWz37JqHKsl/7bPJ/9kVHnTPEj5FfCFMdAljPF7pC27aLakgqLbXKOTLGiiYz/Ie3sJj9okw2Z5U02bWmzJraViKYVhm7jNst2TP7gnFdbjA65GpeYvGWIKes4v8NyvmU/TdH6SjPnX9h/mPv37+//f4sWLdCyZUs0atQIH374Ibp06RJUnWPHjsXIArOUk5OD1NTUkPuqKIoSiRR7rIyGDRuiVq1a+O6779ClSxekpKRgPwMhnOTEiRM4ePCg0S4dGxuL2NjYQvsLCm+pMqhsaTMWTfqVHe1aMrMxlSzbkF4dVBnSK4N3X9ZLhcv+yKh0VIxU0FTgrJf9l2qB7cmVhjKussmLQWYUMdmcqfaIVKb015YxPmQ2b+ltwuPSO4bt80mB4zZlsmY90t9cqiK2U1S8BZnJXPaNcyDnwhQtTnqqSGS0OFN9pnpsfsE2m7Epwh+VpymbjeynW68OYotBYbIxS198OQ4ZjY/l+PQqfxtkP93y1FPeyodCsfsx79mzBwcOHECdk7aBtLQ0ZGVlYf369f4yK1asQH5+Ptq1a1fc3VEURYl4PCvm3NxcfFdAHu3cuRMbN25EYmIiEhMTMWnSJPTr1w8pKSnYsWMHRo8ejXPOOQfdT0rNZs2a4corr8SQIUPw7LPP4vjx4xg2bBj69+/vySMD8CkPGS+ZtlMqQipUmWWa+6WtkyqJ3hoSqgraD+lNUbBPQGE/ZSJVmbSRyuh3LCf9tKVSpIpgu6ZMHrx0VNZSBdJ2LFfkSaXMz1QjHAcVOI+b7JdUN1TGbI+2fhknmvY/KnQZVZDR+6QHg7SVF+VxIO3WErl61KQ8iYzMR0XuNkqZyZZqiscsvQfcKlyTzZdK17Ryzq2tWX5nTCvwTJnYJaYMMbzWtuh7wcJ6R4yIYK+ML774Aq1bt0br1q0BACNHjkTr1q0xYcIElC9fHps2bcL//d//4dxzz8Utt9yCNm3a4OOPPw4wRcybNw9NmzZFly5d0LNnT3To0AHPP/98+EalKIoSxUR1rIz+/bPx2mu+FThURjKLs+kuTUUtlaH0W5Z3cSpD+WZb2iMJ98ucgFS69OZ49NHA+ghVF5WhjFssY16Y4itTRZhWRrK8jN5Gpcl5pXqgMqbCZkYUGc2O9cpy3M95kSpIPgmZ3pzbIoyRU8WtsK1wc7tCzO1KOZN3hEl5m3LxBRuVraRxm6Xba3Zv2/mm48HGFtHocoqiKGWYqFbMBbNkywwmxJSBgzZWqhEZQ4LeENIGLHPZSUUto9oR6SVCaPulH7L0sqCylDZh6T1BNUBFLJW69AWlguU4pMKWmUVkf+Sbcs6/yYZOpB2X5aQ3CpHR5mREN1M8aNmuPL+oLNmm2NZExrj2Gs3MFg/ZFqdYeotIrwNTvSZ/aonNlhxsNmq3mVdsUfpsKy1N/Qy2fYkqZkVRlDJMVCtmIBuPPuq7c5myJBBpr6Nt12QbJTJmhbR1Sh9TUyxfmatPRlMjMvu0rX6JjJkhlTXniTZiaTs22aZNaoqKnMpWKlq39lk+OdDLxaSQTVHqvMY+LmiDtmVHkf7ENttkMLGgi8IWXY5jlJ4owcZPDnWlmy1LNT1uTE8c/Nsy+U+bsClrt9gU/4gRqpgVRVHKLFGtmIcPz0a9ekUrZkL7msmLgZ95N5fKkYqS/rNE2qVkvGPTm3QZs8PkgxkTE9geQ4WYbLxuY+aabLNyXCbkk4fbiGteV4u5zWJhU3smT4lTjdNtVmXbcfndC7diDZfSdYuMJOi2Xfm3Jgl1HNIjy+ShFWq7amNWFEUpwxR7rIziRvobSz9XaaOVcY+5YkyqIBm7wRQXQcZZkBG7TLZY0yonGV+Y9UuFy/7QNkuvDX6WTw5yfFwZKP2FbfZRk73T1J6MU2BTjabsHybbuk3tsF+neuNuU8Bu4/nKMUtbqwmbDdl2XrgxKUl6dXi1XXuNpucVWb+MxufVm8R03mkVK0NRFEXxRlTbmAvm/CNUpFJhUX0wq/Obb/q2tNnSDkb/XtZDv2jWRzsZY1BQcfI8+iHLqG3S3ijjMxOqEhktjtm36UXC/tG2LeML84mAb+xldDxpj+MTgiwnfV/lSkmpVmRcCZ4vPQYIbe4sL7OOy+h4MiqejJrH8lSrshy9Ygr6U8tz5Fxyv3wqsSk96VliikJHTArS5tli8kyR59v8nW0E6yVhWtlo8lsOdgWjVLoyY7v08CE2bw7Nkq0oiqJEt2Ju1y4bn3/uu3PRi4HeC6ZIXCZvArkSjjZYKkTeTflZ2opNMTSk94S0hUu/adqQTVmyqZyp4qQ6kpHSZDv8LMchVdXEib4tFaz0WiEmdeP2zbjNN1iuWJSqxqRC3a4KA9yvMAvWr1nWY/Kkceu5YqvfbXkbJe31EWr7xd1f9cpQFEUpw0S1V8bFF//xf9p2qXpMq5BMdibaVAm9NeTdV8aMoP1Kqi1TTFuW52epkIm0A1LZ8nwZX5jtmMZnsvGa/Jw5n9LP2Ss2Lw+TwuV8SHVqUkUmf2o5D16wKWKv0clMSs5ttme39dviD7tVlqWZ8+5U7ZueDEq7v+FEFbOiKEqEEdU25vPPz8bmzT5bj21lGKG3gsyqzS2VsMwtZ3pjzzf1tMVS6cqYGrSBM1MK62PUOdYj7Zn0HmE/eD5hVDpCG7v0k5b51Nge+yE9DUzxDYjJj9mUednkCWDy2ZWxPmRcaCJt0CY7sLx+Bb8npni9RKp6U1wWk33f7XsOWY8tFrXEa3S4cNlk3bZjs61LQvXSML1Hke3J9y8mpk7NwfjxamNWFEUpk0S1Yh4+PBuJib47l9csB/QaoM3YlDFYegO4zVIhkSvQbPnUpBcIs3xTwRIZU4NXM5gYEYA9UplX3Koe2a4pU7TNF9YG2ynof22L0+sVk73c5LFT0iv4ivt8r9HtQm3PhMmjx+TvbPvOq1eGoihKGSaqFXPv3n8oZmkTpiKSOfPofSGzOsjsEDJ3H5G56qhoZYYUaePlCjSuGGQmFPbHZJc05R402S/ZL/Zf2shNNnBpV5U2WbmSTmZIkTn+WI7eLaa41jJWh8yhaOqH9M+WKxBlzA35/SiItPXKuMC2eCxuM5lIe7ct5obEbQ5Cr7Gw3cYKCdeKPJMidWvr9Vqv6TziNuqfKmZFUZQyTFT7Mf/wA7Boke//BXO4FcQU/U3aaqkwqaSp+BiNTsZJZswLqijp5UGFRqVHpUxbscx9x/ZMb5LnzPFt6XVBdUGvDq5Y5Hn0XmA5uUKR9UmlSW8NqYSpKjmPbEf6CbM+jscE1ahU+IT9lf7bbJ/qU/af7crYwcwgU9SThim2ApH5C6WCNL1fkErOFG3OpPRMMapt3g2mVa8mbO9H3Cplm9I3+bSTop5mCpaz+Xu7XbHp1oat0eUURVEUP1FtY87OzkbPnj5bD22ZUrFRMck34TIjMpViUdHHgMIr4GQmEhknWWZCkTZO6f9s854weSVIu5z8zP5RiUuFKVWYzJBCTJHUTNmnpQI2qS6pUk3+z7x+Jv9y0/xJtcnrVlCdSd9oU1Qyk23SbcyMYG21pvgkNhur22w2Xr1S3GbDDjaOtVeFbnqCIMFmoJFKW23MiqIoZZioVsydO2ejQgXfnYtKUGY6NkWFkzZQKjzbm2yTnUxmu6YiljkAifStlCvdpOp5+unA+qTHgOw3nwgIV+7RFk04H7LfVIvyicPUf2JbHSfhfNKmzPZk/+WTkPS6kZhWGhYVG9hkwzVl6nYb31gqOVsmcmLyg/aqJINVzqH6L5soaT9mk/IN9slFFbOiKEoZJqoV89ix2ahVy3fnssUfkLZXUwxetz6dUjVRyUnbqvQbltHp6C3AWBUSqVRNSt20Ik62T+8R2ralf7NJVZnUkGnevao82Y6MuSFVqbR52/prmifAHgHPpOTc2njdxo4IltKKW2xTnm7jXEdL3GhVzIqiKGWYqFbMQDZGj/bduRiFjV4BMgocbZh8q89MILRpMmobo7XRf5nKksyYEXhc5vxr2DCwH1TS9I9m+1z5Z8oRSOR5tC3TNmzyO5Z+z7J+joPj5/bf//ZtmRtRrkiU3h2mjCjsF/2sZa5AeX04f48+6ttKu6xpNZ5E1kslz/plFL+CdcmcfnJ1pHwPIFcpSru33C/HZHqfYbL5SkzvJ0xPBybbtc2W7nVlHc+Xq2FtcU5MK/+8ZoqR9crzvdqYWX7ECFXMiqIoZRZPinnOnDmYM2cOfvjhBwDAeeedhwkTJqBHjx4AgCNHjuCee+7B66+/jqNHj6J79+545plnkFzAALlr1y5kZGRg5cqVqFq1KtLT0zFt2jRUqOB+ESIV85Qp2ThxwnfnkmrHZs+SMRRM/szyrb/0jyYy9gbVglR4Nq8Ck2cAkd4Qptx6UkURnsd54opEtkOvD1uMXJsngi1DMjH5HUu1JT0Y3MaDkF4fnKeCPrayDmk3l/ZuqWSlsnYbZ9it7dVrLr9Qc/959ayx4VahlpStONh2IjYe81lnnYXp06dj/fr1+OKLL9C5c2dcddVV2Lp1KwDg7rvvxqJFizB//nysWrUKP//8M64p8Aybl5eHXr164dixY/jss8/w8ssv46WXXsKEUN9+KIqinEZ4ipXRp0+fgM9TpkzBnDlz8Pnnn+Oss87C3/72N7z66qvo3LkzAGDu3Llo1qwZPv/8c1x88cVYunQptm3bhg8++ADJyclo1aoVJk+ejDFjxuDBBx9EpUqVPHU+NxfIy/P9nwrXhMyRZ4oiZ1oZxxVjjHUhFbPMki2hIpWKWkaN43FpdyNUcRyH7AcVIpWmVN48jyslqeB5nOqQipo2drcR2KSipdeJ9Jfmkwpt2RLTSkr236TI5ROEjPFRFLaYFnK1qKmvUqmalJltpZ20n9ty+bldqSfrM50fbsUaqn+0xGv0PLfY+jdsGDB+fGhtuCVoG3NeXh5ef/11HD58GGlpaVi/fj2OHz+Orl27+ss0bdoU9erVw+rVqwEAq1evRosWLQJMG927d0dOTo5fdSuKopR1PEeX27x5M9LS0nDkyBFUrVoVCxYsQPPmzbFx40ZUqlQJNWrUCCifnJyMzJOyMDMzM+BHmcd5zMTRo0dx9OhR/+ecnBwAvshyl16Kk+cHbmWcXipLKmd6H1AR0rbK8/kWn94X8k29tClTGcoVbEQqR+mtwPOksiX0upBv3qnuZIwKGX+aypjTT68NekFQpU2c6NvSW4T1y/jJpkhgVOxyvLacgTIaHMdLOC+mbOSE45exj3meVNxA4e+KzY5NbP7LxFYPKapvBc83Wfykwrb5osu5l8dDVZ7yWhJbbA1+p21eKG6j55ls+V7HzeODB5+6vXDiWTE3adIEGzduxJo1a5CRkYH09HRs27atOPrmZ9q0aUhISPD/S01NLdb2FEVRShPPirlSpUo456Rsa9OmDdatW4fZs2fjuuuuw7Fjx5CVlRWgmvft24eUk1IkJSUFa9euDahv38nbZIo07hZg7NixGFngdpaTk4PU1FS0a/fHm3DeFUeP9m1NaoF3cZnrj8qSCtoUNY4KlwqQd2WZZVrefWUWDOnXK5HnU4XQz5r+0rTR0qZqilNM5ShVg/SFpVKmou7b17eVipftmOyVtCHbVCIx2extSCVsUpXS9lxQbclrLOv2GmWNuM3+bKvH9m5cHjeVl4rerZeEPN+G9BAyIb8bXvM52vpjUtamuNkm/shgcupy4SRkP+b8/HwcPXoUbdq0QcWKFbF8+XL/se3bt2PXrl1IS0sDAKSlpWHz5s3YzzdoAJYtW4bq1aujefPmxjZiY2NRvXr1gH+KoiinK54U89ixY9GjRw/Uq1cPhw4dwquvvooPP/wQS5YsQUJCAm655RaMHDkSiYmJqF69Ou68806kpaXh4osvBgB069YNzZs3x0033YQZM2YgMzMT999/P4YOHYrY2FjPnS/o40vlKO+GpjxrVIi8m8qMIVKxsTyVsS3XnLxLL13q29K7Q/rXsn8mf1/aTrlC0RT1jUpVKkPpzyz9o/mZ55ls3lTatFXLiGncTxs0+yfnd+9e35a2b5tdke3I8ZpWALI+vguw2TuLOldiylwizzOt4CNS5YebcGelDjVLdrD1un3a8vrkEmoOw5LA0w/z/v37MXDgQOzduxcJCQlo2bIllixZgiuuuAIA8Pjjj6NcuXLo169fwAITUr58eSxevBgZGRlIS0tDlSpVkJ6ejofkGxxFUZQyTFTHyigYj1lmhZbK07TeXtqMZXQzmbuPylZ6R0g/Xyo1afuVq8ls2TG8YsoiIRWt26wNhOqFil3GMzC9AbepE5tHg2klIcchj9tiJJ/KJ9htpotwrWTzGrXORLArAeV3MlhbeqjHveLVb5vYYpHY+qfR5RRFUcowUZ0lu107oFYt3/+pUKmYpW2Zfr1ErpDjW3tTpDAqXypsuZJPrgrjebw7U3kzFofMuCwVrbzrc3yyX1Khsv8cD8vLHIYyGpzsN2H/qJQZz1k+gbBd2oxl/GmJHJ/MsydjmLA++eQi/ZtlZhqWk9+LopBxOkx+zSaPH/m0QfVuU3A2xWbLii2xPXUQ09OaqZ5w4TZudajI9xHEtmJQXk/+TUa0H7OiKIpSvES1YgYKvxnnijMqzDp1fFuu4CPSe0EqWyphGb+XKoorA/v1822pzGirpgKkopOxKKjg5cpCE6yf/TPFfqCiZSwMGU9ZPimYbLfS64Q2dW7pTy3VJf3BqXB53KSKeP1kZhlClcj66afO8zgP7L/0vnCTq1Dax6UHC+FYpHIO1hZsmhOTP7XbetzGS5b9d9tf03G35UzfAdt5pkwzJmT8ZVO9EpMXR0miillRFCXCiGqvjOHDszFrVuDbUbf2t2BjzZq8HmT8ZRkvmQqeCpznMTYFY1YwRgSVpynLhByPSd3YYgUHa/+U/eeKQemtEWrks1BxY78sqTjAwWLrn9cMHyVFuHMb2trRnH+KoihKsRHVNub4+D/+79b+JH045Qo4+dZe5grkanLpBSBtvqyHNk+eL71FaGPmikDG6pD9pO3cNB7ZrrQtS0wxP+R80EZryizCWBqMrcH9ct5Masbkz23ab1NF0jeXuHlSCFaZevV39hofWdpgZXl+l2xPN7Y40CZM/fVqS3bbv3CvXIyGlX4SVcyKoigRRtTbmGfP9tl6qNyYyUTmeJMxYuXKPtqGZZxlwrst/X5Zj8xIInMHsjyVqCxH9WVTY7Q9yzjF8jypmKW/NZW+9IuWKxKl4qQqoe1b5gZku/Q2YS5BXg/bikNp85cxLmT8aZM/s1wRSGQskILeG5xT9oVPL7x2tvi/LC/zTUpFJ/MwerWJmmJMu/UicKuYQ/UzDvb8UJVtuJW2LKc2ZkVRlDJMVNuYk5L+8GaQXg9UFTJ3nQkZL5nnyxV6jIMslTVtq1RmVFtypR+30ieWCtfkkyrtjDKjiAkqQ6leOD6pjOUKQVNOQrnSkfXQZs5oeuyfXNEocwbKqH/sH8fNfpkyxNhW50kbeUGkapeR8CQ2W6rcz/pN9n7ZD5OCtcW0IMHGkXYbHc+GW6UcrDeFybYdbq+M0vTSUcWsKIoSYUS1Yl69GrjwQt//qeS4pXKl1wHtc1RFb77p21JRU2VQqcnYD6yP51M5UxkS2Q8qRub443mEilpmtaZaYftUjFRzLM/+ymzaxJQPjisXuZKO7cjYFmyX/eFxzgf307bMcbNftN/SDitX4jEGh7S/ynKm1XhyvmSsEJkDkfsLetFIVc+2pVKUfbL5xAerOE1eGW7L2yL1mWzmsh2OJ9xZqWW7/Czrdxtn2atNW+7nd8+rD35xoopZURQlwohqr4yxY7Mxdarv7ahXdSKjr9n8ZWXOOGLz5eTdmEpOxsSgcqVfsVdVEuwbcKkSTDnxqHRlzj9T+3I/bfEcp/SicPvtC9UzIJSyNj/eUBVVuOIdy/55raesEOw8qFeGoihKGSaqbcz33WcvY1ITprf7pruofJtvipkhkd4ash3pDSJjWpgw2f1MMWhtypbKWML+e1Xm9LqQ8ZHlPISKLUKbKbOKl7JubZW2Prk9bmvHazQ5Uz02Sipusir5wqhiVhRFiTCiWjE/9RQwbpzv/yYlZoolwbf1Jv9guVKMSKVsWrHHctLLgzZttkdvAZuftVQVJlu69EMmch6ohLlf5jy0nW9TN5w/rgD0iinmhVfcZloG7Cvj3L7HsHkLmHBrKw5WWYYaha6klXK4VvLJ46anykhCFbOiKEqEEdWKef/+wtHXTHZCGU2N59GPmN4SMlqczOTB86mAqQx5PtthPdKvl4qWSpm2XXpFmFQVlbzsj8l2Kr1OpDcE+8H6qNilmpBZvmUUPbliT/pXE5P3BWNusH2Oh9eTilmuNCSyvybbu4wxUhCpiOXTjZxzW1umeuX7CJMClxEOTXGNbTZgU+46Wa/NhmxTpF6zhhNbOyZlK7+Ltnalf7ZtHiIBVcyKoigRRlT7MQPZyMjw+RNKf1xbFDIqVXplSCVNxSajo/HuS0XM86VXg4y3wLux7J9c6fb0074tVYjMnSdtwTK6G+2HMpqejE3BnH2MNcJ6pPK32eOk+pBxlG2xdtnvGTN820mTfFt53aSKJfLdgsyuzfnndSvKH5urMWUkQKnIbHZveY2lx48sR2yRBU3R42S+RlOkQpsfttsMIzabu8kzif20ZdMxxRJ3+8Rg6qfEbZxs+bf+5JPqx6woilJmiWrFPHx4NurV8925pB1J5uCTd2upjKmWTDEjbIpZRpszxQfmfhlbgrA+qQ6kWjPZBaXqkMpRIvtl84KQcaxldmtT1m23q9bkPHL8VKFEXh+JtHHL61UUpqcPqQTdZq021et1TrzmzDM9pdjyXAab0YQE6yctFb/b89zGkfbaP1P9uvJPURSlDBPVXhm5uX/YdmUcZLmSTno9UCkzhx/59799W3oJSEXLevmWn5+lLVYqNsJ2eVxGP5MZRWRmEPZLxqIw5Qakl4TMqEJvC9qUpdeJza/a5DfO/pjiPUuVxCh/0i5LW7tccSm9W6Q9WJaTuQeLgn2Rc82+yrFKm6rNpsx6pAI3eRBJhSzLy35IWJ/JJmxT+Kb9Jl92m3+2jMsiy/M7YlojIJ8wvPp322z0kRhbRBWzoihKhBH1NuZjxwJtPaa4vVS4VIxSYUlVZIqlIW2nMhcdoUKlVwcVG+Myy2hyNjui9BM22eNMngPSNmzygzapCenja8pbJ7Nt23xNTaqM9cgnHj4RUOnbchRKivIccNs3WbdbLwVTNhW3NmPb6tLizv5cUsox3O3YvDmIW8WsNmZFUZQyTFQr5s6ds9Grl+/OJW2WhMpOZhSROeSopInMLCK9LajA5Uo/IrNl8zPL7d3r2/7pT0WN8I/+sX2p4KUCluWk37bJVm7ybpArBKlcqdzlCkiZcYVbzo9E+vzKfrBe9nviRN82I8O35XhpD2Y7Mhei9OuWKyGLgm3LfIomTxSTYjV5d7jNJWizhYaqMIsrFgWxZT6RylV6Ttn6Y3rKDNa/2Xbe4MFRopinT5+OmJgYjBgxwr+vU6dOiImJCfh3++23B5y3a9cu9OrVC/Hx8UhKSsKoUaNw4sSJULqiKIpy2hC0Yl63bh2uvfZaVK9eHZdffjlmzZoFwPfDfO655+KhAsa8+Ph4/x0mLy8PrVq1QkpKCmbOnIm9e/di4MCBGDJkCKZOneqqbSrmKVOykZ3tq5cKSNpspRcFlZzJV5RKUSpKqXpkjA3Cuz7PM8VBcLvOn7j10ZS582SMCWmDt61CkzZjjpteG7L/bmN5mJBxIuQTAJW6zAxjywko7b4FVZycU9MKNZM9W7ZlWpHnFa8KL1g/5HApcZtydvtkESqh5iiMWj/m3NxcDBgwAC+88ALOOOOMQsfj4+ORkpLi/1dwEEuXLsW2bdvwz3/+E61atUKPHj0wefJkPP300zh27FjwI1EURTlNCEoxp6enIzExEY8//jg6deqEVq1aBSjmrVu3wnEcpKSkoE+fPnjggQcQHx8PAJgwYQLeeecdbKT0ArBz5040bNgQX375JVq3bl2ovaNHj+Lo0aP+zzk5OUhNTcXYsdn45hvfjz4VminTrVQvUtlJ30npvSBXoNEP2BQfgMpcRmejVwHrpdKTKxelupDR8WyqRypPibSxul3NJr1QZD9NuQPdqj75RGKKXyG9XojJG4XwuhalsE2eKSalbFoFasKUhVq2T2z+wV5twzYbqtt63eJWuRZX+yRcirwkFbPnBSavv/46vvzyS6xbt67I4zfccAPq16+PunXrYtOmTRgzZgy2b9+Ot07+omRmZiJZfMP5OVOuJjjJtGnTMInRbRRFUU5zPP0w7969G8OHD8eyZcsQFxdXZJlbb73V//8WLVqgTp066NKlC3bs2IFG0jXAJWPHjsXIArdRKuadO/+w8VKNSD9eaWuWSK8C6eUgoSqiMpTeGnJFn8xkQgVIxUflJs+X6ob9scUGNnlbyP1SxUj7KJFvwE2ZWNhP2a5UibJeqWg5D3JFpoz2J+dRxoGWPqtcIcrvS0FVZlpZZ/JRl7EveK3deiWY5t6ELYOKLe+hqZ1Qbc2247yWNq+ScGF6QvCqlCMhPrMnG/P69euxf/9+/L//9/9QoUIFVKhQAatWrcITTzyBChUqIC8vr9A57dq1AwB8d/KNSkpKCvaJv0Z+TjH4bsXGxqJ69eoB/xRFUU5XPCnmLl26YPPmzQH7Bg0ahKZNm2LMmDEoX758oXNoS65Tpw4AIC0tDVOmTMH+/fuRdDIh3LJly1C9enU0b97cU+eTk80xKqTKkDZP28o3ebcnbM9kTzS9/ZfeGlK5SWUq79YyfrPXvHLSVu5WDRGOQ/pRB6sq5HnySaBbt6L7K5W99A83rdSk0ubngtHqTO8LTPGRWc4UJY7lTCv+vEaLI6Zr5/Yaer32tmtrU8CmeNDyfK/KubhzI0ZCJhNPP8zVqlXD+eefH7CvSpUqqFmzJs4//3zs2LEDr776Knr27ImaNWti06ZNuPvuu3HZZZehZcuWAIBu3bqhefPmuOmmmzBjxgxkZmbi/vvvx9ChQxEbGxu+kSmKokQpYY0uV6lSJXzwwQeYNWsWDh8+jNTUVPTr1w/333+/v0z58uWxePFiZGRkIC0tDVWqVEF6enqA37Nb4uOBWrV8/5fKWSowU1YH6edLpUybprRNF3AmAWCOvSFXj3Er/aSJzDASrN2Qn7kijjn1JPI8m3+1yVuFSFu6zStEKmAibe2mdqWdV8ZCkXGdpd95wffPpvjF8qnHFnNBIq+hrMdUzvYUZLNJk3ApyWAVtNv2vCrnUPsfrlyHxUnIP8wffvih//+pqalYtWqV9Zz69evj3XffDbVpRVGU05KojpXRv382mjULfBFoshlLZJZrKiuT9wb9o6m4uPJNRpkjprgKsp/EtEpMKkuZaSTYVWEyhrApe4epfamspW3elH/OhJwfWY8tdyDhk9KmTb6tKVZHQdVpil0hsflwmxRfsDZlYota53ZuTIS7fLhicBQXwUbli/iVf4qiKErxEdUZTKpUKWy349akwKg0ZXZqIqPPSVsqlTJt0KxPxgk2RS8zKXWJvKtLhSvHKVcuShutVMA8zvHL/UTavmXsCp4nbeqmuNA2O6KMasfyvM5SmcvrzH6aMrIUpXZNeRNNKp9thjunnKmcVMJS6XmNtyIVfKixNWyYFKrXGB+htkeKO351OFDFrCiKEmFEtWKuWtUcFU76pkr/ZtoiJabs1byrs95LLvFtqThlBhR+ll4F7N/33/u2VMBSAVIhsp8yl55NcS9d6tvSxiqVNG2wJ13JjXGX5QpIKlG2b7Ixsz56S5hWFnJcnFc+Gcj+yBgc7B/r5zxKf++TXpqFsqEXtD1zn+k7ISP2mZ5OpLKUNmlTnkRbJhRTeZOXiM1rw61t3OYnbcIUc0S2F2z9EpP3CwnVBl8aqGJWFEWJMKLaK2PKlGz87W++t6NUVCaVQduvzHRhyuBBvMYTJqa4xjYfWFuEM1PeN2njtUXRI15j+poyFxMZBc+t+mN5nm+KbUxMkd5sMUDuuafw+dITh30wrc6UtmhiWukmn9JsGTpIsPGWTZ5IwWZCKSm/33CdT8IVfY+oV4aiKEoZJqptzEBhJWWCsRek2qFakrZV7qeSI8HGdqV90XRXZr2m+k0r6Uw2XlOcBAmfHGz2SdZvGzeVuVdfUdqCTSsZZbRAYvJXl/ZXzh+9Mwqu4JRPJzL/I+eG9nBiU8gSt0pZ1iMj8cnvgunpRY7ddC1sWXFMc26qx4RNkbpd0WjDrZKX7bn9LSkJVDEriqJEGPrDrCiKEmFE9cu//v2z8dprPiN8sE7wppdZMsiRTEVketlkCpZucpWSSWTlSz5TABu3iSblOGS4U9Mjv8lkYUrVJfFqypAuacQWeEbOhy0lVlHzKl88hrrUONjypvPCtQTaRjS6lQEld1305Z+iKEoZJupf/pnCd5qgKmBgddMCDb7okUF+pMuTVLTyOJWcdGcj8uWddAMz4TboEduXAfalWxuRS7KlijK9KJIv0NwGBpKJC2zBp4gtVKRp0YY8vyBMP2VyWTSlLzP1yVYPMS1VttVrI9il36Z+eG3fNG7T016wSjbY8l7dAAcP9tZOKKhiVhRFiTCi2sZ8yy3Z2L3bZ+thQHjT3dOkdKWNVQYLMqWul4rara1WLnW2KWST6rItezW5HtnKyfpNiypMLlqcPypv28IW08IRuVTbNm9ulyfL5K1A4acifpfoWicDYBGbm5kkWMVrq1eWN9ndg7UhB7vQxUa4bdjyPYXX66ALTBRFURQjUa2Yx47NRlyc787FoD0yvCORAd7lZ+kVwSA6UqGZAtZLlWJbUsxgSVz4IpcFS+8EGRCH8DyTt4UtsL1UVVJ1yPngfpNClf002ZpNzvwmm7l8cjHZ/KWiN12HgvMo54zvHzintrFITLZU09NXqAo6WCUebAB/t4usvLZTUime1CtDURRF8UxUK+YpU7IxbpzvzmUK5mOyhZr8bG1v3E3+tiY1ZDou/Yqp0qS9U3oT2FSHSQkTU2hKW6qq4sIU9Eg+kUgvFJNtndiud1HeHCYFSpuz6T2GWyVb3Mov2JRJXtux7Se29xGljdfroYpZURSlDBPVinn48GzMmuW7c8mA9FLR0keVAWqorLlliEcqNwbVkV4cDMxOpUs/aOkXLW2/0tYrgydRjbF/Mmwn6+dxk6KXwZmowKnQpR8154uYbNUS2X/TE4u8HqaVeybbtwn5BMN+y3k31VswIBGvmWl1Juea155bYlPGNm8Jt8rN9B336h9s8+Rxu5rV9rTp1qZueyoMdeWjqb9evV5UMSuKopRholoxZ2dn48UXfXcum/3Lq6+p23JShXj1gjDhth82NWCz/3kNXxrqG31ZT6hB2037vdrmgcKpokzXSqa5svUxWmJOELdPAJE6nuLqnypmRVGUMkxUK+bevbNx7JjvzkU7oc2f15SaSSpdYrIHEmnb9ZpKipj8oCUmv2UbXlcCmvrB/bRZSxu8KRqfW/Vl8ls22aJN45BIL5iCCtr0FOD2qUMmjjV9R9x6TdhW6Hl9avAaidBULtinGbdPdaH6MYfLz9vE1Kk5GD9eFbOiKEqZJKqjy6WlAePH+/4/erRva1IjptRMVBNebcG26Gam8m7X8weLLRGn7I8cr9vVWaY0SVT0UjHLdk2YbMTsnyk6oE3FSQ+Goq6b/A7YEsiSTZsCP5u+G26fbkxR3mwKU86BKea2xPY+xtYPeTxYbH9rwZ5PQrWVDxv2x+9NcaOKWVEUJcKIahtzQVtPSWV5cItXe1uw9Xlt162KMuE2wwkJt/eGV++WUNryipybSPdesKH9D0S9MhRFUcowUa2Yb7nlDz9m4nUVlmn1kvRDdvvG2lRvsKvDvNoHQyVcfsqhtk/cxqUgbld5uWnDtBLOdB6REe/CjdunrkhTvMFGwYuU/ke0Yv7pp59w4403ombNmqhcuTJatGiBL774wn/ccRxMmDABderUQeXKldG1a1d8++23AXUcPHgQAwYMQPXq1VGjRg3ccsstyDX5cymKopQxPCnmX3/9Fa1bt8bll1+OjIwM1K5dG99++y0aNWqERidfjz/yyCOYNm0aXn75ZTRo0AAPPPAANm/ejG3btiEuLg4A0KNHD+zduxfPPfccjh8/jkGDBuHCCy/Eq6++6qofRa38I/RXlX7Jbr0EpA3T5k9LpCKTfr6mOMEm1WOKAWFTYTY/axmP2pSN22TTNeWr8xrtzesTiIxj4fZ6eF35WRRu31/IumU2HHk8WEVYUvGJw7Xir7ii3hGvXiWm82z7S1Ixe3KXe+SRR5Camoq5c+f69zVo0MD/f8dxMGvWLNx///246qqrAAD/+Mc/kJycjIULF6J///74+uuv8f7772PdunVo27YtAODJJ59Ez5498de//hV169YNx7gURVGiFk+KuXnz5ujevTv27NmDVatW4cwzz8Qdd9yBIUOGAAC+//57NGrUCBs2bEArhkED0LFjR7Rq1QqzZ8/G3//+d9xzzz349ddf/cdPnDiBuLg4zJ8/H3379i3U7tGjR3H06FH/55ycHKSmpmLs2GxMnRrcncv0dt92t3UbmcsWNc2t6vC6esstJu8Kr14XoXrBhOplYWonmFggbnPieVXQMn+hV0K1zUaKMi8twjWOiLUxf//995gzZw4aN26MJUuWICMjA3fddRdePvmNyzz5vJ0s4vwlJyf7j2VmZiIpKSngeIUKFZCYmOgvI5k2bRoSEhL8/1JTU710W1EUJarwpJgrVaqEtm3b4rPPPvPvu+uuu7Bu3TqsXr0an332Gdq3b4+ff/4ZderU8Ze59tprERMTgzfeeANTp07Fyy+/jO3btwfUnZSUhEmTJiEjI6NQuybFnJ2djSlTfHcut/nH3MYtcOsPHGwErnD1x+bNIQnVu8PtuLx6ddj8n2W9xGsGE1PMlKLK2ur2arcONku1V2yrUYtLAZ8uCttExCrmOnXqoHnz5gH7mjVrhl27dgEAUk6+qdonMm3u27fPfywlJQX79+8POH7ixAkcPHjQX0YSGxuL6tWrB/xTFEU5XfH08q99+/aFlO5///tf1K9fH4DvRWBKSgqWL1/utzHn5ORgzZo1fiWclpaGrKwsrF+/Hm3atAEArFixAvn5+WjXrp2nzk+YANSr5/u/STnKOAIy04ZJAfPeYlIBp4q5APzhlSG9KyRuM3bI9mQ/JVIZ0uZqW0lnilHB8rZxE5syd6t8bU8S0pYsY6KwvVMpZbfR5ULx7ChYr8wh6BW3ytTmCRNsvabzbMdl3sZwte+WcF/P4sTTD/Pdd9+NSy65BFOnTsW1116LtWvX4vnnn8fzzz8PAIiJicGIESPw8MMPo3Hjxn53ubp16+Lqq68G4FPYV155JYYMGYJnn30Wx48fx7Bhw9C/f3/1yFAURUEQK/8WL16MsWPH4ttvv0WDBg0wcuRIv1cG4HOZmzhxIp5//nlkZWWhQ4cOeOaZZ3Duuef6yxw8eBDDhg3DokWLUK5cOfTr1w9PPPEEqroMv0Vbz9ix2ahVy2fWCLdvpVtfSFk+WH/dYO/WXs+3eXmE2w5qOz9UH9dwqqxweTPY6ifS1z1aON1tySYi1o8ZAHr37o3evXsbj8fExOChhx7CQ6b4kwASExNdLyZRFEUpa0R1rIxT5fyzEW4lFy5lSSJFjRS3inTbbnGeFy6PlGDb8+pzHWkxJ0rrO1LSRKxXhqIoilL8RLViDmXlHwm3CiLFHccgVIo7foGNSFRVpd0nemksWVI89Zf2+KIdVcyKoihlmKhWzFOmZOPECd+dyxZLgv7E1ar5tiZ7nskrweR3K6PP2WzW8ny3drlwK9xgfTrlOGx58ryuaAzXk0dxemsU93kdOvi2n3wS3nptRJuiLunroopZURSlDBPVirkoG3O431gH659sqkeW9+oRYDs/2HYiLQJZuPzMi9NLo7i55hrf9q23Tl0uWK+ISBlnSaOKWVEURfFMVCvmKVOyMW5c0YqZ2OIfeMVmKzaVl7ZYUwYRryrHraIntvNsSrW4883Z4l0XR6aS0s6hZ6s3mNjSxUmk+ty7Jdi/NVXMiqIoZZioVszZ2dm4807fnYt51dzaUE13faloRcz/QuW9ZrkI1rvC7V3epNBtmVrCZY+0PbG4rdfr9XKrdmW/APfZU4L1PLH1ya3ilFHp3L5nIKW1UvB0sXmrYlYURSnDeA5iFEncdtsf8ZhNmNSEKfuyVMomxWtqh5gyiNjKh+qnLG3pUjkTr94Zwdq0bQED3caLkO3L8UlkP0/V31B9pW12b1vf3EKlzKh04apXUlzR9GzY/maCbT/cER1LAlXMiqIoEUbU25iZ88+28i7Yu2ek+fd6JVh7Y0nHiTYRLvut/Jye/kdZt9mrbQqwuOfKNAZb/0P1lQ+VSPlbCBW1MSuKopRhot7G3KyZ7/+2u7F8i8+cfzIXX6ixcRmTQ2alkPVKWzcVoamcHIfXfklkP4P10pB2QY7D6yo0kzIO1uYu+yXbpxdPQWx9LS7FF+xTHpWyXCFos6+7bSdYTN9t21NMpNqE2f7gwSXXpipmRVGUCCOqbcwFV/7xrmbLxGvC7d3b5hccqq3aFuvCtD9cKiRcOf+Ky3e1JNRTaSu0YIm0FYKnG2pjVhRFKcNEtY05N7ewPcu0Uk+Wo43Za4Zi0yox+pYGG6PCZpeTNlOv0dfcKnrpd8wnELf1BEuwai/UlYPhaMvteaG07aZ+zh3fH/D9idvvSLSv9As2jkwkPhmpYlYURYkwotrGXNDWE612wXBhUwHhjqdgorhzCRan2inu71BJ+YbL9y3yKbKk/0bcKtmSxut3VW3MiqIoZZiotjED3tfjE5P3AZF3c9pA5QrDcClEr4rXtN9kW7b59bqdR1s/Qo0b4VZNBevrKqMHAn/YZktL0YW7PTkOt55Kbj11wtU/W/vFvSo3klHFrCiKEmFEtWJ+6ingxAnf/+lN4NX7wK1KMK3E89quVyVITJG3TFHg5OdQVUKocSLk+KU/eLBeJF5he6dSf24jA4Zb4YarnJwbPuXZYmuYlKotQqBs1/adtJW3nef2eLjPK0lUMSuKokQYUe2VUTBLdrhUjM1bwbTyz219wfp2hjuugMluGK52IsVOG0o/TCo/XO8TSku5md6XFBeR8l0IFfXKUBRFKcNEtY0Z8G6bNJ3n1jbrVinb6pH9cBsJzFSv1/NNqs/kvSFX5hWX/Y8Eex3D3Y+CuLW12ihtGyeV8ldfBXd+aSv+soAnxXz22WcjJiam0L+hQ4cCADp16lTo2O233x5Qx65du9CrVy/Ex8cjKSkJo0aNwgm+wVMURVG82Zj/97//IS8vz/95y5YtuOKKK7By5Up06tQJnTp1wrnnnouHCiSYi4+P99tj8vLy0KpVK6SkpGDmzJnYu3cvBg4ciCFDhmDq1KmuO01bz/Dh2Zg1y5uth8qPhMvOFq4obKHGeDD1QypetzZmt/WHSrh9ZYPpZ7gjBJaWovTavimGeEm1Hy2UpI3Zkymjdu3aAZ+nT5+ORo0aoWPHjv598fHxSJHR50+ydOlSbNu2DR988AGSk5PRqlUrTJ48GWPGjMGDDz6ISpUqBTEERVGU04ugvTKOHTuGunXrYuTIkRg3bhwAnylj69atcBwHKSkp6NOnDx544AHEx8cDACZMmIB33nkHGzdu9Nezc+dONGzYEF9++SVat27tqu1gYmXY/GiJjC8glesnn/i2zBZhi89MZFS7c84puj/EpBy9RpUzzUe4MqWYcOu9YnpCcJs1PFgvkoLzzVWAtpVx4YpVHWokPa8Z1d0+jchMKF77dbopZEnEKuaCLFy4EFlZWbj55pv9+2644QbUr18fdevWxaZNmzBmzBhs374db5280pmZmUgWEVX4OZO/WEVw9OhRHD161P85Jycn2G4riqJEPEEr5u7du6NSpUpYtGiRscyKFSvQpUsXfPfdd2jUqBFuvfVW/Pjjj1iyZIm/zG+//YYqVarg3XffRY8ePYqs58EHH8SkSZMK7Z8yJRtxcb47V7Bv72223WC9O4KNfWEiVBtsqH6+kW5fLckYu8FG5gt3dDmv9RX3NSyp/oXaXrDvEiLej/nHH3/EBx98gMGW7ITt2rUDAHx3Mop8SkoK9onI6/xssksDwNixY5Gdne3/t3v37mC6rSiKEhUEpZgffPBBPPfcc9i9ezcqVDBbQz799FN06NABX331FVq2bIn33nsPvXv3xt69e5GUlAQAeP755zFq1Cjs378fsbGxrtoPh43ZtqrLpjCJ17tuqCvpiFc7Y7jUS0mpGrftFWf851D7bqqnuGNWhwvNIRhIRNuY8/PzMXfuXKSnpwf8KO/YsQOvvvoqevbsiZo1a2LTpk24++67cdlll6Fly5YAgG7duqF58+a46aabMGPGDGRmZuL+++/H0KFDXf8oK4qinO54VsxLly5F9+7dsX37dpx77rn+/bt378aNN96ILVu24PDhw0hNTUXfvn1x//33B9xdfvzxR2RkZODDDz9ElSpVkJ6ejunTp59SeUuKypJtI1ilRpjTT/p6hutNfaTj1fuBFJe9L1ikZwPgPiZ3tBPsXEaLwjcRru9QRCvmbt26oajf8tTUVKxatcp6fv369fHuu+96bVZRFKXMENXR5dzYmMP9JjzUcm6hfa9KFd820tRKab25j0SK67tXWrhdPRpqfeHC7erZaFLMQXllKIqiKMVH1CvmF18MvHOFK+aFV68Heb7sj9e7eLjjS4dbrRR3vcHOv6wnlP6FGrM6XIS7vVBt6SXtnxwpqGJWFEUpw0S1YvbilWEi0u7Op6MN1g2h2qxJKPN0utmKJcX1HiTSbM7FhSpmRVGUMkxUK2YvK/+ildKKj2yrP9rmOxL6Gy77eaQRCXNbEqhiVhRFKcNEtWIumCXbLVK1eM3hZ6s31BgSphWGXnFr/4sUr41IjF4XKXNT2u26Pc/2nYtWZc1+Dx6sillRFKXMEtVZsn/7zbsN1JRJRJ4nM5W4rddrLj23NmSbwpf1iHwEhfplivFhGrepX6Z+e1W+Mju3qX630fdM5YuqN9g5DlblF7cCD7fPvNv+Uimnp/u2L7/s7rySxqtyZ7mSzM+hillRFCXCiGrFnJvr/i4vc+pRMVerVrhOAGjVyrel3cykQGXOQOaPMx232ZKlnU7mIDRhi1fMfvCzKZMXx22D8xRslDkZm5j9s9ne+YQjFbbb6H5F7Zd1EfbNlBPQrQI11S/L2+p1W79prt0+hYRqi6ZSNilnU/2mp81Q40KH+v6C7Y8fH1z7waCKWVEUJcKIasXcsOEfd2VGYZOKikqMqufQId+2Q4fAchKToqaCZXvyfO6XUPGxHywnFSOVcffuRdcnVYNJmUtMKsBke5bYbOtFxTkuWK8Jk9K94w7flk8UNjuwyWYur1dR/TH1gZ+lmie2sbrNaB6q3TzUjOkS09+EVyVNpRyq4rV5eUi/cNme7YnI9j6I9aiNWVEUpQwT1X7MvXtnY9Eidyv/5N2RSps5YN36+8q7s0mV2DKbmPpLJU/viGDfqHu1q8n+e8WrN4o8Lm3IpbHSMFyrLMtarjy3yl4+BUncZkqRT4nF7RfN8R05koPx49WPWVEUpUwS1Yp5ypRsxMX57lxe37hGagaOcMXaLa3YGiXdTjiJxj5HI6Ynikiff42VoSiKUoaJasV8yy2FM5i4hXdtYvJ2CDaDSbA+k173h2rbJSa/4uLKkuF1tVq45iuUPpvKl3ZmjlDnKty4nUeT540k2Kda2/le50MVs6IoShkmqhWzmztXqDFwvSpVt/WYlKlJ/ZDStr+59UKR5cOlKr3OTzhVYqj2+9MtWl24CDW2htu/rVBRxawoilKGieqVfyNHAjVr+v7PFXO2OAGEK+vat/dt5aohIm3PtIvR/5nlTSv+eNfmCjSezxWFcsUdVwbK/st+SUwr3SSyPbli0PZkIaPzERmLwys2e6OMLWKyR9oiuRW1QtHmk05kBEE5d26VcqjvL2zthXpeqFH0iIzzYloDcMEFvq1Xbw3TvBK3T6EmZL0jRrg7LxyoYlYURYkwotrGHEqWbCo0udKstO11ZbX90h63UvKUtveIV9TGrCiKUoaJasUcSpbsULM+hItQ3yBHqrog4fLWiCYibWyR1p9oRRWzoihKGSaqFXNRWbLdvjE32ZiDfWMu27cpxFDbkXhV/LZ+ElVZkU+kKuJw+bBHygpLVcyKoihlGM9+zIcOHcIDDzyABQsWYP/+/WjdujVmz56NCy+8EADgOA4mTpyIF154AVlZWWjfvj3mzJmDxo0b++s4ePAg7rzzTixatAjlypVDv379MHv2bFS1peEQFCzu1X+Wfsi2bBCmuMomf1+TApZKNlilbFIDts/yfFs/bdhiDkeqiisNijsCYXFFIowU2D9bPGdZPprxrJgHDx6MZcuW4ZVXXsHmzZvRrVs3dO3aFT/99BMAYMaMGXjiiSfw7LPPYs2aNahSpQq6d++OI0eO+OsYMGAAtm7dimXLlmHx4sX46KOPcOutt4ZvVIqiKFGMJxvz77//jmrVquHtt99Gr169/PvbtGmDHj16YPLkyahbty7uuece3HvvvQCA7OxsJCcn46WXXkL//v3x9ddfo3nz5li3bh3atm0LAHj//ffRs2dP7NmzB3Xr1rX2w42tp7i9LGwZSmwUd9xlr/WYVmWZ7H0k1AhsUnmb6o+U+VJKH9P7oeImYm3MJ06cQF5eHuLi4gL2V65cGZ988gl27tyJzMxMdO3a1X8sISEB7dq1w+rVqwEAq1evRo0aNfw/ygDQtWtXlCtXDmvWrCmy3aNHjyInJyfgn6IoyumKJxtztWrVkJaWhsmTJ6NZs2ZITk7Ga6+9htWrV+Occ85B5skACslcHH+S5ORk/7HMzEwkJSUFdqJCBSQmJvrLSKZNm4ZJkyYV2j9yJFCvnu//pngHwUYrs5VnTAtTewX7WNR+xhEIN8EqQ5kJ2na+Ldqe23gEMqZHuPy4w2WHPR2IlqcFt/2kUpZ/Q8UdvW/w4PDWeyo825hfeeUVOI6DM888E7GxsXjiiSdw/fXXo1y54nPwGDt2LLKzs/3/du/eXWxtKYqilDZB+zEfPnwYOTk5qFOnDq677jrk5ubiySefRKNGjbBhwwa0atXKX7Zjx45o1aoVZs+ejb///e+455578Ouvv/qPnzhxAnFxcZg/fz769u1rbTsUW49bheU2jrPbcqYVfrSxiocMfzQ1QpVAwu0bWlwxbaMtHkJBQvU9P92yZQd7zWzzFup3QcZzDvcaARKxNuaCVKlSBXXq1MGvv/6KJUuW4KqrrkKDBg2QkpKC5cuX+8vl5ORgzZo1SEtLAwCkpaUhKysL69ev95dZsWIF8vPz0a5duxCGoiiKcnrgWTEvWbIEjuOgSZMm+O677zBq1CjExcXh448/RsWKFfHII49g+vTpePnll9GgQQM88MAD2LRpE7Zt2+Z/adijRw/s27cPzz77LI4fP45Bgwahbdu2ePXVV131wYtXhqS4vBjkcZuNWvYnGhSkG0p6HF6vc1ErQ6NRzZ+KUL/74fJo8npeqFmB3HoMmZ4GbathBw8uOcXseYFJdnY2xo4diz179iAxMRH9+vXDlClTULFiRQDA6NGjcfjwYdx6663IyspChw4d8P777wd4csybNw/Dhg1Dly5d/AtMnnjiifCNSlEUJYqJ6lgZRcVjlndRaecz2XRN6sC02sjUjimTCnGrCmii5139k0+Kbp+E2+5nw2tWi+Iq5zYmCOfRTe7HYJ96TG2H29Zpo7gUf7Djsf0NFcwmE0z98m+PfysdOvi2bj2yiGmcU6fmYPz4CLcxK4qiKMVDVCtmL/GYg7VfEbd3VXk+z+ObY+Y38xqTwqTEr7km8DNVgglbu7aVePQdLS5PA5t9NFIijYXSdrTbsMNNccUSmTHDt+XfiCnGhttcg1HhlaEoiqIUD1GdJXv6dGDqVN//qeRklDnaFg2LCgspUpllWmaVJlz5x/07dvi2UhHzeKNGgfW7Xe/Pfki/Zt7NGSWPWbfJV1/5ttK3k9jGbctALJ8YbCv/TKrRbVZu+dlt3GuWP1UWbrc+3rbypr57XYUYLr/fUL0cgm1Xlrdd41BjlJvml38zpr99W0TK0nyiUcWsKIoSYZw2NubiorT8ckvahqrR24InmuzepyJS+xUpqI1ZURSlDBPVirkoP2Yb0iZMG2y4FaNbO2VJ2Qvdxn2w9Zu49X4JNlaHqZyNYPy7i6svJUVxxx4PtT+hnhfuv5Fg4zmrYlYURSnDRLVizs7OxqxZvjuXXNlFbNHRvHoTmHCrmIN9411c8QrCrbZKW625hV4pQGGfba+KLdIUtvwOu1ntWJz9CDaujNv6vT6Vyn65jaCoK/8URVHKMFGvmIO9c9nWyROvNlivMTfc9lPWT1gvfTXfestb/ab2SspeWVyrvoqjvyXlARMtTx3hJtzj5lORXJMQbPQ9tTEriqKUYaJaMQ8fno3YWN+dS0aLI6F6RdjsU8Hapk0xKNxGvSNes3V7jQFissdJ3K7eMkXlk7n/ZDs2+6Db/hE+aQD27DAmhUW8ZhgPly3adC2Lijld8LhtPLK8xHSNTatuw21jLq4nDtvTsipmRVGUMkxUK2YvfsxelZxNYbtVSaZ2iutNebhtxMEq8lDftBNZjyn6XTj7E26f6mixFUeKF0mo8+s2NroJ0/mqmBVFUcowUR1dDgi/QiOm+qQdz6tC9WrnCxXp1+0Vk83WRqgr9kKtJxJUaiT0wQul7YfttR1TedP7JrfwacwWfa44UcWsKIoSYZQZG3NxE6ydLNooLhUVCUo3EvpwOnG6zCeV84gRamNWFEUps0S1Yh4+PBv16gXeuSLl7hzsm/1weUF49RKJlHlTwk9JXWOv3+3S6kew9Q0eXHKKOSpf/vFecvRoDo4cCTyWk1MKHSoC9kv2x7TfdNxrebfteC2nRC8ldY2D/a6WdD+Cr89XYUlo2ahUzN9//z0aMYmeoihKCbJ7926cddZZxdpGVCrmxMREAMCuXbuQkJBQyr0pHnJycpCamordu3cX+2NTaaFjPD0oS2Pctm0b6tatW+ztReUPc7lyvneWCQkJp+0XgVSvXl3HeBqgYzw9OPPMM/2/P8WJemUoiqJEGPrDrCiKEmFE5Q9zbGwsJk6ciNjY2NLuSrGhYzw90DGeHpT0GKPSK0NRFOV0JioVs6IoyumM/jAriqJEGPrDrCiKEmHoD7OiKEqEEZU/zE8//TTOPvtsxMXFoV27dli7dm1pd8kV06ZNw4UXXohq1aohKSkJV199NbZv3x5Q5siRIxg6dChq1qyJqlWrol+/ftjHTK0n2bVrF3r16oX4+HgkJSVh1KhROHHiREkOxTXTp09HTEwMRowY4d93Oozxp59+wo033oiaNWuicuXKaNGiBb744gv/ccdxMGHCBNSpUweVK1dG165d8e233wbUcfDgQQwYMADVq1dHjRo1cMsttyA31MwGYSIvLw8PPPAAGjRogMqVK6NRo0aYPHlyQJyIaBvjRx99hD59+qBu3bqIiYnBwoULA46HazybNm3CpZdeiri4OKSmpmLGjBneO+tEGa+//rpTqVIl5+9//7uzdetWZ8iQIU6NGjWcffv2lXbXrHTv3t2ZO3eus2XLFmfjxo1Oz549nXr16jm5ubn+MrfffruTmprqLF++3Pniiy+ciy++2Lnkkkv8x0+cOOGcf/75TteuXZ0NGzY47777rlOrVi1n7NixpTGkU7J27Vrn7LPPdlq2bOkMHz7cvz/ax3jw4EGnfv36zs033+ysWbPG+f77750lS5Y43333nb/M9OnTnYSEBGfhwoXOV1995fzf//2f06BBA+f333/3l7nyyiudCy64wPn888+djz/+2DnnnHOc66+/vjSGVIgpU6Y4NWvWdBYvXuzs3LnTmT9/vlO1alVn9uzZ/jLRNsZ3333XGT9+vPPWW285AJwFCxYEHA/HeLKzs53k5GRnwIABzpYtW5zXXnvNqVy5svPcc8956mvU/TBfdNFFztChQ/2f8/LynLp16zrTpk0rxV4Fx/79+x0AzqpVqxzHcZysrCynYsWKzvz58/1lvv76aweAs3r1asdxfF+ucuXKOZmZmf4yc+bMcapXr+4cPXq0ZAdwCg4dOuQ0btzYWbZsmdOxY0f/D/PpMMYxY8Y4HTp0MB7Pz893UlJSnJkzZ/r3ZWVlObGxsc5rr73mOI7jbNu2zQHgrFu3zl/mvffec2JiYpyffvqp+Drvkl69ejl/+ctfAvZdc801zoABAxzHif4xyh/mcI3nmWeecc4444yA7+mYMWOcJk2aeOpfVJkyjh07hvXr16Nr167+feXKlUPXrl2xevXqUuxZcGRnZwP4IyjT+vXrcfz48YDxNW3aFPXq1fOPb/Xq1WjRogWSCyQ26969O3JycrB169YS7P2pGTp0KHr16hUwFuD0GOM777yDtm3b4s9//jOSkpLQunVrvPDCC/7jO3fuRGZmZsAYExIS0K5du4Ax1qhRA23btvWX6dq1K8qVK4c1a9aU3GAMXHLJJVi+fDn++9//AgC++uorfPLJJ+jRoweA02OMBQnXeFavXo3LLrsMlSpV8pfp3r07tm/fjl9//dV1f6IqiNEvv/yCvLy8gD9YAEhOTsY333xTSr0Kjvz8fIwYMQLt27fH+eefDwDIzMxEpUqVUKNGjYCyycnJyMzM9Jcpavw8Fgm8/vrr+PLLL7Fu3bpCx06HMX7//feYM2cORo4ciXHjxmHdunW46667UKlSJaSnp/v7WNQYCo4xKSkp4HiFChWQmJgYEWO87777kJOTg6ZNm6J8+fLIy8vDlClTMGDAAAA4LcZYkHCNJzMzEw0aNChUB4+dccYZrvoTVT/MpxNDhw7Fli1b8Mknn5R2V8LK7t27MXz4cCxbtgxxcXGl3Z1iIT8/H23btsXUqVMBAK1bt8aWLVvw7LPPIj09vZR7Fx7+9a9/Yd68eXj11Vdx3nnnYePGjRgxYgTq1q172owxkokqU0atWrVQvnz5Qm/w9+3bh5SUlFLqlXeGDRuGxYsXY+XKlQEBt1NSUnDs2DFkZWUFlC84vpSUlCLHz2Olzfr167F//378v//3/1ChQgVUqFABq1atwhNPPIEKFSogOTk56sdYp04dNG/ePGBfs2bNsGvXLgB/9PFU39OUlBTs378/4PiJEydw8ODBiBjjqFGjcN9996F///5o0aIFbrrpJtx9992YNm0agNNjjAUJ13jC9d2Nqh/mSpUqoU2bNli+fLl/X35+PpYvX460tLRS7Jk7HMfBsGHDsGDBAqxYsaLQI0+bNm1QsWLFgPFt374du3bt8o8vLS0NmzdvDviCLFu2DNWrVy/0Y1EadOnSBZs3b8bGjRv9/9q2bYsBAwb4/x/tY2zfvn0hN8f//ve/qF+/PgCgQYMGSElJCRhjTk4O1qxZEzDGrKwsrF+/3l9mxYoVyM/PR7t27UpgFKfmt99+KxR3uHz58sjPzwdweoyxIOEaT1paGj766CMcP37cX2bZsmVo0qSJazMGgOh0l4uNjXVeeuklZ9u2bc6tt97q1KhRI+ANfqSSkZHhJCQkOB9++KGzd+9e/7/ffvvNX+b222936tWr56xYscL54osvnLS0NCctLc1/nK5k3bp1czZu3Oi8//77Tu3atSPGlawoCnplOE70j3Ht2rVOhQoVnClTpjjffvutM2/ePCc+Pt755z//6S8zffp0p0aNGs7bb7/tbNq0ybnqqquKdL1q3bq1s2bNGueTTz5xGjduHDHucunp6c6ZZ57pd5d76623nFq1ajmjR4/2l4m2MR46dMjZsGGDs2HDBgeA89hjjzkbNmxwfvzxx7CNJysry0lOTnZuuukmZ8uWLc7rr7/uxMfHn/7uco7jOE8++aRTr149p1KlSs5FF13kfP7556XdJVcAKPLf3Llz/WV+//1354477nDOOOMMJz4+3unbt6+zd+/egHp++OEHp0ePHk7lypWdWrVqOffcc49z/PjxEh6Ne+QP8+kwxkWLFjnnn3++Exsb6zRt2tR5/vnnA47n5+c7DzzwgJOcnOzExsY6Xbp0cbZv3x5Q5sCBA87111/vVK1a1alevbozaNAg59ChQyU5DCM5OTnO8OHDnXr16jlxcXFOw4YNnfHjxwe4gUXbGFeuXFnk3196errjOOEbz1dffeV06NDBiY2Ndc4880xn+vTpnvuqYT8VRVEijKiyMSuKopQF9IdZURQlwtAfZkVRlAhDf5gVRVEiDP1hVhRFiTD0h1lRFCXC0B9mRVGUCEN/mBVFUSIM/WFWFEWJMPSHWVEUJcLQH2ZFUZQIQ3+YFUVRIoz/D99Vi6IY9awDAAAAAElFTkSuQmCC", 64 | "text/plain": [ 65 | "
" 66 | ] 67 | }, 68 | "metadata": {}, 69 | "output_type": "display_data" 70 | } 71 | ], 72 | "source": [ 73 | "from matspy import spy\n", 74 | "\n", 75 | "spy(A)" 76 | ] 77 | }, 78 | { 79 | "cell_type": "markdown", 80 | "metadata": { 81 | "ExecuteTime": { 82 | "end_time": "2023-08-22T23:04:45.970063Z", 83 | "start_time": "2023-08-22T23:04:45.607749Z" 84 | }, 85 | "collapsed": false, 86 | "jupyter": { 87 | "outputs_hidden": false 88 | } 89 | }, 90 | "source": [ 91 | "# Precision\n", 92 | "\n", 93 | "Sometimes we may wish to set near-zero values to zero. The `precision` argument does that. Only values `abs(value) > precision` are plotted.\n", 94 | "\n", 95 | "This argument is compatible with [matplotlib.pyplot.spy()](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.spy.html)'s `precision` parameter.\n", 96 | "\n", 97 | "As a simple demonstration, use `precision` to filter random values:" 98 | ] 99 | }, 100 | { 101 | "cell_type": "code", 102 | "execution_count": 4, 103 | "metadata": { 104 | "ExecuteTime": { 105 | "end_time": "2023-08-31T00:41:41.616444Z", 106 | "start_time": "2023-08-31T00:41:41.604573Z" 107 | }, 108 | "jupyter": { 109 | "source_hidden": true 110 | } 111 | }, 112 | "outputs": [ 113 | { 114 | "data": { 115 | "text/html": [ 116 | "
precision = 0precision = 0.2precision = 0.8
" 117 | ], 118 | "text/plain": [ 119 | "" 120 | ] 121 | }, 122 | "metadata": {}, 123 | "output_type": "display_data" 124 | } 125 | ], 126 | "source": [ 127 | "arr = np.random.random((100, 100))\n", 128 | "\n", 129 | "from IPython.display import display, HTML\n", 130 | "from matspy import to_sparkline\n", 131 | "\n", 132 | "precisions = [0, 0.2, 0.8]\n", 133 | "display(HTML(f''\n", 134 | " f''\n", 135 | " f''\n", 136 | " f''\n", 137 | " f\"\"\n", 138 | " f\"\"\n", 139 | " f\"\"\n", 140 | " f\"
precision = {precisions[0]}precision = {precisions[1]}precision = {precisions[2]}
{to_sparkline(arr, sparkline_size=1.5, precision=precisions[0])}{to_sparkline(arr, sparkline_size=1.5, precision=precisions[1])}{to_sparkline(arr, sparkline_size=1.5, precision=precisions[2])}
\"))" 141 | ] 142 | }, 143 | { 144 | "cell_type": "code", 145 | "execution_count": null, 146 | "metadata": { 147 | "ExecuteTime": { 148 | "end_time": "2023-08-31T00:41:41.617408Z", 149 | "start_time": "2023-08-31T00:41:41.615424Z" 150 | }, 151 | "collapsed": false, 152 | "jupyter": { 153 | "outputs_hidden": false 154 | } 155 | }, 156 | "outputs": [], 157 | "source": [] 158 | } 159 | ], 160 | "metadata": { 161 | "kernelspec": { 162 | "display_name": "Python 3 (ipykernel)", 163 | "language": "python", 164 | "name": "python3" 165 | }, 166 | "language_info": { 167 | "codemirror_mode": { 168 | "name": "ipython", 169 | "version": 3 170 | }, 171 | "file_extension": ".py", 172 | "mimetype": "text/x-python", 173 | "name": "python", 174 | "nbconvert_exporter": "python", 175 | "pygments_lexer": "ipython3", 176 | "version": "3.11.2" 177 | } 178 | }, 179 | "nbformat": 4, 180 | "nbformat_minor": 4 181 | } 182 | -------------------------------------------------------------------------------- /demo-pydata-sparse.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": { 7 | "jupyter": { 8 | "source_hidden": true 9 | }, 10 | "ExecuteTime": { 11 | "end_time": "2023-08-31T04:16:28.689682Z", 12 | "start_time": "2023-08-31T04:16:28.275270Z" 13 | } 14 | }, 15 | "outputs": [], 16 | "source": [ 17 | "import sparse" 18 | ] 19 | }, 20 | { 21 | "cell_type": "code", 22 | "execution_count": 2, 23 | "metadata": { 24 | "jupyter": { 25 | "source_hidden": true 26 | }, 27 | "ExecuteTime": { 28 | "end_time": "2023-08-31T04:16:28.739002Z", 29 | "start_time": "2023-08-31T04:16:28.693626Z" 30 | } 31 | }, 32 | "outputs": [], 33 | "source": [ 34 | "import scipy\n", 35 | "A = sparse.COO.from_scipy_sparse(scipy.io.mmread(\"doc/matrices/email-Eu-core.mtx.gz\")).asformat(\"csr\")" 36 | ] 37 | }, 38 | { 39 | "cell_type": "markdown", 40 | "metadata": { 41 | "ExecuteTime": { 42 | "end_time": "2023-08-22T23:04:28.653403Z", 43 | "start_time": "2023-08-22T23:04:28.580379Z" 44 | } 45 | }, 46 | "source": [ 47 | "\n", 48 | "Now view the entire matrix as a spy plot:" 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": 3, 54 | "metadata": { 55 | "ExecuteTime": { 56 | "end_time": "2023-08-31T04:16:30.310280Z", 57 | "start_time": "2023-08-31T04:16:28.744808Z" 58 | } 59 | }, 60 | "outputs": [ 61 | { 62 | "data": { 63 | "text/plain": "
", 64 | "image/png": "iVBORw0KGgoAAAANSUhEUgAAAYEAAAFgCAYAAABdQcUDAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8pXeV/AAAACXBIWXMAAA9hAAAPYQGoP6dpAAB6MElEQVR4nO2dfZyVc/7/X9PdTFPNZJpmpqhUQpFqSxmFUrpvSbu0Gw2/Ct1QsqJCQjeya8td2LVYcrOskC8qIqxUontCotAU0kyF7ubz++P0Oua853z6XNc5Z27Omffz8ehxZq5zXZ+b65zper0/n/dNkjHGQFEURamUVCnvASiKoijlhz4EFEVRKjH6EFAURanE6ENAURSlEqMPAUVRlEqMPgQURVEqMfoQUBRFqcToQ0BRFKUSow8BRVGUSow+BJSEZe/evRg+fDhycnKQlJSEcePG4auvvkJSUhIee+yx8h5eheWyyy7D8ccfX97DUMoIXw+BvXv3YsqUKejduzcyMjKcf0yffPIJevfujdq1ayMjIwOXXnopvv/++xLnFRUVYdasWWjatClSUlJw2mmn4emnny5x3mWXXYakpKQS/04++WQ/06hU83vhhRdw8cUXo1mzZkhNTcVJJ52E6667Drt37y5x7vHHHx+2/6uuuirkvMceeyzseUlJScjPzw+e9/bbb1vPS0pKwrRp04Lnbt++HTfeeCO6deuGOnXqICkpCW+//bbneV522WXo2rVryLHp06fjsccew8iRI/HEE0/g0ksv9dxeNGzcuBG33norvvrqq7DvFxUVYe7cuWjbti1q1qyJevXq4dxzz8WaNWusbc6bNw9JSUmoXbt2ife6du2Kyy67LEajTzweeOABfegfhWp+Tv7hhx9w2223oXHjxmjTps1R/0i/+eYbnH322UhPT8f06dOxd+9e/PWvf8W6deuwYsUK1KhRI3ju5MmTMXPmTIwYMQKnn346XnrpJfz5z39GUlISBg8eHNJucnIy/vnPf4YcS09P9zONSjW/K664Ag0bNsQll1yCxo0bY926dbjvvvvw6quv4qOPPkLNmjVDzm/bti2uu+66kGMnnnhi2LZvu+02NG3aNORY3bp1gz+3bNkSTzzxRInrnnjiCSxatAg9e/YMHtu0aRPuvPNOtGjRAq1bt8ayZcv8TrUES5YswRlnnIEpU6YEj9n+Y44lGzduxNSpU9G1a9ewivr//b//h3nz5mHo0KEYM2YM9u3bh48//hg7d+4M297evXsxYcIE1KpVq5RHnpg88MADyMzM1AelDeODX3/91Wzfvt0YY8zKlSsNAPPoo4+GPXfkyJGmZs2a5uuvvw4eW7x4sQFgHnrooeCxb775xlSvXt2MHj06eKyoqMicddZZ5rjjjjOHDh0KHs/LyzO1atXyPNZ///vf1vdffvllk5+fH7fz88pbb71V4tjjjz9uAJh//OMfIcebNGli+vXr52zz0UcfNQDMypUrIxrTCSecYFq0aBFyrLCw0Pz444/GGGOee+45AyDs2G3k5eWZc845J+RY06ZNS8xny5YtR/1cY8HRxv/ss88aAOaFF17w3N4NN9xgTjrpJDNkyJCw349zzjnH5OXlRTHiUPLy8kyTJk1i1l55c8opp5T4bii/4Ws5KDk5GTk5OZ7O/e9//4v+/fujcePGwWM9evTAiSeeiP/85z/BYy+99BIOHjyIUaNGBY8lJSVh5MiR+Oabb8IqwsOHD6OwsPCo/T///PMYOnQo7rnnnhLvLVy4EIMGDcLf/va3uJ2fV+QSCQAMHDgQQGA5KxwHDhzAvn37PLW/Z88eHD582PN4VqxYgS+++AJDhgwJOV6nTh1kZGR4budocBlqy5Yt+L//+7/g8tPRrIAlS5bgrLPOQq1atVC3bl2cf/75Je7P119/jVGjRuGkk04KLuP88Y9/DGn3sccewx//+EcAQLdu3YJ906q8++670bFjRwwcOBBFRUXO+/z555/j73//O+6++25Uq+bLcA/Lk08+ifbt26NmzZrIyMjA4MGDsW3bNud1RUVFmD17Nk455RSkpKQgOzsbV155JX766aeQ844//nj0798fb7/9Njp06ICaNWuidevWwfm/8MILaN26NVJSUtC+fXt8/PHHJfr69NNP8Yc//AEZGRlISUlBhw4d8PLLL4ecwyXJ//3vfxg/fjzq16+PWrVqYeDAgSFLsscffzw2bNiApUuXBj8L/k0cPHgQU6dORYsWLZCSkoJ69eqhS5cuWLx4sc+7GuCXX37BNddcg8zMTNSpUwe///3v8e233yIpKQm33npryLnffvsthg0bhoYNGyI5ORlNmzbFyJEjceDAARhj0K1bN9SvXz/EOjxw4ABat26N5s2bB783e/bswbhx43D88ccjOTkZWVlZOO+88/DRRx95HnepbAx/++232LlzJzp06FDivY4dO4Z88B9//DFq1aqFli1bljiP7xfn559/RlpaGtLT05GRkYHRo0dj7969JfoZMmQIRo0ahXHjxuHf//538Pj777+PCy+8EGeddRZuv/32uJ1fNHDdPjMzs8R7S5YsQWpqKmrXro3jjz8ec+bMsbbTrVs3pKWlITU1Fb///e/x+eefO/ueN28eAJR4CMQSLkNlZmaibdu2eOKJJ/DEE0+gfv36Yc9/44030KtXL+zcuRO33norxo8fj/fffx+dO3cO+Q9+5cqVeP/99zF48GDcc889uOqqq/Dmm2+ia9eu+PnnnwEAZ599Nq655hoAwKRJk4J9t2zZEoWFhVixYgVOP/10TJo0Cenp6ahduzaaNWsWIhyKM27cOHTr1g19+/aN+r5MmzYNQ4cORYsWLXD33Xdj3LhxePPNN3H22WeH3SMqzpVXXonrr78enTt3xpw5c3D55Zdj3rx56NWrFw4ePBhy7hdffIE///nPGDBgAGbMmIGffvoJAwYMwLx583DttdfikksuwdSpU7F582ZcdNFFKCoqCl67YcMGnHHGGfjkk09w44034m9/+xtq1aqFCy64APPnzy8xrquvvhpr1qzBlClTMHLkSCxYsABjxowJvj979mwcd9xxOPnkk4OfxeTJkwEAt956K6ZOnYpu3brhvvvuw+TJk9G4cWNf/4EW57LLLsO9996Lvn374s4770TNmjXRr1+/Eud999136NixI5555hlcfPHFuOeee3DppZdi6dKl+Pnnn5GUlIR//etf+PXXX0P246ZMmYINGzbg0UcfDS4NXnXVVZg7dy4GDRqEBx54AH/5y19Qs2ZNq8ALS6QmxNGWS/heuOWY66+/3gAwv/76qzHGmH79+plmzZqVOG/fvn0GgLnxxhuDx2688UZzww03mGeffdY8/fTTJi8vzwAwnTt3NgcPHizRxuHDh82f/vQnU61aNfPSSy+ZtWvXmmOOOcacfvrpZs+ePXE/v0gZNmyYqVq1qvnss89Cjg8YMMDceeed5sUXXzSPPPKIOeusswwAM2HChJDznn32WXPZZZeZxx9/3MyfP9/cdNNNJjU11WRmZpqtW7da+z106JDJzs42HTt2POr4IlkOCke45a1wy0Ft27Y1WVlZweUoY4xZs2aNqVKlihk6dGjw2M8//1yij2XLlpX4LtjG/9FHHxkApl69eiY7O9s88MADZt68eaZjx44mKSnJvPbaayHnv/LKK6ZatWpmw4YNxpjolgu/+uorU7VqVTNt2rSQ4+vWrTPVqlULOS6Xg959910DwMybNy/k2tdff73E8SZNmhgA5v333w8eW7hwoQFQYvn0oYceKnGfunfvblq3bh38+zEmsHx65plnhiwhckmyR48epqioKHj82muvNVWrVjW7d+8OHrMtB7Vp08bT8qcXVq1aZQCYcePGhRy/7LLLDAAzZcqU4LGhQ4eaKlWqhF1OLT4X3p8nn3zSfPDBB6Zq1aol2k9PTw9Zao6EUnkIvPPOOwaAefbZZ0u8d/PNNxsA5qeffjLGGHPuueeali1bljjv8OHDBoAZO3bsUccxbdo0A8A8/fTTYd8/cOCA6dOnj0lJSTFZWVmmZcuW5ocffkiY+fll3rx5Yf9jD0dRUZHp1auXqVatmtm2bdtRz3333XdNUlKSufLKK63n8D+DOXPmHLWtsnwIfPfdd9b70atXL5OZmRm27QMHDpgffvjBfP/996Zu3bohf5y28fN7A8B88MEHweN79uwxmZmZpnPnzsFj+/fvNy1atDBjxowJHovmIXD33XebpKQk8/nnn5vvv/8+5F/Lli1Njx49Qvop/hC45pprTHp6utm5c2eJa2vXrm2GDx8ePLdJkyamVatWIX3v3r3bACjxWaxevdoAMI888ogxxpgff/zRJCUlmdtvv71EP1OnTjUAzDfffGOM+e0h8J///CekzRdeeMEAMGvWrAkesz0EzjnnHHP88ceXEEORwL9T2RYfDnwIHD582KSlpZnzzz/fU7u9evUyxxxzjGnRooU58cQTSwiRJk2amA4dOphvv/024rGXynIQPU72799f4r1ff/015JyaNWt6Os/GtddeiypVquCNN94I+3716tUxZ84cHDx4EDt37sS0adNQr14975MJQ0Wanx/effddDBs2DL169Qpxz7SRlJSEa6+9FocOHXK6a3bp0gWdOnU66jjnzZuHqlWr4uKLL/Y79FLj66+/BgCcdNJJJd5r2bIlfvjhh+D66y+//IJbbrkFjRo1QnJyMjIzM1G/fn3s3r0bBQUFzr74WTdt2hSdOnUKHq9duzYGDBiAFStW4NChQwCAv//97/jhhx8wderUqOcIBPYWjDFo0aIF6tevH/Lvk08+sXom8dqCggJkZWWVuHbv3r0lri2+Twb85t3WqFGjsMe5r/DFF1/AGIObb765RD/08HL1dcwxx4S0eTRuu+027N69GyeeeCJat26N66+/HmvXrnVeF46vv/4aVapUKeEtd8IJJ4T8/v3336OwsBCnnnqqp3YfeeQR/Pzzz/j888/x2GOPlfj/YtasWVi/fj0aNWqEjh074tZbb8WXX37pa+zR7zSFoUGDBgACvt+S7du3IyMjA8nJycFz33rrLRhjkJSUFHIeADRs2PCofXGDbteuXWHf//HHH3HBBRcgMzMTOTk5GDFiBE466SS0atUqorlxzMXHWJyynp9X1qxZg9///vc49dRT8fzzz3veZOQfrpf+GzVqhE2bNoV975dffsH8+fPRo0cPZGdnex94BeLqq6/Go48+inHjxiE3Nxfp6elBN9/i69o2+FmHm39WVhYOHjwYfODccccdGDVqFAoLC4NOAnv37oUxBl999RVSU1ORlZXleexFRUVISkrCa6+9hqpVq5Z4P1z8QfFrs7Kygvs5ErnXEq79ox03Ryrc8h7+5S9/Qa9evcKeK/9TdbV5NM4++2xs3rwZL730EhYtWoR//vOf+Pvf/44HH3wQw4cPd15fFrz99ttBEblu3Trk5uaGvH/RRRfhrLPOwvz587Fo0SLcdddduPPOO/HCCy+gT58+nvoolYfAsccei/r16+PDDz8s8d6KFSvQtm3b4O9t27bFP//5T3zyySch/zEvX748+P7R2LNnD3744Yewm3579+5F37598d1332Hp0qVo0KABunTpgvPOOw//+9//Io6KrCjz88rmzZvRu3dvZGVl4dVXXz3qH7yEqsJL/19++aX1vJdffhl79uwp1Q3hSGjSpAkAhH14ffrpp8jMzAxuwj3//PPIy8sL8Sr79ddfS2yqFn/YF6dhw4bIycnBt99+W+K97777DikpKahTpw62bt2KvXv3YtasWZg1a1aJc5s2bYrzzz8fL774otdponnz5jDGoGnTpta4j6Nd+8Ybb6Bz585OyzUamjVrBiBgvffo0SNm7do+DwDIyMjA5Zdfjssvvxx79+7F2WefjVtvvdX3Q6BJkyYoKirCli1b0KJFi+DxL774IuS8+vXrIy0tDevXr3e2uX37dlx99dXo2bMnatSoEXw48jtLGjRogFGjRmHUqFHYuXMnfve732HatGmeHwKlljZi0KBBeOWVV0Lcz95880189tlnQRc6ADj//PNRvXp1PPDAA8Fjxhg8+OCDOPbYY3HmmWcCCPyx7dmzp0Q/t99+O4wx6N27d8jx/fv34/zzz8eGDRvw6quv4rTTTkP9+vWxePFiVK1aFeedd15IdGu8zc8r+fn56NmzJ6pUqYKFCxda/5PetWtXCVfPgwcPYubMmahRowa6desWPB4uKvrVV1/FqlWrrON86qmnkJqaGnRPrSg0aNAAbdu2xeOPPx7yn/n69euxaNGiEK+cqlWrllCY9957b4n7xodGOI+biy++GNu2bQtxQ/zhhx/w0ksv4dxzz0WVKlWQlZWF+fPnl/jXrVs3pKSkYP78+Zg4caKveV544YWoWrUqpk6dWmIOxhj8+OOP1msvuugiHD58OKw33aFDh5yeRV7JyspC165d8dBDD4W1ssN977xQq1atsGOUc65duzZOOOGEsMu3Lmi5FP87BwLfj+JUqVIFF1xwARYsWBBWRBb/bEaMGIGioiI88sgjePjhh1GtWjUMGzYseM7hw4dLLENmZWWhYcOGvubg2xK47777sHv3bnz33XcAgAULFuCbb74BEDCXuc43adIkPPfcc+jWrRvGjh2LvXv34q677kLr1q1x+eWXB9s77rjjMG7cONx11104ePAgTj/9dLz44ot49913g2vIQOA/s3bt2uFPf/pTMI3CwoUL8eqrr6J37944//zzQ8b5/PPP47333sOCBQtCTKjGjRtj0aJFOOuss3D33XeXUFrxMj9aMa4I2N69e+PLL7/EhAkT8N577+G9994LvpednY3zzjsPQECp33HHHfjDH/6Apk2bYteuXXjqqaewfv16TJ8+PSR+4swzz0S7du3QoUMHpKen46OPPsK//vUvNGrUCJMmTSoxhl27duG1117DoEGDjmqF3HHHHQACboJAILKY473pppuOOs9ouOuuu9CnTx/k5uZi2LBh+OWXX3DvvfciPT09xL+7f//+eOKJJ5Ceno5WrVph2bJleOONN0rsMbVt2xZVq1bFnXfeiYKCAiQnJ+Pcc89FVlYWJk6ciP/85z8YNGgQxo8fj/T0dDz44IM4ePAgpk+fDgBITU3FBRdcUGKcL774IlasWBH2PRfNmzfHHXfcgYkTJ+Krr77C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" 65 | }, 66 | "metadata": {}, 67 | "output_type": "display_data" 68 | } 69 | ], 70 | "source": [ 71 | "from matspy import spy\n", 72 | "\n", 73 | "spy(A)" 74 | ] 75 | }, 76 | { 77 | "cell_type": "code", 78 | "execution_count": 3, 79 | "outputs": [], 80 | "source": [], 81 | "metadata": { 82 | "collapsed": false, 83 | "ExecuteTime": { 84 | "end_time": "2023-08-31T04:16:30.312084Z", 85 | "start_time": "2023-08-31T04:16:30.311101Z" 86 | } 87 | } 88 | } 89 | ], 90 | "metadata": { 91 | "kernelspec": { 92 | "display_name": "Python 3 (ipykernel)", 93 | "language": "python", 94 | "name": "python3" 95 | }, 96 | "language_info": { 97 | "codemirror_mode": { 98 | "name": "ipython", 99 | "version": 3 100 | }, 101 | "file_extension": ".py", 102 | "mimetype": "text/x-python", 103 | "name": "python", 104 | "nbconvert_exporter": "python", 105 | "pygments_lexer": "ipython3", 106 | "version": "3.11.2" 107 | } 108 | }, 109 | "nbformat": 4, 110 | "nbformat_minor": 4 111 | } 112 | -------------------------------------------------------------------------------- /doc/images/sparkline_aa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alugowski/matspy/b0b59c8c9765206067b988deaafce0c26640c9f8/doc/images/sparkline_aa.png -------------------------------------------------------------------------------- /doc/images/spy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alugowski/matspy/b0b59c8c9765206067b988deaafce0c26640c9f8/doc/images/spy.png -------------------------------------------------------------------------------- /doc/images/triple_product.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alugowski/matspy/b0b59c8c9765206067b988deaafce0c26640c9f8/doc/images/triple_product.png -------------------------------------------------------------------------------- /doc/matrices/email-Eu-core.mtx.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alugowski/matspy/b0b59c8c9765206067b988deaafce0c26640c9f8/doc/matrices/email-Eu-core.mtx.gz -------------------------------------------------------------------------------- /matspy/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | import dataclasses 6 | from dataclasses import dataclass, asdict 7 | from typing import Type, Tuple, Dict, List, Union 8 | 9 | from .adapters import Driver, MatrixSpyAdapter 10 | 11 | 12 | @dataclass 13 | class MatSpyParams: 14 | indices: bool = True 15 | """Whether to show row/column indices.""" 16 | 17 | title: Union[bool, str] = True 18 | """Title of spy plot. If `True` then generate matrix description such as dimensions, nnz, datatype.""" 19 | 20 | shading: str = "relative" 21 | """ 22 | How to shade buckets: 23 | - `'binary'`: A nonempty bucket is considered full. 24 | - `'relative'`: A bucket is shaded relative to the fullness of the fullest bucket in the matrix. 25 | - `'absolute'`: A bucket is shaded according to how full it is. 26 | """ 27 | 28 | shading_absolute_min: float = 0.2 29 | """ 30 | if `shading == 'absolute'`: buckets with values less than this will be clipped to this value 31 | so they may be visible in the plot. 32 | """ 33 | 34 | shading_relative_min: float = 0.4 35 | """ 36 | if `shading == 'relative'`: the lightest non-zeros have this value. 37 | """ 38 | 39 | shading_relative_max_percentile: float = 0.99 40 | """ 41 | if `shading == 'relative'`: define what a 'full' bucket is as a percentile of the nonzero buckets. 42 | A simple max would allow one or two outliers to skew the entire range making the plot appear too light. 43 | """ 44 | 45 | figsize: float = 3.5 46 | """Figure size for spy plots, of longest side, in default matplotlib units (inches).""" 47 | 48 | sparkline_size: float = 1 49 | """Figure size for sparklines, of longest side, in default matplotlib units (inches).""" 50 | 51 | dpi: float = None 52 | """Default spy image DPI. If None the matplotlib default Figure dpi is used.""" 53 | 54 | buckets: int = None 55 | """Pixel count of longest side of spy image. If None then computed from size and DPI.""" 56 | 57 | precision: float = None 58 | """ 59 | Applies to dense matrices like numpy arrays. If None or 0, nonzero values are plotted. Else only values with 60 | absolute value > `precision` are plotted. 61 | 62 | Behaves like `matplotlib.pyplot.spy`'s `precision` argument, but for dense arrays only. 63 | """ 64 | 65 | spy_aa_tweaks_enabled: bool = None 66 | """ 67 | Whether to_sparkline() may tweak parameters like bucket count to prevent visible aliasing artifacts. 68 | If None then defaults to True if dpi and buckets are also None. 69 | """ 70 | 71 | color_empty: Union[Tuple[float, float, float, float], str] = (1.0, 1.0, 1.0, 1.0) # RGBA: empty space is white 72 | """Color for empty space. Can be anything matplotlib accepts, like RGB or RGBA tuples.""" 73 | 74 | color_full: Union[Tuple[float, float, float, float], str] = (0.0, 0.0, 1.0, 1.0) # RGBA: non-zeros are blue 75 | """Color for a full bucket. Can be anything matplotlib accepts, like RGB or RGBA tuples.""" 76 | 77 | def _assert_one_of(self, var, choices): 78 | if getattr(self, var) not in choices: 79 | raise ValueError(f"{var} must be one of: " + ", ".join(choices)) 80 | 81 | def get(self, **kwargs): 82 | ret = dataclasses.replace(self) 83 | 84 | # Allow some explicit overwrites for convenience 85 | if "title" in kwargs and "title_latex" not in kwargs: 86 | kwargs["title_latex"] = kwargs["title"] 87 | 88 | if "figsize" in kwargs and "sparkline_size" not in kwargs: 89 | kwargs["sparkline_size"] = kwargs["figsize"] 90 | 91 | # Update all parameters with the ones in kwargs 92 | for key, value in kwargs.items(): 93 | if hasattr(ret, key): 94 | setattr(ret, key, value) 95 | 96 | # validate 97 | ret._assert_one_of("shading", ['relative', 'absolute', 'binary']) 98 | 99 | # Apply some default rules 100 | if ret.spy_aa_tweaks_enabled is None: 101 | ret.spy_aa_tweaks_enabled = ret.buckets is None and ret.dpi is None 102 | 103 | return ret 104 | 105 | def to_kwargs(self): 106 | return asdict(self) 107 | 108 | 109 | params = MatSpyParams() 110 | _drivers: List[Type[Driver]] = [] 111 | _driver_prefixes: Dict[str, Type[Driver]] = {} 112 | 113 | 114 | def register_driver(driver: Type[Driver]): 115 | _drivers.append(driver) 116 | 117 | for prefix in driver.get_supported_type_prefixes(): 118 | _driver_prefixes[prefix] = driver 119 | 120 | 121 | def _register_bundled(): 122 | """ 123 | Register the built-in drivers. 124 | """ 125 | from .adapters.scipy_driver import SciPyDriver 126 | register_driver(SciPyDriver) 127 | 128 | from .adapters.numpy_driver import NumPyDriver 129 | register_driver(NumPyDriver) 130 | 131 | from .adapters.graphblas_driver import GraphBLASDriver 132 | register_driver(GraphBLASDriver) 133 | 134 | from .adapters.sparse_driver import PyDataSparseDriver 135 | register_driver(PyDataSparseDriver) 136 | 137 | 138 | _register_bundled() 139 | 140 | 141 | def _get_driver(mat): 142 | type_str = ".".join((type(mat).__module__, type(mat).__name__)) 143 | for prefix, driver in _driver_prefixes.items(): 144 | if type_str.startswith(prefix): 145 | return driver 146 | 147 | raise AttributeError("Unsupported type: " + type_str) 148 | 149 | 150 | def _get_spy_adapter(mat) -> MatrixSpyAdapter: 151 | if isinstance(mat, MatrixSpyAdapter): 152 | return mat 153 | 154 | adapter = _get_driver(mat).adapt_spy(mat) 155 | if not adapter: 156 | raise AttributeError("Unsupported matrix") 157 | 158 | return adapter 159 | 160 | 161 | def to_spy_heatmap(mat, buckets=500, **kwargs): 162 | options = params.get(**kwargs) 163 | options.buckets = buckets 164 | adapter = _get_spy_adapter(mat) 165 | 166 | from .spy_renderer import get_spy_heatmap 167 | heatmap = get_spy_heatmap(adapter, **options.to_kwargs()) 168 | return heatmap 169 | 170 | 171 | from matspy.spy_renderer import spy, spy_to_mpl, to_sparkline 172 | 173 | 174 | __all__ = ["to_sparkline", "to_spy_heatmap", "spy_to_mpl", "spy"] 175 | -------------------------------------------------------------------------------- /matspy/adapters/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | from abc import ABC, abstractmethod 6 | from typing import Any, Iterable, Optional, Tuple 7 | 8 | import numpy as np 9 | 10 | 11 | def describe(shape: tuple = None, nnz: int = None, nz_type=None, layout: str = None, notes: str = None) -> str: 12 | """ 13 | Create a simple description string from potentially interesting pieces of metadata. 14 | """ 15 | parts = [] 16 | by = chr(215) # × 17 | if len(shape) == 1: 18 | parts.append(f"length={shape[0]}") 19 | elif len(shape) == 2: 20 | parts.append(f"{shape[0]}{by}{shape[1]}") 21 | else: 22 | parts.append(f"shape={by.join(shape)}") 23 | 24 | if nnz is not None: 25 | dtype_str = f" '{str(nz_type)}'" if nz_type else "" 26 | parts.append(f"{nnz}{dtype_str} elements") 27 | elif nz_type is not None: 28 | parts.append(f"'{str(nz_type)}' elements") 29 | 30 | if layout is not None: 31 | parts.append(str(layout)) 32 | 33 | if notes: 34 | parts.append(notes) 35 | 36 | return ", ".join(parts) 37 | 38 | 39 | class MatrixSpyAdapter(ABC): 40 | def __init__(self): 41 | self.options = {} 42 | 43 | @abstractmethod 44 | def describe(self) -> str: 45 | pass 46 | 47 | @abstractmethod 48 | def get_shape(self) -> tuple: 49 | pass 50 | 51 | @abstractmethod 52 | def get_spy(self, spy_shape: tuple) -> np.array: 53 | pass 54 | 55 | def set_option(self, key, value): 56 | self.options[key] = value 57 | 58 | def get_option(self, key, dflt): 59 | return self.options.get(key, dflt) 60 | 61 | 62 | class Driver(ABC): 63 | @staticmethod 64 | @abstractmethod 65 | def get_supported_type_prefixes() -> Iterable[str]: 66 | pass 67 | 68 | @staticmethod 69 | @abstractmethod 70 | def adapt_spy(mat: Any) -> Optional[MatrixSpyAdapter]: 71 | pass 72 | 73 | 74 | def generate_spy_triple_product(matrix_shape, spy_shape, uneven_to_end=True) ->\ 75 | Tuple[Tuple[np.array, np.array], Tuple[np.array, np.array]]: 76 | """ 77 | Generate left and right matrices to create a matrix spy plot using two matrix multiplications. 78 | """ 79 | left_shape = (spy_shape[0], matrix_shape[0]) 80 | right_shape = (matrix_shape[1], spy_shape[1]) 81 | 82 | left_nnz = max(left_shape) 83 | right_nnz = max(right_shape) 84 | 85 | def gen_even(stop, num): 86 | return np.linspace(0, stop, num=num, endpoint=False, dtype="int64") 87 | 88 | def gen(stop, num): 89 | remainder = num % stop 90 | if not uneven_to_end or num % stop == 0 or remainder > 4: 91 | return gen_even(stop, num) 92 | 93 | step = int(num / stop) 94 | a = np.repeat(np.arange(0, stop, dtype='int64'), step) 95 | b = np.full((num - len(a)), stop - 1, dtype="int64") 96 | return np.concatenate((a, b)) 97 | 98 | left_rows = gen(left_shape[0], num=left_nnz) 99 | left_cols = gen_even(left_shape[1], num=left_nnz) 100 | 101 | right_rows = gen_even(right_shape[0], num=right_nnz) 102 | right_cols = gen(right_shape[1], num=right_nnz) 103 | 104 | return (left_shape, (left_rows, left_cols)), (right_shape, (right_rows, right_cols)) 105 | -------------------------------------------------------------------------------- /matspy/adapters/graphblas_driver.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | from typing import Any, Iterable 6 | 7 | from . import Driver, MatrixSpyAdapter 8 | 9 | 10 | class GraphBLASDriver(Driver): 11 | @staticmethod 12 | def get_supported_type_prefixes() -> Iterable[str]: 13 | return ["graphblas."] 14 | 15 | @staticmethod 16 | def adapt_spy(mat: Any) -> MatrixSpyAdapter: 17 | from .graphblas_impl import GraphBLASSpy 18 | return GraphBLASSpy(mat) 19 | -------------------------------------------------------------------------------- /matspy/adapters/graphblas_impl.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import numpy as np 4 | import graphblas as gb 5 | 6 | from . import describe, generate_spy_triple_product 7 | from . import MatrixSpyAdapter 8 | 9 | 10 | def generate_spy_triple_product_gb(matrix_shape, spy_shape) -> Tuple[gb.Matrix, gb.Matrix]: 11 | # construct a triple product that will scale the matrix 12 | left, right = generate_spy_triple_product(matrix_shape, spy_shape) 13 | 14 | left_shape, (left_rows, left_cols) = left 15 | right_shape, (right_rows, right_cols) = right 16 | 17 | left_mat = gb.Matrix.from_coo( 18 | left_rows, left_cols, np.ones(len(left_rows)), 19 | nrows=left_shape[0], ncols=left_shape[1], 20 | dtype='int64' 21 | ) 22 | 23 | right_mat = gb.Matrix.from_coo( 24 | right_rows, right_cols, np.ones(len(right_rows)), 25 | nrows=right_shape[0], ncols=right_shape[1], 26 | dtype='int64' 27 | ) 28 | 29 | del left 30 | del right 31 | 32 | return left_mat, right_mat 33 | 34 | 35 | class GraphBLASSpy(MatrixSpyAdapter): 36 | def __init__(self, mat): 37 | super().__init__() 38 | self.mat = mat 39 | 40 | def get_shape(self) -> tuple: 41 | return self.mat.shape 42 | 43 | def get_format(self, is_transposed=False): 44 | x = self.mat 45 | try: 46 | # SS, SuiteSparse-specific: format (ends with "r" or "c"), and is_iso 47 | fmt = x.ss.format 48 | if is_transposed: 49 | fmt = fmt[:-1] + ("c" if fmt[-1] == "r" else "r") 50 | if x.ss.is_iso: 51 | return f"{fmt} (iso)" 52 | return fmt 53 | except AttributeError: 54 | return None 55 | 56 | def describe(self) -> str: 57 | parts = [f"gb.{type(self.mat).__name__}", f"'{self.mat.dtype}'"] 58 | 59 | return describe(shape=self.mat.shape, 60 | nnz=self.mat.nvals, 61 | layout=self.get_format(), 62 | notes=", ".join(parts)) 63 | 64 | def get_spy(self, spy_shape: tuple) -> np.array: 65 | # construct a triple product that will scale the matrix 66 | left, right = generate_spy_triple_product_gb(self.mat.shape, spy_shape) 67 | 68 | # construct result 69 | spy = gb.Matrix(float, nrows=spy_shape[0], ncols=spy_shape[1]) 70 | 71 | # triple product 72 | spy << left.mxm(self.mat, op=gb.semiring.plus_first).mxm(right, op=gb.semiring.plus_first) 73 | 74 | return spy.to_dense(fill_value=0, dtype=spy.dtype) 75 | -------------------------------------------------------------------------------- /matspy/adapters/numpy_driver.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | from typing import Any, Iterable 6 | 7 | from . import Driver, MatrixSpyAdapter 8 | 9 | 10 | class NumPyDriver(Driver): 11 | @staticmethod 12 | def get_supported_type_prefixes() -> Iterable[str]: 13 | return ["numpy."] 14 | 15 | @staticmethod 16 | def adapt_spy(mat: Any) -> MatrixSpyAdapter: 17 | from .numpy_impl import NumPySpy 18 | return NumPySpy(mat) 19 | -------------------------------------------------------------------------------- /matspy/adapters/numpy_impl.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | import numpy as np 6 | from scipy.sparse import csr_matrix 7 | 8 | from . import describe, MatrixSpyAdapter 9 | from .scipy_impl import SciPySpy 10 | 11 | 12 | class NumPySpy(MatrixSpyAdapter): 13 | def __init__(self, arr): 14 | super().__init__() 15 | if len(arr.shape) != 2: 16 | raise ValueError("Only 2D arrays are supported") 17 | self.arr = arr 18 | 19 | def get_shape(self) -> tuple: 20 | return self.arr.shape 21 | 22 | def describe(self) -> str: 23 | return describe(shape=self.arr.shape, nz_type=self.arr.dtype, layout="array") 24 | 25 | def get_spy(self, spy_shape: tuple) -> np.array: 26 | precision = self.get_option("precision", None) 27 | 28 | if self.arr.dtype == 'object': 29 | not_none = (self.arr != np.array([None])) 30 | if precision: 31 | arr = self.arr 32 | if not np.all(not_none): 33 | # avoid comparisons to None by making a copy and replacing None with 0 34 | arr = arr.copy() 35 | arr[arr == np.array([None])] = 0 36 | mask = (arr > precision) | (arr < -precision) 37 | else: 38 | mask = (self.arr != 0) & not_none 39 | else: 40 | if precision: 41 | mask = (self.arr > precision) | (self.arr < -precision) 42 | else: 43 | mask = (self.arr != 0) 44 | 45 | return SciPySpy(csr_matrix(mask)).get_spy(spy_shape) 46 | -------------------------------------------------------------------------------- /matspy/adapters/scipy_driver.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | from typing import Any, Iterable 6 | 7 | from . import Driver, MatrixSpyAdapter 8 | 9 | 10 | class SciPyDriver(Driver): 11 | @staticmethod 12 | def get_supported_type_prefixes() -> Iterable[str]: 13 | return ["scipy.sparse."] 14 | 15 | @staticmethod 16 | def adapt_spy(mat: Any) -> MatrixSpyAdapter: 17 | from .scipy_impl import SciPySpy 18 | return SciPySpy(mat) 19 | -------------------------------------------------------------------------------- /matspy/adapters/scipy_impl.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | from typing import Tuple 6 | 7 | import numpy as np 8 | import scipy.sparse 9 | 10 | from . import describe, generate_spy_triple_product, MatrixSpyAdapter 11 | 12 | 13 | def generate_spy_triple_product_coo(matrix_shape, spy_shape) -> Tuple[scipy.sparse.coo_matrix, scipy.sparse.coo_matrix]: 14 | # construct a triple product that will scale the matrix 15 | left, right = generate_spy_triple_product(matrix_shape, spy_shape) 16 | 17 | left_shape, (left_rows, left_cols) = left 18 | right_shape, (right_rows, right_cols) = right 19 | left_mat = scipy.sparse.coo_matrix((np.ones(len(left_rows)), (left_rows, left_cols)), shape=left_shape) 20 | right_mat = scipy.sparse.coo_matrix((np.ones(len(right_rows)), (right_rows, right_cols)), shape=right_shape) 21 | 22 | return left_mat, right_mat 23 | 24 | 25 | class SciPySpy(MatrixSpyAdapter): 26 | def __init__(self, mat): 27 | super().__init__() 28 | self.mat = mat 29 | 30 | def get_shape(self) -> tuple: 31 | return self.mat.shape 32 | 33 | def describe(self) -> str: 34 | return describe(shape=self.mat.shape, nnz=self.mat.nnz, nz_type=self.mat.dtype, 35 | layout=self.mat.getformat()) 36 | 37 | def get_spy(self, spy_shape: tuple) -> np.array: 38 | # construct a triple product that will scale the matrix 39 | left, right = generate_spy_triple_product_coo(self.mat.shape, spy_shape) 40 | 41 | # save existing matrix data 42 | mat_data_save = self.mat.data 43 | 44 | # replace with all ones 45 | self.mat.data = np.ones(self.mat.data.shape) 46 | 47 | # triple product 48 | spy = left @ self.mat @ right 49 | 50 | # restore original matrix data 51 | self.mat.data = mat_data_save 52 | 53 | return np.array(spy.todense()) 54 | -------------------------------------------------------------------------------- /matspy/adapters/sparse_driver.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | from typing import Any, Iterable 6 | 7 | from . import Driver, MatrixSpyAdapter 8 | 9 | 10 | class PyDataSparseDriver(Driver): 11 | @staticmethod 12 | def get_supported_type_prefixes() -> Iterable[str]: 13 | return ["sparse."] 14 | 15 | @staticmethod 16 | def adapt_spy(mat: Any) -> MatrixSpyAdapter: 17 | from .sparse_impl import PyDataSparseSpy 18 | return PyDataSparseSpy(mat) 19 | -------------------------------------------------------------------------------- /matspy/adapters/sparse_impl.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | from typing import Tuple 6 | 7 | import numpy as np 8 | import sparse 9 | 10 | from . import describe, generate_spy_triple_product, MatrixSpyAdapter 11 | 12 | 13 | def generate_spy_triple_product_sparse(matrix_shape, spy_shape) -> Tuple[sparse.SparseArray, sparse.SparseArray]: 14 | # construct a triple product that will scale the matrix 15 | left, right = generate_spy_triple_product(matrix_shape, spy_shape) 16 | 17 | left_shape, (left_rows, left_cols) = left 18 | right_shape, (right_rows, right_cols) = right 19 | left_mat = sparse.COO(coords=(left_rows, left_cols), data=np.ones(len(left_rows)), shape=left_shape) 20 | right_mat = sparse.COO(coords=(right_rows, right_cols), data=np.ones(len(right_rows)), shape=right_shape) 21 | 22 | return left_mat, right_mat 23 | 24 | 25 | class PyDataSparseSpy(MatrixSpyAdapter): 26 | def __init__(self, mat): 27 | super().__init__() 28 | self.mat = mat 29 | 30 | def get_shape(self) -> tuple: 31 | return self.mat.shape 32 | 33 | def describe(self) -> str: 34 | try: 35 | fmt = self.mat.format 36 | except AttributeError: 37 | fmt = self.mat.__class__.__name__ 38 | 39 | return describe(shape=self.mat.shape, 40 | nnz=self.mat.nnz, nz_type=self.mat.dtype, 41 | layout=fmt) 42 | 43 | def get_spy(self, spy_shape: tuple) -> np.array: 44 | if isinstance(self.mat, sparse.DOK): 45 | self.mat = self.mat.asformat("coo") 46 | 47 | # construct a triple product that will scale the matrix 48 | left, right = generate_spy_triple_product_sparse(self.mat.shape, spy_shape) 49 | 50 | # save existing matrix data 51 | mat_data_save = self.mat.data 52 | 53 | # replace with all ones 54 | self.mat.data = np.ones(self.mat.data.shape) 55 | 56 | # triple product 57 | try: 58 | spy = left @ self.mat @ right 59 | except ValueError: 60 | # broken matmul on some types 61 | temp = self.mat.asformat("coo") 62 | spy = left @ temp @ right 63 | 64 | # restore original matrix data 65 | self.mat.data = mat_data_save 66 | 67 | return np.array(spy.todense()) 68 | -------------------------------------------------------------------------------- /matspy/spy_renderer.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | import numpy as np 6 | 7 | import matplotlib.pyplot as plt 8 | from matplotlib.ticker import MaxNLocator 9 | from matplotlib.colors import LinearSegmentedColormap 10 | 11 | from .adapters import MatrixSpyAdapter 12 | # noinspection PyProtectedMember 13 | from matspy import params, to_spy_heatmap, _get_spy_adapter 14 | 15 | 16 | def _get_relative_max(heatmap, k): 17 | ret = None 18 | 19 | if min(heatmap.shape) > 3: 20 | # slice off the final row/column because those can be artificially high due to aliasing effects 21 | corner = heatmap[0:(heatmap.shape[0]-1), 0:(heatmap.shape[1]-1)] 22 | ret = _top_k(corner, k) 23 | 24 | if not ret: 25 | ret = _top_k(heatmap, k) 26 | 27 | return ret if ret else 1 28 | 29 | 30 | def _top_k(arr, k): 31 | arr = arr.flatten() 32 | 33 | if arr.size == 0: 34 | return None 35 | 36 | if k == 1: 37 | ret = np.max(arr, initial=0) 38 | return ret if ret else None 39 | 40 | k = min(k, arr.size - 1) 41 | srt = np.sort(arr) 42 | 43 | for top in srt[-k:]: 44 | if top > 0: 45 | return top 46 | 47 | return None 48 | 49 | 50 | def _rescale(arr, from_range, to_range): 51 | from_size = from_range[1] - from_range[0] 52 | to_size = to_range[1] - to_range[0] 53 | 54 | if from_size == 0: 55 | return np.full_like(arr, to_range[1]) 56 | 57 | return (arr - from_range[0]) * (to_size / from_size) + to_range[0] 58 | 59 | 60 | # noinspection PyUnusedLocal 61 | def get_spy_heatmap(adapter: MatrixSpyAdapter, buckets, shading, shading_absolute_min, 62 | shading_relative_min, shading_relative_max_percentile, precision, **kwargs): 63 | # find spy matrix shape 64 | mat_shape = adapter.get_shape() 65 | if mat_shape[0] == 0 or mat_shape[1] == 0: 66 | return np.array([[]]) 67 | 68 | ratio = buckets / max(mat_shape) 69 | spy_shape = tuple(max(1, int(ratio * x)) for x in mat_shape) 70 | 71 | adapter.set_option("precision", precision) 72 | dense = adapter.get_spy(spy_shape=spy_shape) 73 | 74 | if not dense.flags.writeable: 75 | dense = np.array(dense) 76 | 77 | dense[dense < 0] = 0 78 | 79 | # scale values 80 | if shading == "absolute": 81 | divisor = max(adapter.get_shape()) / buckets 82 | divisor *= divisor # area 83 | dense /= divisor 84 | dense[(0 < dense) & (dense < shading_absolute_min)] = shading_absolute_min 85 | dense[dense > 1] = 1 86 | elif shading == "relative": 87 | mask = dense > 0 88 | 89 | small = np.min(dense[mask], initial=0) 90 | 91 | nnz = dense[mask].flatten().size 92 | k = max(1, nnz - int(nnz * shading_relative_max_percentile)) 93 | big = _get_relative_max(dense, k) 94 | 95 | scaled = _rescale(dense, (small, big), (shading_relative_min, 1)) 96 | dense[mask] = scaled[mask] 97 | dense[dense > 1] = 1 98 | elif shading == "binary": 99 | dense[dense != 0] = 1 100 | else: 101 | raise ValueError("shading must be one of 'absolute', 'relative', 'binary'") 102 | 103 | return dense 104 | 105 | 106 | def _get_spy_cmap(options): 107 | return LinearSegmentedColormap.from_list("spy_cmap", [options.color_empty, options.color_full]) 108 | 109 | 110 | def _tweak_divisor(num, divisor, lower=0.2, higher=0.5): 111 | if num <= divisor: 112 | return num 113 | 114 | bucket_candidates = \ 115 | list(range(divisor + 1, divisor + min(int(divisor * higher), 200))) + \ 116 | list(range(divisor - 1, divisor - min(int(divisor * lower), 200), -1)) 117 | 118 | best_remainder, best_candidate = (num % divisor, divisor) 119 | 120 | for candidate in bucket_candidates: 121 | if candidate < 1: 122 | continue 123 | 124 | extra = num % candidate 125 | if extra < best_remainder: 126 | best_remainder = extra 127 | best_candidate = candidate 128 | 129 | return best_candidate 130 | 131 | 132 | def _resize_figure_to_match_dpi(fig, target_dpi): 133 | fig_width, fig_height = fig.get_size_inches() 134 | plot_frac_width = fig.subplotpars.right - fig.subplotpars.left 135 | plot_frac_height = fig.subplotpars.top - fig.subplotpars.bottom 136 | 137 | plot_width = fig_width * plot_frac_width 138 | plot_height = fig_height * plot_frac_height 139 | 140 | ratio = target_dpi / fig.dpi 141 | target_plot_width = plot_width * ratio 142 | target_plot_height = plot_height * ratio 143 | 144 | fig.set_size_inches(fig_width + (target_plot_width - plot_width), 145 | fig_height + (target_plot_height - plot_height)) 146 | 147 | 148 | def spy_to_mpl(mat, **kwargs): 149 | """ 150 | Create a spy plot and return as matplotlib figure without showing. 151 | """ 152 | options = params.get(**kwargs) 153 | adapter = _get_spy_adapter(mat) 154 | 155 | fig, ax = plt.subplots() 156 | fig.set_size_inches(options.figsize, options.figsize) 157 | if options.indices: 158 | ax.xaxis.set_major_locator(MaxNLocator(integer=True, min_n_ticks=0, nbins='auto')) 159 | ax.xaxis.set_ticks_position('bottom') 160 | ax.yaxis.set_major_locator(MaxNLocator(integer=True, min_n_ticks=0, nbins='auto')) 161 | else: 162 | ax.set_xticks([]) 163 | ax.set_yticks([]) 164 | ax.set_ylim(adapter.get_shape()[0], 0) 165 | ax.set_xlim(0, adapter.get_shape()[1]) 166 | 167 | if options.title is True: 168 | options.title = adapter.describe() 169 | if options.title: 170 | plt.title(options.title) 171 | 172 | plt.tight_layout() 173 | 174 | max_dim = max(adapter.get_shape()) 175 | bbox = ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted()) 176 | fig_dim_max_pixels = max(bbox.width, bbox.height) * fig.dpi 177 | 178 | if options.buckets: 179 | # explicit bucket size from the user 180 | pass 181 | elif options.dpi: 182 | # explicit dpi from the user 183 | options.buckets = int(options.dpi * options.figsize) 184 | else: 185 | # from matplotlib figure dimensions 186 | options.buckets = int(fig_dim_max_pixels / 2) 187 | 188 | if options.spy_aa_tweaks_enabled: 189 | # tweak the bucket size to better fit the matrix 190 | options.buckets = _tweak_divisor(max_dim, options.buckets, lower=0.2, higher=0.2) 191 | 192 | # tweak the figure size to better fit the bucket count 193 | new_dpi = _tweak_divisor(options.buckets, int(fig.dpi), lower=0.1, higher=0.1) 194 | _resize_figure_to_match_dpi(fig, new_dpi) 195 | 196 | interpolation = "bilinear" if fig_dim_max_pixels / options.buckets < 1.2 else "nearest" 197 | 198 | ax.imshow(to_spy_heatmap(adapter, **options.to_kwargs()), 199 | cmap=_get_spy_cmap(options), 200 | interpolation=interpolation, interpolation_stage="rgba", aspect="equal", origin="upper", vmin=0, vmax=1, 201 | extent=[0, adapter.get_shape()[1], adapter.get_shape()[0], 0]) 202 | 203 | return fig, ax 204 | 205 | 206 | def spy(mat, **kwargs): 207 | fig, ax = spy_to_mpl(mat, **kwargs) 208 | plt.show() 209 | plt.close(fig) 210 | 211 | 212 | def to_sparkline(mat, retscale=False, scale=None, html_border="1px solid black", **kwargs): 213 | options = params.get(**kwargs) 214 | adapter = _get_spy_adapter(mat) 215 | 216 | max_dim = max(adapter.get_shape()) 217 | if scale is None: 218 | scale = options.sparkline_size / max_dim 219 | options.figsize = scale * max_dim 220 | sizing_dpi = plt.rcParams["figure.dpi"] 221 | 222 | img_height, img_width = tuple(int((dim / max_dim) * options.figsize * sizing_dpi) for dim in adapter.get_shape()) 223 | 224 | if not options.dpi: 225 | # no explicit dpi from the user, use matplotlib default 226 | options.dpi = plt.rcParams["figure.dpi"] 227 | 228 | if options.buckets: 229 | # user-specified bucket size 230 | options.dpi = options.buckets / options.figsize 231 | else: 232 | # auto select bucket size 233 | options.buckets = int(options.dpi * options.figsize) 234 | 235 | # If the bucket size does not evenly divide the matrix dimensions then 236 | # there may be visible artifacts like banding in the spy image. These artifacts can 237 | # give the impression of structure that isn't there. Some tweaks to parameters may alleviate this. 238 | if options.spy_aa_tweaks_enabled: 239 | # tweak the bucket size to better fit the matrix 240 | options.buckets = _tweak_divisor(max_dim, options.buckets, lower=0.5, higher=0.5) 241 | 242 | repeat = max(img_height, img_width) / options.buckets 243 | repeat = int(repeat) if repeat >= 2 else 1 244 | 245 | heatmap = to_spy_heatmap(adapter, **options.to_kwargs()) 246 | if heatmap.size == 0: 247 | # zero-size 248 | return "▫" # a single character that is an empty square 249 | 250 | if repeat > 1: 251 | heatmap = heatmap.repeat(repeat, axis=0) 252 | heatmap = heatmap.repeat(repeat, axis=1) 253 | spy_cmap = _get_spy_cmap(options) 254 | image = spy_cmap(heatmap) 255 | 256 | from io import BytesIO 257 | import base64 258 | bio = BytesIO() 259 | plt.imsave(bio, image, format="png", origin="upper", vmin=0, vmax=1, dpi=(options.dpi*repeat)) 260 | encoded = base64.b64encode(bio.getvalue()).decode() 261 | style = f' style="border: {html_border};"' if html_border else '' 262 | sparkline = f'' 263 | 264 | if retscale: 265 | return sparkline, scale 266 | else: 267 | return sparkline 268 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "matspy" 3 | version = "1.0.0" 4 | description="Sparse matrix spy plot and sparkline renderer that works with Jupyter." 5 | readme = "README.md" 6 | authors = [ 7 | { name = "Adam Lugowski"}, 8 | ] 9 | dependencies = [ 10 | 'numpy', 11 | 'matplotlib', 12 | ] 13 | requires-python = ">=3.7" 14 | 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Intended Audience :: Science/Research", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: BSD License", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Topic :: Software Development :: Libraries", 22 | "Topic :: Scientific/Engineering", 23 | ] 24 | 25 | keywords = [ 26 | "matrix", 27 | "sparse", 28 | "spy", 29 | "plot", 30 | "graph", 31 | "numpy", 32 | "scipy", 33 | "graphblas", 34 | ] 35 | 36 | [project.urls] 37 | homepage = "https://github.com/alugowski/matspy" 38 | repository = "https://github.com/alugowski/matspy" 39 | 40 | [project.optional-dependencies] 41 | test = ["pytest", "scipy", "matplotlib", "html5lib", "matrepr"] 42 | testextra = ["python-graphblas", "sparse"] 43 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | jupyter 2 | scipy 3 | matplotlib 4 | pytest 5 | pytest-subtests 6 | html5lib 7 | matrepr -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | import unittest 6 | 7 | import matspy 8 | 9 | 10 | class BasicTests(unittest.TestCase): 11 | def test_adaptation_errors(self): 12 | with self.assertRaises(AttributeError): 13 | matspy._get_spy_adapter(set()) 14 | 15 | def test_argument_errors(self): 16 | with self.assertRaises(ValueError): 17 | matspy.to_spy_heatmap([], shading="foobar") 18 | 19 | 20 | if __name__ == '__main__': 21 | unittest.main() 22 | -------------------------------------------------------------------------------- /tests/test_graphblas.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | import unittest 6 | 7 | from matspy import spy_to_mpl, to_sparkline, to_spy_heatmap 8 | import matspy 9 | 10 | try: 11 | import graphblas as gb 12 | 13 | # Context initialization must happen before any other imports 14 | gb.init("suitesparse", blocking=True) 15 | except ImportError: 16 | gb = None 17 | 18 | 19 | @unittest.skipIf(gb is None, "python-graphblas not installed") 20 | class GraphBLASTests(unittest.TestCase): 21 | def setUp(self): 22 | self.mats = [ 23 | gb.Matrix.from_coo([0, 1, 2, 3, 4], [0, 1, 2, 3, 4], [0, 1, 2, 3, 4], nrows=5, ncols=5), 24 | ] 25 | 26 | def test_no_crash(self): 27 | import matplotlib.pyplot as plt 28 | for mat in self.mats: 29 | fig, ax = spy_to_mpl(mat) 30 | plt.close(fig) 31 | 32 | res = to_sparkline(mat) 33 | self.assertGreater(len(res), 10) 34 | 35 | def test_shape(self): 36 | mat = gb.Matrix.from_coo([0, 1, 2, 3, 4], [0, 0, 0, 0, 0], [0, 1, 2, 3, 4], nrows=5, ncols=1) 37 | adapter = matspy._get_spy_adapter(mat) 38 | self.assertEqual((5, 1), adapter.get_shape()) 39 | 40 | def test_buckets_1(self): 41 | import scipy.sparse 42 | 43 | density = 0.3 44 | # for dims in [(501, 501), (10, 10)]: 45 | for dims in [(10, 10)]: 46 | r = gb.io.from_scipy_sparse(scipy.sparse.random(*dims, density=density)) 47 | heatmap = to_spy_heatmap(r, buckets=1, shading="absolute") 48 | self.assertEqual(len(heatmap), 1) 49 | self.assertAlmostEqual(heatmap[0][0], density, places=2) 50 | 51 | heatmap = to_spy_heatmap(r, buckets=1, shading="binary") 52 | self.assertAlmostEqual(heatmap[0][0], 1.0, places=2) 53 | 54 | 55 | if __name__ == '__main__': 56 | unittest.main() 57 | -------------------------------------------------------------------------------- /tests/test_heatmap.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | import unittest 6 | 7 | import numpy.random 8 | try: 9 | import scipy 10 | import scipy.sparse 11 | except ImportError: 12 | scipy = None 13 | 14 | from matspy import to_spy_heatmap 15 | 16 | numpy.random.seed(123) 17 | 18 | 19 | @unittest.skipIf(scipy is None, "scipy not installed") 20 | class SpyHeatmapTests(unittest.TestCase): 21 | def test_buckets_1(self): 22 | density = 0.3 23 | for dims in [(501, 501), (10, 10)]: 24 | r = scipy.sparse.random(*dims, density=density) 25 | heatmap = to_spy_heatmap(r, buckets=1, shading="absolute") 26 | self.assertEqual(len(heatmap), 1) 27 | self.assertAlmostEqual(heatmap[0][0], density, places=2) 28 | 29 | heatmap = to_spy_heatmap(r, buckets=1, shading="binary") 30 | self.assertAlmostEqual(heatmap[0][0], 1.0, places=2) 31 | 32 | def test_aa_tweaks(self): 33 | from matspy.spy_renderer import _tweak_divisor 34 | 35 | # more pixels than matrix dimensions 36 | orig, display = 80, 100 37 | self.assertEqual(_tweak_divisor(orig, display), orig) 38 | 39 | orig, display = 100, 100 40 | self.assertEqual(_tweak_divisor(orig, display), orig) 41 | 42 | # matrix slightly bigger than screen 43 | orig, display = 101, 100 44 | self.assertEqual(_tweak_divisor(orig, display), orig) 45 | 46 | orig, display = 100, 99 47 | self.assertEqual(_tweak_divisor(orig, display), orig) 48 | 49 | # matrix bigger than screen 50 | orig, display = 415, 100 51 | buckets = _tweak_divisor(orig, display) 52 | self.assertLessEqual(orig % buckets, 2) 53 | 54 | orig, display = 4315, 873 55 | buckets = _tweak_divisor(orig, display) 56 | self.assertLessEqual(orig % buckets, 2) 57 | 58 | 59 | if __name__ == '__main__': 60 | unittest.main() 61 | -------------------------------------------------------------------------------- /tests/test_numpy.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | import unittest 6 | 7 | import numpy as np 8 | try: 9 | import scipy 10 | except ImportError: 11 | scipy = None 12 | 13 | from matspy import spy_to_mpl, to_sparkline, to_spy_heatmap 14 | 15 | np.random.seed(123) 16 | 17 | 18 | @unittest.skipIf(scipy is None, "scipy not installed") # scipy is used internally by numpy_impl 19 | class NumPyTests(unittest.TestCase): 20 | def setUp(self): 21 | self.mats = [ 22 | np.array([[]]), 23 | np.random.random((10, 10)), 24 | ] 25 | 26 | def test_no_crash(self): 27 | import matplotlib.pyplot as plt 28 | for mat in self.mats: 29 | fig, ax = spy_to_mpl(mat) 30 | plt.close(fig) 31 | 32 | res = to_sparkline(mat) 33 | self.assertGreater(len(res), 5) 34 | 35 | def test_shape(self): 36 | arr = np.array([]) 37 | with self.assertRaises(ValueError): 38 | spy_to_mpl(arr) 39 | 40 | def test_count(self): 41 | arrs = [ 42 | (1, np.array([[1]])), 43 | (1, np.array([[1, 0], [0, 0]])), 44 | (1, np.array([[1, None], [None, None]])), 45 | (1, np.array([[1, 0], [None, None]])), 46 | ] 47 | 48 | for count, arr in arrs: 49 | area = np.prod(arr.shape) 50 | heatmap = to_spy_heatmap(arr, buckets=1, shading="absolute") 51 | self.assertEqual(len(heatmap), 1) 52 | self.assertAlmostEqual( count / area, heatmap[0][0], places=2) 53 | 54 | def test_precision(self): 55 | precision = 0.5 56 | arrs = [ 57 | (1, np.array([[1]])), 58 | (1, np.array([[1, 0], [0, 0]])), 59 | (1, np.array([[1, None], [0.4, -0.2]])), 60 | (1, np.array([[1, 0], [None, None]])), 61 | (1, np.array([[1, 0.5], [0, -0.1]])), 62 | ] 63 | 64 | for count, arr in arrs: 65 | area = np.prod(arr.shape) 66 | heatmap = to_spy_heatmap(arr, buckets=1, shading="absolute", precision=precision) 67 | self.assertEqual(len(heatmap), 1) 68 | self.assertAlmostEqual( count / area, heatmap[0][0], places=2) 69 | 70 | 71 | if __name__ == '__main__': 72 | unittest.main() 73 | -------------------------------------------------------------------------------- /tests/test_scipy.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | import unittest 6 | 7 | import numpy.random 8 | try: 9 | import scipy 10 | import scipy.sparse 11 | except ImportError: 12 | scipy = None 13 | 14 | from matspy import spy_to_mpl, to_sparkline 15 | 16 | numpy.random.seed(123) 17 | 18 | 19 | @unittest.skipIf(scipy is None, "scipy not installed") 20 | class SciPyTests(unittest.TestCase): 21 | def setUp(self): 22 | self.mats = [ 23 | scipy.sparse.random(10, 10, density=0.4).tocoo(), 24 | scipy.sparse.random(5, 10, density=0.4).tocsr(), 25 | scipy.sparse.random(5, 1, density=0.4).tocsc(), 26 | scipy.sparse.coo_matrix(([], ([], [])), shape=(10, 10)).tocoo(), 27 | scipy.sparse.coo_matrix(([], ([], [])), shape=(10, 10)).tocsr(), 28 | scipy.sparse.coo_matrix(([], ([], [])), shape=(10, 10)).tocsc(), 29 | ] 30 | 31 | def test_no_crash(self): 32 | import matplotlib.pyplot as plt 33 | for mat in self.mats: 34 | fig, ax = spy_to_mpl(mat) 35 | plt.close(fig) 36 | 37 | res = to_sparkline(mat) 38 | self.assertGreater(len(res), 10) 39 | 40 | 41 | if __name__ == '__main__': 42 | unittest.main() 43 | -------------------------------------------------------------------------------- /tests/test_sparkline.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | import unittest 6 | 7 | import numpy.random 8 | try: 9 | import scipy 10 | import scipy.sparse 11 | except ImportError: 12 | scipy = None 13 | 14 | from matspy import to_sparkline 15 | 16 | numpy.random.seed(123) 17 | 18 | 19 | @unittest.skipIf(scipy is None, "scipy not installed") 20 | class SparklineTests(unittest.TestCase): 21 | def test_small_buckets(self): 22 | for dims in [(1001, 1001), (11, 11)]: 23 | with self.subTest(str(dims)): 24 | r = scipy.sparse.random(*dims, density=0.2) 25 | s = to_sparkline(r, buckets=10) 26 | self.assertGreater(len(s), 1) 27 | 28 | def test_buckets_1(self): 29 | for dims in [(1001, 1001), (1000, 1000), (501, 501), (500, 500), (10, 11), (11, 11)]: 30 | with self.subTest(str(dims)): 31 | r = scipy.sparse.random(*dims, density=0.2) 32 | s = to_sparkline(r, buckets=1) 33 | self.assertGreater(len(s), 1) 34 | 35 | 36 | if __name__ == '__main__': 37 | unittest.main() 38 | -------------------------------------------------------------------------------- /tests/test_sparse.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2023 Adam Lugowski. 2 | # Use of this source code is governed by the BSD 2-clause license found in the LICENSE.txt file. 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | import unittest 6 | 7 | try: 8 | import sparse 9 | except ImportError: 10 | sparse = None 11 | 12 | import numpy as np 13 | try: 14 | import scipy 15 | import scipy.sparse 16 | except ImportError: 17 | scipy = None 18 | 19 | from matspy import spy_to_mpl, to_sparkline, to_spy_heatmap 20 | 21 | np.random.seed(123) 22 | 23 | 24 | @unittest.skipIf(sparse is None or scipy is None, "pydata/sparse not installed") 25 | class PyDataSparseTests(unittest.TestCase): 26 | def setUp(self): 27 | self.mats = [ 28 | sparse.COO.from_scipy_sparse(scipy.sparse.random(10, 10, density=0.4)), 29 | sparse.COO.from_scipy_sparse(scipy.sparse.random(5, 10, density=0.4)), 30 | sparse.COO.from_scipy_sparse(scipy.sparse.random(5, 1, density=0.4)), 31 | sparse.COO.from_scipy_sparse(scipy.sparse.coo_matrix(([], ([], [])), shape=(10, 10))), 32 | ] 33 | 34 | def test_no_crash(self): 35 | import matplotlib.pyplot as plt 36 | for fmt in "coo", "gcxs", "dok", "csr", "csc": 37 | for source_mat in self.mats: 38 | mat = source_mat.asformat(fmt) 39 | 40 | fig, ax = spy_to_mpl(mat) 41 | plt.close(fig) 42 | 43 | res = to_sparkline(mat) 44 | self.assertGreater(len(res), 10) 45 | 46 | def test_count(self): 47 | arrs = [ 48 | (0, sparse.COO(np.array([[0]]))), 49 | (1, sparse.COO(np.array([[1]]))), 50 | (0, sparse.COO(np.array([[0, 0], [0, 0]]))), 51 | (1, sparse.COO(np.array([[1, 0], [0, 0]]))), 52 | ] 53 | 54 | for count, arr in arrs: 55 | area = np.prod(arr.shape) 56 | heatmap = to_spy_heatmap(arr, buckets=1, shading="absolute") 57 | self.assertEqual(len(heatmap), 1) 58 | self.assertAlmostEqual( count / area, heatmap[0][0], places=2) 59 | 60 | 61 | if __name__ == '__main__': 62 | unittest.main() 63 | -------------------------------------------------------------------------------- /tools/make_readme_links_absolute.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cat $(dirname "$0")/../README.md | 4 | sed -e 's,doc/images/,https://raw.githubusercontent.com/alugowski/matspy/main/doc/images/,g' | 5 | sed -e 's,(demo,(https://nbviewer.org/github/alugowski/matspy/blob/main/demo,g' --------------------------------------------------------------------------------