diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 02b0915..0000000 --- a/.flake8 +++ /dev/null @@ -1,28 +0,0 @@ -[flake8] -# References: -# https://flake8.readthedocs.io/en/latest/user/configuration.html -# https://flake8.readthedocs.io/en/latest/user/error-codes.html -# https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes - -ignore = - # E203: whitespace before ':' - E203, - # E226: missing whitespace around arithmetic operator - E226, - # E231: missing whitespace after ',', ';', or ':' - E231, - # E402: module level imports on one line - E402, - # E501: line too long - E501, - # W503: line break before binary operator - W503, - # W504: line break after binary operator - W504, -exclude = - # - # ignore the following directories - # - .eggs, - build, - sphinxext, diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 96b96ca..ef7dadd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,3 +17,7 @@ updates: timezone: "Europe/London" labels: - "🤖 Bot" + groups: + actions: + patterns: + - "*" \ No newline at end of file diff --git a/.github/workflows/ci-citation.yml b/.github/workflows/ci-citation.yml index df19bca..2b03999 100644 --- a/.github/workflows/ci-citation.yml +++ b/.github/workflows/ci-citation.yml @@ -20,7 +20,7 @@ jobs: name: "validate" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.github/workflows/ci-docs.yml b/.github/workflows/ci-docs.yml index a3e5990..4de5b77 100644 --- a/.github/workflows/ci-docs.yml +++ b/.github/workflows/ci-docs.yml @@ -18,9 +18,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: key: tox-${{ hashFiles('requirements/dev.yml') }}-${{ hashFiles('tox.ini') }} path: | diff --git a/.github/workflows/ci-manifest.yml b/.github/workflows/ci-manifest.yml index cf79783..f72a811 100644 --- a/.github/workflows/ci-manifest.yml +++ b/.github/workflows/ci-manifest.yml @@ -23,4 +23,4 @@ concurrency: jobs: manifest: name: "check-manifest" - uses: scitools/workflows/.github/workflows/ci-manifest.yml@2024.09.1 + uses: scitools/workflows/.github/workflows/ci-manifest.yml@2025.07.3 diff --git a/.github/workflows/ci-template-check.yml b/.github/workflows/ci-template-check.yml new file mode 100644 index 0000000..d2d2525 --- /dev/null +++ b/.github/workflows/ci-template-check.yml @@ -0,0 +1,16 @@ +# Checks if a PR makes any changes that ought to be shared via templating. +# See the called workflow in the scitools/workflows repo for more details. + +name: ci-template-check + +on: + pull_request_target: + branches: + - main + +jobs: + prompt-share: + uses: scitools/workflows/.github/workflows/ci-template-check.yml@2025.07.3 + secrets: inherit + with: + pr_number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index aa0b4b6..c75a0a6 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -13,18 +13,31 @@ concurrency: cancel-in-progress: true jobs: - build: + tests: + name: "${{ matrix.session }} (${{ matrix.version }})" - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ "ubuntu-latest" ] + version: ["py310", "py311"] + session: [ "test" ] + include: + - version: "py311" + coverage: "--cov-report= --cov=tephi" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: key: tox-${{ hashFiles('requirements/dev.yml') }}-${{ hashFiles('tox.ini') }} path: | .tox - name: Run tox - run: pipx run 'tox<4' + run: | + pip install 'tox<4' + tox -e ${{ matrix.version }}-${{ matrix.session }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ee7c09d..d266bee 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 1d213fd..26daa29 100644 --- a/.gitignore +++ b/.gitignore @@ -59,7 +59,7 @@ coverage.xml # Sphinx documentation docs/_build/ # ignore autogen apidoc files -docs/tephi/source/api +docs/source/api # misc *.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e75a3f..ebec141 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,28 +1,114 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks +# See https://pre-commit.ci/#configuration +# See https://github.com/scientific-python/cookie#sp-repo-review + +ci: + autofix_prs: false + autoupdate_commit_msg: "chore: update pre-commit hooks" + + +# Alphabetised, for lack of a better order. +files: | + (?x)( + docs\/.+\.py| + docs\/.+\.rst| + pyproject\.toml| + setup\.py| + src\/.+\.py + ) +minimum_pre_commit_version: 1.21.0 + repos: + +# Hook for pre-commit's built-in checks - repo: https://github.com/pre-commit/pre-commit-hooks - rev: 'v4.4.0' + rev: v5.0.0 hooks: # Prevent giant files from being committed. - id: check-added-large-files + # Check whether files parse as valid Python. + - id: check-ast + # Check for file name conflicts on case-insensitive filesystems. + - id: check-case-conflict # Check for files that contain merge conflict strings. - id: check-merge-conflict - # Check for debugger imports and py37+ `breakpoint()` calls in python source. + # Check for debugger imports and py37+ `breakpoint()` calls in Python source. - id: debug-statements - # Don't commit to master branch. + # Check TOML file syntax. + - id: check-toml + # Check YAML file syntax. + - id: check-yaml + # Makes sure files end in a newline and only a newline. + # Duplicates Ruff W292 but also works on non-Python files. + - id: end-of-file-fixer + # Replaces or checks mixed line ending. + - id: mixed-line-ending + # Don't commit to main branch. - id: no-commit-to-branch + # Trims trailing whitespace. + # Duplicates Ruff W291 but also works on non-Python files. + - id: trailing-whitespace + +# Hooks from all other repos +# NOTE : keep these in hook-name (aka 'id') order -- repo: https://github.com/psf/black - rev: 23.7.0 +- repo: https://github.com/adamchainz/blacken-docs + rev: 1.19.1 hooks: - - id: black + - id: blacken-docs + types: [file, rst] + +- repo: https://github.com/codespell-project/codespell + rev: "v2.4.1" + hooks: + - id: codespell + types_or: [asciidoc, python, markdown, rst] + additional_dependencies: [tomli] + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v1.17.0' + hooks: + - id: mypy + exclude: 'noxfile\.py|docs/conf\.py' + +- repo: https://github.com/numpy/numpydoc + rev: v1.9.0 + hooks: + - id: numpydoc-validation + types: [file, python] + +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.12.5" + hooks: + - id: ruff + types: [file, python] + args: [--fix, --show-fixes] + - id: ruff-format types: [file, python] - args: [--config=./pyproject.toml] -- repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 +- repo: https://github.com/aio-libs/sort-all + rev: v1.3.0 hooks: - - id: flake8 + - id: sort-all types: [file, python] - args: [--config=./.flake8] + +- repo: https://github.com/scientific-python/cookie + rev: 2025.05.02 + hooks: + - id: sp-repo-review + additional_dependencies: ["repo-review[cli]"] + args: ["--show=errskip"] + +- repo: https://github.com/abravalheri/validate-pyproject + # More exhaustive than Ruff RUF200. + rev: "v0.24.1" + hooks: + - id: validate-pyproject + +- repo: https://github.com/pre-commit/pygrep-hooks + rev: "v1.10.0" + hooks: + - id: rst-backticks + - id: rst-directive-colons + - id: rst-inline-touching-normal diff --git a/.readthedocs.yml b/.readthedocs.yml index df660ad..8d4cc3f 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,9 +1,13 @@ version: 2 +sphinx: + # path to conf file + configuration: docs/source/conf.py + fail_on_warning: false build: os: "ubuntu-22.04" tools: - python: "mambaforge-22.9" + python: "mambaforge-23.11" conda: environment: requirements/rtd.yml diff --git a/CITATION.cff b/CITATION.cff index 231c056..cd65be9 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,5 +1,6 @@ cff-version: 1.2.0 message: "If you've used tephi in your research, please cite us using the references below." +title: "tephi" authors: - family-names: "Little" given-names: "Bill" @@ -7,11 +8,10 @@ authors: - family-names: "Sadek" given-names: "Elias" orcid: "https://orcid.org/0009-0007-3284-9745" - -title: "tephi" +- name: "Tephi Contributors" abstract: "Tephigram plotting in Python" date-released: "2014-07-01" license: "BSD-3-Clause" license-url: "https://spdx.org/licenses/BSD-3-Clause.html" repository-code: "https://github.com/SciTools/tephi" -type: "software" \ No newline at end of file +type: "software" diff --git a/MANIFEST.in b/MANIFEST.in index 9cfe63a..bad0fa3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -15,3 +15,6 @@ recursive-include docs *.png recursive-include docs *.py recursive-include docs *.rst recursive-include docs Makefile +recursive-include tephi *.json +recursive-include tephi *.npz +recursive-include tephi *.txt diff --git a/docs/tephi/Makefile b/docs/Makefile similarity index 100% rename from docs/tephi/Makefile rename to docs/Makefile diff --git a/docs/tephi/make.bat b/docs/make.bat similarity index 100% rename from docs/tephi/make.bat rename to docs/make.bat diff --git a/docs/tephi/source/_static/favicon.ico b/docs/source/_static/favicon.ico similarity index 100% rename from docs/tephi/source/_static/favicon.ico rename to docs/source/_static/favicon.ico diff --git a/docs/tephi/source/_static/tephi-logo-200-137.png b/docs/source/_static/tephi-logo-200-137.png similarity index 100% rename from docs/tephi/source/_static/tephi-logo-200-137.png rename to docs/source/_static/tephi-logo-200-137.png diff --git a/docs/tephi/source/barbs.rst b/docs/source/barbs.rst similarity index 97% rename from docs/tephi/source/barbs.rst rename to docs/source/barbs.rst index 30d2b2e..99a9562 100644 --- a/docs/tephi/source/barbs.rst +++ b/docs/source/barbs.rst @@ -34,7 +34,7 @@ A profile must be first plotted before the barbs are associated with that profil dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) dews = zip(dew_data.pressure, dew_data.dewpoint) - tpg = tephi.Tephigram() + tpg = tephi.TephiAxes() profile = tpg.plot(dews) barbs = [(0, 0, 900), (1, 30, 850), (5, 60, 800), (10, 90, 750), (15, 120, 700), (20, 150, 650), @@ -64,7 +64,7 @@ Note that, the barbs default to the same colour as their associated profile. dews = zip(dew_data.pressure, dew_data.dewpoint) temps = zip(temp_data.pressure, temp_data.temperature) - tpg = tephi.Tephigram() + tpg = tephi.TephiAxes() dprofile = tpg.plot(dews) dbarbs = [(0, 0, 900), (15, 120, 600), (35, 240, 300)] dprofile.barbs(dbarbs) @@ -89,7 +89,7 @@ Barbs may also be plotted using wind speed and wind direction data (associated w barb_data = tephi.loadtxt(winds, column_titles=column_titles) dews = zip(barb_data.pressure, barb_data.dewpoint) barbs = zip(barb_data.wind_speed, barb_data.wind_direction, barb_data.pressure) - tpg = tephi.Tephigram() + tpg = tephi.TephiAxes() profile = tpg.plot(dews) profile.barbs(barbs) plt.show() @@ -113,7 +113,7 @@ This transparency allows full control when plotting barbs on the tephigram. dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) dews = zip(dew_data.pressure, dew_data.dewpoint) - tpg = tephi.Tephigram() + tpg = tephi.TephiAxes() profile = tpg.plot(dews) barbs = [(0, 0, 900), (1, 30, 850), (5, 60, 800), (10, 90, 750), (15, 120, 700), (20, 150, 650), @@ -141,7 +141,7 @@ By default, the barbs are plotted on the right hand side of the tephigram. The p dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) dews = zip(dew_data.pressure, dew_data.dewpoint) - tpg = tephi.Tephigram() + tpg = tephi.TephiAxes() profile = tpg.plot(dews) barbs = [(0, 0, 900), (1, 30, 850), (5, 60, 800), (10, 90, 750), (15, 120, 700), (20, 150, 650), diff --git a/docs/tephi/source/conf.py b/docs/source/conf.py similarity index 93% rename from docs/tephi/source/conf.py rename to docs/source/conf.py index a436619..14cfaf2 100644 --- a/docs/tephi/source/conf.py +++ b/docs/source/conf.py @@ -1,8 +1,6 @@ -# -*- coding: utf-8 -*- -# -# tephi documentation build configuration file, created by -# sphinx-quickstart on Thu Jun 26 15:24:09 2014. -# +"""Tephi documentation build configuration file.""" + +# created by sphinx-quickstart on Thu Jun 26 15:24:09 2014 # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this @@ -11,17 +9,19 @@ # All configuration values have a default; values that are commented out # serve to show the default. -from datetime import datetime +from __future__ import annotations + +from datetime import datetime, timedelta, timezone import os import pathlib import sys # ensure tephi is discoverable by rtd build environment -root = pathlib.Path(__file__).absolute().parent.parent.parent.parent +root = pathlib.Path(__file__).absolute().parent.parent.parent os.environ["PYTHONPATH"] = str(root) sys.path.insert(0, str(root)) -import tephi +import tephi # noqa: E402 # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -42,6 +42,7 @@ "sphinx.ext.intersphinx", "matplotlib.sphinxext.mathmpl", "matplotlib.sphinxext.plot_directive", + "matplotlib.sphinxext.roles", "sphinx_copybutton", ] @@ -62,8 +63,8 @@ # General information about the project. project = "tephi" -copyright = f"2014-{datetime.now().year}, British Crown Copyright, Met Office" - +tzone = timezone(timedelta()) +copyright_info = f"2014-{datetime.now(tzone).year}, British Crown Copyright, Met Office" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. @@ -190,7 +191,7 @@ # -- Options for LaTeX output -------------------------------------------------- -latex_elements = {} +latex_elements: dict[str, str] = {} # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', @@ -203,7 +204,7 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ("index", "tephi.tex", "tephi Documentation", copyright, "manual"), + ("index", "tephi.tex", "tephi Documentation", copyright_info, "manual"), ] # The name of an image file (relative to this directory) to place at the top of @@ -231,7 +232,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [("index", "tephi", "tephi Documentation", [copyright], 1)] +man_pages = [("index", "tephi", "tephi Documentation", [copyright_info], 1)] # If true, show URL addresses after external links. # man_show_urls = False @@ -247,7 +248,7 @@ "index", "tephi", "tephi Documentation", - copyright, + copyright_info, "tephi", "One line description of project.", "Miscellaneous", diff --git a/docs/source/customise.rst b/docs/source/customise.rst new file mode 100644 index 0000000..8430050 --- /dev/null +++ b/docs/source/customise.rst @@ -0,0 +1,62 @@ +.. tephigram_user_guide_customise: + +Tephigram customisation +======================= + +This section discusses how finer control of the tephigram isobars, saturated adiabats and humidity mixing ratio lines and text can be achieved. + +.. testsetup:: + + import tephi + from pprint import pprint + +There are two main methods to customise tephigram lines: default values, and individual values. Default values apply to +ALL axes by default, whereas individual values affect only the axes you change them on. + +The default values of barbs, isobars, mixing ratios, isopleths and wet adiabats are stored in the +``constants.defaults`` dictionary. Changing these values will change the default behaviour of the tephigram. + +Individual values can only be changed for the three adjustable isopleths (isobars, humidity mixing ratios, and wet +adiabats. + +Barbs +----- +Barb defaults can be altered via the ``constants.defaults`` dictionary. + +from tephi.constants import defaults +defaults["barbs_gutter"] +defaults["barbs_length"] +defaults["barbs_linewidth"] +defaults["barbs_zorder"] + +Isopleths +--------- + +Defaults +^^^^^^^^ +.. note:: + "" can be replaced by any of "isobar", "mixing_ratio" and "wet_adiabat", to change the + respective isopleth defaults. + +from tephi.constants import defaults +defaults["_line"] +defaults["_nbins"] +defaults["_text"] +defaults["_ticks"] +defaults["_min_"] +defaults["_max_"] + +Individual +^^^^^^^^^^ + +If you wish to change the behaviour of the three additional gridlines (isobars, wet adiabats, humidity mixing ratios) +for a specific axes, you can edit the gridline artist properties. + +tephigram = TephiAxes() +tephigram.add_() +tephigram. + +.. note:: + Currently, the only directly editable values are nbins, ticks, and the max\_ and min\_ values for the respective. + isopleth. Other values can be changed through the ``_kwarg`` dictionary, although this should be improved + in the future. diff --git a/docs/tephi/source/figures/tephi_axes.png b/docs/source/figures/tephi_axes.png similarity index 100% rename from docs/tephi/source/figures/tephi_axes.png rename to docs/source/figures/tephi_axes.png diff --git a/docs/tephi/source/figures/tephi_axis_dry_adiabat.png b/docs/source/figures/tephi_axis_dry_adiabat.png similarity index 100% rename from docs/tephi/source/figures/tephi_axis_dry_adiabat.png rename to docs/source/figures/tephi_axis_dry_adiabat.png diff --git a/docs/tephi/source/figures/tephi_axis_isobar.png b/docs/source/figures/tephi_axis_isobar.png similarity index 100% rename from docs/tephi/source/figures/tephi_axis_isobar.png rename to docs/source/figures/tephi_axis_isobar.png diff --git a/docs/tephi/source/figures/tephi_axis_isotherm.png b/docs/source/figures/tephi_axis_isotherm.png similarity index 100% rename from docs/tephi/source/figures/tephi_axis_isotherm.png rename to docs/source/figures/tephi_axis_isotherm.png diff --git a/docs/tephi/source/figures/tephi_axis_mixing.png b/docs/source/figures/tephi_axis_mixing.png similarity index 100% rename from docs/tephi/source/figures/tephi_axis_mixing.png rename to docs/source/figures/tephi_axis_mixing.png diff --git a/docs/tephi/source/figures/tephi_axis_wet_adiabat.png b/docs/source/figures/tephi_axis_wet_adiabat.png similarity index 100% rename from docs/tephi/source/figures/tephi_axis_wet_adiabat.png rename to docs/source/figures/tephi_axis_wet_adiabat.png diff --git a/docs/tephi/source/figures/tephi_right_angles.png b/docs/source/figures/tephi_right_angles.png similarity index 100% rename from docs/tephi/source/figures/tephi_right_angles.png rename to docs/source/figures/tephi_right_angles.png diff --git a/docs/tephi/source/glossary.rst b/docs/source/glossary.rst similarity index 92% rename from docs/tephi/source/glossary.rst rename to docs/source/glossary.rst index dd1668a..9dd0163 100644 --- a/docs/tephi/source/glossary.rst +++ b/docs/source/glossary.rst @@ -7,10 +7,10 @@ Glossary .. glossary:: :sorted: - anchor + xylim A sequence of two (pressure, temperature) pairs that specify the bottom left-hand corner and the - top right-hand corner of the plot. The pressure data points must be in units of mb or hPa, and the - temperature data points must be in units of :sup:`o`\ C. + top right-hand corner of the plot. The pressure data points must be in units of mb or hPa, and the + temperature data points must be in units of :sup:`o`\ C. dry adiabat A line of constant potential temperature, measured in units of :sup:`o`\ C. The zeroth dry adiabat line @@ -25,18 +25,18 @@ Glossary isotherm A line of constant temperature, measured in :sup:`o`\ C. The zeroth isotherm line is an axis of the tephigram, see :ref:`intro-isotherm`. - + line specification A sequence of one or more tuple pairs containing a :term:`line step` value and a :term:`zoom level` value. Used to control the frequency at which the tephigram plots :term:`isobar` lines, :term:`humidity mixing ratio` lines, and :term:`saturated adiabat` lines. Note that, specifying a :term:`zoom level` of ``None`` forces the associated - lines **always** to be visible. + lines **always** to be visible. line step The first value in the tuple pair of a :term:`line specification`. An integer that denotes N\ :sup:`th`\ step multiples. i.e. a line step of ``25`` denotes all lines that are a multiple of 25, or every 25\ :sup:`th`\ item from an enumerated list of values. - + pseudo saturated wet adiabat A line of constant equivalent potential temperature for saturated air parcels, measured in units of :sup:`o`\ C, see :ref:`intro-saturated-adiabat`. diff --git a/docs/tephi/source/index.rst b/docs/source/index.rst similarity index 91% rename from docs/tephi/source/index.rst rename to docs/source/index.rst index fe40a9b..55e5b58 100644 --- a/docs/tephi/source/index.rst +++ b/docs/source/index.rst @@ -1,7 +1,7 @@ .. tephi documentation master file, created by sphinx-quickstart on Thu Jun 26 15:24:09 2014. You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. + contain the root ``toctree`` directive. The tephi user guide ==================== @@ -30,4 +30,4 @@ User guide table of contents :caption: API :maxdepth: 2 - api/modules \ No newline at end of file + api/modules diff --git a/docs/tephi/source/introduction.rst b/docs/source/introduction.rst similarity index 96% rename from docs/tephi/source/introduction.rst rename to docs/source/introduction.rst index 33096c5..bf1ce44 100644 --- a/docs/tephi/source/introduction.rst +++ b/docs/source/introduction.rst @@ -5,7 +5,7 @@ Introduction The tephigram is a thermodynamic or energy diagram, devised in 1915 by Sir William Napier Shaw, a former Director-General of the Met Office. -It is a graphical representation of the obervations of pressure, temperature and humidity, made in a vertical sounding of the atmosphere, typically from radiosondes. +It is a graphical representation of the observations of pressure, temperature and humidity, made in a vertical sounding of the atmosphere, typically from radiosondes. The axis of the tephigram are temperature (T) and entropy (:math:`\phi`), hence the name "T-:math:`\phi`-gram". The axes and lines of the tephigram are shown in :ref:`tephi_axes`. @@ -121,7 +121,7 @@ Pseudo saturated wet adiabats Saturated adiabats are lines of constant equivalent potential temperature for saturated air parcels, measured in units :sup:`o`\ C. They run as vertically curved lines across the plot from top to bottom. -The saturated adiabats represent the rate at which saturated air will cool when rising i.e. the saturated adiabatic lapse rate (SALR). +The saturated adiabats represent the rate at which saturated air will cool when rising i.e. the saturated adiabatic lapse rate (SALR). .. _tephi_axis_wet_adiabat: diff --git a/docs/tephi/source/plot/barbs.py b/docs/source/plot/barbs.py similarity index 82% rename from docs/tephi/source/plot/barbs.py rename to docs/source/plot/barbs.py index 725fda7..85061df 100644 --- a/docs/tephi/source/plot/barbs.py +++ b/docs/source/plot/barbs.py @@ -1,3 +1,5 @@ +"""An example to show barbs being plotted.""" + import matplotlib.pyplot as plt import numpy as np @@ -27,7 +29,7 @@ } color = "blue" -kwargs = dict(length=8, color=color) +kwargs = {"length": 8, "color": color} lsx = 1 rsx = 3 ly = 23 @@ -42,16 +44,12 @@ for i, u in enumerate(range(5, 50, 5)): y = ly - (i + 2) * 2 plt.barbs(lsx, y, u, 0, **kwargs) - plt.text( - lsx + delta, y, knots[np.searchsorted(_BARB_BINS, u, side="right")] - ) + plt.text(lsx + delta, y, knots[np.searchsorted(_BARB_BINS, u, side="right")]) for i, u in enumerate(range(50, 105, 5)): y = ly - i * 2 plt.barbs(rsx, y, u, 0, **kwargs) - plt.text( - rsx + delta, y, knots[np.searchsorted(_BARB_BINS, u, side="right")] - ) + plt.text(rsx + delta, y, knots[np.searchsorted(_BARB_BINS, u, side="right")]) ax = plt.gca() ax.set_xlim(0, 5) diff --git a/docs/tephi/source/plotting.rst b/docs/source/plotting.rst similarity index 90% rename from docs/tephi/source/plotting.rst rename to docs/source/plotting.rst index 1b01533..78f4b68 100644 --- a/docs/tephi/source/plotting.rst +++ b/docs/source/plotting.rst @@ -9,9 +9,10 @@ This section describes how to visualise one or more data sets as a tephigram. import os.path import tephi - dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') - dry_bulb = os.path.join(tephi.DATA_DIR, 'temps.txt') - winds = os.path.join(tephi.DATA_DIR, 'barbs.txt') + + dew_point = os.path.join(tephi.DATA_DIR, "dews.txt") + dry_bulb = os.path.join(tephi.DATA_DIR, "temps.txt") + winds = os.path.join(tephi.DATA_DIR, "barbs.txt") dews, temps = tephi.loadtxt(dew_point, dry_bulb) @@ -20,20 +21,20 @@ Tephigram data Throughout this user guide we will make use of three data sets to plot temperature profiles on a tephigram. -Currently, the tephigram module can only plot data from ascii text files. +Currently, the tephigram module can only plot data from ascii text files. These files may contain pressure, temperature, wind speed and wind direction data sets. -Here pressure is measured in units of *millibars* or *hectopascals*, +Here pressure is measured in units of *millibars* or *hectopascals*, temperature is measured in units of *degrees celsius*, wind speed is measured in *knots* and wind direction is measured in *degrees from north*. -Note that the data set must consist of one or more pressure and temperature paired values, -and optionally one wind speed and wind direction pair for each pressure value. -Thus any temperature value must be paired with a pressure value, +Note that the data set must consist of one or more pressure and temperature paired values, +and optionally one wind speed and wind direction pair for each pressure value. +Thus any temperature value must be paired with a pressure value, and wind speed and wind direction pairs must be paired with a pressure value. -Data from the text files is loaded into one :func:`collections.namedtuple` instance per text file. -Each column of data representing a given phenomenon in a text file is loaded into a single named tuple. -The name of each tuple is set using a list of strings passed to the loader. +Data from the text files is loaded into one :func:`collections.namedtuple` instance per text file. +Each column of data representing a given phenomenon in a text file is loaded into a single named tuple. +The name of each tuple is set using a list of strings passed to the loader. If not specified, the names default to *(pressure, temperature)*. For our example tephigram data sets we have a 2-dimensional *dew-point* data set: @@ -57,7 +58,7 @@ And a 2-dimensional *dry-bulb* data set, with each named tuple printed individua -38. -47. -51. -56. -57. -63. -63. -64. -69. -77. -79. -77. -78. -78. -72. -71. -69.] -A convenience function, as introduced above, has been provided to assist with loading one or more text files of pressure, temperature, wind speed and wind direction data; see :func:`tephi.loadtxt`. +A convenience function, as introduced above, has been provided to assist with loading one or more text files of pressure, temperature, wind speed and wind direction data; see :func:`tephi.loadtxt`. Here it is used to load the third example data set that contains four columns of data, being *pressure*, *temperature*, *wind speed* and *wind direction*:: >>> import os.path @@ -79,7 +80,7 @@ Here it is used to load the third example data set that contains four columns of 240., 270., 285., 300., 330., 359.], dtype=float32)) .. note:: - WMO upper-level pressure, temperature, humidity, and wind reports *FM 35-IX Ext. TEMP*, *FM 36-IX Ext. TEMP SHIP*, *FM 37-IX Ext. TEMP DROP* and + WMO upper-level pressure, temperature, humidity, and wind reports *FM 35-IX Ext. TEMP*, *FM 36-IX Ext. TEMP SHIP*, *FM 37-IX Ext. TEMP DROP* and *FM 38-IX Ext. MOBIL* are currently **not** supported. @@ -107,7 +108,7 @@ The temperature profile of a single tephigram data set can easily be plotted. dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) dews = zip(dew_data.pressure, dew_data.dewpoint) - tpg = tephi.Tephigram() + tpg = tephi.TephiAxes() tpg.plot(dews) plt.show() @@ -133,7 +134,7 @@ Plotting more than one data set is achieved by over-plotting each data set indiv dews = zip(dew_data.pressure, dew_data.dewpoint) temps = zip(temp_data.pressure, temp_data.temperature) - tpg = tephi.Tephigram() + tpg = tephi.TephiAxes() tpg.plot(dews) tpg.plot(temps) plt.show() @@ -146,7 +147,7 @@ Customising a temperature profile All keyword arguments passed to :meth:`tephi.Tephigram.plot` are simply passed through to :func:`matplotlib.pyplot.plot`. -This transparency allows full control when plotting a temperature profile on the tephigram. +This transparency allows full control when plotting a temperature profile on the tephigram. .. plot:: :include-source: @@ -160,7 +161,7 @@ This transparency allows full control when plotting a temperature profile on the dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) dews = zip(dew_data.pressure, dew_data.dewpoint) - tpg = tephi.Tephigram() + tpg = tephi.TephiAxes() tpg.plot(dews, label='Dew-point temperature', color='blue', linewidth=2, linestyle='--', marker='s') plt.show() @@ -184,13 +185,13 @@ However, fixed axis tick locations can easily be configured for either axis if r dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) dews = zip(dew_data.pressure, dew_data.dewpoint) - tpg = tephi.Tephigram(isotherm_locator=tephi.Locator(10), dry_adiabat_locator=tephi.Locator(20)) + tpg = tephi.TephiAxes(isotherm_locator=tephi.Locator(10), dry_adiabat_locator=tephi.Locator(20)) tpg.plot(dews) plt.show() The above may also be achieved without using a :class:`tephi.Locator`:: - tpg = tephi.Tephigram(isotherm_locator=10, dry_adiabat_locator=20) + tpg = tephi.TephiAxes(isotherm_locator=10, dry_adiabat_locator=20) .. _plot-anchor: @@ -201,7 +202,7 @@ Anchoring a plot By default, the tephigram will automatically center the plot around all temperature profiles. This behaviour may not be desirable when comparing separate tephigram plots against one another. -To fix the extent of a plot, simply specify an :term:`anchor` point to the tephigram. +To fix the extent of a plot, simply specify an :term:`xylim` point to the tephigram. .. plot:: :include-source: @@ -215,8 +216,6 @@ To fix the extent of a plot, simply specify an :term:`anchor` point to the tephi dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) dews = zip(dew_data.pressure, dew_data.dewpoint) - tpg = tephi.Tephigram(anchor=[(1000, 0), (300, 0)]) + tpg = tephi.TephiAxes(xylim=[(1000, 0), (300, 0)]) tpg.plot(dews) plt.show() - - diff --git a/docs/tephi/source/customise.rst b/docs/tephi/source/customise.rst deleted file mode 100644 index 5391a5c..0000000 --- a/docs/tephi/source/customise.rst +++ /dev/null @@ -1,454 +0,0 @@ -.. tephigram_user_guide_customise: - -Tephigram customisation -======================= - -This section discusses how finer control of the tephigram isobars, saturated adiabats and humidity mixing ratio lines and text can be achieved. - -.. testsetup:: - - import tephi - from pprint import pprint - - -Isobar control --------------- - -Isobar lines -^^^^^^^^^^^^ - -The default behaviour of the tephigram *isobar line* is controlled by the :data:`tephi.ISOBAR_LINE` dictionary: - - >>> print(tephi.ISOBAR_LINE) - {'color': 'blue', 'linewidth': 0.5, 'clip_on': True} - -This is a dictionary of *key* and *value* pairs that are passed through as keyword arguments to :func:`matplotlib.pyplot.plot`. - -Updating the ``ISOBAR_LINE`` dictionary will subsequently change the default behaviour of how the tephigram isobar lines are plotted. - -.. plot:: - :include-source: - :align: center - - import matplotlib.pyplot as plt - import os.path - - import tephi - - dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') - dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) - dews = zip(dew_data.pressure, dew_data.dewpoint) - tephi.ISOBAR_LINE.update({'color': 'purple', 'linewidth': 3, 'linestyle': '--'}) - tpg = tephi.Tephigram() - tpg.plot(dews) - plt.show() - -.. plot:: - - import tephi - tephi.ISOBAR_LINE = {'color': 'blue', 'linewidth': 0.5, 'clip_on': True} - - -Isobar text -^^^^^^^^^^^ - -Similarly, the default behaviour of the tephigram *isobar text* is controlled by the :data:`tephi.ISOBAR_TEXT` dictionary: - - >>> pprint(tephi.ISOBAR_TEXT) - {'clip_on': True, 'color': 'blue', 'ha': 'right', 'size': 8, 'va': 'bottom'} - -This is a dictionary of *key* and *value* pairs that are passed through as keyword arguments to :func:`matplotlib.pyplot.text`. - -Updating the ``ISOBAR_TEXT`` dictionary will change the default behaviour of how the tephigram isobar text is plotted. - -.. plot:: - :include-source: - :align: center - - import matplotlib.pyplot as plt - import os.path - - import tephi - - dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') - dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) - dews = zip(dew_data.pressure, dew_data.dewpoint) - tephi.ISOBAR_TEXT.update({'color': 'purple', 'size': 12}) - tpg = tephi.Tephigram() - tpg.plot(dews) - plt.show() - -.. plot:: - - import tephi - tephi.ISOBAR_TEXT = {'color': 'blue', 'va': 'bottom', 'ha': 'right', 'clip_on': True, 'size': 8} - - -Isobar frequency -^^^^^^^^^^^^^^^^ - -The *frequency* at which isobar lines are plotted on the tephigram is controlled by the :data:`tephi.ISOBAR_SPEC` list: - - >>> print(tephi.ISOBAR_SPEC) - [(25, 0.03), (50, 0.1), (100, 0.25), (200, 1.5)] - -This :term:`line specification` is a sequence of one or more tuple pairs that contain an isobar pressure :term:`line step` and a :term:`zoom level`. - -For example, ``(25, 0.03)`` states that all isobar lines that are a multiple of ``25`` mb will be plotted i.e. visible, when the :term:`zoom level` is at or -below ``0.03``. - -The *overall range* of isobar pressure levels that may be plotted is controlled by the :data:`tephi.MIN_PRESSURE` and -:data:`tephi.MAX_PRESSURE` variables: - - >>> print(tephi.MIN_PRESSURE) - 50 - >>> print(tephi.MAX_PRESSURE) - 1000 - -Note that, it is possible to set a *fixed* isobar pressure :term:`line step` for a tephigram plot by setting the associated :term:`zoom level` to ``None``. -This is opposed to relying on the plot :term:`zoom level` of the tephigram to control line visibility. - -For example, to **always** show isobar lines that are a multiple of 50 mb, irrespective of the :term:`zoom level`, - -.. plot:: - :include-source: - :align: center - - import matplotlib.pyplot as plt - import os.path - - import tephi - - dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') - dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) - dews = zip(dew_data.pressure, dew_data.dewpoint) - tephi.ISOBAR_SPEC = [(50, None)] - tpg = tephi.Tephigram() - tpg.plot(dews) - plt.show() - -.. plot:: - - import tephi - tephi.ISOBAR_SPEC = [(25, 0.03), (50, 0.1), (100, 0.25), (200, 1.5)] - -It is also possible to control which *individual* isobar lines should be *fixed* via the :data:`tephi.ISOBAR_FIXED` list: - - >>> print(tephi.ISOBAR_FIXED) - [50, 1000] - -By default, the isobar lines at 50 mb and 1000 mb will **always** be plotted. - - -Isobar line extent -^^^^^^^^^^^^^^^^^^ - -The extent of each tephigram *isobar line* is controlled by the :data:`tephi.MIN_THETA` and -:data:`tephi.MAX_THETA` variables: - - >>> print(tephi.MIN_THETA) - 0 - >>> print(tephi.MAX_THETA) - 250 - -For example, to change the isobar line extent behaviour to be between 15 :sup:`o`\ C and 60 :sup:`o`\ C, - -.. plot:: - :include-source: - :align: center - - import matplotlib.pyplot as plt - import os.path - - import tephi - - dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') - dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) - dews = zip(dew_data.pressure, dew_data.dewpoint) - tephi.MIN_THETA = 15 - tephi.MAX_THETA = 60 - tpg = tephi.Tephigram() - tpg.plot(dews) - plt.show() - -.. plot:: - - import tephi - tephi.MIN_THETA = 0 - tephi.MAX_THETA = 250 - - -Saturated adiabat control -------------------------- - -Saturated adiabat lines -^^^^^^^^^^^^^^^^^^^^^^^ - -The default behaviour of the tephigram *pseudo saturated wet adiabat line* is controlled by the :data:`tephi.WET_ADIABAT_LINE` dictionary: - - >>> print(tephi.WET_ADIABAT_LINE) - {'color': 'orange', 'linewidth': 0.5, 'clip_on': True} - -This is a dictionary of *key* and *value* pairs that are passed through as keyword arguments to :func:`matplotlib.pyplot.plot`. - -Updating the ``WET_ADIABAT_LINE`` dictionary will change the default behaviour of **all** saturated adiabat line plotting. - -.. plot:: - :include-source: - :align: center - - import matplotlib.pyplot as plt - import os.path - - import tephi - - dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') - dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) - dews = zip(dew_data.pressure, dew_data.dewpoint) - tephi.WET_ADIABAT_LINE.update({'color': 'purple', 'linewidth': 3, 'linestyle': '--'}) - tpg = tephi.Tephigram() - tpg.plot(dews) - plt.show() - -.. plot:: - - import tephi - tephi.WET_ADIABAT_LINE = {'color': 'orange', 'linewidth': 0.5, 'clip_on': True} - - -Saturated adiabat text -^^^^^^^^^^^^^^^^^^^^^^ - -The default behavour of the tephigram *saturated adiabat text* is controlled by the :data:`tephi.WET_ADIABAT_TEXT` dictionary: - - >>> pprint(tephi.WET_ADIABAT_TEXT) - {'clip_on': True, 'color': 'orange', 'ha': 'left', 'size': 8, 'va': 'bottom'} - -This is a dictionary of *key* and *value* pairs that are passed through as keyword arguments to :func:`matplotlib.pyplot.text`. - -Updating the ``WET_ADIABAT_TEXT`` dictionary will change the default behaviour of how the text of associated saturated adiabat lines are plotted. - -.. plot:: - :include-source: - :align: center - - import matplotlib.pyplot as plt - import os.path - - import tephi - - dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') - dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) - dews = zip(dew_data.pressure, dew_data.dewpoint) - tephi.WET_ADIABAT_TEXT.update({'color': 'purple', 'size': 12}) - tpg = tephi.Tephigram() - tpg.plot(dews) - plt.show() - -.. plot:: - - import tephi - tephi.WET_ADIABAT_TEXT = {'color': 'orange', 'va': 'bottom', 'ha': 'left', 'clip_on': True, 'size': 8} - - -Saturated adiabat line frequency -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The *frequency* at which saturated adiabat lines are plotted on the tephigram is controlled by the :data:`tephi.WET_ADIABAT_SPEC` list: - - >>> print(tephi.WET_ADIABAT_SPEC) - [(1, 0.05), (2, 0.15), (4, 1.5)] - -This :term:`line specification` is a sequence of one or more tuple pairs that contain a saturated adiabat temperature :term:`line step` and a -:term:`zoom level`. - -For example, ``(2, 0.15)`` states that all saturated adiabat lines that are a multiple of ``2`` :sup:`o`\ C will be plotted i.e. visible, -when the :term:`zoom level` is at or below ``0.15``. - -The *overall range* of saturated adiabat levels that may be plotted is controlled by the :data:`tephi.MIN_WET_ADIABAT` and -:data:`tephi.MAX_WET_ADIABAT` variables: - - >>> print(tephi.MIN_WET_ADIABAT) - 1 - >>> print(tephi.MAX_WET_ADIABAT) - 60 - -Note that, it is possible to set a *fixed* saturated adiabat temperature :term:`line step` for a tephigram plot by setting the -associated :term:`zoom level` to ``None``. - -For example, to **always** show saturated adiabat lines that are a multiple of 5 :sup:`o`\ C, irrespective of the :term:`zoom level`, - -.. plot:: - :include-source: - :align: center - - import matplotlib.pyplot as plt - import os.path - - import tephi - - dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') - dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) - dews = zip(dew_data.pressure, dew_data.dewpoint) - tephi.WET_ADIABAT_SPEC = [(5, None)] - tpg = tephi.Tephigram() - tpg.plot(dews) - plt.show() - -.. plot:: - - import tephi - tephi.WET_ADIABAT_SPEC = [(1, 0.05), (2, 0.15), (4, 1.5)] - -It is also possible to control which *individual* saturated adiabat lines should be *fixed* via the :data:`tephi.WET_ADIABAT_FIXED` variable: - - >>> print(tephi.WET_ADIABAT_FIXED) - None - -By default, no saturated adiabat lines are fixed. To force saturated adiabat lines with a temperature of ``15`` :sup:`o`\ C and ``17`` :sup:`o`\ C -always to be plotted, - -.. plot:: - :include-source: - :align: center - - import matplotlib.pyplot as plt - import os.path - - import tephi - - dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') - dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) - dews = zip(dew_data.pressure, dew_data.dewpoint) - tephi.WET_ADIABAT_FIXED = [15, 17] - tpg = tephi.Tephigram() - tpg.plot(dews) - plt.show() - -.. plot:: - - import tephi - tephi.WET_ADIABAT_FIXED = None - - -Humidity mixing ratio control ------------------------------ - -Humidity mixing ratio lines -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The default behaviour of the tephigram *humidity mixing ratio line* is controlled by the :data:`tephi.MIXING_RATIO_LINE` dictionary: - - >>> print(tephi.MIXING_RATIO_LINE) - {'color': 'green', 'linewidth': 0.5, 'clip_on': True} - -This is a dictionary of *key* and *value* pairs that are passed through as keyword arguments to :func:`matplotlib.pyplot.plot`. - -Updating the ``MIXING_RATIO_LINE`` dictionary will change the default behaviour of **all** humidity mixing ratio line plotting. - -.. plot:: - :include-source: - :align: center - - import matplotlib.pyplot as plt - import os.path - - import tephi - - dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') - dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) - dews = zip(dew_data.pressure, dew_data.dewpoint) - tephi.MIXING_RATIO_LINE.update({'color': 'purple', 'linewidth': 3, 'linestyle': '--'}) - tpg = tephi.Tephigram() - tpg.plot(dews) - plt.show() - -.. plot:: - - import tephi - tephi.MIXING_RATIO_LINE = {'color': 'green', 'linewidth': 0.5, 'clip_on': True} - - -Humidity mixing ratio text -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The default behaviour of the tephigram *humidity mixing ratio text* is controlled by the :data:`tephi.MIXING_RATIO_TEXT` dictionary: - - >>> pprint(tephi.MIXING_RATIO_TEXT) - {'clip_on': True, 'color': 'green', 'ha': 'right', 'size': 8, 'va': 'bottom'} - -This is a dictionary of *key* and *value* pairs that are passed through as keyword arguments to :func:`matplotlib.pyplot.text`. - -Updating the ``MIXING_RATIO_TEXT`` dictionary will change the default behaviour of how the text of associated humidity mixing ratio lines are plotted. - -.. plot:: - :include-source: - :align: center - - import matplotlib.pyplot as plt - import os.path - - import tephi - - dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') - dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) - dews = zip(dew_data.pressure, dew_data.dewpoint) - tephi.MIXING_RATIO_TEXT.update({'color': 'purple', 'size': 12}) - tpg = tephi.Tephigram() - tpg.plot(dews) - plt.show() - -.. plot:: - - import tephi - tephi.MIXING_RATIO_TEXT = {'color': 'green', 'va': 'bottom', 'ha': 'right', 'clip_on': True, 'size': 8} - - -Humidity mixing ratio line frequency -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The *frequency* at which humidity mixing ratio lines are plotted on the tephigram is controlled by the :data:`tephi.MIXING_RATIO_SPEC` list: - - >>> print(tephi.MIXING_RATIO_SPEC) - [(1, 0.05), (2, 0.18), (4, 0.3), (8, 1.5)] - -This :term:`line specification` is a sequence of one or more tuple pairs that contain a humidity mixing ratio :term:`line step` and a -:term:`zoom level`. - -For example, ``(4, 0.3)`` states that every *fourth* humidity mixing ratio line will be plotted i.e. visible, when the :term:`zoom level` -is at or below ``0.3``. - -The *overall range* of humidity mixing ratio levels that may be plotted is controlled by the :data:`tephi.MIXING_RATIOS` list: - - >>> print(tephi.MIXING_RATIOS) - [0.001, 0.002, 0.005, 0.01, 0.02, 0.03, 0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, 48.0, 52.0, 56.0, 60.0, 68.0, 80.0] - -Note that, it is possible to control which *individual* humidity mixing ratio lines should be *fixed* i.e. **always** visible, via the :data:`tephi.MIXING_RATIO_FIXED` variable: - - >>> print(tephi.MIXING_RATIO_FIXED) - None - -By default, no humidity mixing ratio lines are fixed. To force humidity mixing ratio lines ``4.0`` g kg\ :sup:`-1`\ and ``6.0`` g kg\ :sup:`-1`\ -always to be plotted independent of the :term:`zoom level`, - -.. plot:: - :include-source: - :align: center - - import matplotlib.pyplot as plt - import os.path - - import tephi - - dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') - dew_data = tephi.loadtxt(dew_point, column_titles=('pressure', 'dewpoint')) - dews = zip(dew_data.pressure, dew_data.dewpoint) - tephi.MIXING_RATIO_FIXED = [4.0, 6.0] - tpg = tephi.Tephigram() - tpg.plot(dews) - plt.show() - -.. plot:: - - import tephi - tephi.MIXING_RATIO_FIXED = None diff --git a/environment.yml b/environment.yml index 9648c3a..f23ccc0 100644 --- a/environment.yml +++ b/environment.yml @@ -2,5 +2,5 @@ name: tephi-binder channels: - conda-forge dependencies: - - python>=3.6 + - python>=3.10 - tephi diff --git a/index.ipynb b/index.ipynb index 678dc30..9fe680b 100644 --- a/index.ipynb +++ b/index.ipynb @@ -87,8 +87,8 @@ "\n", "data_dewpoint, data_drybulb = tephi.loadtxt(fname_dewpoint, fname_drybulb, column_titles=column_titles)\n", "\n", - "dewpoint = list(zip(data_dewpoint.pressure, data_dewpoint.dewpoint))\n", - "drybulb = list(zip(data_drybulb.pressure, data_drybulb.temperature))" + "dewpoint = zip(data_dewpoint.pressure, data_dewpoint.dewpoint)\n", + "drybulb = zip(data_drybulb.pressure, data_drybulb.temperature)" ] }, { @@ -110,7 +110,7 @@ "\n", "data_barbs = tephi.loadtxt(fname_barbs, column_titles=column_titles)\n", "\n", - "barbs = list(zip(data_barbs.wind_speed, data_barbs.wind_direction, data_barbs.pressure))" + "barbs = zip(data_barbs.wind_speed, data_barbs.wind_direction, data_barbs.pressure)" ] }, { diff --git a/pyproject.toml b/pyproject.toml index 94ddbee..fb909e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,199 @@ -[tool.black] -line-length = 79 -target-version = ["py37", "py38", "py39", "py310"] -include = '\.pyi?$' +# See https://github.com/SciTools/.github/wiki/Linting +# for SciTools linting guidelines [build-system] # Defined by PEP 518 -requires = [ - "setuptools>=45", - "wheel", -] +requires = ["setuptools>=77.0.3"] # Defined by PEP 517 build-backend = "setuptools.build_meta" +[project] +authors = [ + {name = "Tephi Contributors", email = "scitools.pub@gmail.com"} +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Scientific/Engineering :: Atmospheric Science", + "Topic :: Scientific/Engineering :: Visualization", +] +description = "Tephigram plotting in Python" +dynamic = [ + "readme", + "version", +] +keywords = [ + "tephigram", + "radiosonde", + "meteorology", + "visualization", +] +license = "BSD-3-Clause" +license-files = ["LICENSE"] +name = "tephi" +requires-python = ">=3.10" +dependencies = ["matplotlib", "numpy", "scipy"] + +[project.urls] +Code = "https://github.com/SciTools/tephi" +Issues = "https://github.com/SciTools/tephi/issues" +Binder= "https://mybinder.org/v2/gh/SciTools/tephi/main?filepath=index.ipynb" +Docs = "https://tephi.readthedocs.io/en/latest/" + +[tool.black] +line-length = 79 +target-version = ["py310", "py311"] +include = '\.pyi?$' + +[tool.mypy] +strict = false +ignore_missing_imports = true +warn_unused_configs = true +warn_unreachable = true +enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] +exclude = [] + +[tool.numpydoc_validation] +checks = [ + "all", # Enable all numpydoc validation rules, apart from the following: + + # -> Docstring text (summary) should start in the line immediately + # after the opening quotes (not in the same line, or leaving a + # blank line in between) + "GL01", # Permit summary line on same line as docstring opening quotes. + + # -> Closing quotes should be placed in the line after the last text + # in the docstring (do not close the quotes in the same line as + # the text, or leave a blank line between the last text and the + # quotes) + "GL02", # Permit a blank line before docstring closing quotes. + + # -> Double line break found; please use only one blank line to + # separate sections or paragraphs, and do not leave blank lines + # at the end of docstrings + "GL03", # Ignoring. + + # -> See Also section not found + "SA01", # Not all docstrings require a "See Also" section. + + # -> No extended summary found + "ES01", # Not all docstrings require an "Extended Summary" section. + + # -> No examples section found + "EX01", # Not all docstrings require an "Examples" section. + + # -> No Yields section found + "YD01", # Not all docstrings require a "Yields" section. +] +exclude = [ + '\.__eq__$', + '\.__ne__$', + '\.__repr__$', +] + +[tool.pytest.ini_options] +minversion = "6.0" +markers = ["graphical: mark a test as a graphical test"] +addopts = ["-ra", + "-v", + "--cov-config=.coveragerc", + "--cov=tephi", + "--cov-report=term-missing", + "--doctest-modules", + "--showlocals", + "--strict-markers", + "--strict-config" + ] +xfail_strict = true +log_cli_level = "info" +testpaths = [ + "tephi/", +] +doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS NUMBER" +filterwarnings = ["default"] + +[tool.repo-review] +# These are a list of the currently failing tests: +ignore = [ + # https://learn.scientific-python.org/development/guides/style/#PC180 + "PC180", # Uses prettier + + # https://learn.scientific-python.org/development/guides/packaging-simple/#PY005 + "PY005", # Has tests folder +] + +[tool.ruff] +line-length = 88 + +[tool.ruff.format] +preview = false + +[tool.ruff.lint] +ignore = [ + # flake8-commas (COM) + # https://docs.astral.sh/ruff/rules/#flake8-commas-com + "COM812", # Trailing comma missing. + "COM819", # Trailing comma prohibited. + + # flake8-implicit-str-concat (ISC) + # https://docs.astral.sh/ruff/rules/single-line-implicit-string-concatenation/ + # NOTE: This rule may cause conflicts when used with "ruff format". + "ISC001", # Implicitly concatenate string literals on one line. + + # TODO: exceptions that still need investigating are below. Might be fixable, or might become permanent (above): + + "INP001", # File `` is part of an implicit namespace package. Add an `__init__.py`. + + ] + preview = false + select = [ + "ALL", + + # list specific rules to include that is skipped using numpy convention. + "D212", # Multi-line docstring summary should start at the first line + ] + +[tool.ruff.lint.isort] +force-sort-within-sections = true +known-first-party = ["tephi"] + +[tool.ruff.lint.per-file-ignores] +# All test scripts +"tephi/tests/*.py" = [ + # https://docs.astral.sh/ruff/rules/undocumented-public-module/ + "D100", # Missing docstring in public module + "D205", # 1 blank line required between summary line and description + "D401", # 1 First line of docstring should be in imperative mood +] +"setup.py" = [ + "EXE001", # Shebang is present but file is not executable +] +"docs/source/conf.py" = [ + "ERA001", # Has commented out code + "INP001" # File `docs/source/conf.py` is part of an implicit namespace package. Add an `__init__.py`. +] +"docs/source/plot/barbs.py" = [ + "INP001" # File `docs/source/plot/barbs.py` is part of an implicit namespace package. Add an `__init__.py`. +] + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.setuptools.dynamic] +version = { attr = "tephi.__version__"} +readme = {file = ["README.md"], content-type = "text/markdown"} + +[tool.setuptools.package-data] +tephi = [ + "etc/test_data/*.txt", + "tests/results/*.npz", + "tests/results/imagerepo.json" +] + +[tool.setuptools.packages.find] +include = ["tephi*"] diff --git a/requirements/dev.yml b/requirements/dev.yml index 5150458..0206356 100644 --- a/requirements/dev.yml +++ b/requirements/dev.yml @@ -1,8 +1,10 @@ name: tephi-dev channels: - conda-forge + - nodefaults dependencies: - - matplotlib + - matplotlib>=3.10 + - shapely - numpy - scipy - pip diff --git a/requirements/rtd.yml b/requirements/rtd.yml index 8ae66ae..ea41852 100644 --- a/requirements/rtd.yml +++ b/requirements/rtd.yml @@ -1,8 +1,10 @@ name: tephi-docs channels: - conda-forge + - nodefaults dependencies: - matplotlib + - shapely - numpy - scipy - sphinx diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 65cf4f9..0000000 --- a/setup.cfg +++ /dev/null @@ -1,70 +0,0 @@ -[flake8] -exclude = - .git, - docs, - tephi/tests/__init__.py - .eggs - -[tool:pytest] -testpaths = - tephi/ -markers = - graphical: mark a test as a graphical test -addopts = - -ra - -v - --cov-config=.coveragerc - --cov=tephi - --cov-report=term-missing - --doctest-modules -doctest_optionflags = NORMALIZE_WHITESPACE ELLIPSIS NUMBER - -[metadata] -name = tephi -version = attr: tephi.__version__ -author = UK Met Office -author_email = scitools-iris-dev@googlegroups.com -url = https://github.com/SciTools/tephi -classifiers = - Development Status :: 4 - Beta - Intended Audience :: Science/Research - Operating System :: OS Independent - License :: OSI Approved :: BSD License - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Topic :: Scientific/Engineering :: Atmospheric Science - Topic :: Scientific/Engineering :: Visualization -license_files = LICENSE -description = Tephigram plotting in Python -long_description = file: README.md -long_description_content_type = text/markdown -project_urls = - code = https://github.com/SciTools/tephi - issues = https://github.com/SciTools/tephi/issues - binder = https://mybinder.org/v2/gh/SciTools/tephi/main?filepath=index.ipynb - documentation = https://tephi.readthedocs.io/en/latest/ -keywords = - tephigram - radiosonde - meteorology - visualization - -[options] -packages = find: -setup_requires = - setuptools>=40.8.0 - wheel -install_requires = - matplotlib - numpy - scipy -python_requires = >=3.8 - -[options.package_data] -tephi = - etc/test_data/*.txt - tests/results/*.npz - tests/results/imagerepo.json diff --git a/setup.py b/setup.py deleted file mode 100644 index dceb71e..0000000 --- a/setup.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python -# Copyright Tephi contributors -# -# This file is part of Tephi and is released under the BSD license. -# See LICENSE in the root of the repository for full licensing details. - -from setuptools import setup - - -if __name__ == "__main__": - setup() diff --git a/tephi/__init__.py b/tephi/__init__.py index e65ed75..076dbcf 100644 --- a/tephi/__init__.py +++ b/tephi/__init__.py @@ -12,121 +12,28 @@ """ from collections import namedtuple from collections.abc import Iterable -from functools import partial + from matplotlib.font_manager import FontProperties import matplotlib.pyplot as plt +from mpl_toolkits.axisartist import Subplot from mpl_toolkits.axisartist.grid_helper_curvelinear import ( GridHelperCurveLinear, ) -from mpl_toolkits.axisartist import Subplot -import numbers +from mpl_toolkits.axisartist.grid_finder import MaxNLocator import numpy as np import os.path - -from . import isopleths -from . import transforms - +import math +from . import artists, isopleths, transforms __version__ = "0.4.0.dev0" - -# -# Miscellaneous constants. -# -DEFAULT_WIDTH = 700 # in pixels - -ISOBAR_SPEC = [(25, 0.03), (50, 0.10), (100, 0.25), (200, 1.5)] -ISOBAR_LINE = {"color": "blue", "linewidth": 0.5, "clip_on": True} -ISOBAR_TEXT = { - "size": 8, - "color": "blue", - "clip_on": True, - "va": "bottom", - "ha": "right", -} -ISOBAR_FIXED = [50, 1000] - -WET_ADIABAT_SPEC = [(1, 0.05), (2, 0.15), (4, 1.5)] -WET_ADIABAT_LINE = {"color": "orange", "linewidth": 0.5, "clip_on": True} -WET_ADIABAT_TEXT = { - "size": 8, - "color": "orange", - "clip_on": True, - "va": "bottom", - "ha": "left", -} -WET_ADIABAT_FIXED = None - -MIXING_RATIO_SPEC = [(1, 0.05), (2, 0.18), (4, 0.3), (8, 1.5)] -MIXING_RATIO_LINE = {"color": "green", "linewidth": 0.5, "clip_on": True} -MIXING_RATIO_TEXT = { - "size": 8, - "color": "green", - "clip_on": True, - "va": "bottom", - "ha": "right", -} -MIXING_RATIOS = [ - 0.001, - 0.002, - 0.005, - 0.01, - 0.02, - 0.03, - 0.05, - 0.1, - 0.15, - 0.2, - 0.3, - 0.4, - 0.5, - 0.6, - 0.8, - 1.0, - 1.5, - 2.0, - 2.5, - 3.0, - 4.0, - 5.0, - 6.0, - 7.0, - 8.0, - 9.0, - 10.0, - 12.0, - 14.0, - 16.0, - 18.0, - 20.0, - 24.0, - 28.0, - 32.0, - 36.0, - 40.0, - 44.0, - 48.0, - 52.0, - 56.0, - 60.0, - 68.0, - 80.0, -] -MIXING_RATIO_FIXED = None - -MIN_PRESSURE = 50 # mb = hPa -MAX_PRESSURE = 1000 # mb = hPa -MIN_THETA = 0 # degC -MAX_THETA = 250 # degC -MIN_WET_ADIABAT = 1 # degC -MAX_WET_ADIABAT = 60 # degC -MIN_TEMPERATURE = -50 # degC - +from .artists import WetAdiabatArtist, IsobarArtist, HumidityMixingRatioArtist RESOURCES_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), "etc") DATA_DIR = os.path.join(RESOURCES_DIR, "test_data") - +# TODO: Decide on whether to keep this, or come up with an alternate +# method of loading files def loadtxt(*filenames, **kwargs): """ Load one or more text files of pressure, temperature, wind speed and wind @@ -137,49 +44,53 @@ def loadtxt(*filenames, **kwargs): value (degC), wind speed (knots) and wind direction value (degrees from north). - Note that blank lines and comment lines beginning with a '#' are ignored. - - For example: - - >>> import os.path - >>> import tephi - - >>> winds = os.path.join(tephi.DATA_DIR, 'barbs.txt') - >>> columns = ('pressure', 'dewpoint', 'wind_speed', 'wind_direction') - >>> data = tephi.loadtxt(winds, column_titles=columns) - >>> pressure = data.pressure - >>> dews = data.dewpoint - >>> wind_speed = data.wind_speed - >>> wind_direction = data.wind_direction - - .. seealso:: :func:`numpy.loadtxt`. - - Args: - - * filenames: one or more filenames. + Parameters + ---------- + filenames : iterable of str + One or more filenames. - Kwargs: + Other Parameters + ---------------- - * column_titles: + column_titles : list of iterables, optional List of iterables, or None. If specified, should contain one title string for each column of data per specified file. If all of multiple files loaded have the same column titles, then only one tuple of column titles need be specified. - - * delimiter: + delimiter : str, optional The string used to separate values. This is passed directly to :func:`np.loadtxt`, which defaults to using any whitespace as delimiter if this keyword is not specified. - - * dtype: + dtype : type, optional The datatype to cast the data in the text file to. Passed directly to :func:`np.loadtxt`. - Returns: - A :func:`collections.namedtuple` instance containing one tuple, named - with the relevant column title if specified, for each column of data - in the text file loaded. If more than one file is loaded, a sequence - of namedtuples is returned. + Returns + ------- + data : collections.namedtuple + Contains one tuple, named with the relevant column title if specified, + for each column of data in the text file loaded. If more than one file + is loaded, a sequence of namedtuples is returned. + + Notes + ----- + Note that blank lines and comment lines beginning with a '#' are ignored. + + Examples + -------- + >>> import os.path + >>> import tephi + >>> winds = os.path.join(tephi.DATA_DIR, 'barbs.txt') + >>> columns = ('pressure', 'dewpoint', 'wind_speed', 'wind_direction') + >>> data = tephi.loadtxt(winds, column_titles=columns) + >>> pressure = data.pressure + >>> dews = data.dewpoint + >>> wind_speed = data.wind_speed + >>> wind_direction = data.wind_direction + + See Also + -------- + :func:`numpy.loadtxt`. """ @@ -238,7 +149,7 @@ def _repr(nt): if multiple_titles: tephidata = namedtuple("tephidata", column_titles[ct]) tephidata.__repr__ = _repr - payload = np.loadtxt(arg, dtype=dtype, delimiter=delimiter) + payload = np.loadtxt(arg, dtype=dtype, delimiter=delimiter, converters=float) item = tephidata(*payload.T) data.append(item) else: @@ -251,21 +162,27 @@ def _repr(nt): return data -class _FormatterTheta: - """Dry adiabats potential temperature axis tick formatter.""" +class _FormatterTheta(object): + """ + Dry adiabats potential temperature axis tick formatter. + + """ def __call__(self, direction, factor, values): - return [r"$\theta={:.1f}$".format(value) for value in values] + return [r"$\theta={}$".format(value) for value in values] -class _FormatterIsotherm: - """Isotherms temperature axis tick formatter.""" +class _FormatterIsotherm(object): + """ + Isotherms temperature axis tick formatter. + + """ def __call__(self, direction, factor, values): - return [r" $T={:.1f}$".format(value) for value in values] + return [r"$T={}$".format(value) for value in values] -class Locator: +class Locator(object): """ Determine the fixed step axis tick locations when called with a tick range. @@ -282,550 +199,237 @@ def __init__(self, step): >>> from tephi import Locator >>> locator = Locator(10) >>> locator(-45, 23) - (array([-50, -40, -30, -20, -10, 0, 10, 20]), 8, 1) + (array([-50, -40, -30, -20, -10, 0, 10, 20, 30]), 9, 1) Args: - * step: the step value for each axis tick. + * step: + The step value for each axis tick. """ self.step = int(step) def __call__(self, start, stop): - """Calculate the axis ticks given the provided tick range.""" + """ + Calculate the axis ticks given the provided tick range. + """ step = self.step - start = (int(start) // step) * step - stop = (int(stop) // step) * step - ticks = np.arange(start, stop + step, step, dtype=int) + start = math.floor(int(start) / step) * step + stop = math.ceil(int(stop) / step) * step + ticks = np.arange(start, stop + step, step) return ticks, len(ticks), 1 -def _refresh_isopleths(axes): - """ - Refresh the plot isobars, wet adiabats and mixing ratios and associated - text labels. - - Args: - - * axes: - Tephigram plotting :class:`matplotlib.axes.AxesSubplot` instance. - - Returns: - Boolean, whether the plot has changed. - - """ - changed = False - - # Determine the current zoom level. - xlim = axes.get_xlim() - delta_xlim = xlim[1] - xlim[0] - ylim = axes.get_ylim() - zoom = delta_xlim / axes.tephigram_original_delta_xlim - - # Determine the display mid-point. - x_point = xlim[0] + delta_xlim * 0.5 - y_point = ylim[0] + (ylim[1] - ylim[0]) * 0.5 - xy = np.array([[x_point, y_point]]) - xy_point = axes.tephigram_inverse.transform(xy)[0] - - for profile in axes.tephigram_profiles: - profile.refresh() +class TephiAxes(Subplot): + name = "tephigram" - for isopleth in axes.tephigram_isopleths: - changed = isopleth.refresh(zoom, xy_point) or changed + def __init__(self, *args, **kwargs): + # Validate the subplot arguments. - return changed - - -def _handler(event): - """Matplotlib event handler.""" - - for axes in event.canvas.figure.axes: - if hasattr(axes, "tephigram"): - if _refresh_isopleths(axes): - event.canvas.figure.show() - - -class _PlotGroup(dict): - """ - Container for a related group of tephigram isopleths. - - Manages the creation and plotting of all isopleths within the group. - - """ - - def __init__( - self, - axes, - plot_func, - text_kwargs, - step, - zoom, - tags, - fixed=None, - xfocus=None, - ): - self.axes = axes - self.text_kwargs = text_kwargs - self.step = step - self.zoom = zoom - - pairs = [] - for tag in tags: - text = plt.text(0, 0, str(tag), **text_kwargs) - text.set_bbox( - dict( - boxstyle="Round,pad=0.3", - facecolor="white", - edgecolor="white", - alpha=0.5, - clip_on=True, - clip_box=self.axes.bbox, + # TODO: Remove limit of super() behaviour. + # Currently, it only accepts format of 123 or (1, 2, 3). + if len(args) == 0: + args = (1, 1, 1) + elif (len(args) == 1 + and isinstance(args[0], tuple) + and len(args[0]) == 3): + args = args[0] + elif len(args) == 1 and isinstance(args[0], int): + args = tuple([int(c) for c in str(args[0])]) + if len(args) != 3: + msg = ( + "Integer subplot specification must be a " + "three digit number. Not {}.".format(len(args)) ) - ) - pairs.append((tag, [plot_func(tag), text])) - - dict.__init__(self, pairs) - for line, text in self.values(): - line.set_visible(True) - text.set_visible(True) - self._visible = True - - if fixed is None: - fixed = [] - - if not isinstance(fixed, Iterable): - fixed = [fixed] - - if zoom is None: - self.fixed = set(tags) - else: - self.fixed = set(tags) & set(fixed) - - self.xfocus = xfocus - - def __setitem__(self, tag, item): - emsg = "Cannot add or set an item into the plot group {!r}" - raise ValueError(emsg.format(self.step)) - - def __getitem__(self, tag): - if tag not in self.keys(): - emsg = "Tag item {!r} is not a member of the plot group {!r}" - raise KeyError(emsg.format(tag, self.step)) - return dict.__getitem__(self, tag) - - def refresh(self, zoom, xy_point): - """ - Refresh all isopleths within the plot group. - - Args: - - * zoom: - Zoom level of the current plot, relative to the initial plot. - * xy_point: - The center point of the current point, transformed into - temperature and potential temperature. - - Returns: - Boolean, whether the plot group has changed. - - """ - if self.zoom is None or zoom <= self.zoom: - changed = self._item_on() - else: - changed = self._item_off() - self._refresh_text(xy_point) - return changed - - def _item_on(self, zoom=None): - changed = False - if zoom is None or self.zoom is None or zoom <= self.zoom: - if not self._visible: - for line, text in self.values(): - line.set_visible(True) - text.set_visible(True) - changed = True - self._visible = True - return changed - - def _item_off(self, zoom=None): - changed = False - if self.zoom is not None and (zoom is None or zoom > self.zoom): - if self._visible: - for tag, (line, text) in self.items(): - if tag not in self.fixed: - line.set_visible(False) - text.set_visible(False) - changed = True - self._visible = False - return changed - - def _generate_text(self, tag, xy_point): - line, text = self[tag] - x_data = line.get_xdata() - y_data = line.get_ydata() - - if self.xfocus: - delta = np.power(x_data - xy_point[0], 2) - else: - delta = np.power(x_data - xy_point[0], 2) + np.power( - y_data - xy_point[1], 2 - ) - index = np.argmin(delta) - text.set_position((x_data[index], y_data[index])) - - def _refresh_text(self, xy_point): - if self._visible: - for tag in self: - self._generate_text(tag, xy_point) - elif self.fixed: - for tag in self.fixed: - self._generate_text(tag, xy_point) - - -class _PlotCollection: - """ - Container for tephigram isopleths. - - Manages the creation and plotting of all tephigram isobars, mixing ratio - lines and pseudo saturated wet adiabats. - - """ - - def __init__( - self, - axes, - spec, - stop, - plot_func, - text_kwargs, - fixed=None, - minimum=None, - xfocus=None, - ): - if isinstance(stop, Iterable): - if minimum and minimum > max(stop): - emsg = "Minimum value of {!r} exceeds all other values" - raise ValueError(emsg.format(minimum)) - - items = [ - [step, zoom, set(stop[step - 1 :: step])] - for step, zoom in sorted(spec, reverse=True) - ] + raise ValueError(msg) else: - if minimum and minimum > stop: - emsg = "Minimum value of {!r} exceeds maximum threshold {!r}" - raise ValueError(emsg.format(minimum, stop)) - - items = [ - [step, zoom, set(range(step, stop + step, step))] - for step, zoom in sorted(spec, reverse=True) - ] - - for index, item in enumerate(items): - if minimum: - item[2] = set([value for value in item[2] if value >= minimum]) - - for subitem in items[index + 1 :]: - subitem[2] -= item[2] - - self.groups = { - item[0]: _PlotGroup( - axes, plot_func, text_kwargs, *item, fixed=fixed, xfocus=xfocus - ) - for item in items - if item[2] - } - - if not self.groups: - emsg = "The plot collection failed to generate any plot groups" - raise ValueError(emsg) - - def refresh(self, zoom, xy_point): - """ - Refresh all isopleth groups within the plot collection. - - Args: - - * zoom: - Zoom level of the current plot, relative to the initial plot. - * xy_point: - The center point of the current plot, transformed into - temperature and potential temperature. - - Returns: - Boolean, whether any plot group has changed. - - """ - changed = False - - for group in self.groups.values(): - changed = group.refresh(zoom, xy_point) or changed + msg = "Invalid arguments: " + ", ".join(["{}" for _ in len(args)]) + raise ValueError(msg.format(*args)) - return changed + # Process the kwargs + figure = kwargs.get("figure") + if figure is None: + figure = plt.gcf() + # TODO: xylim should be split, to mirror the super() + xylim = kwargs.pop("xylim", None) -class Tephigram: - """ - Generate a tephigram of one or more pressure and temperature data sets. - - """ - - def __init__( - self, - figure=None, - isotherm_locator=None, - dry_adiabat_locator=None, - anchor=None, - ): - """ - Initialise the tephigram transformation and plot axes. - - Kwargs: - - * figure: - An existing :class:`matplotlib.figure.Figure` instance for the - tephigram plot. If a figure is not provided, a new figure will - be created by default. - * isotherm_locator: - A :class:`tephi.Locator` instance or a numeric step size - for the isotherm lines. - * dry_adiabat_locator: - A :class:`tephi.Locator` instance or a numeric step size - for the dry adiabat lines. - * anchor: - A sequence of two (pressure, temperature) pairs specifying the extent - of the tephigram plot in terms of the bottom right-hand corner, and - the top left-hand corner. Pressure data points must be in units of - mb or hPa, and temperature data points must be in units of degC. + dry_adiabat_locator = kwargs.pop("dry_adiabat_locator", None) + isotherm_locator = kwargs.pop("isotherm_locator", None) - For example: - - .. plot:: - :include-source: - - import matplotlib.pyplot as plt - from numpy import column_stack - import os.path - import tephi - from tephi import Tephigram - - dew_point = os.path.join(tephi.DATA_DIR, 'dews.txt') - dry_bulb = os.path.join(tephi.DATA_DIR, 'temps.txt') - dew_data, temp_data = tephi.loadtxt(dew_point, dry_bulb) - dews = column_stack((dew_data.pressure, dew_data.temperature)) - temps = column_stack((temp_data.pressure, temp_data.temperature)) - tpg = Tephigram() - tpg.plot(dews, label='Dew-point', color='blue', linewidth=2) - tpg.plot(temps, label='Dry-bulb', color='red', linewidth=2) - plt.show() - - """ - if not figure: - # Create a default figure. - self.figure = plt.figure(0, figsize=(9, 9)) - else: - self.figure = figure - - # Configure the locators. if isotherm_locator and not isinstance(isotherm_locator, Locator): - if not isinstance(isotherm_locator, numbers.Number): - raise ValueError("Invalid isotherm locator") - locator_isotherm = Locator(isotherm_locator) + if isinstance(isotherm_locator, int): + locator_T = MaxNLocator( + nbins=isotherm_locator, + steps=[10], + integer=True + ) + else: + raise ValueError("Invalid isotherm locator.") else: - locator_isotherm = isotherm_locator - - if dry_adiabat_locator and not isinstance( - dry_adiabat_locator, Locator - ): - if not isinstance(dry_adiabat_locator, numbers.Number): - raise ValueError("Invalid dry adiabat locator") - locator_theta = Locator(dry_adiabat_locator) + locator_T = isotherm_locator + + if dry_adiabat_locator and not isinstance(dry_adiabat_locator, Locator): + if isinstance(dry_adiabat_locator, int): + locator_theta = MaxNLocator( + nbins=dry_adiabat_locator, + steps=[10], + integer=True + ) + else: + raise ValueError("Invalid dry adiabat locator.") else: locator_theta = dry_adiabat_locator - # Define the tephigram coordinate-system transformation. - self.tephi_transform = transforms.TephiTransform() - ghelper = GridHelperCurveLinear( - self.tephi_transform, + gridder = GridHelperCurveLinear( + transforms.TephiTransform(), tick_formatter1=_FormatterIsotherm(), - grid_locator1=locator_isotherm, + grid_locator1=locator_T, tick_formatter2=_FormatterTheta(), grid_locator2=locator_theta, ) - self.axes = Subplot(self.figure, 1, 1, 1, grid_helper=ghelper) - self.transform = self.tephi_transform + self.axes.transData - self.axes.axis["isotherm"] = self.axes.new_floating_axis(1, 0) - self.axes.axis["theta"] = self.axes.new_floating_axis(0, 0) - self.axes.axis["left"].get_helper().nth_coord_ticks = 0 - self.axes.axis["left"].toggle(all=True) - self.axes.axis["bottom"].get_helper().nth_coord_ticks = 1 - self.axes.axis["bottom"].toggle(all=True) - self.axes.axis["top"].get_helper().nth_coord_ticks = 0 - self.axes.axis["top"].toggle(all=False) - self.axes.axis["right"].get_helper().nth_coord_ticks = 1 - self.axes.axis["right"].toggle(all=True) - self.axes.gridlines.set_linestyle("solid") - - self.figure.add_subplot(self.axes) - - # Configure default axes. - axis = self.axes.axis["left"] + super(TephiAxes, self).__init__( + figure, *args, grid_helper=gridder, **kwargs + ) + + # The tephigram cache. + transform = transforms.TephiTransform() + self.transData + + self.tephi = dict( + xylim=xylim, + figure=figure.add_subplot(self), + profiles=isopleths.ProfileList(), + transform=transform, + ) + + # Create each axis. + self.axis["isotherm"] = self.new_floating_axis(1, 0) + self.axis["theta"] = self.new_floating_axis(0, 0) + self.axis["left"].get_helper().nth_coord_ticks = 0 + self.axis["left"].toggle(all=True) + self.axis["bottom"].get_helper().nth_coord_ticks = 1 + self.axis["bottom"].toggle(all=True) + self.axis["top"].get_helper().nth_coord_ticks = 0 + self.axis["top"].toggle(all=False) # Turned-off + self.axis["right"].get_helper().nth_coord_ticks = 1 + self.axis["right"].toggle(all=True) + self.gridlines.set_linestyle("solid") + + # Configure each axis. + axis = self.axis["left"] axis.major_ticklabels.set_fontsize(10) axis.major_ticklabels.set_va("baseline") axis.major_ticklabels.set_rotation(135) - axis = self.axes.axis["right"] + axis = self.axis["right"] axis.major_ticklabels.set_fontsize(10) axis.major_ticklabels.set_va("baseline") axis.major_ticklabels.set_rotation(-135) - self.axes.axis["top"].major_ticklabels.set_fontsize(10) - axis = self.axes.axis["bottom"] + self.axis["top"].major_ticklabels.set_fontsize(10) + axis = self.axis["bottom"] axis.major_ticklabels.set_fontsize(10) axis.major_ticklabels.set_ha("left") - axis.major_ticklabels.set_va("top") + axis.major_ticklabels.set_va("bottom") axis.major_ticklabels.set_rotation(-45) # Isotherms: lines of constant temperature (degC). - axis = self.axes.axis["isotherm"] + axis = self.axis["isotherm"] axis.set_axis_direction("right") axis.set_axislabel_direction("-") axis.major_ticklabels.set_rotation(90) - axis.major_ticklabels.set_fontsize(10) + axis.major_ticklabels.set_fontsize(8) axis.major_ticklabels.set_va("bottom") axis.major_ticklabels.set_color("grey") - axis.major_ticklabels.set_visible(False) # turned-off + axis.major_ticklabels.set_visible(False) # Turned-off + axis.major_ticklabels.set_clip_box(self.bbox) # Dry adiabats: lines of constant potential temperature (degC). - axis = self.axes.axis["theta"] + axis = self.axis["theta"] axis.set_axis_direction("right") axis.set_axislabel_direction("+") - axis.major_ticklabels.set_fontsize(10) + axis.major_ticklabels.set_fontsize(8) axis.major_ticklabels.set_va("bottom") axis.major_ticklabels.set_color("grey") - axis.major_ticklabels.set_visible(False) # turned-off + axis.major_ticklabels.set_visible(False) # Turned-off + axis.major_ticklabels.set_clip_box(self.bbox) axis.line.set_linewidth(3) axis.line.set_linestyle("--") # Lock down the aspect ratio. - self.axes.set_aspect(1.0) - self.axes.grid(True) + self.set_aspect("equal") + self.grid(True) # Initialise the text formatter for the navigation status bar. - self.axes.format_coord = self._status_bar - - # Factor in the tephigram transform. - ISOBAR_TEXT["transform"] = self.transform - WET_ADIABAT_TEXT["transform"] = self.transform - MIXING_RATIO_TEXT["transform"] = self.transform - - # Create plot collections for the tephigram isopleths. - func = partial( - isopleths.isobar, - MIN_THETA, - MAX_THETA, - self.axes, - self.transform, - ISOBAR_LINE, - ) - self._isobars = _PlotCollection( - self.axes, - ISOBAR_SPEC, - MAX_PRESSURE, - func, - ISOBAR_TEXT, - fixed=ISOBAR_FIXED, - minimum=MIN_PRESSURE, - ) - - func = partial( - isopleths.wet_adiabat, - MAX_PRESSURE, - MIN_TEMPERATURE, - self.axes, - self.transform, - WET_ADIABAT_LINE, - ) - self._wet_adiabats = _PlotCollection( - self.axes, - WET_ADIABAT_SPEC, - MAX_WET_ADIABAT, - func, - WET_ADIABAT_TEXT, - fixed=WET_ADIABAT_FIXED, - minimum=MIN_WET_ADIABAT, - xfocus=True, - ) + self.format_coord = self._status_bar - func = partial( - isopleths.mixing_ratio, - MIN_PRESSURE, - MAX_PRESSURE, - self.axes, - self.transform, - MIXING_RATIO_LINE, - ) - self._mixing_ratios = _PlotCollection( - self.axes, - MIXING_RATIO_SPEC, - MIXING_RATIOS, - func, - MIXING_RATIO_TEXT, - fixed=MIXING_RATIO_FIXED, - ) - - # Initialise for the tephigram plot event handler. - plt.connect("motion_notify_event", _handler) - self.axes.tephigram = True - self.axes.tephigram_original_delta_xlim = DEFAULT_WIDTH - self.original_delta_xlim = DEFAULT_WIDTH - self.axes.tephigram_transform = self.tephi_transform - self.axes.tephigram_inverse = self.tephi_transform.inverted() - self.axes.tephigram_isopleths = [ - self._isobars, - self._wet_adiabats, - self._mixing_ratios, - ] - - # The tephigram profiles. - self._profiles = [] - self.axes.tephigram_profiles = self._profiles - - # Center the plot around the anchor extent. - self._anchor = anchor - if self._anchor is not None: - self._anchor = np.asarray(anchor) - if ( - self._anchor.ndim != 2 - or self._anchor.shape[-1] != 2 - or len(self._anchor) != 2 - ): + # Center the plot around the xylim extent. + if xylim is not None: + xylim = np.asarray(xylim) + if xylim.shape != (2, 2): msg = ( - "Invalid anchor, expecting [(bottom-right-pressure, " - "bottom-right-temperature), (top-left-pressure, " - "top-left-temperature)]" + "Invalid xylim, expecting [(BLHC-T, BLHC-t)," + "(TRHC-T, TRHC-t)]" ) raise ValueError(msg) - ( - (bottom_pressure, bottom_temp), - (top_pressure, top_temp), - ) = self._anchor - - if (bottom_pressure - top_pressure) < 0: - raise ValueError("Invalid anchor pressure range") - if (bottom_temp - top_temp) < 0: - raise ValueError("Invalid anchor temperature range") - - self._anchor = isopleths.Profile(anchor, self.axes) - self._anchor.plot(visible=False) - xlim, ylim = self._calculate_extents() - self.axes.set_xlim(xlim) - self.axes.set_ylim(ylim) + xlim, ylim = transforms.convert_Tt2xy(xylim[:, 0], xylim[:, 1]) + self.set_xlim(xlim) + self.set_ylim(ylim) + self.tephi["xylim"] = xlim, ylim + + def _search_artists(self, artist): + list_of_relevant_artists = [a for a in self.artists if type(a) == artist] + if len(list_of_relevant_artists) == 1: + return list_of_relevant_artists[0] + elif len(list_of_relevant_artists) == 0: + return None + else: + raise ValueError(f"Found more than one {artist} artist.") + + @property + def wet_adiabat(self): + return self._search_artists(WetAdiabatArtist) + + @wet_adiabat.setter + def wet_adiabat(self, artist): + if type(artist) is WetAdiabatArtist: + old_artist = self._search_artists(WetAdiabatArtist) + if old_artist: + old_artist.remove() + self.add_artist(artist) + else: + raise ValueError(f"Artist {artist} is not of type {WetAdiabatArtist}.") + + @property + def isobar(self): + return self._search_artists(IsobarArtist) + + @isobar.setter + def isobar(self, artist): + if type(artist) is IsobarArtist: + old_artist = self._search_artists(IsobarArtist) + if old_artist: + old_artist.remove() + self.add_artist(artist) + else: + raise ValueError(f"Artist {artist} is not of type {IsobarArtist}.") + + @property + def mixing_ratio(self): + return self._search_artists(HumidityMixingRatioArtist) + + @mixing_ratio.setter + def mixing_ratio(self, artist): + if type(artist) is HumidityMixingRatioArtist: + old_artist = self._search_artists(HumidityMixingRatioArtist) + if old_artist: + old_artist.remove() + self.add_artist(artist) + else: + raise ValueError(f"Artist {artist} is not of type {HumidityMixingRatioArtist}.") def plot(self, data, **kwargs): """ - Plot the environmental lapse rate profile of the pressure and - temperature data points. + Plot the profile of the pressure and temperature data points. The pressure and temperature data points are transformed into potential temperature and temperature data points before plotting. @@ -839,51 +443,47 @@ def plot(self, data, **kwargs): Args: - * data: (pressure, temperature) pair data points. + * data: + Pressure and temperature pair data points. .. note:: All keyword arguments are passed through to :func:`matplotlib.pyplot.plot`. - For example: - .. plot:: :include-source: import matplotlib.pyplot as plt - from tephi import Tephigram + from tephi import TephiAxes - tpg = Tephigram() + ax = TephiAxes() data = [[1006, 26.4], [924, 20.3], [900, 19.8], [850, 14.5], [800, 12.9], [755, 8.3]] - profile = tpg.plot(data, color='red', linestyle='--', - linewidth=2, marker='o') + profile = ax.plot(data, color='red', linestyle='--', + linewidth=2, marker='o') barbs = [(10, 45, 900), (20, 60, 850), (25, 90, 800)] profile.barbs(barbs) plt.show() - For associating wind barbs with an environmental lapse rate profile, - see :meth:`~tephi.isopleths.Profile.barbs`. + For associating wind barbs with the profile, see + :meth:`~tephi.isopleths.Profile.barbs`. """ - profile = isopleths.Profile(data, self.axes) + profile = isopleths.Profile(self, data) profile.plot(**kwargs) - self._profiles.append(profile) + self.tephi["profiles"].append(profile) # Center the tephigram plot around all the profiles. - if self._anchor is None: + if self.tephi["xylim"] is None: xlim, ylim = self._calculate_extents(xfactor=0.25, yfactor=0.05) - self.axes.set_xlim(xlim) - self.axes.set_ylim(ylim) - - # Refresh the tephigram plot isopleths. - _refresh_isopleths(self.axes) + self.set_xlim(xlim) + self.set_ylim(ylim) # Show the plot legend. if "label" in kwargs: font_properties = FontProperties(size="x-small") plt.legend( - loc="upper left", + loc="upper right", fancybox=True, shadow=True, prop=font_properties, @@ -891,46 +491,106 @@ def plot(self, data, **kwargs): return profile + def add_isobars( + self, + ticks=None, + line=None, + text=None, + min_theta=None, + max_theta=None, + nbins=None, + ): + self.isobar = artists.IsobarArtist( + ticks=ticks, + line=line, + text=text, + min_theta=min_theta, + max_theta=max_theta, + nbins=nbins, + ) + + def add_wet_adiabats( + self, + ticks=None, + line=None, + text=None, + min_temperature=None, + max_pressure=None, + nbins=None, + ): + self.wet_adiabat = artists.WetAdiabatArtist( + ticks=ticks, + line=line, + text=text, + min_temperature=min_temperature, + max_pressure=max_pressure, + nbins=nbins, + ) + + def add_mixing_ratios( + self, + ticks=None, + line=None, + text=None, + min_pressure=None, + max_pressure=None, + nbins=None, + ): + self.mixing_ratio = artists.HumidityMixingRatioArtist( + ticks=ticks, + line=line, + text=text, + min_pressure=min_pressure, + max_pressure=max_pressure, + nbins=nbins, + ) + def _status_bar(self, x_point, y_point): - """Generate text for the interactive backend navigation status bar.""" + """ + Generate text for the interactive backend navigation status bar. + """ temperature, theta = transforms.convert_xy2Tt(x_point, y_point) pressure, _ = transforms.convert_Tt2pT(temperature, theta) - xlim = self.axes.get_xlim() - zoom = (xlim[1] - xlim[0]) / self.original_delta_xlim - msg = "T:{:.2f}, theta:{:.2f}, phi:{:.2f} (zoom:{:.3f})" - text = msg.format( - float(temperature), float(theta), float(pressure), zoom - ) - - return text + text = "T={:.2f}\u00b0C, \u03b8={:.2f}\u00b0C, p={:.2f}hPa" + return text.format(float(temperature), float(theta), float(pressure)) def _calculate_extents(self, xfactor=None, yfactor=None): - min_x = min_y = 1e10 - max_x = max_y = -1e-10 - profiles = self._profiles - transform = self.tephi_transform.transform - - if self._anchor is not None: - profiles = [self._anchor] - - for profile in profiles: - temperature = profile.temperature.reshape(-1, 1) - theta = profile.theta.reshape(-1, 1) - xy_points = transform(np.concatenate((temperature, theta), axis=1)) - x_points = xy_points[:, 0] - y_points = xy_points[:, 1] - min_x = np.min([min_x, np.min(x_points)]) - min_y = np.min([min_y, np.min(y_points)]) - max_x = np.max([max_x, np.max(x_points)]) - max_y = np.max([max_y, np.max(y_points)]) - - if xfactor is not None: - delta_x = max_x - min_x - min_x, max_x = min_x - xfactor * delta_x, max_x + xfactor * delta_x - - if yfactor is not None: - delta_y = max_y - min_y - min_y, max_y = min_y - yfactor * delta_y, max_y + yfactor * delta_y - - return ([min_x, max_x], [min_y, max_y]) + min_x = min_y = np.inf + max_x = max_y = -np.inf + + if self.tephi["xylim"] is not None: + xlim, ylim = self.tephi["xylim"] + else: + for profile in self.tephi["profiles"]: + temperature = profile.points.temperature + theta = profile.points.theta + x_points, y_points = transforms.convert_Tt2xy( + temperature, theta + ) + min_x, min_y = ( + np.min([min_x, np.min(x_points)]), + np.min([min_y, np.min(y_points)]), + ) + max_x, max_y = ( + np.max([max_x, np.max(x_points)]), + np.max([max_y, np.max(y_points)]), + ) + + if xfactor is not None: + delta_x = max_x - min_x + min_x, max_x = ( + (min_x - xfactor * delta_x), + (max_x + xfactor * delta_x), + ) + + if yfactor is not None: + delta_y = max_y - min_y + min_y, max_y = ( + (min_y - yfactor * delta_y), + (max_y + yfactor * delta_y), + ) + + xlim, ylim = (min_x, max_x), (min_y, max_y) + + return xlim, ylim diff --git a/tephi/artists.py b/tephi/artists.py new file mode 100644 index 0000000..2759cf2 --- /dev/null +++ b/tephi/artists.py @@ -0,0 +1,307 @@ +import matplotlib.artist +import numpy as np +from scipy.interpolate import interp1d +from shapely.geometry import LineString, Polygon +from shapely.prepared import prep + +from .constants import default +from .isopleths import Isobar, WetAdiabat, HumidityMixingRatio +from .transforms import convert_xy2Tt, convert_Tt2pT + + +class IsoplethArtist(matplotlib.artist.Artist): + def __init__(self): + super(IsoplethArtist, self).__init__() + self._isopleths = None + + def _locator(self, x0, x1, y0, y1): + temperature, theta = convert_xy2Tt([x0, x0, x1, x1], [y0, y1, y1, y0]) + bbox = prep(Polygon(zip(temperature, theta))) + mask = [bbox.intersects(item.geometry) for item in self._isopleths] + mask = np.asarray(mask) + + if self.nbins: + indices = np.where(mask)[0] + if indices.size: + if self.nbins < indices.size: + mask[:] = False + upint = indices.size + self.nbins - 1 + # this is an ugly solution, I'm sure there must be better ones + mask[indices[:: upint // self.nbins + 1]] = True + + return mask + + +class IsobarArtist(IsoplethArtist): + def __init__( + self, + ticks=None, + line=None, + text=None, + min_theta=None, + max_theta=None, + nbins=None, + ): + super(IsobarArtist, self).__init__() + if ticks is None: + ticks = default.get("isobar_ticks") + self.ticks = ticks + self._kwargs = {} + if line is None: + line = default.get("isobar_line") + self._kwargs["line"] = line + if text is None: + text = default.get("isobar_text") + self._kwargs["text"] = text + if min_theta is None: + min_theta = default.get("isobar_min_theta") + self.min_theta = min_theta + if max_theta is None: + max_theta = default.get("isobar_max_theta") + self.max_theta = max_theta + if nbins is None: + nbins = default.get("isobar_nbins") + elif nbins < 2 or isinstance(nbins, str): + nbins = None + self.nbins = nbins + + @matplotlib.artist.allow_rasterization + def draw( + self, renderer, line=None, text=None, min_theta=None, max_theta=None + ): + if not self.get_visible(): + return + axes = self.axes + draw_kwargs = dict(self._kwargs["line"]) + if line is not None: + draw_kwargs.update(line) + text_kwargs = dict(self._kwargs["text"]) + if text is not None: + text_kwargs.update(text) + if min_theta is None: + min_theta = self.min_theta + if max_theta is None: + max_theta = self.max_theta + + if self._isopleths is None: + isobars = [] + for tick in self.ticks: + isobars.append(Isobar(axes, tick, min_theta, max_theta)) + self._isopleths = np.asarray(isobars) + + (x0, x1), (y0, y1) = axes.get_xlim(), axes.get_ylim() + mask = self._locator(x0, x1, y0, y1) + + mx = x0 + axes.viewLim.width * 0.5 + temperature, theta = convert_xy2Tt([mx, mx], [y0, y1]) + text_line = LineString(zip(temperature, theta)) + + temperature, theta = convert_xy2Tt([mx] * 50, np.linspace(y0, y1, 50)) + pressure, _ = convert_Tt2pT(temperature, theta) + func = interp1d(pressure, theta, bounds_error=False) + + for isobar in self._isopleths[mask]: + isobar.draw(renderer, **draw_kwargs) + point = text_line.intersection(isobar.geometry) + if point: + isobar.refresh( + point.x, point.y, renderer=renderer, **text_kwargs + ) + else: + if func(isobar.data) < isobar.extent.theta.lower: + T = isobar.points.temperature[isobar.index.theta.lower] + t = isobar.extent.theta.lower + else: + T = isobar.points.temperature[isobar.index.theta.upper] + t = isobar.extent.theta.upper + isobar.refresh(T, t, renderer=renderer, **text_kwargs) + + +class WetAdiabatArtist(IsoplethArtist): + def __init__( + self, + ticks=None, + line=None, + text=None, + min_temperature=None, + max_pressure=None, + nbins=None, + ): + super(WetAdiabatArtist, self).__init__() + if ticks is None: + ticks = default.get("wet_adiabat_ticks") + self.ticks = sorted(ticks) + self._kwargs = {} + if line is None: + line = default.get("wet_adiabat_line") + self._kwargs["line"] = line + if text is None: + text = default.get("wet_adiabat_text") + self._kwargs["text"] = text + if min_temperature is None: + min_temperature = default.get("wet_adiabat_min_temperature") + self.min_temperature = min_temperature + if max_pressure is None: + max_pressure = default.get("wet_adiabat_max_pressure") + self.max_pressure = max_pressure + if nbins is None: + nbins = default.get("wet_adiabat_nbins") + if nbins < 2 or isinstance(nbins, str): + nbins = None + self.nbins = nbins + + @matplotlib.artist.allow_rasterization + def draw( + self, + renderer, + line=None, + text=None, + min_temperature=None, + max_pressure=None, + ): + if not self.get_visible(): + return + axes = self.axes + draw_kwargs = dict(self._kwargs["line"]) + if line is not None: + draw_kwargs.update(line) + text_kwargs = dict(self._kwargs["text"]) + if text is not None: + text_kwargs.update(text) + if min_temperature is None: + min_temperature = self.min_temperature + if max_pressure is None: + max_pressure = self.max_pressure + + if self._isopleths is None: + adiabats = [] + for tick in self.ticks: + adiabats.append( + WetAdiabat(axes, tick, min_temperature, max_pressure) + ) + self._isopleths = np.asarray(adiabats) + + (x0, x1), (y0, y1) = axes.get_xlim(), axes.get_ylim() + mask = self._locator(x0, x1, y0, y1) + + mx = x0 + axes.viewLim.width * 0.5 + my = y0 + axes.viewLim.height * 0.5 + temperature, theta = convert_xy2Tt([x0, mx, x1], [y0, my, y1]) + text_line = LineString(zip(temperature, theta)) + mT = temperature[1] + snap = None + + for adiabat in self._isopleths[mask]: + adiabat.draw(renderer, **draw_kwargs) + point = text_line.intersection(adiabat.geometry) + if point: + adiabat.refresh( + point.x, point.y, renderer=renderer, **text_kwargs + ) + else: + upper = abs(adiabat.extent.temperature.upper - mT) + lower = abs(adiabat.extent.temperature.lower - mT) + if snap == "upper" or upper < lower: + T = adiabat.extent.temperature.upper + t = adiabat.points.theta[adiabat.index.temperature.upper] + snap = "upper" + else: + T = adiabat.extent.temperature.lower + t = adiabat.points.theta[adiabat.index.temperature.lower] + snap = "lower" + adiabat.refresh(T, t, renderer=renderer, **text_kwargs) + + +class HumidityMixingRatioArtist(IsoplethArtist): + def __init__( + self, + ticks=None, + line=None, + text=None, + min_pressure=None, + max_pressure=None, + nbins=None, + ): + super(HumidityMixingRatioArtist, self).__init__() + if ticks is None: + ticks = default.get("mixing_ratio_ticks") + self.ticks = ticks + self._kwargs = {} + if line is None: + line = default.get("mixing_ratio_line") + self._kwargs["line"] = line + if text is None: + text = default.get("mixing_ratio_text") + self._kwargs["text"] = text + if min_pressure is None: + min_pressure = default.get("mixing_ratio_min_pressure") + self.min_pressure = min_pressure + if max_pressure is None: + max_pressure = default.get("mixing_ratio_max_pressure") + self.max_pressure = max_pressure + if nbins is None: + nbins = default.get("mixing_ratio_nbins") + if nbins < 2 or isinstance(nbins, str): + nbins = None + self.nbins = nbins + + @matplotlib.artist.allow_rasterization + def draw( + self, + renderer, + line=None, + text=None, + min_pressure=None, + max_pressure=None, + ): + if not self.get_visible(): + return + axes = self.axes + draw_kwargs = dict(self._kwargs["line"]) + if line is not None: + draw_kwargs.update(line) + text_kwargs = dict(self._kwargs["text"]) + if text is not None: + text_kwargs.update(text) + if min_pressure is None: + min_pressure = self.min_pressure + if max_pressure is None: + max_pressure = self.max_pressure + + if self._isopleths is None: + ratios = [] + for tick in self.ticks: + ratios.append( + HumidityMixingRatio(axes, tick, min_pressure, max_pressure) + ) + self._isopleths = np.asarray(ratios) + + (x0, x1), (y0, y1) = axes.get_xlim(), axes.get_ylim() + mask = self._locator(x0, x1, y0, y1) + + mx = x0 + axes.viewLim.width * 0.5 + my = y0 + axes.viewLim.height * 0.5 + temperature, theta = convert_xy2Tt([x0, mx, x1], [y1, my, y0]) + text_line = LineString(zip(temperature, theta)) + mt = theta[1] + snap = None + + for ratio in self._isopleths[mask]: + ratio.draw(renderer, **draw_kwargs) + point = text_line.intersection(ratio.geometry) + if point: + ratio.refresh( + point.x, point.y, renderer=renderer, **text_kwargs + ) + else: + upper = abs(ratio.extent.theta.upper - mt) + lower = abs(ratio.extent.theta.lower - mt) + if snap == "upper" or upper < lower: + T = ratio.points.temperature[ratio.index.theta.upper] + t = ratio.extent.theta.upper + snap = "upper" + else: + T = ratio.points.temperature[ratio.index.theta.lower] + t = ratio.extent.theta.lower + snap = "lower" + ratio.refresh(T, t, renderer=renderer, **text_kwargs) diff --git a/tephi/constants.py b/tephi/constants.py new file mode 100644 index 0000000..d162a1f --- /dev/null +++ b/tephi/constants.py @@ -0,0 +1,135 @@ +# Copyright Tephi contributors +# +# This file is part of Tephi and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Tephigram transform and isopleth constants.""" + +# The specific heat capacity of dry air at a constant pressure, +# in units of J kg-1 K-1. +# TBC: This was originally set to 1.01e3 +Cp = 1004.0 + +# Dimensionless ratio: Rd / Cp. +K = 0.286 + +# Conversion offset between degree Celsius and Kelvin. +KELVIN = 273.15 + +# The specific latent heat of vapourisation of water at 0 degC, +# in units of J kg-1. +L = 2.501e6 + +MA = 300.0 + +# The specific gas constant for dry air, in units of J kg-1 K-1. +Rd = 287.0 + +# The specific gas constant for water vapour, in units of J kg-1 K-1. +Rv = 461.0 + +# Dimensionless ratio: Rd / Rv. +E = 0.622 + +# Base surface pressure. +P_BASE = 1000.0 + +# TODO: add in hodograph and mode defaults +default = { + "barbs_gutter": 0.1, + "barbs_length": 7, + "barbs_linewidth": 1.5, + "barbs_zorder": 10, + "isobar_line": dict(color="blue", linewidth=0.5, clip_on=True), + "isobar_min_theta": 0, + "isobar_max_theta": 250, + "isobar_nbins": None, + "isobar_text": dict( + size=8, color="blue", clip_on=True, va="bottom", ha="right" + ), + "isobar_ticks": [ + 1050, + 1000, + 950, + 900, + 850, + 800, + 700, + 600, + 500, + 400, + 300, + 250, + 200, + 150, + 100, + 70, + 50, + 40, + 30, + 20, + 10, + ], + "isopleth_picker": 3, + "isopleth_zorder": 10, + "mixing_ratio_line": dict(color="green", linewidth=0.5, clip_on=True), + "mixing_ratio_text": dict( + size=8, color="green", clip_on=True, va="bottom", ha="right" + ), + "mixing_ratio_min_pressure": 10, + "mixing_ratio_max_pressure": P_BASE, + "mixing_ratio_nbins": 10, + "mixing_ratio_ticks": [ + 0.001, + 0.002, + 0.005, + 0.01, + 0.02, + 0.03, + 0.05, + 0.1, + 0.15, + 0.2, + 0.3, + 0.4, + 0.5, + 0.6, + 0.8, + 1.0, + 1.5, + 2.0, + 2.5, + 3.0, + 4.0, + 5.0, + 6.0, + 7.0, + 8.0, + 9.0, + 10.0, + 12.0, + 14.0, + 16.0, + 18.0, + 20.0, + 24.0, + 28.0, + 32.0, + 36.0, + 40.0, + 44.0, + 48.0, + 52.0, + 56.0, + 60.0, + 68.0, + 80.0, + ], + "wet_adiabat_line": dict(color="orange", linewidth=0.5, clip_on=True), + "wet_adiabat_min_temperature": -50, + "wet_adiabat_max_pressure": P_BASE, + "wet_adiabat_nbins": 10, + "wet_adiabat_text": dict( + size=8, color="orange", clip_on=True, va="top", ha="left" + ), + "wet_adiabat_ticks": range(1, 61), +} diff --git a/tephi/isopleths.py b/tephi/isopleths.py index f6d698e..b5807b0 100644 --- a/tephi/isopleths.py +++ b/tephi/isopleths.py @@ -7,21 +7,29 @@ environment profiles and barbs. """ + +from __future__ import absolute_import, division, print_function + +from abc import ABCMeta, abstractmethod +from collections import namedtuple import math +import matplotlib.artist from matplotlib.collections import PathCollection from matplotlib.path import Path import matplotlib.pyplot as plt -import matplotlib.transforms as mtransforms +import matplotlib.transforms as mtrans +from mpl_toolkits.axisartist import Subplot import numpy as np +from shapely.geometry import LineString from scipy.interpolate import interp1d -from ._constants import CONST_CP, CONST_L, CONST_KELVIN, CONST_RD, CONST_RV -from . import transforms +import tephi.constants as constants +from tephi.constants import default +import tephi.transforms as transforms # Wind barb speed (knots) ranges used since 1 January 1955. _BARB_BINS = np.arange(20) * 5 + 3 -_BARB_GUTTER = 0.1 _BARB_DTYPE = np.dtype( dict( names=("speed", "angle", "pressure", "barb"), @@ -29,216 +37,51 @@ ) ) -# -# Reference: http://www-nwp/~hadaa/tephigram/tephi_plot.html -# - - -def mixing_ratio( - min_pressure, max_pressure, axes, transform, kwargs, mixing_ratio_value -): - """ - Generate and plot a humidity mixing ratio line. - - A line of constant saturation mixing ratio with respect to a - plane water surface (g kg-1). - - Args: - - * min_pressure: - Minumum pressure, in mb or hPa, for the mixing ratio line extent. - - * max_pressure: - Maximum pressure, in mb or hPa, for the mixing ratio line extent. - - * axes: - Tephigram plotting :class:`matplotlib.axes.AxesSubplot` instance. - - * transform: - Tephigram plotting transformation - :class:`matplotlib.transforms.CompositeGenericTransform` instance. - - * kwargs: - Keyword arguments for the mixing ratio :class:`matplotlib.lines.Line2D` - instance. - - * mixing_ratio_value: - The mixing ratio value to be plotted. - - Returns: - The mixing ratio :class:`matplotlib.lines.Line2D` instance. - - """ - pressures = np.linspace(min_pressure, max_pressure, 100) - temps = transforms.convert_pw2T(pressures, mixing_ratio_value) - _, thetas = transforms.convert_pT2Tt(pressures, temps) - (line,) = axes.plot(temps, thetas, transform=transform, **kwargs) - - return line - - -def isobar(min_theta, max_theta, axes, transform, kwargs, pressure): - """ - Generate and plot an isobar line. - - A line of constant pressure (mb). - - Args: - - * min_theta: - Minimum potential temperature, in degC, for the isobar extent. - - * max_theta: - Maximum potential temperature, in degC, for the isobar extent. - - * axes: - Tephigram plotting :class:`matplotlib.axes.AxesSubplot` instance. - - * transform: - Tephigram plotting transformation - :class:`matplotlib.transforms.CompositeGenericTransform` instance. - - * kwargs: - Keyword arguments for the isobar :class:`matplotlib.lines.Line2D` - instance. - - * pressure: - The isobar pressure value, in mb or hPa, to be plotted. - - Returns: - The isobar :class:`matplotlib.lines.Line2D` instance. - - """ - steps = 100 - thetas = np.linspace(min_theta, max_theta, steps) - _, temps = transforms.convert_pt2pT([pressure] * steps, thetas) - (line,) = axes.plot(temps, thetas, transform=transform, **kwargs) - - return line - - -def _wet_adiabat_gradient(min_temperature, pressure, temperature, dp): - """ - Calculate the wet adiabat change in pressure and temperature. - - Args: - - * min_temperature: - Minimum potential temperature, in degC, for the wet adiabat line - extent. - - * pressure: - Pressure point value, in mb or hPa, from which to calculate the - gradient difference. - - * temperature: - Potential temperature point value, in degC, from which to calculate - the gradient difference. - - * dp: - The wet adiabat change in pressure, in mb or hPa, from which to - calculate the gradient difference. - - Returns: - The gradient change as a (pressure, potential-temperature) value pair. - - """ - - # TODO: Discover the meaning of the magic numbers. - - kelvin = temperature + CONST_KELVIN - lsbc = (CONST_L / CONST_RV) * ((1.0 / CONST_KELVIN) - (1.0 / kelvin)) - rw = 6.11 * np.exp(lsbc) * (0.622 / pressure) - lrwbt = (CONST_L * rw) / (CONST_RD * kelvin) - nume = ((CONST_RD * kelvin) / (CONST_CP * pressure)) * (1.0 + lrwbt) - deno = 1.0 + (lrwbt * ((0.622 * CONST_L) / (CONST_CP * kelvin))) - gradi = nume / deno - dt = dp * gradi - - if (temperature + dt) < min_temperature: - dt = min_temperature - temperature - dp = dt / gradi +# Isopleth defaults. +_DRY_ADIABAT_STEPS = 50 +_HUMIDITY_MIXING_RATIO_STEPS = 50 +_ISOBAR_STEPS = 50 +_ISOTHERM_STEPS = 50 +_SATURATION_ADIABAT_PRESSURE_DELTA = -5.0 - return dp, dt +BOUNDS = namedtuple("BOUNDS", "lower upper") +POINTS = namedtuple("POINTS", "temperature theta pressure") -def wet_adiabat( - max_pressure, min_temperature, axes, transform, kwargs, temperature -): - """ - Generate and plot a pseudo saturated wet adiabat line. - - A line of constant equivalent potential temperature for saturated - air parcels (degC). - - Args: - - * max_pressure: - Maximum pressure, in mb or hPa, for the wet adiabat line extent. - - * min_temperature: - Minimum potential temperature, in degC, for the wet adiabat line - extent. - - * axes: - Tephigram plotting :class:`matplotlib.axes.AxesSubplot` instance. - - * transform: - Tephigram plotting transformation - :class:`matplotlib.transforms.CompositeGenericTransform` instance. - - * kwargs: - Keyword arguments for the mixing ratio :class:`matplotlib.lines.Line2D` - instance. - - * temperature: - The wet adiabat value, in degC, to be plotted. - - Returns: - The wet adiabat :class:`matplotlib.lines.Line2D` instance. - - """ - temps = [temperature] - pressures = [max_pressure] - dp = -5.0 - - for i in range(200): - dp, dt = _wet_adiabat_gradient( - min_temperature, pressures[i], temps[i], dp +class BarbArtist(matplotlib.artist.Artist): + def __init__(self, barbs, **kwargs): + super(BarbArtist, self).__init__() + self._gutter = kwargs.pop("gutter", default.get("barbs_gutter")) + self._kwargs = dict( + length=default.get("barbs_length"), + zorder=default.get("barbs_zorder", 10), ) - temps.append(temps[i] + dt) - pressures.append(pressures[i] + dp) - - _, thetas = transforms.convert_pT2Tt(pressures, temps) - (line,) = axes.plot(temps, thetas, transform=transform, **kwargs) - - return line - - -class Barbs: - """Generate a wind arrow barb.""" - - def __init__(self, axes): - """ - Create a wind arrow barb for the given axes. - - Args: - - * axes: - A :class:`matplotlib.axes.AxesSubplot` instance. - - """ - self.axes = axes - self.barbs = None - self._gutter = None - self._transform = axes.tephigram_transform + axes.transData - self._kwargs = None - self._custom_kwargs = None - self._custom = dict( + self._kwargs.update(kwargs) + self.set_zorder(self._kwargs["zorder"]) + self._path_kwargs = dict( + color=None, + linewidth=default.get("barbs_linewidth"), + zorder=self._kwargs["zorder"], + ) + alias_by_kwarg = dict( color=["barbcolor", "color", "edgecolor", "facecolor"], linewidth=["lw", "linewidth"], linestyle=["ls", "linestyle"], ) + for kwarg, alias in iter(alias_by_kwarg.items()): + common = set(alias).intersection(kwargs) + if common: + self._path_kwargs[kwarg] = kwargs[sorted(common)[0]] + barbs = np.asarray(list(barbs)) + if barbs.ndim != 2 or barbs.shape[-1] != 3: + msg = ( + "The barbs require to be a sequence of wind speed, " + "wind direction and pressure value triples." + ) + raise ValueError(msg) + self.barbs = np.empty(barbs.shape[0], dtype=_BARB_DTYPE) + for i, barb in enumerate(barbs): + self.barbs[i] = tuple(barb) + (None,) @staticmethod def _uv(magnitude, angle): @@ -281,6 +124,7 @@ def _uv(magnitude, angle): def _make_barb(self, temperature, theta, speed, angle): """Add the barb to the plot at the specified location.""" + transform = self.axes.tephi["transform"] u, v = self._uv(speed, angle) if 0 < speed < _BARB_BINS[0]: # Plot the missing barbless 1-2 knots line. @@ -289,8 +133,9 @@ def _make_barb(self, temperature, theta, speed, angle): pivot = self._kwargs.get("pivot", "tip") offset = pivot_points[pivot] verts = [(0.0, offset), (0.0, length + offset)] - rangle = math.radians(-angle) - verts = mtransforms.Affine2D().rotate(rangle).transform(verts) + verts = ( + mtrans.Affine2D().rotate(math.radians(-angle)).transform(verts) + ) codes = [Path.MOVETO, Path.LINETO] path = Path(verts, codes) size = length**2 / 4 @@ -299,165 +144,298 @@ def _make_barb(self, temperature, theta, speed, angle): [path], (size,), offsets=xy, - transOffset=self._transform, - **self._custom_kwargs, + transOffset=transform, + **self._path_kwargs, ) - barb.set_transform(mtransforms.IdentityTransform()) - self.axes.add_collection(barb) + barb.set_transform(mtrans.IdentityTransform()) else: - barb = plt.barbs( - temperature, - theta, - u, - v, - transform=self._transform, - **self._kwargs, + barb = self.axes.barbs( + temperature, theta, u, v, transform=transform, **self._kwargs ) + collections = list(self.axes.collections).remove(barb) + if collections: + self.axes.collections = tuple(collections) return barb - def refresh(self): - """Refresh the plot with the barbs.""" - if self.barbs is not None: - xlim = self.axes.get_xlim() - ylim = self.axes.get_ylim() - y = np.linspace(*ylim)[::-1] - xdelta = xlim[1] - xlim[0] - x = np.ones(y.size) * (xlim[1] - (xdelta * self._gutter)) - xy = np.column_stack((x, y)) - points = self.axes.tephigram_inverse.transform(xy) - temperature, theta = points[:, 0], points[:, 1] - pressure, _ = transforms.convert_Tt2pT(temperature, theta) - min_pressure, max_pressure = np.min(pressure), np.max(pressure) - func = interp1d(pressure, temperature) - for i, (speed, angle, pressure, barb) in enumerate(self.barbs): - if min_pressure < pressure < max_pressure: - p2T = func(pressure) - temperature, theta = transforms.convert_pT2Tt( - pressure, p2T - ) - if barb is None: - self.barbs[i]["barb"] = self._make_barb( - temperature, theta, speed, angle - ) - else: - barb.set_offsets(np.array([[temperature, theta]])) - barb.set_visible(True) + @matplotlib.artist.allow_rasterization + def draw(self, renderer): + if not self.get_visible(): + return + axes = self.axes + x0, x1 = axes.get_xlim() + y0, y1 = axes.get_ylim() + y = np.linspace(y0, y1)[::-1] + x = np.asarray([x1 - ((x1 - x0) * self._gutter)] * y.size) + temperature, theta = transforms.convert_xy2Tt(x, y) + pressure, _ = transforms.convert_Tt2pT(temperature, theta) + min_pressure, max_pressure = np.min(pressure), np.max(pressure) + func = interp1d(pressure, temperature) + for i, (speed, angle, pressure, barb) in enumerate(self.barbs): + if min_pressure < pressure < max_pressure: + temperature, theta = transforms.convert_pT2Tt( + pressure, func(pressure) + ) + if barb is None: + barb = self._make_barb(temperature, theta, speed, angle) + self.barbs[i]["barb"] = barb else: - if barb is not None: - barb.set_visible(False) + barb.set_offsets(np.array([[temperature, theta]])) - def plot(self, barbs, **kwargs): - """ - Plot the sequence of barbs. + # collections are not automatically added to the figure + barb.set_figure(self.axes.figure) + barb.draw(renderer) - Args: +class Isopleth(object): + __metaclass__ = ABCMeta - * barbs: - Sequence of speed, direction and pressure value triples for - each barb. Where speed is measured in units of knots, direction - in units of degrees (clockwise from north), and pressure must - be in units of mb or hPa. + def __init__(self, axes): + self.axes = axes + self._transform = axes.tephi["transform"] + self.points = self._generate_points() + self.geometry = LineString( + np.vstack((self.points.temperature, self.points.theta)).T + ) + self.line = None + self.label = None + self._kwargs = dict(line={}, text={}) + Tmin, Tmax = ( + np.argmin(self.points.temperature), + np.argmax(self.points.temperature), + ) + tmin, tmax = ( + np.argmin(self.points.theta), + np.argmax(self.points.theta), + ) + pmin, pmax = ( + np.argmin(self.points.pressure), + np.argmax(self.points.pressure), + ) + self.index = POINTS( + BOUNDS(Tmin, Tmax), BOUNDS(tmin, tmax), BOUNDS(pmin, pmax) + ) + self.extent = POINTS( + BOUNDS( + self.points.temperature[Tmin], self.points.temperature[Tmax] + ), + BOUNDS(self.points.theta[tmin], self.points.theta[tmax]), + BOUNDS(self.points.pressure[pmin], self.points.pressure[pmax]), + ) - Kwargs: + @abstractmethod + def _generate_points(self): + pass + + def draw(self, renderer, **kwargs): + if self.line is None: + if "zorder" not in kwargs: + kwargs["zorder"] = default.get("isopleth_zorder") + draw_kwargs = dict(self._kwargs["line"]) + draw_kwargs.update(kwargs) + self.line = plt.Line2D( + self.points.temperature, + self.points.theta, + transform=self._transform, + **draw_kwargs, + ) + self.line.set_clip_box(self.axes.bbox) + self.line.draw(renderer) + return self.line - * gutter: - Proportion offset from the right hand side axis to plot the - barbs. Defaults to 0.1 + def plot(self, **kwargs): + """ + Plot the points of the isopleth. - Also see :func:`matplotlib.pyplot.barbs` + Kwargs: + See :func:`matplotlib.pyplot.plot`. + + Returns: + The isopleth :class:`matplotlib.lines.Line2D` """ - self._gutter = kwargs.pop("gutter", _BARB_GUTTER) - # zorder of 4.1 is higher than all MPL defaults, excluding legend. Also - # higher than tephi default for plot-lines. - self._kwargs = dict(length=7, zorder=4.1) - self._kwargs.update(kwargs) - self._custom_kwargs = dict( - color=None, linewidth=1.5, zorder=self._kwargs["zorder"] + if self.line is not None: + if self.line in self.axes.lines: + self.axes.lines.remove(self.line) + if "zorder" not in kwargs: + kwargs["zorder"] = default.get("isopleth_zorder") + if "picker" not in kwargs: + kwargs["picker"] = default.get("isopleth_picker") + plot_kwargs = dict(self._kwargs["line"]) + plot_kwargs.update(kwargs) + (self.line,) = Subplot.plot( + self.axes, + self.points.temperature, + self.points.theta, + transform=self._transform, + **plot_kwargs, ) - for key, values in self._custom.items(): - common = set(values).intersection(kwargs) - if common: - self._custom_kwargs[key] = kwargs[sorted(common)[0]] - if hasattr(barbs, "__next__"): - barbs = list(barbs) - barbs = np.asarray(barbs) - if barbs.ndim != 2 or barbs.shape[-1] != 3: - msg = ( - "The barbs require to be a sequence of wind speed, " - "wind direction and pressure value triples." - ) - raise ValueError(msg) - self.barbs = np.empty(barbs.shape[0], dtype=_BARB_DTYPE) - for i, barb in enumerate(barbs): - self.barbs[i] = tuple(barb) + (None,) - self.refresh() + return self.line + def text(self, temperature, theta, text, **kwargs): + if "zorder" not in kwargs: + kwargs["zorder"] = default.get("isopleth_zorder", 10) + 1 + text_kwargs = dict(self._kwargs["text"]) + text_kwargs.update(kwargs) + if self.label is not None and self.label in self.axes.texts: + self.axes.lines.remove(self.label) + self.label = self.axes.text( + temperature, + theta, + str(text), + transform=self._transform, + **text_kwargs, + ) + self.label.set_bbox( + dict( + boxstyle="Round,pad=0.3", + facecolor="white", + edgecolor="white", + alpha=0.5, + clip_on=True, + clip_box=self.axes.bbox, + ) + ) + return self.label + + def refresh(self, temperature, theta, renderer=None, **kwargs): + if self.label is None: + self.text(temperature, theta, self.data, **kwargs) + if renderer is not None: + try: + self.axes.tests = tuple( + list(self.axes.texts).remove(self.label) + ) + except TypeError: + self.axes.tests = None + else: + self.label.set_position((temperature, theta)) + if renderer is not None: + self.label.draw(renderer) -class Profile: - """Generate an environmental lapse rate profile.""" - def __init__(self, data, axes): - """ - Create an environmental lapse rate profile from the sequence of - pressure and temperature point data. +class DryAdiabat(Isopleth): + def __init__(self, axes, theta, min_pressure, max_pressure): + self.data = theta + self.bounds = BOUNDS(min_pressure, max_pressure) + self._steps = _DRY_ADIABAT_STEPS + super(DryAdiabat, self).__init__(axes) - Args: + def _generate_points(self): + pressure = np.linspace( + self.bounds.lower, self.bounds.upper, self._steps + ) + theta = np.asarray([self.data] * self._steps) + _, temperature = transforms.convert_pt2pT(pressure, theta) + return POINTS(temperature, theta, pressure) - * data: - Sequence of pressure and temperature points defining the - environmental lapse rate. - * axes: - The axes on which to plot the profile. +class HumidityMixingRatio(Isopleth): + def __init__(self, axes, mixing_ratio, min_pressure, max_pressure): + self.data = mixing_ratio + self.bounds = BOUNDS(min_pressure, max_pressure) + self._step = _HUMIDITY_MIXING_RATIO_STEPS + super(HumidityMixingRatio, self).__init__(axes) - """ - if hasattr(data, "__next__"): - data = list(data) - self.data = np.asarray(data) - if self.data.ndim != 2 or self.data.shape[-1] != 2: - msg = ( - "The environment profile data requires to be a sequence " - "of (pressure, temperature) value pairs." - ) - raise ValueError(msg) - self.axes = axes - self._transform = axes.tephigram_transform + axes.transData - self.pressure = self.data[:, 0] - self.temperature = self.data[:, 1] - _, self.theta = transforms.convert_pT2Tt( - self.pressure, self.temperature + def _generate_points(self): + pressure = np.linspace( + self.bounds.lower, self.bounds.upper, self._step ) - self.line = None - self._barbs = Barbs(axes) + temperature = transforms.convert_pw2T(pressure, self.data) + _, theta = transforms.convert_pT2Tt(pressure, temperature) + return POINTS(temperature, theta, pressure) + + +class Isobar(Isopleth): + def __init__(self, axes, pressure, min_theta, max_theta): + self.data = pressure + self.bounds = BOUNDS(min_theta, max_theta) + self._steps = _ISOBAR_STEPS + super(Isobar, self).__init__(axes) + self._kwargs["line"] = default.get("isobar_line") + self._kwargs["text"] = default.get("isobar_text") + + def _generate_points(self): + pressure = np.asarray([self.data] * self._steps) + theta = np.linspace(self.bounds.lower, self.bounds.upper, self._steps) + _, temperature = transforms.convert_pt2pT(pressure, theta) + return POINTS(temperature, theta, pressure) + + +class Isotherm(Isopleth): + def __init__(self, axes, temperature, min_pressure, max_pressure): + self.data = temperature + self.bounds = BOUNDS(min_pressure, max_pressure) + self._steps = _ISOTHERM_STEPS + super(Isotherm, self).__init__(axes) + + def _generate_points(self): + pressure = np.linspace( + self.bounds.lower, self.bounds.upper, self._steps + ) + temperature = np.asarray([self.data] * self._steps) + _, theta = transforms.convert_pT2Tt(pressure, temperature) + return POINTS(temperature, theta, pressure) - def plot(self, **kwargs): + +class Profile(Isopleth): + def __init__(self, axes, data): """ - Plot the environmental lapse rate profile. + Create a profile from the sequence of pressure and temperature points. - Kwargs: + Args: - See :func:`matplotlib.pyplot.plot`. + * axes: + The tephigram axes on which to plot the profile. - Returns: - The profile :class:`matplotlib.lines.Line2D` + * data: + Sequence of pressure and temperature points defining + the profile. """ - if self.line is not None and self.line in self.axes.lines: - self.axes.lines.remove(self.line) - - # zorder of 4 is higher than all MPL defaults, excluding legend. - if "zorder" not in kwargs: - kwargs["zorder"] = 4 + self.data = np.asarray(list(data)) + super(Profile, self).__init__(axes) + self._barbs = None + self._highlight = None + + def has_highlight(self): + return self._highlight is not None + + def highlight(self, state=None): + if state is None: + state = not self.has_highlight() + if state: + if self._highlight is None: + linewidth = self.line.get_linewidth() * 7 + zorder = default.get("isopleth_zorder", 10) - 1 + kwargs = dict( + linewidth=linewidth, + color="grey", + alpha=0.3, + transform=self._transform, + zorder=zorder, + ) + (self._highlight,) = Subplot.plot( + self.axes, + self.points.temperature, + self.points.theta, + **kwargs, + ) + else: + if self._highlight is not None: + self.axes.lines.remove(self._highlight) + self._highlight = None - (self.line,) = self.axes.plot( - self.temperature, self.theta, transform=self._transform, **kwargs - ) - return self.line + def _generate_points(self): + if self.data.ndim != 2 or self.data.shape[-1] != 2: + msg = ( + "The profile data requires to be a sequence " + "of pressure, temperature value pairs." + ) + raise ValueError(msg) - def refresh(self): - """Refresh the plot with the profile and any associated barbs.""" - self._barbs.refresh() + pressure = self.data[:, 0] + temperature = self.data[:, 1] + _, theta = transforms.convert_pT2Tt(pressure, temperature) + return POINTS(temperature, theta, pressure) def barbs(self, barbs, **kwargs): """ @@ -473,10 +451,85 @@ def barbs(self, barbs, **kwargs): Kwargs: + * kwargs: See :func:`matplotlib.pyplot.barbs` """ colors = ["color", "barbcolor", "edgecolor", "facecolor"] if not set(colors).intersection(kwargs): kwargs["color"] = self.line.get_color() - self._barbs.plot(barbs, **kwargs) + self._barbs = BarbArtist(barbs, **kwargs) + self.axes.add_artist(self._barbs) + + def get_barbs(self): + return self._barbs.barbs + + +class WetAdiabat(Isopleth): + def __init__(self, axes, theta_e, min_temperature, max_pressure): + self.data = theta_e + self.bounds = BOUNDS(min_temperature, max_pressure) + self._delta_pressure = _SATURATION_ADIABAT_PRESSURE_DELTA + super(WetAdiabat, self).__init__(axes) + + def _gradient(self, pressure, temperature, dp): + stop = False + + kelvin = temperature + constants.KELVIN + lsbc = (constants.L / constants.Rv) * ( + (1.0 / constants.KELVIN) - (1.0 / kelvin) + ) + rw = 6.11 * np.exp(lsbc) * (constants.E / pressure) + lrwbt = (constants.L * rw) / (constants.Rd * kelvin) + numerator = ((constants.Rd * kelvin) / (constants.Cp * pressure)) * ( + 1.0 + lrwbt + ) + denominator = 1.0 + ( + lrwbt * ((constants.E * constants.L) / (constants.Cp * kelvin)) + ) + grad = numerator / denominator + dt = dp * grad + + if (temperature + dt) < self.bounds.lower: + dt = self.bounds.lower - temperature + dp = dt / grad + stop = True + + return dp, dt, stop + + def _generate_points(self): + temperature = [self.data] + pressure = [self.bounds.upper] + stop = False + dp = self._delta_pressure + + while not stop: + dp, dT, stop = self._gradient(pressure[-1], temperature[-1], dp) + pressure.append(pressure[-1] + dp) + temperature.append(temperature[-1] + dT) + + _, theta = transforms.convert_pT2Tt(pressure, temperature) + return POINTS(temperature, theta, pressure) + + +class ProfileList(list): + def __new__(cls, profiles=None): + profile_list = list.__new__(cls, profiles) + if not all(isinstance(profile, Profile) for profile in profile_list): + msg = "All items in the list must be a Profile instance." + raise TypeError(msg) + return profile_list + + def highlighted(self): + profiles = [profile for profile in self if profile.has_highlight()] + return profiles + + def picker(self, artist): + result = None + for profile in self: + if profile.line == artist: + result = profile + break + if result is None: + raise ValueError("Picker cannot find the profile.") + return result diff --git a/tephi/tests/results/imagerepo.json b/tephi/tests/results/imagerepo.json index b039ed0..f42efa6 100644 --- a/tephi/tests/results/imagerepo.json +++ b/tephi/tests/results/imagerepo.json @@ -1,36 +1,82 @@ { + "test_tephigram.TestSubplots.test_subplot.0": [ + "https://scitools.github.io/test-tephi-imagehash/images/d9aaa4f6a2555b4a1cfee1a14b429a16f035254b875e5abc2de1cb43ea1ca5e1.png" + ], + "test_tephigram.TestTephigramAxes.test_add_humidity_mixing_ratios.0": [ + "https://scitools.github.io/test-tephi-imagehash/images/bfa8907fc0574f801faab87de1c00783941f787c7be08787c41e7878b8e12b87.png" + ], + "test_tephigram.TestTephigramAxes.test_add_isobars.0": [ + "https://scitools.github.io/test-tephi-imagehash/images/bea0907fc15f6f803eaa907de1c00783943f78787be08787c41e7c78b8e12b87.png" + ], + "test_tephigram.TestTephigramAxes.test_add_wet_adiabats.0": [ + "https://scitools.github.io/test-tephi-imagehash/images/bfa8907fc0574f801eaab87de1d00783941f78787be08787c41e7c78b8e12b87.png" + ], + "test_tephigram.TestTephigramAxes.test_plot_dews_locator_adiabat_numeric.0": [ + "https://scitools.github.io/test-tephi-imagehash/images/bc5ace0f91e46631cde5398c96589cc6cb3462619e9b3f139c6649896e733326.png" + ], + "test_tephigram.TestTephigramAxes.test_plot_dews_locator_adiabat_object.0": [ + "https://scitools.github.io/test-tephi-imagehash/images/ea5a9f0d91e32639c5a5198d96729cc6cbb462619f9e3f1398646d093c736326.png", + "https://scitools.github.io/test-tephi-imagehash/images/ea5a9f0d91e46639c5a5398996759cc6cbb462619f8b3f1298646bcc3c736216.png" + ], + "test_tephigram.TestTephigramAxes.test_plot_dews_locator_isotherm_numeric.0": [ + "https://scitools.github.io/test-tephi-imagehash/images/ea5a8f6d91e16630c5ed392c86599cc6cbb46a6196986a929d66ed893e23343e.png" + ], + "test_tephigram.TestTephigramAxes.test_plot_dews_locator_isotherm_object.0": [ + "https://scitools.github.io/test-tephi-imagehash/images/ea5acf6c91e16630c5ed392c96599cc6cb947e6796986a9298662d893473e1e4.png", + "https://scitools.github.io/test-tephi-imagehash/images/ea5a8f2d91e16630c5ed392c96599cc6cb946a6196986a929d66ed893e23363e.png" + ], + "test_tephigram.TestTephigramAxes.test_plot_dews_locator_numeric.0": [ + "https://scitools.github.io/test-tephi-imagehash/images/bc0ece6f91e46630cde5398cd6589cc6cbbc6261929837b39c6649893e13363e.png" + ], + "test_tephigram.TestTephigramAxes.test_plot_dews_locator_object.0": [ + "https://scitools.github.io/test-tephi-imagehash/images/ea5a8f2d91e36639c5e5198d86521cc7cb946e639adc273398646d09347961f6.png", + "https://scitools.github.io/test-tephi-imagehash/images/ea5a8f4d91e46639c5a5398996751cd6cbbc6a61929863b29c664bc93e137616.png" + ], + "test_tephigram.TestTephigramAxes.test_plot_xylim.0": [ + "https://scitools.github.io/test-tephi-imagehash/images/bfe8e15ee0451c03911a63e0063a6c6e79c09b9be335f073944f2ecc55f87ca3.png" + ], "test_tephigram.TestTephigramBarbs.test_barbs.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/e9259e5b92db6d249e9a3386c65969c7c330964f0c9c69233c646e19e5b3c786.png" + "https://scitools.github.io/test-tephi-imagehash/images/e9259e5b92db6d249e9a3386c65969c7c330964f0c9c69233c646e19e5b3c786.png", + "https://scitools.github.io/test-tephi-imagehash/images/e92d96da92d36d25849a938ec64969c7cb30964f8c9c6d339c643e19e1a3e786.png" ], "test_tephigram.TestTephigramBarbs.test_barbs_from_file.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/e96a9f3c92c36639c4a439ac96599cd6c3346261979d7a124966cd8d3c73b686.png" + "https://scitools.github.io/test-tephi-imagehash/images/e96a9f3c92c36639c4a439ac96599cd6c3346261979d7a124966cd8d3c73b686.png", + "https://scitools.github.io/test-tephi-imagehash/images/e95e8f2690d36639c4a4392c96599cc6cbb662619f9d5a12d9666d8d2c733686.png" ], "test_tephigram.TestTephigramBarbs.test_color.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/e92596db92db6d249e9a3386c64969c7c331964f0c9c69233c646e19e5b3c786.png" + "https://scitools.github.io/test-tephi-imagehash/images/e92596db92db6d249e9a3386c64969c7c331964f0c9c69233c646e19e5b3c786.png", + "https://scitools.github.io/test-tephi-imagehash/images/e92d96da92d36d25949a938ec64969c7cb30964f8c9c6d339c643a19e1a3e786.png" ], "test_tephigram.TestTephigramBarbs.test_gutter.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/e46499999b9b6666c999338ec65869c7c330d8cf34dc69233c246e19e5b3c786.png" + "https://scitools.github.io/test-tephi-imagehash/images/e46499999b9b6666c999338ec65869c7c330d8cf34dc69233c246e19e5b3c786.png", + "https://scitools.github.io/test-tephi-imagehash/images/e46c999991936666cd9b9b0ec65a69c7c33098cf96dc6d3398243e19e1a3a786.png" ], "test_tephigram.TestTephigramBarbs.test_length.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/b1ccce31ce73318ece119363929c69c7c330ce6d1cde69983c646e19e5b39586.png" + "https://scitools.github.io/test-tephi-imagehash/images/b1ccce31ce73318ece119363929c69c7c330ce6d1cde69983c646e19e5b39586.png", + "https://scitools.github.io/test-tephi-imagehash/images/b98cce71c673318ccc919a63921c79c7c338ce659cde69989c643e19e1e3b386.png" ], "test_tephigram.TestTephigramBarbs.test_pivot.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/edb09a6992cb65b69bcd334cc65a6dc6cf31924d3c9a49333c2c6319c5e72494.png" + "https://scitools.github.io/test-tephi-imagehash/images/edb09a6992cb65b69bcd334cc65a6dc6cf31924d3c9a49333c2c6319c5e72494.png", + "https://scitools.github.io/test-tephi-imagehash/images/edb0926992cb6db69bcd934cc65668c7cf30924d8c986d339c6c6199b1e72694.png" ], "test_tephigram.TestTephigramBarbs.test_rotate.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/e9259e5992cf64b69e9b3324865a69c3c334964f1c9c69333c646c19e5b3c786.png" + "https://scitools.github.io/test-tephi-imagehash/images/e9259e5992cf64b69e9b3324865a69c3c334964f1c9c69333c646c19e5b3c786.png", + "https://scitools.github.io/test-tephi-imagehash/images/e9399e5992c76436cc9b9b2cc65a69c3cb34964f8c986d339c642819b1e3e786.png" ], "test_tephigram.TestTephigramPlot.test_plot_anchor.0": [ "https://scitools.github.io/test-tephi-imagehash/images/fba8c82d8a55b03da4dd2c2f899faf22827f03cad48a3ab0ba256f9c6a2970cb.png" ], "test_tephigram.TestTephigramPlot.test_plot_dews.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/e85a9f2d90c56630cce539ac96599ce6c734626197997a924966cd8dbc733686.png" + "https://scitools.github.io/test-tephi-imagehash/images/e85a9f2d90c56630cce539ac96599ce6c734626197997a924966cd8dbc733686.png", + "https://scitools.github.io/test-tephi-imagehash/images/ea5a8f2d91e16631c5ad392c96799ce6c9b46261979b5a9298662d893c7327a6.png" ], "test_tephigram.TestTephigramPlot.test_plot_dews_custom.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/e85a986d99e66631c66d698e971999a6c7966261979b7a9269668c893c7332a6.png" + "https://scitools.github.io/test-tephi-imagehash/images/e85a986d99e66631c66d698e971999a6c7966261979b7a9269668c893c7332a6.png", + "https://scitools.github.io/test-tephi-imagehash/images/e85b996d99a66631c66d398c921999e6cd967261979a5a9249666d896c736726.png" ], "test_tephigram.TestTephigramPlot.test_plot_dews_label.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/e85a992d90c56630cced39afc6198cf6c734e26197997a9269669d8d387330e6.png" + "https://scitools.github.io/test-tephi-imagehash/images/e85a992d90c56630cced39afc6198cf6c734e26197997a9269669d8d387330e6.png", + "https://scitools.github.io/test-tephi-imagehash/images/e85acd2d91e16631c5ed992c965198e6c3b4f261979b5a9249666d896c7365a6.png" ], "test_tephigram.TestTephigramPlot.test_plot_dews_locator_adiabat_numeric.0": [ "https://scitools.github.io/test-tephi-imagehash/images/e85a9e0c91a3663ccda5398c965a9cf6cb3462619b9c3f134c66cd099e737346.png", @@ -57,18 +103,24 @@ "https://scitools.github.io/test-tephi-imagehash/images/e85a9f0991e76639c5a53989965d1cd6cf346a63939867b30c664bcc1e1b7216.png" ], "test_tephigram.TestTephigramPlot.test_plot_dews_temps.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/e969cc3992c76726cd973326c65869c6c3319e4d9c9849333c2c6399e5a7a7a4.png" + "https://scitools.github.io/test-tephi-imagehash/images/e969cc3992c76726cd973326c65869c6c3319e4d9c9849333c2c6399e5a7a7a4.png", + "https://scitools.github.io/test-tephi-imagehash/images/e978cc3492c76637cd87932cc65a69c6c3313e4f9c9869331c6c6099e1e73794.png" ], "test_tephigram.TestTephigramPlot.test_plot_dews_temps_custom.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/f1289c9996c76726cd9d333cc658cc66c731ce4c3cde499934247199b1e73326.png" + "https://scitools.github.io/test-tephi-imagehash/images/f1289c9996c76726cd9d333cc658cc66c731ce4c3cde499934247199b1e73326.png", + "https://scitools.github.io/test-tephi-imagehash/images/f338cc99c4e76627c98db32c925a98679331964c9c9e4d9924646199e5e76736.png" ], "test_tephigram.TestTephigramPlot.test_plot_temps.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/e9698e1892c76636cd9a3386c65a69c3c330de4f9c9869333c646e19e5b3c786.png" + "https://scitools.github.io/test-tephi-imagehash/images/e9698e1892c76636cd9a3386c65a69c3c330de4f9c9869333c646e19e5b3c786.png", + "https://scitools.github.io/test-tephi-imagehash/images/e969cc3c92c76636cd96930ec65a69c3c330664f9c986d339c643e19e1a3e786.png" ], "test_tephigram.TestTephigramPlot.test_plot_temps_custom.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/e161999996c76677c998330ec65a4c63c731ce4c8cde4d9b3c24399961b3b3a6.png" + "https://scitools.github.io/test-tephi-imagehash/images/e161999996c76677c998330ec65a4c63c731ce4c8cde4d9b3c24399961b3b3a6.png", + "https://scitools.github.io/test-tephi-imagehash/images/e969cc9884c76673c99c930e965a996393319a4c9cdc6d933c646c99e5a367a6.png" ], "test_tephigram.TestTephigramPlot.test_plot_temps_label.0": [ - "https://scitools.github.io/test-tephi-imagehash/images/e9699f1992c76636cd9e3326c65a6cc3c730ce6f9c9869333824321961b3b786.png" + "https://scitools.github.io/test-tephi-imagehash/images/e9699f1992c76636cd9e3326c65a6cc3c730ce6f9c9869333824321961b3b786.png", + "https://scitools.github.io/test-tephi-imagehash/images/e9699e1992cf6636cd9a3326c65a6cc3c730ce6f9c98693338243e1961b3b586.png", + "https://scitools.github.io/test-tephi-imagehash/images/e969ccbc96c76637cd96932e965a19c39330924f9c986d331c646c9965a36786.png" ] } \ No newline at end of file diff --git a/tephi/tests/test_tephigram.py b/tephi/tests/test_tephigram.py index bd4ce2a..3d1dd7b 100644 --- a/tephi/tests/test_tephigram.py +++ b/tephi/tests/test_tephigram.py @@ -6,6 +6,8 @@ Tests the tephigram plotting capability provided by tephi. """ +import matplotlib + # Import tephi test package first so that some things can be initialised # before importing anything else. import tephi.tests as tests @@ -14,7 +16,7 @@ import pytest import tephi -from tephi import Tephigram +from tephi import TephiAxes def _load_result(filename): @@ -27,10 +29,12 @@ def _load_result(filename): _expected_temps = _load_result("temps.npz") _expected_barbs = _load_result("barbs.npz") +# make the default size for this session 8x8in +matplotlib.rcParams['figure.figsize'] = (8, 8) class TestTephigramLoadTxt(tests.TephiTest): @pytest.fixture(autouse=True) - def setup(self): + def _setup(self): self.filename_dews = tephi.tests.get_data_path("dews.txt") self.filename_temps = tephi.tests.get_data_path("temps.txt") self.filename_barbs = tephi.tests.get_data_path("barbs.txt") @@ -98,44 +102,39 @@ def test_dtype(self): assert dews.pressure[0].dtype == np.int32 assert dews.temperature[0].dtype == np.int32 - @pytest.mark.graphical @pytest.mark.usefixtures("close_plot", "nodeid") class TestTephigramPlot(tests.GraphicsTest): @pytest.fixture(autouse=True) - def setup(self): + def _setup(self): self.dews = _expected_dews.T self.temps = _expected_temps.T + self.tephigram = TephiAxes() + def test_plot_dews(self, nodeid): - tephigram = Tephigram() - tephigram.plot(self.dews) + self.tephigram.plot(self.dews) self.check_graphic(nodeid) def test_plot_temps(self, nodeid): - tephigram = Tephigram() - tephigram.plot(self.temps) + self.tephigram.plot(self.temps) self.check_graphic(nodeid) def test_plot_dews_temps(self, nodeid): - tephigram = Tephigram() - tephigram.plot(self.dews) - tephigram.plot(self.temps) + self.tephigram.plot(self.dews) + self.tephigram.plot(self.temps) self.check_graphic(nodeid) def test_plot_dews_label(self, nodeid): - tephigram = Tephigram() - tephigram.plot(self.dews, label="Dew-point temperature") + self.tephigram.plot(self.dews, label="Dew-point temperature") self.check_graphic(nodeid) def test_plot_temps_label(self, nodeid): - tephigram = Tephigram() - tephigram.plot(self.temps, label="Dry-bulb temperature") + self.tephigram.plot(self.temps, label="Dry-bulb temperature") self.check_graphic(nodeid) def test_plot_dews_custom(self, nodeid): - tephigram = Tephigram() - tephigram.plot( + self.tephigram.plot( self.dews, label="Dew-point temperature", linewidth=2, @@ -145,8 +144,7 @@ def test_plot_dews_custom(self, nodeid): self.check_graphic(nodeid) def test_plot_temps_custom(self, nodeid): - tephigram = Tephigram() - tephigram.plot( + self.tephigram.plot( self.temps, label="Dry-bulb temperature", linewidth=2, @@ -156,15 +154,14 @@ def test_plot_temps_custom(self, nodeid): self.check_graphic(nodeid) def test_plot_dews_temps_custom(self, nodeid): - tephigram = Tephigram() - tephigram.plot( + self.tephigram.plot( self.dews, label="Dew-point temperature", linewidth=2, color="blue", marker="s", ) - tephigram.plot( + self.tephigram.plot( self.temps, label="Dry-bulb temperature", linewidth=2, @@ -173,58 +170,86 @@ def test_plot_dews_temps_custom(self, nodeid): ) self.check_graphic(nodeid) +@pytest.mark.graphical +@pytest.mark.usefixtures("close_plot", "nodeid") +class TestTephigramAxes(tests.GraphicsTest): + @pytest.fixture(autouse=True) + def _setup(self): + self.dews = _expected_dews.T + self.temps = _expected_temps.T + def test_plot_dews_locator_isotherm_numeric(self, nodeid): - tephigram = Tephigram(isotherm_locator=10) + tephigram = TephiAxes(isotherm_locator=30) tephigram.plot(self.dews) self.check_graphic(nodeid) def test_plot_dews_locator_isotherm_object(self, nodeid): - tephigram = Tephigram(isotherm_locator=tephi.Locator(10)) + tephigram = TephiAxes(isotherm_locator=tephi.Locator(10)) tephigram.plot(self.dews) self.check_graphic(nodeid) def test_plot_dews_locator_adiabat_numeric(self, nodeid): - tephigram = Tephigram(dry_adiabat_locator=10) + tephigram = TephiAxes(dry_adiabat_locator=10) tephigram.plot(self.dews) self.check_graphic(nodeid) def test_plot_dews_locator_adiabat_object(self, nodeid): - tephigram = Tephigram(dry_adiabat_locator=tephi.Locator(10)) + tephigram = TephiAxes(dry_adiabat_locator=tephi.Locator(10)) tephigram.plot(self.dews) self.check_graphic(nodeid) def test_plot_dews_locator_numeric(self, nodeid): - tephigram = Tephigram(isotherm_locator=10, dry_adiabat_locator=10) + tephigram = TephiAxes(isotherm_locator=10, dry_adiabat_locator=10) tephigram.plot(self.dews) self.check_graphic(nodeid) def test_plot_dews_locator_object(self, nodeid): locator = tephi.Locator(10) - tephigram = Tephigram( + tephigram = TephiAxes( isotherm_locator=locator, dry_adiabat_locator=locator ) tephigram.plot(self.dews) self.check_graphic(nodeid) - def test_plot_anchor(self, nodeid): - tephigram = Tephigram(anchor=[(1000, 0), (300, 0)]) + def test_plot_xylim(self, nodeid): + tephigram = TephiAxes(xylim=[(0, 0), (40, 200)]) tephigram.plot(self.dews) self.check_graphic(nodeid) + def test_add_wet_adiabats(self, nodeid): + # the xylim is needed so that the isopleths actually appear + tephigram = TephiAxes(xylim=[(0, 0), (40, 70)]) + + tephigram.add_wet_adiabats() + self.check_graphic(nodeid) + + def test_add_humidity_mixing_ratios(self, nodeid): + # the xylim is needed so that the isopleths actually appear + tephigram = TephiAxes(xylim=[(0, 0), (40, 70)]) + + tephigram.add_mixing_ratios() + self.check_graphic(nodeid) + + def test_add_isobars(self, nodeid): + # the xylim is needed so that the isopleths actually appear + tephigram = TephiAxes(xylim=[(0, 0), (40, 70)]) + + tephigram.add_isobars() + self.check_graphic(nodeid) @pytest.mark.graphical @pytest.mark.usefixtures("close_plot", "nodeid") class TestTephigramBarbs(tests.GraphicsTest): @pytest.fixture(autouse=True) - def setup(self): + def _setup(self): self.dews = _expected_dews.T self.temps = _expected_temps.T magnitude = np.hstack(([0], np.arange(20) * 5 + 2, [102])) self.barbs = [(m, 45, 1000 - i * 35) for i, m in enumerate(magnitude)] + self.tephigram = TephiAxes() def test_rotate(self, nodeid): - tephigram = Tephigram() - profile = tephigram.plot(self.temps) + profile = self.tephigram.plot(self.temps) profile.barbs( [ (0, 0, 900), @@ -246,43 +271,53 @@ def test_rotate(self, nodeid): self.check_graphic(nodeid) def test_barbs(self, nodeid): - tephigram = Tephigram() - profile = tephigram.plot(self.temps) + profile = self.tephigram.plot(self.temps) profile.barbs(self.barbs, zorder=10) self.check_graphic(nodeid) def test_barbs_from_file(self, nodeid): - tephigram = Tephigram() dews = _expected_barbs.T[:, :2] barbs = np.column_stack( (_expected_barbs[2], _expected_barbs[3], _expected_barbs[0]) ) - profile = tephigram.plot(dews) - profile.barbs(barbs, zorder=10) + profile = self.tephigram.plot(dews) + profile.barbs(barbs, zorder=200) self.check_graphic(nodeid) def test_gutter(self, nodeid): - tephigram = Tephigram() - profile = tephigram.plot(self.temps) + profile = self.tephigram.plot(self.temps) profile.barbs(self.barbs, gutter=0.5, zorder=10) self.check_graphic(nodeid) def test_length(self, nodeid): - tephigram = Tephigram() - profile = tephigram.plot(self.temps) + profile = self.tephigram.plot(self.temps) profile.barbs(self.barbs, gutter=0.9, length=10, zorder=10) self.check_graphic(nodeid) def test_color(self, nodeid): - tephigram = Tephigram() - profile = tephigram.plot(self.temps) + profile = self.tephigram.plot(self.temps) profile.barbs(self.barbs, color="green", zorder=10) self.check_graphic(nodeid) def test_pivot(self, nodeid): - tephigram = Tephigram() - tprofile = tephigram.plot(self.temps) + tprofile = self.tephigram.plot(self.temps) tprofile.barbs(self.barbs, gutter=0.2, pivot="tip", length=8) - dprofile = tephigram.plot(self.dews) + dprofile = self.tephigram.plot(self.dews) dprofile.barbs(self.barbs, gutter=0.3, pivot="middle", length=8) self.check_graphic(nodeid) + +class TestSubplots(tests.GraphicsTest): + @pytest.fixture(autouse=True) + def _setup(self): + self.dews = _expected_dews.T + self.temps = _expected_temps.T + + def test_subplot(self, nodeid): + tephi_one = TephiAxes(133) + tephi_two = TephiAxes((1,3,1)) + + tephi_one.plot(self.temps) + tephi_one.plot(self.dews) + tephi_two.plot(self.dews) + + self.check_graphic(nodeid) diff --git a/tephi/transforms.py b/tephi/transforms.py index a904cf8..7183e66 100644 --- a/tephi/transforms.py +++ b/tephi/transforms.py @@ -6,15 +6,11 @@ Tephigram transform support. """ + from matplotlib.transforms import Transform import numpy as np -from ._constants import CONST_K, CONST_KELVIN, CONST_L, CONST_MA, CONST_RV - - -# -# Reference: http://www-nwp/~hadaa/tephigram/tephi_plot.html -# +import tephi.constants as constants def convert_Tt2pT(temperature, theta): @@ -37,12 +33,12 @@ def convert_Tt2pT(temperature, theta): temperature, theta = np.asarray(temperature), np.asarray(theta) # Convert temperature and theta from degC to kelvin. - kelvin = temperature + CONST_KELVIN - theta = theta + CONST_KELVIN + kelvin = temperature + constants.KELVIN + theta = theta + constants.KELVIN # Calculate the associated pressure given the temperature and # potential temperature. - pressure = 1000.0 * np.power(kelvin / theta, 1 / CONST_K) + pressure = constants.P_BASE * np.power(kelvin / theta, 1 / constants.K) return pressure, temperature @@ -67,13 +63,13 @@ def convert_pT2Tt(pressure, temperature): pressure, temperature = np.asarray(pressure), np.asarray(temperature) # Convert temperature from degC to kelvin. - kelvin = temperature + CONST_KELVIN + kelvin = temperature + constants.KELVIN # Calculate the potential temperature given the pressure and temperature. - theta = kelvin * ((1000.0 / pressure) ** CONST_K) + theta = kelvin * ((constants.P_BASE / pressure) ** constants.K) # Convert potential temperature from kelvin to degC. - return temperature, theta - CONST_KELVIN + return temperature, theta - constants.KELVIN def convert_pt2pT(pressure, theta): @@ -95,14 +91,14 @@ def convert_pt2pT(pressure, theta): pressure, theta = np.asarray(pressure), np.asarray(theta) # Convert potential temperature from degC to kelvin. - theta = theta + CONST_KELVIN + theta = theta + constants.KELVIN - # Calculate the temperature given the pressure and - # potential temperature. - kelvin = theta * (pressure**CONST_K) / (1000.0**CONST_K) + # Calculate the temperature given the pressure and potential temperature. + denom = constants.P_BASE**constants.K + kelvin = theta * (pressure**constants.K) / denom # Convert temperature from kelvin to degC. - return pressure, kelvin - CONST_KELVIN + return pressure, kelvin - constants.KELVIN def convert_Tt2xy(temperature, theta): @@ -125,13 +121,13 @@ def convert_Tt2xy(temperature, theta): temperature, theta = np.asarray(temperature), np.asarray(theta) # Convert potential temperature from degC to kelvin. - theta = theta + CONST_KELVIN + theta = theta + constants.KELVIN theta = np.clip(theta, 1, 1e10) phi = np.log(theta) - x_data = phi * CONST_MA + temperature - y_data = phi * CONST_MA - temperature + x_data = phi * constants.MA + temperature + y_data = phi * constants.MA - temperature return x_data, y_data @@ -155,10 +151,10 @@ def convert_xy2Tt(x_data, y_data): """ x_data, y_data = np.asarray(x_data), np.asarray(y_data) - phi = (x_data + y_data) / (2 * CONST_MA) + phi = (x_data + y_data) / (2 * constants.MA) temperature = (x_data - y_data) / 2.0 - theta = np.exp(phi) - CONST_KELVIN + theta = np.exp(phi) - constants.KELVIN return temperature, theta @@ -173,21 +169,22 @@ def convert_pw2T(pressure, mixing_ratio): Pressure in mb in hPa. * mixing_ratio: - Dimensionless mixing ratios. + Mixing ratio in g kg-1. Returns: Temperature in degC. """ - pressure = np.array(pressure) + pressure = np.asarray(pressure) # Calculate the dew-point. - vapp = pressure * (8.0 / 5.0) * (mixing_ratio / 1000.0) + vapp = pressure * (8.0 / 5.0) * (mixing_ratio / constants.P_BASE) temp = 1.0 / ( - (1.0 / CONST_KELVIN) - ((CONST_RV / CONST_L) * np.log(vapp / 6.11)) + (1.0 / constants.KELVIN) + - ((constants.Rv / constants.L) * np.log(vapp / 6.11)) ) - return temp - CONST_KELVIN + return temp - constants.KELVIN class TephiTransform(Transform): @@ -214,7 +211,7 @@ def transform_non_affine(self, values): """ return np.concatenate( - convert_Tt2xy(values[:, 0:1], values[:, 1:2]), axis=1 + convert_Tt2xy(values[:, 0:1], values[:, 1:2]), axis=-1 ) def inverted(self): @@ -247,7 +244,7 @@ def transform_non_affine(self, values): """ return np.concatenate( - convert_xy2Tt(values[:, 0:1], values[:, 1:2]), axis=1 + convert_xy2Tt(values[:, 0:1], values[:, 1:2]), axis=-1 ) def inverted(self): diff --git a/tox.ini b/tox.ini index 20d36bf..d096e2a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,9 @@ [tox] requires = tox-conda -minversion = 3.15 -base_python = py311 -envlist=py{38,39,310,311} +isolated_build = True -[testenv] +[testenv:py{310,311}-test] description = invoke pytest to run automated tests deps = pytest @@ -21,7 +19,7 @@ commands = description = invoke sphinx-build to build the docs/run the doctests setenv = - DOCSDIR = {toxinidir}/docs/tephi/source + DOCSDIR = {toxinidir}/docs/source BUILDDIR = {toxinidir}/docs/_build conda_env = requirements/rtd.yml commands =