diff --git a/.copier-answers.yml b/.copier-answers.yml new file mode 100644 index 0000000..c0e3095 --- /dev/null +++ b/.copier-answers.yml @@ -0,0 +1,23 @@ +# Changes here will be overwritten by Copier +_commit: v2.0.1 +_src_path: gh:lincc-frameworks/python-project-template +author_email: sergey@lisakov.com +author_name: Sergey Lisakov +create_example_module: false +custom_install: true +enforce_style: [] +failure_notification: [] +include_benchmarks: false +include_docs: true +include_notebooks: false +mypy_type_checking: none +package_name: plotnik +project_license: BSD +project_name: plotnik +project_organization: pozitron57 +python_versions: +- '3.8' +- '3.9' +- '3.10' +- '3.11' +- '3.12' diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 0000000..b1a286b --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1,4 @@ +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ +ref-names: $Format:%D$ \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..343a755 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,24 @@ +# For explanation of this file and uses see +# https://git-scm.com/docs/gitattributes +# https://developer.lsst.io/git/git-lfs.html#using-git-lfs-enabled-repositories +# https://lincc-ppt.readthedocs.io/en/latest/practices/git-lfs.html +# +# Used by https://github.com/lsst/afwdata.git +# *.boost filter=lfs diff=lfs merge=lfs -text +# *.dat filter=lfs diff=lfs merge=lfs -text +# *.fits filter=lfs diff=lfs merge=lfs -text +# *.gz filter=lfs diff=lfs merge=lfs -text +# +# apache parquet files +# *.parq filter=lfs diff=lfs merge=lfs -text +# +# sqlite files +# *.sqlite3 filter=lfs diff=lfs merge=lfs -text +# +# gzip files +# *.gz filter=lfs diff=lfs merge=lfs -text +# +# png image files +# *.png filter=lfs diff=lfs merge=lfs -text + +.git_archival.txt export-subst \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3b5ca19 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/build-documentation.yml b/.github/workflows/build-documentation.yml new file mode 100644 index 0000000..6d18a97 --- /dev/null +++ b/.github/workflows/build-documentation.yml @@ -0,0 +1,35 @@ +# This workflow will install Python dependencies, build the package and then build the documentation. + +name: Build documentation + + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install dependencies + run: | + sudo apt-get update + python -m pip install --upgrade pip + if [ -f docs/requirements.txt ]; then pip install -r docs/requirements.txt; fi + pip install . + - name: Build docs + run: | + sphinx-build -T -E -b html -d docs/build/doctrees ./docs docs/build/html diff --git a/.github/workflows/pre-commit-ci.yml b/.github/workflows/pre-commit-ci.yml new file mode 100644 index 0000000..e1905dd --- /dev/null +++ b/.github/workflows/pre-commit-ci.yml @@ -0,0 +1,35 @@ +# This workflow runs pre-commit hooks on pushes and pull requests to main +# to enforce coding style. To ensure correct configuration, please refer to: +# https://lincc-ppt.readthedocs.io/en/latest/practices/ci_precommit.html +name: Run pre-commit hooks + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + pre-commit-ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install dependencies + run: | + sudo apt-get update + python -m pip install --upgrade pip + pip install .[dev] + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - uses: pre-commit/action@v3.0.1 + with: + extra_args: --all-files --verbose + env: + SKIP: "check-lincc-frameworks-template-version,no-commit-to-branch,check-added-large-files,validate-pyproject,sphinx-build,pytest-check" + - uses: pre-commit-ci/lite-action@v1.1.0 + if: failure() && github.event_name == 'pull_request' && github.event.pull_request.draft == false \ No newline at end of file diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml new file mode 100644 index 0000000..f7cecc2 --- /dev/null +++ b/.github/workflows/publish-to-pypi.yml @@ -0,0 +1,37 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://github.com/pypa/gh-action-pypi-publish#trusted-publishing + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml new file mode 100644 index 0000000..d909312 --- /dev/null +++ b/.github/workflows/smoke-test.yml @@ -0,0 +1,42 @@ +# This workflow will run daily at 06:45. +# It will install Python dependencies and run tests with a variety of Python versions. +# See documentation for help debugging smoke test issues: +# https://lincc-ppt.readthedocs.io/en/latest/practices/ci_testing.html#version-culprit + +name: Unit test smoke test + +on: + + # Runs this workflow automatically + schedule: + - cron: 45 6 * * * + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + sudo apt-get update + python -m pip install --upgrade pip + pip install -e .[dev] + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: List dependencies + run: | + pip list + - name: Run unit tests with pytest + run: | + python -m pytest \ No newline at end of file diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..a25e7be --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,34 @@ +# This workflow will install Python dependencies, run tests and report code coverage with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Unit test and code coverage + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + sudo apt-get update + python -m pip install --upgrade pip + pip install -e .[dev] + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Run unit tests with pytest + run: | + python -m pytest diff --git a/.gitignore b/.gitignore index 7243ae5..5ddaec3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,153 @@ -__pycache__/ +# vim *.swp + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +_version.py + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +_readthedocs/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# vscode +.vscode/ + +# dask +dask-worker-space/ + +# tmp directory +tmp/ + +# Mac OS +.DS_Store + +# Airspeed Velocity performance results +_results/ +_html/ + +# Project initialization script +.initialize_new_project.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8b0dda4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,79 @@ +repos: + # Compare the local template version to the latest remote template version + # This hook should always pass. It will print a message if the local version + # is out of date. + - repo: https://github.com/lincc-frameworks/pre-commit-hooks + rev: v0.1.1 + hooks: + - id: check-lincc-frameworks-template-version + name: Check template version + description: Compare current template version against latest + verbose: true + # Clear output from jupyter notebooks so that only the input cells are committed. + - repo: local + hooks: + - id: jupyter-nb-clear-output + name: Clear output from Jupyter notebooks + description: Clear output from Jupyter notebooks. + files: \.ipynb$ + stages: [commit] + language: system + entry: jupyter nbconvert --clear-output + # Prevents committing directly branches named 'main' and 'master'. + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: no-commit-to-branch + name: Prevent main branch commits + description: Prevent the user from committing directly to the primary branch. + - id: check-added-large-files + name: Check for large files + description: Prevent the user from committing very large files. + args: ['--maxkb=500'] + # Verify that pyproject.toml is well formed + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.12.1 + hooks: + - id: validate-pyproject + name: Validate pyproject.toml + description: Verify that pyproject.toml adheres to the established schema. + # Verify that GitHub workflows are well formed + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.28.0 + hooks: + - id: check-github-workflows + args: ["--verbose"] + # Make sure Sphinx can build the documentation without issues. + - repo: local + hooks: + - id: sphinx-build + name: Build documentation with Sphinx + entry: sphinx-build + language: system + always_run: true + exclude_types: [file, symlink] + args: + [ + "-T", # Show full trace back on exception + "-E", # Don't use saved env. always read all files. + "-b", # Flag to select which builder to use + "html", # Use the HTML builder + "-d", # Flag for cached environment and doctrees + "./docs/_build/doctrees", # directory + "./docs", # Source directory of documents + "./_readthedocs", # Output directory for rendered documents. + ] + # Run unit tests, verify that they pass. Note that coverage is run against + # the ./src directory here because that is what will be committed. In the + # github workflow script, the coverage is run against the installed package + # and uploaded to Codecov by calling pytest like so: + # `python -m pytest --cov= --cov-report=xml` + - repo: local + hooks: + - id: pytest-check + name: Run unit tests + description: Run unit tests with pytest. + entry: bash -c "if python -m pytest --co -qq; then python -m pytest --cov=./src --cov-report=html; fi" + language: system + pass_filenames: false + always_run: true diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..79bfc27 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,22 @@ +# .readthedocs.yml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.10" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Optionally declare the Python requirements required to build your docs +python: + install: + - requirements: docs/requirements.txt + - method: pip + path: . diff --git a/.setup_dev.sh b/.setup_dev.sh new file mode 100644 index 0000000..d8cd955 --- /dev/null +++ b/.setup_dev.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +# This script should be run by new developers to install this package in +# editable mode and configure their local environment + +echo "Checking virtual environment" +if [ -z "${VIRTUAL_ENV}" ] && [ -z "${CONDA_PREFIX}" ]; then + echo 'No virtual environment detected: none of $VIRTUAL_ENV or $CONDA_PREFIX is set.' + echo + echo "=== This script is going to install the project in the system python environment ===" + echo "Proceed? [y/N]" + read -r RESPONCE + if [ "${RESPONCE}" != "y" ]; then + echo "See https://lincc-ppt.readthedocs.io/ for details." + echo "Exiting." + exit 1 + fi + +fi + +echo "Checking pip version" +MINIMUM_PIP_VERSION=22 +pipversion=( $(python -m pip --version | awk '{print $2}' | sed 's/\./ /g') ) +if let "${pipversion[0]}<${MINIMUM_PIP_VERSION}"; then + echo "Insufficient version of pip found. Requires at least version ${MINIMUM_PIP_VERSION}." + echo "See https://lincc-ppt.readthedocs.io/ for details." + exit 1 +fi + +echo "Installing package and runtime dependencies in local environment" +python -m pip install -e . > /dev/null + +echo "Installing developer dependencies in local environment" +python -m pip install -e .'[dev]' > /dev/null +if [ -f docs/requirements.txt ]; then python -m pip install -r docs/requirements.txt; fi + +echo "Installing pre-commit" +pre-commit install > /dev/null + +####################################################### +# Include any additional configurations below this line +####################################################### diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96ebd91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2023, Sergey Lisakov + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +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. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +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. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..aa8ae08 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,26 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= -T -E -d _build/doctrees -D language=en +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = ../_readthedocs/ + +.PHONY: help clean Makefile + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +# Cleans up files generated by the build process +clean: + rm -r "_build/doctrees" + rm -r "$(BUILDDIR)" + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..234a6fc --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,57 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + + +import os +import sys +from importlib.metadata import version + +# Define path to the code to be documented **relative to where conf.py (this file) is kept** +sys.path.insert(0, os.path.abspath("../src/")) + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "plotnik" +copyright = "2023, Sergey Lisakov" +author = "Sergey Lisakov" +release = version("plotnik") +# for example take major/minor +version = ".".join(release.split(".")[:2]) + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = ["sphinx.ext.mathjax", "sphinx.ext.napoleon", "sphinx.ext.viewcode"] + +extensions.append("autoapi.extension") + +# -- sphinx-copybutton configuration ---------------------------------------- +extensions.append("sphinx_copybutton") +## sets up the expected prompt text from console blocks, and excludes it from +## the text that goes into the clipboard. +copybutton_exclude = ".linenos, .gp" +copybutton_prompt_text = ">> " + +## lets us suppress the copy button on select code blocks. +copybutton_selector = "div:not(.no-copybutton) > div.highlight > pre" + +templates_path = [] +exclude_patterns = ["_build", "**.ipynb_checkpoints"] + +# This assumes that sphinx-build is called from the root directory +master_doc = "index" +# Remove 'view source code' from top of page (for html, not python) +html_show_sourcelink = False +# Remove namespaces from class/method signatures +add_module_names = False + +autoapi_type = "python" +autoapi_dirs = ["../src"] +autoapi_ignore = ["*/__main__.py", "*/_version.py"] +autoapi_add_toc_tree_entry = False +autoapi_member_order = "bysource" + +html_theme = "sphinx_rtd_theme" diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..883ffcf --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,50 @@ +.. plotnik documentation main file. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to plotnik's documentation! +======================================================================================== + +Dev Guide - Getting Started +--------------------------- + +Before installing any dependencies or writing code, it's a great idea to create a +virtual environment. LINCC-Frameworks engineers primarily use `conda` to manage virtual +environments. If you have conda installed locally, you can run the following to +create and activate a new environment. + +.. code-block:: console + + >> conda create env -n python=3.10 + >> conda activate + + +Once you have created a new environment, you can install this project for local +development using the following commands: + +.. code-block:: console + + >> pip install -e .'[dev]' + >> pre-commit install + >> conda install pandoc + + +Notes: + +1) The single quotes around ``'[dev]'`` may not be required for your operating system. +2) ``pre-commit install`` will initialize pre-commit for this local repository, so + that a set of tests will be run prior to completing a local commit. For more + information, see the Python Project Template documentation on + `pre-commit `_. +3) Installing ``pandoc`` allows you to verify that automatic rendering of Jupyter notebooks + into documentation for ReadTheDocs works as expected. For more information, see + the Python Project Template documentation on + `Sphinx and Python Notebooks `_. + + +.. toctree:: + :hidden: + + Home page + API Reference + diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..34071a0 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ + +sphinx +sphinx-autoapi +sphinx-copybutton +sphinx-rtd-theme \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cfa70c8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "plotnik" +license = {file = "LICENSE"} +readme = "README.md" +authors = [ + { name = "Sergey Lisakov", email = "sergey@lisakov.com" } +] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: BSD License", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Operating System :: OS Independent", + "Programming Language :: Python", +] +dynamic = ["version"] +requires-python = ">=3.8" +dependencies = [ + "numpy", + "matplotlib", + "scipy", +] + +[project.urls] +"Source Code" = "https://github.com/pozitron57/plotnik" + +# On a mac, install optional dependencies with `pip install '.[dev]'` (include the single quotes) +[project.optional-dependencies] +dev = [ + "pre-commit", # Used to run checks before finalizing a git commit + "pytest", +] + +[build-system] +requires = [ + "setuptools>=62", # Used to build and package the Python project + "setuptools_scm>=6.2", # Gets release version from git. Makes it available programmatically +] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +write_to = "src/plotnik/_version.py" + +[tool.pytest.ini_options] +testpaths = [ + "tests", +] diff --git a/__init__.py b/src/plotnik/__init__.py similarity index 100% rename from __init__.py rename to src/plotnik/__init__.py index d9db51b..a33ee0d 100644 --- a/__init__.py +++ b/src/plotnik/__init__.py @@ -1,2 +1,2 @@ -from .processes import * from .drawing import Drawing +from .processes import * diff --git a/drawing.py b/src/plotnik/drawing.py similarity index 68% rename from drawing.py rename to src/plotnik/drawing.py index fd92c92..b78ceeb 100644 --- a/drawing.py +++ b/src/plotnik/drawing.py @@ -1,41 +1,43 @@ import subprocess + import matplotlib.pyplot as plt -from matplotlib import rcParams -from matplotlib.patches import FancyArrowPatch import numpy as np -from .processes import Process +from matplotlib.patches import FancyArrowPatch + from .global_drawing import GLOBAL_DRAWING + class Drawing: def __init__(self): self.last_point = None - self.config = {'font': 'stix', - 'fontsize': 34, - 'lw': 3.2, - 'aspect': 1, - 'xlim': [0, 10.7], - 'ylim': [0, 10.7], - 'xname': '', - 'yname': '', - 'yname_y': None, - 'xname_x': None, - 'xname_ofst': None, - 'yname_ofst': None, - 'zero': True, - 'zero_x': 0, - 'zero_ofst': None, - 'axes_arrow_width': None, # Set axes arrows width - 'axes_arrow_length': None, # Set axes arrows length - 'axes_arrow_scale': 1, # Scale axes arrows by a factor - 'arrow_size': 27, # Default arrows size for processes - 'tick_width': None, - 'tick_length': None, - 'center_x': None, - 'center_y': None, - 'center': None, - 'y_gap': None, - 'y_gap_size': 0.05, - } + self.config = { + 'font': 'stix', + 'fontsize': 34, + 'lw': 3.2, + 'aspect': 1, + 'xlim': [0, 10.7], + 'ylim': [0, 10.7], + 'xname': '', + 'yname': '', + 'yname_y': None, + 'xname_x': None, + 'xname_ofst': None, + 'yname_ofst': None, + 'zero': True, + 'zero_x': 0, + 'zero_ofst': None, + 'axes_arrow_width': None, # Set axes arrows width + 'axes_arrow_length': None, # Set axes arrows length + 'axes_arrow_scale': 1, # Scale axes arrows by a factor + 'arrow_size': 27, # Default arrows size for processes + 'tick_width': None, + 'tick_length': None, + 'center_x': None, + 'center_y': None, + 'center': None, + 'y_gap': None, + 'y_gap_size': 0.05, + } self.grid_config = {} # Initialization of grid_config as an empty dictionary. self.fig = None self.ax = None @@ -54,39 +56,43 @@ def __iadd__(self, process): return self ## For global drawing - #def initialize_axes(self): - ##self.fig, self.ax = plt.subplots() - #pass + # def initialize_axes(self): + ##self.fig, self.ax = plt.subplots() + # pass def update_rcParams(self): tick_length = self.config['tick_length'] if self.config['tick_length'] is not None else 6.5 tick_width = self.config['tick_width'] if self.config['tick_width'] is not None else 2 - plt.rcParams.update({ - "font.size": self.config['fontsize'], - "xtick.major.size": tick_length, - "ytick.major.size": tick_length, - "xtick.major.width": tick_width, - "ytick.major.width": tick_width, - }) + plt.rcParams.update( + { + 'font.size': self.config['fontsize'], + 'xtick.major.size': tick_length, + 'ytick.major.size': tick_length, + 'xtick.major.width': tick_width, + 'ytick.major.width': tick_width, + } + ) if 'stix' in self.config.get('font', ''): - plt.rcParams.update({ - "text.usetex": False, - "mathtext.fontset": "custom", - "mathtext.it": "STIX Two Text:italic", - "mathtext.rm": "STIX Two Text", - "mathtext.sf": "STIX Two Text", - "font.sans-serif": "STIX Two Text", - }) + plt.rcParams.update( + { + 'text.usetex': False, + 'mathtext.fontset': 'custom', + 'mathtext.it': 'STIX Two Text:italic', + 'mathtext.rm': 'STIX Two Text', + 'mathtext.sf': 'STIX Two Text', + 'font.sans-serif': 'STIX Two Text', + } + ) else: - plt.rcParams.update({ - "text.usetex": True, - "font.family": "serif", - "text.latex.preamble": "\n".join([ - r"\usepackage[T2A]{fontenc}", - r"\usepackage[utf8]{inputenc}", - r"\usepackage[russian]{babel}" - ]) - }) + plt.rcParams.update( + { + 'text.usetex': True, + 'font.family': 'serif', + 'text.latex.preamble': '\n'.join( + [r'\usepackage[T2A]{fontenc}', r'\usepackage[utf8]{inputenc}', r'\usepackage[russian]{babel}'] + ), + } + ) # Create figure and axes self.fig, self.ax = plt.subplots() @@ -103,20 +109,20 @@ def update_rcParams(self): # Remove standard ticks self.ax.set_xticks([]) self.ax.set_yticks([]) - + def set_config(self, **kwargs): self.config.update(kwargs) self.update_rcParams() - + ## Set the axes limits if they have been specified. - #if 'xlim' in kwargs: - #self.ax.set_xlim(kwargs['xlim']) - #if 'ylim' in kwargs: - #self.ax.set_ylim(kwargs['ylim']) + # if 'xlim' in kwargs: + # self.ax.set_xlim(kwargs['xlim']) + # if 'ylim' in kwargs: + # self.ax.set_ylim(kwargs['ylim']) def add_process(self, process): if self.ax is None: - raise Exception("Axes not initialized.") + raise Exception('Axes not initialized.') process.config = self.config # Set config for the process if process.start is None and self.last_point is not None: # Use last point from the previous process @@ -135,7 +141,7 @@ def add_process(self, process): self._add_xtick_label(process.start[0], process.start_xtick_label) if hasattr(process, 'end_xtick_label'): self._add_xtick_label(process.end[0], process.end_xtick_label) - + # Add aditional lines (tox, toy, tozero) for line_type, line_part, color, ls, lw in process.extra_lines: start_x, start_y = process.start if process.start else (None, None) @@ -161,9 +167,24 @@ def add_process(self, process): # Update the last point so the next process can use it as its starting point. if process.end is not None: - self.last_point = process.end - - def grid(self, step=None, step_x=None, step_y=None, ls='-', color='#777777', lw=0.9, zorder=-9, Nx=None, Ny=None, x_start=None, x_end=None, y_start=None, y_end=None): + self.last_point = process.end + + def grid( + self, + step=None, + step_x=None, + step_y=None, + ls='-', + color='#777777', + lw=0.9, + zorder=-9, + Nx=None, + Ny=None, + x_start=None, + x_end=None, + y_start=None, + y_end=None, + ): # Ignore step_x и step_y, if 'step' is set if step is not None: if step_x is not None or step_y is not None: @@ -211,41 +232,58 @@ def _add_grid(self): x_step = grid_config['step_x'] y_step = grid_config['step_y'] - x_start = grid_config.get('x_start', max(xlim[0], 0 - (Nx // 2) * x_step) ) - x_end = grid_config.get('x_end', min(xlim[1], x_start + Nx * x_step) ) + x_start = grid_config.get('x_start', max(xlim[0], 0 - (Nx // 2) * x_step)) + x_end = grid_config.get('x_end', min(xlim[1], x_start + Nx * x_step)) if ylim[0] >= 0: - y_start = grid_config.get('y_start', y_step * (ylim[0] // y_step) ) + y_start = grid_config.get('y_start', y_step * (ylim[0] // y_step)) else: - y_start = grid_config.get('y_start', y_step * (ylim[0] // y_step+1) ) - y_end = grid_config.get('y_end', min(ylim[1], y_start + Ny * y_step) ) + y_start = grid_config.get('y_start', y_step * (ylim[0] // y_step + 1)) + y_end = grid_config.get('y_end', min(ylim[1], y_start + Ny * y_step)) # Draw vertical lines for x in np.arange(x_start, x_end + grid_config['step_x'], grid_config['step_x']): - if x != 0 and xlim[0] <= x <= min(xlim[1], x_end): # Учитываем x_end и игнорируем линию, совпадающую с осью Y - self.ax.plot([x, x], [y_start, y_end], linestyle=grid_config['ls'], - color=grid_config['color'], linewidth=grid_config['lw'], - zorder=grid_config['zorder'], clip_on=False) + if x != 0 and xlim[0] <= x <= min( + xlim[1], x_end + ): # Учитываем x_end и игнорируем линию, совпадающую с осью Y + self.ax.plot( + [x, x], + [y_start, y_end], + linestyle=grid_config['ls'], + color=grid_config['color'], + linewidth=grid_config['lw'], + zorder=grid_config['zorder'], + clip_on=False, + ) # Draw horizontal lines for y in np.arange(y_start, y_end + y_step, y_step): - if y != 0 and ylim[0] <= y <= min(ylim[1], y_end): # Учитываем y_end и игнорируем линию, совпадающую с осью X - self.ax.plot([x_start, x_end], [y, y], linestyle=grid_config['ls'], - color=grid_config['color'], linewidth=grid_config['lw'], - zorder=grid_config['zorder'], clip_on=False) + if y != 0 and ylim[0] <= y <= min( + ylim[1], y_end + ): # Учитываем y_end и игнорируем линию, совпадающую с осью X + self.ax.plot( + [x_start, x_end], + [y, y], + linestyle=grid_config['ls'], + color=grid_config['color'], + linewidth=grid_config['lw'], + zorder=grid_config['zorder'], + clip_on=False, + ) def _add_xtick_label(self, x, label): xlen = self.config['xlim'][1] - self.config['xlim'][0] ylen = self.config['ylim'][1] - self.config['ylim'][0] - xlabel_ofst = self.config.get('xlabel_ofst', [xlen * 0.02, ylen * 0.14 * self.config['fontsize']/30]) + xlabel_ofst = self.config.get('xlabel_ofst', [xlen * 0.02, ylen * 0.14 * self.config['fontsize'] / 30]) if self.config['tick_length'] is not None: tick_length = self.config['tick_length'] - else: - tick_length = xlabel_ofst[1]*0.2 + else: + tick_length = xlabel_ofst[1] * 0.2 # Add label text self.ax.text(x, -xlabel_ofst[1], label, va='baseline', ha='center', fontsize=self.config['fontsize']) # Draw a tick line - self.ax.plot([x, x], [0, -tick_length], color='k', - linestyle='-', linewidth=self.config['lw'] * 0.8, clip_on=False) + self.ax.plot( + [x, x], [0, -tick_length], color='k', linestyle='-', linewidth=self.config['lw'] * 0.8, clip_on=False + ) def _add_ytick_label(self, y_val, label): xlen = self.config['xlim'][1] - self.config['xlim'][0] @@ -253,26 +291,31 @@ def _add_ytick_label(self, y_val, label): aspect = self.config['aspect'] ylabel_ofst = self.config.get('ylabel_ofst', [xlen * 0.05 * aspect, ylen * 0.03]) - xlabel_ofst = self.config.get('xlabel_ofst', [xlen * 0.02, ylen * 0.14 * self.config['fontsize']/30]) + xlabel_ofst = self.config.get('xlabel_ofst', [xlen * 0.02, ylen * 0.14 * self.config['fontsize'] / 30]) if self.config['tick_length'] is not None: tick_length = self.config['tick_length'] - else: - tick_length = xlabel_ofst[1]*0.2 * aspect + else: + tick_length = xlabel_ofst[1] * 0.2 * aspect # Add label text self.ax.text(-ylabel_ofst[0], y_val, label, va='center', ha='right', fontsize=self.config['fontsize']) # Draw a tick line - self.ax.plot([-tick_length, 0], [y_val, y_val], color='k', - linestyle='-', linewidth=self.config['lw'] * 0.8, - clip_on=False) - + self.ax.plot( + [-tick_length, 0], + [y_val, y_val], + color='k', + linestyle='-', + linewidth=self.config['lw'] * 0.8, + clip_on=False, + ) + def add_xticks(self, xticks, names=None, bg=False, bgcolor='white', bgsize=None, direction='out'): ylen = self.config['ylim'][1] - self.config['ylim'][0] xlen = self.config['xlim'][1] - self.config['xlim'][0] - xlabel_ofst = self.config.get('xlabel_ofst', [xlen * 0.02, ylen * 0.14 * self.config['fontsize']/30]) + xlabel_ofst = self.config.get('xlabel_ofst', [xlen * 0.02, ylen * 0.14 * self.config['fontsize'] / 30]) if self.config['tick_length'] is None: - tick_length = xlabel_ofst[1]*0.2 + tick_length = xlabel_ofst[1] * 0.2 else: tick_length = self.config['tick_length'] @@ -287,9 +330,9 @@ def add_xticks(self, xticks, names=None, bg=False, bgcolor='white', bgsize=None, x_label = label_dict.get(x, x) # Replace . for a comma as a decimal separator if isinstance(x, (int, float)): - x_label = f"{x_label}".replace('.', ',') + x_label = f'{x_label}'.replace('.', ',') # Оборачиваем метку в LaTeX - #x_label = f"${x_label}$" + # x_label = f"${x_label}$" if bg: # Set background for a ticklabel @@ -297,12 +340,18 @@ def add_xticks(self, xticks, names=None, bg=False, bgcolor='white', bgsize=None, if bgsize: bg_params['boxstyle'] = f'round,pad={bgsize}' # Add the text with a background - self.ax.text(x, -xlabel_ofst[1], x_label, va='baseline', ha='center', - fontsize=self.config['fontsize'], bbox=bg_params) + self.ax.text( + x, + -xlabel_ofst[1], + x_label, + va='baseline', + ha='center', + fontsize=self.config['fontsize'], + bbox=bg_params, + ) else: # Add the text without a background - self.ax.text(x, -xlabel_ofst[1], x_label, va='baseline', ha='center', - fontsize=self.config['fontsize']) + self.ax.text(x, -xlabel_ofst[1], x_label, va='baseline', ha='center', fontsize=self.config['fontsize']) # Рисование штриха if direction == 'out': @@ -312,19 +361,17 @@ def add_xticks(self, xticks, names=None, bg=False, bgcolor='white', bgsize=None, else: end = 0 - self.ax.plot([x, x], [0, end], color='k', linestyle='-', - linewidth=self.config['lw'] * 0.8, clip_on=False) - + self.ax.plot([x, x], [0, end], color='k', linestyle='-', linewidth=self.config['lw'] * 0.8, clip_on=False) def add_yticks(self, yticks, names=None, direction='out'): aspect = self.config['aspect'] xlen = self.config['xlim'][1] - self.config['xlim'][0] ylen = self.config['ylim'][1] - self.config['ylim'][0] ylabel_ofst = self.config.get('ylabel_ofst', [xlen * 0.05 * aspect, ylen * 0.03]) - xlabel_ofst = self.config.get('xlabel_ofst', [xlen * 0.02, ylen * 0.14 * self.config['fontsize']/30]) + xlabel_ofst = self.config.get('xlabel_ofst', [xlen * 0.02, ylen * 0.14 * self.config['fontsize'] / 30]) if self.config['tick_length'] is None: - tick_length = xlabel_ofst[1]*0.2 + tick_length = xlabel_ofst[1] * 0.2 else: tick_length = self.config['tick_length'] @@ -339,29 +386,27 @@ def add_yticks(self, yticks, names=None, direction='out'): y_label = label_dict.get(y, y) # Replace . for a comma as a decimal separator if isinstance(y, (int, float)): - y_label = f"{y_label}".replace('.', ',') - y_label = f"${y_label}$" - self.ax.text(-ylabel_ofst[0], y, y_label, va='center', ha='right', - fontsize=self.config['fontsize']) + y_label = f'{y_label}'.replace('.', ',') + y_label = f'${y_label}$' + self.ax.text(-ylabel_ofst[0], y, y_label, va='center', ha='right', fontsize=self.config['fontsize']) # Draw a tick line if direction == 'out': - start = -tick_length*aspect + start = -tick_length * aspect elif direction == 'in': - start = tick_length*aspect + start = tick_length * aspect else: start = 0 self.ax.plot([start, 0], [y, y], 'k-', clip_on=False, linewidth=self.config['lw'] * 0.8) - def show(self): if GLOBAL_DRAWING.drawing is self: GLOBAL_DRAWING.release_processes() xlen = self.config['xlim'][1] - self.config['xlim'][0] ylen = self.config['ylim'][1] - self.config['ylim'][0] - lw = self.config['lw']*0.8 + lw = self.config['lw'] * 0.8 xlim = self.config.get('xlim', self.ax.get_xlim()) ylim = self.config.get('ylim', self.ax.get_ylim()) @@ -374,8 +419,8 @@ def show(self): aspect = 1 # Draw axes with arrows - longer_axis = np.amax([xlen,ylen]) - k = 1/2 + longer_axis = np.amax([xlen, ylen]) + k = 1 / 2 axes_arrow_width = self.config.get('axes_arrow_width') axes_arrow_length = self.config.get('axes_arrow_length') if ylen >= xlen: @@ -409,68 +454,79 @@ def show(self): axes_arrow_scale = self.config.get('axes_arrow_scale', 1) ## X axis - #arrowstyle_x = f"-|>,head_length={hl_x},head_width={hw_x}" - #arrow_x = FancyArrowPatch((xlim[0], 0), (xlim[1], 0), - #clip_on=False, - #arrowstyle=arrowstyle_x, - #mutation_scale = 200 / xlen * axes_arrow_scale, - #mutation_aspect=aspect, - #shrinkA=0, - #shrinkB=0, - #lw=lw, color='k', zorder=5) - #self.ax.add_patch(arrow_x) + # arrowstyle_x = f"-|>,head_length={hl_x},head_width={hw_x}" + # arrow_x = FancyArrowPatch((xlim[0], 0), (xlim[1], 0), + # clip_on=False, + # arrowstyle=arrowstyle_x, + # mutation_scale = 200 / xlen * axes_arrow_scale, + # mutation_aspect=aspect, + # shrinkA=0, + # shrinkB=0, + # lw=lw, color='k', zorder=5) + # self.ax.add_patch(arrow_x) # Set x_gap x_gap = self.config.get('x_gap', None) x_gap_size = self.config.get('x_gap_size', 0.05) * xlen x_gap_margin = x_gap_size * 0.5 # Small gap before and after the dots - + if x_gap is not None: arrow_x_segments = [ ((xlim[0], 0), (x_gap - x_gap_size / 2 - x_gap_margin, 0)), - ((x_gap + x_gap_size / 2 + x_gap_margin, 0), (xlim[1] - hl_x, 0)) # Adjust to avoid double arrow + ((x_gap + x_gap_size / 2 + x_gap_margin, 0), (xlim[1] - hl_x, 0)), # Adjust to avoid double arrow ] else: arrow_x_segments = [((xlim[0], 0), (xlim[1] - hl_x, 0))] # Adjust to add arrow - + # Draw X axis segments - arrowstyle_x = f"-|>,head_length={hl_x},head_width={hw_x}" + arrowstyle_x = f'-|>,head_length={hl_x},head_width={hw_x}' for i, (start, end) in enumerate(arrow_x_segments): if i == len(arrow_x_segments) - 1: # Add arrow to the last segment - arrow_x = FancyArrowPatch(start, end, - clip_on=False, - arrowstyle=arrowstyle_x, - mutation_scale=200 / xlen * axes_arrow_scale, - mutation_aspect=aspect, - shrinkA=0, - shrinkB=0, - lw=lw, color='k', zorder=5) + arrow_x = FancyArrowPatch( + start, + end, + clip_on=False, + arrowstyle=arrowstyle_x, + mutation_scale=200 / xlen * axes_arrow_scale, + mutation_aspect=aspect, + shrinkA=0, + shrinkB=0, + lw=lw, + color='k', + zorder=5, + ) else: # Add regular line segments - arrow_x = FancyArrowPatch(start, end, - clip_on=False, - arrowstyle='-', - mutation_scale=200 / xlen * axes_arrow_scale, - mutation_aspect=aspect, - shrinkA=0, - shrinkB=0, - lw=lw, color='k', zorder=5) + arrow_x = FancyArrowPatch( + start, + end, + clip_on=False, + arrowstyle='-', + mutation_scale=200 / xlen * axes_arrow_scale, + mutation_aspect=aspect, + shrinkA=0, + shrinkB=0, + lw=lw, + color='k', + zorder=5, + ) self.ax.add_patch(arrow_x) - + # Add dots to indicate the gap on X axis if x_gap is not None: dot_size = lw * 1.2 # Adjust dot size self.ax.plot(x_gap - x_gap_size / 2, 0, 'ko', markersize=dot_size, clip_on=False) self.ax.plot(x_gap, 0, 'ko', markersize=dot_size, clip_on=False) self.ax.plot(x_gap + x_gap_size / 2, 0, 'ko', markersize=dot_size, clip_on=False) - - # Add small gaps before and after the dots - self.ax.plot(x_gap - x_gap_size / 2 - x_gap_margin, 0, 'w|', markersize=dot_size * 1.5, clip_on=False, linewidth=lw) - self.ax.plot(x_gap + x_gap_size / 2 + x_gap_margin, 0, 'w|', markersize=dot_size * 1.5, clip_on=False, linewidth=lw) - - + # Add small gaps before and after the dots + self.ax.plot( + x_gap - x_gap_size / 2 - x_gap_margin, 0, 'w|', markersize=dot_size * 1.5, clip_on=False, linewidth=lw + ) + self.ax.plot( + x_gap + x_gap_size / 2 + x_gap_margin, 0, 'w|', markersize=dot_size * 1.5, clip_on=False, linewidth=lw + ) # Set y_gap y_gap = self.config['y_gap'] @@ -480,34 +536,44 @@ def show(self): if y_gap is not None: arrow_y_segments = [ ((0, ylim[0]), (0, y_gap - y_gap_size / 2 - gap_margin)), - ((0, y_gap + y_gap_size / 2 + gap_margin), (0, ylim[1] - 0.08)) # Adjust to avoid double arrow + ((0, y_gap + y_gap_size / 2 + gap_margin), (0, ylim[1] - 0.08)), # Adjust to avoid double arrow ] else: arrow_y_segments = [((0, ylim[0]), (0, ylim[1] - 0.08))] # Adjust to add arrow # Y axis segments - arrowstyle_y = f"-|>,head_length={hl_y},head_width={hw_y}" + arrowstyle_y = f'-|>,head_length={hl_y},head_width={hw_y}' for i, (start, end) in enumerate(arrow_y_segments): if i == len(arrow_y_segments) - 1: # Add arrow to the last segment - arrow_y = FancyArrowPatch(start, end, - clip_on=False, - arrowstyle=arrowstyle_y, - mutation_scale=200 / xlen * axes_arrow_scale, - mutation_aspect=aspect, - shrinkA=0, - shrinkB=0, - lw=lw, color='k', zorder=5) + arrow_y = FancyArrowPatch( + start, + end, + clip_on=False, + arrowstyle=arrowstyle_y, + mutation_scale=200 / xlen * axes_arrow_scale, + mutation_aspect=aspect, + shrinkA=0, + shrinkB=0, + lw=lw, + color='k', + zorder=5, + ) else: # Add regular line segments - arrow_y = FancyArrowPatch(start, end, - clip_on=False, - arrowstyle='-', - mutation_scale=200 / xlen * axes_arrow_scale, - mutation_aspect=aspect, - shrinkA=0, - shrinkB=0, - lw=lw, color='k', zorder=5) + arrow_y = FancyArrowPatch( + start, + end, + clip_on=False, + arrowstyle='-', + mutation_scale=200 / xlen * axes_arrow_scale, + mutation_aspect=aspect, + shrinkA=0, + shrinkB=0, + lw=lw, + color='k', + zorder=5, + ) self.ax.add_patch(arrow_y) # Add dots to indicate the gap @@ -518,24 +584,26 @@ def show(self): self.ax.plot(0, y_gap + y_gap_size / 2, 'ko', markersize=dot_size, clip_on=False) # Add small gaps before and after the dots - self.ax.plot(0, y_gap - y_gap_size / 2 - gap_margin, 'w|', markersize=dot_size*1.5, clip_on=False, linewidth=lw) - self.ax.plot(0, y_gap + y_gap_size / 2 + gap_margin, 'w|', markersize=dot_size*1.5, clip_on=False, linewidth=lw) - + self.ax.plot( + 0, y_gap - y_gap_size / 2 - gap_margin, 'w|', markersize=dot_size * 1.5, clip_on=False, linewidth=lw + ) + self.ax.plot( + 0, y_gap + y_gap_size / 2 + gap_margin, 'w|', markersize=dot_size * 1.5, clip_on=False, linewidth=lw + ) # Set labels padding. Used in d.add_xticks() but NOT in d.ax.add_xticks() - xlabel_ofst = self.config.get('xlabel_ofst', [xlen * 0.02, ylen * 0.14 * self.config['fontsize']/30]) + xlabel_ofst = self.config.get('xlabel_ofst', [xlen * 0.02, ylen * 0.14 * self.config['fontsize'] / 30]) ylabel_ofst = self.config.get('ylabel_ofst', [xlen * 0.05 * aspect, ylen * 0.03]) - # Add axis labels xname, yname. If xname_ofst, yname_ofst are not # specified, then yname is vertically aligned with ylabels, and xname # is horizontally aligned with xlabels. if self.config['xname_ofst'] is None: - x_dx = xlabel_ofst[0] + x_dx = xlabel_ofst[0] x_dy = -xlabel_ofst[1] else: - x_dx = self.config['xname_ofst'][0] - x_dy = self.config['xname_ofst'][1] + x_dx = self.config['xname_ofst'][0] + x_dy = self.config['xname_ofst'][1] # If yname is specified in set_config, add it as a new tick and remove # the tick mark. If yname_y is specified in set_config, add it as a new @@ -551,9 +619,14 @@ def show(self): # New yname position new_yname_pos_x = -yname_offset_x new_yname_pos_y = arrow_tip_y + yname_offset_y - self.ax.text(new_yname_pos_x, new_yname_pos_y, - self.config['yname'], ha='right', va='top', - fontsize=self.config['fontsize']) + self.ax.text( + new_yname_pos_x, + new_yname_pos_y, + self.config['yname'], + ha='right', + va='top', + fontsize=self.config['fontsize'], + ) else: current_ticks = list(self.ax.get_yticks()) current_labels = [tick.get_text() for tick in self.ax.get_yticklabels()] @@ -586,9 +659,14 @@ def show(self): # New xname position new_xname_pos_x = arrow_tip_x + xname_offset_x new_xname_pos_y = -xname_offset_y - self.ax.text(new_xname_pos_x, new_xname_pos_y, - self.config['xname'], ha='right', va='top', - fontsize=self.config['fontsize']) + self.ax.text( + new_xname_pos_x, + new_xname_pos_y, + self.config['xname'], + ha='right', + va='top', + fontsize=self.config['fontsize'], + ) else: current_xticks = list(self.ax.get_xticks()) current_xlabels = [tick.get_text() for tick in self.ax.get_xticklabels()] @@ -611,7 +689,6 @@ def show(self): if xticks: xticks[-1].tick1line.set_markersize(0) - # Add a zero at the origin if the 'zero' parameter is set to True. If # zero_ofst is set, then zero is added via ax.text(). Otherwise, it is # added as an x-axis label without a tick mark. @@ -621,13 +698,13 @@ def show(self): zero_ofst = self.config.get('zero_ofst', [0.3, 0.3]) if self.config['zero_ofst'] is None: - # Add zero as a new x tick + # Add zero as a new x tick current_xticks = list(self.ax.get_xticks()) current_xlabels = [tick.get_text() for tick in self.ax.get_xticklabels()] if 0 not in current_xticks: zero_x = self.config.get('zero_x', 0) current_xticks.append(-zero_x) - current_xlabels.append("$0$") + current_xlabels.append('$0$') self.ax.set_xticks(current_xticks) self.ax.set_xticklabels(current_xlabels) # Remove the tick at zero @@ -637,9 +714,9 @@ def show(self): else: # Use zero_ofst for positioning zero with ax.text() - self.ax.text(-zero_ofst[0], -zero_ofst[1], '$0$', - fontsize=self.config['fontsize'], ha='right', - va='baseline') + self.ax.text( + -zero_ofst[0], -zero_ofst[1], '$0$', fontsize=self.config['fontsize'], ha='right', va='baseline' + ) # Draw grid if self.config.get('grid', False): @@ -654,9 +731,11 @@ def save(self, filename, **kwargs): # Trim whitespace using Inkscape if 'crop' is specified and True if kwargs.get('crop', False): inkscape_command = [ - 'inkscape', - '--actions', - 'select-by-id:patch_1,patch_2;delete;select-all:all;fit-canvas-to-selection;export-filename:' + filename + ';export-do;', - filename + 'inkscape', + '--actions', + 'select-by-id:patch_1,patch_2;delete;select-all:all;fit-canvas-to-selection;export-filename:' + + filename + + ';export-do;', + filename, ] subprocess.run(inkscape_command) diff --git a/global_drawing.py b/src/plotnik/global_drawing.py similarity index 80% rename from global_drawing.py rename to src/plotnik/global_drawing.py index 105adf2..2f0ae7d 100644 --- a/global_drawing.py +++ b/src/plotnik/global_drawing.py @@ -1,5 +1,6 @@ # global_drawing.py + class GlobalDrawing: def __init__(self): self.drawing = None @@ -7,24 +8,24 @@ def __init__(self): def set(self, drawing) -> None: if self.drawing is not None: - raise ValueError("Global drawing has already been set") + raise ValueError('Global drawing has already been set') self.drawing = drawing def store_process(self, process) -> None: if self.drawing is None: - raise ValueError("Global drawing is not set") + raise ValueError('Global drawing is not set') self.processes.append(process) def release_processes(self): if self.drawing is None: - raise ValueError("Global drawing is not set") + raise ValueError('Global drawing is not set') for process in self.processes: self.drawing.add_process(process) self.processes = [] def release_drawing(self): self.release_processes() - + drawing = self.drawing self.drawing = None self.processes = [] @@ -33,11 +34,11 @@ def release_drawing(self): def last_point(self): if self.drawing is None: - raise ValueError("Global drawing is not set") + raise ValueError('Global drawing is not set') if len(self.processes) == 0: return None return self.processes[-1].end - + class GlobalDrawingSingleton: instance = None @@ -49,4 +50,3 @@ def __new__(cls): GLOBAL_DRAWING = GlobalDrawingSingleton() - diff --git a/processes.py b/src/plotnik/processes.py similarity index 76% rename from processes.py rename to src/plotnik/processes.py index 2b63e91..ee69602 100644 --- a/processes.py +++ b/src/plotnik/processes.py @@ -1,6 +1,7 @@ -from matplotlib.patches import FancyArrowPatch, ArrowStyle import numpy as np +from matplotlib.patches import ArrowStyle, FancyArrowPatch from scipy.interpolate import interp1d + from .global_drawing import GLOBAL_DRAWING @@ -16,7 +17,7 @@ def __init__(self): self.linestyle = '-' self.zorder = 1 self.linewidth = 2.5 - self.extra_lines = [] # to store tox(), toy(), tozero() information + self.extra_lines = [] # to store tox(), toy(), tozero() information self.xtick_labels = [] self.ytick_labels = [] self._add_to_global_drawing() @@ -36,9 +37,9 @@ def to(self, end_x, end_y=None): self.end = (end_x, end_y) return self - def arrow(self, size=None, pos=0.54, color='black', reverse=False, - filled=True, zorder=3, head_length=0.6, - head_width=0.2): + def arrow( + self, size=None, pos=0.54, color='black', reverse=False, filled=True, zorder=3, head_length=0.6, head_width=0.2 + ): self.arrow_params = { 'size': size, 'pos': pos, @@ -47,7 +48,7 @@ def arrow(self, size=None, pos=0.54, color='black', reverse=False, 'filled': filled, 'zorder': zorder, 'head_length': head_length, - 'head_width': head_width + 'head_width': head_width, } if size is not None: self.arrow_params['size'] = size @@ -76,19 +77,22 @@ def _add_arrow(self, ax, x_values, y_values): # Arrow style if self.arrow_params['filled']: - style = ArrowStyle('-|>', head_length=self.arrow_params['head_length'], - head_width=self.arrow_params['head_width']) + style = ArrowStyle( + '-|>', head_length=self.arrow_params['head_length'], head_width=self.arrow_params['head_width'] + ) else: - style = ArrowStyle('->', head_length=self.arrow_params['head_length'], - head_width=self.arrow_params['head_width']) + style = ArrowStyle( + '->', head_length=self.arrow_params['head_length'], head_width=self.arrow_params['head_width'] + ) # Draw arrow arrow = FancyArrowPatch( - (x, y), (x + dx, y + dy), + (x, y), + (x + dx, y + dy), arrowstyle=style, color=self.arrow_params['color'], mutation_scale=arrow_size, - zorder=self.arrow_params['zorder'] + zorder=self.arrow_params['zorder'], ) ax.add_patch(arrow) @@ -107,7 +111,7 @@ def lw(self, linewidth): def zord(self, zorder): self.zorder = zorder return self - + def dot(self, pos='end', **kwargs): default_params = {'size': 8, 'color': 'black', 'zorder': 5} # Add user specified parameters to default parameters @@ -126,17 +130,19 @@ def _add_dots(self, ax): point = self.start if position == 'start' else self.end # Check that point has 2 coordinates if point and None not in point: - ax.plot(point[0], point[1], - marker=self.dots_params[position].get('marker', 'o'), - markersize=self.dots_params[position].get('size', 6), - color=self.dots_params[position].get('color', 'k'), - zorder=self.dots_params[position].get('zorder', '5'), - ) + ax.plot( + point[0], + point[1], + marker=self.dots_params[position].get('marker', 'o'), + markersize=self.dots_params[position].get('size', 6), + color=self.dots_params[position].get('color', 'k'), + zorder=self.dots_params[position].get('zorder', '5'), + ) def calculate_ofst(self, point=None): # Check that config is accessible if not hasattr(self, 'config') or not self.config: - raise ValueError("Config not set for this process.") + raise ValueError('Config not set for this process.') xlen = self.config['xlim'][1] - self.config['xlim'][0] ylen = self.config['ylim'][1] - self.config['ylim'][0] @@ -144,9 +150,9 @@ def calculate_ofst(self, point=None): if self.config['center']: center_x, center_y = self.config['center'] else: - center_x = self.config['center_x'] if self.config['center_x'] else xlen/2 - center_y = self.config['center_y'] if self.config['center_y'] else ylen/2 - + center_x = self.config['center_x'] if self.config['center_x'] else xlen / 2 + center_y = self.config['center_y'] if self.config['center_y'] else ylen / 2 + # Calculate the offset depending on the point's position k = self.config['fontsize'] / 27 dx = xlen * 0.05 * k if point and point[0] >= center_x else -xlen * 0.05 * k @@ -154,8 +160,20 @@ def calculate_ofst(self, point=None): return (dx, dy) - def label(self, text1=None, text2=None, ofst=None, start_ofst=None, end_ofst=None, - end_dx=None, end_dy=None, start_dx=None, start_dy=None, dx=None, dy=None): + def label( + self, + text1=None, + text2=None, + ofst=None, + start_ofst=None, + end_ofst=None, + end_dx=None, + end_dy=None, + start_dx=None, + start_dy=None, + dx=None, + dy=None, + ): # Set dx and dy if they are provided if dx is not None: start_dx = end_dx = dx @@ -184,8 +202,6 @@ def label(self, text1=None, text2=None, ofst=None, start_ofst=None, end_ofst=Non self.end_label = {'text': text2, 'ofst': end_ofst} return self - - def _add_labels(self, ax, config): def add_label(point, label_data, ax, config): if label_data['ofst'] is None: @@ -198,9 +214,9 @@ def add_label(point, label_data, ax, config): dx = dx if dx is not None else default_dx dy = dy if dy is not None else default_dy - ax.text(point[0] + dx, point[1] + dy, label_data['text'], - fontsize=config['fontsize'], ha='center', va='center') - + ax.text( + point[0] + dx, point[1] + dy, label_data['text'], fontsize=config['fontsize'], ha='center', va='center' + ) if hasattr(self, 'start_label') and self.start: add_label(self.start, self.start_label, ax, config) @@ -208,7 +224,6 @@ def add_label(point, label_data, ax, config): if hasattr(self, 'end_label') and self.end: add_label(self.end, self.end_label, ax, config) - # For Bezier().connect() to work def tangent_at_end(self): if hasattr(self, 'x_values') and hasattr(self, 'y_values') and len(self.x_values) > 1: @@ -223,7 +238,6 @@ def tangent_at_start(self): direction = (self.x_values[1] - self.x_values[0], self.y_values[1] - self.y_values[0]) return direction return None - def tox(self, type='both', color='k', ls='--', lw=1.6): self.extra_lines.append(('x', type, color, ls, lw)) @@ -241,6 +255,7 @@ def xtick(self, *labels, which=None): # Function to convert a number to a string with a dot replaced by a comma def format_label(value): return f'${str(value).replace(".", "{,}")}$' + # If labels are not provided, use the numerical values of the coordinates if not labels: if isinstance(self, State) or which == 'start': @@ -266,6 +281,7 @@ def ytick(self, *labels, which=None): # Function to convert a number to a string, replacing a dot with a comma def format_label(value): return f'${str(value).replace(".", "{,}")}$' + # If labels are not provided, use the numerical values of the coordinates if not labels: if isinstance(self, State) or which == 'start': @@ -289,15 +305,21 @@ def format_label(value): def plot(self, ax, config): if hasattr(self, 'x_values') and hasattr(self, 'y_values'): - ax.plot(self.x_values, self.y_values, color=self.color, - linestyle=self.linestyle, linewidth=self.linewidth, - zorder=self.zorder) + ax.plot( + self.x_values, + self.y_values, + color=self.color, + linestyle=self.linestyle, + linewidth=self.linewidth, + zorder=self.zorder, + ) if self.arrow_params: self._add_arrow(ax, self.x_values, self.y_values) self._add_dots(ax) # Add labels self._add_labels(ax, config) + class State(Process): def __init__(self, drawing=None): super().__init__() # Call the constructor of the parent class @@ -324,7 +346,7 @@ def dot(self, size=6, color='k'): def plot(self, ax, config): if self.start is None: - raise ValueError("Start point not set for State.") + raise ValueError('Start point not set for State.') x, y = self.start @@ -334,13 +356,15 @@ def plot(self, ax, config): ## If a label is provided, draw it self._add_labels(ax, config) - + return self + class Linear(Process): def __init__(self): super().__init__() self.type = 'linear' + def plot(self, ax, config): if self.start and self.end: V1, p1 = self.start @@ -349,17 +373,18 @@ def plot(self, ax, config): self.y_values = np.linspace(p1, p2, 100) super().plot(ax, config) + class Iso_t(Process): def __init__(self): super().__init__() self.type = 'iso_t' def plot(self, ax, config): - #if self.start is None: - #if self.drawing and self.drawing.last_point: - #self.start = self.drawing.last_point - #else: - #raise ValueError("Start point must be set for 'Iso_t' process.") + # if self.start is None: + # if self.drawing and self.drawing.last_point: + # self.start = self.drawing.last_point + # else: + # raise ValueError("Start point must be set for 'Iso_t' process.") V1, p1 = self.start @@ -373,13 +398,13 @@ def plot(self, ax, config): self.y_values = p1 * V1 / self.x_values super().plot(ax, config) - def to(self, end, end_type="pressure"): + def to(self, end, end_type='pressure'): V1, p1 = self.start - if end_type == "pressure": + if end_type == 'pressure': p2 = end V2 = p1 * V1 / p2 - elif end_type == "volume": + elif end_type == 'volume': V2 = end p2 = p1 * V1 / V2 else: @@ -388,6 +413,7 @@ def to(self, end, end_type="pressure"): self.end = V2, p2 return self + class Power(Process): def __init__(self, power=2, drawing=None): super().__init__() @@ -395,7 +421,6 @@ def __init__(self, power=2, drawing=None): self.power = power def plot(self, ax, config): - x1, y1 = self.start # If the end point is not defined, use parameters from the to() method @@ -430,18 +455,19 @@ def to(self, end, end_type='x'): return self + class Adiabatic(Process): - def __init__(self, gamma=5/3): + def __init__(self, gamma=5 / 3): super().__init__() self.gamma = gamma self.type = 'adiabatic' def plot(self, ax, config): - #if self.start is None: - #if self.drawing and self.drawing.last_point: - #self.start = self.drawing.last_point - #else: - #raise ValueError("Start point must be set for 'Adiabatic' process.") + # if self.start is None: + # if self.drawing and self.drawing.last_point: + # self.start = self.drawing.last_point + # else: + # raise ValueError("Start point must be set for 'Adiabatic' process.") V1, p1 = self.start @@ -452,24 +478,25 @@ def plot(self, ax, config): V2, p2 = self.end self.x_values = np.linspace(V1, V2, 100) - self.y_values = (p1 * V1 ** self.gamma) / self.x_values ** self.gamma + self.y_values = (p1 * V1**self.gamma) / self.x_values**self.gamma super().plot(ax, config) - def to(self, end, end_type="pressure"): + def to(self, end, end_type='pressure'): V1, p1 = self.start - if end_type == "pressure": + if end_type == 'pressure': p2 = end - V2 = (p1 * V1 ** self.gamma / p2) ** (1 / self.gamma) - elif end_type == "volume": + V2 = (p1 * V1**self.gamma / p2) ** (1 / self.gamma) + elif end_type == 'volume': V2 = end - p2 = (p1 * V1 ** self.gamma) / V2 ** self.gamma + p2 = (p1 * V1**self.gamma) / V2**self.gamma else: raise ValueError(f"Unknown end_type '{end_type}'") self.end = V2, p2 return self + class Bezier(Process): def __init__(self, x=0, y=0, x1=None, y1=None, x2=None, y2=None): super().__init__() @@ -527,42 +554,50 @@ def _find_intersection(self, process1, process2): def plot(self, ax, config): # Needed to store x_values - if self.start and self.end: # Why this check? What happens else? + if self.start and self.end: # Why this check? What happens else? x1, y1 = self.start x2, y2 = self.end t = np.linspace(0, 1, 100) if self.x1 is not None and self.x2 is not None: # Third-order Bezier curve - self.x_values = (1-t)**3 * x1 + 3 * (1-t)**2 * t * self.x1 + 3 * (1-t) * t**2 * self.x2 + t**3 * x2 - self.y_values = (1-t)**3 * y1 + 3 * (1-t)**2 * t * self.y1 + 3 * (1-t) * t**2 * self.y2 + t**3 * y2 + self.x_values = ( + (1 - t) ** 3 * x1 + 3 * (1 - t) ** 2 * t * self.x1 + 3 * (1 - t) * t**2 * self.x2 + t**3 * x2 + ) + self.y_values = ( + (1 - t) ** 3 * y1 + 3 * (1 - t) ** 2 * t * self.y1 + 3 * (1 - t) * t**2 * self.y2 + t**3 * y2 + ) else: # Second-order Bezier curve - self.x_values = (1-t)**2 * x1 + 2 * (1-t) * t * self.x + t**2 * x2 - self.y_values = (1-t)**2 * y1 + 2 * (1-t) * t * self.y + t**2 * y2 + self.x_values = (1 - t) ** 2 * x1 + 2 * (1 - t) * t * self.x + t**2 * x2 + self.y_values = (1 - t) ** 2 * y1 + 2 * (1 - t) * t * self.y + t**2 * y2 super().plot(ax, config) def get_point(self, n): - if self.start and self.end: # Why this check? What happens else? + if self.start and self.end: # Why this check? What happens else? x1, y1 = self.start x2, y2 = self.end t = np.linspace(0, 1, 100) if self.x1 is not None and self.x2 is not None: # Third-order Bezier curve - self.x_values = (1-t)**3 * x1 + 3 * (1-t)**2 * t * self.x1 + 3 * (1-t) * t**2 * self.x2 + t**3 * x2 - self.y_values = (1-t)**3 * y1 + 3 * (1-t)**2 * t * self.y1 + 3 * (1-t) * t**2 * self.y2 + t**3 * y2 + self.x_values = ( + (1 - t) ** 3 * x1 + 3 * (1 - t) ** 2 * t * self.x1 + 3 * (1 - t) * t**2 * self.x2 + t**3 * x2 + ) + self.y_values = ( + (1 - t) ** 3 * y1 + 3 * (1 - t) ** 2 * t * self.y1 + 3 * (1 - t) * t**2 * self.y2 + t**3 * y2 + ) else: # Second-order Bezier curve - self.x_values = (1-t)**2 * x1 + 2 * (1-t) * t * self.x + t**2 * x2 - self.y_values = (1-t)**2 * y1 + 2 * (1-t) * t * self.y + t**2 * y2 + self.x_values = (1 - t) ** 2 * x1 + 2 * (1 - t) * t * self.x + t**2 * x2 + self.y_values = (1 - t) ** 2 * y1 + 2 * (1 - t) * t * self.y + t**2 * y2 self.coordinates = list(zip(self.x_values, self.y_values)) if 0 <= n < len(self.coordinates): return self.coordinates[n][0], self.coordinates[n][1] else: - raise IndexError("Index out of the range of Bezier curve points.") + raise IndexError('Index out of the range of Bezier curve points.') def get_coordinates(self): self.coordinates = list(zip(self.x_values, self.y_values)) @@ -603,21 +638,20 @@ def calculate_coefficients(self): B = np.array([y1, y2, y0]) self.a, self.b, self.c = np.linalg.solve(A, B) - def plot(self, ax, config): self.calculate_coefficients() ## Ensure end_y_or_type is specified - #if isinstance(self.end, tuple) and isinstance(self.end[1], str): - #end_x, end_y_or_type = self.end - #if end_y_or_type == 'x': - #x2 = end_x - #y2 = self.a * self.x2**2 + self.b * self.x2 + self.c - #elif end_y_or_type == 'y': - #y2 = end_y - #x2 = (-self.b + np.sqrt(self.b**2-4*self.a*self.c)) / (2*self.a) - ## x2=(-self.b - np.sqrt(self.b**2-4*self.a*self.c)) / (2*self.a) - #self.end = (x2, y2) + # if isinstance(self.end, tuple) and isinstance(self.end[1], str): + # end_x, end_y_or_type = self.end + # if end_y_or_type == 'x': + # x2 = end_x + # y2 = self.a * self.x2**2 + self.b * self.x2 + self.c + # elif end_y_or_type == 'y': + # y2 = end_y + # x2 = (-self.b + np.sqrt(self.b**2-4*self.a*self.c)) / (2*self.a) + ## x2=(-self.b - np.sqrt(self.b**2-4*self.a*self.c)) / (2*self.a) + # self.end = (x2, y2) print(self.a, self.b, self.c) if self.start and self.end: @@ -628,16 +662,16 @@ def plot(self, ax, config): super().plot(ax, config) -#def end_x(process): - #if process.type == 'power': - #x1, y1 = process.start - #_, y2 = process.end - #return x1 * (y2 / y1)**0.5 -#def end_y(process): - #if process.type == 'power': - #x1, y1 = process.start - #x2, _ = process.end - #return y1 * (x2 / x1)**2 +# def end_x(process): +# if process.type == 'power': +# x1, y1 = process.start +# _, y2 = process.end +# return x1 * (y2 / y1)**0.5 +# def end_y(process): +# if process.type == 'power': +# x1, y1 = process.start +# x2, _ = process.end +# return y1 * (x2 / x1)**2 # end_p, end_V are not needed: # Use @@ -645,33 +679,35 @@ def plot(self, ax, config): # v1 = A1.end[0] # p1 = A1.end[1] -#def end_v(process): - #V1, p1 = process.start - #if process.type == 'iso_t': - #_, p2 = process.end - #return V1 * p1 / p2 - #elif process.type == 'adiabatic': - #_, p2 = process.end - #return V1 * (p1 / p2) ** (1 / process.gamma) - #return None -#def end_p(process): - #V1, p1 = process.start - #if process.type == 'iso_t': - #V2, _ = process.end - #return p1 * V1 / V2 - #elif process.type == 'adiabatic': - #V2, _ = process.end - #return (p1 * V1 ** process.gamma) / V2 ** process.gamma - #return None +# def end_v(process): +# V1, p1 = process.start +# if process.type == 'iso_t': +# _, p2 = process.end +# return V1 * p1 / p2 +# elif process.type == 'adiabatic': +# _, p2 = process.end +# return V1 * (p1 / p2) ** (1 / process.gamma) +# return None +# def end_p(process): +# V1, p1 = process.start +# if process.type == 'iso_t': +# V2, _ = process.end +# return p1 * V1 / V2 +# elif process.type == 'adiabatic': +# V2, _ = process.end +# return (p1 * V1 ** process.gamma) / V2 ** process.gamma +# return None + # Find intersection adiabatic and iso_t using (v1,p1) and (v3,p3) -def common_pv(v1, p1, v3, p3, gamma=5/3): - v2 = v1**(gamma/(gamma-1)) * (p1 / (p3 * v3))**(1/(gamma-1)) - p2 = p3*v3/v2 - return v2,p2 +def common_pv(v1, p1, v3, p3, gamma=5 / 3): + v2 = v1 ** (gamma / (gamma - 1)) * (p1 / (p3 * v3)) ** (1 / (gamma - 1)) + p2 = p3 * v3 / v2 + return v2, p2 + # Find intersection adiabatic and iso_t using process names -def common_QT(process1, process2, gamma=5/3): +def common_QT(process1, process2, gamma=5 / 3): if process1.type == 'state': x1, y1 = process1.start else: @@ -681,23 +717,24 @@ def common_QT(process1, process2, gamma=5/3): else: x2, y2 = process2.end ### Calculate common x, y - x = (y1*x1**gamma / (y2*x2))**(1/(gamma-1)) - y = y2*x2/x - return x,y + x = (y1 * x1**gamma / (y2 * x2)) ** (1 / (gamma - 1)) + y = y2 * x2 / x + return x, y + # Distribute points evenly along the curve for better arrow placement def interpolate_curve(x_values, y_values, num_points=100): # Calculate the curve length - total_length = np.sum(np.sqrt(np.diff(x_values)**2 + np.diff(y_values)**2)) - length_along_curve = np.insert(np.cumsum(np.sqrt(np.diff(x_values)**2 + np.diff(y_values)**2)), 0, 0) + total_length = np.sum(np.sqrt(np.diff(x_values) ** 2 + np.diff(y_values) ** 2)) + length_along_curve = np.insert(np.cumsum(np.sqrt(np.diff(x_values) ** 2 + np.diff(y_values) ** 2)), 0, 0) # Limit the maximum distance by the maximum value of the curve length max_length = length_along_curve[-1] distance = np.linspace(0, max_length, num_points) # Interpolate x and y as functions of length - f_x = interp1d(length_along_curve, x_values, kind='linear', bounds_error=False, fill_value="extrapolate") - f_y = interp1d(length_along_curve, y_values, kind='linear', bounds_error=False, fill_value="extrapolate") + f_x = interp1d(length_along_curve, x_values, kind='linear', bounds_error=False, fill_value='extrapolate') + f_y = interp1d(length_along_curve, y_values, kind='linear', bounds_error=False, fill_value='extrapolate') # Calculate new x and y values new_x_values = f_x(distance) diff --git a/tests/plotnik/conftest.py b/tests/plotnik/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plotnik/test_plotnik.py b/tests/plotnik/test_plotnik.py new file mode 100644 index 0000000..f8af7e0 --- /dev/null +++ b/tests/plotnik/test_plotnik.py @@ -0,0 +1,5 @@ +from plotnik import * + + +def test_plotnik(): + pass \ No newline at end of file