├── .github
└── workflows
│ ├── ci.yml
│ └── docs.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── docs
├── conf.py
├── environment.yml
├── index.rst
├── live_plot.rst
└── sketch.rst
├── examples
├── raw_sketch_updates.py
├── sine_waves.py
├── streaming_client.py
└── streaming_server.py
├── matplotlive
├── __init__.py
├── exceptions.py
├── live_plot.py
└── sketch.py
├── pyproject.toml
├── tests
├── __init__.py
├── test_live_plot.py
└── test_sketch.py
└── tox.ini
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 | workflow_dispatch:
9 |
10 | jobs:
11 | coverage:
12 | name: "Coverage"
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: "Checkout sources"
17 | uses: actions/checkout@v4
18 |
19 | - name: "Set up Python 3.8"
20 | uses: actions/setup-python@v4
21 | with:
22 | python-version: "3.8"
23 |
24 | - name: "Install dependencies"
25 | run: |
26 | python -m pip install --upgrade pip
27 | python -m pip install coveralls tox
28 |
29 | - name: "Check code coverage"
30 | env:
31 | MOSEKLM_LICENSE_FILE: ${{ secrets.MSK_LICENSE }}
32 | run: |
33 | tox run -e coverage
34 |
35 | - name: "Coveralls"
36 | env:
37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38 | run: |
39 | coveralls --service=github --rcfile=pyproject.toml
40 |
41 | lint:
42 | name: "Code style"
43 | runs-on: ubuntu-latest
44 |
45 | steps:
46 | - name: "Checkout sources"
47 | uses: actions/checkout@v4
48 |
49 | - name: "Set up Python ${{ matrix.python-version }}"
50 | uses: actions/setup-python@v4
51 | with:
52 | python-version: "${{ matrix.python-version }}"
53 |
54 | - name: "Install dependencies"
55 | run: |
56 | python -m pip install --upgrade pip
57 | python -m pip install tox
58 |
59 | - name: "Test with tox for ${{ matrix.os }}"
60 | run: |
61 | tox -e lint
62 | env:
63 | PLATFORM: ubuntu-latest
64 |
65 | test:
66 | name: "Test ${{ matrix.os }} with python-${{ matrix.python-version }}"
67 | runs-on: ${{ matrix.os }}
68 |
69 | strategy:
70 | matrix:
71 | os: [ubuntu-latest]
72 | python-version: ["3.8", "3.9", "3.10"]
73 |
74 | steps:
75 | - name: "Checkout sources"
76 | uses: actions/checkout@v4
77 |
78 | - name: "Set up Python"
79 | uses: actions/setup-python@v4
80 | with:
81 | python-version: "${{ matrix.python-version }}"
82 |
83 | - name: "Install dependencies"
84 | run: |
85 | python -m pip install --upgrade pip
86 | python -m pip install tox tox-gh-actions
87 |
88 | - name: "Run tox targets for ${{ matrix.python-version }}"
89 | run: |
90 | tox run
91 |
92 | ci_success:
93 | name: "CI success"
94 | runs-on: ubuntu-latest
95 | needs: [coverage, lint, test]
96 | steps:
97 | - run: echo "CI workflow completed successfully"
98 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: Documentation
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | defaults:
10 | run:
11 | # See https://github.com/mamba-org/setup-micromamba#about-login-shells
12 | shell: bash -l {0}
13 |
14 | jobs:
15 | docs:
16 | name: "GitHub Pages"
17 | runs-on: ubuntu-latest
18 | permissions:
19 | contents: write
20 | steps:
21 | - name: "Checkout Git repository"
22 | uses: actions/checkout@v4
23 |
24 | - name: "Install Conda environment with Micromamba"
25 | uses: mamba-org/setup-micromamba@v1
26 | with:
27 | cache-downloads: true
28 | environment-file: docs/environment.yml
29 | environment-name: matplotlive
30 |
31 | - name: "Build documentation"
32 | run: |
33 | sphinx-build docs _build -W
34 |
35 | - name: "Deploy to GitHub Pages"
36 | uses: peaceiris/actions-gh-pages@v3
37 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
38 | with:
39 | publish_branch: gh-pages
40 | github_token: ${{ secrets.GITHUB_TOKEN }}
41 | publish_dir: _build/
42 | force_orphan: true
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .coverage
2 | .tox
3 | __pycache__
4 | _build
5 | dist/
6 | examples/matplotlive
7 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ### Changed
11 |
12 | - CICD: Update checkout action to v4
13 |
14 | ## [1.1.1] - 2024-08-29
15 |
16 | ### Added
17 |
18 | - LivePlot: Add `push` function
19 |
20 | ### Fixed
21 |
22 | - Fix push function missing from previous release
23 |
24 | ## [1.1.0] - 2024-08-27
25 |
26 | ### Added
27 |
28 | - LivePlot: Add `legend` shorthand function
29 | - LivePlot: Add `reset` function
30 | - Sketch: Add a `reset` function
31 |
32 | ## [1.0.0] - 2024-08-06
33 |
34 | ### Added
35 |
36 | - Base class for all library exceptions
37 | - LivePlot class for live time series streaming
38 | - Sine waves example using a LivePlot
39 | - Sine waves example using a raw Sketch rather than a LivePlot
40 | - Sketch class for fast line redrawing on an existing Matplotlib canvas
41 | - Unit tests for the Sketch class
42 |
43 | [unreleased]: https://github.com/stephane-caron/matplotlive/compare/v1.1.1...HEAD
44 | [1.1.1]: https://github.com/stephane-caron/matplotlive/compare/v1.1.0...v1.1.1
45 | [1.1.0]: https://github.com/stephane-caron/matplotlive/compare/v1.0.0...v1.1.0
46 | [1.0.0]: https://github.com/stephane-caron/matplotlive/releases/tag/v1.0.0
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # matplotlive
2 |
3 | [](https://github.com/stephane-caron/matplotlive/actions)
4 | [](https://stephane-caron.github.io/matplotlive/)
5 | [](https://coveralls.io/github/stephane-caron/matplotlive?branch=main)
6 | [](https://anaconda.org/conda-forge/matplotlive)
7 | [](https://pypi.org/project/matplotlive/)
8 |
9 | Stream live plots to a [Matplotlib](https://matplotlib.org/) figure.
10 |
11 | ## Example
12 |
13 |
14 |
15 | ```py
16 | import math
17 | import matplotlive
18 |
19 | plot = matplotlive.LivePlot(
20 | timestep=0.01, # seconds
21 | duration=1.0, # seconds
22 | ylim=(-5.0, 5.0),
23 | )
24 |
25 | for i in range(10_000):
26 | plot.send("bar", math.sin(0.3 * i))
27 | plot.send("foo", 3 * math.cos(0.2 * i))
28 | plot.update()
29 | ```
30 |
31 | ## Installation
32 |
33 | ### From conda-forge
34 |
35 | ```console
36 | conda install -c conda-forge matplotlive
37 | ```
38 |
39 | ### From PyPI
40 |
41 | ```console
42 | pip install matplotlive
43 | ```
44 |
45 | ## See also
46 |
47 | - [Teleplot](https://github.com/nesnes/teleplot): alternative to plot telemetry data from a running program.
48 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # SPDX-License-Identifier: LGPL-3.0-or-later
4 | # Copyright 2024 Inria
5 |
6 | import re
7 | import sys
8 | from os.path import abspath, dirname, join
9 |
10 | sys.path.insert(0, abspath(".."))
11 |
12 | # -- General configuration ------------------------------------------------
13 |
14 | # Sphinx extension module names as strings. They can be extensions coming with
15 | # Sphinx (named 'sphinx.ext.*') or your custom ones.
16 | extensions = [
17 | "sphinx.ext.autodoc",
18 | "sphinx.ext.coverage",
19 | "sphinx.ext.napoleon", # before sphinx_autodoc_typehints
20 | "sphinx_autodoc_typehints",
21 | "sphinx_mdinclude", # include Markdown
22 | ]
23 |
24 | # List of modules to be mocked up
25 | autodoc_mock_imports = []
26 |
27 | # Add any paths that contain templates here, relative to this directory.
28 | templates_path = []
29 |
30 | # The suffix(es) of source filenames.
31 | # You can specify multiple suffix as a list of string:
32 | #
33 | # source_suffix = ['.rst', '.md']
34 | source_suffix = ".rst"
35 |
36 | # The master toctree document.
37 | master_doc = "index"
38 |
39 | # General information about the project.
40 | project = "matplotlive"
41 | copyright = "2024 Inria"
42 | author = "Stéphane Caron"
43 |
44 | # The version info for the project you're documenting, acts as replacement for
45 | # |version| and |release|, also used in various other places throughout the
46 | # built documents.
47 | version = None
48 |
49 | # The full version, including alpha/beta/rc tags.
50 | release = None
51 |
52 | # Read version info directly from the module's __init__.py
53 | init_path = join(
54 | dirname(dirname(str(abspath(__file__)))), "matplotlive"
55 | )
56 | with open(f"{init_path}/__init__.py", "r") as fh:
57 | for line in fh:
58 | match = re.match('__version__ = "((\\d.\\d).\\d)[a-z0-9\\-]*".*', line)
59 | if match is not None:
60 | release = match.group(1)
61 | version = match.group(2)
62 | break
63 |
64 | # The language for content autogenerated by Sphinx. Refer to documentation
65 | # for a list of supported languages.
66 | language = "en"
67 |
68 | # List of patterns, relative to source directory, that match files and
69 | # directories to ignore when looking for source files.
70 | # This patterns also effect to html_static_path and html_extra_path
71 | exclude_patterns = ["build", "Thumbs.db", ".DS_Store"]
72 |
73 | # The name of the Pygments (syntax highlighting) style to use.
74 | pygments_style = "sphinx"
75 |
76 | # If true, `todo` and `todoList` produce output, else they produce nothing.
77 | todo_include_todos = False
78 |
79 | # -- Options for HTML output ----------------------------------------------
80 |
81 | # The theme to use for HTML and HTML Help pages. See the documentation for
82 | # a list of builtin themes.
83 | html_theme = "furo"
84 |
85 | # Theme options are theme-specific and customize the look and feel of a theme
86 | # further. For a list of options available for each theme, see the
87 | # documentation.
88 | html_theme_options = {}
89 |
90 | # Output file base name for HTML help builder.
91 | htmlhelp_basename = "matplotlivedoc"
92 |
--------------------------------------------------------------------------------
/docs/environment.yml:
--------------------------------------------------------------------------------
1 | name: matplotlive
2 | channels:
3 | - conda-forge
4 | dependencies:
5 | - matplotlib
6 | - pip >= 21.3.1
7 | - pip:
8 | - furo >= 2023.8.17
9 | - sphinx >= 7.2.2
10 | - sphinx-autodoc-typehints
11 | - sphinx-favicon
12 | - sphinx-mdinclude
13 | - sphinx_rtd_theme
14 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. title:: Table of Contents
2 |
3 | .. mdinclude:: ../README.md
4 |
5 | .. toctree::
6 | :maxdepth: 1
7 |
8 | live_plot.rst
9 | sketch.rst
10 |
--------------------------------------------------------------------------------
/docs/live_plot.rst:
--------------------------------------------------------------------------------
1 | *********
2 | Live plot
3 | *********
4 |
5 | .. automodule:: matplotlive.live_plot
6 | :members:
7 |
--------------------------------------------------------------------------------
/docs/sketch.rst:
--------------------------------------------------------------------------------
1 | ******
2 | Sketch
3 | ******
4 |
5 | .. automodule:: matplotlive.sketch
6 | :members:
7 |
--------------------------------------------------------------------------------
/examples/raw_sketch_updates.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | #
4 | # SPDX-License-Identifier: Apache-2.0
5 | # Copyright 2024 Inria
6 |
7 | """Draw sine waves using a raw Sketch rather than a live plot."""
8 |
9 | import numpy as np
10 |
11 | from matplotlive import Sketch
12 |
13 | OMEGA = 20.0 # Hz
14 | TIMESTEP = 1e-2 # s
15 | NB_STEPS = 100
16 |
17 | trange = np.linspace(0.0, NB_STEPS * TIMESTEP, NB_STEPS)
18 | sketch = Sketch(
19 | xlim=(trange[0], trange[-1]),
20 | ylim=(-1.5, 1.5),
21 | ylim_right=(-3.5, 3.5),
22 | )
23 |
24 | sketch.add_line("sine", "left", "b-")
25 | sketch.left_axis.set_ylabel(r"$\sin(\omega t)$", color="b")
26 | sketch.left_axis.tick_params(axis="y", labelcolor="b")
27 |
28 | sketch.add_line("cosine", "right", "g-")
29 | sketch.right_axis.set_ylabel(r"$3 \cos(\omega t)$", color="g")
30 | sketch.right_axis.tick_params(axis="y", labelcolor="g")
31 |
32 | sketch.redraw() # update axis labels
33 |
34 | for i in range(500):
35 | t = i * TIMESTEP
36 | sketch.update_line("sine", trange, np.sin(OMEGA * (trange + t)))
37 | sketch.update_line("cosine", trange, 3 * np.cos(OMEGA * (trange + t)))
38 | sketch.update()
39 |
--------------------------------------------------------------------------------
/examples/sine_waves.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | #
4 | # SPDX-License-Identifier: Apache-2.0
5 | # Copyright 2024 Inria
6 |
7 | """Draw sine waves using a live plot."""
8 |
9 | import math
10 |
11 | from matplotlive import LivePlot
12 |
13 | OMEGA = 20.0 # Hz
14 | TIMESTEP = 1e-2 # [s]
15 |
16 | plot = LivePlot(
17 | timestep=TIMESTEP,
18 | duration=1.0,
19 | ylim=(-1.5, 1.5),
20 | ylim_right=(-3.5, 3.5),
21 | )
22 |
23 | plot.add_left("sin", "b-")
24 | plot.left_axis.set_ylabel(r"$\sin(\omega t)$", color="b")
25 | plot.left_axis.tick_params(axis="y", labelcolor="b")
26 |
27 | plot.add_right("3 cos", "g-")
28 | plot.add_right("1.5 cos", "g:")
29 | plot.right_axis.set_ylabel(r"Cosines", color="g")
30 | plot.right_axis.tick_params(axis="y", labelcolor="g")
31 |
32 | plot.legend()
33 | plot.redraw()
34 |
35 | for i in range(500):
36 | t = i * TIMESTEP
37 | plot.send("sin", math.sin(OMEGA * t))
38 | plot.send("3 cos", 3 * math.cos(OMEGA * t))
39 | plot.send("1.5 cos", 1.5 * math.cos(OMEGA * t))
40 | plot.update()
41 |
--------------------------------------------------------------------------------
/examples/streaming_client.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | #
4 | # SPDX-License-Identifier: Apache-2.0
5 | # Copyright 2024 Inria
6 |
7 | """Stream sine waves from streaming_server.py to a live plot."""
8 |
9 | import socket
10 |
11 | try:
12 | import msgpack
13 | except ModuleNotFoundError:
14 | raise ModuleNotFoundError(
15 | "This example needs msgpack: `conda install msgpack-python` "
16 | "or `pip install msgpack`"
17 | )
18 |
19 | try:
20 | from loop_rate_limiters import RateLimiter
21 | except ModuleNotFoundError:
22 | raise ModuleNotFoundError(
23 | "This example needs loop rate limiters: "
24 | "`[conda|pip] install loop-rate-limiters`"
25 | )
26 |
27 | from matplotlive import LivePlot
28 |
29 | OMEGA = 20.0 # Hz
30 | TIMESTEP = 1e-2 # seconds
31 | DURATION = 5.0 # duration of this example, in seconds
32 |
33 |
34 | def prepare_plot():
35 | """Prepare a live plot to stream to."""
36 | plot = LivePlot(
37 | timestep=TIMESTEP,
38 | duration=1.0,
39 | ylim=(-1.5, 1.5),
40 | ylim_right=(-3.5, 3.5),
41 | )
42 | plot.add_left("sine", "b-")
43 | plot.left_axis.set_ylabel(r"$\sin(\omega t)$", color="b")
44 | plot.left_axis.tick_params(axis="y", labelcolor="b")
45 | plot.add_right("cosine", "g-")
46 | plot.right_axis.set_ylabel(r"$3 \cos(\omega t)$", color="g")
47 | plot.right_axis.tick_params(axis="y", labelcolor="g")
48 | plot.redraw()
49 | return plot
50 |
51 |
52 | def main():
53 | """Main function of this example."""
54 | unpacker = msgpack.Unpacker(raw=False)
55 | rate = RateLimiter(frequency=1.0 / TIMESTEP)
56 | plot = prepare_plot()
57 | server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
58 | try:
59 | server.connect(("localhost", 4747))
60 | except ConnectionRefusedError:
61 | raise ConnectionRefusedError(
62 | "Did you run `streaming_server.py` first?"
63 | )
64 | for i in range(int(DURATION / TIMESTEP)):
65 | server.send("get".encode("utf-8"))
66 | data = server.recv(4096)
67 | if not data:
68 | break
69 | unpacker.feed(data)
70 | for unpacked in unpacker:
71 | for key, value in unpacked.items():
72 | plot.send(key, value)
73 | plot.update()
74 | rate.sleep()
75 | server.close()
76 |
77 |
78 | if __name__ == "__main__":
79 | main()
80 |
--------------------------------------------------------------------------------
/examples/streaming_server.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | #
4 | # SPDX-License-Identifier: Apache-2.0
5 | # Copyright 2024 Inria
6 |
7 | """Stream sine waves from streaming_server.py to TCP clients."""
8 |
9 | import asyncio
10 | import math
11 | import socket
12 |
13 | try:
14 | import msgpack
15 | except ModuleNotFoundError:
16 | raise ModuleNotFoundError(
17 | "This example needs msgpack: `conda install msgpack-python` "
18 | "or `pip install msgpack`"
19 | )
20 |
21 | try:
22 | from loop_rate_limiters import AsyncRateLimiter
23 | except ModuleNotFoundError:
24 | raise ModuleNotFoundError(
25 | "This example needs loop rate limiters: "
26 | "`[conda|pip] install loop-rate-limiters`"
27 | )
28 |
29 | waves: dict = {}
30 |
31 | OMEGA = 20.0 # rad/s
32 |
33 |
34 | async def update():
35 | """Update time series in the global dictionary."""
36 | global waves
37 | rate = AsyncRateLimiter(frequency=200.0)
38 | t = 0.0
39 | while True:
40 | waves["sine"] = math.sin(OMEGA * t)
41 | waves["cosine"] = 3 * math.cos(OMEGA * t)
42 | t += rate.dt
43 | await rate.sleep()
44 |
45 |
46 | async def serve(client, address) -> None:
47 | """Serve a new client.
48 |
49 | Args:
50 | client: Socket of the connection.
51 | address: Pair of IP address and port.
52 | """
53 | loop = asyncio.get_event_loop()
54 | request: str = "start"
55 | packer = msgpack.Packer(use_bin_type=True)
56 | print(f"New connection from {address[0]}:{address[1]}")
57 | while request != "stop":
58 | data = await loop.sock_recv(client, 4096)
59 | if not data:
60 | break
61 | request = data.decode("utf-8").strip()
62 | if request == "get":
63 | reply = packer.pack(waves)
64 | await loop.sock_sendall(client, reply)
65 | print(f"Closing connection with {address[0]}:{address[1]}")
66 | client.close()
67 |
68 |
69 | async def listen():
70 | """Listen to incoming connections on port 4747."""
71 | loop = asyncio.get_event_loop()
72 | server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
73 | server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
74 | server_socket.bind(("", 4747))
75 | server_socket.listen(8)
76 | server_socket.setblocking(False) # required by loop.sock_accept
77 | while True:
78 | client_socket, address = await loop.sock_accept(server_socket)
79 | loop.create_task(serve(client_socket, address))
80 |
81 |
82 | async def main():
83 | """Launch the two coroutines of this example server."""
84 | await asyncio.gather(update(), listen())
85 |
86 |
87 | if __name__ == "__main__":
88 | asyncio.run(main())
89 |
--------------------------------------------------------------------------------
/matplotlive/__init__.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | #
4 | # SPDX-License-Identifier: Apache-2.0
5 | # Copyright 2023 Inria
6 |
7 | """Stream live plots to a matplotlib figure."""
8 |
9 | from .exceptions import MatplotliveError
10 | from .live_plot import LivePlot
11 | from .sketch import Sketch
12 |
13 | __version__ = "1.1.1"
14 |
15 | __all__ = [
16 | "MatplotliveError",
17 | "LivePlot",
18 | "Sketch",
19 | ]
20 |
--------------------------------------------------------------------------------
/matplotlive/exceptions.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | #
4 | # SPDX-License-Identifier: Apache-2.0
5 | # Copyright 2024 Inria
6 |
7 | """Exceptions from matplotlive.
8 |
9 | We catch all solver exceptions and re-throw them in a library-owned exception
10 | to avoid abstraction leakage. See this `design decision
11 | `__
12 | for more details on the rationale behind this choice.
13 | """
14 |
15 |
16 | class MatplotliveError(Exception):
17 | """Base class for matplotlive exceptions."""
18 |
--------------------------------------------------------------------------------
/matplotlive/live_plot.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | #
4 | # SPDX-License-Identifier: Apache-2.0
5 | # Copyright 2024 Inria
6 |
7 | """Live plot to which time series data is streamed."""
8 |
9 | from typing import Dict, Optional, Tuple
10 |
11 | import matplotlib
12 | import numpy as np
13 | from numpy.typing import NDArray
14 |
15 | from .exceptions import MatplotliveError
16 | from .sketch import Sketch
17 |
18 |
19 | class LivePlot:
20 | """Live plot to which time series data is streamed."""
21 |
22 | sketch: Sketch
23 | trange: NDArray[np.float64]
24 |
25 | def __init__(
26 | self,
27 | timestep: float,
28 | duration: float,
29 | ylim: Tuple[float, float],
30 | ylim_right: Optional[Tuple[float, float]] = None,
31 | faster: bool = True,
32 | ):
33 | """Initialize a new figure.
34 |
35 | Args:
36 | duration: Duration of the recent-past sliding window, in seconds.
37 | timestep: Time step between two successive values, in seconds.
38 | ylim: Limits for left y-axis.
39 | ylim_right: If set, create a right y-axis with these limits.
40 | faster: If set, use blitting.
41 | """
42 | xlim = (-abs(duration), 0.0)
43 | trange = np.flip(-np.arange(0.0, duration, timestep))
44 | sketch = Sketch(xlim, ylim, ylim_right, faster)
45 | sketch.left_axis.set_xlabel("Time (seconds)")
46 | self.__legend: Dict[str, list] = {"left": [], "right": []}
47 | self.__max_updates: int = 0
48 | self.__nb_updates: Dict[str, int] = {}
49 | self.__shape = (len(trange),)
50 | self.series: Dict[str, NDArray[np.float64]] = {}
51 | self.sketch = sketch
52 | self.trange = trange
53 |
54 | @property
55 | def left_axis(self) -> matplotlib.axes.Subplot:
56 | """Left axis of the plot."""
57 | return self.sketch.left_axis
58 |
59 | @property
60 | def right_axis(self) -> Optional[matplotlib.axes.Subplot]:
61 | """Right axis of the plot."""
62 | return self.sketch.right_axis
63 |
64 | def legend(self) -> None:
65 | """Place a legend on the left or right axes that are used."""
66 | if self.__legend["left"]:
67 | self.left_axis.legend(self.__legend["left"], loc="upper left")
68 | if self.right_axis and self.__legend["right"]:
69 | self.right_axis.legend(self.__legend["right"], loc="upper right")
70 |
71 | def redraw(self):
72 | """Redraw the entire plot (e.g. after updating axis labels)."""
73 | self.sketch.redraw()
74 |
75 | def reset(self):
76 | """Clear the plot."""
77 | self.__max_updates = 0
78 | self.series = {}
79 | self.sketch.reset()
80 |
81 | def __add(self, name: str, side: str, *args, **kwargs) -> None:
82 | self.sketch.add_line(name, side, *args, **kwargs)
83 | if name in self.series:
84 | raise MatplotliveError(f"a series named '{name}' already exists")
85 | self.series[name] = np.full(self.__shape, np.nan)
86 | self.__nb_updates[name] = 0
87 | self.__legend[side].append(name)
88 |
89 | def add_left(self, name: str, *args, **kwargs) -> None:
90 | """Add a new time series to the left axis.
91 |
92 | Args:
93 | name: Name of the time series.
94 | args: Positional arguments forwarded to ``pyplot.plot``.
95 | kwargs: Keyword arguments forwarded to ``pyplot.plot``.
96 | """
97 | self.__add(name, "left", *args, **kwargs)
98 |
99 | def add_right(self, name: str, *args, **kwargs) -> None:
100 | """Add a new time series to the right axis.
101 |
102 | Args:
103 | name: Name of the time series.
104 | args: Positional arguments forwarded to ``pyplot.plot``.
105 | kwargs: Keyword arguments forwarded to ``pyplot.plot``.
106 | """
107 | self.__add(name, "right", *args, **kwargs)
108 |
109 | def send(self, name: str, value: float) -> None:
110 | """Send a new value to a time series, adding it if needed.
111 |
112 | Args:
113 | name: Name of the time series.
114 | value: New value for the series.
115 | """
116 | if name not in self.series:
117 | self.add_left(name)
118 | # Deleting and appending is slightly faster than rolling an array of
119 | # size 20 (mean ± std. dev. of 7 runs, 100,000 loops each):
120 | #
121 | # %timeit np.append(np.delete(a, 0), 11111)
122 | # 5.69 µs ± 18.5 ns per loop
123 | #
124 | # %timeit np.roll(a, 1)
125 | # 6.49 µs ± 17.4 ns per loop
126 | #
127 | new_series = np.append(np.delete(self.series[name], 0), value)
128 | self.sketch.update_line(name, self.trange, new_series)
129 | self.series[name] = new_series
130 | self.__nb_updates[name] += 1
131 | self.__max_updates = max(self.__max_updates, self.__nb_updates[name])
132 |
133 | def push(self, name: str, value: float) -> None:
134 | """Send a new value to an existing time series.
135 |
136 | Args:
137 | name: Name of the time series.
138 | value: New value for the series.
139 |
140 | Note:
141 | The difference between :func:`send` and :func:`push` happens when
142 | the series was not added: :func:`send` will create it, while
143 | :func:`push` will skip. Pushing is convenient when monitoring many
144 | signals: you can push all of them in your program, and maintain a
145 | separate list of those to plot when adding them.
146 | """
147 | if name not in self.series:
148 | return
149 | return self.send(name, value)
150 |
151 | def update(self) -> None:
152 | """Update plot with latest time-series values.
153 |
154 | Calling this function will catch up all time series with the most
155 | recent one.
156 | """
157 | for name in self.series:
158 | while self.__nb_updates[name] < self.__max_updates:
159 | self.send(name, self.series[name][-1])
160 | self.sketch.update()
161 |
--------------------------------------------------------------------------------
/matplotlive/sketch.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | #
4 | # SPDX-License-Identifier: Apache-2.0
5 | # Copyright 2023 Inria
6 |
7 | """Updatable plot using matplotlib."""
8 |
9 | from typing import Any, Dict, Optional, Sequence, Tuple
10 |
11 | import matplotlib
12 | from matplotlib import pyplot as plt
13 | from matplotlib.axes._axes import Axes
14 |
15 | from .exceptions import MatplotliveError
16 |
17 |
18 | class Sketch:
19 | """Updatable plot using matplotlib."""
20 |
21 | left_axis: Axes
22 | lines: Dict[str, Any]
23 | right_axis: Optional[Axes]
24 |
25 | def __init__(
26 | self,
27 | xlim: Tuple[float, float],
28 | ylim: Tuple[float, float],
29 | ylim_right: Optional[Tuple[float, float]] = None,
30 | faster: bool = True,
31 | ):
32 | """Initialize sketch plot.
33 |
34 | Args:
35 | xlim: Limits for the x-axis.
36 | ylim: Limits for left-hand side y-axis.
37 | ylim_right: If set, create a right y-axis with these limits.
38 | faster: If set, use blitting.
39 | """
40 | if faster: # blitting doesn't work with all matplotlib backends
41 | matplotlib.use("TkAgg")
42 | figure, left_axis = plt.subplots()
43 | left_axis.set_xlim(*xlim)
44 | left_axis.set_ylim(*ylim)
45 | right_axis = None
46 | if ylim_right is not None:
47 | right_axis = left_axis.twinx() # type: ignore
48 | right_axis.set_ylim(*ylim_right)
49 | plt.show(block=False)
50 | plt.pause(0.05)
51 | self.background = None
52 | self.canvas = figure.canvas
53 | self.canvas.mpl_connect("draw_event", self.__on_draw)
54 | self.faster = faster
55 | self.figure = figure
56 | self.left_axis = left_axis
57 | self.lines = {}
58 | self.right_axis = right_axis # type: ignore
59 |
60 | def redraw(self) -> None:
61 | """Redraw the entire plot (e.g. after updating axis labels)."""
62 | plt.show(block=False)
63 |
64 | def reset(self) -> None:
65 | """Reset the sketch."""
66 | for line in self.lines.values():
67 | line.remove()
68 | self.lines = {}
69 | self.update()
70 |
71 | def add_line(self, name: str, side: str, *args, **kwargs) -> None:
72 | """Add a line-plot to the left axis.
73 |
74 | Args:
75 | name: Name to refer to this line, for updates.
76 | side: Axis to which the line is attached, "left" or "right".
77 | args: Forwarded to ``pyplot.plot``.
78 | kwargs: Forwarded to ``pyplot.plot``.
79 | """
80 | axis = self.left_axis if side == "left" else self.right_axis
81 | if axis is None:
82 | raise MatplotliveError(f"{side}-hand side axis not initialized")
83 | kwargs["animated"] = True
84 | (line,) = axis.plot([], *args, **kwargs)
85 | self.lines[name] = line
86 |
87 | def legend(self, legend: Sequence[str]) -> None:
88 | """Add a legend to the plot.
89 |
90 | Args:
91 | legend: Legend.
92 | """
93 | self.left_axis.legend(legend)
94 |
95 | def update_line(self, name: str, xdata, ydata) -> None:
96 | """Update a previously-added line.
97 |
98 | Args:
99 | name: Name of the line to update.
100 | xdata: New x-axis data.
101 | ydata: New y-axis data.
102 | """
103 | self.lines[name].set_data(xdata, ydata)
104 |
105 | def __draw_lines(self) -> None:
106 | for line in self.lines.values():
107 | self.figure.draw_artist(line)
108 |
109 | def __on_draw(self, event) -> None:
110 | if event is not None:
111 | if event.canvas != self.canvas:
112 | raise RuntimeError
113 | self.background = self.canvas.copy_from_bbox( # type: ignore
114 | self.figure.bbox,
115 | )
116 | self.__draw_lines()
117 |
118 | def update(self) -> None:
119 | """Update the output figure."""
120 | if self.background is None:
121 | self.__on_draw(None)
122 | elif self.faster:
123 | self.canvas.restore_region(self.background)
124 | self.__draw_lines()
125 | self.canvas.blit(self.figure.bbox)
126 | else: # slow mode, if blitting doesn't work
127 | self.canvas.draw()
128 | self.canvas.flush_events()
129 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["flit_core >=2,<4"]
3 | build-backend = "flit_core.buildapi"
4 |
5 | [project]
6 | name = "matplotlive"
7 | readme = "README.md"
8 | authors = [
9 | {name = "Stéphane Caron", email = "stephane.caron@inria.fr"},
10 | ]
11 | maintainers = [
12 | {name = "Stéphane Caron", email = "stephane.caron@inria.fr"},
13 | ]
14 | dynamic = ['version', 'description']
15 | requires-python = ">=3.8"
16 | classifiers = [
17 | "Development Status :: 4 - Beta",
18 | "Intended Audience :: Developers",
19 | "Intended Audience :: Science/Research",
20 | "License :: OSI Approved :: Apache Software License",
21 | "Programming Language :: Python :: 3.8",
22 | "Programming Language :: Python :: 3.9",
23 | "Programming Language :: Python :: 3.10",
24 | "Programming Language :: Python :: 3.11",
25 | "Programming Language :: Python :: 3.12",
26 | "Topic :: Scientific/Engineering :: Visualization",
27 | ]
28 | dependencies = [
29 | "matplotlib >=3.3.4",
30 | "numpy >=1.15.4",
31 | ]
32 |
33 | [project.urls]
34 | Homepage = "https://github.com/stephane-caron/matplotlive"
35 | Source = "https://github.com/stephane-caron/matplotlive"
36 | Tracker = "https://github.com/stephane-caron/matplotlive/issues"
37 | Changelog = "https://github.com/stephane-caron/matplotlive/blob/main/CHANGELOG.md"
38 |
39 | [tool.black]
40 | line-length = 79
41 |
42 | [tool.flit.module]
43 | name = "matplotlive"
44 |
45 | [tool.flit.sdist]
46 | exclude = [
47 | ".git*",
48 | ]
49 |
50 | [tool.ruff]
51 | line-length = 79
52 |
53 | [tool.ruff.lint]
54 | select = [
55 | # pyflakes
56 | "F",
57 | # pycodestyle
58 | "E",
59 | "W",
60 | # isort
61 | "I001",
62 | # pydocstyle
63 | "D"
64 | ]
65 | ignore = [
66 | "D401", # good for methods but not for class docstrings
67 | "D405", # British-style section names are also "proper"!
68 | ]
69 |
70 | [tool.ruff.lint.pydocstyle]
71 | convention = "google"
72 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | # Make sure Python treats the test directory as a package.
2 |
--------------------------------------------------------------------------------
/tests/test_live_plot.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | #
4 | # SPDX-License-Identifier: Apache-2.0
5 | # Copyright 2024 Inria
6 |
7 | """Unit tests for the LivePlot class."""
8 |
9 | import unittest
10 |
11 | from matplotlive import LivePlot, MatplotliveError
12 |
13 |
14 | class TestLivePlot(unittest.TestCase):
15 | """Test live plots."""
16 |
17 | def make_test_plot(self, *args, **kwargs):
18 | return LivePlot(
19 | timestep=0.01,
20 | duration=0.5,
21 | ylim=(-1.0, 1.0),
22 | faster=False,
23 | *args,
24 | **kwargs
25 | )
26 |
27 | def test_init(self):
28 | plot = self.make_test_plot()
29 | self.assertIsNotNone(plot.trange)
30 | self.assertEqual(len(plot.series), 0)
31 |
32 | def test_add(self):
33 | plot = self.make_test_plot()
34 | self.assertEqual(len(plot.series), 0)
35 | plot.add_left("foo")
36 | self.assertEqual(len(plot.series), 1)
37 | self.assertIsNotNone(plot.left_axis)
38 |
39 | def test_uninitialized_right(self):
40 | plot = self.make_test_plot()
41 | self.assertIsNone(plot.right_axis)
42 | with self.assertRaises(MatplotliveError):
43 | plot.add_right("bar")
44 |
45 | def test_add_right(self):
46 | plot = self.make_test_plot(ylim_right=(0.0, 10.0))
47 | plot.add_right("bar")
48 | self.assertEqual(len(plot.series), 1)
49 | self.assertIsNotNone(plot.right_axis)
50 |
51 | def test_add_twice(self):
52 | plot = self.make_test_plot()
53 | plot.add_left("foo")
54 | with self.assertRaises(MatplotliveError):
55 | plot.add_left("foo")
56 |
57 | def test_send(self):
58 | plot = self.make_test_plot()
59 | plot.send("foo", 1.0)
60 | self.assertTrue("foo" in plot.series)
61 | plot.send("foo", 2.0)
62 | plot.send("foo", 3.0)
63 | self.assertAlmostEqual(plot.series["foo"][-1], 3.0)
64 |
65 | def test_push(self):
66 | plot = self.make_test_plot()
67 | plot.push("foo", 1.0)
68 | self.assertFalse("foo" in plot.series)
69 | plot.add_left("foo")
70 | self.assertTrue("foo" in plot.series)
71 | plot.push("foo", 2.0)
72 | plot.push("foo", 3.0)
73 | self.assertAlmostEqual(plot.series["foo"][-1], 3.0)
74 |
75 | def test_update(self):
76 | plot = self.make_test_plot()
77 | plot.add_left("foo")
78 | plot.add_left("bar")
79 | plot.send("foo", 1.0)
80 | plot.send("foo", 2.0)
81 | plot.send("bar", 3.0)
82 | plot.update()
83 | self.assertAlmostEqual(plot.series["foo"][-1], 2.0)
84 | self.assertAlmostEqual(plot.series["bar"][-1], 3.0)
85 |
--------------------------------------------------------------------------------
/tests/test_sketch.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | #
4 | # SPDX-License-Identifier: Apache-2.0
5 | # Copyright 2024 Inria
6 |
7 | """Unit tests for the overall app."""
8 |
9 | import unittest
10 |
11 | from matplotlive import Sketch
12 |
13 |
14 | class TestSketch(unittest.IsolatedAsyncioTestCase):
15 | """Tests the base Sketch class."""
16 |
17 | def setUp(self):
18 | self.live_plot = Sketch(
19 | xlim=(0.0, 12.0),
20 | ylim=(-1.0, 1.0),
21 | ylim_right=(-10.0, 10.0),
22 | faster=False,
23 | )
24 |
25 | def test_left_axis(self):
26 | plot = Sketch(
27 | xlim=(0.0, 12.0),
28 | ylim=(-1.0, 1.0),
29 | faster=False,
30 | )
31 | self.assertIsNotNone(plot.left_axis)
32 | self.assertIsNone(plot.right_axis)
33 |
34 | def test_right_axis(self):
35 | plot = Sketch(
36 | xlim=(0.0, 12.0),
37 | ylim=(-1.0, 1.0),
38 | ylim_right=(-10.0, 10.0),
39 | faster=False,
40 | )
41 | self.assertIsNotNone(plot.left_axis)
42 | self.assertIsNotNone(plot.right_axis)
43 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | isolated_build = True
3 | env_list = py
4 |
5 | [gh-actions]
6 | python =
7 | 3.8: py38
8 | 3.9: py39
9 | 3.10: py310
10 | 3.11: py311
11 | 3.12: py312
12 |
13 | [testenv]
14 | deps =
15 | numpy
16 | commands =
17 | python -m unittest discover --failfast
18 |
19 | [testenv:coverage]
20 | deps =
21 | coverage[toml]
22 | numpy
23 | commands =
24 | coverage erase
25 | coverage run -m unittest discover
26 | coverage report --include="matplotlive/**" --rcfile={toxinidir}/pyproject.toml
27 |
28 | [testenv:lint]
29 | deps =
30 | black >=22.10.0
31 | ruff >=0.2.2
32 | mypy >=0.812
33 | pylint >=2.8.2
34 | commands =
35 | black --check --diff matplotlive
36 | mypy matplotlive --ignore-missing-imports
37 | pylint matplotlive --exit-zero --rcfile={toxinidir}/tox.ini
38 | ruff check matplotlive
39 | ruff format --check matplotlive
40 |
--------------------------------------------------------------------------------