diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml index 23465135f..02100ee4e 100644 --- a/.github/workflows/documentation.yaml +++ b/.github/workflows/documentation.yaml @@ -18,8 +18,8 @@ jobs: name: Build Documentation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: '3.10' - name: Install dependencies diff --git a/.github/workflows/sonarqube.yaml b/.github/workflows/sonarqube.yaml index 2428e517e..0731b8d20 100644 --- a/.github/workflows/sonarqube.yaml +++ b/.github/workflows/sonarqube.yaml @@ -43,13 +43,13 @@ jobs: steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: # Disable shallow clones for better analysis fetch-depth: 0 - name: Set up Python 3.12 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index 5d0c04f5c..c3c6d17ec 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -1,6 +1,7 @@ # This workflow will install Python dependencies, run tests the specified Python version # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions -name: Python 3.12 tests +name: Python Tests +run-name: ${{ github.event_name == 'workflow_dispatch' && format('{0} vs. {1}', github.ref_name, github.event.inputs.version_to_compare) || github.event.head_commit.message || github.event.pull_request.title }} on: push: @@ -17,57 +18,159 @@ on: - '.github/ISSUE_TEMPLATE/**' - '**/README.md' - '**/LICENSE.md' - + pull_request: types: [opened, reopened, synchronize] - + workflow_dispatch: + inputs: + version_to_compare: + description: 'Name of branch to diff against current branch (default: develop)' + default: 'develop' jobs: + # New job to determine which branches to test + setup-matrix: + runs-on: ubuntu-latest + outputs: + branches: ${{ steps.set-branches.outputs.branches }} + python-versions: ${{ steps.set-py-versions.outputs.versions }} + steps: + - id: set-py-versions + run: echo "versions=[\"3.12\", \"3.14\"]" >> $GITHUB_OUTPUT + - id: set-branches + run: | + if [ "${{ github.event_name }}" == "pull_request" ]; then + echo "branches=[\"${{ github.head_ref }}\", \"${{ github.base_ref }}\"]" >> $GITHUB_OUTPUT + elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "branches=[\"${{ github.ref_name }}\", \"${{ github.event.inputs.version_to_compare }}\"]" >> $GITHUB_OUTPUT + else + echo "branches=[\"${{ github.ref_name }}\"]" >> $GITHUB_OUTPUT + fi + build: + needs: setup-matrix runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["3.12"] + python-version: ${{ fromJSON(needs.setup-matrix.outputs.python-versions) }} + branch: ${{ fromJSON(needs.setup-matrix.outputs.branches) }} steps: - - uses: actions/checkout@v4 + # checkout METplotpy + - uses: actions/checkout@v6 + with: + ref: ${{ matrix.branch }} + + # checkout METcalcpy + - uses: actions/checkout@v6 + with: + repository: dtcenter/METcalcpy + path: METcalcpy + ref: develop + - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - - name: Retrieve METcalcpy repository develop branch - run: | - metcalcpy_dir="$RUNNER_WORKSPACE/METcalcpy" - git clone https://github.com/dtcenter/METcalcpy ${metcalcpy_dir} - cd ${metcalcpy_dir} - git -C ${metcalcpy_dir} checkout develop - python -m pip install -e ${metcalcpy_dir} - + - name: Install dependencies run: | python -m pip install --upgrade pip + + # force pandas to be less than 3.0.0 for Python 3.12 to test for backward compatibility + if [ ${{ matrix.python-version }} == "3.12" ]; then pip install "pandas<3"; fi + + python -m pip install -e METcalcpy if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - pip install --upgrade kaleido - - -# - uses: browser-actions/setup-chrome@v1 -# - name: Install Google Chrome for Plotly -# run: | -# chrome --version -# ${{ steps.setup-chrome.outputs.chrome-path }} - - - -# Checking the branch name, not necessary but useful when setting things up. -# - name: Extract branch name -# shell: bash -# run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" -# id: extract_branch + + - name: Download additional input data + run: | + mkdir -p ${{ runner.workspace }}/test_input + wget -P ${{ runner.workspace }}/test_input \ + https://dtcenter.ucar.edu/dfiles/code/METplus/METplotpy_Data/develop/sample_data_METplotpy.tgz + tar -xzf ${{ runner.workspace }}/test_input/sample_data_METplotpy.tgz -C ${{ runner.workspace }}/test_input - name: Test with pytest run: | + export METPLOTPY_TEST_OUTPUT=${{ runner.workspace }}/output/${{ matrix.branch }} + export METPLOTPY_TEST_INPUT=${{ runner.workspace }}/test_input/METplotpy_Data pytest + + - name: Upload output data artifact + uses: actions/upload-artifact@v6 + with: + name: test_output_${{ matrix.branch }}_${{ matrix.python-version }} + path: ${{ runner.workspace }}/output/${{ matrix.branch }}/test_output + + compare-output: + needs: [setup-matrix, build] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ${{ fromJSON(needs.setup-matrix.outputs.python-versions) }} + + # Only run if there are exactly 2 branches to compare + if: fromJSON(needs.setup-matrix.outputs.branches)[1] != null + steps: + - name: Download artifacts + run: | + mkdir -p "${{ runner.workspace }}/artifacts" + gh run download --repo "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" --dir "${{ runner.workspace }}/artifacts" + env: + GH_TOKEN: ${{ github.token }} + + - name: Checkout diff_util.py from METplus + uses: actions/checkout@v6 + with: + repository: dtcenter/METplus + ref: develop + sparse-checkout: | + metplus/util/diff_util.py + sparse-checkout-cone-mode: false + path: METplus + + - name: Set up Python for diff_util.py + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install dependencies for diff + run: | + pip install pillow pandas numpy netCDF4 + + - name: Run diff_util.py + run: | + # Extract branch names from the setup-matrix output + # assumes 1st branch is the current branch and the 2nd branch is the truth data + OUTPUT_BRANCH=$(echo '${{ needs.setup-matrix.outputs.branches }}' | jq -r '.[0]') + TRUTH_BRANCH=$(echo '${{ needs.setup-matrix.outputs.branches }}' | jq -r '.[1]') + + # Define paths to the downloaded data + # Note: upload-artifact v4 nested paths differently, usually named by 'name' provided in upload + OUTPUT_DIR="${{ runner.workspace }}/artifacts/test_output_${OUTPUT_BRANCH}_${{ matrix.python-version }}" + TRUTH_DIR="${{ runner.workspace }}/artifacts/test_output_${TRUTH_BRANCH}_${{ matrix.python-version }}" + DIFF_DIR="${{ runner.workspace }}/diff_${{ matrix.python-version }}" + + echo "Comparing $OUTPUT_DIR and $TRUTH_DIR" + + # Ensure directories exist before running script + if [ -d "$OUTPUT_DIR" ] && [ -d "$TRUTH_DIR" ]; then + export METPLUS_DIFF_SKIP_KEYWORDS=".html,_rank.png" + python METplus/metplus/util/diff_util.py "$TRUTH_DIR" "$OUTPUT_DIR" --debug --save_diff --diff_dir "$DIFF_DIR" + else + echo "One or both data directories are missing." + ls ${{ runner.workspace }}/artifacts + exit 1 + fi + + - name: Upload diff artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: diff_${{ matrix.python-version }} + path: ${{ runner.workspace }}/diff_${{ matrix.python-version }} + if-no-files-found: ignore diff --git a/.github/workflows/vulnerabilities.yaml b/.github/workflows/vulnerabilities.yaml index 826852b37..89e226c8e 100644 --- a/.github/workflows/vulnerabilities.yaml +++ b/.github/workflows/vulnerabilities.yaml @@ -22,7 +22,7 @@ jobs: selftest: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: install run: | python -m venv env/ diff --git a/.gitignore b/.gitignore index ad8168d41..42b4c42b1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ __pycache__/ # files that are generated when package is installed build/ metplotpy.egg-info/ + +.DS_Store diff --git a/docs/Users_Guide/histogram.rst b/docs/Users_Guide/histogram.rst index a820fbf3b..8f1d3c123 100644 --- a/docs/Users_Guide/histogram.rst +++ b/docs/Users_Guide/histogram.rst @@ -96,7 +96,7 @@ configuration file, which serves as a starting point for creating a will be built. **NOTE**: This default configuration file is automatically loaded by -**histogram.py, rank_hist.py, prob_hist.py, and rel_hist.py.** +**hist.py, rank_hist.py, prob_hist.py, and rel_hist.py.** .. literalinclude:: ../../metplotpy/plots/config/hist_defaults.yaml diff --git a/docs/Users_Guide/release-notes.rst b/docs/Users_Guide/release-notes.rst index 787730f09..1a2ca9340 100644 --- a/docs/Users_Guide/release-notes.rst +++ b/docs/Users_Guide/release-notes.rst @@ -10,7 +10,6 @@ describes the bugfix, enhancement, or new feature: METplotpy Version 4.0.0-beta1 release notes (20260205) ====================================================== - .. dropdown:: New Plots None diff --git a/docs/Users_Guide/reliability_diagram.rst b/docs/Users_Guide/reliability_diagram.rst index 4f15663ae..ee0865d86 100644 --- a/docs/Users_Guide/reliability_diagram.rst +++ b/docs/Users_Guide/reliability_diagram.rst @@ -160,7 +160,7 @@ corresponding to the *plot_filename* setting in the default configuration file. Otherwise, this will need to be specified in *plot_filename* in the **minimal_box.yaml** file): -.. literalinclude:: ../../test/reliability_diagram/custom_reliability_use_defaults.yaml +.. literalinclude:: ../../test/reliability_diagram/minimal_reliability.yaml Copy this file to the working directory: diff --git a/docs/copyright.txt b/docs/copyright.txt index 75eec2d44..19a595bdc 100644 --- a/docs/copyright.txt +++ b/docs/copyright.txt @@ -1,5 +1,5 @@ # ============================* -# ** Copyright UCAR (c) 1992 - 2024 +# ** Copyright UCAR (c) 1992 - 2026 # ** University Corporation for Atmospheric Research (UCAR) # ** National Center for Atmospheric Research (NCAR) # ** Research Applications Lab (RAL) diff --git a/internal/scripts/installation/modulefiles/3.1.0_derecho b/internal/scripts/installation/modulefiles/3.1.0_derecho deleted file mode 100644 index 8d2716459..000000000 --- a/internal/scripts/installation/modulefiles/3.1.0_derecho +++ /dev/null @@ -1,28 +0,0 @@ -#%Module###################################################################### -## -## METplotpy -## -proc ModulesHelp { } { - puts stderr "Sets up the paths and environment variables to use the METplotpy-3.1.0 - *** For help see the official MET webpage at -#http://www.dtcenter.org/met/users ***" -} - -module load ncarenv/23.09 -module load intel/2023.2.1 - -prepend-path PATH /glade/work/dtcrt/METplus/derecho/miniconda/miniconda3/envs/metplus_v5.1_py3.10/bin - -setenv METPLOTPY_SOURCE /glade/work/dtcrt/METplus/derecho/components/METplotpy/installations/METplotpy-3.1.0 -setenv METPLOTPY_BASE /glade/work/dtcrt/METplus/derecho/components/METplotpy/installations/METplotpy-3.1.0 - -prepend-path PATH /glade/work/dtcrt/METplus/derecho/components/METplotpy/installations/METplotpy-3.1.0/metplotpy/contributed -prepend-path PATH /glade/work/dtcrt/METplus/derecho/components/METplotpy/installations/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PATH /glade/work/dtcrt/METplus/derecho/components/METplotpy/installations/METplotpy-3.1.0/metplotpy/plots -prepend-path PATH /glade/work/dtcrt/METplus/derecho/components/METplotpy/installations/METplotpy-3.1.0/metplotpy -prepend-path PATH /glade/work/dtcrt/METplus/derecho/components/METplotpy/installations/METplotpy-3.1.0 -prepend-path PYTHONPATH /glade/work/dtcrt/METplus/derecho/components/METplotpy/installations/METplotpy-3.1.0/metplotpy/contributed -prepend-path PYTHONPATH /glade/work/dtcrt/METplus/derecho/components/METplotpy/installations/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PYTHONPATH /glade/work/dtcrt/METplus/derecho/components/METplotpy/installations/METplotpy-3.1.0/metplotpy/plots -prepend-path PYTHONPATH /glade/work/dtcrt/METplus/derecho/components/METplotpy/installations/METplotpy-3.1.0/metplotpy -prepend-path PYTHONPATH /glade/work/dtcrt/METplus/derecho/components/METplotpy/installations/METplotpy-3.1.0 diff --git a/internal/scripts/installation/modulefiles/3.1.0_gaea b/internal/scripts/installation/modulefiles/3.1.0_gaea deleted file mode 100644 index cc26d6537..000000000 --- a/internal/scripts/installation/modulefiles/3.1.0_gaea +++ /dev/null @@ -1,25 +0,0 @@ -#%Module###################################################################### -## -## METplotpy -## -proc ModulesHelp { } { - puts stderr "Sets up the paths and environment variables to use the METplotpy-3.1.0. - *** For help see the official MET webpage at http://www.dtcenter.org/met/users ***" -} - -module load intel-oneapi/intel/2023.2.0 - -setenv METPLOTPY_SOURCE /usw/met/METplotpy/METplotpy-3.1.0 -setenv METPLOTPY_BASE /usw/met/METplotpy/METplotpy-3.1.0 - -prepend-path PATH /ncrc/proj/nggps_psd/Julie.Prestopnik/projects/miniconda/miniconda3/envs/metplus_v5.1_py3.10/bin -prepend-path PATH /usw/met/METplotpy/METplotpy-3.1.0/metplotpy/contributed -prepend-path PATH /usw/met/METplotpy/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PATH /usw/met/METplotpy/METplotpy-3.1.0/metplotpy/plots -prepend-path PATH /usw/met/METplotpy/METplotpy-3.1.0/metplotpy -prepend-path PATH /usw/met/METplotpy/METplotpy-3.1.0 -prepend-path PYTHONPATH /usw/met/METplotpy/METplotpy-3.1.0/metplotpy/contributed -prepend-path PYTHONPATH /usw/met/METplotpy/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PYTHONPATH /usw/met/METplotpy/METplotpy-3.1.0/metplotpy/plots -prepend-path PYTHONPATH /usw/met/METplotpy/METplotpy-3.1.0/metplotpy -prepend-path PYTHONPATH /usw/met/METplotpy/METplotpy-3.1.0 diff --git a/internal/scripts/installation/modulefiles/3.1.0_hera b/internal/scripts/installation/modulefiles/3.1.0_hera deleted file mode 100644 index 07f1c960e..000000000 --- a/internal/scripts/installation/modulefiles/3.1.0_hera +++ /dev/null @@ -1,25 +0,0 @@ -#%Module###################################################################### -## -## METplotpy -## -proc ModulesHelp { } { - puts stderr "Sets up the paths and environment variables to use the METplotpy-3.1.0. - *** For help see the official MET webpage at http://www.dtcenter.org/met/users ***" -} - -prereq intel/2024.2.1 - -setenv METPLOTPY_SOURCE /contrib/METplotpy/METplotpy-3.1.0 -setenv METPLOTPY_BASE /contrib/METplotpy/METplotpy-3.1.0 - -prepend-path PATH /scratch1/BMC/dtc/miniconda/miniconda3/envs/metplus_v6.1_py3.12/bin -prepend-path PATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy/contributed -prepend-path PATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy/plots -prepend-path PATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy -prepend-path PATH /contrib/METplotpy/METplotpy-3.1.0 -prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy/contributed -prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy/plots -prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy -prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.1.0 diff --git a/internal/scripts/installation/modulefiles/3.1.0_jet b/internal/scripts/installation/modulefiles/3.1.0_jet deleted file mode 100644 index 85bf61c75..000000000 --- a/internal/scripts/installation/modulefiles/3.1.0_jet +++ /dev/null @@ -1,25 +0,0 @@ -#%Module###################################################################### -## -## METplotpy -## -proc ModulesHelp { } { - puts stderr "Sets up the paths and environment variables to use the METplotpy-3.1.0 - *** For help see the official MET webpage at http://www.dtcenter.org/met/users ***" -} - -prereq intel/2024.2.1 - -setenv METPLOTPY_SOURCE /contrib/met/METplotpy/METplotpy-3.1.0 -setenv METPLOTPY_BASE /contrib/met/METplotpy/METplotpy-3.1.0 - -prepend-path PATH /lfs6/HFIP/dtc-hurr/METplus/miniconda/miniconda3/envs/metplus_v6.1_py3.12/bin -prepend-path PATH /contrib/met/METplotpy/METplotpy-3.1.0/metplotpy/contributed -prepend-path PATH /contrib/met/METplotpy/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PATH /contrib/met/METplotpy/METplotpy-3.1.0/metplotpy/plots -prepend-path PATH /contrib/met/METplotpy/METplotpy-3.1.0/metplotpy -prepend-path PATH /contrib/met/METplotpy/METplotpy-3.1.0 -prepend-path PYTHONPATH /contrib/met/METplotpy/METplotpy-3.1.0/metplotpy/contributed -prepend-path PYTHONPATH /contrib/met/METplotpy/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PYTHONPATH /contrib/met/METplotpy/METplotpy-3.1.0/metplotpy/plots -prepend-path PYTHONPATH /contrib/met/METplotpy/METplotpy-3.1.0/metplotpy -prepend-path PYTHONPATH /contrib/met/METplotpy/METplotpy-3.1.0 diff --git a/internal/scripts/installation/modulefiles/3.1.0_ursa b/internal/scripts/installation/modulefiles/3.1.0_ursa deleted file mode 100644 index 9ffb356e9..000000000 --- a/internal/scripts/installation/modulefiles/3.1.0_ursa +++ /dev/null @@ -1,25 +0,0 @@ -#%Module###################################################################### -## -## METplotpy -## -proc ModulesHelp { } { - puts stderr "Sets up the paths and environment variables to use the METplotpy-3.1.0. - *** For help see the official MET webpage at http://www.dtcenter.org/met/users ***" -} - -prereq intel-oneapi-compilers/2025.1.1 - -setenv METPLOTPY_SOURCE /contrib/METplotpy/METplotpy-3.1.0 -setenv METPLOTPY_BASE /contrib/METplotpy/METplotpy-3.1.0 - -prepend-path PATH /scratch3/BMC/dtc/METplus/miniconda/miniconda3/envs/metplus_v6.1_py3.12/bin -prepend-path PATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy/contributed -prepend-path PATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy/plots -prepend-path PATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy -prepend-path PATH /contrib/METplotpy/METplotpy-3.1.0 -prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy/contributed -prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy/plots -prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.1.0/metplotpy -prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.1.0 diff --git a/internal/scripts/installation/modulefiles/3.1.0_wcoss2 b/internal/scripts/installation/modulefiles/3.1.0_wcoss2 deleted file mode 100644 index ea6ea5b47..000000000 --- a/internal/scripts/installation/modulefiles/3.1.0_wcoss2 +++ /dev/null @@ -1,30 +0,0 @@ -#%Module###################################################################### -## -## METplotpy -## -proc ModulesHelp { } { - puts stderr "Sets up the paths and environment variables to use the METplotpy-3.1.0. - *** For help see the official MET webpage at http://www.dtcenter.org/met/users ***" -} - -module reset -module use /apps/ops/para/libs/modulefiles/compiler/intel/19.1.3.304 -module load intel -module use /apps/dev/modulefiles/ -module load ve/evs/2.0 -module use /apps/sw_review/emc/METcalcpy/modulefiles -module load metcalcpy/3.1.0 - -setenv METPLOTPY_SOURCE /apps/sw_review/emc/METplotpy/3.1.0 -setenv METPLOTPY_BASE /apps/sw_review/emc/METplotpy/3.1.0 - -prepend-path PATH /apps/sw_review/emc/METplotpy/3.1.0/metplotpy/contributed -prepend-path PATH /apps/sw_review/emc/METplotpy/3.1.0/metplotpy/plots/performance_diagram -prepend-path PATH /apps/sw_review/emc/METplotpy/3.1.0/metplotpy/plots -prepend-path PATH /apps/sw_review/emc/METplotpy/3.1.0/metplotpy -prepend-path PATH /apps/sw_review/emc/METplotpy/3.1.0 -prepend-path PYTHONPATH /apps/sw_review/emc/METplotpy/3.1.0/metplotpy/contributed -prepend-path PYTHONPATH /apps/sw_review/emc/METplotpy/3.1.0/metplotpy/plots/performance_diagram -prepend-path PYTHONPATH /apps/sw_review/emc/METplotpy/3.1.0/metplotpy/plots -prepend-path PYTHONPATH /apps/sw_review/emc/METplotpy/3.1.0/metplotpy -prepend-path PYTHONPATH /apps/sw_review/emc/METplotpy/3.1.0 diff --git a/internal/scripts/installation/modulefiles/3.1.0_casper b/internal/scripts/installation/modulefiles/3.2.0_casper similarity index 65% rename from internal/scripts/installation/modulefiles/3.1.0_casper rename to internal/scripts/installation/modulefiles/3.2.0_casper index 2d786c42e..ea67c53d3 100644 --- a/internal/scripts/installation/modulefiles/3.1.0_casper +++ b/internal/scripts/installation/modulefiles/3.2.0_casper @@ -3,7 +3,7 @@ ## METplotpy ## proc ModulesHelp { } { - puts stderr "Sets up the paths and environment variables to use the METplotpy-3.1.0 + puts stderr "Sets up the paths and environment variables to use the METplotpy-3.2.0 *** For help see the official MET webpage at http://www.dtcenter.org/met/users ***" } @@ -12,16 +12,16 @@ module load intel/2024.2.1 prepend-path PATH /glade/work/dtcrt/METplus/casper/miniconda/miniconda3/envs/metplus_v6.1_py3.12/bin -setenv METPLOTPY_SOURCE /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.1.0 -setenv METPLOTPY_BASE /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.1.0 +setenv METPLOTPY_SOURCE /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.2.0 +setenv METPLOTPY_BASE /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.2.0 -prepend-path PATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.1.0/metplotpy/contributed -prepend-path PATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.1.0/metplotpy/plots -prepend-path PATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.1.0/metplotpy -prepend-path PATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.1.0 -prepend-path PYTHONPATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.1.0/metplotpy/contributed -prepend-path PYTHONPATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PYTHONPATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.1.0/metplotpy/plots -prepend-path PYTHONPATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.1.0/metplotpy -prepend-path PYTHONPATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.1.0 +prepend-path PATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.2.0/metplotpy/contributed +prepend-path PATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.2.0/metplotpy/plots +prepend-path PATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.2.0/metplotpy +prepend-path PATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.2.0 +prepend-path PYTHONPATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.2.0/metplotpy/contributed +prepend-path PYTHONPATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PYTHONPATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.2.0/metplotpy/plots +prepend-path PYTHONPATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.2.0/metplotpy +prepend-path PYTHONPATH /glade/work/dtcrt/METplus/casper/components/METplotpy/installations/METplotpy-3.2.0 diff --git a/internal/scripts/installation/modulefiles/3.2.0_gaea b/internal/scripts/installation/modulefiles/3.2.0_gaea new file mode 100644 index 000000000..16ec59755 --- /dev/null +++ b/internal/scripts/installation/modulefiles/3.2.0_gaea @@ -0,0 +1,25 @@ +#%Module###################################################################### +## +## METplotpy +## +proc ModulesHelp { } { + puts stderr "Sets up the paths and environment variables to use the METplotpy-3.2.0. + *** For help see the official MET webpage at http://www.dtcenter.org/met/users ***" +} + +module load intel-oneapi/intel/2023.2.0 + +setenv METPLOTPY_SOURCE /usw/met/METplotpy/METplotpy-3.2.0 +setenv METPLOTPY_BASE /usw/met/METplotpy/METplotpy-3.2.0 + +prepend-path PATH /ncrc/proj/nggps_psd/Julie.Prestopnik/projects/miniconda/miniconda3/envs/metplus_v6.1_py3.12/bin +prepend-path PATH /usw/met/METplotpy/METplotpy-3.2.0/metplotpy/contributed +prepend-path PATH /usw/met/METplotpy/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PATH /usw/met/METplotpy/METplotpy-3.2.0/metplotpy/plots +prepend-path PATH /usw/met/METplotpy/METplotpy-3.2.0/metplotpy +prepend-path PATH /usw/met/METplotpy/METplotpy-3.2.0 +prepend-path PYTHONPATH /usw/met/METplotpy/METplotpy-3.2.0/metplotpy/contributed +prepend-path PYTHONPATH /usw/met/METplotpy/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PYTHONPATH /usw/met/METplotpy/METplotpy-3.2.0/metplotpy/plots +prepend-path PYTHONPATH /usw/met/METplotpy/METplotpy-3.2.0/metplotpy +prepend-path PYTHONPATH /usw/met/METplotpy/METplotpy-3.2.0 diff --git a/internal/scripts/installation/modulefiles/3.2.0_hera b/internal/scripts/installation/modulefiles/3.2.0_hera new file mode 100644 index 000000000..b94c86099 --- /dev/null +++ b/internal/scripts/installation/modulefiles/3.2.0_hera @@ -0,0 +1,25 @@ +#%Module###################################################################### +## +## METplotpy +## +proc ModulesHelp { } { + puts stderr "Sets up the paths and environment variables to use the METplotpy-3.2.0. + *** For help see the official MET webpage at http://www.dtcenter.org/met/users ***" +} + +prereq intel/2024.2.1 + +setenv METPLOTPY_SOURCE /contrib/METplotpy/METplotpy-3.2.0 +setenv METPLOTPY_BASE /contrib/METplotpy/METplotpy-3.2.0 + +prepend-path PATH /scratch1/BMC/dtc/miniconda/miniconda3/envs/metplus_v6.1_py3.12/bin +prepend-path PATH /contrib/METplotpy/METplotpy-3.2.0/metplotpy/contributed +prepend-path PATH /contrib/METplotpy/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PATH /contrib/METplotpy/METplotpy-3.2.0/metplotpy/plots +prepend-path PATH /contrib/METplotpy/METplotpy-3.2.0/metplotpy +prepend-path PATH /contrib/METplotpy/METplotpy-3.2.0 +prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.2.0/metplotpy/contributed +prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.2.0/metplotpy/plots +prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.2.0/metplotpy +prepend-path PYTHONPATH /contrib/METplotpy/METplotpy-3.2.0 diff --git a/internal/scripts/installation/modulefiles/3.1.0_hercules b/internal/scripts/installation/modulefiles/3.2.0_hercules similarity index 59% rename from internal/scripts/installation/modulefiles/3.1.0_hercules rename to internal/scripts/installation/modulefiles/3.2.0_hercules index 2a424ebe4..26d40f349 100644 --- a/internal/scripts/installation/modulefiles/3.1.0_hercules +++ b/internal/scripts/installation/modulefiles/3.2.0_hercules @@ -3,25 +3,25 @@ ## METplotpy ## proc ModulesHelp { } { - puts stderr "Sets up the paths and environment variables to use the METplotpy-3.1.0. + puts stderr "Sets up the paths and environment variables to use the METplotpy-3.2.0. *** For help see the official MET webpage at http://www.dtcenter.org/met/users ***" } module load contrib module load intel-oneapi-compilers/2022.2.1 -setenv METPLOTPY_SOURCE /apps/contrib/MET/METplotpy/METplotpy-3.1.0 -setenv METPLOTPY_BASE /apps/contrib/MET/METplotpy/METplotpy-3.1.0 +setenv METPLOTPY_SOURCE /apps/contrib/MET/METplotpy/METplotpy-3.2.0 +setenv METPLOTPY_BASE /apps/contrib/MET/METplotpy/METplotpy-3.2.0 -#setenv METPLOTPY_PATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/ -prepend-path PATH /work/noaa/ovp/miniconda/miniconda3/envs/metplus_v5.1_py3.10/bin -prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy/contributed -prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy/plots -prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy -prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0 -prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy/contributed -prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy/plots -prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy -prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0 +#setenv METPLOTPY_PATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/ +prepend-path PATH /work/noaa/ovp/miniconda/miniconda3/envs/metplus_v6.1_py3.12/bin +prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy/contributed +prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy/plots +prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy +prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0 +prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy/contributed +prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy/plots +prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy +prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0 diff --git a/internal/scripts/installation/modulefiles/3.2.0_jet b/internal/scripts/installation/modulefiles/3.2.0_jet new file mode 100644 index 000000000..40f620f91 --- /dev/null +++ b/internal/scripts/installation/modulefiles/3.2.0_jet @@ -0,0 +1,25 @@ +#%Module###################################################################### +## +## METplotpy +## +proc ModulesHelp { } { + puts stderr "Sets up the paths and environment variables to use the METplotpy-3.2.0 + *** For help see the official MET webpage at http://www.dtcenter.org/met/users ***" +} + +prereq intel/2024.2.1 + +setenv METPLOTPY_SOURCE /contrib/met/METplotpy/METplotpy-3.2.0 +setenv METPLOTPY_BASE /contrib/met/METplotpy/METplotpy-3.2.0 + +prepend-path PATH /lfs6/HFIP/dtc-hurr/METplus/miniconda/miniconda3/envs/metplus_v6.1_py3.12/bin +prepend-path PATH /contrib/met/METplotpy/METplotpy-3.2.0/metplotpy/contributed +prepend-path PATH /contrib/met/METplotpy/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PATH /contrib/met/METplotpy/METplotpy-3.2.0/metplotpy/plots +prepend-path PATH /contrib/met/METplotpy/METplotpy-3.2.0/metplotpy +prepend-path PATH /contrib/met/METplotpy/METplotpy-3.2.0 +prepend-path PYTHONPATH /contrib/met/METplotpy/METplotpy-3.2.0/metplotpy/contributed +prepend-path PYTHONPATH /contrib/met/METplotpy/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PYTHONPATH /contrib/met/METplotpy/METplotpy-3.2.0/metplotpy/plots +prepend-path PYTHONPATH /contrib/met/METplotpy/METplotpy-3.2.0/metplotpy +prepend-path PYTHONPATH /contrib/met/METplotpy/METplotpy-3.2.0 diff --git a/internal/scripts/installation/modulefiles/3.1.0_orion b/internal/scripts/installation/modulefiles/3.2.0_orion similarity index 60% rename from internal/scripts/installation/modulefiles/3.1.0_orion rename to internal/scripts/installation/modulefiles/3.2.0_orion index b233f0a93..983024091 100644 --- a/internal/scripts/installation/modulefiles/3.1.0_orion +++ b/internal/scripts/installation/modulefiles/3.2.0_orion @@ -3,25 +3,25 @@ ## METplotpy ## proc ModulesHelp { } { - puts stderr "Sets up the paths and environment variables to use the METplotpy-3.1.0. + puts stderr "Sets up the paths and environment variables to use the METplotpy-3.2.0. *** For help see the official MET webpage at http://www.dtcenter.org/met/users ***" } module load contrib module load intel-oneapi-compilers/2024.1.0 -setenv METPLOTPY_SOURCE /apps/contrib/MET/METplotpy/METplotpy-3.1.0 -setenv METPLOTPY_BASE /apps/contrib/MET/METplotpy/METplotpy-3.1.0 +setenv METPLOTPY_SOURCE /apps/contrib/MET/METplotpy/METplotpy-3.2.0 +setenv METPLOTPY_BASE /apps/contrib/MET/METplotpy/METplotpy-3.2.0 -#setenv METPLOTPY_PATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/ +#setenv METPLOTPY_PATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/ prepend-path PATH /work/noaa/ovp/miniconda/miniconda3/envs/metplus_v6.1_py3.12/bin -prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy/contributed -prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy/plots -prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy -prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0 -prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy/contributed -prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy/plots/performance_diagram -prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy/plots -prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0/metplotpy -prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.1.0 +prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy/contributed +prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy/plots +prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy +prepend-path PATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0 +prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy/contributed +prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy/plots +prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0/metplotpy +prepend-path PYTHONPATH /apps/contrib/MET/METplotpy/METplotpy-3.2.0 diff --git a/internal/scripts/installation/modulefiles/3.2.0_smac-c5 b/internal/scripts/installation/modulefiles/3.2.0_smac-c5 new file mode 100644 index 000000000..f6cc6acf2 --- /dev/null +++ b/internal/scripts/installation/modulefiles/3.2.0_smac-c5 @@ -0,0 +1,31 @@ +#%Module###################################################################### +## +## METplotpy +## +proc ModulesHelp { } { + puts stderr "Sets up the paths and environment variables to use the METplotpy-3.2.0. + *** For help see the official MET webpage at http://www.dtcenter.org/met/users ***" +} + +module load compiler-rt/2024.0.1 +module load tbb/2021.11 +module load oclfpga/2024.0.0 +module load compiler/2024.0.1 +module load mkl/2024.0 +module load mpi/2021.11 + + +setenv METPLOTPY_SOURCE /atec/opt/atectest/METplotpy/METplotpy-3.2.0 +setenv METPLOTPY_BASE /atec/opt/atectest/METplotpy/METplotpy-3.2.0 + +prepend-path PATH /atec/opt/atectest/METplus/miniconda/miniconda3/envs/metplus_v6.1_py3.12/bin +prepend-path PATH /atec/opt/atectest/METplotpy/METplotpy-3.2.0/metplotpy/contributed +prepend-path PATH /atec/opt/atectest/METplotpy/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PATH /atec/opt/atectest/METplotpy/METplotpy-3.2.0/metplotpy/plots +prepend-path PATH /atec/opt/atectest/METplotpy/METplotpy-3.2.0/metplotpy +prepend-path PATH /atec/opt/atectest/METplotpy/METplotpy-3.2.0 +prepend-path PYTHONPATH /atec/opt/atectest/METplotpy/METplotpy-3.2.0/metplotpy/contributed +prepend-path PYTHONPATH /atec/opt/atectest/METplotpy/METplotpy-3.2.0/metplotpy/plots/performance_diagram +prepend-path PYTHONPATH /atec/opt/atectest/METplotpy/METplotpy-3.2.0/metplotpy/plots +prepend-path PYTHONPATH /atec/opt/atectest/METplotpy/METplotpy-3.2.0/metplotpy +prepend-path PYTHONPATH /atec/opt/atectest/METplotpy/METplotpy-3.2.0 diff --git a/internal/scripts/installation/modulefiles/3.2.0_wcoss2 b/internal/scripts/installation/modulefiles/3.2.0_wcoss2 new file mode 100644 index 000000000..d20fed4bd --- /dev/null +++ b/internal/scripts/installation/modulefiles/3.2.0_wcoss2 @@ -0,0 +1,30 @@ +#%Module###################################################################### +## +## METplotpy +## +proc ModulesHelp { } { + puts stderr "Sets up the paths and environment variables to use the METplotpy-3.2.0. + *** For help see the official MET webpage at http://www.dtcenter.org/met/users ***" +} + +module reset +module use /apps/ops/para/libs/modulefiles/compiler/intel/19.1.3.304 +module load intel +module use /apps/dev/modulefiles/ +module load ve/evs/2.0 +module use /apps/sw_review/emc/METcalcpy/modulefiles +module load metcalcpy/3.2.0 + +setenv METPLOTPY_SOURCE /apps/sw_review/emc/METplotpy/3.2.0 +setenv METPLOTPY_BASE /apps/sw_review/emc/METplotpy/3.2.0 + +prepend-path PATH /apps/sw_review/emc/METplotpy/3.2.0/metplotpy/contributed +prepend-path PATH /apps/sw_review/emc/METplotpy/3.2.0/metplotpy/plots/performance_diagram +prepend-path PATH /apps/sw_review/emc/METplotpy/3.2.0/metplotpy/plots +prepend-path PATH /apps/sw_review/emc/METplotpy/3.2.0/metplotpy +prepend-path PATH /apps/sw_review/emc/METplotpy/3.2.0 +prepend-path PYTHONPATH /apps/sw_review/emc/METplotpy/3.2.0/metplotpy/contributed +prepend-path PYTHONPATH /apps/sw_review/emc/METplotpy/3.2.0/metplotpy/plots/performance_diagram +prepend-path PYTHONPATH /apps/sw_review/emc/METplotpy/3.2.0/metplotpy/plots +prepend-path PYTHONPATH /apps/sw_review/emc/METplotpy/3.2.0/metplotpy +prepend-path PYTHONPATH /apps/sw_review/emc/METplotpy/3.2.0 diff --git a/metplotpy/contributed/hovmoeller/__init__.py b/metplotpy/contributed/hovmoeller/__init__.py deleted file mode 100644 index 8d1c8b69c..000000000 --- a/metplotpy/contributed/hovmoeller/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/metplotpy/contributed/hovmoeller/hovmoeller_calc.py b/metplotpy/contributed/hovmoeller/hovmoeller_calc.py deleted file mode 100644 index ee7edfbe7..000000000 --- a/metplotpy/contributed/hovmoeller/hovmoeller_calc.py +++ /dev/null @@ -1,61 +0,0 @@ -# ============================* - # ** Copyright UCAR (c) 2020 - # ** University Corporation for Atmospheric Research (UCAR) - # ** National Center for Atmospheric Research (NCAR) - # ** Research Applications Lab (RAL) - # ** P.O.Box 3000, Boulder, Colorado, 80307-3000, USA - # ============================* - - - -import numpy as np - -""" -Routines used to compute Hovmoeller diagrams and pattern correlation. - -Included: - -lat_avg: - -pattern_corr: -""" - - -def lat_avg(data, latmin, latmax): - """ - Compute latitudinal average for hovmoeller diagram. - :param data: input data (time, lat, lon) - :type data: xarray.Dataarray - :param latmin: southern latitude for averaging - :type latmin: float - :param latmax: northern latitude for averaging - :type latmax: float - :return: data (time, lon) - :rtype: xarray.Dataarray - """ - data = data.sel(lat=slice(latmin, latmax)) - units = data.attrs['units'] - data = data.mean(dim='lat') - data.attrs['units'] = units - data = data.squeeze() - - return data - - -def pattern_corr(a, b): - """ - Compute the pattern correlation between two 2D (time, lon) fields - :param a: (time, lon) data array - :type a: float - :param b: (time, lon) data array - :type b: float - :return: correlation - :rtype: float - """ - a1d = a.stack(lt=('time', 'lon')) - b1d = b.stack(lt=('time', 'lon')) - - corr = np.corrcoef(a1d, b1d) - corr = corr[0, 1] - - return corr diff --git a/metplotpy/contributed/hovmoeller/hovmoeller_plotly.py b/metplotpy/contributed/hovmoeller/hovmoeller_plotly.py deleted file mode 100644 index 78cff9858..000000000 --- a/metplotpy/contributed/hovmoeller/hovmoeller_plotly.py +++ /dev/null @@ -1,314 +0,0 @@ -# ============================* - # ** Copyright UCAR (c) 2020 - # ** University Corporation for Atmospheric Research (UCAR) - # ** National Center for Atmospheric Research (NCAR) - # ** Research Applications Lab (RAL) - # ** P.O.Box 3000, Boulder, Colorado, 80307-3000, USA - # ============================* - - - -""" -Hovmoeller plots using plotly module. -""" - -import numpy as np -import plotly.graph_objects as go -import plotly.express as px -from netCDF4 import num2date - - -def hov_resources(pltvarname): - """ - Set the colormap to be used for the Hovmoeller plot. - :param pltvarname: name of variable to be plotted - :type pltvarname: string - :return: rgb string colorscale - :rtype: color string - """ - if pltvarname == 'precip': - cmap_rgb = [[0, "rgb(255, 255, 255)"], [0.111111, "rgb(255, 255, 255)"], - [0.111111, "rgb(135, 206, 250)"], [0.222222, "rgb(135, 206, 250)"], - [0.222222, "rgb(30, 144, 255)"], [0.333333, "rgb(30, 144, 255)"], - [0.333333, "rgb(0, 0, 238)"], [0.444444, "rgb(0, 0, 238)"], - [0.444444, "rgb(131, 111, 255)"], [0.666667, "rgb(131, 111, 255)"], - [0.666667, "rgb(171, 130, 255)"], [0.888889, "rgb(171, 130, 255)"], - [0.888889, "rgb(145, 44, 238)"], [1, "rgb(145, 44, 238)"]] - elif pltvarname == 'uwnd': - cmap_rgb = [[0.0, "rgb(49,54,149)"], - [0.1111111111111111, "rgb(69,117,180)"], - [0.2222222222222222, "rgb(116,173,209)"], - [0.3333333333333333, "rgb(171,217,233)"], - [0.4444444444444444, "rgb(224,243,248)"], - [0.5, "rgb(255,255,255)"], - [0.5555555555555556, "rgb(254,224,144)"], - [0.6666666666666666, "rgb(253,174,97)"], - [0.7777777777777778, "rgb(244,109,67)"], - [0.8888888888888888, "rgb(215,48,39)"], - [1.0, "rgb(165,0,38)"]] - elif pltvarname == 'vwnd': - cmap_rgb = [[0.0, "rgb(49,54,149)"], - [0.1111111111111111, "rgb(69,117,180)"], - [0.2222222222222222, "rgb(116,173,209)"], - [0.3333333333333333, "rgb(171,217,233)"], - [0.4444444444444444, "rgb(224,243,248)"], - [0.5, "rgb(255,255,255)"], - [0.5555555555555556, "rgb(254,224,144)"], - [0.6666666666666666, "rgb(253,174,97)"], - [0.7777777777777778, "rgb(244,109,67)"], - [0.8888888888888888, "rgb(215,48,39)"], - [1.0, "rgb(165,0,38)"]] - elif pltvarname == 'div': - cmap_rgb = [[0.0, "rgb(49,54,149)"], - [0.1111111111111111, "rgb(69,117,180)"], - [0.2222222222222222, "rgb(116,173,209)"], - [0.3333333333333333, "rgb(171,217,233)"], - [0.4444444444444444, "rgb(224,243,248)"], - [0.5, "rgb(255,255,255)"], - [0.5555555555555556, "rgb(254,224,144)"], - [0.6666666666666666, "rgb(253,174,97)"], - [0.7777777777777778, "rgb(244,109,67)"], - [0.8888888888888888, "rgb(215,48,39)"], - [1.0, "rgb(165,0,38)"]] - elif pltvarname == 'olr': - cmap_rgb = [[0, "rgb(145, 44, 238)"], [0.14, "rgb(145, 44, 238)"], - [0.14, "rgb(171, 130, 255)"], [0.28, "rgb(171, 130, 255)"], - [0.28, "rgb(131, 111, 255)"], [0.42, "rgb(131, 111, 255)"], - [0.42, "rgb(0, 0, 238)"], [0.56, "rgb(0, 0, 238)"], - [0.56, "rgb(30, 144, 255)"], [0.71, "rgb(30, 144, 255)"], - [0.71, "rgb(135, 206, 250)"], [0.85, "rgb(135, 206, 250)"], - [0.85, "rgb(255, 255, 255)"], [1, "rgb(255, 255, 255)"]] - else: - cmap_rgb = [[0.0, "rgb(49,54,149)"], - [0.1111111111111111, "rgb(69,117,180)"], - [0.2222222222222222, "rgb(116,173,209)"], - [0.3333333333333333, "rgb(171,217,233)"], - [0.4444444444444444, "rgb(224,243,248)"], - [0.5, "rgb(255,255,255)"], - [0.5555555555555556, "rgb(254,224,144)"], - [0.6666666666666666, "rgb(253,174,97)"], - [0.7777777777777778, "rgb(244,109,67)"], - [0.8888888888888888, "rgb(215,48,39)"], - [1.0, "rgb(165,0,38)"]] - - return cmap_rgb - - -def get_clevels(pltvarname): - """ - Set contour levels for given variable. - :param pltvarname: name of variable to be plotted - :type pltvarname: string - :return: cmin, cmax, cspc - :rtype: float - """ - if pltvarname == "precip": - cmin = 0.2 - cmax = 1.6 - cspc = 0.2 - elif (pltvarname == 'uwnd') or (pltvarname == 'vwnd'): - cmin = -21. - cmax = 21. - cspc = 2. - elif pltvarname == 'div': - cmin = -0.000011 - cmax = 0.000011 - cspc = 0.000002 - elif pltvarname == 'olr': - cmin = 160. - cmax = 240. - cspc = 20. - else: - print('Warning: Not a default variable name (' + pltvarname + '). To ensure best plotting results please ' - 'specify min, max and spacing for contour levels.') - cmin = -21. - cmax = 21. - cspc = 2. - - return cmin, cmax, cspc - - -def get_timestr(time): - """ - Generate time string for y-axis labels. - :param time: time coordinate - :type time: datetime object - :return: timestr - :rtype: str - """ - ts = (time - np.datetime64('1970-01-01T00:00:00Z')) / np.timedelta64(1, 'h') - date = num2date(ts, 'hours since 1970-01-01T00:00:00Z') - timestr = [i.strftime("%Y-%m-%d %H:%M") for i in date] - - return timestr - - -def get_latstring(lats, latn): - """ - Generate string describing the latitude band averaged over. - :param lats: southern latitude limit of the average - :type lats: float - :param latn: northern latitude limit of the average - :type latn: float - :return: latstr - :rtype: str - """ - if lats < 0: - hems = 'S' - lats = -lats - else: - hems = 'N' - if latn < 0: - hemn = 'S' - latn = -latn - else: - hemn = 'N' - - latstr = str(lats) + hems + " - " + str(latn) + hemn - - return latstr - - -def hovmoeller(data, lon, time, datestrt, datelast, plotpath, lats, latn, spd, source, pltvarname, lev=[], - cmin=[], cmax=[], cspc=[]): - """ - Main driver for plotting Hovmoeller diagrams. - :param data: input data, should be (time, lon) - :type data: numeric - :param lon: longitude coordinate of data - :type lon: float - :param time: time coordinate of data - :type time: datetime - :param datestrt: start date for Hovmoeller, used in plot file name - :type datestrt: str - :param datelast: end date for Hovmoeller, used in plot file name - :type datelast: str - :param plotpath: path for saving the figure - :type plotpath: str - :param lats: southern latitude limit of the average - :type lats: float - :param latn: northern latitude limit of the average - :type latn: float - :param spd: number of observations per day - :type spd: int - :param source: source of the data, e.g. (ERAI, TRMM, ...), used in plot file name - :type source: str - :param pltvarname: name of variable to be plotted - :type pltvarname: str - :param lev: vertical level of data (optional) - :type lev: str - :param cmin: contour level minimum (optional) - :type cmin: float - :param cmax: contour level maximum (optional) - :type cmax: float - :param cspc: contour spacing (optional) - :type cspc: float - :return: none - :rtype: none - """ - - """ - Set plot type and plot file name. - """ - plttype = "png" - plotname = plotpath + "Hovmoeller_" + source + pltvarname + lev + "_" + str(datestrt) + "-" + str( - datelast) + "." + plttype - - """ - Set plot resources: colormap, time string, contour levels, latitude band string - """ - cmap_rgb = hov_resources(pltvarname) - - timestr = get_timestr(time) - - if (not cmin) or (not cmax) or (not cspc): - cmin, cmax, cspc = get_clevels(pltvarname) - - latstring = get_latstring(lats, latn) - - """ - Generate the Hovmoeller plot. - """ - fig = go.Figure() - - fig.add_trace( - go.Contour( - z=data.values, - x=lon, - y=timestr, - colorscale=cmap_rgb, - contours=dict(start=cmin, end=cmax, size=cspc, - showlines=False), - colorbar=dict(title=data.attrs['units'], - len=0.6, - lenmode='fraction') - ) - ) - - fig.update_layout( - title=source + " " + pltvarname + lev, - width=600, - height=900, - annotations=[ - go.layout.Annotation( - x=300, - y=timestr[5], - xref="x", - yref="y", - text=latstring, - showarrow=False, - bgcolor="white", - opacity=0.9, - font=dict(size=16) - ) - ] - ) - - fig.update_xaxes(ticks="inside", tick0=0, dtick=30, title_text='longitude') - fig.update_yaxes(autorange="reversed", ticks="inside", nticks=11) - - fig.write_image(plotname) - - return - - -def plot_pattcorr(PC, labels, plotpath, lats, latn): - """ - Plot pattern correlation curves as a function of lead time. - :param PC: - :type PC: - :param labels: - :type labels: - :param plotpath: - :type plotpath: - :param region: - :type region: str - :return: - :rtype: - """ - - latstring = get_latstring(lats, latn) - - plttype = "png" - plotname = plotpath + "PatternCorrelationHovmoeller." + plttype - nlines = len(labels) - - fig = go.Figure() - for ll in np.arange(0, nlines): - fig.add_trace(go.Scatter(x=PC['fchrs'], y=PC[:, ll], - mode='lines', - name=labels[ll])) - - # fig.add_trace(go.Scatter(x=PC['fchrs'], y=PC[:, 1], - # mode='lines', - # name=labels[1])) - # fig.add_trace(go.Scatter(x=PC['fchrs'], y=PC[:, 2], - # mode='lines', - # name=labels[2])) - - fig.update_layout(title=latstring) - - fig.update_xaxes(ticks="", tick0=0, dtick=24, title_text='lead time (h)') - fig.update_yaxes(ticks="", tick0=0, dtick=0.1, title_text='correlation') - - fig.write_image(plotname) diff --git a/metplotpy/contributed/hovmoeller/run_hovmoeller.py b/metplotpy/contributed/hovmoeller/run_hovmoeller.py deleted file mode 100644 index bd3744179..000000000 --- a/metplotpy/contributed/hovmoeller/run_hovmoeller.py +++ /dev/null @@ -1,60 +0,0 @@ -# ============================* - # ** Copyright UCAR (c) 2020 - # ** University Corporation for Atmospheric Research (UCAR) - # ** National Center for Atmospheric Research (NCAR) - # ** Research Applications Lab (RAL) - # ** P.O.Box 3000, Boulder, Colorado, 80307-3000, USA - # ============================* - - - -import numpy as np -import xarray as xr -import sys - -sys.path.append('../../') -""" -local scripts, if loading from a different directory include that with a '.' between -directory name and script name -""" -from metplotpy.contributed.hovmoeller.hovmoeller_calc import lat_avg -from metplotpy.contributed.hovmoeller.hovmoeller_plotly import hovmoeller -plotpath = '../plots/hovmoeller/' - -""" -Parameters to set for the Hovmoeller diagrams. -""" -datestrt = '2016-01-01' # plot start date, format: yyyy-mm-dd -datelast = '2016-03-31' # plot end date, format: yyyy-mm-dd -latMax = 5. # maximum latitude for the average -latMin = -5. # minimum latitude for the average - -print("reading data from file:") - -ds = xr.open_dataset('/data/mgehne/ERAI/MetricsObs/precip.erai.sfc.1p5.2x.1979-2016.nc') -A = ds.precip -lonA = ds.lon -print("extracting time period:") -A = A.sel(time=slice(datestrt, datelast)) -timeA = ds.time.sel(time=slice(datestrt, datelast)) -ds.close() - -print("average over latitude band:") -A = A * 1000 / 4 -A.attrs['units'] = 'mm/day' -A = lat_avg(A, latmin=latMin, latmax=latMax) - -print("plot hovmoeller diagram:") -spd = 2 # number of obs per day -source = "ERAI" # data source -var = "precip" # variable to plot -lev = "" # level -# plot using user set contour levels -contourmin = 0.2 # contour minimum -contourmax = 1.2 # contour maximum -contourspace = 0.2 # contour spacing -hovmoeller(A, lonA, timeA, datestrt, datelast, plotpath, latMin, latMax, spd, source, var, lev, - contourmin, contourmax, contourspace) -# plot using default contour levels for this variable -# hovmoeller(A, lonA, timeA, datestrt, datelast, plotpath, latMin, latMax, spd, source, var, lev) - diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index 77f3dab5f..c5794ccbe 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -15,20 +15,16 @@ from datetime import datetime import os import re -from operator import add import pandas as pd -import plotly.graph_objects as go -from plotly.graph_objects import Figure -from plotly.subplots import make_subplots +from matplotlib import pyplot as plt import metcalcpy.util.utils as calc_util + from metplotpy.plots import util from metplotpy.plots.bar.bar_config import BarConfig from metplotpy.plots.bar.bar_series import BarSeries from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ - PLOTLY_PAPER_BGCOOR class Bar(BasePlot): @@ -55,18 +51,7 @@ def __init__(self, parameters: dict) -> None: self.logger = self.config_obj.logger self.logger.info(f"Start bar plot: {datetime.now()}") # Check that we have all the necessary settings for each series - self.logger.info("Consistency checking of config settings for colors, " - "legends, etc.") - is_config_consistent = self.config_obj._config_consistency_check() - if not is_config_consistent: - value_error_msg = ("ValueError: The number of series defined by series_val_1 and " - "derived curves is inconsistent with the number of " - "settings required for describing each series. Please " - "check the number of your configuration file's " - "plot_i, plot_disp, series_order, user_legend, show_legend and " - "colors settings.") - self.logger.error(value_error_msg) - raise ValueError(value_error_msg) + self.config_obj.config_consistency_check() # Read in input data, location specified in config file self.logger.info(f"Begin reading input data: {datetime.now()}") @@ -159,6 +144,9 @@ def _create_series(self, input_data): # reorder series series_list = self.config_obj.create_list_by_series_ordering(series_list) + if self.config_obj.xaxis_reverse: + series_list.reverse() + return series_list def _create_figure(self): @@ -166,43 +154,36 @@ def _create_figure(self): Create a bar plot from defaults and custom parameters """ # create and draw the plot - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) - # placeholder for the number of stats - n_stats = [0] * len(self.config_obj.indy_vals) + wts_size_styles = self.get_weights_size_styles() - if self.config_obj.xaxis_reverse is True: - self.series_list.reverse() + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) - # add series lines - for series in self.series_list: + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) - # Don't generate the plot for this series if - # it isn't requested (as set in the config file) - if series.plot_disp: - self._draw_series(series) + self._add_series(ax) + self._add_x2axis(ax, wts_size_styles['x2lab']) - # aggregate number of stats - n_stats = list(map(add, n_stats, series.series_points['nstat'])) + self._add_legend(ax) - # add custom lines + # add custom lines if lines are defined in config if len(self.series_list) > 0: - self._add_lines( - self.config_obj, - sorted( - self.series_list[0].series_data[self.config_obj.indy_var].unique()) - ) + self._add_lines(ax, self.config_obj, self.config_obj.indy_vals) - # apply y axis limits - self._yaxis_limits() + plt.tight_layout() + def _add_series(self, ax): + # add series lines + for idx, series in enumerate(self.series_list): - # add x2 axis - self._add_x2axis(n_stats) + # Don't generate the plot for this series if + # it isn't requested (as set in the config file) + if series.plot_disp: + self._draw_series(ax, series, idx) - def _draw_series(self, series: BarSeries) -> None: + def _draw_series(self, ax: plt.Axes, series: BarSeries, idx: int) -> None: """ Draws the formatted Bar on the plot :param series: Bar series object with data and parameters @@ -210,7 +191,8 @@ def _draw_series(self, series: BarSeries) -> None: y_points = series.series_points['dbl_med'] is_threshold, is_percent_threshold = util.is_threshold_value( - series.series_data[self.config_obj.indy_var]) + series.series_data[self.config_obj.indy_var] + ) # If there are any None types in the series_points['dbl_med'] list, then use the # indy_vals defined in the config file to ensure that the number of y_points @@ -218,6 +200,7 @@ def _draw_series(self, series: BarSeries) -> None: # same number of x_points. if None in y_points: x_points = self.config_obj.indy_vals + y_points = [item if item is not None else 0 for item in y_points] elif is_percent_threshold: x_points = self.config_obj.indy_var elif is_threshold: @@ -233,244 +216,11 @@ def _draw_series(self, series: BarSeries) -> None: else: x_points = sorted(series.series_data[self.config_obj.indy_var].unique()) - # add the plot - self.figure.add_trace( - go.Bar( - x=x_points, - y=y_points, - showlegend=self.config_obj.show_legend[series.idx] == 1, - name=self.config_obj.user_legends[series.idx], - marker_color=self.config_obj.colors_list[series.idx], - marker_line_color=self.config_obj.colors_list[series.idx] - ) - ) - - def _create_layout(self) -> Figure: - """ - Creates a new layout based on the properties from the config file - including plots size, annotation and title + x_locs, width = self._get_x_locs_and_width(x_points, idx) - :return: Figure object - """ - # create annotation - annotation = [ - {'text': util.apply_weight_style(self.config_obj.parameters['plot_caption'], - self.config_obj.parameters[ - 'caption_weight']), - 'align': 'left', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': self.config_obj.parameters['caption_align'], - 'y': self.config_obj.caption_offset, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - }] - # create title - title = {'text': util.apply_weight_style(self.config_obj.title, - self.config_obj.parameters[ - 'title_weight']), - 'font': { - 'size': self.config_obj.title_font_size, - }, - 'y': self.config_obj.title_offset, - 'x': self.config_obj.parameters['title_align'], - 'xanchor': 'center', - 'xref': 'paper' - } - - # create a layout - fig = make_subplots(specs=[[{"secondary_y": False}]]) - - # add size, annotation, title - fig.update_layout( - width=self.config_obj.plot_width, - height=self.config_obj.plot_height, - margin=self.config_obj.plot_margins, - paper_bgcolor=PLOTLY_PAPER_BGCOOR, - annotations=annotation, - title=title, - plot_bgcolor=PLOTLY_PAPER_BGCOOR - ) - fig.update_layout( - xaxis={ - 'tickmode': 'array', - 'tickvals': self.config_obj.indy_vals, - 'ticktext': self.config_obj.indy_label, - } - ) - - return fig - - def _add_xaxis(self) -> None: - """ - Configures and adds x-axis to the plot - """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - ticks="inside", - zeroline=False, - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size}, - type='category' - ) - # reverse xaxis if needed - if self.config_obj.xaxis_reverse is True: - self.figure.update_xaxes(autorange="reversed") - - def _add_yaxis(self) -> None: - """ - Configures and adds y-axis to the plot - """ - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_1, - self.config_obj.parameters[ - 'ylab_weight']), - secondary_y=False, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - zeroline=False, - ticks="inside", - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.y_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['ylab_offset']), - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size} - ) - - def _add_legend(self) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure - """ - self.figure.update_layout(legend={'x': self.config_obj.bbox_x, - 'y': self.config_obj.bbox_y, - 'xanchor': 'center', - 'yanchor': 'top', - 'bordercolor': - self.config_obj.legend_border_color, - 'borderwidth': - self.config_obj.legend_border_width, - 'orientation': - self.config_obj.legend_orientation, - 'font': { - 'size': self.config_obj.legend_size, - 'color': "black" - } - }) - - def _yaxis_limits(self) -> None: - """ - Apply limits on y axis if needed - """ - if len(self.config_obj.parameters['ylim']) > 0: - self.figure.update_layout( - yaxis={'range': [self.config_obj.parameters['ylim'][0], - self.config_obj.parameters['ylim'][1]], - 'autorange': False}) - - def _add_x2axis(self, n_stats) -> None: - """ - Creates x2axis based on the properties from the config file - and attaches it to the initial Figure - - :param n_stats: - labels for the axis - """ - if self.config_obj.show_nstats: - self.figure.update_layout(xaxis2={'title_text': - util.apply_weight_style('NStats', - self.config_obj.parameters[ - 'x2lab_weight'] - ), - 'linecolor': PLOTLY_AXIS_LINE_COLOR, - 'linewidth': PLOTLY_AXIS_LINE_WIDTH, - 'overlaying': 'x', - 'side': 'top', - 'showgrid': False, - 'zeroline': False, - 'ticks': "inside", - 'title_font': { - 'size': - self.config_obj.x2_title_font_size - }, - 'title_standoff': abs( - self.config_obj.parameters[ - 'x2lab_offset'] - ), - 'tickmode': 'array', - 'tickvals': self.config_obj.indy_vals, - 'ticktext': n_stats, - 'tickangle': self.config_obj.x2_tickangle, - 'tickfont': { - 'size': - self.config_obj.x2_tickfont_size - }, - 'scaleanchor': 'x' - } - ) - # reverse x2axis if needed - if self.config_obj.xaxis_reverse is True: - self.figure.update_layout(xaxis2={'autorange': "reversed"}) - - # need to add an invisible line with all values = None - self.figure.add_trace( - go.Scatter(y=[None] * len(self.config_obj.indy_vals), - x=self.config_obj.indy_vals, - xaxis='x2', showlegend=False) - ) - - def remove_file(self): - """ - Removes previously made image file . Invoked by the parent class before - self.output_file - attribute can be created, but overridden here. - """ - - super().remove_file() - self._remove_html() - - def _remove_html(self) -> None: - """ - Removes previously made HTML file. - """ - - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" - - # remove the old file if it exist - if os.path.exists(html_name): - os.remove(html_name) - - def write_html(self) -> None: - """ - Is needed - creates and saves the html representation of the plot WITHOUT - Plotly.js - """ - if self.config_obj.create_html is True: - # construct the file name from plot_filename - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" - - # save html - self.figure.write_html(html_name, include_plotlyjs=False) + # add the plot + ax.bar(x=x_locs, height=y_points, width=width, align='center', color=self.config_obj.colors_list[series.idx], + label=self.config_obj.user_legends[series.idx]) def write_output_file(self) -> None: """ diff --git a/metplotpy/plots/bar/bar_config.py b/metplotpy/plots/bar/bar_config.py index 5a6436ac2..ad2c68217 100644 --- a/metplotpy/plots/bar/bar_config.py +++ b/metplotpy/plots/bar/bar_config.py @@ -18,8 +18,7 @@ import itertools from ..config import Config -from .. import constants -from .. import util +from .. import constants as constants import metcalcpy.util.utils as utils @@ -53,12 +52,11 @@ def __init__(self, parameters: dict) -> None: # caption parameters self.caption_size = int(constants.DEFAULT_CAPTION_FONTSIZE * self.get_config_value('caption_size')) - self.caption_offset = self.parameters['caption_offset'] - 3.1 + self.caption_offset = self.parameters['caption_offset'] * constants.DEFAULT_CAPTION_Y_OFFSET ############################################## # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -76,7 +74,6 @@ def __init__(self, parameters: dict) -> None: if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) ############################################## # x2-axis parameters @@ -111,37 +108,11 @@ def __init__(self, parameters: dict) -> None: self.legend_orientation = 'v' else: self.legend_orientation = 'h' - self.legend_border_color = "black" self.show_legend = self._get_show_legend() - - def _get_plot_disp(self) -> list: - """ - Retrieve the values that determine whether to display a particular series - and convert them to bool if needed - - Args: - - Returns: - A list of boolean values indicating whether or not to - display the corresponding series - """ - - plot_display_config_vals = self.get_config_value('plot_disp') - plot_display_bools = [] - for val in plot_display_config_vals: - if isinstance(val, bool): - plot_display_bools.append(val) - - if isinstance(val, str): - plot_display_bools.append(val.upper() == 'TRUE') - - return self.create_list_by_series_ordering(plot_display_bools) - def _get_fcst_vars(self, index): """ Retrieve a list of the inner keys (fcst_vars) to the fcst_var_val dictionary. - Args: index: identifier used to differentiate between fcst_var_val_1 and fcst_var_val_2 config settings @@ -149,7 +120,6 @@ def _get_fcst_vars(self, index): a list containing all the fcst variables requested in the fcst_var_val setting in the config file. This will be used to subset the input data that corresponds to a particular series. - """ fcst_var_val_dict = self.get_config_value('fcst_var_val_1') @@ -183,38 +153,6 @@ def _get_plot_stat(self) -> str: " Supported values are sum, mean, and median.") return stat_to_plot - def _config_consistency_check(self) -> bool: - """ - Checks that the number of settings defined for plot_ci, - plot_disp, series_order, user_legend colors, and series_symbols - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - series (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) - - """ - # Determine the number of series based on the number of - # permutations from the series_var setting in the - # config file - - # Numbers of values for other settings for series - num_plot_disp = len(self.plot_disp) - num_series_ord = len(self.series_ordering) - num_colors = len(self.colors_list) - num_legends = len(self.user_legends) - status = False - - if self.num_series == num_plot_disp == \ - num_series_ord == num_colors \ - == num_legends : - status = True - return status - def _get_user_legends(self, legend_label_type: str = '') -> list: """ Retrieve the text that is to be displayed in the legend at the bottom of the plot. @@ -267,8 +205,8 @@ def get_series_y(self) -> list: for x in reversed(list(all_fields_values_orig.keys())): all_fields_values[x] = all_fields_values_orig.get(x) - if self._get_fcst_vars("1"): - all_fields_values['fcst_var'] = list(self._get_fcst_vars("1").keys()) + if self.get_fcst_vars_keys(1): + all_fields_values['fcst_var'] = self.get_fcst_vars_keys(1) all_fields_values['stat_name'] = self.get_config_value('list_stat_1') return utils.create_permutations_mv(all_fields_values, 0) @@ -301,12 +239,9 @@ def calculate_number_of_series(self) -> int: """ # Retrieve the lists from the series_val_1 dictionary series_vals_list = self.series_vals_1.copy() - if isinstance(self.fcst_var_val_1, list) is True: - fcst_vals = self.fcst_var_val_1 - elif isinstance(self.fcst_var_val_1, dict) is True: - fcst_vals = list(self.fcst_var_val_1.values()) - fcst_vals_flat = [item for sublist in fcst_vals for item in sublist] - series_vals_list.append(fcst_vals_flat) + fcst_vals = list(self.fcst_var_val_1.values()) + fcst_vals = [item for sublist in fcst_vals for item in sublist] + series_vals_list.append(fcst_vals) # Utilize itertools' product() to create the cartesian product of all elements # in the lists to produce all permutations of the series_val values and the diff --git a/metplotpy/plots/bar/bar_series.py b/metplotpy/plots/bar/bar_series.py index f455bd154..ec4d329f9 100644 --- a/metplotpy/plots/bar/bar_series.py +++ b/metplotpy/plots/bar/bar_series.py @@ -20,7 +20,7 @@ from pandas import DataFrame import metcalcpy.util.utils as utils -from metplotpy.plots import util +from metplotpy.plots import util as util from .. import GROUP_SEPARATOR from ..series import Series diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 6925ce646..2493d6c9c 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -1,14 +1,13 @@ # ============================* - # ** Copyright UCAR (c) 2020 + # ** Copyright UCAR (c) 2026 # ** University Corporation for Atmospheric Research (UCAR) - # ** National Center for Atmospheric Research (NCAR) + # ** National Science Foundation National Center for Atmospheric Research (NSF NCAR) # ** Research Applications Lab (RAL) # ** P.O.Box 3000, Boulder, Colorado, 80307-3000, USA # ============================* -# !/usr/bin/env conda run -n blenny_363 python """ Class Name: base_plot.py """ @@ -17,30 +16,36 @@ import os import logging import warnings +from datetime import datetime import numpy as np +from matplotlib.axes import Axes +from matplotlib.font_manager import FontProperties + +from matplotlib import pyplot as plt + import yaml from typing import Union -import kaleido -import metplotpy.plots.util +from operator import add from metplotpy.plots.util import strtobool from .config import Config -from metplotpy.plots.context_filter import ContextFilter +from . import constants + -# kaleido 0.x will be deprecated after September 2025 and Chrome will no longer -# be included with kaleido from version 1.0.0. Explicitly get Chrome via call to kaleido. +### +# Global matplotlib default setting overrides +### -# In some instances, we do NOT want Chrome to be installed at run-time. If the -# PRE_LOAD_CHROME environment variable exists, or set to TRUE, -# then Chrome will be assumed to have been pre-loaded. Otherwise, -# invoke get_chrome_sync() to install Chrome in the -# /path-to-python-libs/pythonx.yz/site-packages/... directory +# set default for dashed and dotted lines to be longer and wider spaced +plt.rcParams['lines.dashed_pattern'] = [10, 10] +plt.rcParams['lines.dotted_pattern'] = [5, 5] -# Check if the PRE_LOAD_CHROME env variable exists -aquire_chrome = False +# Turn off spines globally +plt.rcParams['axes.spines.top'] = False +plt.rcParams['axes.spines.right'] = False turn_on_logging = strtobool('LOG_BASE_PLOT') # Log when Chrome is downloaded at runtime -if turn_on_logging is True: +if turn_on_logging: log = logging.getLogger("base_plot") log.setLevel(logging.INFO) @@ -49,24 +54,11 @@ # set the WRITE_LOG env var to True to save the log message to a # separate log file write_log = strtobool('WRITE_LOG') - if write_log is True: + if write_log: file_handler = logging.FileHandler("./base_plot.log") file_handler.setFormatter(formatter) log.addHandler(file_handler) -# Only load Chrome at run-time if PRE_LOAD_CHROME is False or not defined. -# Some applications may not want to load Chrome at runtime and -# will set the PRE_LOAD_CHROME to True to indicate that it is already -# loaded/downloaded prior to runtime. -chrome_env =strtobool ('PRE_LOAD_CHROME') -if chrome_env is False: - aquire_chrome=True - kaleido.get_chrome_sync() - - -# Log when kaleido is downloading Chrome -if aquire_chrome is True and turn_on_logging is True: - log.info("Plotly kaleido is loading Chrome at run time") class BasePlot: """A class that provides methods for building Plotly plot's common features @@ -76,10 +68,6 @@ class BasePlot: use as an abstract class for the common plot types """ - # image formats supported by plotly - IMAGE_FORMATS = ("png", "jpeg", "webp", "svg", "pdf", "eps") - DEFAULT_IMAGE_FORMAT = 'png' - def __init__(self, parameters, default_conf_filename): """Inits BasePlot with user defined and default dictionaries. Removes the old image if it exists @@ -115,56 +103,6 @@ def __init__(self, parameters, default_conf_filename): self.remove_file() self.config_obj = Config(self.parameters) - def get_image_format(self): - """Reads the image format type from user provided image name. - Uses file extension as a type. If the file extension is not valid - - returns 'png' as a default - - Args: - - Returns: - - image format - """ - - # get image name from properties - image_name = self.get_config_value('image_name') - if image_name: - - # extract and validate the file extension - strings = image_name.split('.') - if strings and strings[-1] in self.IMAGE_FORMATS: - return strings[-1] - - # print the message if invalid and return default - print('Unrecognised image format. png will be used') - return self.DEFAULT_IMAGE_FORMAT - - def get_legend(self): - """Creates a Plotly legend dictionary with values from users and default parameters - If users parameters dictionary doesn't have needed values - use defaults - - Args: - - Returns: - - dictionary used by Plotly to build the legend - """ - - current_legend = dict( - x=self.get_config_value('legend', 'x'), # x-position - y=self.get_config_value('legend', 'y'), # y-position - font=dict( - family=self.get_config_value('legend', 'font', 'family'), # font family - size=self.get_config_value('legend', 'font', 'size'), # font size - color=self.get_config_value('legend', 'font', 'color'), # font color - ), - bgcolor=self.get_config_value('legend', 'bgcolor'), # background color - bordercolor=self.get_config_value('legend', 'bordercolor'), # border color - borderwidth=self.get_config_value('legend', 'borderwidth'), # border width - xanchor=self.get_config_value('legend', 'xanchor'), # horizontal position anchor - yanchor=self.get_config_value('legend', 'yanchor') # vertical position anchor - ) - return current_legend - def get_legend_style(self): """ Retrieve the legend style settings that are set @@ -177,6 +115,7 @@ def get_legend_style(self): are set in METviewer """ legend_box = self.get_config_value('legend_box').lower() + borderwidth = 0 if legend_box == 'o': # Draws a box around the legend borderwidth = 1 @@ -191,129 +130,92 @@ def get_legend_style(self): legend_orientation = "v" legend_inset = self.get_config_value('legend_inset') legend_size = self.get_config_value('legend_size') - legend_settings = dict(border_width=borderwidth, - orientation=legend_orientation, - legend_inset=dict(x=legend_inset['x'], - y=legend_inset['y']), - legend_size=legend_size) + legend_settings = { + "border_width": borderwidth, + "orientation": legend_orientation, + "legend_inset": { + 'x': legend_inset['x'], + 'y': legend_inset['y'], + }, + 'legend_size': legend_size, + } return legend_settings - def get_title(self): - """Creates a Plotly title dictionary with values from users and default parameters - If users parameters dictionary doesn't have needed values - use defaults - - Args: - - Returns: - - dictionary used by Plotly to build the title - """ - current_title = dict( - text=self.get_config_value('title'), # plot's title - # Sets the container `x` refers to. "container" spans the entire `width` of the plot. - # "paper" refers to the width of the plotting area only. - xref="paper", - x=0.5 # x position with respect to `xref` - ) - return current_title - - def get_xaxis(self): - """Creates a Plotly x-axis dictionary with values from users and default parameters - If users parameters dictionary doesn't have needed values - use defaults - - Args: - - Returns: - - dictionary used by Plotly to build the x-axis - """ - current_xaxis = dict( - linecolor=self.get_config_value('xaxis', 'linecolor'), # x-axis line color - # whether or not a line bounding x-axis is drawn - showline=self.get_config_value('xaxis', 'showline'), - linewidth=self.get_config_value('xaxis', 'linewidth') # width (in px) of x-axis line - ) - return current_xaxis - - def get_yaxis(self): - """Creates a Plotly y-axis dictionary with values from users and default parameters - If users parameters dictionary doesn't have needed values - use defaults - - Args: - - Returns: - - dictionary used by Plotly to build the y-axis + def get_weights_size_styles(self): """ - current_yaxis = dict( - linecolor=self.get_config_value('yaxis', 'linecolor'), # y-axis line color - linewidth=self.get_config_value('yaxis', 'linewidth'), # width (in px) of y-axis line - # whether or not a line bounding y-axis is drawn - showline=self.get_config_value('yaxis', 'showline'), - # whether or not grid lines are drawn - showgrid=self.get_config_value('yaxis', 'showgrid'), - ticks=self.get_config_value('yaxis', 'ticks'), # whether ticks are drawn or not. - tickwidth=self.get_config_value('yaxis', 'tickwidth'), # Sets the tick width (in px). - tickcolor=self.get_config_value('yaxis', 'tickcolor'), # Sets the tick color. - # the width (in px) of the grid lines - gridwidth=self.get_config_value('yaxis', 'gridwidth'), - gridcolor=self.get_config_value('yaxis', 'gridcolor') # the color of the grid lines - ) - - # Sets the range of the range slider. defaults to the full y-axis range - y_range = self.get_config_value('yaxis', 'range') - if y_range is not None: - current_yaxis['range'] = y_range - return current_yaxis + Set up the font properties for the plot title: style (regular, italic), size, and + weight (normal, bold) for the title, captions, x-axis label, and y-axis label. - def get_xaxis_title(self): - """Creates a Plotly x-axis label title dictionary with values - from users and default parameters. - If users parameters dictionary doesn't have needed values - use defaults - - Args: - - Returns: - - dictionary used by Plotly to build the x-axis label title as annotation + Returns: + weights_size_styles: A dictionary containing the font property information + for the title, captions, x-axis label, and y-axis label """ - x_axis_label = dict( - x=self.get_config_value('xaxis', 'x'), # x-position of label - y=self.get_config_value('xaxis', 'y'), # y-position of label - showarrow=False, - text=self.get_config_value('xaxis', 'title', 'text'), - xref="paper", # the annotation's x coordinate axis - yref="paper", # the annotation's y coordinate axis - font=dict( - family=self.get_config_value('xaxis', 'title', 'font', 'family'), - size=self.get_config_value('xaxis', 'title', 'font', 'size'), - color=self.get_config_value('xaxis', 'title', 'font', 'color'), - ) - ) - return x_axis_label - - def get_yaxis_title(self): - """Creates a Plotly y-axis label title dictionary with values - from users and default parameters - If users parameters dictionary doesn't have needed values - use defaults - - Args: + weights_size_styles = {} + + # For title + title_property= FontProperties() + title_property.set_size(self.config_obj.title_size) + style = self.config_obj.title_weight[0] + wt = self.config_obj.title_weight[1] + title_property.set_style(style) + title_property.set_weight(wt) + weights_size_styles['title'] = title_property + + # For caption + caption_property = FontProperties() + caption_property.set_size(self.config_obj.caption_size) + cap_style = self.config_obj.caption_weight[0] + cap_wt = self.config_obj.caption_weight[1] + caption_property.set_style(cap_style) + caption_property.set_weight(cap_wt) + weights_size_styles['caption'] = caption_property + + # For xaxis label + xlab_property= FontProperties() + xlab_property.set_size(self.config_obj.x_title_font_size) + xlab_style = self.config_obj.xlab_weight[0] + xlab_wt = self.config_obj.xlab_weight[1] + xlab_property.set_style(xlab_style) + xlab_property.set_weight(xlab_wt) + weights_size_styles['xlab'] = xlab_property + + # For yaxis label + ylab_property = FontProperties() + ylab_property.set_size(self.config_obj.y_title_font_size) + ylab_style = self.config_obj.ylab_weight[0] + ylab_wt = self.config_obj.ylab_weight[1] + ylab_property.set_style(ylab_style) + ylab_property.set_weight(ylab_wt) + weights_size_styles['ylab'] = ylab_property + + # For x2axis label if set + if (hasattr(self.config_obj, 'x2lab_weight') + and self.config_obj.x2lab_weight is not None + and hasattr(self.config_obj, 'x2_title_font_size') + and self.config_obj.x2_title_font_size is not None): + x2lab_property= FontProperties() + x2lab_property.set_size(self.config_obj.x2_title_font_size) + x2lab_style, x2lab_wt = self.config_obj.x2lab_weight + x2lab_property.set_style(x2lab_style) + x2lab_property.set_weight(x2lab_wt) + weights_size_styles['x2lab'] = x2lab_property + + + # For y2axis label if set + if (hasattr(self.config_obj, 'y2lab_weight') + and self.config_obj.y2lab_weight is not None + and hasattr(self.config_obj, 'y2_title_font_size') + and self.config_obj.y2_title_font_size is not None): + y2lab_property= FontProperties() + y2lab_property.set_size(self.config_obj.y2_title_font_size) + y2lab_style, y2lab_wt = self.config_obj.y2lab_weight + y2lab_property.set_style(y2lab_style) + y2lab_property.set_weight(y2lab_wt) + weights_size_styles['y2lab'] = y2lab_property + + return weights_size_styles - Returns: - - dictionary used by Plotly to build the y-axis label title as annotation - """ - y_axis_label = dict( - x=self.get_config_value('yaxis', 'x'), # x-position of label - y=self.get_config_value('yaxis', 'y'), # y-position of label - showarrow=False, - text=self.get_config_value('yaxis', 'title', 'text'), - textangle=-90, # the angle at which the `text` is drawn with respect to the horizontal - xref="paper", # the annotation's x coordinate axis - yref="paper", # the annotation's y coordinate axis - font=dict( - family=self.get_config_value('xaxis', 'title', 'font', 'family'), - size=self.get_config_value('xaxis', 'title', 'font', 'size'), - color=self.get_config_value('xaxis', 'title', 'font', 'color'), - ) - ) - return y_axis_label def get_config_value(self, *args): """Gets the value of a configuration parameter. @@ -371,39 +273,21 @@ def get_img_bytes(self): return None - def save_to_file(self): - """Saves the image to a file specified in the config file. - Prints a message if fails - - Args: - - Returns: - - """ - image_name = self.get_config_value('plot_filename') - - # Suppress deprecation warnings from third-party packages that are not in our control. - warnings.filterwarnings("ignore", category=DeprecationWarning) - - # Create the directory for the output plot if it doesn't already exist - dirname = os.path.dirname(os.path.abspath(image_name)) - os.makedirs(dirname, exist_ok=True) - if self.figure: - try: - self.figure.write_image(image_name) - except FileNotFoundError: - self.logger.error(f"FileNotFoundError: Cannot save to file" - f" {image_name}") - # print("Can't save to file " + image_name) - except ResourceWarning: - self.logger.warning(f"ResourceWarning: in _kaleido" - f" {image_name}") - - except ValueError as ex: - self.logger.error(f"ValueError: Could not save output file.") - else: - self.logger.error(f"The figure {dirname} cannot be saved.") - print("Oops! The figure was not created. Can't save.") + def save_to_file(self, plot_filename=None, **kwargs) -> None: + """!Saves the plot to a file. + Add any arguments passed to the function directly to plt.savefig.""" + image_name = plot_filename if plot_filename else self.get_config_value('plot_filename') + + self.logger.info(f"Saving to file: {image_name} : {datetime.now()}") + os.makedirs(os.path.dirname(image_name), exist_ok=True) + plot_obj = plt if not self.figure else self.figure + try: + plot_obj.savefig(image_name, dpi=self.get_config_value('plot_res'), **kwargs) + self.logger.info(f"Finished saving plot {datetime.now()}") + except Exception as ex: + self.logger.error(f"Failed to save plot to file: {ex}") + finally: + plt.close('all') def remove_file(self): """Removes previously made image file . @@ -414,69 +298,27 @@ def remove_file(self): if image_name is not None and os.path.exists(image_name): os.remove(image_name) - def show_in_browser(self): - """Creates a plot and opens it in the browser. - - Args: + @staticmethod + def add_horizontal_line(ax: plt.Axes, y: float, line_properties: dict) -> None: + """Adds a horizontal line to the matplotlib plot - Returns: + @param ax: Matplotlib Axes object + @param y y value for the line + @param line_properties dictionary with line properties like color, width, dash + @returns None + """ + ax.axhline(y=y, xmin=0, xmax=1, **line_properties) - """ - if self.figure: - self.figure.show() - else: - self.logger.error(" Figure not created. Nothing to show in the " - "browser. ") - print("Oops! The figure was not created. Can't show") + @staticmethod + def add_vertical_line(ax: plt.Axes, x: float, line_properties: dict) -> None: + """Adds a vertical line to the matplotlib plot - def _add_lines(self, config_obj: Config, x_points_index: Union[list, None] = None) -> None: - """ Adds custom horizontal and/or vertical line to the plot. - All line's metadata is in the config_obj.lines - Args: - @config_obj - plot's configurations - @x_points_index - list of x-values that are used to create a plot - Returns: + @param ax: Matplotlib Axes object + @param x x value for the line + @param line_properties dictionary with line properties like color, width, dash + @returns None """ - if hasattr(config_obj, 'lines') and config_obj.lines is not None: - shapes = [] - for line in config_obj.lines: - # draw horizontal line - if line['type'] == 'horiz_line': - shapes.append(dict( - type='line', - yref='y', y0=line['position'], y1=line['position'], - xref='paper', x0=0, x1=0.95, - line={'color': line['color'], - 'dash': line['line_style'], - 'width': line['line_width']}, - )) - elif line['type'] == 'vert_line': - # draw vertical line - try: - if x_points_index is None: - val = line['position'] - else: - ordered_indy_label = config_obj.create_list_by_plot_val_ordering(config_obj.indy_label) - index = ordered_indy_label.index(line['position']) - val = x_points_index[index] - shapes.append(dict( - type='line', - yref='paper', y0=0, y1=1, - xref='x', x0=val, x1=val, - line={'color': line['color'], - 'dash': line['line_style'], - 'width': line['line_width']}, - )) - except ValueError: - line_position = line["position"] - self.logger.warning(f" Vertical line with position " - f"{line_position} cannot be created.") - print(f'WARNING: vertical line with position ' - f'{line_position} can\'t be created') - # ignore everything else - - # draw lines - self.figure.update_layout(shapes=shapes) + ax.axvline(x=x, ymin=0, ymax=1, **line_properties) @staticmethod def get_array_dimensions(data): @@ -492,3 +334,399 @@ def get_array_dimensions(data): np_array = np.array(data) return len(np_array.shape) + + def _add_title(self, ax, font_properties, title_override=None): + title = title_override if title_override else self.config_obj.title + ax.set_title( + title.replace('
', '\n'), + fontproperties=font_properties, + color=constants.DEFAULT_TITLE_COLOR, + pad=28, + x=self.config_obj.parameters['title_align'], + y=self.config_obj.title_offset, + ) + + def _add_caption(self, plt, font_properties): + y_pos = max(0.01, self.config_obj.caption_offset) + plt.figtext( + self.config_obj.caption_align, y_pos, + self.config_obj.plot_caption, + fontproperties=font_properties, + color=self.config_obj.parameters['caption_col'], + ) + + def _add_legend(self, ax: plt.Axes, handles_and_labels=None, loc='upper center') -> None: + """Creates a plot legend based on the properties from the config file. + Note: This should be called after adding the series, because the plot + labels need to be created before including them in the legend. + """ + orientation = "horizontal" if self.config_obj.legend_orientation == 'h' else "vertical" + + handles, labels = ax.get_legend_handles_labels() + if handles_and_labels: + handles = [item[0] for item in handles_and_labels] + labels = [item[1] for item in handles_and_labels] + + if not handles: + print("Warning: No labels found. Use ax.plot(..., label='name')") + + # handle plots that only have a single boolean for show legend + show_legend = self.config_obj.show_legend + if isinstance(show_legend, bool): + show_legend = [show_legend] * len(handles) + + # only show legend entries that have show_legend set to True + filtered_handles = [h for h, show in zip(handles, show_legend) if show == 1] + filtered_labels = [l for l, show in zip(labels, show_legend) if show == 1] + + legend = ax.legend( + handles=filtered_handles, + labels=filtered_labels, + bbox_to_anchor=(self.config_obj.bbox_x, self.config_obj.bbox_y), + loc=loc, + edgecolor=self.config_obj.legend_border_color, + frameon=self.config_obj.draw_box, + ncol=max(1, len(handles)) if orientation == "horizontal" else 1, + fontsize=self.config_obj.legend_size, + labelcolor="black" + ) + if legend: + frame = legend.get_frame() + frame.set_linewidth(self.config_obj.legend_border_width) + + def _add_xaxis(self, ax: plt.Axes, fontproperties: FontProperties, label=None, grid_on=None) -> None: + """ + Configures and adds x-axis to the plot. Handles vertical plot by switching x and y axis. + """ + is_vert = getattr(self.config_obj, 'vert_plot', False) + if label is None: + label = self.config_obj.xaxis if not is_vert else self.config_obj.yaxis_1 + + if grid_on is None: + grid_on = self.config_obj.grid_on + + ax.set_xlabel(label, fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['xlab_offset']) * constants.PIXELS_TO_POINTS) + + if self.config_obj.indy_label and not is_vert: + xtick_locs = self._get_xtick_locs() + ax.set_xticks(xtick_locs, self.config_obj.indy_label) + + ax.tick_params(axis="x", direction="in", which="both", labelrotation=self.config_obj.x_tickangle) + if grid_on: + ax.grid(True, which='major', axis='x', color=self.config_obj.blended_grid_col, + linestyle='-', linewidth=self.config_obj.parameters['grid_lwd']) + ax.set_axisbelow(True) + + # if vertical plot is requested, use y-axis settings for x-axis + if is_vert: + if len(self.config_obj.parameters['ylim']) > 0: + ax.set_xlim(self.config_obj.parameters['ylim']) + + if self.config_obj.yaxis_reverse: + ax.invert_xaxis() + + return + + self._set_xlim(ax) + + if self.config_obj.xaxis_reverse: + ax.invert_xaxis() + + def _set_xlim(self, ax: Axes): + if len(self.config_obj.parameters.get('xlim', [])) > 0: + # TODO: support xlim_step? only used for line plots + ax.set_xlim(self.config_obj.parameters['xlim']) + elif getattr(self.config_obj, 'start_from_zero', False): + xtick_locs = self._get_xtick_locs() + if len(xtick_locs) > 0: + ax.set_xlim(min(xtick_locs), max(xtick_locs)) + + def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties, label=None, grid_on=None) -> None: + """ + Configures and adds y-axis to the plot. Handles vertical plot by switching x and y axis. + """ + is_vert = getattr(self.config_obj, 'vert_plot', False) + if label is None: + label = self.config_obj.yaxis_1 if not is_vert else self.config_obj.xaxis + + if grid_on is None: + grid_on = self.config_obj.grid_on + + ax.set_ylabel(label, fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['ylab_offset']) * constants.PIXELS_TO_POINTS) + ax.tick_params(axis="y", direction="in", which="both", labelrotation=self.config_obj.y_tickangle) + + # add grid lines if requested + if grid_on: + ax.grid(True, which='major', axis='y', color=self.config_obj.blended_grid_col, linestyle='-', linewidth=self.config_obj.parameters['grid_lwd']) + ax.set_axisbelow(True) + + # if vertical plot is requested, use x-axis settings for y-axis + if is_vert: + if self.config_obj.indy_label: + xtick_locs = self._get_xtick_locs() + ax.set_yticks(xtick_locs, self.config_obj.indy_label) + + if getattr(self.config_obj, 'start_from_zero', False): + if len(xtick_locs) > 0: + ax.set_ylim(min(xtick_locs), max(xtick_locs)) + + if self.config_obj.xaxis_reverse: + ax.invert_yaxis() + + return + + # set y limits if min/max are defined in config + if len(self.config_obj.parameters['ylim']) > 0: + ax.set_ylim(self.config_obj.parameters['ylim']) + + if self.config_obj.yaxis_reverse: + ax.invert_yaxis() + + def _get_xtick_locs(self): + # use the indices as tick locations + xtick_locs = np.arange(len(self.config_obj.indy_label)) + if self.config_obj.indy_vals: + # Use the actual numeric values from indy_vals as tick locations + try: + xtick_locs = [float(i) for i in self.config_obj.indy_vals] + # if they are not numeric, revert to using the indices + except ValueError: + pass + + return xtick_locs + + + def _get_nstats(self) -> list: + """ + Calculates n_stats for the x2 axis. + Default implementation sums nstat across all active series. + """ + n_stats = [0] * len(self.config_obj.indy_vals) + for series in self.series_list: + if series.plot_disp: + # aggregate number of stats + n_stats = list(map(add, n_stats, series.series_points.get('nstat', []))) + + return n_stats + + def _add_x2axis(self, ax, fontproperties: FontProperties) -> None: + """ + Creates x2axis based on the properties from the config file. + + Note: This function is based on logic from individual plots that show number of stats (n_stats) + on the top x-axis. This will need to be modified if other plots display a 2nd x-axis + with other information. + + Note: this function may need to be called after adding the series, because some + plots add ticks that will conflict with the explicit x ticks set in this function. + Calliing this after will override the ticks and prevent a conflict. + + :param n_stats labels for the axis + """ + if not self.config_obj.show_nstats: + return + + num_lines = 1 + n_stats = self._get_nstats() + if n_stats and isinstance(n_stats, list) and len(n_stats) > 0 and isinstance(n_stats[0], list): + num_lines = len(n_stats[0]) + + # Adjust labelpad based on number of n_stats lines to avoid overlap + # Each line takes approximately fontsize points + some spacing + extra_pad = 2 + if num_lines > 1: + extra_pad = num_lines * self.config_obj.x2_tickfont_size * 1.2 + + label_args = { + 'fontproperties': fontproperties, + 'labelpad': (abs(self.config_obj.parameters['x2lab_offset']) * constants.PIXELS_TO_POINTS) + extra_pad, + } + + if not self.config_obj.vert_plot: + ax_top = ax.secondary_xaxis('top') + ax_top.set_xlabel('NStats', **label_args) + self._set_nstat_ticks(ax, ax_top, n_stats, is_vertical=False) + + # adjust title padding if x2 axis is shown on top + if num_lines > 1: + ax.set_title(ax.get_title(), + fontproperties=ax.title.get_fontproperties(), + color=ax.title.get_color(), + pad=extra_pad + 15, + x=self.config_obj.parameters['title_align'], + y=self.config_obj.title_offset) + + else: + ax_right = ax.secondary_yaxis('right') + ax_right.set_ylabel('NStats', **label_args) + self._set_nstat_ticks(ax, ax_right, n_stats, is_vertical=True) + + def _set_nstat_ticks(self, ax, ax_secondary, n_stats, is_vertical=False): + if not n_stats: + return + + if is_vertical: + current_locs = ax.get_yticks() + else: + current_locs = ax.get_xticks() + + # handle single value, single color n_stat (list of strings or ints) + if not isinstance(n_stats[0], list): + if is_vertical: + ax_secondary.set_yticks(current_locs, labels=n_stats, size=self.config_obj.x2_tickfont_size) + else: + ax_secondary.set_xticks(current_locs, labels=n_stats, size=self.config_obj.x2_tickfont_size) + return + + self._set_nstat_ticks_multiple(ax, ax_secondary, current_locs, is_vertical, n_stats) + + def _set_nstat_ticks_multiple(self, ax, ax_secondary, current_locs, is_vertical: bool, n_stats): + # handle n_stat for multiple series that are color coded + if is_vertical: + ax_secondary.set_yticks(current_locs) + ax_secondary.set_yticklabels([]) + transform = ax.get_yaxis_transform() # X=axes coords, Y=data coords + else: + ax_secondary.set_xticks(current_locs) + ax_secondary.set_xticklabels([]) + transform = ax.get_xaxis_transform() # X=data coords, Y=axes coords + + for i, loc in enumerate(current_locs): + # Avoid IndexError if current_locs has more ticks than n_stats + if i >= len(n_stats): + break + for j, stat_info in enumerate(n_stats[i]): + # Offset position for each series to mimic newlines + # Using offset points ensures consistent spacing regardless of plot size + if is_vertical: + x, y = 1.0, loc + offset_x = (j * self.config_obj.x2_tickfont_size * 1.2) + 2 + offset_y = 0 + ha, va = 'left', 'center' + else: + x, y = loc, 1.0 + offset_x = 0 + offset_y = (j * self.config_obj.x2_tickfont_size * 1.2) + 2 + ha, va = 'center', 'bottom' + + # Use the main axes 'ax' to add the text with offset points + ax.annotate(stat_info['val'], + xy=(x, y), + xycoords=transform, + xytext=(offset_x, offset_y), + textcoords='offset points', + color=stat_info['color'], + ha=ha, va=va, + fontsize=self.config_obj.x2_tickfont_size) + + def _add_y2axis(self, ax: plt.Axes, fontproperties: Union[FontProperties, None]): + """ + Adds y2-axis if needed + """ + ax_right = ax.twinx() + ax_right.spines['right'].set_visible(True) + ax_right.set_ylabel(self.config_obj.yaxis_2, fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['y2lab_offset']) * constants.PIXELS_TO_POINTS) + + # set y2 limits if defined in config + if len(self.config_obj.parameters['y2lim']) > 0: + ax_right.set_ylim(self.config_obj.parameters['y2lim']) + + return ax_right + + def _sync_yaxes(self, ax, ax2, yaxis_min: Union[float, None], yaxis_max: Union[float, None]): + if not self.config_obj.sync_yaxes or self.config_obj.vert_plot: + return + + # set y limits if defined in config or if min/max are provided + if len(self.config_obj.parameters['ylim']) > 0: + yaxis_min = self.config_obj.parameters['ylim'][0] + yaxis_max = self.config_obj.parameters['ylim'][1] + + if yaxis_min is not None and yaxis_max is not None: + ax.set_ylim(yaxis_min, yaxis_max) + ax2.set_ylim(yaxis_min, yaxis_max) + + def _add_lines(self, ax: plt.Axes, config_obj: Config, x_points_index: Union[list, None] = None) -> None: + """Adds custom horizontal and/or vertical line to the plot. + All line's metadata is in the config_obj.lines + Args: + @param ax - matplotlib Axes object + @param config_obj plot configuration object + @param x_points_index optional list of x-values that are used to create vertical line + """ + if not hasattr(config_obj, 'lines') or config_obj.lines is None: + return + + for line in config_obj.lines: + + # format line properties in format that matplotlib expects + line_properties = { + 'color': line['color'], + 'linewidth': line['line_width'], + 'linestyle': line['line_style'], + } + + # draw horizontal line + if line['type'] == 'horiz_line': + + y_position = line['position'] + self.add_horizontal_line(ax, y_position, line_properties) + + elif line['type'] == 'vert_line': + + # draw vertical line + x_position = line['position'] + try: + if x_points_index is not None: + ordered_indy_label = config_obj.create_list_by_plot_val_ordering( + config_obj.indy_label) + index = ordered_indy_label.index(line['position']) + x_position = x_points_index[index] + + self.add_vertical_line(ax, x_position, line_properties) + + except ValueError: + msg = f"Vertical line with position {x_position} cannot be created." + self.logger.warning(msg) + print(f"WARNING: {msg}") + + def _get_x_locs_and_width(self, x_points, index, stagger_scale=None): + if stagger_scale is None: + stagger_scale = constants.MPL_DEFAULT_BAR_WIDTH + + try: + # Attempt to convert x_points to floats (handles numeric indy_vals) + # Threshold values (e.g., ">5.0") will raise a ValueError/TypeError + base = np.array([float(x) for x in x_points]) + + if len(base) > 1: + # Calculate the minimum spacing between numeric x-points + # to determine an appropriate bar width. + sorted_base = np.sort(base) + spacing = np.diff(sorted_base) + min_spacing = np.min(spacing) + # Ensure spacing is positive to avoid zero-width bars + if min_spacing <= 0: + min_spacing = 1.0 + else: + min_spacing = 1.0 + except (ValueError, TypeError): + # Fallback to integer indices for non-numeric data (e.g., thresholds) + base = np.arange(len(x_points)) + min_spacing = 1.0 + + n_visible_series = sum(1 for s in self.series_list if s.plot_disp) + n = max(n_visible_series, 1) + + # Scale width and offset by min_spacing to ensure bars fit within the numeric gaps + width = (min_spacing * stagger_scale) / n + offset = (index - (n - 1) / 2.0) * width + x_locs = base + offset + return x_locs, width + + def write_output_file(self) -> None: + """To be implemented by child class""" + pass diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 35e9f522f..d43039645 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -21,10 +21,9 @@ from operator import add from itertools import chain import pandas as pd +import numpy as np -import plotly.graph_objects as go -from plotly.subplots import make_subplots -from plotly.graph_objects import Figure +from matplotlib import pyplot as plt import metcalcpy.util.utils as calc_util @@ -32,7 +31,7 @@ from metplotpy.plots.box.box_config import BoxConfig from metplotpy.plots.box.box_series import BoxSeries from metplotpy.plots import util -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR +from metplotpy.plots.constants import MPL_DEFAULT_BOX_WIDTH class Box(BasePlot): @@ -61,15 +60,7 @@ def __init__(self, parameters): self.logger.info(f"Start bar plot at {datetime.now()}") # Check that we have all the necessary settings for each series - is_config_consistent = self.config_obj._config_consistency_check() - self.logger.info("Checking consistency of user_legends, colors, etc...") - if not is_config_consistent: - raise ValueError("The number of series defined by series_val_1/2 and derived curves is" - " inconsistent with the number of settings" - " required for describing each series. Please check" - " the number of your configuration file's plot_i," - " plot_disp, series_order, user_legend," - " colors, show_legend and series_symbols settings.") + self.config_obj.config_consistency_check() # Read in input data, location specified in config file self.input_df = self._read_input_data() @@ -86,11 +77,6 @@ def __init__(self, parameters): # line width, and criteria needed to subset the input dataframe. self.series_list = self._create_series(self.input_df) - # create figure - # pylint:disable=assignment-from-no-return - # Need to have a self.figure that we can pass along to - # the methods in base_plot.py (BasePlot class methods) to - # create binary versions of the plot. self._create_figure() def _read_input_data(self): @@ -105,11 +91,9 @@ def _read_input_data(self): Returns: """ - self.config_obj.logger.info(f"Begin reading input data:" - f" {datetime.now()}") + self.config_obj.logger.info(f"Begin reading input data: {datetime.now()}") file = self.config_obj.parameters['stat_input'] - self.config_obj.logger.info(f"Finish reading input data:" - f" {datetime.now()}") + self.config_obj.logger.info(f"Finish reading input data: {datetime.now()}") return pd.read_csv(file, sep='\t', header='infer', float_precision='round_trip') def _create_series(self, input_data): @@ -128,8 +112,7 @@ def _create_series(self, input_data): """ - self.logger.info(f"Begin generating series objects: " - f"{datetime.now()}") + self.logger.info(f"Begin generating series objects: {datetime.now()}") series_list = [] # add series for y1 axis @@ -172,122 +155,126 @@ def _create_series(self, input_data): # reorder series series_list = self.config_obj.create_list_by_series_ordering(series_list) - self.logger.info(f"End generating series objects: " - f"{datetime.now()}") + if self.config_obj.xaxis_reverse: + series_list.reverse() + + self.logger.info(f"End generating series objects: {datetime.now()}") return series_list def _create_figure(self): """ Create a box plot from default and custom parameters""" - self.logger.info(f"Begin creating the figure: " - f"{datetime.now()}") - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_y2axis() - self._add_legend() - - # placeholder for the number of stats - n_stats = [0] * len(self.config_obj.indy_vals) - - # placeholder for the min and max values for y-axis - yaxis_min = None - yaxis_max = None - - if self.config_obj.xaxis_reverse is True: - self.series_list.reverse() + self.logger.info(f"Begin creating the figure: {datetime.now()}") - for series in self.series_list: - # Don't generate the plot for this series if - # it isn't requested (as set in the config file) - if series.plot_disp: - # collect min-max if we need to sync axis - if self.config_obj.sync_yaxes is True: - yaxis_min, yaxis_max = self._find_min_max(series, yaxis_min, yaxis_max) + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) - self._draw_series(series) + wts_size_styles = self.get_weights_size_styles() - # aggregate number of stats - n_stats = list(map(add, n_stats, series.series_points['nstat'])) + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) - # add custom lines - if len(self.series_list) > 0: - self._add_lines( - self.config_obj, - sorted(self.series_list[0].series_data[self.config_obj.indy_var].unique()) - ) + ax_y2 = None + if wts_size_styles.get('y2lab') and self.config_obj.parameters['list_stat_2']: + ax_y2 = self._add_y2axis(ax, wts_size_styles['y2lab']) - # apply y axis limits - self._yaxis_limits() - self._y2axis_limits() + handles_and_labels, yaxis_min, yaxis_max = self._add_series(ax, ax_y2) - # sync axis - self._sync_yaxis(yaxis_min, yaxis_max) + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) # add x2 axis - self._add_x2axis(n_stats) + if wts_size_styles.get('x2lab'): + self._add_x2axis(ax, wts_size_styles['x2lab']) + + self._sync_yaxes(ax, ax_y2, yaxis_min, yaxis_max) + self._add_legend(ax, handles_and_labels) - self.figure.update_layout(boxmode='group') + self._add_custom_lines(ax) - self.logger.info(f"End creating the figure: " - f"{datetime.now()}") + plt.tight_layout() - def _draw_series(self, series: BoxSeries) -> None: + self.logger.info(f"End creating the figure: {datetime.now()}") + + def _add_custom_lines(self, ax): + # add custom lines if lines are defined in config + if len(self.series_list) > 0: + self._add_lines(ax, self.config_obj, self.config_obj.indy_vals) + + def _draw_series(self, ax: plt.Axes, ax2, series: BoxSeries, idx: int): """ Draws the boxes on the plot :param series: Line series object with data and parameters """ - self.logger.info(f"Begin drawing the boxes on the plot for " - f"{series.series_name}: " - f"{datetime.now()}") - # defaults markers and colors for the regular box plot - line_color = dict(color='rgb(0,0,0)') - fillcolor = series.color - marker_color = 'rgb(0,0,0)' - marker_line_color = 'rgb(0,0,0)' - marker_symbol = 'circle-open' - - # markers and colors for points only plot - if self.config_obj.box_pts: - line_color = dict(color='rgba(0,0,0,0)') - fillcolor = 'rgba(0,0,0,0)' - marker_color = series.color - marker_symbol = 'circle' - marker_line_color = series.color - - # create a trace - self.figure.add_trace( - go.Box(x=series.series_data[self.config_obj.indy_var], - y=series.series_data['stat_value'], - notched=self.config_obj.box_notch, - line=line_color, - fillcolor=fillcolor, - name=series.user_legends, - showlegend=self.config_obj.show_legend[series.idx] == 1, - # quartilemethod='linear', #"exclusive", "inclusive", or "linear" - boxmean=self.config_obj.box_avg, - boxpoints=self.config_obj.boxpoints, # outliers, all, False - pointpos=0, - marker=dict(size=4, - color=marker_color, - line=dict( - width=1, - color=marker_line_color - ), - symbol=marker_symbol, - ), - jitter=0 - ), - secondary_y=series.y_axis != 1 - ) - - self.logger.info(f"End drawing the boxes on the plot: " - f"{datetime.now()}") - - @staticmethod - def _find_min_max(series: BoxSeries, yaxis_min: Union[float, None], + self.logger.info(f"Begin drawing the boxes on the plot for {series.series_name}: {datetime.now()}") + + # Group your 'stat_value' data by 'indy_var' categories first + data_to_plot = self._get_data_to_plot(series) + x_locs, width = self._get_x_locs_and_width(self.config_obj.indy_vals, idx) + + plot_ax = ax + if ax2 and ax2.get_ylabel() in series.series_data.stat_name.values: + plot_ax = ax2 + + # Define properties for median and mean lines + median_props = { + 'color': 'black', + 'linewidth': 1, + } + mean_props = { + 'linestyle': '--', + 'color': 'black', + 'linewidth': 1, + } + + boxplot = plot_ax.boxplot(data_to_plot, positions=x_locs, + patch_artist=True, + widths=width, + label=self.config_obj.user_legends[series.idx], + showmeans=self.config_obj.box_avg, + meanline=self.config_obj.box_avg, + medianprops=median_props, + meanprops=mean_props, + whis=self.config_obj.whis, + showfliers=self.config_obj.showfliers, + ) + + for box in boxplot['boxes']: + box.set_facecolor(series.color) + + return boxplot['boxes'][0] + + def _get_data_to_plot(self, series): + data_to_plot = [group_data for name, group_data in + series.series_data.groupby(self.config_obj.indy_var)['stat_value']] + return data_to_plot + + def _get_data_to_plot_and_x_locs(self, series, idx): + x_locs, width = self._get_x_locs_and_width(self.config_obj.indy_vals, idx) + + data_to_plot = [group_data for name, group_data in + series.series_data.groupby(self.config_obj.indy_var)['stat_value']] + return data_to_plot, x_locs, width + def _add_series(self, ax, ax2): + handles_and_labels = [] + yaxis_min = None + yaxis_max = None + + for idx, series in enumerate(self.series_list): + # Don't generate the plot for this series if + # it isn't requested (as set in the config file) + if series.plot_disp: + # collect min-max if we need to sync axis + if self.config_obj.sync_yaxes: + yaxis_min, yaxis_max = self._find_min_max(series, yaxis_min, yaxis_max) + + handle = self._draw_series(ax, ax2, series, idx) + handles_and_labels.append((handle, handle.get_label())) + + return handles_and_labels, yaxis_min, yaxis_max + + def _find_min_max(self, series: BoxSeries, yaxis_min: Union[float, None], yaxis_max: Union[float, None]) -> tuple: """ Finds min and max value between provided min and max and y-axis CI values of this series @@ -298,8 +285,7 @@ def _find_min_max(series: BoxSeries, yaxis_min: Union[float, None], :param yaxis_max: previously calculated max value :return: a tuple with calculated min/max """ - self.logger.info(f"Begin finding min and max CI values: " - f"{datetime.now()}") + self.logger.info(f"Begin finding min and max CI values: {datetime.now()}") # calculate series upper and lower limits of CIs indexes = range(len(series.series_points['dbl_med'])) upper_range = [series.series_points['dbl_med'][i] + series.series_points['dbl_up_ci'][i] @@ -310,274 +296,10 @@ def _find_min_max(series: BoxSeries, yaxis_min: Union[float, None], if yaxis_min is None or yaxis_max is None: return min(low_range), max(upper_range) - self.logger.info(f"End finding min and max CI values: " - f"{datetime.now()}") + self.logger.info(f"End finding min and max CI values: {datetime.now()}") return min(chain([yaxis_min], low_range)), max(chain([yaxis_max], upper_range)) - def _yaxis_limits(self) -> None: - """ - Apply limits on y2 axis if needed - """ - if len(self.config_obj.parameters['ylim']) > 0: - self.figure.update_layout(yaxis={'range': [self.config_obj.parameters['ylim'][0], - self.config_obj.parameters['ylim'][1]], - 'autorange': False}) - - def _y2axis_limits(self) -> None: - """ - Apply limits on y2 axis if needed - """ - if len(self.config_obj.parameters['y2lim']) > 0: - self.figure.update_layout(yaxis2={'range': [self.config_obj.parameters['y2lim'][0], - self.config_obj.parameters['y2lim'][1]], - 'autorange': False}) - - def _create_layout(self) -> Figure: - """ - Creates a new layout based on the properties from the config file - including plots size, annotation and title - - :return: Figure object - """ - # create annotation - annotation = [ - {'text': util.apply_weight_style(self.config_obj.parameters['plot_caption'], - self.config_obj.parameters['caption_weight']), - 'align': 'left', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': self.config_obj.parameters['caption_align'], - 'y': self.config_obj.caption_offset, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - }] - # create title - title = {'text': util.apply_weight_style(self.config_obj.title, - self.config_obj.parameters['title_weight']), - 'font': { - 'size': self.config_obj.title_font_size, - }, - 'y': self.config_obj.title_offset, - 'x': self.config_obj.parameters['title_align'], - 'xanchor': 'center', - 'xref': 'paper' - } - - # create a layout and allow y2 axis - fig = make_subplots(specs=[[{"secondary_y": True}]]) - - # add size, annotation, title - fig.update_layout( - width=self.config_obj.plot_width, - height=self.config_obj.plot_height, - margin=self.config_obj.plot_margins, - paper_bgcolor=PLOTLY_PAPER_BGCOOR, - annotations=annotation, - title=title, - plot_bgcolor=PLOTLY_PAPER_BGCOOR - ) - - fig.update_layout( - xaxis={ - 'tickmode': 'array', - 'tickvals': self.config_obj.indy_vals, - 'ticktext': self.config_obj.indy_label - } - ) - - return fig - - def _add_xaxis(self) -> None: - """ - Configures and adds x-axis to the plot - """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - ticks="inside", - zeroline=False, - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size} - ) - # reverse xaxis if needed - if hasattr( self.config_obj, 'xaxis_reverse' ) and self.config_obj.xaxis_reverse is True: - self.figure.update_xaxes(autorange="reversed") - - def _add_yaxis(self) -> None: - """ - Configures and adds y-axis to the plot - """ - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_1, - self.config_obj.parameters['ylab_weight']), - secondary_y=False, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - zeroline=False, - ticks="inside", - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.y_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['ylab_offset']), - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size}, - exponentformat='none' - ) - - def _add_y2axis(self) -> None: - """ - Adds y2-axis if needed - """ - if self.config_obj.parameters['list_stat_2']: - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_2, - self.config_obj.parameters['y2lab_weight']), - secondary_y=True, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - zeroline=False, - ticks="inside", - title_font={ - 'size': self.config_obj.y2_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['y2lab_offset']), - tickangle=self.config_obj.y2_tickangle, - tickfont={'size': self.config_obj.y2_tickfont_size}, - exponentformat='none' - ) - - def _sync_yaxis(self, yaxis_min: Union[float, None], yaxis_max: Union[float, None]) -> None: - """ - Forces y1 and y2 axes sync if needed by specifying the same limits on both axis. - Use ylim property to determine the limits. If this value is not provided - - use method parameters - - :param yaxis_min: min value or None - :param yaxis_max: max value or None - """ - if self.config_obj.sync_yaxes is True: - if len(self.config_obj.parameters['ylim']) > 0: - # use plot config parameter - range_min = self.config_obj.parameters['ylim'][0] - range_max = self.config_obj.parameters['ylim'][1] - else: - # use method parameter - range_min = yaxis_min - range_max = yaxis_max - - if range_min is not None and range_max is not None: - # update y axis - self.figure.update_layout(yaxis={'range': [range_min, - range_max], - 'autorange': False}) - - # update y2 axis - self.figure.update_layout(yaxis2={'range': [range_min, - range_max], - 'autorange': False}) - - def _add_x2axis(self, n_stats) -> None: - """ - Creates x2axis based on the properties from the config file - and attaches it to the initial Figure - - :param n_stats: - labels for the axis - """ - if self.config_obj.show_nstats: - self.figure.update_layout(xaxis2={'title_text': - util.apply_weight_style('NStats', - self.config_obj.parameters['x2lab_weight'] - ), - 'linecolor': PLOTLY_AXIS_LINE_COLOR, - 'linewidth': PLOTLY_AXIS_LINE_WIDTH, - 'overlaying': 'x', - 'side': 'top', - 'showgrid': False, - 'zeroline': False, - 'ticks': "inside", - 'title_font': { - 'size': self.config_obj.x2_title_font_size - }, - 'title_standoff': abs( - self.config_obj.parameters['x2lab_offset'] - ), - 'tickmode': 'array', - 'tickvals': self.config_obj.indy_vals, - 'ticktext': n_stats, - 'tickangle': self.config_obj.x2_tickangle, - 'tickfont': { - 'size': self.config_obj.x2_tickfont_size - }, - 'scaleanchor': 'x' - } - ) - # reverse x2axis if needed - if self.config_obj.xaxis_reverse is True: - self.figure.update_layout(xaxis2={'autorange':"reversed"}) - - # need to add an invisible line with all values = None - self.figure.add_trace( - go.Scatter(y=[None] * len(self.config_obj.indy_vals), x=self.config_obj.indy_vals, - xaxis='x2', showlegend=False) - ) - - def _add_legend(self) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure - """ - self.figure.update_layout(legend={'x': self.config_obj.bbox_x, - 'y': self.config_obj.bbox_y, - 'xanchor': 'center', - 'yanchor': 'top', - 'bordercolor': self.config_obj.legend_border_color, - 'borderwidth': self.config_obj.legend_border_width, - 'orientation': self.config_obj.legend_orientation, - 'font': { - 'size': self.config_obj.legend_size, - 'color': "black" - }, - 'traceorder': 'normal' - }) - if hasattr( self.config_obj, 'xaxis_reverse' ) and self.config_obj.xaxis_reverse is True: - self.figure.update_layout(legend={'traceorder':'reversed'}) - - def write_html(self) -> None: - """ - Is needed - creates and saves the html representation of the plot WITHOUT Plotly.js - """ - self.config_obj.logger.info(f"Begin writing HTML file: " - f"{datetime.now()}") - - # is_create = self.config_obj.create_html - if self.config_obj.create_html is True: - # construct the file name from plot_filename - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" - - # save html - self.figure.write_html(html_name, include_plotlyjs=False) - - self.logger.info(f"End writing HTML file: " - f"{datetime.now()}") - def write_output_file(self) -> None: """ Formats y1 and y2 series point data to the 2-dim arrays and saves them to the files @@ -589,44 +311,39 @@ def write_output_file(self) -> None: # otherwise use points_path path match = re.match(r'(.*)(.data)', self.config_obj.parameters['stat_input']) - if self.config_obj.dump_points_1 is True or self.config_obj.dump_points_2 is True and match: - filename = match.group(1) - # replace the default path with the custom - if self.config_obj.points_path is not None: - # get the file name - path = filename.split(os.path.sep) - if len(path) > 0: - filename = path[-1] + if not self.config_obj.dump_points_1 and not self.config_obj.dump_points_2 or not match: + return + + filename = match.group(1) + # replace the default path with the custom + if self.config_obj.points_path is not None: + filename = os.path.join(self.config_obj.points_path, os.path.basename(filename)) + + filename = f"{filename}.points1" + if os.path.exists(filename): + os.remove(filename) + # create directory if needed + os.makedirs(os.path.dirname(filename), exist_ok=True) + + for series in self.series_list: + for indy_val in self.config_obj.indy_vals: + if calc_util.is_string_integer(indy_val): + data_for_indy = series.series_data[ + series.series_data[self.config_obj.indy_var] == int(indy_val)] + elif calc_util.is_string_strictly_float(indy_val): + data_for_indy = series.series_data[ + series.series_data[self.config_obj.indy_var] == float(indy_val)] else: - filename = '.' + os.path.sep - filename = self.config_obj.points_path + os.path.sep + filename - - filename = filename + '.points1' - if os.path.exists(filename): - os.remove(filename) - # create directory if needed - os.makedirs(os.path.dirname(filename), exist_ok=True) - - for series in self.series_list: - for indy_val in self.config_obj.indy_vals: - if calc_util.is_string_integer(indy_val): - data_for_indy = series.series_data[ - series.series_data[self.config_obj.indy_var] == int(indy_val)] - elif calc_util.is_string_strictly_float(indy_val): - data_for_indy = series.series_data[ - series.series_data[self.config_obj.indy_var] == float(indy_val)] - else: - data_for_indy = series.series_data[ - series.series_data[self.config_obj.indy_var] == indy_val] - - file_object = open(filename, 'a') + data_for_indy = series.series_data[ + series.series_data[self.config_obj.indy_var] == indy_val] + + with open(filename, 'a') as file_object: file_object.write('\n') file_object.write(' '.join([str(elem) for elem in series.series_name]) + ' ' + indy_val) file_object.write('\n') - file_object.close() - quantile_data = data_for_indy['stat_value'].quantile([0, 0.25, 0.5, 0.75, 1]).iloc[::-1] - quantile_data.to_csv(filename, header=False, index=None, sep=' ', mode='a') - file_object.close() + + quantile_data = data_for_indy['stat_value'].quantile([0, 0.25, 0.5, 0.75, 1]).iloc[::-1] + quantile_data.to_csv(filename, header=False, index=None, sep=' ', mode='a') def main(config_filename=None): diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index 410fc8c39..34e618ca3 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -18,8 +18,8 @@ import itertools from ..config import Config -from .. import constants -from .. import util +from .. import constants as constants +from .. import util as util import metcalcpy.util.utils as utils @@ -57,12 +57,11 @@ def __init__(self, parameters: dict) -> None: # caption parameters self.caption_size = int(constants.DEFAULT_CAPTION_FONTSIZE * self.get_config_value('caption_size')) - self.caption_offset = self.parameters['caption_offset'] - 3.1 + self.caption_offset = self.parameters['caption_offset'] * constants.DEFAULT_CAPTION_Y_OFFSET ############################################## # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -73,12 +72,18 @@ def __init__(self, parameters: dict) -> None: self.y_tickfont_size = self.parameters['ytlab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## - # y2-axis parameters - self.y2_title_font_size = self.parameters['y2lab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.y2_tickangle = self.parameters['y2tlab_orient'] + # y2-axis parameters (optional - not used for revision box) + self.y2_title_font_size = None + if self.parameters.get('y2lab_size'): + self.y2_title_font_size = self.parameters['y2lab_size'] + constants.DEFAULT_TITLE_FONTSIZE + + self.y2_tickangle = self.parameters['y2tlab_orient'] if self.parameters.get('y2tlab_orient') else None if self.y2_tickangle in constants.YAXIS_ORIENTATION.keys(): self.y2_tickangle = constants.YAXIS_ORIENTATION[self.y2_tickangle] - self.y2_tickfont_size = self.parameters['y2tlab_size'] + constants.DEFAULT_TITLE_FONTSIZE + + self.y2_tickfont_size = None + if self.parameters.get('y2tlab_size'): + self.y2_tickfont_size = self.parameters['y2tlab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## # x-axis parameters @@ -87,15 +92,20 @@ def __init__(self, parameters: dict) -> None: if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) ############################################## - # x2-axis parameters - self.x2_title_font_size = self.parameters['x2lab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.x2_tickangle = self.parameters['x2tlab_orient'] + # x2-axis parameters (optional - not used for revision box) + self.x2_title_font_size = None + if self.parameters.get('x2lab_size'): + self.x2_title_font_size = self.parameters['x2lab_size'] + constants.DEFAULT_TITLE_FONTSIZE + + self.x2_tickangle = self.parameters['x2tlab_orient'] if self.parameters.get('x2tlab_orient') else None if self.x2_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x2_tickangle = constants.XAXIS_ORIENTATION[self.x2_tickangle] - self.x2_tickfont_size = self.parameters['x2tlab_size'] + constants.DEFAULT_TITLE_FONTSIZE + + self.x2_tickfont_size = None + if self.parameters.get('x2tlab_size'): + self.x2_tickfont_size = self.parameters['x2tlab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## # series parameters @@ -124,42 +134,24 @@ def __init__(self, parameters: dict) -> None: self.legend_orientation = 'v' else: self.legend_orientation = 'h' - self.legend_border_color = "black" - box_outline = self._get_bool('box_outline') - if box_outline is True: - self.boxpoints = 'outliers' - else: - self.boxpoints = False + # Default Matplotlib values for whiskers + self.whis = 1.5 + self.showfliers = True + self.box_avg = self._get_bool('box_avg') self.box_notch = self._get_bool('box_notch') self.box_pts = self._get_bool('box_pts') - if self.box_pts is True: + if self.box_pts: + self.showfliers = False self.boxpoints = 'all' - - def _get_plot_disp(self) -> list: - """ - Retrieve the values that determine whether to display a particular series - and convert them to bool if needed - - Args: - - Returns: - A list of boolean values indicating whether or not to - display the corresponding series - """ - - plot_display_config_vals = self.get_config_value('plot_disp') - plot_display_bools = [] - for val in plot_display_config_vals: - if isinstance(val, bool): - plot_display_bools.append(val) - - if isinstance(val, str): - plot_display_bools.append(val.upper() == 'TRUE') - - return self.create_list_by_series_ordering(plot_display_bools) + elif self._get_bool('box_outline'): + self.boxpoints = 'outliers' + else: + self.whis = [0, 100] + self.showfliers = False + self.boxpoints = False def _get_fcst_vars(self, index): """ @@ -209,38 +201,6 @@ def _get_plot_stat(self) -> str: " Supported values are sum, mean, and median.") return stat_to_plot - def _config_consistency_check(self) -> bool: - """ - Checks that the number of settings defined for plot_ci, - plot_disp, series_order, user_legend colors, and series_symbols - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - series (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) - - """ - # Determine the number of series based on the number of - # permutations from the series_var setting in the - # config file - - # Numbers of values for other settings for series - num_plot_disp = len(self.plot_disp) - num_series_ord = len(self.series_ordering) - num_colors = len(self.colors_list) - num_legends = len(self.user_legends) - status = False - - if self.num_series == num_plot_disp == \ - num_series_ord == num_colors \ - == num_legends: - status = True - return status - def _get_user_legends(self, legend_label_type: str = '') -> list: """ Retrieve the text that is to be displayed in the legend at the bottom of the plot. @@ -314,15 +274,18 @@ def get_series_y(self, axis: int) -> list: :param axis: y-axis (1 or 2) :return: an array of series components tuples """ - all_fields_values_orig = self.get_config_value('series_val_' + str(axis)).copy() + if not self.get_config_value(f'series_val_{axis}'): + return [] + + all_fields_values_orig = self.get_config_value(f'series_val_{axis}').copy() all_fields_values = {} for x in reversed(list(all_fields_values_orig.keys())): all_fields_values[x] = all_fields_values_orig.get(x) - if self._get_fcst_vars(axis): - all_fields_values['fcst_var'] = list(self._get_fcst_vars(axis).keys()) + if self.get_fcst_vars_keys(axis): + all_fields_values['fcst_var'] = self.get_fcst_vars_keys(axis) - all_fields_values['stat_name'] = self.get_config_value('list_stat_' + str(axis)) + all_fields_values['stat_name'] = self.get_config_value(f'list_stat_{axis}') return utils.create_permutations_mv(all_fields_values, 0) def _get_all_series_y(self, axis: int) -> list: @@ -353,9 +316,9 @@ def calculate_number_of_series(self) -> int: """ # Retrieve the lists from the series_val_1 dictionary series_vals_list = self.series_vals_1.copy() - if isinstance(self.fcst_var_val_1, list) is True: + if isinstance(self.fcst_var_val_1, list): fcst_vals = self.fcst_var_val_1 - elif isinstance(self.fcst_var_val_1, dict) is True: + elif isinstance(self.fcst_var_val_1, dict): fcst_vals = list(self.fcst_var_val_1.values()) else: fcst_vals = list() @@ -369,9 +332,9 @@ def calculate_number_of_series(self) -> int: if self.series_vals_2: series_vals_list_2 = self.series_vals_2.copy() - if isinstance(self.fcst_var_val_2, list) is True: + if isinstance(self.fcst_var_val_2, list): fcst_vals_2 = self.fcst_var_val_2 - elif isinstance(self.fcst_var_val_2, dict) is True: + elif isinstance(self.fcst_var_val_2, dict): fcst_vals_2 = list(self.fcst_var_val_2.values()) else: fcst_vals_2 = list() @@ -382,7 +345,9 @@ def calculate_number_of_series(self) -> int: total = len(permutations) # add derived - total = total + len(self.get_config_value('derived_series_1')) - total = total + len(self.get_config_value('derived_series_2')) + if self.get_config_value('derived_series_1'): + total = total + len(self.get_config_value('derived_series_1')) + if self.get_config_value('derived_series_2'): + total = total + len(self.get_config_value('derived_series_2')) return total diff --git a/metplotpy/plots/box/box_series.py b/metplotpy/plots/box/box_series.py index 9546be9ac..0778a2a99 100644 --- a/metplotpy/plots/box/box_series.py +++ b/metplotpy/plots/box/box_series.py @@ -22,7 +22,9 @@ from pandas import DataFrame import metcalcpy.util.utils as utils -import metplotpy.plots.util + +import metplotpy.plots.util as util + from ..series import Series @@ -215,7 +217,7 @@ def _calculate_derived_values(self, log_level = self.config.log_level log_filename = self.config.log_filename - logger = metplotpy.plots.util.get_common_logger(log_level, log_filename) + logger = util.get_common_logger(log_level, log_filename) logger.info(f"Start calculating derived values: " @@ -265,5 +267,4 @@ def _calculate_derived_values(self, else: self.series_data = pd.concat([self.series_data, (stats_indy_1)], sort=False) - logger.info(f"End calculating derived values: " - f"{datetime.now()}") + logger.info(f"End calculating derived values: {datetime.now()}") diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 88d02d4cc..f06f1cff5 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -17,6 +17,8 @@ import itertools from typing import Union +from datetime import datetime +import re import metcalcpy.util.utils as utils import metplotpy.plots.util @@ -45,8 +47,12 @@ def __init__(self, parameters): self.title_font = constants.DEFAULT_TITLE_FONT self.title_color = constants.DEFAULT_TITLE_COLOR self.xaxis = self.get_config_value('xaxis') + self.xaxis_reverse = False + self.yaxis_reverse = False + self.vert_plot = False self.yaxis_1 = self.get_config_value('yaxis_1') self.yaxis_2 = self.get_config_value('yaxis_2') + self.sync_yaxes = False self.title = self.get_config_value('title') self.use_ee = self._get_bool('event_equal') self.indy_vals = self.get_config_value('indy_vals') @@ -54,15 +60,12 @@ def __init__(self, parameters): self.indy_var = self.get_config_value('indy_var') self.show_plot_in_browser = self.get_config_value('show_plot_in_browser') - # Plot figure dimensions can be in either inches or pixels - pixels = self.get_config_value('plot_units') - plot_width = self.get_config_value('plot_width') - self.plot_width = self.calculate_plot_dimension(plot_width, pixels) - plot_height = self.get_config_value('plot_height') - self.plot_height = self.calculate_plot_dimension(plot_height, pixels) + # Plot figure dimensions should be in inches + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') self.plot_caption = self.get_config_value('plot_caption') # plain text, bold, italic, bold italic are choices in METviewer UI - self.caption_weight = self.get_config_value('caption_weight') + self.caption_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.get_config_value('caption_weight')] self.caption_color = self.get_config_value('caption_col') # relative magnification self.caption_size = self.get_config_value('caption_size') @@ -95,12 +98,17 @@ def __init__(self, parameters): self.legend_ncol = self.get_config_value('legend_ncol') legend_box = self.get_config_value('legend_box') self.draw_box = False + self.legend_border_width = 0 if legend_box is not None: legend_box = legend_box.lower() if legend_box == 'o': - # Don't draw a box around legend labels + # draw a box around legend labels self.draw_box = True + self.legend_border_width = 2 + self.legend_orientation = 'h' + if self.parameters['legend_ncol'] == 1: + self.legend_orientation = 'v' # some settings used by some but not all plot types @@ -108,16 +116,17 @@ def __init__(self, parameters): self.plot_margins = self.get_config_value('mar') self.grid_on = self._get_bool('grid_on') if self.get_config_value('mar_offset'): - self.plot_margins = dict(l=0, - r=self.parameters['mar'][3] + 20, - t=self.parameters['mar'][2] + 80, - b=self.parameters['mar'][0] + 80, - pad=5 - ) + self.plot_margins = { + 'l': 0, + 'r': self.parameters['mar'][3] + 20, + 't': self.parameters['mar'][2] + 80, + 'b': self.parameters['mar'][0] + 80, + 'pad': 5, + } self.grid_col = self.get_config_value('grid_col') if self.grid_col: - self.blended_grid_col = metplotpy.plots.util.alpha_blending(self.grid_col, 0.5) + self.blended_grid_col = metplotpy.plots.util.alpha_blending(self.grid_col, 0.5) self.show_nstats = self._get_bool('show_nstats') self.indy_stagger = self._get_bool('indy_stagger') @@ -147,9 +156,16 @@ def __init__(self, parameters): # re-create the METviewer xlab_weight. Use the # MV_TO_MPL_CAPTION_STYLE dictionary to map these caption styles to # what was requested in METviewer - mv_xlab_weight = self.get_config_value('xlab_weight') - self.xlab_weight = constants.MV_TO_MPL_CAPTION_STYLE[mv_xlab_weight] + xlab_weight = self.get_config_value('xlab_weight') + if xlab_weight is None: + xlab_weight = 1 + self.xlab_weight = constants.MV_TO_MPL_CAPTION_STYLE[int(xlab_weight)] + self.x2lab_weight = self.get_config_value('x2lab_weight') + if self.x2lab_weight is not None: + self.x2lab_weight = constants.MV_TO_MPL_CAPTION_STYLE[int(self.x2lab_weight)] + + self.x_title_font_size = self.parameters['xlab_size'] + constants.DEFAULT_TITLE_FONTSIZE self.x_tickangle = self.parameters['xtlab_orient'] if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] @@ -180,7 +196,13 @@ def __init__(self, parameters): # MV_TO_MPL_CAPTION_STYLE dictionary to map these caption styles to # what was requested in METviewer mv_ylab_weight = self.get_config_value('ylab_weight') - self.ylab_weight = constants.MV_TO_MPL_CAPTION_STYLE[mv_ylab_weight] + if mv_ylab_weight is None: + mv_ylab_weight = 1 + self.ylab_weight = constants.MV_TO_MPL_CAPTION_STYLE[int(mv_ylab_weight)] + + self.y2lab_weight = self.get_config_value('y2lab_weight') + if self.y2lab_weight is not None: + self.y2lab_weight = constants.MV_TO_MPL_CAPTION_STYLE[int(self.y2lab_weight)] # Adjust the caption left/right relative to the y-axis # METviewer default is set to 0, corresponds to y=0.05 in Matplotlib @@ -193,18 +215,16 @@ def __init__(self, parameters): mv_title_weight = self.get_config_value('title_weight') # use the same constants dictionary as used for captions - self.title_weight = constants.MV_TO_MPL_CAPTION_STYLE[mv_title_weight] + if mv_title_weight is None: + mv_title_weight = 1 + self.title_weight = constants.MV_TO_MPL_CAPTION_STYLE[int(mv_title_weight)] # These values can't be used as-is, the only choice for aligning in Matplotlib # are center (default), left, and right mv_title_align = self.get_config_value('title_align') self.title_align = float(mv_title_align) - # does nothing because the vertical position in Matplotlib is - # automatically chosen to avoid labels and ticks on the topmost - # x-axis - mv_title_offset = self.get_config_value('title_offset') - self.title_offset = float(mv_title_offset) + self.title_offset = 1.0 + abs(self.parameters['title_offset']) * constants.DEFAULT_TITLE_OFFSET # legend style settings as defined in METviewer user_settings = self._get_legend_style() @@ -229,11 +249,9 @@ def __init__(self, parameters): self.legend_ncol = self.get_config_value('legend_ncol') # Don't draw a box around legend labels unless an 'o' is set - self.draw_box = False legend_box = self.get_config_value('legend_box').lower() - - if legend_box == 'o': - self.draw_box = True + self.draw_box = legend_box == 'o' + self.legend_border_color = "black" # These are the inner keys to the series_val setting, and # they represent the series variables of @@ -325,13 +343,15 @@ def _get_legend_style(self) -> dict: legend_bbox_x = legend_inset['x'] legend_bbox_y = legend_inset['y'] legend_size = self.get_config_value('legend_size') - legend_settings = dict(bbox_x=legend_bbox_x, - bbox_y=legend_bbox_y, - legend_size=legend_size, - legend_ncol=legend_ncol, - legend_box=legend_box) + legend_settings = { + 'bbox_x': legend_bbox_x, + 'bbox_y': legend_bbox_y, + 'legend_size': legend_size, + 'legend_ncol': legend_ncol, + 'legend_box': legend_box, + } else: - legend_settings = dict() + legend_settings = {} return legend_settings @@ -404,6 +424,43 @@ def _get_fcst_vars(self, index: int) -> list: return all_fcst_vars + def get_fcst_vars_dict(self, index: int) -> dict: + """Retrieve a dictionary of the fcst_var_val_{index} variable from the config. + + Args: + index: identifier used to differentiate between fcst_var_val_1 and + fcst_var_val_2 config settings + Returns: + a list containing all the fcst variables requested in the + fcst_var_val setting in the config file. This will be + used to subset the input data that corresponds to a particular series. + + """ + if index not in (1, 2): + return {} + + fcst_dict = self.get_config_value(f'fcst_var_val_{index}') + if fcst_dict is None: + return {} + return fcst_dict + + def get_fcst_vars_keys(self, index: int) -> list: + """Retrieve a list of keys from the fcst_var_val_{index} variable from the config. + + Args: + index: identifier used to differentiate between fcst_var_val_1 and + fcst_var_val_2 config settings + Returns: + a list containing all the fcst variables requested in the + fcst_var_val setting in the config file. This will be + used to subset the input data that corresponds to a particular series. + + """ + fcst_vars_dict = self.get_fcst_vars_dict(index) + if fcst_vars_dict is None: + return [] + return list(fcst_vars_dict.keys()) + def _get_series_val_names(self) -> list: """ Get a list of all the variable value names (i.e. inner key of the @@ -446,11 +503,11 @@ def calculate_number_of_series(self) -> int: # Utilize itertools' product() to create the cartesian product of all elements # in the lists to produce all permutations of the series_val values and the # fcst_var_val values. - permutations = [p for p in itertools.product(*series_vals_list)] + permutations = list(itertools.product(*series_vals_list)) return len(permutations) - def _get_colors(self) -> list: + def _get_colors(self, config_name="colors") -> list: """ Retrieves the colors used for lines and markers, from the config file (default or custom). @@ -461,9 +518,18 @@ def _get_colors(self) -> list: (and their corresponding marker symbols) """ - colors_settings = self.get_config_value('colors') + colors_settings = self.get_config_value(config_name) + colors_settings = [self._format_color(color) for color in colors_settings] return self.create_list_by_series_ordering(list(colors_settings)) + @staticmethod + def _format_color(input_color): + if not input_color.startswith('rgb('): + return input_color + numbers = re.findall(r'\d+', input_color) + return tuple(int(num) / 255.0 for num in numbers) + + def _get_con_series(self) -> list: """ Retrieves the 'connect across NA' values used for lines and markers, from the @@ -520,12 +586,43 @@ def _get_markers(self): # markers is the matplotlib symbol: .,o, ^, d, H, or s markers_list.append(marker) else: - # markers are indicated by name: small circle, circle, triangle, - # diamond, hexagon, square + # markers are indicated by name or PCH number markers_list.append(constants.PCH_TO_MATPLOTLIB_MARKER[marker.lower()]) markers_list_ordered = self.create_list_by_series_ordering(list(markers_list)) return markers_list_ordered + def _get_markers_size(self) -> list: + """Convert marker names from the config file into matplotlib marker sizes. + Use the default marker size if the marker size is not a supported value. + + Args: + + Returns: + markers_size: a list of the integers that define the size of the markers + or None if the marker size is not a supported value. + """ + markers = self.get_config_value('series_symbols') + markers_size = [] + for marker in markers: + markers_size.append(constants.PCH_TO_MATPLOTLIB_MARKER_SIZE.get(marker)) + + return self.create_list_by_series_ordering(markers_size) + + def _get_markers_open(self) -> list: + """Parse info from markers to determine if they should be open or filled. + + Args: + + Returns: + a list of the boolean values to indicate if the marker should be open or filled. + """ + markers = self.get_config_value('series_symbols') + markers_open = [] + for marker in markers: + markers_open.append('open' in marker.lower() or 'small circle' in marker.lower()) + + return self.create_list_by_series_ordering(markers_open) + def _get_linewidths(self) -> Union[list, None]: """ Retrieve all the linewidths from the configuration file, if not specified in any config file, use the default values of 2 @@ -560,6 +657,16 @@ def _get_user_legends(self, legend_label_type: str ) -> list: Retrieve the text that is to be displayed in the legend at the bottom of the plot. Each entry corresponds to a series. + For legend labels that aren't set (ie in conf file they are set to '') + create a legend label based on the permutation of the series names + appended by 'user_legend label'. For example, for: + series_val_1: + model: + - NoahMPv3.5.1_d01 + vx_mask: + - CONUS + The constructed legend label will be "NoahMPv3.5.1_d01 CONUS Performance" + Args: @parm legend_label_type: The legend label, such as 'Performance', used when the user hasn't indicated a legend in the @@ -569,41 +676,7 @@ def _get_user_legends(self, legend_label_type: str ) -> list: a list consisting of the series label to be displayed in the plot legend. """ - all_legends = self.get_config_value('user_legend') - - # for legend labels that aren't set (ie in conf file they are set to '') - # create a legend label based on the permutation of the series names - # appended by 'user_legend label'. For example, for: - # series_val_1: - # model: - # - NoahMPv3.5.1_d01 - # vx_mask: - # - CONUS - # The constructed legend label will be "NoahMPv3.5.1_d01 CONUS Performance" - - - # Check for empty list as setting in the config file - legends_list = [] - - # set a flag indicating when a legend label is specified - legend_label_unspecified = True - - # Check if a stat curve was requested, if so, then the number - # of series_val_1 values will be inconsistent with the number of - # legend labels 'specified' (either with actual labels or whitespace) - - num_series = self.calculate_number_of_series() - if len(all_legends) == 0: - for i in range(num_series): - legends_list.append(' ') - else: - for legend in all_legends: - if len(legend) == 0: - legend = ' ' - legends_list.append(legend) - else: - legend_label_unspecified = False - legends_list.append(legend) + legends_list, legend_label_unspecified = self._get_legends_list() ll_list = [] series_list = self.all_series_vals @@ -615,8 +688,7 @@ def _get_user_legends(self, legend_label_type: str ) -> list: # check if summary_curve is present if 'summary_curve' in self.parameters.keys() and self.parameters['summary_curve'] != 'none': return [legend_label_type, self.parameters['summary_curve'] + ' ' + legend_label_type] - else: - return [legend_label_type] + return [legend_label_type] perms = utils.create_permutations(series_list) for idx,ll in enumerate(legends_list): @@ -635,6 +707,35 @@ def _get_user_legends(self, legend_label_type: str ) -> list: legends_list_ordered = self.create_list_by_series_ordering(ll_list) return legends_list_ordered + def _get_legends_list(self): + all_legends = self.get_config_value('user_legend') + + # Check for empty list as setting in the config file + legends_list = [] + + # set a flag indicating when a legend label is specified + legend_label_unspecified = True + + # Check if a stat curve was requested, if so, then the number + # of series_val_1 values will be inconsistent with the number of + # legend labels 'specified' (either with actual labels or whitespace) + + num_series = self.calculate_number_of_series() + if len(all_legends) == 0: + for _ in range(num_series): + legends_list.append(' ') + else: + for legend in all_legends: + if len(legend) == 0: + legend = ' ' + legends_list.append(legend) + else: + legend_label_unspecified = False + legends_list.append(legend) + + return legends_list, legend_label_unspecified + + def _get_plot_resolution(self) -> int: """ Retrieve the plot_res and plot_unit to determine the dpi @@ -646,36 +747,67 @@ def _get_plot_resolution(self) -> int: plot resolution in units of dpi (dots per inch) """ - # Initialize to the default resolution - # set by matplotlib + # Initialize to the default resolution set by matplotlib dpi = 100 # first check if plot_res is set in config file if self.get_config_value('plot_res'): resolution = self.get_config_value('plot_res') - # check if the units value has been set in the config file - if self.get_config_value('plot_units'): - units = self.get_config_value('plot_units').lower() - if units == 'in': - return resolution + # convert mm to inches + if self.get_config_value('plot_units') and self.get_config_value('plot_units').lower() == 'mm': + return resolution * constants.MM_TO_INCHES - if units == 'mm': - # convert mm to inches so we can - # set dpi value - return resolution * constants.MM_TO_INCHES - - # units not supported, assume inches - return resolution - - # units not indicated, assume - # we are dealing with inches + # units not indicated, assume we are dealing with inches or pixels return resolution # no plot_res value is set, return the default # dpi used by matplotlib return dpi + def _get_plot_disp(self) -> list: + """ + Retrieve the values that determine whether to display a particular series + and convert them to bool if needed + + Args: + + Returns: + A list of boolean values indicating whether or not to + display the corresponding series + """ + + plot_display_config_vals = self.get_config_value('plot_disp') + plot_display_bools = [] + for val in plot_display_config_vals: + if isinstance(val, bool): + plot_display_bools.append(val) + + if isinstance(val, str): + plot_display_bools.append(val.upper() == 'TRUE') + + return self.create_list_by_series_ordering(plot_display_bools) + + def _convert_units_to_inches(self, value, units): + units_lower = units.lower() + if units_lower == 'mm': + return value * constants.MM_TO_INCHES + if units_lower == 'cm': + return value * constants.CM_TO_INCHES + if units_lower == 'pixels': + # convert pixels to inches if resolution is available + if self.get_config_value('plot_res'): + return value / self.get_config_value('plot_res') + + return value + + + # if unsupported units are specified, log a warning but assume inches + if units_lower != 'in': + self.logger.warning(f"Invalid units specified: {units}. Expected in, mm, cm, or pixels. Assuming inches.") + + return value + def create_list_by_series_ordering(self, setting_to_order) -> list: """ Generate a list of series plotting settings based on what is set @@ -773,11 +905,10 @@ def create_list_by_plot_val_ordering(self, setting_to_order: str) -> list: return ordered_settings_list - def calculate_plot_dimension(self, config_value: str , output_units: str) -> int: + def calculate_plot_dimension(self, config_value: str) -> int: ''' To calculate the width or height that defines the size of the plot. - Matplotlib defines these values in inches, Python plotly defines these - in terms of pixels. METviewer accepts units of inches or mm for width and + Matplotlib defines these values in inches. METviewer accepts units of inches or mm for width and height, so conversion from mm to inches or mm to pixels is necessary, depending on the requested output units, output_units. @@ -785,43 +916,18 @@ def calculate_plot_dimension(self, config_value: str , output_units: str) -> int @param config_value: The plot dimension to convert, either a width or height, in inches or mm @param output_units: pixels or in (inches) to indicate which - units to use to define plot size. Python plotly uses pixels and - Matplotlib uses inches. + units to use to define plot size. Matplotlib uses inches. Returns: converted_value : converted value from in/mm to pixels or mm to inches based on input values ''' - + value2convert = self.get_config_value(config_value) - resolution = self.get_config_value('plot_res') units = self.get_config_value('plot_units') - # initialize converted_value to some small value - converted_value = 0 - - # convert to pixels - # plotly uses pixels for setting plot size (width and height) - if output_units.lower() == 'pixels': - if units.lower() == 'in': - # value in pixels - converted_value = int(resolution * value2convert) - elif units.lower() == 'mm': - # Convert mm to pixels - converted_value = int(resolution * value2convert * constants.MM_TO_INCHES) - # Matplotlib uses inches (in) for setting plot size (width and height) - elif output_units.lower() == 'in': - if units.lower() == 'mm': - # Convert mm to inches - converted_value = value2convert * constants.MM_TO_INCHES - else: - converted_value = value2convert - - # plotly does not allow any value smaller than 10 pixels - if output_units.lower() == 'pixels' and converted_value < 10: - converted_value = 10 + return self._convert_units_to_inches(value2convert, units) - return converted_value def _get_bool(self, param: str) -> Union[bool, None]: """ @@ -852,46 +958,114 @@ def _get_lines(self) -> Union[list, None]: Args: Returns: - :return: list of lines properties or None + :return: list of lines properties or None """ # get property value from the parameters lines = self.get_config_value('lines') + if lines is None: + return None # if the property exists - proceed - if lines is not None: - # validate data and replace the values - for line in lines: - - # validate line_type - line_type = line['type'] - if line_type not in ('horiz_line', 'vert_line') : - print(f'WARNING: custom line type {line["type"]} is not supported') + # validate data and replace the values + for line in lines: + + # validate line_type + if line['type'] not in ('horiz_line', 'vert_line') : + print(f'WARNING: custom line type {line["type"]} is not supported') + line['type'] = None + continue + + # convert position to float if line_type=horiz_line + if line['type'] == 'horiz_line': + try: + line['position'] = float(line['position']) + except ValueError: + print(f'WARNING: custom line position {line["position"]} is invalid') line['type'] = None - else: - # convert position to float if line_type=horiz_line - if line['type'] == 'horiz_line': - try: - line['position'] = float(line['position']) - except ValueError: - print(f'WARNING: custom line position {line["position"]} is invalid') - line['type'] = None - else: - # convert position to string if line_type=vert_line - line['position'] = str(line['position']) - - # convert line_style - line_style = line['line_style'] - if line_style in constants.LINE_STYLE_TO_PLOTLY_DASH.keys(): - line['line_style'] = constants.LINE_STYLE_TO_PLOTLY_DASH[line_style] - else: - line['line_style'] = None - - # convert line_width to float - try: - line['line_width'] = float(line['line_width']) - except ValueError: - print(f'WARNING: custom line width {line["line_width"]} is invalid') - line['type'] = None + else: + # convert position to string if line_type=vert_line + line['position'] = str(line['position']) + + # convert line_width to float + try: + line['line_width'] = float(line['line_width']) + except ValueError: + print(f'WARNING: custom line width {line["line_width"]} is invalid') + line['type'] = None + + # convert line style to matplotlib format if necessary + if line['line_style'] in constants.LINESTYLE_BY_NAMES: + line['line_style'] = constants.LINESTYLE_BY_NAMES[line['line_style']] return lines + + def config_consistency_check(self) -> None: + """Checks that the number of settings defined for + plot_disp, series_ordering, colors_list, user_legends, and show_legend + are consistent with number of series. + + @raises ValueError if any of settings are inconsistent with the + number of series (as defined by the cross product of the model + and vx_mask defined in the series_val_1 setting) + """ + lists_to_check = { + "plot_disp": self.plot_disp, + "series_ordering": self.series_ordering, + "colors_list": self.colors_list, + "user_legends": self.user_legends, + "show_legend": self.show_legend, + } + self._config_compare_lists_to_num_series(lists_to_check) + + def _config_compare_lists_to_num_series(self, lists_to_check: dict) -> list: + """ + Checks that the number of settings defined for lists are consistent + with the number of series to plot. + + Args: + @param lists_to_check: dictionary with name of list as key and + actual list to check as value. + + @raises ValueError if any settings are inconsistent with the number of series + """ + self.logger.info(f"Checking consistency of config settings relative to number of series {datetime.now()}") + + # Determine the number of series based on the number of + # permutations from the series_var setting in the config file + error_messages = [] + for name, list_to_check in lists_to_check.items(): + + if len(list_to_check) == self.num_series: + continue + + error_messages.append(f"{name} ({len(list_to_check)}) does not match number of series ({self.num_series})") + + if error_messages: + msg = ( + "The number of series defined by series_val_1/2 and derived curves is " + "inconsistent with the number of settings required for describing each series." + ) + msg += "\n" + "\n".join(error_messages) + self.logger.error(msg) + raise ValueError(msg) + + self.logger.info(f"Config consistency check completed successfully: {datetime.now()}") + + def _get_mode(self) -> list: + """Retrieve all the modes. Convert mode names from the config file into + strings that will determine which matplotlib settings to use. + 'both' - use both lines and markers + 'points' - use linestyle='None' to show only markers + 'lines' - use marker=None to show only lines + + Args: + + Returns: + modes: a list of strings to determine matplotlib settings to use + """ + modes = self.get_config_value('series_type') + mode_list = [] + for mode in modes: + mode_list.append(constants.SERIES_TYPE_TO_PLOT_MODE.get(mode, 'lines+markers')) + return self.create_list_by_series_ordering(mode_list) diff --git a/metplotpy/plots/config/histogram_2d_defaults.yaml b/metplotpy/plots/config/histogram_2d_defaults.yaml index 44d348808..d727583c4 100644 --- a/metplotpy/plots/config/histogram_2d_defaults.yaml +++ b/metplotpy/plots/config/histogram_2d_defaults.yaml @@ -1,7 +1,7 @@ plot_filename: './tmp_z2_p500.png' -height: 800 -width: 1200 +plot_height: 8.5 +plot_width: 11.0 font_size: 20 plot_units: in diff --git a/metplotpy/plots/config/histogram_defaults.yaml b/metplotpy/plots/config/histogram_defaults.yaml deleted file mode 100644 index c5707e735..000000000 --- a/metplotpy/plots/config/histogram_defaults.yaml +++ /dev/null @@ -1,69 +0,0 @@ -height: 400 -width: 600 -title: Histogram #plot's title -bargap: 0.2 -showlegend: True -legend_orientation: h -plot_bgcolor: rgba(0,0,0,0) -#['', 'percent', 'probability', 'density', 'probability','density'] -histnorm: - -plot_filename: histogram.png -scale: 2 - -opacity: 1 -orientation: v - -legend: - x: 0.5 - y: -0.3 - font: - family: sans-serif - size: 12 - color: black - bgcolor: White - bordercolor: Black - borderwidth: 2 - xanchor: center # horizontal position anchor: (“auto” | “left” | “center” | “right” ) - yanchor: top # vertical position anchor: (“auto” | “left” | “center” | “right” ) - -xaxis: - x: 0.5 # x-position of label - y: -0.2 # y-position of label - title: - text: x Axis - font: - family: Courier New, monospace - size: 14 - color: Black - linecolor: black #x-axis line color - showline: True #whether or not a line bounding x-axis is drawn - linewidth: 2 #width (in px) of x-axis line - -yaxis: - x: -0.1 # x-position of label - y: 0.5 # y-position of label - title: - text: y Axis - font: - family: Courier New, monospace - size: 14 - color: Black - linecolor: black # y-axis line color - linewidth: 2 # width (in px) of y-axis line - showline: True # whether or not a line bounding y-axis is drawn - showgrid: True # whether or not grid lines are drawn - ticks: inside # whether ticks are drawn or not. one of ( "outside" | "inside" | "" ) - tickwidth: 1 # Sets the tick width (in px). - tickcolor: black # Sets the tick color. - gridwidth: 1 # the width (in px) of the grid lines - gridcolor: rgb(244, 244, 248) # the color of the grid lines - #range: [0, 6000] #Sets the range of the range slider - -xbins: # bins used for histogram - #start: 0 - #end: 200 - size: 10 - -log_level: ERROR -log_filename: stdout diff --git a/metplotpy/plots/config/hovmoeller_defaults.yaml b/metplotpy/plots/config/hovmoeller_defaults.yaml index 4638b95db..cd1372138 100644 --- a/metplotpy/plots/config/hovmoeller_defaults.yaml +++ b/metplotpy/plots/config/hovmoeller_defaults.yaml @@ -35,7 +35,8 @@ log_filename: stdout # not used in this plot, but needed by the base config -plot_units: in +plot_units: pixels +plot_res: 100 xlab_align: 0.5 xlab_offset: 2 xlab_size: 1 diff --git a/metplotpy/plots/config/performance_diagram_defaults.yaml b/metplotpy/plots/config/performance_diagram_defaults.yaml index c808f2c21..63c747d18 100644 --- a/metplotpy/plots/config/performance_diagram_defaults.yaml +++ b/metplotpy/plots/config/performance_diagram_defaults.yaml @@ -20,7 +20,7 @@ yaxis_2: plot_width: 11 plot_height: 8.5 plot_res: 72 -plot_units: cm +plot_units: in plot_ci: - none - met_prm diff --git a/metplotpy/plots/config/tcmpr_defaults.yaml b/metplotpy/plots/config/tcmpr_defaults.yaml index 8d7fb0d4c..315279aef 100644 --- a/metplotpy/plots/config/tcmpr_defaults.yaml +++ b/metplotpy/plots/config/tcmpr_defaults.yaml @@ -7,7 +7,6 @@ colors: - '#ff0000' - '#800080' - '#FFA500' -create_html: 'False' derived_series_1: [] fixed_vars_vals_input: BASIN: diff --git a/metplotpy/plots/config/wind_rose_defaults.yaml b/metplotpy/plots/config/wind_rose_defaults.yaml index d1386cf31..ef1141540 100644 --- a/metplotpy/plots/config/wind_rose_defaults.yaml +++ b/metplotpy/plots/config/wind_rose_defaults.yaml @@ -19,7 +19,7 @@ wind_rose_marker_colors: - 'rgb(253,193,115)' - 'rgb(236,94,74)' - 'rgb(159,11,68)' -create_figure: True + show_legend: True angularaxis_tickvals: - 0 @@ -33,10 +33,10 @@ angularaxis_ticktext: - 'W' plot_filename: ./wind_rose_default.png stat_input: ./point_stat_mpr.txt -plot_width: 800 -plot_height: 620 +plot_width: 11.0 +plot_height: 8.5 dump_points: True -show_in_browser: False + # not used in this plot, but needed by the base config plot_units: in title_size: 1.4 @@ -78,9 +78,9 @@ caption_weight: 1 title_align: 0.5 title_offset: -2 title_weight: 2.0 -legend_box: o +legend_box: n legend_inset: - x: 0.0 - y: -0.25 + x: 0.55 + y: 0.85 legend_ncol: 1 legend_size: 0.8 diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 2c8fb35c6..b1bb077ef 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -22,6 +22,9 @@ # used to convert plot units in mm to # inches, so we can pass in dpi to matplotlib MM_TO_INCHES = 0.03937008 +CM_TO_INCHES = MM_TO_INCHES * 0.1 + +PIXELS_TO_POINTS = 0.72 # Available Matplotlib Line styles # ':' ... @@ -53,48 +56,59 @@ DEFAULT_TITLE_COLOR = 'black' DEFAULT_TITLE_FONTSIZE = 10 -# Default size used in plotly legend text +# Default size used in legend text DEFAULT_LEGEND_FONTSIZE = 12 DEFAULT_CAPTION_FONTSIZE = 14 -DEFAULT_CAPTION_Y_OFFSET = -3.1 +DEFAULT_CAPTION_Y_OFFSET = 0.01 DEFAULT_TITLE_FONT_SIZE = 11 -DEFAULT_TITLE_OFFSET = (-0.48) +DEFAULT_TITLE_OFFSET = 0.02 AVAILABLE_MARKERS_LIST = ["o", "^", "s", "d", "H", ".", "h"] -AVAILABLE_PLOTLY_MARKERS_LIST = ["circle-open", "circle", - "square", "diamond", - "hexagon", "triangle-up", "asterisk-open"] - -PCH_TO_MATPLOTLIB_MARKER = {'20': '.', '19': 'o', '17': '^', '1': 'H', - '18': 'd', '15': 's', 'small circle': '.', - 'circle': 'o', 'square': 's', - 'triangle': '^', 'rhombus': 'd', 'ring': 'h'} - -PCH_TO_PLOTLY_MARKER = {'0': 'circle-open', '19': 'circle', '20': 'circle', - '17': 'triangle-up', '15': 'square', '18': 'diamond', - '1': 'hexagon2', 'small circle': 'circle-open', - 'circle': 'circle', 'square': 'square', 'triangle': 'triangle-up', - 'rhombus': 'diamond', 'ring': 'hexagon2', '.': 'circle', - 'o': 'circle', '^': 'triangle-up', 'd': 'diamond', 'H': 'circle-open', - 'h': 'hexagon2', 's': 'square'} - -PCH_TO_PLOTLY_MARKER_SIZE = {'.': 5, 'o': 8, 's': 6, '^': 8, 'd': 6, 'H': 7} - -TYPE_TO_PLOTLY_MODE = {'b': 'lines+markers', 'p': 'markers', 'l': 'lines'} -LINE_STYLE_TO_PLOTLY_DASH = {'-': None, '--': 'dash', ':': 'dot', '-:': 'dashdot'} + +PCH_TO_MATPLOTLIB_MARKER = { + # R plotting characters + '20': '.', + '19': 'o', + '17': '^', + '1': 'H', + '18': 'd', + '15': 's', + 'small circle': 'o', # changed from . + 'circle': 'o', + 'square': 's', + 'triangle': '^', + 'rhombus': 'd', + 'ring': 'h', + # plotly marker strings for backwards compatibility + 'circle-open': 'o', # H? + 'triangle-up': '^', + 'diamond': 'd', + 'hexagon': 'h', + 'asterisk-open': '*', # .? +} + +# approximated from plotly marker size to matplotlib marker size +PCH_TO_MATPLOTLIB_MARKER_SIZE = {'.': 14, 'o': 36, 's': 20, '^': 36, 'd': 20, 'H': 28} + +SERIES_TYPE_TO_PLOT_MODE = {'b': 'lines+markers', 'p': 'markers', 'l': 'lines'} + XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} -PLOTLY_PAPER_BGCOOR = "white" -PLOTLY_AXIS_LINE_COLOR = "#c2c2c2" -PLOTLY_AXIS_LINE_WIDTH = 2 - # Caption weights supported in Matplotlib are normal, italic and oblique. # Map these onto the MetViewer requested values of 1 (normal), 2 (bold), # 3 (italic), 4 (bold italic), and 5 (symbol) using a dictionary -MV_TO_MPL_CAPTION_STYLE = {1:('normal', 'normal'), 2:('normal','bold'), 3:('italic', 'normal') - , 4:('italic', 'bold'),5:('oblique','normal')} +MV_TO_MPL_CAPTION_STYLE = { + 1: ('normal', 'normal'), + 2: ('normal','bold'), + 3: ('italic', 'normal'), + 4: ('italic', 'bold'), + 5: ('oblique','normal'), +} # Matplotlib constants MPL_FONT_SIZE_DEFAULT = 11 + +MPL_DEFAULT_BAR_WIDTH = 0.8 +MPL_DEFAULT_BOX_WIDTH = 0.5 diff --git a/metplotpy/plots/contour/contour.py b/metplotpy/plots/contour/contour.py index 233b6e47e..45a3ffa96 100644 --- a/metplotpy/plots/contour/contour.py +++ b/metplotpy/plots/contour/contour.py @@ -18,13 +18,13 @@ import re import csv +from typing import Union + import pandas as pd -import plotly.graph_objects as go -from plotly.subplots import make_subplots -from plotly.graph_objects import Figure +from matplotlib import pyplot as plt +from matplotlib.colors import ListedColormap -from metplotpy.plots.constants import PLOTLY_PAPER_BGCOOR from metplotpy.plots.base_plot import BasePlot from metplotpy.plots import util from metplotpy.plots.contour.contour_config import ContourConfig @@ -35,8 +35,7 @@ class Contour(BasePlot): - """ Generates a Plotly contour plot - """ + """Generates a contour plot""" defaults_name = 'contour_defaults.yaml' @@ -62,21 +61,7 @@ def __init__(self, parameters: dict) -> None: self.logger.info(f"Start contour plot: {datetime.now()}") # Check that we have all the necessary settings for each series - self.logger.info("Consistency checking of config settings for colors,legends, etc.") - is_config_consistent = self.config_obj._config_consistency_check() - if not is_config_consistent: - self.logger.error("ValueError: The number of series defined by " - "series_val_1 is inconsistent with the number of " - "settings required for describing each series. " - "Please check the number of your configuration" - " file's plot_disp, series_order, user_legend," - " colors settings. ") - raise ValueError("The number of series defined by series_val_1 is" - " inconsistent with the number of settings" - " required for describing each series. Please check" - " the number of your configuration file's " - " plot_disp, series_order, user_legend," - " colors settings.") + self.config_obj.config_consistency_check() # Read in input data, location specified in config file self.logger.info(f"Begin reading input data: {datetime.now()}") @@ -89,25 +74,14 @@ def __init__(self, parameters: dict) -> None: self.input_df = calc_util.perform_event_equalization(self.parameters, self.input_df) self.logger.info(f"Event equalization complete: {datetime.now()}") - # Create a list of series objects. - # Each series object contains all the necessary information for plotting, - # such as - # line width, and criteria needed to subset the input dataframe. self.series_list = self._create_series(self.input_df) - # create figure - # pylint:disable=assignment-from-no-return - # Need to have a self.figure that we can pass along to - # the methods in base_plot.py (BasePlot class methods) to - # create binary versions of the plot. self._create_figure() def __repr__(self): - """ Implement repr which can be useful for debugging this - class. - """ + """Implement repr which can be useful for debugging this class.""" - return f'Counture({self.parameters!r})' + return f'Countur({self.parameters!r})' def _read_input_data(self): """ @@ -140,7 +114,6 @@ def _create_series(self, input_data): self.logger.info(f"Generating series objects: {datetime.now()}") # add series for y1 axis - num_series_y1 = len(self.config_obj.get_series_y()) for i, name in enumerate(self.config_obj.get_series_y()): series_obj = ContourSeries(self.config_obj, i, input_data, series_list, name) series_list.append(series_obj) @@ -158,50 +131,62 @@ def _create_figure(self): self.logger.info(f"Creating the figure: {datetime.now()}") # create and draw the plot - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) - for series in self.series_list: + wts_size_styles = self.get_weights_size_styles() - # Don't generate the plot for this series if - # it isn't requested (as set in the config file) - if series.plot_disp: - self._draw_series(series) + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) - x_points_index = list(range(0, len(self.config_obj.indy_vals))) - ordered_indy_label = self.config_obj.create_list_by_plot_val_ordering(self.config_obj.indy_label) + self._add_series(ax) - # display only 5 tick labels on teh x-axis if + xlab_style = wts_size_styles['xlab'] if not self.config_obj.vert_plot else wts_size_styles['ylab'] + ylab_style = wts_size_styles['ylab'] if not self.config_obj.vert_plot else wts_size_styles['xlab'] + self._add_xaxis(ax, xlab_style, grid_on=False) + self._add_yaxis(ax, ylab_style, grid_on=False) + + plt.tight_layout() + + self.logger.info(f"Figure creating complete: {datetime.now()}") + + def _add_series(self, ax): + + # display only 5 tick labels on the x-axis if # - it is a date and # - the size of labels is more than 5 and - # - user did not provide custom labels (the x values and labels array are the same) + # - user did not provide custom labels (the x values and labels array are the same) - if self.config_obj.reverse_x is True: - ordered_indy_label.reverse() + ordered_indy_label = self.config_obj.create_list_by_plot_val_ordering(self.config_obj.indy_label) + ordered_indy_vals = self.config_obj.create_list_by_plot_val_ordering(self.config_obj.indy_vals) - if self.config_obj.indy_var in ['fcst_init_beg', 'fcst_valid_beg'] \ - and len(self.config_obj.indy_vals) > 5 \ - and ordered_indy_label == self.series_list[0].series_points['x']: + if (self.config_obj.indy_var in ['fcst_init_beg', 'fcst_valid_beg'] + and len(self.config_obj.indy_vals) > 5 + and ordered_indy_label == self.series_list[0].series_points['x']): step = int(len(self.config_obj.indy_vals) / 5) - ordered_indy_label_new = [''] * len(self.config_obj.indy_vals) - for i in range(0, len(ordered_indy_label), step): - ordered_indy_label_new[i] = ordered_indy_label[i] - ordered_indy_label = ordered_indy_label_new - - self.figure.update_layout( - xaxis={ - 'tickmode': 'array', - 'tickvals': x_points_index, - 'ticktext': ordered_indy_label, - 'type': 'category', - 'ticks': "outside" - } - ) - self.logger.info(f"Figure creating complete: {datetime.now()}") + indices_to_keep = list(range(0, len(ordered_indy_label), step)) + ordered_indy_label = [ordered_indy_label[i] for i in indices_to_keep] + + # Use indices as positions if not numeric (e.g. for dates) + try: + [float(i) for i in ordered_indy_vals] + ordered_indy_vals = [ordered_indy_vals[i] for i in indices_to_keep] + except (ValueError, TypeError): + ordered_indy_vals = indices_to_keep + + # add series points + for series in self.series_list: + + # Don't generate the plot for this series if + # it isn't requested (as set in the config file) + if not series.plot_disp: + continue + + self._draw_series(ax, series) - def _draw_series(self, series: Series) -> None: + self.config_obj.indy_label = ordered_indy_label + self.config_obj.indy_vals = ordered_indy_vals + + def _draw_series(self, ax, series: Series) -> None: """ Draws the data @@ -209,180 +194,49 @@ def _draw_series(self, series: Series) -> None: """ self.logger.info(f"Drawing the data: {datetime.now()}") line_width = self.config_obj.linewidth_list[series.idx] - if self.config_obj.add_contour_overlay is False: + if not self.config_obj.add_contour_overlay: line_width = 0 - # apply y axis limits - if len(self.config_obj.parameters['ylim']) > 0: - zmin = self.config_obj.parameters['ylim'][0] - zmax = self.config_obj.parameters['ylim'][1] - zauto = False - else: - zmin = None - zmax = None - zauto = True - - self.figure.add_trace( - go.Contour( - z=series.series_points['z'], - x=series.series_points['x'], - y=series.series_points['y'], - showscale=self.config_obj.add_color_bar, - ncontours=self.config_obj.contour_intervals, - line={'color': self.config_obj.colors_list[series.idx], - 'width': line_width, - 'dash': self.config_obj.linestyles_list[series.idx], - 'smoothing': 0}, - contours={ - # 'size': 10, - 'showlabels': True, - 'labelfont': { # label font properties - 'size': 10, - 'color': self.config_obj.colors_list[series.idx] - } - }, - colorscale=self.config_obj.color_palette, - zmin=zmin, - zmax=zmax, - zauto=zauto - ) + ylim = self.config_obj.parameters.get('ylim', []) + z_range = {'vmin': ylim[0], 'vmax': ylim[1]} if len(ylim) > 0 else {'vmin': None, + 'vmax': None} + + # add filled contours + contour_filled = ax.contourf( + series.series_points['x'], + series.series_points['y'], + series.series_points['z'], + levels=self.config_obj.contour_intervals, + cmap=ListedColormap(self.config_obj.color_palette), + **z_range ) - self.logger.info(f"Finished drawing data: {datetime.now()}") - def _create_layout(self) -> Figure: - """ - Creates a new layout based on the properties from the config file - including plots size, annotation and title - - :return: Figure object - """ - # create annotation - annotation = [ - {'text': util.apply_weight_style(self.config_obj.parameters['plot_caption'], - self.config_obj.parameters['caption_weight']), - 'align': 'left', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': self.config_obj.parameters['caption_align'], - 'y': self.config_obj.caption_offset, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - }] - # create title - title = {'text': util.apply_weight_style(self.config_obj.title, - self.config_obj.parameters['title_weight']), - 'font': { - 'size': self.config_obj.title_font_size, - }, - 'y': self.config_obj.title_offset, - 'x': self.config_obj.parameters['title_align'], - 'xanchor': 'center', - 'xref': 'paper' - } - - # create a layout and allow y2 axis - fig = make_subplots(specs=[[{"secondary_y": self.allow_secondary_y}]]) - - # add size, annotation, title - fig.update_layout( - width=self.config_obj.plot_width, - height=self.config_obj.plot_height, - margin=self.config_obj.plot_margins, - paper_bgcolor=PLOTLY_PAPER_BGCOOR, - annotations=annotation, - title=title, - plot_bgcolor=PLOTLY_PAPER_BGCOOR - ) - return fig - - def _add_xaxis(self) -> None: - """ - Configures and adds x-axis to the plot - """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - showgrid=False, - zeroline=False, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size} - ) - - def _add_yaxis(self) -> None: - """ - Configures and adds y-axis to the plot - """ - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_1, - self.config_obj.parameters['ylab_weight']), - secondary_y=False, - showgrid=False, - zeroline=False, - automargin=True, - title_font={ - 'size': self.config_obj.y_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['ylab_offset']), - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size} - ) - - def _add_legend(self) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure - """ - self.figure.update_layout(legend={'x': self.config_obj.bbox_x, - 'y': self.config_obj.bbox_y, - 'xanchor': 'center', - 'yanchor': 'top', - 'bordercolor': self.config_obj.legend_border_color, - 'borderwidth': self.config_obj.legend_border_width, - 'orientation': self.config_obj.legend_orientation, - 'font': { - 'size': self.config_obj.legend_size, - 'color': "black" - } - }) - - def remove_file(self): - """ - Removes previously made image file . Invoked by the parent class before self.output_file - attribute can be created, but overridden here. - """ - - super().remove_file() - self._remove_html() - - def _remove_html(self) -> None: - """ - Removes previously made HTML file. - """ - - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" + # add lines + if line_width > 0: + contour_lines = ax.contour( + series.series_points['x'], + series.series_points['y'], + series.series_points['z'], + levels=self.config_obj.contour_intervals, + colors=self.config_obj.colors_list[series.idx], + linewidths=line_width, + linestyles=self.config_obj.linestyles_list[series.idx], + **z_range + ) - # remove the old file if it exist - if os.path.exists(html_name): - os.remove(html_name) + # add line labels + ax.clabel( + contour_lines, + inline=True, + fontsize=10, + colors=self.config_obj.colors_list[series.idx] + ) - def write_html(self) -> None: - """ - Is needed - creates and saves the html representation of the plot WITHOUT Plotly.js - """ - if self.config_obj.create_html is True: - # construct the file name from plot_filename - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" + # add color bar + if self.config_obj.add_color_bar: + plt.colorbar(contour_filled, ax=ax, ticks=contour_filled.levels) - # save html - self.figure.write_html(html_name, include_plotlyjs=False) + self.logger.info(f"Finished drawing data: {datetime.now()}") def write_output_file(self) -> None: """ @@ -423,7 +277,7 @@ def write_output_file(self) -> None: writer.writerows(series.series_points['z']) file.writelines('\n') file.writelines('\n') - file.close() + self.logger.info(f"Finished writing output file: {datetime.now()}") diff --git a/metplotpy/plots/contour/contour_config.py b/metplotpy/plots/contour/contour_config.py index 6d28d4c76..f077ce195 100644 --- a/metplotpy/plots/contour/contour_config.py +++ b/metplotpy/plots/contour/contour_config.py @@ -45,16 +45,16 @@ def __init__(self, parameters: dict) -> None: self.log_filename = self.get_config_value('log_filename') # plot parameters - self.plot_width = self.calculate_plot_dimension('plot_width', 'pixels') - self.plot_height = self.calculate_plot_dimension('plot_height', 'pixels') - self.plot_margins = dict(l=0, - r=self.parameters['mar'][3] + 20, - t=self.parameters['mar'][2] + 80, - b=self.parameters['mar'][0] + 80, - pad=5 - ) + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') + self.plot_margins = { + 'l': 0, + 'r': self.parameters['mar'][3] + 20, + 't': self.parameters['mar'][2] + 80, + 'b': self.parameters['mar'][0] + 80, + 'pad': 5, + } self.dump_points_1 = self._get_bool('dump_points_1') - self.create_html = self._get_bool('create_html') self.plot_stat = self._get_plot_stat() ############################################## @@ -66,7 +66,6 @@ def __init__(self, parameters: dict) -> None: ############################################## # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -84,7 +83,6 @@ def __init__(self, parameters: dict) -> None: if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) ############################################## @@ -120,9 +118,14 @@ def __init__(self, parameters: dict) -> None: ############################################## self.contour_intervals = self.get_config_value('contour_intervals') self.color_palette = self._get_colorscale() + if self.contour_intervals > len(self.color_palette) - 1: + print(f"WARNING: Number of contour intervals ({self.contour_intervals}) " + f"is more than the number of colors in the color palette ({len(self.color_palette)})." + f" Setting contour intervals to {len(self.color_palette) - 1}") + self.contour_intervals = len(self.color_palette) - 1 self.add_color_bar = self._get_bool('add_color_bar') - self.reverse_x = self._get_bool('reverse_x') - self.reverse_y = self._get_bool('reverse_y') + self.xaxis_reverse = self._get_bool('reverse_x') or self._get_bool('xaxis_reverse') + self.yaxis_reverse = self._get_bool('reverse_y') or self._get_bool('yaxis_reverse') self.add_contour_overlay = self._get_bool('add_contour_overlay') def _get_colorscale(self): @@ -135,33 +138,10 @@ def _get_colorscale(self): """ color_palette = self.get_config_value('color_palette') if color_palette not in util.COLORSCALES.keys(): - print(f'WARNING: Color pallet {color_palette} doesn\'t supported. Using default pallet') + print(f"WARNING: Color pallet {color_palette} doesn't supported. Using default pallet") color_palette = 'green_red' return util.COLORSCALES[color_palette] - def _get_plot_disp(self) -> list: - """ - Retrieve the values that determine whether to display a particular series - and convert them to bool if needed - - Args: - - Returns: - A list of boolean values indicating whether or not to - display the corresponding series - """ - - plot_display_config_vals = self.get_config_value('plot_disp') - plot_display_bools = [] - for val in plot_display_config_vals: - if isinstance(val, bool): - plot_display_bools.append(val) - - if isinstance(val, str): - plot_display_bools.append(val.upper() == 'TRUE') - - return self.create_list_by_series_ordering(plot_display_bools) - def _get_fcst_vars(self, index): """ Retrieve a list of the inner keys (fcst_vars) to the fcst_var_val dictionary. @@ -188,56 +168,22 @@ def _get_fcst_vars(self, index): return fcst_var_val_dict - def _get_linestyles(self) -> list: - """ - Retrieve all the line styles. Convert line style names from - the config file into plotly python's line style names. - - Args: - - Returns: - line_styles: a list of the plotly line styles - """ - line_styles = self.get_config_value('series_line_style') - line_style_list = [] - for line_style in line_styles: - if line_style in constants.LINE_STYLE_TO_PLOTLY_DASH.keys(): - line_style_list.append(constants.LINE_STYLE_TO_PLOTLY_DASH[line_style]) - else: - line_style_list.append(None) - return self.create_list_by_series_ordering(line_style_list) - - def _config_consistency_check(self) -> bool: - """ - Checks that the number of settings defined for plot_ci, - plot_disp, series_order, user_legend colors, and series_symbols - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - series (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) + def config_consistency_check(self) -> None: + """Checks that the number of settings are consistent with number of series. + @raises ValueError if any of settings are inconsistent with the + number of series (as defined by the cross product of the model + and vx_mask defined in the series_val_1 setting) """ - # Determine the number of series based on the number of - # permutations from the series_var setting in the - # config file - - # Numbers of values for other settings for series - num_plot_disp = len(self.plot_disp) - num_series_ord = len(self.series_ordering) - num_legends = len(self.user_legends) - num_line_widths = len(self.linewidth_list) - num_linestyles = len(self.linestyles_list) - status = False - - if 1 == num_plot_disp == num_series_ord \ - == num_legends == num_line_widths == num_linestyles: - status = True - return status + lists_to_check = { + "plot_disp": self.plot_disp, + "series_ordering": self.series_ordering, + "colors_list": self.colors_list, + "user_legends": self.user_legends, + "linewidth_list": self.linewidth_list, + "linestyles_list": self.linestyles_list, + } + self._config_compare_lists_to_num_series(lists_to_check) def _get_user_legends(self, legend_label_type: str = '') -> list: """ @@ -275,8 +221,8 @@ def get_series_y(self) -> list: """ all_fields_values = {} - if self._get_fcst_vars(1): - all_fields_values['fcst_var'] = list(self._get_fcst_vars(1).keys()) + if self.get_fcst_vars_keys(1): + all_fields_values['fcst_var'] = self.get_fcst_vars_keys(1) stat_name = self.get_config_value('list_stat_1') if stat_name is not None: diff --git a/metplotpy/plots/contour/contour_series.py b/metplotpy/plots/contour/contour_series.py index 024bd3def..13b54b4c4 100644 --- a/metplotpy/plots/contour/contour_series.py +++ b/metplotpy/plots/contour/contour_series.py @@ -18,7 +18,7 @@ import numpy as np import warnings -import metplotpy.plots.util +import metplotpy.plots.util as util from ..series import Series @@ -34,8 +34,7 @@ def __init__(self, config, idx: int, input_data, series_list: list, series_name: Union[list, tuple], y_axis: int = 1): self.series_list = series_list self.series_name = series_name - self.logger = metplotpy.plots.util.get_common_logger(config.log_level, - config.log_filename) + self.logger = util.get_common_logger(config.log_level, config.log_filename) super().__init__(config, idx, input_data, y_axis) @@ -101,12 +100,8 @@ def _create_series_points(self) -> dict: self.logger.info(f"Creating the series points: {datetime.now()}") y_real = self.config.indy_vals.copy() - if self.config.reverse_x is True: - y_real.reverse() x_real = self.config.series_vals_1[0].copy() - if self.config.reverse_y is True: - x_real.reverse() z = [[None for i in range(len(y_real))] for j in range(len(x_real))] for ind_y, y in enumerate(y_real): diff --git a/metplotpy/plots/eclv/eclv.py b/metplotpy/plots/eclv/eclv.py index 358539d65..c08c75f5f 100644 --- a/metplotpy/plots/eclv/eclv.py +++ b/metplotpy/plots/eclv/eclv.py @@ -18,14 +18,13 @@ from operator import add from typing import Union import itertools +from datetime import datetime -import plotly.graph_objects as go +from matplotlib import pyplot as plt -from datetime import datetime from metcalcpy.event_equalize import event_equalize from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH from metplotpy.plots.eclv.eclv_config import EclvConfig from metplotpy.plots.eclv.eclv_series import EclvSeries from metplotpy.plots.line.line import Line @@ -65,24 +64,7 @@ def __init__(self, parameters: dict) -> None: self.logger.info(f"Start eclv plot: {datetime.now()}") # Check that we have all the necessary settings for each series - is_config_consistent = self.config_obj._config_consistency_check() - if not is_config_consistent: - self.logger.error("ValueError: The number of series defined by " - "series_val_1 is " - "inconsistent with the number of settings " - "required for" - " describing each series. Please check the number " - "of" - " your configuration file's plot_i, plot_disp, " - "series_order, user_legend, colors and " - f"series_symbols settings. {datetime.now()}") - - raise ValueError("The number of series defined by series_val_1 is" - " inconsistent with the number of settings" - " required for describing each series. Please check" - " the number of your configuration file's plot_i," - " plot_disp, series_order, user_legend," - " colors, and series_symbols settings.") + self.config_obj.config_consistency_check() # Read in input data, location specified in config file self.logger.info(f"Begin reading input data: {datetime.now()}") @@ -114,11 +96,6 @@ def __init__(self, parameters: dict) -> None: # line width, and criteria needed to subset the input dataframe. self.series_list = self._create_series(self.input_df) - # create figure - # pylint:disable=assignment-from-no-return - # Need to have a self.figure that we can pass along to - # the methods in base_plot.py (BasePlot class methods) to - # create binary versions of the plot. self.logger.info(f"Begin creating the figure: {datetime.now()}") self._create_figure() self.logger.info(f"End creating the figure: {datetime.now()}") @@ -172,33 +149,6 @@ def _create_figure(self): Create a eclv plot from defaults and custom parameters """ self.logger.info(f"Begin creating the figure: {datetime.now()}") - # create and draw the plot - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() - - # placeholder for the number of stats - n_stats = [0] * len(self.series_list[0].series_points[0]['x_pnt']) - - # add series lines - for series in self.series_list: - - # Don't generate the plot for this series if - # it isn't requested (as set in the config file) - if series.plot_disp: - self._draw_series(series) - - # aggregate number of stats - for series_points in series.series_points: - n_stats = list(map(add, n_stats, series_points['nstat'])) - - # add custom lines - if len(self.series_list) > 0: - self._add_lines(self.config_obj) - - # apply y axis limits - self._yaxis_limits() # some x points could be very close to each other and the x-axis ticktext is # bunched up do not print the ticktext for the first points by creating the @@ -210,76 +160,67 @@ def _create_figure(self): else: self.x_axis_ticktext.append(var_round) - self.figure.update_layout( - xaxis=dict( - tickmode='array', - tickvals=self.series_list[0].series_points[0]['x_pnt'], - ticktext=self.x_axis_ticktext, - tickangle=self.config_obj.x_tickangle - ), - yaxis=dict( - zeroline=True, - zerolinecolor=PLOTLY_AXIS_LINE_COLOR, - zerolinewidth=PLOTLY_AXIS_LINE_WIDTH - ) - ) + self.config_obj.indy_label = self.x_axis_ticktext + self.config_obj.indy_vals = self.series_list[0].series_points[0]['x_pnt'] + + # create and draw the plot + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) + + wts_size_styles = self.get_weights_size_styles() + + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) + + self._add_series(ax) + + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) # add x2 axis - self._add_x2axis(n_stats) + if wts_size_styles.get('x2lab'): + self._add_x2axis(ax, wts_size_styles['x2lab']) + + self._add_legend(ax) + + # add custom lines + self._add_lines(ax, self.config_obj, self.config_obj.indy_vals) + + plt.tight_layout() self.logger.info(f"Finished creating the figure: {datetime.now()}") - def _add_x2axis(self, n_stats) -> None: - """ - Creates x2axis based on the properties from the config file - and attaches it to the initial Figure + def _add_series(self, ax, ax2=None): + for series in self.series_list: + if not series.plot_disp: + continue + + self._draw_series(ax, ax2, series) - :param n_stats: - labels for the axis + def _get_nstats(self) -> list: """ + Calculates n_stats for the x2 axis. + """ + n_stats = [0] * len(self.series_list[0].series_points[0]['x_pnt']) + for series in self.series_list: + if not series.plot_disp: + continue + + # aggregate number of stats + for series_points in series.series_points: + n_stats = list(map(add, n_stats, series_points['nstat'])) + + x_points = [] + + # create ticktext array similar to x-axis ticktext + for idx, val in enumerate(self.x_axis_ticktext): + if val != '': + x_points.append(n_stats[idx]) + else: + x_points.append('') + + return x_points - if self.config_obj.show_nstats: - x_points = [] - - # create ticktext array simolar to x-axis ticktext - for idx, val in enumerate(self.x_axis_ticktext): - if val != '': - x_points.append(n_stats[idx]) - else: - x_points.append('') - - self.figure.update_layout(xaxis2={ - 'linecolor': PLOTLY_AXIS_LINE_COLOR, - 'linewidth': PLOTLY_AXIS_LINE_WIDTH, - 'overlaying': 'x', - 'side': 'top', - 'showgrid': False, - 'zeroline': False, - 'ticks': "inside", - 'title_font': { - 'size': self.config_obj.x2_title_font_size - }, - 'tickmode': 'array', - 'tickvals': self.series_list[0].series_points[0]['x_pnt'], - 'ticktext': x_points, - 'tickangle': self.config_obj.x2_tickangle, - 'tickfont': { - 'size': self.config_obj.x2_tickfont_size - }, - 'scaleanchor': 'x', - 'automargin': False, - 'matches': 'x', - } - ) - - # need to add an invisible line with all values = None - self.figure.add_trace( - go.Scatter( - y=[None] * len(self.series_list[0].series_points[0]['x_pnt']), - x=self.series_list[0].series_points[0]['x_pnt'], - xaxis='x2', showlegend=False) - ) - - def _draw_series(self, series: Series, + def _draw_series(self, ax: plt.Axes, ax2, series: Series, x_points_index_adj: Union[list, None] = None) -> None: """ Draws the formatted line with CIs if needed on the plot @@ -288,59 +229,22 @@ def _draw_series(self, series: Series, :param x_points_index_adj: values for adjusting x-values position """ self.logger.info(f"Begin drawing the series : {datetime.now()}") - # pct series can have mote than one line + + # pct series can have more than one line for ind, series_points in enumerate(series.series_points): y_points = series_points['dbl_med'] x_points = series_points['x_pnt'] - # show or not ci - # see if any ci values in not 0 - no_ci_up = all(v == 0 for v in series_points['dbl_up_ci']) - no_ci_lo = all(v == 0 for v in series_points['dbl_lo_ci']) - error_y_visible = True - if (no_ci_up is True and no_ci_lo is True) or self.config_obj.plot_ci[ - series.idx] == 'NONE': - error_y_visible = False - - # add the plot - self.figure.add_trace( - go.Scatter(x=x_points, - y=y_points, - showlegend=ind == 0, - mode=self.config_obj.mode[series.idx], - textposition="top right", - name=self.config_obj.user_legends[series.idx], - connectgaps=self.config_obj.con_series[series.idx] == 1, - line={'color': self.config_obj.colors_list[series.idx], - 'width': self.config_obj.linewidth_list[series.idx], - 'dash': self.config_obj.linestyles_list[series.idx]}, - marker_symbol=self.config_obj.marker_list[series.idx], - marker_color=self.config_obj.colors_list[series.idx], - marker_line_color=self.config_obj.colors_list[series.idx], - marker_size=self.config_obj.marker_size[series.idx], - error_y={'type': 'data', - 'symmetric': False, - 'array': series_points['dbl_up_ci'], - 'arrayminus': series_points['dbl_lo_ci'], - 'visible': error_y_visible, - 'thickness': self.config_obj.linewidth_list[ - series.idx]}, - hovertemplate="
".join([ - "Cost/Lost Ratio: %{customdata}", - "Economic Value: %{y}" - ]), - customdata=x_points - ), - secondary_y=False - ) - - self.logger.info(f"Finished drawing the series :" - f" {datetime.now()}") + self._draw_series_item(series, series_points, ax, ax2, x_points, y_points) + + self.logger.info(f"Finished drawing the series : {datetime.now()}") def write_output_file(self) -> None: """ saves series points to the files """ + if not self.config_obj.dump_points_1: + return self.logger.info(f"Begin writing output file: {datetime.now()}") @@ -348,40 +252,35 @@ def write_output_file(self) -> None: # (the input data file) except replace the .data # extension with .points1 extension match = re.match(r'(.*)(.data)', self.config_obj.parameters['stat_input']) + if not match: + return + + filename = match.group(1) + # replace the default path with the custom + if self.config_obj.points_path is not None: + filename = os.path.join(self.config_obj.points_path, os.path.basename(filename)) + + filename = filename + '.points1' + os.makedirs(os.path.dirname(filename), exist_ok=True) + + with open(filename, 'w') as file_handle: + writer = csv.writer(file_handle, delimiter='\t') + for series in self.series_list: + for vals_ind, vals in enumerate(series.series_points): + keys = sorted(vals.keys()) + if vals_ind == 0: + writer.writerow(keys) + else: + file_handle.writelines('\n') + for ind, dbl_med in enumerate(vals['dbl_med']): + vals['dbl_lo_ci'][ind] = dbl_med - vals['dbl_lo_ci'][ind] + vals['dbl_up_ci'][ind] = dbl_med + vals['dbl_up_ci'][ind] + writer.writerows( + zip(*[[round(num, 6) for num in vals[key]] for key in + keys])) + file_handle.writelines('\n') + file_handle.writelines('\n') - if self.config_obj.dump_points_1 is True and match: - filename = match.group(1) - # replace the default path with the custom - if self.config_obj.points_path is not None: - # get the file name - path = filename.split(os.path.sep) - if len(path) > 0: - filename = path[-1] - else: - filename = '.' + os.path.sep - filename = self.config_obj.points_path + os.path.sep + filename - - filename = filename + '.points1' - os.makedirs(os.path.dirname(filename), exist_ok=True) - - with open(filename, 'w') as file: - writer = csv.writer(file, delimiter='\t') - for series in self.series_list: - for vals_ind, vals in enumerate(series.series_points): - keys = sorted(vals.keys()) - if vals_ind == 0: - writer.writerow(keys) - else: - file.writelines('\n') - for ind, dbl_med in enumerate(vals['dbl_med']): - vals['dbl_lo_ci'][ind] = dbl_med - vals['dbl_lo_ci'][ind] - vals['dbl_up_ci'][ind] = dbl_med + vals['dbl_up_ci'][ind] - writer.writerows( - zip(*[[round(num, 6) for num in vals[key]] for key in - keys])) - file.writelines('\n') - file.writelines('\n') - file.close() self.logger.info(f"Finished writing output file: {datetime.now()}") diff --git a/metplotpy/plots/eclv/eclv_config.py b/metplotpy/plots/eclv/eclv_config.py index 82b17e43f..ac806f7fb 100644 --- a/metplotpy/plots/eclv/eclv_config.py +++ b/metplotpy/plots/eclv/eclv_config.py @@ -60,41 +60,26 @@ def _get_user_legends(self, legend_label_type: str = '') -> list: return self.create_list_by_series_ordering(legend_list) - def _config_consistency_check(self) -> bool: - """ - Checks that the number of settings defined for plot_ci, - plot_disp, series_order, user_legend colors, and series_symbols - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - series (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) + def config_consistency_check(self): + """Checks that the number of settings are consistent with number of series. + @raises ValueError if any of settings are inconsistent with the + number of series (as defined by the cross product of the model + and vx_mask defined in the series_val_1 setting) """ - # Determine the number of series based on the number of - # permutations from the series_var setting in the - # config file - - # Numbers of values for other settings for series - num_ci_settings = len(self.plot_ci) - num_plot_disp = len(self.plot_disp) - num_markers = len(self.marker_list) - num_series_ord = len(self.series_ordering) - num_colors = len(self.colors_list) - num_legends = len(self.user_legends) - num_line_widths = len(self.linewidth_list) - num_linestyles = len(self.linestyles_list) - status = False - - if self.num_series == num_plot_disp == \ - num_markers == num_series_ord == num_colors \ - == num_legends == num_line_widths == num_linestyles == num_ci_settings: - status = True - return status + + lists_to_check = { + "plot_ci": self.plot_ci, + "plot_disp": self.plot_disp, + "marker_list": self.marker_list, + "series_ordering": self.series_ordering, + "colors_list": self.colors_list, + "user_legends": self.user_legends, + "linewidth_list": self.linewidth_list, + "linestyles_list": self.linestyles_list, + "show_legend": self.show_legend, + } + self._config_compare_lists_to_num_series(lists_to_check) def calculate_number_of_series(self) -> int: """ diff --git a/metplotpy/plots/eclv/eclv_series.py b/metplotpy/plots/eclv/eclv_series.py index 94433a3ad..585205bf1 100644 --- a/metplotpy/plots/eclv/eclv_series.py +++ b/metplotpy/plots/eclv/eclv_series.py @@ -18,7 +18,7 @@ from scipy.stats import norm import metcalcpy.util.utils as utils -import metplotpy.plots.util +import metplotpy.plots.util as util from ..line.line_series import LineSeries @@ -40,8 +40,7 @@ def _create_series_points(self) -> list: Returns: dictionary with CI ,point values and number of stats as keys """ - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Creating series points: {datetime.now()}") # different ways to subset data for normal and derived series diff --git a/metplotpy/plots/ens_ss/ens_ss.py b/metplotpy/plots/ens_ss/ens_ss.py index f74d56378..1f456114a 100644 --- a/metplotpy/plots/ens_ss/ens_ss.py +++ b/metplotpy/plots/ens_ss/ens_ss.py @@ -21,12 +21,10 @@ import numpy as np import pandas as pd -import plotly.graph_objects as go -from plotly.subplots import make_subplots -from plotly.graph_objects import Figure +from matplotlib import pyplot as plt +from matplotlib import ticker from metcalcpy.event_equalize import event_equalize -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR from metplotpy.plots.ens_ss.ens_ss_config import EnsSsConfig from metplotpy.plots.ens_ss.ens_ss_series import EnsSsSeries from metplotpy.plots.base_plot import BasePlot @@ -62,21 +60,13 @@ def __init__(self, parameters: dict) -> None: self.logger.info(f"Start Ens_ss plot: {datetime.now()}") # Check that we have all the necessary settings for each series - self.logger.info(f"Consistency checking of config settings for colors, " - f"legends, etc.{datetime.now()}") - is_config_consistent = self.config_obj._config_consistency_check() - self.logger.info(f"Finished consistency checking of config settings for colors, " - f"legends, etc.{datetime.now()}") - if not is_config_consistent: - value_error_msg = ("ValueError: The number of series defined by " - "series_val_1 and " - "derived curves is inconsistent with the number of " - "settings required for describing each series. Please " - "check the number of your configuration file's " - "plot_i, plot_disp, series_order, user_legend, show_legend and " - "colors settings.") - self.logger.error(value_error_msg) - raise ValueError(value_error_msg) + self.config_obj.config_consistency_check() + + # if plotting points, add show_legend True in between each show legend value + # do this after the consistency check to ensure the number of + # show_legend values matches the number of series before adding to the list + if self.config_obj.ensss_pts_disp: + self.config_obj.show_legend = [val for item in self.config_obj.show_legend for val in (item, 1)] # Read in input data, location specified in config file self.logger.info(f"Begin reading input data: {datetime.now()}") @@ -94,11 +84,6 @@ def __init__(self, parameters: dict) -> None: # line width, and criteria needed to subset the input dataframe. self.series_list = self._create_series(self.input_df) - # create figure - # pylint:disable=assignment-from-no-return - # Need to have a self.figure that we can pass along to - # the methods in base_plot.py (BasePlot class methods) to - # create binary versions of the plot. self._create_figure() def _perform_event_equalization(self): @@ -204,14 +189,13 @@ def _create_series(self, input_data): for i, name in enumerate(self.config_obj.get_series_y(1)): series_obj = EnsSsSeries(self.config_obj, i, input_data, series_list, name) series_list.append(series_obj) - if self.config_obj.ensss_pts_disp is True: + if self.config_obj.ensss_pts_disp: series_list.append(series_obj) # reorder series series_list = self.config_obj.create_list_by_series_ordering(series_list) - self.logger.info(f"Finished creating series objects:" - f" {datetime.now()}") + self.logger.info(f"Finished creating series objects: {datetime.now()}") return series_list @@ -223,276 +207,94 @@ def _create_figure(self): self.logger.info(f"Begin creating the figure: {datetime.now()}") # create and draw the plot - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) - self._add_y2axis() - self._add_legend() + wts_size_styles = self.get_weights_size_styles() - # add series lines - i = 0 - counter = 1 - if self.config_obj.ensss_pts_disp is True: - counter = 2 - # for series in self.series_list: - while i < len(self.series_list): + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) - # Don't generate the plot for this series if - # it isn't requested (as set in the config file) - if self.series_list[i].plot_disp: - self._draw_series(self.series_list[i]) - i = i + counter + ax_y2 = None + if self.config_obj.ensss_pts_disp: + ax_y2 = self._add_y2axis(ax, wts_size_styles['y2lab']) - # add custom lines - if len(self.series_list) > 0: - self._add_lines(self.config_obj) - - # apply y axis limits - self._yaxis_limits() - self._y2axis_limits() + # format large numbers like 3 million as 3M + ax_y2.yaxis.set_major_formatter(ticker.EngFormatter()) - self.logger.info(f"Finished creating the figure: {datetime.now()}") + handles_and_labels = self._add_series(ax, ax_y2) - def _add_y2axis(self) -> None: - """ - Adds y2-axis if needed - """ - if self.config_obj.ensss_pts_disp is True: - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_2, - self.config_obj.parameters['y2lab_weight'] - ), - secondary_y=True, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - zeroline=False, - ticks="inside", - title_font={ - 'size': self.config_obj.y2_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['y2lab_offset']), - tickangle=self.config_obj.y2_tickangle, - tickfont={'size': self.config_obj.y2_tickfont_size} - ) - - def _y2axis_limits(self) -> None: - """ - Apply limits on y2 axis if needed - """ - if len(self.config_obj.parameters['y2lim']) > 0: - self.figure.update_layout(yaxis2={'range': [self.config_obj.parameters['y2lim'][0], - self.config_obj.parameters['y2lim'][1]], - 'autorange': False}) + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) - def _draw_series(self, series: EnsSsSeries) -> None: - """ - Draws the formatted line on the plot + self._add_legend(ax, handles_and_labels) - :param series: EnsSs series object with data and parameters - """ - self.logger.info(f"Begin drawing the series on the plot:" - f" {datetime.now()}") + self._add_custom_lines(ax) - # add the plot - self.figure.add_trace( - go.Scatter(x=series.series_points['spread_skill'], - y=series.series_points['mse'], - showlegend=self.config_obj.show_legend[series.idx] == 1, - mode=self.config_obj.mode[series.idx], - textposition="top right", - name=self.config_obj.user_legends[series.idx], - line={'color': self.config_obj.colors_list[series.idx], - 'width': self.config_obj.linewidth_list[series.idx], - 'dash': self.config_obj.linestyles_list[series.idx]}, - marker_symbol=self.config_obj.marker_list[series.idx], - marker_color=self.config_obj.colors_list[series.idx], - marker_line_color=self.config_obj.colors_list[series.idx], - marker_size=self.config_obj.marker_size[series.idx] - ), - secondary_y=series.y_axis != 1 - ) - - # add PTS - if self.config_obj.ensss_pts_disp is True: - self.figure.add_trace( - go.Scatter(x=series.series_points['spread_skill'], - y=series.series_points['pts'], - showlegend=True, - mode=self.config_obj.mode[series.idx + 1], - textposition="top right", - name=self.config_obj.user_legends[series.idx + 1], - line={'color': self.config_obj.colors_list[series.idx + 1], - 'width': self.config_obj.linewidth_list[series.idx + 1], - 'dash': self.config_obj.linestyles_list[series.idx + 1]}, - marker_symbol=self.config_obj.marker_list[series.idx + 1], - marker_color=self.config_obj.colors_list[series.idx + 1], - marker_line_color=self.config_obj.colors_list[series.idx + 1], - marker_size=self.config_obj.marker_size[series.idx + 1] - ), - secondary_y=True - ) - - self.logger.info(f"Finished drawing the series on the plot:" - f" {datetime.now()}") - - def _create_layout(self) -> Figure: - """ - Creates a new layout based on the properties from the config file - including plots size, annotation and title + plt.tight_layout() - :return: Figure object - """ - # create annotation - annotation = [ - {'text': util.apply_weight_style(self.config_obj.parameters['plot_caption'], - self.config_obj.parameters['caption_weight']), - 'align': 'left', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': self.config_obj.parameters['caption_align'], - 'y': self.config_obj.caption_offset, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - }] - # create title - title = {'text': util.apply_weight_style(self.config_obj.title, - self.config_obj.parameters['title_weight']), - 'font': { - 'size': self.config_obj.title_font_size, - }, - 'y': self.config_obj.title_offset, - 'x': self.config_obj.parameters['title_align'], - 'xanchor': 'center', - 'xref': 'paper' - } - - # create a layout and allow y2 axis - fig = make_subplots(specs=[[{"secondary_y": True}]]) - - # add size, annotation, title - fig.update_layout( - width=self.config_obj.plot_width, - height=self.config_obj.plot_height, - margin=self.config_obj.plot_margins, - paper_bgcolor=PLOTLY_PAPER_BGCOOR, - annotations=annotation, - title=title, - plot_bgcolor=PLOTLY_PAPER_BGCOOR - ) - return fig - - def _add_xaxis(self) -> None: - """ - Configures and adds x-axis to the plot - """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - ticks="inside", - zeroline=False, - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size} - ) - # reverse xaxis if needed - if self.config_obj.xaxis_reverse is True: - self.figure.update_xaxes(autorange="reversed") - - def _add_yaxis(self) -> None: - """ - Configures and adds y-axis to the plot - """ - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_1, - self.config_obj.parameters['ylab_weight']), - secondary_y=False, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - zeroline=False, - ticks="inside", - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.y_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['ylab_offset']), - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size} - ) - - def _add_legend(self) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure - """ - self.figure.update_layout(legend={'x': self.config_obj.bbox_x, - 'y': self.config_obj.bbox_y, - 'xanchor': 'center', - 'yanchor': 'top', - 'bordercolor': self.config_obj.legend_border_color, - 'borderwidth': self.config_obj.legend_border_width, - 'orientation': self.config_obj.legend_orientation, - 'font': { - 'size': self.config_obj.legend_size, - 'color': "black" - } - }) - - def _yaxis_limits(self) -> None: - """ - Apply limits on y2 axis if needed - """ - if len(self.config_obj.parameters['ylim']) > 0: - self.figure.update_layout(yaxis={'range': [self.config_obj.parameters['ylim'][0], - self.config_obj.parameters['ylim'][1]], - 'autorange': False}) + self.logger.info(f"Finished creating the figure: {datetime.now()}") - def remove_file(self): - """ - Removes previously made image file . Invoked by the parent class before self.output_file - attribute can be created, but overridden here. - """ + def _add_custom_lines(self, ax): + # add custom lines if lines are defined in config + if len(self.series_list) > 0: + self._add_lines(ax, self.config_obj) - super().remove_file() - self._remove_html() + def _add_series(self, ax, ax2): + handles_and_labels = [] + i = 0 + counter = 1 + if self.config_obj.ensss_pts_disp: + counter = 2 - def _remove_html(self) -> None: - """ - Removes previously made HTML file. - """ + # for series in self.series_list: + for idx, series in enumerate(self.series_list): + if not series.plot_disp: + continue - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" + is_points_plot = self.config_obj.ensss_pts_disp and idx % 2 == 1 + plot_ax = ax2 if is_points_plot else ax + handle = self._draw_series(plot_ax, series, idx, is_points_plot) + handles_and_labels.append((handle, handle.get_label())) - # remove the old file if it exist - if os.path.exists(html_name): - os.remove(html_name) + return handles_and_labels - def write_html(self) -> None: + def _draw_series(self, ax, series: EnsSsSeries, index: int, is_points_plot: bool) -> None: """ - Is needed - creates and saves the html representation of the plot WITHOUT Plotly.js + Draws the formatted line on the plot + + :param series: EnsSs series object with data and parameters """ - if self.config_obj.create_html is True: - # construct the file name from plot_filename - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" + self.logger.info(f"Begin drawing the series on the plot: {datetime.now()}") - # save html - self.figure.write_html(html_name, include_plotlyjs=False) + # add the plot + x = series.series_points['spread_skill'] + y = series.series_points['pts'] if is_points_plot else series.series_points['mse'] + + # set arguments for the plot + plot_args = self._get_plot_args(index) + plot_obj = ax.plot(x, y, **plot_args) + + self.logger.info(f"Finished drawing the series on the plot: {datetime.now()}") + return plot_obj[0] + + def _get_plot_args(self, idx): + plot_mode = self.config_obj.mode[idx] + marker = self.config_obj.marker_list[idx] if 'markers' in plot_mode else None + line_style = self.config_obj.linestyles_list[idx] if 'lines' in plot_mode else 'None' + + plot_args = { + 'marker': marker, + 'markersize': self.config_obj.marker_size[idx], + 'label': self.config_obj.user_legends[idx], + 'color': self.config_obj.colors_list[idx], + 'linewidth': self.config_obj.linewidth_list[idx], + 'linestyle': line_style, + } + if self.config_obj.marker_open_list[idx]: + plot_args['markerfacecolor'] = 'none' + plot_args['markeredgecolor'] = self.config_obj.colors_list[idx] + + return plot_args def write_output_file(self) -> None: """ @@ -504,48 +306,49 @@ def write_output_file(self) -> None: # (the input data file) except replace the .data # extension with .points1 extension # otherwise use points_path path + if not self.config_obj.dump_points_1: + return match = re.match(r'(.*)(.data)', self.config_obj.parameters['stat_input']) - if self.config_obj.dump_points_1 is True and match: - i = 0 - counter = 1 - if self.config_obj.ensss_pts_disp is True: - counter = 2 - - filename = match.group(1) - if self.config_obj.points_path is not None: - # get the file name - path = filename.split(os.path.sep) - if len(path) > 0: - filename = path[-1] - else: - filename = '.' + os.path.sep - filename = self.config_obj.points_path + os.path.sep + filename - # else: - # filename = 'points' + if not match: + return - filename = filename + '.points1' - os.makedirs(os.path.dirname(filename), exist_ok=True) + i = 0 + counter = 1 + if self.config_obj.ensss_pts_disp is True: + counter = 2 - with open(filename, 'w') as file: + filename = match.group(1) + if self.config_obj.points_path is not None: + # get the file name + path = filename.split(os.path.sep) + if len(path) > 0: + filename = path[-1] + else: + filename = '.' + os.path.sep + filename = self.config_obj.points_path + os.path.sep + filename + + filename = filename + '.points1' + os.makedirs(os.path.dirname(filename), exist_ok=True) + + with open(filename, 'w') as file: + while i < len(self.series_list): + file.writelines( + map("{}\t{}\n".format, + [round(num, 6) for num in self.series_list[i].series_points['spread_skill']], + [round(num, 6) for num in self.series_list[i].series_points['mse']])) + i = i + counter + # print PTS values + if self.config_obj.ensss_pts_disp is True: + i = 0 + file.write('#PTS\n') while i < len(self.series_list): file.writelines( map("{}\t{}\n".format, [round(num, 6) for num in self.series_list[i].series_points['spread_skill']], - [round(num, 6) for num in self.series_list[i].series_points['mse']])) + [round(num, 6) for num in self.series_list[i].series_points['pts']]) + ) i = i + counter - # print PTS values - if self.config_obj.ensss_pts_disp is True: - i = 0 - file.write('#PTS\n') - while i < len(self.series_list): - file.writelines( - map("{}\t{}\n".format, - [round(num, 6) for num in self.series_list[i].series_points['spread_skill']], - [round(num, 6) for num in self.series_list[i].series_points['pts']]) - ) - i = i + counter - file.close() def main(config_filename=None): diff --git a/metplotpy/plots/ens_ss/ens_ss_config.py b/metplotpy/plots/ens_ss/ens_ss_config.py index a1939721f..9e52cc1a7 100644 --- a/metplotpy/plots/ens_ss/ens_ss_config.py +++ b/metplotpy/plots/ens_ss/ens_ss_config.py @@ -19,7 +19,6 @@ from ..config import Config from .. import constants -from .. import util import metcalcpy.util.utils as utils @@ -59,7 +58,6 @@ def __init__(self, parameters: dict) -> None: ############################################## # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -86,7 +84,6 @@ def __init__(self, parameters: dict) -> None: if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) ############################################## # x2-axis parameters @@ -104,6 +101,7 @@ def __init__(self, parameters: dict) -> None: self.plot_disp = self._get_plot_disp() self.colors_list = self._get_colors() self.marker_list = self._get_markers() + self.marker_open_list = self._get_markers_open() self.marker_size = self._get_markers_size() self.mode = self._get_mode() self.linewidth_list = self._get_linewidths() @@ -155,127 +153,26 @@ def _get_fcst_vars(self, index): return fcst_var_val_dict - def _get_plot_disp(self) -> list: - """ - Retrieve the values that determine whether to display a particular series - and convert them to bool if needed - - Args: - - Returns: - A list of boolean values indicating whether or not to - display the corresponding series - """ - - plot_display_config_vals = self.get_config_value('plot_disp') - plot_display_bools = [] - for val in plot_display_config_vals: - if isinstance(val, bool): - plot_display_bools.append(val) - - if isinstance(val, str): - plot_display_bools.append(val.upper() == 'TRUE') - - return self.create_list_by_series_ordering(plot_display_bools) - - def _get_mode(self) -> list: - """ - Retrieve all the modes. Convert mode names from - the config file into plotly python's mode names. - - Args: - - Returns: - markers: a list of the plotly markers - """ - modes = self.get_config_value('series_type') - mode_list = [] - for mode in modes: - if mode in constants.TYPE_TO_PLOTLY_MODE.keys(): - # the recognized plotly marker names: - # circle-open (for small circle), circle, triangle-up, - # square, diamond, or hexagon - mode_list.append(constants.TYPE_TO_PLOTLY_MODE[mode]) - else: - mode_list.append('ens_sss+markers') - return self.create_list_by_series_ordering(mode_list) - - def _get_markers(self) -> list: - """ - Retrieve all the markers. Convert marker names from - the config file into plotly python's marker names. - - Args: - - Returns: - markers: a list of the plotly markers - """ - markers = self.get_config_value('series_symbols') - markers_list = [] - for marker in markers: - if marker in constants.AVAILABLE_PLOTLY_MARKERS_LIST: - # the recognized plotly marker names: - # circle-open (for small circle), circle, triangle-up, - # square, diamond, or hexagon - markers_list.append(marker) - else: - markers_list.append(constants.PCH_TO_PLOTLY_MARKER[marker]) - return self.create_list_by_series_ordering(markers_list) - - def _get_markers_size(self) -> list: - """ - Retrieve all the markers. Convert marker names from - the config file into plotly python's marker names. - - Args: - - Returns: - markers: a list of the plotly markers - """ - markers = self.get_config_value('series_symbols') - markers_size = [] - for marker in markers: - if marker in constants.AVAILABLE_PLOTLY_MARKERS_LIST: - markers_size.append(marker) - else: - markers_size.append(constants.PCH_TO_PLOTLY_MARKER_SIZE[marker]) + def config_consistency_check(self) -> None: + """Checks that the number of settings defined for + plot_disp, series_ordering, colors_list, user_legends, and show_legend + are consistent with number of series. - return self.create_list_by_series_ordering(markers_size) - - def _config_consistency_check(self) -> bool: + @raises ValueError if any of settings are inconsistent with the + number of series (as defined by the cross product of the model + and vx_mask defined in the series_val_1 setting) """ - Checks that the number of settings defined for plot_ci, - plot_disp, series_order, user_legend colors, and series_symbols - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - series (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) - - """ - # Determine the number of series based on the number of - # permutations from the series_var setting in the - # config file - - # Numbers of values for other settings for series - num_plot_disp = len(self.plot_disp) - num_markers = len(self.marker_list) - num_series_ord = len(self.series_ordering) - num_colors = len(self.colors_list) - num_legends = len(self.user_legends) - num_line_widths = len(self.linewidth_list) - num_linestyles = len(self.linestyles_list) - status = False - - if self.num_series == num_plot_disp == \ - num_markers == num_series_ord == num_colors \ - == num_legends == num_line_widths == num_linestyles: - status = True - return status + lists_to_check = { + "plot_disp": self.plot_disp, + "marker_list": self.marker_list, + "series_ordering": self.series_ordering, + "colors_list": self.colors_list, + "user_legends": self.user_legends, + "linewidth_list": self.linewidth_list, + "linestyles_list": self.linestyles_list, + "show_legend": self.show_legend, + } + self._config_compare_lists_to_num_series(lists_to_check) def _get_user_legends(self, legend_label_type: str = '') -> list: """ @@ -302,7 +199,7 @@ def _get_user_legends(self, legend_label_type: str = '') -> list: ser_components_copy = ser_components.copy() ser_components_copy.append('MSE') legend_list.append(' '.join(map(str, ser_components_copy))) - if self.ensss_pts_disp is True: + if self.ensss_pts_disp: ser_components.append('#PTS') legend_list.append(' '.join(map(str, ser_components))) else: @@ -322,8 +219,8 @@ def get_series_y(self, axis: int) -> list: for field in reversed(list(all_fields_values_orig.keys())): all_fields_values[field] = all_fields_values_orig.get(field) - if self._get_fcst_vars(axis): - all_fields_values['fcst_var'] = list(self._get_fcst_vars(axis).keys()) + if self.get_fcst_vars_keys(axis): + all_fields_values['fcst_var'] = self.get_fcst_vars_keys(axis) return utils.create_permutations_mv(all_fields_values, 0) @@ -355,10 +252,7 @@ def calculate_number_of_series(self) -> int: """ # Retrieve the lists from the series_val_1 dictionary series_vals_list = self.series_vals_1.copy() - if isinstance(self.fcst_var_val_1, list) is True: - fcst_vals = self.fcst_var_val_1 - elif isinstance(self.fcst_var_val_1, dict) is True: - fcst_vals = list(self.fcst_var_val_1.values()) + fcst_vals = list(self.fcst_var_val_1.values()) fcst_vals_flat = [item for sublist in fcst_vals for item in sublist] series_vals_list.append(fcst_vals_flat) @@ -367,26 +261,7 @@ def calculate_number_of_series(self) -> int: # fcst_var_val values. permutations = list(itertools.product(*series_vals_list)) total = len(permutations) - if self.ensss_pts_disp is True: + if self.ensss_pts_disp: total = total * 2 return total - - def _get_linestyles(self) -> list: - """ - Retrieve all the line styles. Convert line style names from - the config file into plotly python's line style names. - - Args: - - Returns: - line_styles: a list of the plotly line styles - """ - line_styles = self.get_config_value('series_line_style') - line_style_list = [] - for line_style in line_styles: - if line_style in constants.LINE_STYLE_TO_PLOTLY_DASH.keys(): - line_style_list.append(constants.LINE_STYLE_TO_PLOTLY_DASH[line_style]) - else: - line_style_list.append(None) - return self.create_list_by_series_ordering(line_style_list) diff --git a/metplotpy/plots/ens_ss/ens_ss_series.py b/metplotpy/plots/ens_ss/ens_ss_series.py index 508a6e51f..348e744f8 100644 --- a/metplotpy/plots/ens_ss/ens_ss_series.py +++ b/metplotpy/plots/ens_ss/ens_ss_series.py @@ -19,7 +19,7 @@ import numpy as np import metcalcpy.util.utils as utils -import metplotpy.plots.util +import metplotpy.plots.util as util from .. import GROUP_SEPARATOR from ..series import Series @@ -71,8 +71,7 @@ def _create_series_points(self) -> dict: Returns: dictionary with CI ,point values and number of stats as keys """ - ens_logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + ens_logger = util.get_common_logger(self.log_level, self.log_filename) ens_logger.info(f"Begin creating the series points: {datetime.now()}") # different ways to subset data for normal and derived series # this is a normal series diff --git a/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds.py b/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds.py index 7cb366fab..5636eca49 100644 --- a/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds.py +++ b/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds.py @@ -17,15 +17,11 @@ import re import csv -import yaml import pandas as pd +import numpy as np -import plotly.graph_objects as go -from plotly.subplots import make_subplots -from plotly.graph_objects import Figure +from matplotlib import pyplot as plt -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ - PLOTLY_PAPER_BGCOOR from metplotpy.plots.equivalence_testing_bounds.equivalence_testing_bounds_series \ import EquivalenceTestingBoundsSeries from metplotpy.plots.line.line_config import LineConfig @@ -62,16 +58,7 @@ def __init__(self, parameters: dict) -> None: self.logger.info(f"Start equivalence testing bounds: {datetime.now()}") # Check that we have all the necessary settings for each series - is_config_consistent = self.config_obj._config_consistency_check() - if not is_config_consistent: - error_msg = ("The number of series defined by series_val_1/2 and derived" - " curves is inconsistent with the number of settings" - " required for describing each series. Please check" - " the number of your configuration file's plot_i," - " plot_disp, series_order, user_legend," - " colors, show_legend and series_symbols settings.") - self.logger.error(f"ValueError: {error_msg}: {datetime.now()}") - raise ValueError(error_msg) + self.config_obj.config_consistency_check() # Read in input data, location specified in config file self.input_df = self._read_input_data() @@ -88,11 +75,6 @@ def __init__(self, parameters: dict) -> None: # line width, and criteria needed to subset the input dataframe. self.series_list = self._create_series(self.input_df) - # create figure - # pylint:disable=assignment-from-no-return - # Need to have a self.figure that we can pass along to - # the methods in met_plot.py (BasePlot class methods) to - # create binary versions of the plot. self._create_figure() def __repr__(self): @@ -185,8 +167,7 @@ def _create_series(self, input_data): # reorder series series_list = self.config_obj.create_list_by_series_ordering(series_list) - self.logger.info(f"Finished creating series object:" - f" {datetime.now()}") + self.logger.info(f"Finished creating series object: {datetime.now()}") return series_list def _create_figure(self): @@ -196,322 +177,122 @@ def _create_figure(self): self.logger.info(f"Creating the figure: {datetime.now()}") # create and draw the plot - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_y2axis() - self._add_legend() + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) - # add series lines + wts_size_styles = self.get_weights_size_styles() + + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) + + ax_y2 = None + if self.config_obj.parameters['list_stat_2']: + ax_y2 = self._add_y2axis(ax, wts_size_styles['y2lab']) + + handles_and_labels = self._add_series(ax, ax_y2) + + xlab_style = wts_size_styles['xlab'] if not self.config_obj.vert_plot else wts_size_styles['ylab'] + ylab_style = wts_size_styles['ylab'] if not self.config_obj.vert_plot else wts_size_styles['xlab'] + self._add_xaxis(ax, xlab_style) + self._add_yaxis(ax, ylab_style, grid_on=False) + # if y limits are not set, use -1 to 1 + if not getattr(self.config_obj, 'vert_plot', False) and not len(self.config_obj.parameters['ylim']): + ax.set_ylim(-1, 1) + + self._add_legend(ax, handles_and_labels) + + # add custom lines + self._add_lines(ax, self.config_obj, self.config_obj.indy_vals) + + plt.tight_layout() + + self.logger.info(f"Finished creating the figure: {datetime.now()}") + + def _add_series(self, ax, ax2): + handles_and_labels = [] ind = 0 for series in self.series_list: # Don't generate the plot for this series if # it isn't requested (as set in the config file) if series.plot_disp: - self._draw_series(series, ind) + handle = self._draw_series(ax, ax2, series, ind) + handles_and_labels.append((handle, handle.get_label())) ind = ind + 1 - self.logger.info(f"Finished creating the figure: {datetime.now()}") + return handles_and_labels - def _draw_series(self, series: LineSeries, ind: int) -> None: + def _draw_series(self, ax, ax2, series: LineSeries, ind: int): """ Draws the formatted ETB line on the plot :param series: EquivalenceTestingBounds series object with data and parameters - :param x_points_index_adj: values for adjusting x-values position + :param ind: index of the series """ self.logger.info(f"Start drawing the lines on the plot: {datetime.now()}") ci_tost_up = series.series_points['ci_tost'][1] ci_tost_lo = series.series_points['ci_tost'][0] dif = series.series_points['dif'] - # add the plot - self.figure.add_trace( - go.Scatter(x=[dif], - y=[ind], - showlegend=self.config_obj.show_legend[series.idx] == 1, - mode=self.config_obj.mode[series.idx], - textposition="top right", - name=self.config_obj.user_legends[series.idx], - connectgaps=self.config_obj.con_series[series.idx] == 1, - line={'color': self.config_obj.colors_list[series.idx], - 'width': self.config_obj.linewidth_list[series.idx], - 'dash': self.config_obj.linestyles_list[series.idx]}, - marker_symbol=self.config_obj.marker_list[series.idx], - marker_color=self.config_obj.colors_list[series.idx], - marker_line_color=self.config_obj.colors_list[series.idx], - marker_size=self.config_obj.marker_size[series.idx], - error_x={'type': 'data', - 'symmetric': False, - 'array': [ci_tost_up - dif], - 'arrayminus': [dif - ci_tost_lo], - 'visible': True, - 'thickness': self.config_obj.linewidth_list[series.idx], - 'width': 0 - } - ), - secondary_y=series.y_axis != 1 - ) - # add bounds lines - self.figure.add_shape(type="line", - x0=series.series_points['eqbound'][0], - y0=0, - x1=series.series_points['eqbound'][0], - y1=1, - yref='paper', - xref='x', - line={'color': self.config_obj.colors_list[series.idx], - 'width': 1, - 'dash': 'dash' - } - ) - - self.figure.add_shape(type="line", - x0=series.series_points['eqbound'][1], - y0=0, - x1=series.series_points['eqbound'][1], - y1=1, - yref='paper', - xref='x', - line={'color': self.config_obj.colors_list[series.idx], - 'width': 1, - 'dash': 'dash' - } - ) - self.logger.info(f"Finished drawing the lines on the plot: {datetime.now()}") - - def _create_layout(self) -> Figure: - """ - Creates a new layout based on the properties from the config file - including plots size, annotation and title + x_points = [dif] + y_points = [ind] - :return: Figure object - """ - # create annotation - annotation = [ - {'text': util.apply_weight_style(self.config_obj.parameters['plot_caption'], - self.config_obj.parameters[ - 'caption_weight']), - 'align': 'left', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': self.config_obj.parameters['caption_align'], - 'y': self.config_obj.caption_offset, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - }] - # create title - title = {'text': util.apply_weight_style(self.config_obj.title, - self.config_obj.parameters[ - 'title_weight']), - 'font': { - 'size': self.config_obj.title_font_size, - }, - 'y': self.config_obj.title_offset, - 'x': self.config_obj.parameters['title_align'], - 'xanchor': 'center', - 'xref': 'paper' - } - - # create a layout and allow y2 axis - fig = make_subplots(specs=[[{"secondary_y": True}]]) - - # add size, annotation, title - fig.update_layout( - width=self.config_obj.plot_width, - height=self.config_obj.plot_height, - margin=self.config_obj.plot_margins, - paper_bgcolor=PLOTLY_PAPER_BGCOOR, - annotations=annotation, - title=title, - plot_bgcolor=PLOTLY_PAPER_BGCOOR + # add the plot + # convert to a numpy array to change None values to NaN + asymmetric_error = np.array([ + ci_tost_up - dif, + dif - ci_tost_lo + ], dtype=float).reshape(2, 1) + + # determine which y-axis to use for the plot + plot_ax = ax if series.y_axis == 1 else ax2 + + # plot error bar + plot_mode = self.config_obj.mode[series.idx] + marker = self.config_obj.marker_list[series.idx] if 'markers' in plot_mode else None + line_style = self.config_obj.linestyles_list[series.idx] if 'lines' in plot_mode else 'None' + + # Swap x and y data if vertical plot + plot_x = y_points if self.config_obj.vert_plot else x_points + plot_y = x_points if self.config_obj.vert_plot else y_points + + # Swap error bars (yerr becomes xerr) if vertical plot + x_err_val = asymmetric_error if not self.config_obj.vert_plot else None + y_err_val = asymmetric_error if self.config_obj.vert_plot else None + + plot_obj = plot_ax.errorbar( + x=plot_x, + y=plot_y, + label=self.config_obj.user_legends[series.idx], + # line style + color=self.config_obj.colors_list[series.idx], + linestyle=line_style, + linewidth=self.config_obj.linewidth_list[series.idx], + # marker style + marker=marker, + markersize=self.config_obj.marker_size[series.idx], + markeredgecolor=self.config_obj.colors_list[series.idx], + markerfacecolor=self.config_obj.colors_list[series.idx], + # error bar + xerr=x_err_val, + yerr=y_err_val, + elinewidth=self.config_obj.linewidth_list[series.idx], + capsize=5, ) - return fig - - def _add_xaxis(self) -> None: - """ - Configures and adds x-axis to the plot - """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - ticks="inside", - zeroline=False, - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size} - ) - - def _add_yaxis(self) -> None: - """ - Configures and adds y-axis to the plot - """ - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_1, - self.config_obj.parameters[ - 'ylab_weight']), - secondary_y=False, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - zeroline=False, - ticks="", - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.y_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['ylab_offset']) + 15, - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size}, - showticklabels=False - ) - - def _add_y2axis(self) -> None: - """ - Adds y2-axis if needed - """ - if self.config_obj.parameters['list_stat_2']: - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_2, - self.config_obj.parameters[ - 'y2lab_weight']), - secondary_y=True, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - zeroline=False, - ticks="inside", - title_font={ - 'size': self.config_obj.y2_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['y2lab_offset']), - tickangle=self.config_obj.y2_tickangle, - tickfont={'size': self.config_obj.y2_tickfont_size} - ) - - def _add_legend(self) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure - """ - self.figure.update_layout(legend={'x': self.config_obj.bbox_x, - 'y': self.config_obj.bbox_y, - 'xanchor': 'center', - 'yanchor': 'top', - 'bordercolor': - self.config_obj.legend_border_color, - 'borderwidth': - self.config_obj.legend_border_width, - 'orientation': - self.config_obj.legend_orientation, - 'font': { - 'size': self.config_obj.legend_size, - 'color': "black" - } - }) - - def _add_x2axis(self, n_stats) -> None: - """ - Creates x2axis based on the properties from the config file - and attaches it to the initial Figure - - :param n_stats: - labels for the axis - """ - if self.config_obj.show_nstats: - x_points_index = list(range(0, len(n_stats))) - self.figure.update_layout(xaxis2={'title_text': - util.apply_weight_style('NStats', - self.config_obj.parameters[ - 'x2lab_weight'] - ), - 'linecolor': PLOTLY_AXIS_LINE_COLOR, - 'linewidth': PLOTLY_AXIS_LINE_WIDTH, - 'overlaying': 'x', - 'side': 'top', - 'showgrid': False, - 'zeroline': False, - 'ticks': "inside", - 'title_font': { - 'size': - self.config_obj.x2_title_font_size - }, - 'title_standoff': abs( - self.config_obj.parameters[ - 'x2lab_offset'] - ), - 'tickmode': 'array', - 'tickvals': x_points_index, - 'ticktext': n_stats, - 'tickangle': self.config_obj.x2_tickangle, - 'tickfont': { - 'size': - self.config_obj.x2_tickfont_size - }, - 'scaleanchor': 'x' - } - ) - - # need to add an invisible line with all values = None - self.figure.add_trace( - go.Scatter(y=[None] * len(x_points_index), x=x_points_index, - xaxis='x2', showlegend=False) - ) - - def remove_file(self): - """ - Removes previously made image file . Invoked by the parent class before - self.output_file - attribute can be created, but overridden here. - """ - super().remove_file() - self._remove_html() - - def _remove_html(self) -> None: - """ - Removes previously made HTML file. - """ - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" - - # remove the old file if it exist - if os.path.exists(html_name): - os.remove(html_name) - - def write_html(self) -> None: - """ - Is needed - creates and saves the html representation of the plot WITHOUT - Plotly.js - """ - - self.logger.info(f"Write html file: {datetime.now()}") - - if self.config_obj.create_html is True: - # construct the file name from plot_filename - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" + # add bounds lines + x = [series.series_points['eqbound'][0], series.series_points['eqbound'][0]] + if len(self.config_obj.parameters['ylim']) > 0: + y = [self.config_obj.parameters['ylim'][0], self.config_obj.parameters['ylim'][1]] + else: + y = [-1, 1] + ax.plot(x, y, color=self.config_obj.colors_list[series.idx], linewidth=1, linestyle='--') - # save html - self.figure.write_html(html_name, include_plotlyjs=False) + x = [series.series_points['eqbound'][1], series.series_points['eqbound'][1]] + ax.plot(x, y, color=self.config_obj.colors_list[series.idx], linewidth=1, linestyle='--') - self.logger.info(f"Finished writing html file: {datetime.now()}") + self.logger.info(f"Finished drawing the lines on the plot: {datetime.now()}") + return plot_obj def write_output_file(self) -> None: """ @@ -559,7 +340,7 @@ def write_output_file(self) -> None: os.makedirs(os.path.dirname(filename), exist_ok=True) # save points - self._save_points(ci_tost_df.values.tolist(), filename) + self._save_points(ci_tost_df.to_numpy().tolist(), filename) self.logger.info(f"Finished writing the output file: {datetime.now()}") diff --git a/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds_series.py b/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds_series.py index 1548dc9cf..bcec550a3 100644 --- a/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds_series.py +++ b/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds_series.py @@ -24,7 +24,7 @@ import metcalcpy.util.correlation as pg import metcalcpy.util.utils as utils -import metplotpy.plots.util +import metplotpy.plots.util as util from metcalcpy.sum_stat import calculate_statistic from .. import GROUP_SEPARATOR from ..line.line_series import LineSeries @@ -54,8 +54,7 @@ def _create_series_points(self) -> dict: dictionary with CI ,point values and number of stats as keys """ - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Creating series points (calculating the values for " f"each point: {datetime.now()}") @@ -147,8 +146,7 @@ def _calculate_tost_paired(self, series_data_1: DataFrame, series_data_2: DataFr :param series_data_2: 2nd data frame sorted by fcst_init_beg """ - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Validating dataframe fcst_valid_beg: " f"{datetime.now()}") all_zero_1 = all(elem is None or math.isnan(elem) diff --git a/metplotpy/plots/histogram/hist.py b/metplotpy/plots/histogram/hist.py index fe12d96ef..c38d6ae58 100644 --- a/metplotpy/plots/histogram/hist.py +++ b/metplotpy/plots/histogram/hist.py @@ -21,14 +21,11 @@ import numpy as np import pandas as pd - -import plotly.graph_objects as go -from plotly.subplots import make_subplots -from plotly.graph_objects import Figure +from matplotlib import pyplot as plt +from matplotlib.ticker import MultipleLocator from metplotpy.plots.histogram import hist_config -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ - PLOTLY_PAPER_BGCOOR +from metplotpy.plots.constants import MPL_DEFAULT_BAR_WIDTH from metplotpy.plots.histogram.hist_series import HistSeries from metplotpy.plots.base_plot import BasePlot from metplotpy.plots import util @@ -69,19 +66,8 @@ def __init__(self, parameters: dict) -> None: f" {datetime.now()}") # Check that we have all the necessary settings for each ser - self.logger.info(f"Performing consistency check for settings in config " - f"file: {datetime.now()}") - is_config_consistent = self.config_obj._config_consistency_check() - self.logger.info(f"Finished with consistency check: {datetime.now()}") - if not is_config_consistent: - error_msg = ("The number of ser defined by series_val_1 is" - " inconsistent with the number of settings" - " required for describing each ser. Please check" - " the number of your configuration file's " - " plot_disp, series_order, user_legend, show_legend" - " colors settings.") - self.logger.error(f"ValueError: {error_msg}") - raise ValueError(error_msg) + self.config_obj.config_consistency_check() + # Read in input data, location specified in config file self.input_df = self._read_input_data() @@ -256,199 +242,55 @@ def _create_figure(self): """ self.logger.info(f"Begin creating the histogram figure: {datetime.now()}") # create and draw the plot - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) + + wts_size_styles = self.get_weights_size_styles() + + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) # add ser boxes - for series in self.series_list: - self._draw_series(series) + for idx, series in enumerate(self.series_list): + self._draw_series(ax, series, idx) + + # use x points from first series if indy label is not set + if not self.config_obj.indy_label: + self.config_obj.indy_label = self._get_x_points(self.series_list[0]) + if not self.config_obj.indy_vals: + self.config_obj.indy_vals = self.config_obj.indy_label + + self._add_xaxis(ax, wts_size_styles['xlab']) + #if self._get_dtick(): + # ax.xaxis.set_major_locator(MultipleLocator(self._get_dtick())) + self._add_yaxis(ax, wts_size_styles['ylab']) # add custom lines if len(self.series_list) > 0: - self._add_lines( - self.config_obj - ) + self._add_lines(ax, self.config_obj) + + self._add_legend(ax) + plt.tight_layout() - self.logger.info(f"Finished creating the histogram figure: " - f"{datetime.now()}") + self.logger.info(f"Finished creating the histogram figure: {datetime.now()}") - def _draw_series(self, series: HistSeries) -> None: + def _draw_series(self, ax: plt.Axes, series: HistSeries, idx: int) -> None: """ Draws the formatted Bar on the plot :param series: Bar ser object with data and parameters """ + x_points = self._get_x_points(series) + y_points = series.series_points - # add the bar to plot - self.figure.add_trace( - go.Bar( - x=self._get_x_points(series), - y=series.series_points, - showlegend=self.config_obj.show_legend[series.idx] == 1, - name=self.config_obj.user_legends[series.idx], - marker_color=self.config_obj.colors_list[series.idx], - marker_line_color=self.config_obj.colors_list[series.idx] - ) - ) + x_locs, width = self._get_x_locs_and_width(x_points, idx) - def _create_layout(self) -> Figure: - """ - Creates a new layout based on the properties from the config file - including plots size, annotation and title - - :return: Figure object - """ - self.logger.info(f"Creating the layout: {datetime.now()}") - - # create annotation - annotation = [ - {'text': util.apply_weight_style(self.config_obj.parameters['plot_caption'], - self.config_obj.parameters[ - 'caption_weight']), - 'align': 'left', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': self.config_obj.parameters['caption_align'], - 'y': self.config_obj.caption_offset, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - }] - # create title - title = {'text': util.apply_weight_style(self.config_obj.title, - self.config_obj.parameters[ - 'title_weight']), - 'font': { - 'size': self.config_obj.title_font_size, - }, - 'y': self.config_obj.title_offset, - 'x': self.config_obj.parameters['title_align'], - 'xanchor': 'center', - 'xref': 'paper' - } - - # create a layout without y2 axis - fig = make_subplots(specs=[[{"secondary_y": False}]]) - - # add size, annotation, title - fig.update_layout( - width=self.config_obj.plot_width, - height=self.config_obj.plot_height, - margin=self.config_obj.plot_margins, - paper_bgcolor=PLOTLY_PAPER_BGCOOR, - annotations=annotation, - title=title, - plot_bgcolor=PLOTLY_PAPER_BGCOOR + ax.bar( + x=x_locs, height=y_points, width=width, align='center', + color=self.config_obj.colors_list[series.idx], + label=self.config_obj.user_legends[series.idx], ) - self.logger.info(f"Finished creating the layout: {datetime.now()}") - return fig - - def _add_xaxis(self) -> None: - """ - Configures and adds x-axis to the plot - """ - self.logger.info(f"Configuring and adding the x-axis: {datetime.now()}") - - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - zeroline=False, - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size}, - dtick=self._get_dtick() - ) - self.logger.info(f"Finished configuring and adding the x-axis:" - f" {datetime.now()}") - - def _add_yaxis(self) -> None: - """ - Configures and adds y-axis to the plot - """ - - self.logger.info(f"Configuring and adding the y-axis: {datetime.now()}") - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_1, - self.config_obj.parameters[ - 'ylab_weight']), - secondary_y=False, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - zeroline=False, - ticks="inside", - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.y_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['ylab_offset']), - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size} - ) - self.logger.info(f"Finished configuring and adding the y-axis:" - f" {datetime.now()}") - - def _add_legend(self) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure - """ - - self.logger.info(f"Adding the legend: {datetime.now()}") - self.figure.update_layout(legend={'x': self.config_obj.bbox_x, - 'y': self.config_obj.bbox_y, - 'xanchor': 'center', - 'yanchor': 'top', - 'bordercolor': - self.config_obj.legend_border_color, - 'borderwidth': - self.config_obj.legend_border_width, - 'orientation': - self.config_obj.legend_orientation, - 'font': { - 'size': self.config_obj.legend_size, - 'color': "black" - } - }) - self.logger.info(f"Finished adding the legend: {datetime.now()}") - - def write_html(self) -> None: - """ - Is needed - creates and saves the html representation of the plot WITHOUT - Plotly.js - """ - - self.logger.info(f"Begin writing html: {datetime.now()}") - - if self.config_obj.create_html is True: - # construct the file name from plot_filename - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" - - # save html - self.figure.write_html(html_name, include_plotlyjs=False) - - self.logger.info(f"Finished writing html: {datetime.now()}") - def write_output_file(self) -> None: - """ - saves box points to the file - """ + """Saves box points to the file""" self.logger.info(f"Begin writing the output file: {datetime.now()}") # if points_path parameter doesn't exist, @@ -479,6 +321,5 @@ def write_output_file(self) -> None: map('{}\t'.format, [round(num, 6) for num in series.series_points])) file.writelines('\n') - file.close() self.logger.info(f"Finished writing the output file: {datetime.now()}") diff --git a/metplotpy/plots/histogram/hist_config.py b/metplotpy/plots/histogram/hist_config.py index 16945ab62..a3f42da59 100644 --- a/metplotpy/plots/histogram/hist_config.py +++ b/metplotpy/plots/histogram/hist_config.py @@ -39,8 +39,8 @@ def __init__(self, parameters: dict) -> None: # plot parameters self.grid_on = self._get_bool('grid_on') - self.plot_width = self.calculate_plot_dimension('plot_width', 'pixels') - self.plot_height = self.calculate_plot_dimension('plot_height', 'pixels') + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') self.dump_points_1 = self._get_bool('dump_points_1') self.create_html = self._get_bool('create_html') @@ -48,12 +48,11 @@ def __init__(self, parameters: dict) -> None: # caption parameters self.caption_size = int(constants.DEFAULT_CAPTION_FONTSIZE * self.get_config_value('caption_size')) - self.caption_offset = self.parameters['caption_offset'] - 3.1 + self.caption_offset = self.parameters['caption_offset'] * constants.DEFAULT_CAPTION_Y_OFFSET ############################################## # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -70,7 +69,6 @@ def __init__(self, parameters: dict) -> None: if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) ############################################## # ser parameters @@ -98,68 +96,12 @@ def __init__(self, parameters: dict) -> None: self.legend_orientation = 'v' else: self.legend_orientation = 'h' - self.legend_border_color = "black" self.normalized_histogram = self._get_bool('normalized_histogram') self.fixed_vars_vals_input = self.parameters['fixed_vars_vals_input'] self.points_path = self.get_config_value('points_path') - def _get_plot_disp(self) -> list: - """ - Retrieve the values that determine whether to display a particular ser - and convert them to bool if needed - - Args: - - Returns: - A list of boolean values indicating whether or not to - display the corresponding ser - """ - - plot_display_config_vals = self.get_config_value('plot_disp') - plot_display_bools = [] - for val in plot_display_config_vals: - if isinstance(val, bool): - plot_display_bools.append(val) - - if isinstance(val, str): - plot_display_bools.append(val.upper() == 'TRUE') - - return self.create_list_by_series_ordering(plot_display_bools) - - def _config_consistency_check(self) -> bool: - """ - Checks that the number of settings defined for plot_ci, - plot_disp, series_order, user_legend colors, and series_symbols - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - ser (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) - - """ - # Determine the number of ser based on the number of - # permutations from the series_var setting in the - # config file - - # Numbers of values for other settings for ser - num_plot_disp = len(self.plot_disp) - num_series_ord = len(self.series_ordering) - num_colors = len(self.colors_list) - num_legends = len(self.user_legends) - status = False - - if self.num_series == num_plot_disp == \ - num_series_ord == num_colors \ - == num_legends: - status = True - return status - def get_series_y(self) -> list: """ Creates an array of ser components (excluding derived) tuples for the specified y-axis diff --git a/metplotpy/plots/histogram/hist_series.py b/metplotpy/plots/histogram/hist_series.py index 38335ba23..d404a3f69 100644 --- a/metplotpy/plots/histogram/hist_series.py +++ b/metplotpy/plots/histogram/hist_series.py @@ -17,7 +17,7 @@ import numpy as np import metcalcpy.util.utils as utils -import metplotpy.plots.util +import metplotpy.plots.util as util from ..series import Series @@ -53,8 +53,8 @@ def _create_all_fields_values_no_indy(self) -> dict: for x in reversed(list(all_fields_values_orig.keys())): all_fields_values[x] = all_fields_values_orig.get(x) - if self.config._get_fcst_vars(1): - all_fields_values['fcst_var'] = list(self.config._get_fcst_vars(1).keys()) + if self.config.get_fcst_vars_keys(1): + all_fields_values['fcst_var'] = self.config.get_fcst_vars_keys(1) all_fields_values_no_indy[1] = all_fields_values return all_fields_values_no_indy @@ -68,8 +68,7 @@ def _create_series_points(self) -> list: Returns: """ - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Begin creating the series points: {datetime.now()}") all_filters = [] @@ -107,8 +106,7 @@ def _create_series_points(self) -> list: else: series_points_results = self.series_data.loc[:, 'stat_value'].tolist() - logger.info(f"Finished creating the series points:" - f" {datetime.now()}") + logger.info(f"Finished creating the series points: {datetime.now()}") return series_points_results diff --git a/metplotpy/plots/histogram/histogram.py b/metplotpy/plots/histogram/histogram.py deleted file mode 100644 index 231d333f3..000000000 --- a/metplotpy/plots/histogram/histogram.py +++ /dev/null @@ -1,217 +0,0 @@ -# ============================* - # ** Copyright UCAR (c) 2020 - # ** University Corporation for Atmospheric Research (UCAR) - # ** National Center for Atmospheric Research (NCAR) - # ** Research Applications Lab (RAL) - # ** P.O.Box 3000, Boulder, Colorado, 80307-3000, USA - # ============================* - - - -""" -Class Name: histogram.py - """ -__author__ = 'Tatiana Burek' - -import os -import plotly.graph_objects as go -import yaml -import pandas as pd -import numpy as np - -from metplotpy.plots.base_plot import BasePlot - - -class Histogram(BasePlot): - """A class that creates histogram using Plotly using a two dimensional data array - - To use: - >>> data = [np.random.randn(500),np.random.randn(500) + 1] - >>> histogram = Histogram(None, data) - >>> histogram.show_in_browser() - """ - DEFAULT_XBINS_SIZE = 10 - DEFAULT_LINE_WIDTH = 1 - DEFAULT_COLOR = 'white' - DEFAULT_TITLE_FORMAT = 'Series {}' - - def __init__(self, parameters, data): - """Inits Histogram with user defined dictionary and a data array. - Creates a Plotly histogram figure using the data - - Args: - @param parameters - dictionary containing user defined parameters - @param data - two dimensional data array - Raises: - ValueError: If the data array has dimension not equal 2. - """ - - default_conf_filename = "histogram_defaults.yaml" - # init common layout - super().__init__(parameters, default_conf_filename) - - - # validate the input array - should be 2-dimensional - dim = Histogram.get_array_dimensions(data) - if dim is None or dim != 2: - raise ValueError('Data array dimension should be 2 but it is {0}'.format(dim)) - - # create figure - self.figure = self._create_figure(data) - - def _create_figure(self, data): - """Draws histogram data on the layout - - Args: - @param data - two dimensional data array - - Returns: - Figure - """ - - # init Figure - fig = go.Figure() - - try: - # create layout with title, axises, legend ... - fig.update_layout( - title=self.get_title(), - xaxis=self.get_xaxis(), - yaxis=self.get_yaxis(), - # gap between bars of adjacent location coordinates - bargap=self.get_config_value('bargap'), - # bargroupgap=0.1 # gap between bars of the same location coordinates\ - showlegend=self.get_config_value('showlegend'), - legend_orientation=self.get_config_value('legend_orientation'), - legend=self.get_legend(), - plot_bgcolor=self.get_config_value('plot_bgcolor'), - height=self.get_config_value('height'), - width=self.get_config_value('width'), - annotations=[ - self.get_xaxis_title(), - self.get_yaxis_title() - ], - ) - # calculate bins_size - xbins_size = self._get_bins_size() - - # add histogram's markers - for i, marker_data in enumerate(data): - fig.add_trace(go.Histogram( - x=marker_data, - histnorm=self.get_config_value('histnorm'), - name=self._get_marker_title(i), # name used in legend and hover labels - xbins=dict( # bins used for histogram - start=self.get_config_value('xbins', 'start'), - end=self.get_config_value('xbins', 'end'), - size=xbins_size - ), - marker_color=self._get_marker_color(i), - opacity=self.get_config_value('opacity'), - showlegend=self.get_config_value('showlegend'), - marker=dict( - line=dict( - width=self._get_line_width(i) - ) - ), - orientation=self.get_config_value('orientation'), - - )) - except ValueError as ex: - raise ValueError( - "An exception of type {0} occurred. Check your data or config file values." - .format(type(ex).__name__)) - - return fig - - def _get_bins_size(self): - """Returns user defined bin size or a default value. - - Args: - - Returns: - xbins size - """ - xbins_size = self.get_config_value('xbins', 'size') - if xbins_size is None: - xbins_size = self.DEFAULT_XBINS_SIZE - return xbins_size - - def _get_marker_title(self, i): - """Returns user defined marker title or a default value. - - Args: - @param i - the index if the marker data in the input array - Returns: - name of the marker - """ - legend_titles = self.get_config_value('legend_titles') - if legend_titles is None or len(legend_titles) <= i: - return self.DEFAULT_TITLE_FORMAT.format(i + 1) - return self.get_config_value('legend_titles')[i] - - def _get_line_width(self, i): - """Returns user defined marker line width or a default value. - - Args: - @param i - the index if the marker data in the input array - Returns: - line width - """ - line_width = self.get_config_value('line_width') - if line_width is None or len(line_width) <= i: - return self.DEFAULT_LINE_WIDTH - - return line_width[i] - - def _get_marker_color(self, i): - """Returns user defined marker color or a default value. - - Args: - @param i - the index if the marker data in the input array - Returns: - color - """ - hist_marker_colors = self.get_config_value('colors') - if hist_marker_colors is None or len(hist_marker_colors) <= i: - return self.DEFAULT_COLOR - return self.get_config_value('colors')[i] - - -def main(): - """ Example how to use Histogram""" - # open user's config file - with open("/Users/tatiana/histogram.yml", 'r') as stream: - try: - docs = yaml.load(stream, Loader=yaml.FullLoader) - except yaml.YAMLError as exc: - print(exc) - - # read user's data from file and arrange it in the array - input_data_file = "/Users/tatiana/Rscript/test_data.txt" - input_data = pd.read_csv(input_data_file, header=[0], sep=' ') - data = [] - data.append(input_data['FCST']) - data.append(input_data['OBS']) - - # create a histogram - try: - histogram = Histogram(docs, data) - # img_bytes = histogram.get_img_bytes() - - # save to file - histogram.save_to_file() - # histogram.show() - except ValueError as v_error: - print(v_error) - - -if __name__ == "__main__": - # main() - data = [np.random.randn(500), np.random.randn(500) + 1] - try: - histogram = Histogram(None, data) - # histogram.show_in_browser() - histogram.save_to_file() - except ValueError as ve: - print(ve) diff --git a/metplotpy/plots/histogram/prob_hist.py b/metplotpy/plots/histogram/prob_hist.py index d4cd7aae4..e9839bd64 100644 --- a/metplotpy/plots/histogram/prob_hist.py +++ b/metplotpy/plots/histogram/prob_hist.py @@ -37,7 +37,8 @@ def _get_x_points(self, series: HistSeries) -> list: if len(ser.series_data) > 0: bin_size = ser.series_data['bin_size'][0] for i in range(1, int(1 / bin_size + 1)): - x_points.append(i * bin_size) + label = format(i * bin_size, '.2f').rstrip('0').rstrip('.') + x_points.append(label) return x_points def _get_dtick(self) -> Union[float, str]: @@ -63,19 +64,7 @@ def main(config_filename=None): Args: @param config_filename: default is None, the name of the custom config file to apply """ - params = util.get_params(config_filename) - try: - plot = ProbHist(params) - plot.save_to_file() - # plot.show_in_browser() - plot.write_html() - plot.write_output_file() - log_level = plot.get_config_value('log_level') - log_filename = plot.get_config_value('log_filename') - logger = util.get_common_logger(log_level, log_filename) - logger.info(f"Finished probability histogram: {datetime.now()}") - except ValueError as val_er: - print(val_er) + util.make_plot(config_filename, ProbHist) if __name__ == "__main__": diff --git a/metplotpy/plots/histogram/rank_hist.py b/metplotpy/plots/histogram/rank_hist.py index a9a054b9b..b9bd61f88 100644 --- a/metplotpy/plots/histogram/rank_hist.py +++ b/metplotpy/plots/histogram/rank_hist.py @@ -13,7 +13,6 @@ """ __author__ = 'Tatiana Burek' -import yaml from datetime import datetime from metplotpy.plots.histogram.hist import Hist from metplotpy.plots import util diff --git a/metplotpy/plots/histogram/rel_hist.py b/metplotpy/plots/histogram/rel_hist.py index 3040451e7..dba0cf45d 100644 --- a/metplotpy/plots/histogram/rel_hist.py +++ b/metplotpy/plots/histogram/rel_hist.py @@ -13,7 +13,6 @@ """ __author__ = 'Tatiana Burek' -import yaml from datetime import datetime from metplotpy.plots import util diff --git a/metplotpy/plots/histogram_2d/histogram_2d.py b/metplotpy/plots/histogram_2d/histogram_2d.py index b7f35159d..3d7801935 100644 --- a/metplotpy/plots/histogram_2d/histogram_2d.py +++ b/metplotpy/plots/histogram_2d/histogram_2d.py @@ -21,13 +21,12 @@ """ Import standard modules """ -import os + import sys from datetime import datetime -import re -import yaml import xarray as xr -import plotly.graph_objects as go + +from matplotlib import pyplot as plt import metplotpy.plots.util as util @@ -37,9 +36,9 @@ from metplotpy.plots.base_plot import BasePlot -class Histogram_2d(BasePlot): +class Histogram2D(BasePlot): """ - Class to create a Plotly Histogram_2d plot from a 2D data array + Class to create a Plotly Histogram2d plot from a 2D data array """ def __init__(self, parameters): @@ -56,7 +55,6 @@ def __init__(self, parameters): self.dims = self.data.dims self.coords = self.data.coords - # Optional setting, indicates *where* to save the dump_points_1 file # used by METviewer self.points_path = self.get_config_value('points_path') @@ -66,61 +64,37 @@ def __init__(self, parameters): # normalized probability distribution function self.pdf = self.data / self.data.sum() - self.figure = go.Figure() - self.create_figure() def create_figure(self): self.logger.info(f"Begin creating the figure: {datetime.now()}") - if self.get_config_value('normalize_to_pdf'): - z_data = self.pdf - else: - z_data = self.data - - self.figure.add_heatmap( - x=self.data.coords[self.dims[0]], - y=self.data.coords[self.dims[1]], - z=z_data, - zmin=self.get_config_value('pdf_min'), - zmax=self.get_config_value('pdf_max')) - - self.figure.update_layout( - height=self.get_config_value('height'), - width=self.get_config_value('width'), - font=dict(size=self.get_config_value('font_size')), - title=self.get_config_value('title'), - xaxis_title=self.get_config_value('xaxis_title'), - yaxis_title=self.get_config_value('yaxis_title'), - ) + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) - self.logger.info(f"Finished creating the figure: {datetime.now()}") + wts_size_styles = self.get_weights_size_styles() - def save_to_file(self): - """Saves the image to a file specified in the config file. - Prints a message if fails + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) - Args: + self.config_obj.xaxis = self.get_config_value('xaxis_title') + self.config_obj.yaxis_1 = self.get_config_value('yaxis_title') - Returns: + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) - """ - image_name = self.get_config_value('plot_filename') - self.logger.info(f"Saving plot to file {image_name}: {datetime.now()} ") - if self.figure: - try: - os.makedirs(os.path.dirname(image_name), exist_ok=True) - self.figure.write_image(image_name) - - except FileNotFoundError: - self.logger.error(f"FileNotFoundError: Can't save to file {image_name}") - except ValueError as err: - self.logger.error(f"ValueError: Some other error occurred " - f"{datetime.now()}: {err}") - else: - self.logger.error("The figure was not created. Cannot save file.") - - self.logger.info(f"Finished saving plot to file: {datetime.now()}") + z_data = self.pdf if self.get_config_value('normalize_to_pdf') else self.data + + colormesh = ax.pcolormesh( + self.data.coords[self.dims[1]], + self.data.coords[self.dims[0]], + z_data, + vmin=self.get_config_value('pdf_min'), + vmax=self.get_config_value('pdf_max'), + shading='nearest' + ) + plt.colorbar(colormesh, ax=ax) + + self.logger.info(f"Finished creating the figure: {datetime.now()}") def write_output_file(self): """ @@ -154,14 +128,7 @@ def _read_input_data(self): def main(config_filename=None): - params = util.get_params(config_filename) - try: - h = Histogram_2d(params) - h.save_to_file() - h.logger.info(f"Finished generating histogram 2D plot: {datetime.now()}") - except ValueError as ve: - print(ve) - + util.make_plot(config_filename, Histogram2D) if __name__ == "__main__": main() diff --git a/metplotpy/plots/hovmoeller/hovmoeller.py b/metplotpy/plots/hovmoeller/hovmoeller.py index 89145be36..7e9b0f158 100644 --- a/metplotpy/plots/hovmoeller/hovmoeller.py +++ b/metplotpy/plots/hovmoeller/hovmoeller.py @@ -20,18 +20,20 @@ """ Import standard modules """ -import os + from datetime import datetime import getpass import sys import numpy as np import xarray as xr -import plotly.graph_objects as go -from netCDF4 import num2date +import pandas as pd + +from matplotlib import pyplot as plt +import matplotlib.dates as mdates + from metplotpy.plots import util from metplotpy.plots.hovmoeller.hovmoeller_config import HovmoellerConfig -import metcalcpy """ Import BasePlot class @@ -62,66 +64,65 @@ def __init__(self, parameters): # Read in input data dataset = self.read_data_set() self.time = self.ds.time.sel( - time=slice(self.config_obj.date_start, self.config_obj.date_end)) - self.time_str = self.get_time_str(self.time) + time=slice(pd.Timestamp(self.config_obj.date_start), + pd.Timestamp(self.config_obj.date_end))) self.lon = self.ds.lon self.data = self.lat_avg(dataset, self.config_obj.lat_min, self.config_obj.lat_max) self.lat_str = self.get_lat_str( self.config_obj.lat_min, self.config_obj.lat_max) - - self.figure = go.Figure() + self.config_obj.title = f"{self.config_obj.title} {self.lat_str}" self.create_figure() def create_figure(self): - self.logger.info(f"Begin creating the figure: {datetime.now()}") - contour_plot = go.Contour( - z=self.data.values, - x=self.lon, - y=self.time_str, - colorscale=self.get_config_value('colorscale'), - contours=dict(start=self.get_config_value('contour_min'), - end=self.get_config_value('contour_max'), - size=self.get_config_value('contour_del'), - showlines=False), - colorbar=dict(title=self.data.attrs['units'], - len=0.6, - lenmode='fraction') - ) - self.logger.info(f"Adding the contour plot: {datetime.now()}") - self.figure.add_trace(contour_plot) - - self.logger.info(f"Update the layout: {datetime.now()}") - self.figure.update_layout( - height=self.config_obj.plot_height, - width=self.config_obj.plot_width, - title=self.config_obj.title + ' ' + self.lat_str, - font=dict(size=self.config_obj.xy_label_fontsize), - title_font_size=self.config_obj.title_size, - xaxis_title=self.config_obj.xaxis, - yaxis_title=self.config_obj.yaxis, + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) + + wts_size_styles = self.get_weights_size_styles() + + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) + + levels = np.arange(self.config_obj.contour_min, + self.config_obj.contour_max + self.config_obj.contour_del, + self.config_obj.contour_del) + contour_filled = ax.contourf( + self.lon, + self.time, + self.data.values, + levels=levels, + cmap=self.get_config_value('colorscale'), + extend='both', ) - self.logger.info(f"Finished creating the figure: {datetime.now()}") + # add color bar + colorbar = plt.colorbar(contour_filled, ax=ax, shrink=0.6, extend='both') + colorbar.set_label(self.data.attrs['units']) - def get_time_str(self, time): - """ - Generate time string for y-axis labels. - :param time: time coordinate - :type time: datetime object - :return: time_str - :rtype: str - """ - ts = (time - np.datetime64('1970-01-01T00:00:00')) / np.timedelta64(1, 'h') - date = num2date(ts, 'hours since 1970-01-01T00:00:00Z') - time_str = [i.strftime("%Y-%m-%d %H:%M") for i in date] - return time_str + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) - def get_lat_str(self, lat_min, lat_max): + # auto space time values on y-axis and use dynamic formatting + locator = mdates.AutoDateLocator() + ax.yaxis.set_major_locator(locator) + formatter = mdates.ConciseDateFormatter(locator) + + # change day format to Month Day format + formatter.formats[2] = '%b %d' + + # change 1st of the month (zero day) to Month Day format + formatter.zero_formats[2] = '%b %d' + + ax.yaxis.set_major_formatter(formatter) + + plt.tight_layout() + self.logger.info(f"Finished creating the figure: {datetime.now()}") + + @staticmethod + def get_lat_str(lat_min, lat_max): """ Generate string describing the latitude band averaged over. :param lat_min: southern latitude limit of the average @@ -146,7 +147,8 @@ def get_lat_str(self, lat_min, lat_max): return lat_str - def lat_avg(self, data, lat_min, lat_max): + @staticmethod + def lat_avg(data, lat_min, lat_max): """ Compute latitudinal average. :param data: input data (time, lat, lon) @@ -189,7 +191,8 @@ def read_data_set(self): dataset = self.ds[self.config_obj.var_name] self.logger.debug(f"Data for {self.config_obj.var_name}") dataset = dataset.sel( - time=slice(self.config_obj.date_start, self.config_obj.date_end)) + time=slice(pd.Timestamp(self.config_obj.date_start), + pd.Timestamp(self.config_obj.date_end))) dataset = dataset * self.config_obj.unit_conversion dataset.attrs['units'] = self.config_obj.var_units @@ -198,24 +201,6 @@ def read_data_set(self): return dataset - def write_html(self) -> None: - """ - Is needed - creates and saves the html representation of the plot WITHOUT - Plotly.js - """ - - self.logger.info(f"Begin writing html output: {datetime.now()}") - if self.config_obj.create_html is True: - # construct the fle name from plot_filename - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" - - # save html - self.figure.write_html(html_name, include_plotlyjs=False) - - self.logger.info(f"Finished writing html output: {datetime.now()}") - - def main(config_filename=None): """ Generates a sample hovmoeller diagram using the diff --git a/metplotpy/plots/hovmoeller/hovmoeller_config.py b/metplotpy/plots/hovmoeller/hovmoeller_config.py index a6b61fd42..0232c2470 100644 --- a/metplotpy/plots/hovmoeller/hovmoeller_config.py +++ b/metplotpy/plots/hovmoeller/hovmoeller_config.py @@ -26,14 +26,14 @@ def __init__(self, parameters): Returns: """ - # init common layout super().__init__(parameters) self.data_dir = self.get_config_value('input_data_dir') self.input_data_file = self.get_config_value('input_data_file') self.log_level = str(self.get_config_value('log_level')).upper() - self.xy_label_fontsize = self.get_config_value('xy_label_font_size') + self.x_title_font_size = self.get_config_value('xy_label_font_size') + self.y_title_font_size = self.get_config_value('xy_label_font_size') self.title_size = self.get_config_value('title_size') self.date_start = self.get_config_value('date_start') self.date_end = self.get_config_value('date_end') @@ -47,5 +47,7 @@ def __init__(self, parameters): self.contour_del = self.get_config_value('contour_del') self.colorscale = self.get_config_value('colorscale') self.xaxis = self.get_config_value('xaxis') - self.yaxis = self.get_config_value('yaxis') - self.create_html = self.get_config_value('create_html') + self.yaxis_1 = self.get_config_value('yaxis') + self.plot_res = self._get_plot_resolution() + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') diff --git a/metplotpy/plots/line/line.py b/metplotpy/plots/line/line.py index b978049c2..1fd837c36 100644 --- a/metplotpy/plots/line/line.py +++ b/metplotpy/plots/line/line.py @@ -10,7 +10,7 @@ """ Class Name: line.py """ -__author__ = 'Tatiana Burek' +__author__ = 'Tatiana Burek, George McCabe' import os from datetime import datetime @@ -20,16 +20,11 @@ from typing import Union from itertools import chain -import yaml import numpy as np import pandas as pd -import plotly.graph_objects as go -from plotly.subplots import make_subplots -from plotly.graph_objects import Figure +from matplotlib import pyplot as plt -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ - PLOTLY_PAPER_BGCOOR from metplotpy.plots.line.line_config import LineConfig from metplotpy.plots.line.line_series import LineSeries from metplotpy.plots.base_plot import BasePlot @@ -58,8 +53,6 @@ def __init__(self, parameters: dict) -> None: # init common layout super().__init__(parameters, self.defaults_name) - self.allow_secondary_y = True - # instantiate a LineConfig object, which holds all the necessary settings # from the # config file that represents the BasePlot object (Line). @@ -70,16 +63,7 @@ def __init__(self, parameters: dict) -> None: self.logger.info(f"Begin creating the line plot: {datetime.now()}") # Check that we have all the necessary settings for each series - is_config_consistent = self.config_obj._config_consistency_check() - if not is_config_consistent: - error_msg = ("The number of series defined by series_val_1/2 and derived " - "curves is inconsistent with the number of settings " - "required for describing each series. Please check " - "the number of your configuration file's plot_ci, " - "plot_disp, series_order, user_legend, " - "colors, series_symbols, and show_legend settings.") - self.logger.error(f"ValueError: {error_msg}: {datetime.now()}") - raise ValueError(error_msg) + self.config_obj.config_consistency_check() # Read in input data, location specified in config file self.input_df = self._read_input_data() @@ -97,11 +81,6 @@ def __init__(self, parameters: dict) -> None: # line width, and criteria needed to subset the input dataframe. self.series_list = self._create_series(self.input_df) - # create figure - # pylint:disable=assignment-from-no-return - # Need to have a self.figure that we can pass along to - # the methods in base_plot.py (BasePlot class methods) to - # create binary versions of the plot. self._create_figure() def __repr__(self): @@ -156,9 +135,6 @@ def _create_series(self, input_data): series_obj = LineSeries(self.config_obj, i, input_data, series_list, name) series_list.append(series_obj) - - - # add series for y2 axis num_series_y2 = len(self.config_obj.get_series_y(2)) for i, name in enumerate(self.config_obj.get_series_y(2)): @@ -219,529 +195,142 @@ def _create_figure(self) -> None: self.logger.info(f"Begin create the figure: {datetime.now()}") # create and draw the plot - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_y2axis() - self._add_legend() - - # calculate stag adjustments - stag_adjustments = self._calc_stag_adjustments() - - x_points_index = list(range(0, len(self.config_obj.indy_vals))) + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) - # create a vertical plot if needed - self._adjust_for_vertical(x_points_index) - - # reverse xaxis if needed - if self.config_obj.xaxis_reverse is True: - if self.config_obj.vert_plot is True: - self.figure.update_yaxes(autorange="reversed") - else: - self.figure.update_xaxes(autorange="reversed") - - # placeholder for the number of stats - n_stats = [0] * len(self.config_obj.indy_vals) - - # placeholder for the min and max values for y-axis - yaxis_min = None - yaxis_max = None + wts_size_styles = self.get_weights_size_styles() - # add series lines - for series in self.series_list: + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) - # Don't generate the plot for this series if - # it isn't requested (as set in the config file) - if series.plot_disp: + ax_y2 = None + if self.config_obj.parameters['list_stat_2']: + ax_y2 = self._add_y2axis(ax, wts_size_styles['y2lab']) - # collect min-max if we need to sync axis - if self.config_obj.sync_yaxes is True: - yaxis_min, yaxis_max = self._find_min_max(series, yaxis_min, - yaxis_max) + yaxis_min, yaxis_max, handles_and_labels = self._add_series(ax, ax_y2) - # apply staggering offset if applicable - if stag_adjustments[series.idx] == 0: - x_points_index_adj = x_points_index - else: - x_points_index_adj = x_points_index + stag_adjustments[series.idx] + xlab_style = wts_size_styles['xlab'] if not self.config_obj.vert_plot else wts_size_styles['ylab'] + ylab_style = wts_size_styles['ylab'] if not self.config_obj.vert_plot else wts_size_styles['xlab'] + self._add_xaxis(ax, xlab_style) + self._add_yaxis(ax, ylab_style) - self._draw_series(series, x_points_index_adj) + # add x2 axis + if wts_size_styles.get('x2lab'): + self._add_x2axis(ax, wts_size_styles['x2lab']) - # aggregate number of stats - n_stats = list(map(add, n_stats, series.series_points['nstat'])) + self._add_legend(ax, handles_and_labels) # add custom lines - self._add_lines(self.config_obj, x_points_index) + self._add_lines(ax, self.config_obj, self.config_obj.indy_vals) - # apply y axis limits - self._yaxis_limits() - self._y2axis_limits() + plt.tight_layout() # sync axis - self._sync_yaxis(yaxis_min, yaxis_max) - - # add x2 axis - self._add_x2axis(n_stats) - - # Allow plots to start from the y=0 line if set in the config file - if self.config_obj.start_from_zero is True: - self.figure.update_xaxes(range=[0, len(x_points_index) - 1]) + self._sync_yaxes(ax, ax_y2, yaxis_min, yaxis_max) self.logger.info(f"Finished creating the figure: {datetime.now()}") + def _add_series(self, ax, ax2): + handles_and_labels = [] - def _draw_series(self, series: Series, x_points_index_adj: Union[list, None] = - None) \ - -> None: - """ - Draws the formatted line with CIs if needed on the plot - - :param series: Line series object with data and parameters - :param x_points_index_adj: values for adjusting x-values position - """ - self.logger.info(f"Begin drawing the lines on the plot: {datetime.now()}") - y_points = series.series_points['dbl_med'] - - # show or not ci - # see if any ci values in not 0 - no_ci_up = all(v == 0 for v in series.series_points['dbl_up_ci']) - no_ci_lo = all(v == 0 for v in series.series_points['dbl_lo_ci']) - error_y_visible = True - if ((no_ci_up is True and no_ci_lo is True) or self.config_obj.plot_ci[ - series.idx] == 'NONE'): - error_y_visible = False - - - # switch x and y values for the vertical plot - error_x = {} - error_y = {} - if self.config_obj.vert_plot is True: - y_points, x_points_index_adj = x_points_index_adj, y_points - self._xaxis_limits() - self.figure.update_xaxes(autorange=False) - - # Error bars for vertical plot - error_x = {'type': 'data', - 'symmetric': False, - 'array': series.series_points['dbl_up_ci'], - 'arrayminus': series.series_points['dbl_lo_ci'], - 'visible': error_y_visible, - 'thickness': self.config_obj.linewidth_list[ - series.idx]} - else: - # Error bars - error_y = { - 'type': 'data', - 'symmetric': False, - 'array': series.series_points['dbl_up_ci'], - 'arrayminus': series.series_points['dbl_lo_ci'], - 'visible': error_y_visible, - 'thickness': self.config_obj.linewidth_list[series.idx] - } - - # add the plot - # orient the confidence interval bars based on the vert_plot setting in - # the yaml configuration file. - self.figure.add_trace( - go.Scatter(x=x_points_index_adj, - y=y_points, - showlegend=self.config_obj.show_legend[series.idx] == 1, - mode=self.config_obj.mode[series.idx], - textposition="top right", - name=self.config_obj.user_legends[series.idx], - connectgaps=self.config_obj.con_series[series.idx] == 1, - line={'color': self.config_obj.colors_list[series.idx], - 'width': self.config_obj.linewidth_list[series.idx], - 'dash': self.config_obj.linestyles_list[series.idx]}, - marker_symbol=self.config_obj.marker_list[series.idx], - marker_color=self.config_obj.colors_list[series.idx], - marker_line_color=self.config_obj.colors_list[series.idx], - marker_size=self.config_obj.marker_size[series.idx], - error_x=error_x, - error_y=error_y - ), - secondary_y=series.y_axis != 1 - ) - - - self.logger.info(f"Finished drawing the lines on the plot:" - f" {datetime.now()}") - - def _create_layout(self) -> Figure: - """ - Creates a new layout based on the properties from the config file - including plots size, annotation and title - - :return: Figure object - """ - # create annotation - annotation = [ - {'text': util.apply_weight_style(self.config_obj.parameters['plot_caption'], - self.config_obj.parameters[ - 'caption_weight']), - 'align': 'left', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': self.config_obj.parameters['caption_align'], - 'y': self.config_obj.caption_offset, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - }] - # create title - title = {'text': util.apply_weight_style(self.config_obj.title, - self.config_obj.parameters[ - 'title_weight']), - 'font': { - 'size': self.config_obj.title_font_size, - }, - 'y': self.config_obj.title_offset, - 'x': self.config_obj.parameters['title_align'], - 'xanchor': 'center', - 'xref': 'paper' - } - - # create a layout and allow y2 axis - fig = make_subplots(specs=[[{"secondary_y": self.allow_secondary_y}]]) - - # add size, annotation, title - fig.update_layout( - width=self.config_obj.plot_width, - height=self.config_obj.plot_height, - margin=self.config_obj.plot_margins, - paper_bgcolor=PLOTLY_PAPER_BGCOOR, - annotations=annotation, - title=title, - plot_bgcolor=PLOTLY_PAPER_BGCOOR - ) - return fig - - def _calc_stag_adjustments(self) -> list: - """ - Calculates the x-axis adjustment for each point if requested. - It needed so hte points and CIs for each x-axis values don't be placed on top - of each other - - :return: the list of the adjustment values - """ - - # get the total number of series - num_stag = len(self.config_obj.all_series_y1) + len( - self.config_obj.all_series_y2) - - # init the result with 0 - stag_vals = [0] * num_stag - - # calculate staggering values - if self.config_obj.indy_stagger is True: - dbl_adj_scale = (len(self.config_obj.indy_vals) - 1) / 150 - stag_vals = np.linspace(-(num_stag / 2) * dbl_adj_scale, - (num_stag / 2) * dbl_adj_scale, - num_stag, - True) - stag_vals = stag_vals + dbl_adj_scale / 2 - return stag_vals - - def _adjust_for_vertical(self, x_points_index: list) -> None: - """ - Switches x and y axis (creates a vertical plot) if needed + # placeholder for the min and max values for y-axis + yaxis_min = None + yaxis_max = None - :param x_points_index: list of indexes for the original x -axis - """ - self.logger.info(f"Begin switching x and y axis: {datetime.now()}") - ordered_indy_label = self.config_obj.create_list_by_plot_val_ordering( - self.config_obj.indy_label) - if self.config_obj.vert_plot is True: - self.figure.update_layout( - yaxis={ - 'tickmode': 'array', - 'tickvals': x_points_index, - 'ticktext': ordered_indy_label - } - ) - else: - self.figure.update_layout( - xaxis={ - 'tickmode': 'array', - 'tickvals': x_points_index, - 'ticktext': ordered_indy_label - } - ) + # add series lines + for series in self.series_list: - self.logger.info(f"Finished switching x and y axis: {datetime.now()}") + # Don't generate the plot for this series if it isn't requested + if not series.plot_disp: + continue - def _add_xaxis(self) -> None: - """ - Configures and adds x-axis to the plot - """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - ticks="inside", - zeroline=False, - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size}, - tickformat='d' - ) - - def _add_yaxis(self) -> None: - """ - Configures and adds y-axis to the plot - """ - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_1, - self.config_obj.parameters[ - 'ylab_weight']), - secondary_y=False, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - zeroline=False, - ticks="inside", - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.y_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['ylab_offset']), - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size} - ) - - def _add_y2axis(self) -> None: - """ - Adds y2-axis if needed - """ - if self.config_obj.parameters['list_stat_2']: - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_2, - self.config_obj.parameters[ - 'y2lab_weight']), - secondary_y=True, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - zeroline=False, - ticks="inside", - title_font={ - 'size': self.config_obj.y2_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['y2lab_offset']), - tickangle=self.config_obj.y2_tickangle, - tickfont={'size': self.config_obj.y2_tickfont_size} - ) - - def _add_legend(self) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure - """ - self.figure.update_layout(legend={'x': self.config_obj.bbox_x, - 'y': self.config_obj.bbox_y - 0.1, - 'xanchor': 'center', - 'yanchor': 'top', - 'bordercolor': - self.config_obj.legend_border_color, - 'borderwidth': - self.config_obj.legend_border_width, - 'orientation': - self.config_obj.legend_orientation, - 'font': { - 'size': self.config_obj.legend_size, - 'color': "black" - } - }) - - def _xaxis_limits(self) -> None: - """ - Apply limits on x axis if needed - especially when a vertical plot is requested + # collect min-max if we need to sync axis + if self.config_obj.sync_yaxes: + yaxis_min, yaxis_max = self._find_min_max(series, yaxis_min, yaxis_max) - step size by default is 1 if undefined /non-existent + handle = self._draw_series(ax, ax2, series) + handles_and_labels.append((handle, handle.get_label())) + return yaxis_min, yaxis_max, handles_and_labels - step size must be integer value - """ - if len(self.config_obj.parameters['xlim']) > 0: - step = round(float(self.config_obj.parameters['xlim_step'])) - if step is None: - step = 1 - - # Convert string values to float, use numpy arange to - # generate a list of labels based on the min, max, and step values - # Round the min and max values to nearest integer - min_x= round(float(self.config_obj.parameters['xlim'][0])) - max_x= round(float(self.config_obj.parameters['xlim'][1])) - tick_labels = list(np.arange(min_x , max_x + step, step)) - - self.figure.update_layout( - xaxis={ - 'range': [min_x, max_x], - 'autorange':False, - 'tickvals':tick_labels} - ) - - def _yaxis_limits(self) -> None: - """ - Apply limits on y axis if needed + def _draw_series(self, ax: plt.Axes, ax2, series: Series): """ - if len(self.config_obj.parameters['ylim']) > 0: - self.figure.update_layout( - yaxis={'range': [self.config_obj.parameters['ylim'][0], - self.config_obj.parameters['ylim'][1]], - 'autorange': False}) - - + Draws the formatted line with CIs if needed on the plot - def _y2axis_limits(self) -> None: - """ - Apply limits on y2 axis if needed - """ - if len(self.config_obj.parameters['y2lim']) > 0: - self.figure.update_layout( - yaxis2={'range': [self.config_obj.parameters['y2lim'][0], - self.config_obj.parameters['y2lim'][1]], - 'autorange': False}) - - def _sync_yaxis(self, yaxis_min: Union[float, None], - yaxis_max: Union[float, None]) -> None: + :param series: Line series object with data and parameters """ - Forces y1 and y2 axes sync if needed by specifying the same limits on both axis. - Use ylim property to determine the limits. If this value is not provided - - use method parameters + self.logger.info(f"Begin drawing the lines on the plot: {datetime.now()}") - :param yaxis_min: min value or None - :param yaxis_max: max value or None - """ - if self.config_obj.sync_yaxes is True: - if len(self.config_obj.parameters['ylim']) > 0: - # use plot config parameter - range_min = self.config_obj.parameters['ylim'][0] - range_max = self.config_obj.parameters['ylim'][1] - else: - # use method parameter - range_min = yaxis_min - range_max = yaxis_max - - if range_min is not None and range_max is not None: - # update y axis - self.figure.update_layout(yaxis={'range': [range_min, - range_max], - 'autorange': False}) - - # update y2 axis - self.figure.update_layout(yaxis2={'range': [range_min, - range_max], - 'autorange': False}) - - def _add_x2axis(self, n_stats) -> None: - """ - Creates x2axis based on the properties from the config file - and attaches it to the initial Figure + # adjust the x points to stagger them to prevent points from overlapping + x_points_index_adj, _ = self._get_x_locs_and_width(self.config_obj.indy_vals, series.idx, + stagger_scale=0.1) - :param n_stats: - labels for the axis - """ - if self.config_obj.show_nstats: - x_points_index = list(range(0, len(n_stats))) - self.figure.update_layout(xaxis2={'title_text': - util.apply_weight_style('NStats', - self.config_obj.parameters[ - 'x2lab_weight'] - ), - 'linecolor': PLOTLY_AXIS_LINE_COLOR, - 'linewidth': PLOTLY_AXIS_LINE_WIDTH, - 'overlaying': 'x', - 'side': 'top', - 'showgrid': False, - 'zeroline': False, - 'ticks': "inside", - 'title_font': { - 'size': - self.config_obj.x2_title_font_size - }, - 'title_standoff': abs( - self.config_obj.parameters[ - 'x2lab_offset'] - ), - 'tickmode': 'array', - 'tickvals': x_points_index, - 'ticktext': n_stats, - 'tickangle': self.config_obj.x2_tickangle, - 'tickfont': { - 'size': - self.config_obj.x2_tickfont_size - }, - 'scaleanchor': 'x' - } - ) - # reverse x2axis if needed - if self.config_obj.xaxis_reverse is True: - self.figure.update_layout(xaxis2={'autorange': "reversed"}) - - # need to add an invisible line with all values = None - self.figure.add_trace( - go.Scatter(y=[None] * len(x_points_index), x=x_points_index, - xaxis='x2', showlegend=False) - ) - - def remove_file(self): - """ - Removes previously made image file . Invoked by the parent class before - self.output_file - attribute can be created, but overridden here. - """ + # convert to a numpy array to change None values to NaN + y_points = np.array(series.series_points['dbl_med'], dtype=float) - super().remove_file() - self._remove_html() + plot_obj = self._draw_series_item(series, series.series_points, ax, ax2, x_points_index_adj, y_points) - def _remove_html(self) -> None: - """ - Removes previously made HTML file. - """ + self.logger.info(f"Finished drawing the lines on the plot: {datetime.now()}") + return plot_obj - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" + def _draw_series_item(self, series, series_points, ax, ax2, x_points, y_points): + # show or not ci - see if any ci values in not 0 + no_ci_up = all(v == 0 for v in series_points['dbl_up_ci']) + no_ci_lo = all(v == 0 for v in series_points['dbl_lo_ci']) - # remove the old file if it exist - if os.path.exists(html_name): - os.remove(html_name) + # convert to a numpy array to change None values to NaN + asymmetric_error = np.array([ + series_points['dbl_up_ci'], + series_points['dbl_lo_ci'] + ], dtype=float) - def write_html(self) -> None: - """ - Is needed - creates and saves the html representation of the plot WITHOUT - Plotly.js - """ - logger = util.get_common_logger(self.config_obj.log_level, - self.config_obj.log_filename) - logger.info(f"Begin writing to html file: {datetime.now()}") - if self.config_obj.create_html is True: - # construct the file name from plot_filename - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" + error_y_visible = True + if (no_ci_up and no_ci_lo) or self.config_obj.plot_ci[series.idx] == 'NONE': + error_y_visible = False - # save html - self.figure.write_html(html_name, include_plotlyjs=False) + # determine which y-axis to use for the plot + plot_ax = ax if series.y_axis == 1 else ax2 + + # plot error bar + plot_mode = self.config_obj.mode[series.idx] + marker = self.config_obj.marker_list[series.idx] if 'markers' in plot_mode else None + line_style = self.config_obj.linestyles_list[series.idx] if 'lines' in plot_mode else 'None' + + # Swap x and y data if vertical plot + plot_x = y_points if self.config_obj.vert_plot else x_points + plot_y = x_points if self.config_obj.vert_plot else y_points + + # Swap error bars (yerr becomes xerr) if vertical plot + x_err_val = asymmetric_error if (self.config_obj.vert_plot and error_y_visible) else None + y_err_val = asymmetric_error if (not self.config_obj.vert_plot and error_y_visible) else None + + plot_obj = plot_ax.errorbar( + x=plot_x, + y=plot_y, + label=self.config_obj.user_legends[series.idx], + # line style + color=self.config_obj.colors_list[series.idx], + linestyle=line_style, + linewidth=self.config_obj.linewidth_list[series.idx], + # marker style + marker=marker, + markersize=self.config_obj.marker_size[series.idx], + markeredgecolor=self.config_obj.colors_list[series.idx], + markerfacecolor=self.config_obj.colors_list[series.idx], + # error bar + xerr=x_err_val, + yerr=y_err_val, + elinewidth=self.config_obj.linewidth_list[series.idx], + capsize=5, + ) + return plot_obj - logger.info(f"Finished writing to html file: {datetime.now()}") def write_output_file(self) -> None: """ Formats y1 and y2 series point data to the 2-dim arrays and saves them to the files """ + if not self.config_obj.dump_points_1 and not self.config_obj.dump_points_2: + return self.logger.info(f"Begin writing to output file: {datetime.now()}") # if points_path parameter doesn't exist, @@ -750,51 +339,51 @@ def write_output_file(self) -> None: # extension with .points1 extension # otherwise use points_path path match = re.match(r'(.*)(.data)', self.config_obj.parameters['stat_input']) - if (self.config_obj.dump_points_1 is True or self.config_obj.dump_points_2 is - True and match): - - # create 2-dim array for y1 points and fill it with 0 - all_points_1 = [[0 for x in range(len(self.config_obj.all_series_y1) * 3)] - for y in - range(len(self.config_obj.indy_vals))] - if self.config_obj.series_vals_2: - # create 2-dim array for y1 points and feel it with 0 - all_points_2 = [ - [0 for x in range(len(self.config_obj.all_series_y2) * 3)] for y in - range(len(self.config_obj.indy_vals))] + if not match: + return + + # create 2-dim array for y1 points and fill it with 0 + all_points_1 = [[0 for _ in range(len(self.config_obj.all_series_y1) * 3)] + for _ in range(len(self.config_obj.indy_vals))] + if self.config_obj.series_vals_2: + # create 2-dim array for y1 points and feel it with 0 + all_points_2 = [ + [0 for _ in range(len(self.config_obj.all_series_y2) * 3)] + for _ in range(len(self.config_obj.indy_vals)) + ] + else: + all_points_2 = [] + + # separate indexes for y1 and y2 series + series_idx_y1 = 0 + series_idx_y2 = 0 + + # get points from each series + for series in self.series_list: + if series.y_axis == 1: + self._record_points(all_points_1, series_idx_y1, series) + series_idx_y1 = series_idx_y1 + 1 + else: + self._record_points(all_points_2, series_idx_y2, series) + series_idx_y2 = series_idx_y2 + 1 + + # replace the default path with the custom + filename = match.group(1) + if self.config_obj.points_path is not None: + # get the file name + path = filename.split(os.path.sep) + if len(path) > 0: + filename = path[-1] else: - all_points_2 = [] - - # separate indexes for y1 and y2 series - series_idx_y1 = 0 - series_idx_y2 = 0 - - # get points from each series - for series in self.series_list: - if series.y_axis == 1: - self._record_points(all_points_1, series_idx_y1, series) - series_idx_y1 = series_idx_y1 + 1 - else: - self._record_points(all_points_2, series_idx_y2, series) - series_idx_y2 = series_idx_y2 + 1 - - # replace the default path with the custom - filename = match.group(1) - if self.config_obj.points_path is not None: - # get the file name - path = filename.split(os.path.sep) - if len(path) > 0: - filename = path[-1] - else: - filename = '.' + os.path.sep - filename = self.config_obj.points_path + os.path.sep + filename - os.makedirs(filename, exist_ok=True) - - # save points - self._save_points(all_points_1, filename + ".points1") - self._save_points(all_points_2, filename + ".points2") - - self.logger.info(f"Finished writing to output file: {datetime.now()}") + filename = '.' + os.path.sep + filename = self.config_obj.points_path + os.path.sep + filename + os.makedirs(filename, exist_ok=True) + + # save points + self._save_points(all_points_1, filename + ".points1") + self._save_points(all_points_2, filename + ".points2") + + self.logger.info(f"Finished writing to output file: {datetime.now()}") @staticmethod def _find_min_max(series: LineSeries, yaxis_min: Union[float, None], @@ -850,17 +439,14 @@ def _record_points(self, all_points: list, series_idx: int, all_points[indy_val_idx][series_idx * 3] = y_points[indy_val_idx] # place CI-low value or None - if not y_points[indy_val_idx] is None \ - and not dbl_lo_ci[indy_val_idx] is None: - + if y_points[indy_val_idx] is not None and dbl_lo_ci[indy_val_idx] is not None: all_points[indy_val_idx][series_idx * 3 + 1] = \ y_points[indy_val_idx] - dbl_lo_ci[indy_val_idx] else: all_points[indy_val_idx][series_idx * 3 + 1] = None # place CI-up value or None - if not y_points[indy_val_idx] is None \ - and not dbl_up_ci[indy_val_idx] is None: + if y_points[indy_val_idx] is not None and dbl_up_ci[indy_val_idx] is not None: all_points[indy_val_idx][series_idx * 3 + 2] = \ y_points[indy_val_idx] + dbl_up_ci[indy_val_idx] else: @@ -891,7 +477,6 @@ def _save_points(points: list, output_file: str) -> None: with open(output_file, "w+") as my_csv: csv_writer = csv.writer(my_csv, delimiter=' ') csv_writer.writerows(all_points_formatted) - my_csv.close() except TypeError: print('Can\'t save points to a file') diff --git a/metplotpy/plots/line/line_config.py b/metplotpy/plots/line/line_config.py index 39149d967..1e229d75d 100644 --- a/metplotpy/plots/line/line_config.py +++ b/metplotpy/plots/line/line_config.py @@ -70,7 +70,6 @@ def __init__(self, parameters: dict) -> None: ############################################## # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -100,7 +99,6 @@ def __init__(self, parameters: dict) -> None: if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) ############################################## # x2-axis parameters @@ -147,29 +145,6 @@ def __init__(self, parameters: dict) -> None: self.legend_border_color = "black" self.points_path = self.get_config_value('points_path') - def _get_plot_disp(self) -> list: - """ - Retrieve the values that determine whether to display a particular series - and convert them to bool if needed - - Args: - - Returns: - A list of boolean values indicating whether or not to - display the corresponding series - """ - - plot_display_config_vals = self.get_config_value('plot_disp') - plot_display_bools = [] - for val in plot_display_config_vals: - if isinstance(val, bool): - plot_display_bools.append(val) - - if isinstance(val, str): - plot_display_bools.append(val.upper() == 'TRUE') - - return self.create_list_by_series_ordering(plot_display_bools) - def _get_fcst_vars(self, index): """ Retrieve a list of the inner keys (fcst_vars) to the fcst_var_val dictionary. @@ -272,90 +247,6 @@ def _get_fixed_vars_vals(self) -> dict: return updated_fixed_vars_vals_dict - - def _get_mode(self) -> list: - """ - Retrieve all the modes. Convert mode names from - the config file into plotly python's mode names. - - Args: - - Returns: - markers: a list of the plotly markers - """ - modes = self.get_config_value('series_type') - mode_list = [] - for mode in modes: - if mode in constants.TYPE_TO_PLOTLY_MODE.keys(): - # the recognized plotly marker names: - # circle-open (for small circle), circle, triangle-up, - # square, diamond, or hexagon - mode_list.append(constants.TYPE_TO_PLOTLY_MODE[mode]) - else: - mode_list.append('lines+markers') - return self.create_list_by_series_ordering(mode_list) - - def _get_linestyles(self) -> list: - """ - Retrieve all the line styles. Convert line style names from - the config file into plotly python's line style names. - - Args: - - Returns: - line_styles: a list of the plotly line styles - """ - line_styles = self.get_config_value('series_line_style') - line_style_list = [] - for line_style in line_styles: - if line_style in constants.LINE_STYLE_TO_PLOTLY_DASH.keys(): - line_style_list.append(constants.LINE_STYLE_TO_PLOTLY_DASH[line_style]) - else: - line_style_list.append(None) - return self.create_list_by_series_ordering(line_style_list) - - def _get_markers(self) -> list: - """ - Retrieve all the markers. Convert marker names from - the config file into plotly python's marker names. - - Args: - - Returns: - markers: a list of the plotly markers - """ - markers = self.get_config_value('series_symbols') - markers_list = [] - for marker in markers: - if marker in constants.AVAILABLE_PLOTLY_MARKERS_LIST: - # the recognized plotly marker names: - # circle-open (for small circle), circle, triangle-up, - # square, diamond, or hexagon - markers_list.append(marker) - else: - markers_list.append(constants.PCH_TO_PLOTLY_MARKER[marker]) - return self.create_list_by_series_ordering(markers_list) - - def _get_markers_size(self) -> list: - """ - Retrieve all the markers. Convert marker names from - the config file into plotly python's marker names. - - Args: - - Returns: - markers: a list of the plotly markers - """ - markers = self.get_config_value('series_symbols') - markers_size = [] - for marker in markers: - if marker in constants.AVAILABLE_PLOTLY_MARKERS_LIST: - markers_size.append(marker) - else: - markers_size.append(constants.PCH_TO_PLOTLY_MARKER_SIZE[marker]) - - return self.create_list_by_series_ordering(markers_size) - def _get_plot_stat(self) -> str: """ Retrieves the plot_stat setting from the config file. @@ -380,24 +271,13 @@ def _get_plot_stat(self) -> str: " Supported values are sum, mean, and median.") return stat_to_plot - def _config_consistency_check(self) -> bool: - """ - Checks that the number of settings defined for plot_ci, - plot_disp, series_order, user_legend, colors, and series_symbols - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - series (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) + def config_consistency_check(self) -> bool: + """Checks that the number of settings are consistent with number of series. + @raises ValueError if any of settings are inconsistent with the + number of series (as defined by the cross product of the model + and vx_mask defined in the series_val_1 setting) """ - # Determine the number of series based on the number of - # permutations from the series_var setting in the - # config file lists_to_check = { "plot_ci": self.plot_ci, @@ -411,18 +291,7 @@ def _config_consistency_check(self) -> bool: "show_legend": self.show_legend, "con_series": self.con_series, } - status = True - for name, list_to_check in lists_to_check.items(): - - if len(list_to_check) == self.num_series: - continue - - self.logger.error( - f"number of series ({self.num_series}) does not match {name} ({len(list_to_check)})" - ) - status = False - - return status + self._config_compare_lists_to_num_series(lists_to_check) def _get_plot_ci(self) -> list: """ diff --git a/metplotpy/plots/line/line_series.py b/metplotpy/plots/line/line_series.py index 04c644e47..6b8194f1f 100644 --- a/metplotpy/plots/line/line_series.py +++ b/metplotpy/plots/line/line_series.py @@ -26,7 +26,7 @@ from scipy.stats import norm import metcalcpy.util.utils as utils -import metplotpy.plots.util +import metplotpy.plots.util as util from ..series import Series from .. import GROUP_SEPARATOR @@ -50,8 +50,7 @@ def __init__(self, config, idx: int, input_data, series_list: list, # Retrieve any fixed variables - self.logger = metplotpy.plots.util.get_common_logger(config.log_level, - config.log_filename) + self.logger = util.get_common_logger(config.log_level, config.log_filename) def _create_all_fields_values_no_indy(self) -> dict: """ @@ -91,8 +90,7 @@ def _calc_point_stat(self, data: list) -> Union[float, None]: :return: mean, median or sum of the values from the input list or None if the statistic parameter is invalid """ - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Begin calculating plot_stat parameter: " f"{datetime.now()}") # calculate point stat @@ -111,8 +109,7 @@ def _calc_point_stat(self, data: list) -> Union[float, None]: else: point_stat = None - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Begin calculating plot_stat parameter: " f"{datetime.now()}") return point_stat @@ -127,8 +124,7 @@ def _create_series_points(self) -> dict: Returns: dictionary with CI ,point values and number of stats as keys """ - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Begin calculating values for each series point: " f"{datetime.now()}") series_data_1 = None @@ -141,8 +137,8 @@ def _create_series_points(self) -> dict: # @nan_val is substituted for the 'NA' in the list of values # that correspond to a column. - filtered_df = metplotpy.plots.util.filter_by_fixed_vars(self.input_data, - self.config.fixed_vars_vals) + filtered_df = util.filter_by_fixed_vars(self.input_data, + self.config.fixed_vars_vals) else: # Nothing specified in the fixed_vars_vals_input setting, # use the original input data diff --git a/metplotpy/plots/mpr_plot/mpr_plot.py b/metplotpy/plots/mpr_plot/mpr_plot.py index 90a77fccf..c3a3ca19d 100644 --- a/metplotpy/plots/mpr_plot/mpr_plot.py +++ b/metplotpy/plots/mpr_plot/mpr_plot.py @@ -19,12 +19,10 @@ import numpy as np import yaml -import plotly.graph_objects as go -from plotly.subplots import make_subplots -import plotly.io as pio +from matplotlib import pyplot as plt +from matplotlib.gridspec import GridSpec from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR from metplotpy.plots.mpr_plot.mpr_plot_config import MprPlotConfig from metplotpy.plots.wind_rose.wind_rose import WindRosePlot @@ -33,15 +31,10 @@ class MprPlotInfo(): """ - A placeholder for the plot. Contains a plotly traces and the additional parameters + A placeholder for the plot. Contains additional parameters """ def __init__(self): - - - # plotly traces for the plot - self.traces = [] - # plot't title self.title = None @@ -119,19 +112,16 @@ def _read_input_data(self) -> None: if not self.input_df: self.input_df = filtered else: - # self.input_df = self.input_df.append(filtered) self.input_df = pd.concat([self.input_df, filtered]) self.logger.info(f"Finished reading input data: {datetime.now()}") - def _create_figure(self) -> go.Figure: + def _create_figure(self) -> plt.Figure: """ - Initialise the figure and add Wnd roses traces - - Args: + Initialise the figure and add subplots Returns: - Multipanel plot as Plotly figure + Multipanel plot as Matplotlib figure """ self.logger.info(f"Begin creating the figure: {datetime.now()}") @@ -151,133 +141,27 @@ def _create_figure(self) -> go.Figure: # find unique cases cases = self.input_df['CASE'].unique() - # each case has at least 2 rows (4 plots) - n_rows = len(cases) * 2 - - # Loop through each of the cases and create plots - self._create_plots(cases) - - # Initialize figure with subplots - subplot_titles = [] - specs = [] - for plot_info in self.plot_info_list: - # collect titles - subplot_titles.append(plot_info.title) - - # add additional specs for the wind rose - if plot_info.col == 1: - if isinstance(plot_info.traces[0], go.Barpolar): - n_rows = n_rows + 2 - specs.append([{'type': 'polar', 'colspan': 2, 'rowspan': 2}, None]) - specs.append([None, None]) - else: - specs.append([{}, {}]) - - fig = make_subplots(rows=int(n_rows), cols=2, - subplot_titles=subplot_titles, - shared_yaxes=False, specs=specs) - - fig.update_xaxes( - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - ticks="outside", - zeroline=False, - automargin=True - ) - - fig.update_yaxes( - secondary_y=False, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - zeroline=False, - ticks="outside", - automargin=True - ) - - # add plots and traces to it's specified locations - for plot_info in self.plot_info_list: - if not isinstance(plot_info.traces[0], go.Barpolar): - # for line plots - for trace in plot_info.traces: - fig.add_trace(trace, row=plot_info.row, col=plot_info.col) - - fig.update_xaxes(title_text=plot_info.xaxes['title_text'], - range=plot_info.xaxes['range'], - row=plot_info.row, col=plot_info.col) - fig.update_yaxes(title_text=plot_info.yaxes['title_text'], - # range=plot_info.yaxes['range'], - row=plot_info.row, col=plot_info.col) - else: - # for wind rose - for trace in plot_info.traces: - fig.add_trace(trace, row=plot_info.row, col=plot_info.col) - - # additional setings for the wind rose plots - fig.update_polars( - bgcolor=PLOTLY_PAPER_BGCOOR, - hole=0.08, - angularaxis_thetaunit="degrees", - angularaxis_rotation=90, - angularaxis_direction='clockwise', - angularaxis_gridcolor=PLOTLY_AXIS_LINE_COLOR, - angularaxis_tickvals=self.config_obj.angularaxis_tickvals, - angularaxis_ticktext=self.config_obj.angularaxis_ticktext, - angularaxis_tickmode='array', - radialaxis_angle=135, - radialaxis_tickmode='linear', - radialaxis_tickangle=100, - radialaxis_tick0=5, - radialaxis_dtick=5, - - radialaxis_gridcolor=PLOTLY_AXIS_LINE_COLOR, - radialaxis_showticklabels=True, - radialaxis_ticksuffix='%' - ) - - # general settings - fig.update_layout( - showlegend=False, - plot_bgcolor=PLOTLY_PAPER_BGCOOR, - bargap=0, - margin=dict( - l=50, - r=50, - b=100, - t=100, - pad=4 - ), - autosize=False, - width=self.config_obj.width, - height=self.config_obj.height, - ) + # Calculate total rows + n_rows = 0 + for case in cases: + n_rows += 2 # for histograms and scatter/qq + if self.config_obj.wind_rose: + case_subset = self.input_df[self.input_df['CASE'] == case] + if case_subset['FCST_VAR'].iloc[0] == 'UGRD': + n_rows += 6 # 3 wind roses * 2 rows each - self.logger.info(f"Finished creating the figure: {datetime.now()}") - return fig + fig = plt.figure(figsize=(self.config_obj.width / 100, self.config_obj.height / 100)) + gs = GridSpec(n_rows, 2, figure=fig) - def _create_plots(self, cases: np.ndarray) -> None: - """ - For the each case create a set of plots: - - histogram for forecast - - histogram for obs - - scatter plot - - Q-Q plot - - wind rose plots for forecast, obs winds and wind error (if requested) - Calculates the position and the title for each plot - :param cases: list of unique cases - :return: - """ - - self.logger.info(f"Creating a plot for each case {datetime.now()}") - row_n = 1 - for case_ind, case in enumerate(cases): + # Loop through each of the cases and create plots + curr_row = 0 + for case in cases: # Get the subset for this case case_subset = self.input_df[self.input_df['CASE'] == case] case_subset.reset_index(inplace=True, drop=True) case_name_1 = f"{case_subset['MODEL'][0]}: {case_subset['FCST_VAR'][0]} at {case_subset['FCST_LEV'][0]}" case_name_2 = f"{case_subset['OBTYPE'][0]}, {case_subset['VX_MASK'][0]}, {case_subset['INTERP_MTHD'][0]} ({case_subset['INTERP_PNTS'][0]})" - case_title = f"{case_name_1}
{case_name_2}" + case_title = f"{case_name_1}\n{case_name_2}" wind_case_title = f"{case_name_1}, {case_name_2}" fcst_obs_data = pd.concat([case_subset['FCST'], case_subset['OBS']]) @@ -285,31 +169,27 @@ def _create_plots(self, cases: np.ndarray) -> None: n_bins = util.pretty(min(fcst_obs_data), max(fcst_obs_data), number_of_intervals) # histogram for forecast - info_fcst = self._create_histogtam(case_title, case_subset, n_bins, 'FCST') - if info_fcst: - info_fcst.row = row_n - self.plot_info_list.append(info_fcst) + ax_fcst = fig.add_subplot(gs[curr_row, 0]) + self._create_histogram(ax_fcst, case_title, case_subset, n_bins, 'FCST') # histogram for obs - info_obs = self._create_histogtam(case_title, case_subset, n_bins, 'OBS') - if info_obs: - info_obs.row = row_n - self.plot_info_list.append(info_obs) + ax_obs = fig.add_subplot(gs[curr_row, 1]) + self._create_histogram(ax_obs, case_title, case_subset, n_bins, 'OBS') + + curr_row += 1 - row_n = row_n + 1 - # create trend line - trend_line = self._create_trend_line(case_subset) + # create trend line coords + x_trend, y_trend = self._create_trend_line(case_subset) # Create a scatter plot - scatter = self._create_scatter_plot(case_title, case_subset, trend_line) - scatter.row = row_n - self.plot_info_list.append(scatter) + ax_scatter = fig.add_subplot(gs[curr_row, 0]) + self._create_scatter_plot(ax_scatter, case_title, case_subset, x_trend, y_trend) # Create a Q-Q plot - qq_plot = self._create_qq_plot(case_title, case_subset, trend_line) - qq_plot.row = row_n - self.plot_info_list.append(qq_plot) - row_n = row_n + 1 + ax_qq = fig.add_subplot(gs[curr_row, 1]) + self._create_qq_plot(ax_qq, case_title, case_subset, x_trend, y_trend) + + curr_row += 1 # Check for UGRD/VGRD vector pairs and plot wind rose if self.config_obj.wind_rose and case_subset['FCST_VAR'][0] == \ @@ -319,44 +199,31 @@ def _create_plots(self, cases: np.ndarray) -> None: vind = self.input_df[self.input_df['CASE'] == vgrd_case] vind.reset_index(inplace=True, drop=True) - # in Rscript: sum(data$OBS_SID[uind] == data$OBS_SID[v_wind_data]) != sum(uind)) if len(case_subset) == len(vind): - - info = self._create_wind_rose_plot(case_subset, vind, wind_case_title, 'FCST') - info.row = row_n - self.plot_info_list.append(info) - row_n = row_n + 2 - - info = self._create_wind_rose_plot(case_subset, vind, wind_case_title, 'OBS') - info.row = row_n - self.plot_info_list.append(info) - row_n = row_n + 2 - - info = self._create_wind_rose_plot(case_subset, vind, wind_case_title, 'FCST-OBS') - info.row = row_n - self.plot_info_list.append(info) - row_n = row_n + 2 + for data_type in ['FCST', 'OBS', 'FCST-OBS']: + ax_wr = fig.add_subplot(gs[curr_row:curr_row+2, :], projection='polar') + self._create_wind_rose_plot(ax_wr, case_subset, vind, wind_case_title, data_type) + curr_row += 2 else: - self.logger.warning(" WARNINING:: UGRD/VGRD vectors do not " - "exactly matc ") - self.logger.info(f"Finished creating a plot: {datetime.now()}") + self.logger.warning(" WARNING:: UGRD/VGRD vectors do not exactly match ") + + plt.tight_layout() + self.logger.info(f"Finished creating the figure: {datetime.now()}") + return fig - def _create_wind_rose_plot(self, u_wind_data: pd.DataFrame, + def _create_wind_rose_plot(self, ax: plt.Axes, u_wind_data: pd.DataFrame, v_wind_data: pd.DataFrame, case_title: str, - data_type: str) -> MprPlotInfo: + data_type: str) -> None: """ - Creates MprPlotInfo for the wind rose plot - :param u_wind_data: DataFrame with a requared column named 'FCST_VAR' with values 'UGRD' - and columns 'OBS' and 'FCST' with U data - :param v_wind_data: DataFrame with a requared column named 'FCST_VAR' with values 'VGRD' - and columns 'OBS' and 'FCST' with V data + Creates wind rose plot on the provided axes + :param ax: axes to plot on + :param u_wind_data: DataFrame with U wind data + :param v_wind_data: DataFrame with V wind data :param case_title: title - :param data_type: type of the wind rose.Indicates which data to use for the plot. - Can be 'FCST', 'OBS' or 'FCST-OBS' - :return: MprPlotInfo object with wind rose traces and title + :param data_type: type of the wind rose ('FCST', 'OBS', or 'FCST-OBS') """ - self.logger.info('Begin creating a wind rose plot') + self.logger.info(f"Begin creating a wind rose plot for {data_type}") if data_type == 'FCST-OBS': title = 'Wind Errors' elif data_type == 'FCST': @@ -364,37 +231,25 @@ def _create_wind_rose_plot(self, u_wind_data: pd.DataFrame, else: title = 'Observed' - # init MprPlotInfo - info = MprPlotInfo() - info.col = 1 - info.title = f'{title} winds {len(u_wind_data)} points
{case_title}
' - # create custom parameters for the plot docs = { - 'create_figure': False, 'show_legend': False, - 'type': data_type + 'type': data_type, + 'title': f'{title} winds {len(u_wind_data)} points\n{case_title}' } # add main parameters docs.update(self.config_obj.parameters) - # create a wind rose - plot = WindRosePlot(docs, u_wind_data, v_wind_data) - - # record traces - for trace in plot.traces: - info.traces.append(trace) - + # create a wind rose on the provided axis + WindRosePlot(docs, u_wind_data, v_wind_data, ax=ax) self.logger.info(f"Finished creating wind rose: {datetime.now()} ") - return info - - def _create_trend_line(self, case_subset: pd.DataFrame) -> go.Scatter: + def _create_trend_line(self, case_subset: pd.DataFrame) -> tuple: """ - Creates a trend line to use in a scatter and Q-Q plots + Creates coordinates for a trend line to use in a scatter and Q-Q plots It calculates the intercept and slope for the line using OBS and FCST data :param case_subset: DataFrame with data for this case - :return: a trend line as a Plotly Scatter + :return: x and y coordinates for the trend line """ fcst = case_subset['FCST'] @@ -406,197 +261,88 @@ def _create_trend_line(self, case_subset: pd.DataFrame) -> go.Scatter: intercept = slope_intercept[1] if intercept == 0 and slope == 0: - x_coords = [-1, 1] - y_coords = [-1, 1] + x_coords = np.array([-1, 1]) + y_coords = np.array([-1, 1]) else: y_coords = intercept + slope * x_coords - trend_line = go.Scatter(x=x_coords, - y=y_coords, - line={'color': 'black', - 'width': 1, - 'dash': 'dash'}, - showlegend=False, - mode='lines', - name='Trend Line' - ) - return trend_line - - def _create_qq_plot(self, case_title: str, case_subset: pd.DataFrame, - trend_line: go.Scatter) -> MprPlotInfo: + return x_coords, y_coords + + def _create_qq_plot(self, ax: plt.Axes, case_title: str, case_subset: pd.DataFrame, + x_trend: np.ndarray, y_trend: np.ndarray) -> None: """ - MprPlotInfo for the Q-Q plot + Plots the Q-Q plot on the provided axes + :param ax: axes to plot on :param case_title: plot title :param case_subset: DataFrame with FCST and OBS data - :param trend_line: Scatter for the trend line - :return: MprPlotInfo object with Q-Q plot traces and title + :param x_trend: x coordinates for the trend line + :param y_trend: y coordinates for the trend line """ self.logger.info(f"Begin creating qq plot: {datetime.now()}") # subset and sort data - qq_fcst = case_subset['FCST'].tolist() - qq_fcst.sort() - - qq_obs = case_subset['OBS'].tolist() - qq_obs.sort() + qq_fcst = np.sort(case_subset['FCST']) + qq_obs = np.sort(case_subset['OBS']) # create the plot - qq_plot = go.Scatter( - x=qq_fcst, - y=qq_obs, - mode='markers', - name='Q-Q Plot', - marker=dict( - color=self.config_obj.marker_color, - line=dict( - color='rgb(174,167,250)' - ) - ) - ) - - # init MprPlotInfo - info = MprPlotInfo() - info.col = 2 - info.traces.append(qq_plot) - info.traces.append(trend_line) - info.title = f"Q-Q Plot of {len(case_subset)} points
{case_title}" - info.xaxes['title_text'] = 'Forecast' - info.yaxes['title_text'] = 'Observation' + ax.scatter(qq_fcst, qq_obs, color=self.config_obj.marker_color, edgecolors='blue', alpha=0.7) + ax.plot(x_trend, y_trend, color='black', linestyle='--', linewidth=1) + ax.set_title(f"Q-Q Plot of {len(case_subset)} points\n{case_title}") + ax.set_xlabel('Forecast') + ax.set_ylabel('Observation') self.logger.info(f"Finished creating qq plot: {datetime.now()}") - return info - def _create_scatter_plot(self, case_title: str, case_subset: pd.DataFrame, - trend_line: go.Scatter) -> MprPlotInfo: + def _create_scatter_plot(self, ax: plt.Axes, case_title: str, case_subset: pd.DataFrame, + x_trend: np.ndarray, y_trend: np.ndarray) -> None: """ - MprPlotInfo for the Scatter plot + Plots the Scatter plot on the provided axes + :param ax: axes to plot on :param case_title: plot title :param case_subset: DataFrame with FCST and OBS data - :param trend_line: Scatter for the trend line - :return: MprPlotInfo object with Scatter plot traces and title + :param x_trend: x coordinates for the trend line + :param y_trend: y coordinates for the trend line """ self.logger.info(f"Begin creating scatter plot: {datetime.now()}") # create the plot - scatter = go.Scatter( - x=case_subset['FCST'], - y=case_subset['OBS'], - mode='markers', - name='Scatter Plot', - marker=dict( - color=self.config_obj.marker_color, - line=dict( - color='rgb(174,167,250)' - ) - ) - ) + ax.scatter(case_subset['FCST'], case_subset['OBS'], color=self.config_obj.marker_color, edgecolors='blue', alpha=0.7) + ax.plot(x_trend, y_trend, color='black', linestyle='--', linewidth=1) - # init MprPlotInfo - info = MprPlotInfo() - info.col = 1 - info.traces.append(scatter) - info.traces.append(trend_line) - info.title = f"Scatter Plot of {len(case_subset)} points
{case_title}" - info.xaxes['title_text'] = 'Forecast' - info.yaxes['title_text'] = 'Observation' + ax.set_title(f"Scatter Plot of {len(case_subset)} points\n{case_title}") + ax.set_xlabel('Forecast') + ax.set_ylabel('Observation') self.logger.info(f"Finished creating scatter plot: {datetime.now()}") - return info - def _create_histogtam(self, case_title: str, case_subset: pd.DataFrame, - n_bins: np.ndarray, data_type: str) -> MprPlotInfo: + def _create_histogram(self, ax: plt.Axes, case_title: str, case_subset: pd.DataFrame, + n_bins: np.ndarray, data_type: str) -> None: """ - Creates MprPlotInfo for the histogtam + Plots the histogram on the provided axes + :param ax: axes to plot on :param case_title: plot title :param case_subset: DataFrame with FCST and OBS data :param n_bins: data bins - :param data_type: type for 'FCST' or 'OBS' histogtam - :return: MprPlotInfo object with Scatter plot traces and title + :param data_type: type for 'FCST' or 'OBS' histogram """ - self.logger.info(f"Begin creating the histogram: {datetime.now()}") - info = MprPlotInfo() - if data_type == 'FCST': - title = 'Forecast' - info.col = 1 - else: - title = 'Observation' - info.col = 2 - - # calculate histogram data and bins - hist_kwargs = dict() - hist_kwargs['range'] = (min(case_subset[data_type]), max(case_subset[data_type])) - hist_counts, hist_bins = \ - np.histogram(case_subset[data_type], n_bins, weights=None, **hist_kwargs) - - hist_bins = 0.5 * (hist_bins[:-1] + hist_bins[1:]) + self.logger.info(f"Begin creating the histogram for {data_type}: {datetime.now()}") + title = 'Forecast' if data_type == 'FCST' else 'Observation' # create plot - histogram = go.Bar( - x=hist_bins, - y=hist_counts, - name=f"{title} Histogram", - marker=dict( - color='#ffffff', - line=dict( - color='rgb(1, 1, 1)', - width=1 - ) - ) - ) + ax.hist(case_subset[data_type], bins=n_bins, color='white', edgecolor='black', alpha=0.75) - # init MprPlotInfo - info.traces.append(histogram) - info.title = f"{title} Histogram of {len(case_subset)} points
{case_title}" - info.xaxes['title_text'] = title - info.xaxes['range'] = [0, n_bins[-1]] - info.yaxes['title_text'] = 'Frequency' + ax.set_title(f"{title} Histogram of {len(case_subset)} points\n{case_title}") + ax.set_xlabel(title) + ax.set_ylabel('Frequency') self.logger.info(f"Finished creating histogram: {datetime.now()}") - return info - - def save_to_file(self) -> None: - """Saves the image to a file specified in the config file. - Prints a message if fails - - Args: - - Returns: - - """ - self.logger.info("Saving to file") - image_name = self.get_config_value('plot_filename') - pio.kaleido.scope.default_format = "png" - pio.kaleido.scope.default_height = self.config_obj.height - pio.kaleido.scope.default_width = self.config_obj.width - if self.figure: - try: - os.makedirs(os.path.dirname(image_name), exist_ok=True) - self.figure.write_image(image_name) - except FileNotFoundError: - self.logger.error(f"FileNotFoundError: {image_name}") - except ValueError as ex: - self.logger.error(f"ValueError: {ex}") - else: - self.logger.warning("WARNING: Oops! The figure was not created. Can't " - "save.") - self.logger.info(f"Finished saving mpr plot {datetime.now()}") def main(config_filename=None): """ - Generates a sample, default, line plot using the - default and custom config files on sample data found in this directory. - The location of the input data is defined in either the default or - custom config file. - """ - params = util.get_params(config_filename) - try: - plot = MprPlot(params) - plot.save_to_file() - if plot.config_obj.show_in_browser: - plot.show_in_browser() - plot.logger.info(f"Finished plotting for mpr: {datetime.now()}") - except ValueError as ve: - print(ve) + Generates a mpr plot using the + default and custom config files on sample data. + """ + util.make_plot(config_filename, MprPlot) if __name__ == "__main__": diff --git a/metplotpy/plots/mpr_plot/mpr_plot_config.py b/metplotpy/plots/mpr_plot/mpr_plot_config.py index e1835a96f..3391d1f02 100644 --- a/metplotpy/plots/mpr_plot/mpr_plot_config.py +++ b/metplotpy/plots/mpr_plot/mpr_plot_config.py @@ -29,7 +29,8 @@ def __init__(self, parameters): self.wind_rose_breaks = self.get_config_value('wind_rose_breaks') self.wind_rose_angle = self.get_config_value('wind_rose_angle') - self.wind_rose_marker_colors = self.get_config_value('wind_rose_marker_colors') + marker_colors = self.get_config_value('wind_rose_marker_colors') + self.wind_rose_marker_colors = [self._format_color(color) for color in marker_colors] if len(self.wind_rose_marker_colors) != len(self.wind_rose_breaks): raise ValueError('wind_rose_marker_colors must have the same size as wind_rose_breaks') @@ -44,5 +45,5 @@ def __init__(self, parameters): self.mpr_file_list = self.get_config_value('mpr_file_list') self.width = self.get_config_value('width') self.height = self.get_config_value('height') - self.marker_color = self.get_config_value('marker_color') + self.marker_color = self._format_color(self.get_config_value('marker_color')) self.show_in_browser = self.get_config_value('show_in_browser') diff --git a/metplotpy/plots/performance_diagram/performance_diagram.py b/metplotpy/plots/performance_diagram/performance_diagram.py index a9d96d305..651b3b0ef 100644 --- a/metplotpy/plots/performance_diagram/performance_diagram.py +++ b/metplotpy/plots/performance_diagram/performance_diagram.py @@ -66,7 +66,7 @@ def __init__(self, parameters): # config file. self.config_obj = PerformanceDiagramConfig(self.parameters) - # Logging in matplotlib plots is different from the plotly plots. The + # For logging in matplotlib plots is different from the plotly plots. # ContextFilter cannot be used to add the username/userid to the log. # Use the extra={'user':userid} in the log.xyz(msg,...) syntax instead. self.logger = self.config_obj.logger @@ -149,14 +149,6 @@ def _create_series(self, input_data): self.logger.info(f"Finished creating series objects: {datetime.now()}") return series_list - def save_to_file(self): - """ - This is the matplotlib-friendly implementation, which overrides the parent class' - version (which is a Python Plotly implementation). - - """ - plt.savefig(self.config_obj.output_image) - def remove_file(self): """ Removes previously made image file. Invoked by the parent class before self.output_file @@ -389,10 +381,10 @@ def _create_figure(self): if self.config_obj.yaxis_2: ax2.set_ylabel(self.config_obj.yaxis_2, fontsize=9) + self.logger.info(f"Finished drawing CSI lines: {datetime.now()}") + # use plt.tight_layout() to prevent label box from scrolling off the figure plt.tight_layout() - plt.savefig(self.get_config_value('plot_filename')) - self.logger.info(f"Finished drawing CSI lines: {datetime.now()}") self.save_to_file() self.logger.info("Finished saving file.") diff --git a/metplotpy/plots/performance_diagram/performance_diagram_config.py b/metplotpy/plots/performance_diagram/performance_diagram_config.py index 263227f88..4ccf8b4bd 100644 --- a/metplotpy/plots/performance_diagram/performance_diagram_config.py +++ b/metplotpy/plots/performance_diagram/performance_diagram_config.py @@ -5,11 +5,7 @@ # ** Research Applications Lab (RAL) # ** P.O.Box 3000, Boulder, Colorado, 80307-3000, USA # ============================* - - - -#!/usr/bin/env conda run -n blenny_363 python """ Class Name: performance_diagram_config.py @@ -67,8 +63,8 @@ def __init__(self, parameters): self.linewidth_list = self._get_linewidths() self.linestyles_list = self._get_linestyles() self.user_legends = self._get_user_legends("Performance") - self.plot_width = self.calculate_plot_dimension('plot_width', 'in') - self.plot_height = self.calculate_plot_dimension('plot_height', 'in') + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') # x-axis labels and x-axis ticks self.x_title_font_size = self.parameters['xlab_size'] * constants.DEFAULT_CAPTION_FONTSIZE @@ -123,24 +119,6 @@ def _get_series_order(self): series_order_list = [ord for ord in ordinals] return series_order_list - - def _get_plot_disp(self): - """ - Retrieve the boolean values that determine whether to display a particular series - - Args: - - Returns: - A list of boolean values indicating whether or not to - display the corresponding series - """ - - plot_display_vals = self.get_config_value('plot_disp') - plot_display_bools = [pd for pd in plot_display_vals] - plot_display_bools_ordered = self.create_list_by_series_ordering(plot_display_bools) - return plot_display_bools_ordered - - def _get_plot_stat(self): """ Retrieves the plot_stat setting from the config file. diff --git a/metplotpy/plots/polar_plot/polar_plot.py b/metplotpy/plots/polar_plot/polar_plot.py index 4b5a9f999..0703d1e0d 100644 --- a/metplotpy/plots/polar_plot/polar_plot.py +++ b/metplotpy/plots/polar_plot/polar_plot.py @@ -41,9 +41,7 @@ """ Import BasePlot class """ -from plots.base_plot import BasePlot -#from ..base_plot import BasePlot - +from metplotpy.plots.base_plot import BasePlot class PolarPlot(BasePlot): diff --git a/metplotpy/plots/reliability_diagram/reliability.py b/metplotpy/plots/reliability_diagram/reliability.py index fc928af71..96d88356b 100644 --- a/metplotpy/plots/reliability_diagram/reliability.py +++ b/metplotpy/plots/reliability_diagram/reliability.py @@ -22,13 +22,12 @@ import numpy as np import pandas as pd -import plotly.graph_objects as go -from plotly.subplots import make_subplots -from plotly.graph_objects import Figure +from matplotlib import pyplot as plt +from matplotlib import ticker -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR from metplotpy.plots.base_plot import BasePlot from metplotpy.plots import util +from metplotpy.plots.constants import MPL_DEFAULT_BAR_WIDTH from metplotpy.plots.reliability_diagram.reliability_config import ReliabilityConfig from metplotpy.plots.reliability_diagram.reliability_series import ReliabilitySeries @@ -58,16 +57,7 @@ def __init__(self, parameters: dict) -> None: self.logger.info(f"Begin reliability diagram: {datetime.now()}") # Check that we have all the necessary settings for each series - is_config_consistent = self.config_obj._config_consistency_check() - if not is_config_consistent: - value_error_msg = ("The number of series defined by series_val_1 " - " inconsistent with the number of settings" - " required for describing each series. Please check" - " the number of your configuration file's plot_i," - " plot_disp, series_order, user_legend," - " colors, show_legend and series_symbols settings.") - self.logger.error(f"ValueError:{value_error_msg}") - raise ValueError(value_error_msg) + self.config_obj.config_consistency_check() # Read in input data, location specified in config file self.input_df = self._read_input_data() @@ -78,11 +68,6 @@ def __init__(self, parameters: dict) -> None: # line width, and criteria needed to subset the input dataframe. self.series_list = self._create_series(self.input_df) - # create figure - # pylint:disable=assignment-from-no-return - # Need to have a self.figure that we can pass along to - # the methods in base_plot.py (BasePlot class methods) to - # create binary versions of the plot. self._create_figure() def __repr__(self): @@ -151,23 +136,61 @@ def _create_figure(self): """ # create and draw the plot - self.logger.info(f"Begin creating the lines on the reliability plot: " - f"{datetime.now()}") + self.logger.info(f"Begin creating the lines on the reliability plot: {datetime.now()}") - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_y2axis() + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) - self._add_legend() + wts_size_styles = self.get_weights_size_styles() + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) + + ax2 = None + if self.config_obj.rely_event_hist: + # create inset or create 2nd y-axis + if self.config_obj.inset_hist: + ax2 = ax.inset_axes((0.08, 0.7, 0.47, 0.28)) + self._add_xaxis(ax2, wts_size_styles['xlab']) + ax2.set_xlim(0, 1) + else: + ax2 = self._add_y2axis(ax, None) + + self._add_yaxis(ax2, wts_size_styles['ylab'], label="# Forecasts", grid_on=True) + + # format large numbers like 3 million as 3M + ax2.yaxis.set_major_formatter(ticker.EngFormatter()) + + handles_and_labels = self._add_series(ax, ax2) + + self._add_xaxis(ax, wts_size_styles['xlab']) + ax.set_xlim(0, 1) + self._add_yaxis(ax, wts_size_styles['ylab']) + ax.set_ylim(0, 1) + ax.set_yticks(np.linspace(0, 1, 11)) + + self._add_legend(ax, handles_and_labels) + + self._add_custom_lines(ax) + + plt.tight_layout() + + self.logger.info(f"Finished drawing lines on reliability diagram {datetime.now()}") + + def _add_custom_lines(self, ax): + # add custom lines if lines are defined in config + # TODO: move to base_plot? + if len(self.series_list) > 0: + self._add_lines(ax, self.config_obj, self.config_obj.indy_vals) + + def _add_series(self, ax, ax2): + handles_and_labels = [] # calculate stag adjustments stag_adjustments = self._calc_stag_adjustments() x_points_index = self.series_list[-1].series_points['thresh_i'].tolist() # add series lines - for series in self.series_list: + for index, series in enumerate(self.series_list): # apply staggering offset if applicable if stag_adjustments[series.idx] == 0: x_points_index_adj = x_points_index @@ -177,15 +200,12 @@ def _create_figure(self): # Don't generate the plot for this series if # it isn't requested (as set in the config file) if series.plot_disp: - self._draw_series(series, x_points_index_adj) - # add custom lines - if len(self.series_list) > 0: - self._add_lines(self.config_obj) + handle = self._draw_series(ax, ax2, series, x_points_index_adj, index) + handles_and_labels.append((handle, handle.get_label())) - self.logger.info(f"Finished drawing lines on reliability diagram" - f" {datetime.now()}") + return handles_and_labels - def _draw_series(self, series: ReliabilitySeries, x_points_index_adj: list) -> None: + def _draw_series(self, ax, ax2, series: ReliabilitySeries, x_points_index_adj: list, idx) -> None: """ Draws the formatted line with CIs if needed on the plot @@ -195,32 +215,24 @@ def _draw_series(self, series: ReliabilitySeries, x_points_index_adj: list) -> N self.logger.info(f"Draw the bar plot and skill lines: {datetime.now()}") if series.idx == 0: - self._add_noskill_polygon(series.series_points['stat_value'][0]) - - if self.config_obj.rely_event_hist is True and 'n_i' in series.series_points: - x_axis = 'x1' - if self.config_obj.inset_hist is True: - x_axis = 'x2' - - bar_trace = go.Bar( - x=x_points_index_adj, - y=series.series_points['n_i'].tolist(), - name="Absolute_cases", - marker_color=self.config_obj.colors_list[series.idx], - marker_line_color=self.config_obj.colors_list[series.idx], - opacity=1, - showlegend=False, - xaxis=x_axis, - yaxis='y2' - ) - if self.config_obj.inset_hist is True: - self.figure.add_trace(bar_trace) - else: - self.figure.add_trace(bar_trace, secondary_y=True) + self._add_noskill_polygon(ax, series.series_points['stat_value'][0]) - self._add_noskill_line(series.series_points['stat_value'][0]) - self._add_perfect_reliability_line() - self._add_noresolution_line(series.series_points['stat_value'][0]) + if self.config_obj.rely_event_hist and 'n_i' in series.series_points: + + n_visible_series = sum(1 for s in self.series_list if s.plot_disp) + n = max(n_visible_series, 1) + width = MPL_DEFAULT_BAR_WIDTH / 40 + offset = (idx - (n - 1) / 2.0) * width + x_locs = [item + offset for item in x_points_index_adj] + + ax2.bar(x=x_locs, height=series.series_points['n_i'].tolist(), align='center', + width=width, + color=self.config_obj.colors_list[series.idx], + label="Absolute_cases") + + self._add_noskill_line(ax, series.series_points['stat_value'][0]) + self._add_perfect_reliability_line(ax) + self._add_noresolution_line(ax, series.series_points['stat_value'][0]) y_points = series.series_points['stat_value'].tolist() stat_bcu = all(v == 0 for v in series.series_points['stat_btcu']) @@ -232,393 +244,133 @@ def _draw_series(self, series: ReliabilitySeries, x_points_index_adj: list) -> N error_y_visible = False # add the plot - line_trace = go.Scatter(x=x_points_index_adj, - y=y_points, - showlegend=self.config_obj.show_legend[series.idx] == 1, - mode=self.config_obj.mode[series.idx], - textposition="top right", - name=self.config_obj.user_legends[series.idx], - connectgaps=self.config_obj.con_series[series.idx] == 1, - line={'color': self.config_obj.colors_list[series.idx], - 'width': self.config_obj.linewidth_list[series.idx], - 'dash': self.config_obj.linestyles_list[series.idx]}, - marker_symbol=self.config_obj.marker_list[series.idx], - marker_color=self.config_obj.colors_list[series.idx], - marker_line_color=self.config_obj.colors_list[series.idx], - marker_size=self.config_obj.marker_size[series.idx], - error_y={'type': 'data', - 'symmetric': False, - 'array': series.series_points['stat_btcu'], - 'arrayminus': series.series_points['stat_btcl'], - 'visible': error_y_visible, - 'thickness': self.config_obj.linewidth_list[series.idx]} - - ) - - if self.config_obj.inset_hist is True: - self.figure.add_trace(line_trace) - else: - self.figure.add_trace(line_trace, secondary_y=False) + y_errors = [series.series_points['stat_btcl'], series.series_points['stat_btcu']] + plot_mode = self.config_obj.mode[series.idx] + marker = self.config_obj.marker_list[series.idx] if 'markers' in plot_mode else None + line_style = self.config_obj.linestyles_list[series.idx] if 'lines' in plot_mode else 'None' + + plot_obj = ax.errorbar( + x=x_points_index_adj, + y=y_points, + label=self.config_obj.user_legends[series.idx], + # line style + color=self.config_obj.colors_list[series.idx], + linestyle=line_style, + linewidth=self.config_obj.linewidth_list[series.idx], + # marker style + marker=marker, + markersize=self.config_obj.marker_size[series.idx], + markeredgecolor=self.config_obj.colors_list[series.idx], + markerfacecolor=self.config_obj.colors_list[series.idx], + # error bar + yerr=y_errors if error_y_visible else None, + elinewidth=self.config_obj.linewidth_list[series.idx], + ) self.logger.info(f"Finished with bar plot and skill lines :{datetime.now()}") + return plot_obj - def _add_y2axis(self) -> None: - """ - Adds y2-axis if needed - """ - - if self.config_obj.rely_event_hist is True and self.config_obj.inset_hist is False: - self.figure.update_yaxes(title_text='', - secondary_y=True, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - zeroline=False, - ticks="inside", - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size} - ) - - def _add_noskill_polygon(self, o_bar: Union[float, None]) -> None: + def _add_noskill_polygon(self, ax, o_bar: Union[float, None]) -> None: """ Adds no-skill polygon to the graph if needed and o_bar is not None :param o_bar: o_bar value or None """ + if not self.config_obj.add_noskill_line: + return + + if not o_bar: + print(" WARNING: no-skill polygon can't be created for the series") + return self.logger.info("Adding no-skill polygon") - if self.config_obj.add_noskill_line is True: - if o_bar and o_bar is not None: - self.figure.add_trace( - go.Scatter(x=[o_bar, o_bar, 1, 1, o_bar, 0, 0], - y=[0, 1, 1, (1 - o_bar) / 2 + o_bar, o_bar, o_bar, 0], - fill='toself', - fillcolor='#ededed', - line={'color': '#ededed'}, - showlegend=False, - name='No-Skill poly', - hoverinfo='skip', - opacity=0.5 - ) - ) - else: - print(' WARNING: no-skill polygon can\'t be created for the series') - def _add_noskill_line(self, o_bar: Union[float, None]) -> None: + x = [o_bar, o_bar, 1, 1, o_bar, 0, 0] + y = [0, 1, 1, (1 - o_bar) / 2 + o_bar, o_bar, o_bar, 0] + ax.fill(x, y, + facecolor='#ededed', + edgecolor='#ededed', + alpha=0.5, + label='_no-skill-poly_') + + def _add_noskill_line(self, ax, o_bar: Union[float, None]) -> None: """ Adds no-skill line to the graph if needed and o_bar is not None :param o_bar: o_bar value or None """ - + if not self.config_obj.add_noskill_line: + return self.logger.info("Adding no-skill line") - if self.config_obj.add_noskill_line is True: - if o_bar and o_bar is not None: - # create a line - intercept = 0.5 * o_bar - self.figure.add_trace( - go.Scatter(x=[0, 1], - y=[util.abline(0, intercept, 0.5), util.abline(1, intercept, 0.5)], - line={'color': self.config_obj.noskill_line_col, - 'dash': 'dash', - 'width': 1}, - showlegend=False, - mode='lines', - name='No-Skill' - ) - ) - # create annotation - self.figure.add_annotation( - x=1, - y=util.abline(1, intercept, 0.5), - xref="x", - yref="y", - text="No-Skill", - showarrow=True, - font={ - 'color': '#636363', - 'size': self.config_obj.x_tickfont_size - }, - align="left", - ax=10, - ay=0, - textangle=90 - ) - else: - print(' WARNING: no-skill line can\'t be created for the series') - - def _add_perfect_reliability_line(self) -> None: - """ - Adds perfect reliability line to the graph if needed - :return: - """ + if not o_bar: + print(" WARNING: no-skill line can't be created for the series") + return - self.logger.info("Adding perfect reliability line") - if self.config_obj.add_skill_line is True: - self.figure.add_trace( - go.Scatter(x=[0, 1], - y=[util.abline(0, 0, 1), util.abline(1, 0, 1)], - line={'color': 'grey', - 'width': 1}, - showlegend=False, - mode='lines', - name='Perfect reliability' - ) - ) - self.figure.add_annotation( - x=1, - y=util.abline(1, 0, 1), - xref="x", - yref="y", - text="Perfect reliability", - font={ - 'color': '#636363', - 'size': self.config_obj.x_tickfont_size - }, - showarrow=True, - align="left", - ax=10, - ay=0, - textangle=90 - ) - - def _add_noresolution_line(self, o_bar: Union[float, None]) -> None: - """ - Adds no-resolution line to the graph if needed and o_bar is not None - :param o_bar: o_bar value or None - """ + # create a line + intercept = 0.5 * o_bar + x = [0, 1] + y = [util.abline(0, intercept, 0.5), util.abline(1, intercept, 0.5)] + ax.plot(x, y, label='_No-Skill_', color=self.config_obj.noskill_line_col, linewidth=1, linestyle='--') - self.logger.info("Adding no-resolution line") - if self.config_obj.add_reference_line is True: - if o_bar and o_bar is not None: - self.figure.add_trace( - go.Scatter(x=[0, 1], - y=[util.abline(0, o_bar, 0), util.abline(1, o_bar, 0)], - line={'color': self.config_obj.reference_line_col, - 'dash': 'dash', - 'width': 1}, - showlegend=False, - mode='lines', - name='No-resolution' - ) - ) - self.figure.add_trace( - go.Scatter(x=[util.abline(0, o_bar, 0), util.abline(1, o_bar, 0)], - y=[0, 1], - line={'color': 'red', - 'dash': 'dash', - 'width': 1}, - showlegend=False, - mode='lines', - name='No-resolution' - ) - ) - self.figure.add_annotation( - x=1, - y=util.abline(1, o_bar, 0), - xref="x", - yref="y", - text="No-resolution", - showarrow=True, - font={ - 'color': '#636363', - 'size': self.config_obj.x_tickfont_size - }, - align="left", - ax=10, - ay=0, - textangle=90 - ) - else: - print(' WARNING: no-resolution line can\'t be created for the series') - - def _create_layout(self) -> Figure: - """ - Creates a new layout based on the properties from the config file - including plots size, annotation and title - - :return: Figure object - """ # create annotation - annotation = [ - {'text': util.apply_weight_style(self.config_obj.parameters['plot_caption'], - self.config_obj.parameters['caption_weight']), - 'align': 'left', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': self.config_obj.parameters['caption_align'], - 'y': self.config_obj.caption_offset, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - }] - # create title - title = {'text': util.apply_weight_style(self.config_obj.title, - self.config_obj.parameters['title_weight']), - 'font': { - 'size': self.config_obj.title_font_size, - }, - 'y': self.config_obj.title_offset, - 'x': self.config_obj.parameters['title_align'], - 'xanchor': 'center', - 'xref': 'paper' - } - - # create a layout and allow y2 axis - if self.config_obj.rely_event_hist is True and self.config_obj.inset_hist is True: - # us go.Layout and go.Figure to create a figure because of the inset - layout = go.Layout( - yaxis=dict( - range=[0, 1], - tickvals=[x / 10.0 for x in range(0, 11, 1)], - ticktext=[x / 10.0 for x in range(0, 11, 1)], - showgrid=False, - title_text= - util.apply_weight_style(self.config_obj.yaxis_1, - self.config_obj.parameters['ylab_weight']), - title_standoff=abs(self.config_obj.parameters['ylab_offset']) + 10, - - ), - xaxis2=dict( - domain=[0.08, 0.55], - anchor='y2' - ), - yaxis2=dict( - domain=[0.7, 0.98], - anchor='x2', - title_text='# Forecasts', - showgrid=True, - title_standoff=0 - ) - ) - fig = go.Figure(layout=layout) - else: - fig = make_subplots(specs=[[{"secondary_y": True}]]) - - # add size, annotation, title - fig.update_layout( - width=self.config_obj.plot_width, - height=self.config_obj.plot_height, - margin=self.config_obj.plot_margins, - paper_bgcolor=PLOTLY_PAPER_BGCOOR, - annotations=annotation, - title=title, - plot_bgcolor=PLOTLY_PAPER_BGCOOR + ax.text( + 1, util.abline(1, intercept, 0.5), + "No-Skill", + size=self.config_obj.x_tickfont_size, + color='#636363', + rotation=270, + transform=ax.transAxes, ) - return fig - - def _add_xaxis(self) -> None: - """ - Configures and adds x-axis to the plot - """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - ticks="inside", - zeroline=False, - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size}, - tickmode='array', - tickvals=[x / 10.0 for x in range(0, 11, 1)], - ticktext=[x / 10.0 for x in range(0, 11, 1)], - range=[0, 1] - ) - - def _add_yaxis(self) -> None: + def _add_perfect_reliability_line(self, ax) -> None: """ - Configures and adds y-axis to the plot + Adds perfect reliability line to the graph if needed """ + if not self.config_obj.add_skill_line: + return - self.figure.update_yaxes( - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - - zeroline=False, - ticks="inside", - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.y_title_font_size - }, - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size}, - + self.logger.info("Adding perfect reliability line") + x = [0., 1.] + y = [util.abline(0, 0, 1), util.abline(1, 0, 1)] + ax.plot(x, y, label='_Perfect reliability_', color='grey', zorder=0, linewidth=1) + + ax.text( + 1, util.abline(1, 0, 1), + "Perfect reliability", + size=self.config_obj.x_tickfont_size, + color='#636363', + rotation=270, + transform=ax.transAxes, ) - # adjustments for the inset - if self.config_obj.rely_event_hist is False or self.config_obj.inset_hist is False: - self.figure.update_yaxes(secondary_y=False, - showgrid=False, - range=[0, 1], - tickvals=[x / 10.0 for x in range(0, 11, 1)], - ticktext=[x / 10.0 for x in range(0, 11, 1)], - title_standoff= - abs(self.config_obj.parameters['ylab_offset']) + 10, - title_text= - util.apply_weight_style(self.config_obj.yaxis_1, - self.config_obj.parameters['ylab_weight']) - ) - - def _add_legend(self) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure - """ - self.figure.update_layout(legend={'x': self.config_obj.bbox_x, - 'y': self.config_obj.bbox_y, - 'xanchor': 'center', - 'yanchor': 'top', - 'bordercolor': self.config_obj.legend_border_color, - 'borderwidth': self.config_obj.legend_border_width, - 'orientation': self.config_obj.legend_orientation, - 'font': { - 'size': self.config_obj.legend_size, - 'color': "black" - } - }) - - def remove_file(self): - """ - Removes previously made image file . Invoked by the parent class before self.output_file - attribute can be created, but overridden here. - """ - - super().remove_file() - self._remove_html() - def _remove_html(self) -> None: + def _add_noresolution_line(self, ax, o_bar: Union[float, None]) -> None: """ - Removes previously made HTML file. + Adds no-resolution line to the graph if needed and o_bar is not None + :param o_bar: o_bar value or None """ + if not self.config_obj.add_reference_line: + return - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" - - # remove the old file if it exist - if os.path.exists(html_name): - os.remove(html_name) + self.logger.info("Adding no-resolution line") - def write_html(self) -> None: - """ - Is needed - creates and saves the html representation of the plot WITHOUT Plotly.js - """ - self.logger.info("Writing html file.") - if self.config_obj.create_html is True: - # construct the fle name from plot_filename - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" - - # save html - self.figure.write_html(html_name, include_plotlyjs=False) + if not o_bar: + print(" WARNING: no-resolution line can't be created for the series") + return + + end_to_end = [0, 1] + ab_line = [util.abline(0, o_bar, 0), util.abline(1, o_bar, 0)] + ax.plot(end_to_end, ab_line, label='_No-resolution_', + color=self.config_obj.reference_line_col, linestyle='--', zorder=0, linewidth=1) + + ax.plot(ab_line, end_to_end, label='_No-resolution_', + color=self.config_obj.reference_line_col, linestyle='--', zorder=0, linewidth=1) + + ax.text( + 1, util.abline(1, o_bar, 0), + "No-resolution", + size=self.config_obj.x_tickfont_size, + color='#636363', + rotation=270, + transform=ax.transAxes, + ) def write_output_file(self) -> None: """ diff --git a/metplotpy/plots/reliability_diagram/reliability_config.py b/metplotpy/plots/reliability_diagram/reliability_config.py index d011c054c..f1432944c 100644 --- a/metplotpy/plots/reliability_diagram/reliability_config.py +++ b/metplotpy/plots/reliability_diagram/reliability_config.py @@ -16,7 +16,6 @@ __author__ = 'Tatiana Burek' import itertools -from datetime import datetime from ..config import Config from .. import constants @@ -41,14 +40,16 @@ def __init__(self, parameters: dict) -> None: # plot parameters self.grid_on = self._get_bool('grid_on') - self.plot_width = self.calculate_plot_dimension('plot_width', 'pixels') - self.plot_height = self.calculate_plot_dimension('plot_height', 'pixels') - self.plot_margins = dict(l=0, - r=self.parameters['mar'][3] + 20, - t=self.parameters['mar'][2] + 80, - b=self.parameters['mar'][0] + 80, - pad=5 - ) + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') + + self.plot_margins = { + 'l': 0, + 'r': self.parameters['mar'][3] + 20, + 't': self.parameters['mar'][2] + 80, + 'b': self.parameters['mar'][0] + 80, + 'pad': 5, + } self.indy_stagger = self._get_bool('indy_stagger_1') self.blended_grid_col = util.alpha_blending(self.parameters['grid_col'], 0.5) self.variance_inflation_factor = self._get_bool('variance_inflation_factor') @@ -75,7 +76,6 @@ def __init__(self, parameters: dict) -> None: ############################################## # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -92,7 +92,6 @@ def __init__(self, parameters: dict) -> None: if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) ############################################## # series parameters @@ -111,6 +110,8 @@ def __init__(self, parameters: dict) -> None: self.con_series = self._get_con_series() self.num_series = self.calculate_number_of_series() self.show_legend = self._get_show_legend() + if not self.indy_label: + self.indy_label = self.indy_vals ############################################## # legend parameters @@ -129,29 +130,6 @@ def __init__(self, parameters: dict) -> None: self.legend_orientation = 'h' self.legend_border_color = "black" - def _get_plot_disp(self) -> list: - """ - Retrieve the values that determine whether to display a particular series - and convert them to bool if needed - - Args: - - Returns: - A list of boolean values indicating whether or not to - display the corresponding series - """ - - plot_display_config_vals = self.get_config_value('plot_disp') - plot_display_bools = [] - for val in plot_display_config_vals: - if isinstance(val, bool): - plot_display_bools.append(val) - - if isinstance(val, str): - plot_display_bools.append(val.upper() == 'TRUE') - - return self.create_list_by_series_ordering(plot_display_bools) - def _get_fcst_vars(self, index): """ Retrieve a list of the inner keys (fcst_vars) to the fcst_var_val dictionary. @@ -171,127 +149,27 @@ def _get_fcst_vars(self, index): return fcst_var_val_dict - def _get_mode(self) -> list: - """ - Retrieve all the modes. Convert mode names from - the config file into plotly python's mode names. - - Args: - - Returns: - markers: a list of the plotly markers - """ - modes = self.get_config_value('series_type') - mode_list = [] - for mode in modes: - if mode in constants.TYPE_TO_PLOTLY_MODE.keys(): - # the recognized plotly marker names: - # circle-open (for small circle), circle, triangle-up, - # square, diamond, or hexagon - mode_list.append(constants.TYPE_TO_PLOTLY_MODE[mode]) - else: - mode_list.append('lines+markers') - return self.create_list_by_series_ordering(mode_list) - - def _get_linestyles(self) -> list: - """ - Retrieve all the line styles. Convert line style names from - the config file into plotly python's line style names. - - Args: - - Returns: - line_styles: a list of the plotly line styles - """ - line_styles = self.get_config_value('series_line_style') - line_style_list = [] - for line_style in line_styles: - if line_style in constants.LINE_STYLE_TO_PLOTLY_DASH.keys(): - line_style_list.append(constants.LINE_STYLE_TO_PLOTLY_DASH[line_style]) - else: - line_style_list.append(None) - return self.create_list_by_series_ordering(line_style_list) - - def _get_markers(self) -> list: - """ - Retrieve all the markers. Convert marker names from - the config file into plotly python's marker names. - - Args: - - Returns: - markers: a list of the plotly markers - """ - markers = self.get_config_value('series_symbols') - markers_list = [] - for marker in markers: - if marker in constants.AVAILABLE_PLOTLY_MARKERS_LIST: - # the recognized plotly marker names: - # circle-open (for small circle), circle, triangle-up, - # square, diamond, or hexagon - markers_list.append(marker) - else: - markers_list.append(constants.PCH_TO_PLOTLY_MARKER[marker]) - return self.create_list_by_series_ordering(markers_list) - - def _get_markers_size(self) -> list: - """ - Retrieve all the markers. Convert marker names from - the config file into plotly python's marker names. - - Args: - - Returns: - markers: a list of the plotly markers - """ - markers = self.get_config_value('series_symbols') - markers_size = [] - for marker in markers: - if marker in constants.AVAILABLE_PLOTLY_MARKERS_LIST: - markers_size.append(marker) - else: - markers_size.append(constants.PCH_TO_PLOTLY_MARKER_SIZE[marker]) - - return self.create_list_by_series_ordering(markers_size) - - def _config_consistency_check(self) -> bool: - """ - Checks that the number of settings defined for plot_ci, - plot_disp, series_order, user_legend colors, and series_symbols - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - series (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) + def config_consistency_check(self) -> None: + """Checks that the number of settings defined for + plot_disp, series_ordering, colors_list, user_legends, and show_legend + are consistent with number of series. + @raises ValueError if any of settings are inconsistent with the + number of series (as defined by the cross product of the model + and vx_mask defined in the series_val_1 setting) """ - self.logger.info(f"Begin consistency check: {datetime.now()}") - # Determine the number of series based on the number of - # permutations from the series_var setting in the - # config file - - # Numbers of values for other settings for series - num_ci_settings = len(self.plot_ci) - num_plot_disp = len(self.plot_disp) - num_markers = len(self.marker_list) - num_series_ord = len(self.series_ordering) - num_colors = len(self.colors_list) - num_legends = len(self.user_legends) - num_line_widths = len(self.linewidth_list) - num_linestyles = len(self.linestyles_list) - num_show_legend = len(self.show_legend) - status = False - - if self.num_series == num_plot_disp == \ - num_markers == num_series_ord == num_colors \ - == num_legends == num_line_widths == num_linestyles == num_ci_settings == num_show_legend: - status = True - self.logger.info(f"Finished consistency check :{datetime.now()}") - return status + lists_to_check = { + "plot_ci": self.plot_ci, + "plot_disp": self.plot_disp, + "marker_list": self.marker_list, + "series_ordering": self.series_ordering, + "colors_list": self.colors_list, + "user_legends": self.user_legends, + "linewidth_list": self.linewidth_list, + "linestyles_list": self.linestyles_list, + "show_legend": self.show_legend, + } + self._config_compare_lists_to_num_series(lists_to_check) def _get_plot_ci(self) -> list: """ diff --git a/metplotpy/plots/reliability_diagram/reliability_series.py b/metplotpy/plots/reliability_diagram/reliability_series.py index 92be74954..1c09eb3fd 100644 --- a/metplotpy/plots/reliability_diagram/reliability_series.py +++ b/metplotpy/plots/reliability_diagram/reliability_series.py @@ -44,14 +44,14 @@ def _create_all_fields_values_no_indy(self) -> dict: """ all_fields_values_no_indy = {} all_fields_values = self.config.get_config_value('series_val_1').copy() - if self.config._get_fcst_vars(1): - all_fields_values['fcst_var'] = list(self.config._get_fcst_vars(1).keys()) + if self.config.get_fcst_vars_keys(1): + all_fields_values['fcst_var'] = self.config.get_fcst_vars_keys(1) all_fields_values['stat_name'] = self.config.get_config_value('list_stat_1') all_fields_values_no_indy[1] = all_fields_values all_fields_values = self.config.get_config_value('series_val_2').copy() - if self.config._get_fcst_vars(2): - all_fields_values['fcst_var'] = list(self.config._get_fcst_vars(2).keys()) + if self.config.get_fcst_vars_keys(2): + all_fields_values['fcst_var'] = self.config.get_fcst_vars_keys(2) all_fields_values['stat_name'] = self.config.get_config_value('list_stat_2') all_fields_values_no_indy[2] = all_fields_values diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index bfca2af43..9a2d3a61a 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -9,11 +9,12 @@ """ Class Name: revision_box.py - """ +""" import os import re from datetime import datetime -import plotly.graph_objects as go + +import numpy as np from metplotpy.plots.base_plot import BasePlot @@ -21,9 +22,10 @@ from metplotpy.plots import util import metcalcpy.util.utils as calc_util -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH + from metplotpy.plots.revision_box.revision_box_config import RevisionBoxConfig from metplotpy.plots.revision_box.revision_box_series import RevisionBoxSeries +from metplotpy.plots.constants import MPL_DEFAULT_BOX_WIDTH class RevisionBox(Box): @@ -51,14 +53,7 @@ def __init__(self, parameters: dict) -> None: self.logger.info(f"Begin revision box plotting: {datetime.now()}") # Check that we have all the necessary settings for each series - is_config_consistent = self.config_obj._config_consistency_check() - if not is_config_consistent: - raise ValueError("The number of series defined by series_val_1 is" - " inconsistent with the number of settings" - " required for describing each series. Please check" - " the number of your configuration file's plot_i," - " plot_disp, series_order, user_legend," - " colors, show_legend and series_symbols settings.") + self.config_obj.config_consistency_check() # Read in input data, location specified in config file self.input_df = self._read_input_data() @@ -107,7 +102,7 @@ def _create_series(self, input_data): series_list = [] # add series for y1 axis - for i, name in enumerate(self.config_obj.get_series_y()): + for i, name in enumerate(self.config_obj.get_series_y(1)): series_obj = RevisionBoxSeries(self.config_obj, i, input_data, series_list, name) series_list.append(series_obj) @@ -121,126 +116,66 @@ def _create_figure(self): """ Create a box plot from default and custom parameters""" self.logger.info(f"Begin creating the figure: {datetime.now()}") - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() + + self._create_annotation() + + # set the x-axis labels to match the user legends + self.config_obj.indy_label = self.config_obj.user_legends + + super()._create_figure() + + self.logger.info(f"Finished creating figure: {datetime.now()}") + + def _create_annotation(self): + if not self.config_obj.revision_run and not self.config_obj.revision_ac: + self.config_obj.plot_caption = None + return annotation_text_all = '' for inx, series in enumerate(self.series_list): # Don't generate the plot for this series if # it isn't requested (as set in the config file) - if series.plot_disp: - self._draw_series(series) - # construct annotation text - annotation_text = series.user_legends + ': ' - if self.config_obj.revision_run: - annotation_text = annotation_text + 'WW Runs Test:' + series.series_points['revision_run'] + ' ' + if not series.plot_disp: + continue - if self.config_obj.revision_ac: - annotation_text = annotation_text + "Auto-Corr Test: p=" \ - + series.series_points['auto_cor_p'] \ - + ", r=" + series.series_points['auto_cor_r'] + # construct annotation text + annotation_text = f"{series.user_legends}: " + if self.config_obj.revision_run: + annotation_text += f"WW Runs Test: {series.series_points['revision_run']} " - annotation_text_all = annotation_text_all + annotation_text - if inx < len(self.series_list) - 1: - annotation_text_all = annotation_text_all + '
' - - # add custom lines - if len(self.series_list) > 0: - self._add_lines( - self.config_obj, - sorted(self.series_list[0].series_data[self.config_obj.indy_var].unique()) - ) - - # apply y axis limits - self._yaxis_limits() - - # add Auto-Corr Test and/or WW Runs Test results if needed - if self.config_obj.revision_run or self.config_obj.revision_ac: - self.figure.add_annotation(text=annotation_text_all, - align='left', - showarrow=False, - xref='paper', - yref='paper', - x=0, - yanchor='bottom', - xanchor='left', - y=1, - font={ - 'size': self.config_obj.legend_size, - 'color': "black" - }, - bordercolor=self.config_obj.legend_border_color, - borderwidth=0 - ) - - self.logger.info(f"Finished creating figure: {datetime.now()}") - - def _draw_series(self, series: RevisionBoxSeries) -> None: - """ - Draws the boxes on the plot + if self.config_obj.revision_ac: + annotation_text += ( + f"Auto-Corr Test: p={series.series_points['auto_cor_p']}, " + f"r={series.series_points['auto_cor_r']}" + ) - :param series: RevisionBoxSeries object with data and parameters - """ + annotation_text_all += annotation_text + if inx < len(self.series_list) - 1: + annotation_text_all += '\n' + + self.config_obj.plot_caption = annotation_text_all - self.logger.info(f"Begin drawing series: {datetime.now()}") - # defaults markers and colors for the regular box plot - line_color = dict(color='rgb(0,0,0)') - fillcolor = series.color - marker_color = 'rgb(0,0,0)' - marker_line_color = 'rgb(0,0,0)' - marker_symbol = 'circle-open' - - # markers and colors for points only plot - if self.config_obj.box_pts: - line_color = dict(color='rgba(0,0,0,0)') - fillcolor = 'rgba(0,0,0,0)' - marker_color = series.color - marker_symbol = 'circle' - marker_line_color = series.color - - # create a trace - self.figure.add_trace( - go.Box( # x=[series.idx], - y=series.series_points['points']['stat_value'].tolist(), - notched=self.config_obj.box_notch, - line=line_color, - fillcolor=fillcolor, - name=series.user_legends, - showlegend=self.config_obj.show_legend[series.idx] == 1, - boxmean=self.config_obj.box_avg, - boxpoints=self.config_obj.boxpoints, # outliers, all, False - pointpos=0, - marker=dict(size=4, - color=marker_color, - line=dict( - width=1, - color=marker_line_color - ), - symbol=marker_symbol, - ), - jitter=0 - ) - ) - - self.logger.info(f"Finished drawing series:{datetime.now()}") - - def _add_xaxis(self) -> None: + def _add_caption(self, plt, font_properties): """ - Configures and adds x-axis to the plot + Adds a caption to the top left of the plot, just below the title. + Always uses the same position regardless of the config file settings. """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size}, - tickmode='linear' - ) + if self.config_obj.plot_caption: + plt.figtext(0.06, 0.90, self.config_obj.plot_caption, + fontproperties=font_properties, + color=self.config_obj.parameters['caption_col'], + ha='left') + + def _add_custom_lines(self, ax): + return + + def _get_data_to_plot(self, series): + return series.series_points['points']['stat_value'].dropna().values + + def _get_x_locs_and_width(self, x_points, index): + base = np.arange(len(self.config_obj.indy_label)) + x_locs = [base[index]] + return x_locs, MPL_DEFAULT_BOX_WIDTH def write_output_file(self) -> None: """ diff --git a/metplotpy/plots/revision_box/revision_box_config.py b/metplotpy/plots/revision_box/revision_box_config.py index 8f74d50f7..f32898a29 100644 --- a/metplotpy/plots/revision_box/revision_box_config.py +++ b/metplotpy/plots/revision_box/revision_box_config.py @@ -14,14 +14,14 @@ """ import itertools -from ..config import Config -from .. import constants +from ..box.box_config import BoxConfig +from .. import constants as constants from .. import util import metcalcpy.util.utils as utils -class RevisionBoxConfig(Config): +class RevisionBoxConfig(BoxConfig): def __init__(self, parameters: dict) -> None: """ Reads in the plot settings from a revision box plot config file. @@ -32,173 +32,21 @@ def __init__(self, parameters: dict) -> None: super().__init__(parameters) - ############################################## - # Optional setting, indicates *where* to save the dump_points_1 file - # used by METviewer - self.points_path = self.get_config_value('points_path') + # override values set in BoxConfig that are not used by revision box + self.plot_stat = None + self.show_nstats = None + self.dump_points_2 = None + self.vert_plot = None + self.xaxis_reverse = None + self.sync_yaxes = None - # plot parameters - self.dump_points_1 = self._get_bool('dump_points_1') - self.create_html = self._get_bool('create_html') - - ############################################## - # caption parameters - self.caption_size = int(constants.DEFAULT_CAPTION_FONTSIZE - * self.get_config_value('caption_size')) - self.caption_offset = self.parameters['caption_offset'] - 3.1 - - ############################################## - # title parameters - self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET - self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE - - ############################################## - # y-axis parameters - self.y_tickangle = self.parameters['ytlab_orient'] - if self.y_tickangle in constants.YAXIS_ORIENTATION.keys(): - self.y_tickangle = constants.YAXIS_ORIENTATION[self.y_tickangle] - self.y_tickfont_size = self.parameters['ytlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - - ############################################## - # x-axis parameters - self.x_title_font_size = self.parameters['xlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.x_tickangle = self.parameters['xtlab_orient'] - if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): - self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] - self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) - - ############################################## - # series parameters - self.series_ordering = self.get_config_value('series_order') - # Make the series ordering zero-based - self.series_ordering_zb = [sorder - 1 for sorder in self.series_ordering] - self.plot_disp = self._get_plot_disp() - self.colors_list = self._get_colors() - self.all_series_y1 = self.get_series_y() - self.num_series = self.calculate_number_of_series() - self.show_legend = self._get_show_legend() - - ############################################## - # legend parameters - self.user_legends = self._get_user_legends() - self.bbox_x = 0.5 + self.parameters['legend_inset']['x'] - self.bbox_y = -0.12 + self.parameters['legend_inset']['y'] + 0.25 - self.legend_size = int(constants.DEFAULT_LEGEND_FONTSIZE * self.parameters['legend_size']) - if self.parameters['legend_box'].lower() == 'n': - self.legend_border_width = 0 # Don't draw a box around legend labels - else: - self.legend_border_width = 2 # Enclose legend labels in a box - - if self.parameters['legend_ncol'] == 1: - self.legend_orientation = 'v' - else: - self.legend_orientation = 'h' - self.legend_border_color = "black" - - box_outline = self._get_bool('box_outline') - if box_outline is True: - self.boxpoints = 'outliers' - else: - self.boxpoints = False - self.box_avg = self._get_bool('box_avg') - self.box_notch = self._get_bool('box_notch') - - self.box_pts = self._get_bool('box_pts') - if self.box_pts is True: - self.boxpoints = 'all' + self.all_series_y2 = None + # set values specific to RevisionBox not set in BoxConfig self.revision_ac = self._get_bool('revision_ac') self.revision_run = self._get_bool('revision_run') self.indy_stagger = self._get_bool('indy_stagger_1') - def calculate_number_of_series(self) -> int: - """ - From the number of items in the permutation list, - determine how many series "objects" are to be plotted. - - Args: - - Returns: - the number of series - - """ - # Retrieve the lists from the series_val_1 dictionary - series_vals_list = self.series_vals_1.copy() - if isinstance(self.fcst_var_val_1, list) is True: - fcst_vals = self.fcst_var_val_1 - elif isinstance(self.fcst_var_val_1, dict) is True: - fcst_vals = list(self.fcst_var_val_1.values()) - fcst_vals_flat = [item for sublist in fcst_vals for item in sublist] - series_vals_list.append(fcst_vals_flat) - - # Utilize itertools' product() to create the cartesian product of all elements - # in the lists to produce all permutations of the series_val values and the - # fcst_var_val values. - permutations = list(itertools.product(*series_vals_list)) - total = len(permutations) - - return total - - def _get_plot_disp(self) -> list: - """ - Retrieve the values that determine whether to display a particular series - and convert them to bool if needed - - Args: - - Returns: - A list of boolean values indicating whether or not to - display the corresponding series - """ - - plot_display_config_vals = self.get_config_value('plot_disp') - plot_display_bools = [] - for val in plot_display_config_vals: - if isinstance(val, bool): - plot_display_bools.append(val) - - if isinstance(val, str): - plot_display_bools.append(val.upper() == 'TRUE') - - return self.create_list_by_series_ordering(plot_display_bools) - - def get_series_y(self) -> list: - """ - Creates an array of series components (excluding derived) tuples for the y-axis - :param axis: - :return: an array of series components tuples - """ - all_fields_values_orig = self.get_config_value('series_val_1').copy() - all_fields_values = {} - for x in reversed(list(all_fields_values_orig.keys())): - all_fields_values[x] = all_fields_values_orig.get(x) - - if self._get_fcst_vars(1): - all_fields_values['fcst_var'] = list(self._get_fcst_vars(1).keys()) - - all_fields_values['stat_name'] = self.get_config_value('list_stat_1') - return utils.create_permutations_mv(all_fields_values, 0) - - def _get_fcst_vars(self, index): - """ - Retrieve a list of the inner keys (fcst_vars) to the fcst_var_val dictionary. - - Args: - index: identifier used to differentiate between fcst_var_val_1 config settings - Returns: - a list containing all the fcst variables requested in the - fcst_var_val setting in the config file. This will be - used to subset the input data that corresponds to a particular series. - - """ - fcst_var_val_dict = self.get_config_value('fcst_var_val_1') - if not fcst_var_val_dict: - fcst_var_val_dict = {} - - return fcst_var_val_dict - def _get_user_legends(self, legend_label_type: str = '') -> list: """ Retrieve the text that is to be displayed in the legend at the bottom of the plot. @@ -218,7 +66,7 @@ def _get_user_legends(self, legend_label_type: str = '') -> list: legend_list = [] # create legend list for y-axis series - for idx, ser_components in enumerate(self.get_series_y()): + for idx, ser_components in enumerate(self.get_series_y(1)): if idx >= len(all_user_legends) or all_user_legends[idx].strip() == '': # user did not provide the legend - create it legend_list.append(' '.join(map(str, ser_components))) @@ -227,35 +75,3 @@ def _get_user_legends(self, legend_label_type: str = '') -> list: legend_list.append(all_user_legends[idx]) return self.create_list_by_series_ordering(legend_list) - - def _config_consistency_check(self) -> bool: - """ - Checks that the number of settings defined for - plot_disp, series_order, user_legend colors - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - series (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) - - """ - # Determine the number of series based on the number of - # permutations from the series_var setting in the - # config file - - # Numbers of values for other settings for series - num_plot_disp = len(self.plot_disp) - num_series_ord = len(self.series_ordering) - num_colors = len(self.colors_list) - num_legends = len(self.user_legends) - status = False - - if self.num_series == num_plot_disp == \ - num_series_ord == num_colors \ - == num_legends: - status = True - return status diff --git a/metplotpy/plots/revision_box/revision_box_series.py b/metplotpy/plots/revision_box/revision_box_series.py index 4c90ffe31..bbd8f1e60 100644 --- a/metplotpy/plots/revision_box/revision_box_series.py +++ b/metplotpy/plots/revision_box/revision_box_series.py @@ -164,7 +164,8 @@ def _create_series_points(self) -> dict: 'revision_run': None, 'auto_cor_r': None, 'auto_cor_p': None, - 'points': result} + 'points': result + } # calculate revision_run (WW Runs Test) if needed if self.config.revision_run: diff --git a/metplotpy/plots/revision_series/revision_series.py b/metplotpy/plots/revision_series/revision_series.py index 03e6cafdb..983a321fc 100644 --- a/metplotpy/plots/revision_series/revision_series.py +++ b/metplotpy/plots/revision_series/revision_series.py @@ -17,15 +17,12 @@ from typing import Union -import numpy as np +from matplotlib import pyplot as plt -import plotly.graph_objects as go - -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH from metplotpy.plots.base_plot import BasePlot from metplotpy.plots.line.line import Line -from metplotpy.plots import util +from metplotpy.plots import util as util from metplotpy.plots.series import Series import metcalcpy.util.utils as calc_util @@ -62,16 +59,7 @@ def __init__(self, parameters: dict) -> None: self.logger.info('Begin revision series plotting.') # Check that we have all the necessary settings for each series - is_config_consistent = self.config_obj._config_consistency_check() - if not is_config_consistent: - value_error_msg = ("The number of series defined by series_val_1 is" - " inconsistent with the number of settings" - " required for describing each series. Please check " - " the number of your configuration file's plot_i," - " plot_disp, series_order, user_legend," - " colors, show_legend and series_symbols settings.") - self.logger.error(f"ValueError: {value_error_msg}") - raise ValueError(value_error_msg) + self.config_obj.config_consistency_check() # Read in input data, location specified in config file self.input_df = self._read_input_data() @@ -80,17 +68,8 @@ def __init__(self, parameters: dict) -> None: if self.config_obj.use_ee is True: self.input_df = calc_util.perform_event_equalization(self.parameters, self.input_df) - # Create a list of series objects. - # Each series object contains all the necessary information for plotting, - # such as color, marker symbol, - # line width, and criteria needed to subset the input dataframe. self.series_list = self._create_series(self.input_df) - # create figure - # pylint:disable=assignment-from-no-return - # Need to have a self.figure that we can pass along to - # the methods in base_plot.py (BasePlot class methods) to - # create binary versions of the plot. self._create_figure() def __repr__(self): @@ -135,125 +114,77 @@ def _create_figure(self): self.logger.info(f"Begin creating the {self.LONG_NAME} figure: {datetime.now()}") # create and draw the plot - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) + + wts_size_styles = self.get_weights_size_styles() + + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) + + x_points_index = self._add_series(ax) - # calculate stag adjustments - stag_adjustments = self._calc_stag_adjustments() + xlab_style = wts_size_styles['xlab'] if not self.config_obj.vert_plot else wts_size_styles['ylab'] + ylab_style = wts_size_styles['ylab'] if not self.config_obj.vert_plot else wts_size_styles['xlab'] + self._add_xaxis(ax, xlab_style) + self._add_yaxis(ax, ylab_style) + + self._add_legend(ax) + + # add custom lines + self._add_lines(ax, self.config_obj, x_points_index) + + plt.tight_layout() + + self.logger.info(f"Finish creating {self.LONG_NAME} figure: {datetime.now()}") + + def _add_series(self, ax, ax2=None): + x_points_index = [] + ordered_indy_label = [] if len(self.series_list) > 0: x_points_index = list(range(0, len(self.series_list[0].series_points['points']))) ordered_indy_label = self.series_list[0].series_points['points']['fcst_lead'].tolist() - self.figure.update_layout( - xaxis={ - 'tickmode': 'array', - 'tickvals': x_points_index, - 'ticktext': ordered_indy_label, - 'tickangle': -90 - } - ) - - else: - x_points_index = [] + + self.config_obj.indy_label = ordered_indy_label + self.config_obj.indy_vals = x_points_index # add series points for series in self.series_list: # Don't generate the plot for this series if # it isn't requested (as set in the config file) - if series.plot_disp: + if not series.plot_disp: + continue - # apply staggering offset if applicable - if stag_adjustments[series.idx] == 0: - x_points_index_adj = x_points_index - else: - x_points_index_adj = x_points_index + stag_adjustments[series.idx] + x_points_index_adj = x_points_index + if self.config_obj.indy_stagger: + x_points_index_adj, _ = self._get_x_locs_and_width(x_points_index, series.idx, + stagger_scale=0.1) + self._draw_series(ax, None, series, x_points_index_adj) - self._draw_series(series, x_points_index_adj) + return x_points_index - # add custom lines - self._add_lines(self.config_obj, x_points_index) - - # apply y axis limits - self._yaxis_limits() - - self.logger.info(f"Finish creating {self.LONG_NAME} figure: {datetime.now()}") - - def _draw_series(self, series: Series, x_points_index_adj: Union[list, None] = None) -> None: + def _draw_series(self, ax, ax2, series: Series, x_points_adj: Union[list, None] = None) -> None: """ Draws the formatted series points on the plot :param series: RevisionSeries object with data and parameters - :param x_points_index_adj: values for adjusting x-values position + :param x_points_adj: values for adjusting x-values position """ self.logger.info(f"Draw the formatted series: {datetime.now()}") - y_points = series.series_points['points']['stat_value'].tolist() - - # add the plot - self.figure.add_trace( - go.Scatter(x=x_points_index_adj, - y=y_points, - showlegend=self.config_obj.show_legend[series.idx] == 1, - mode='markers', - textposition="top right", - name=series.user_legends, - marker_symbol=self.config_obj.marker_list[series.idx], - marker_color=self.config_obj.colors_list[series.idx], - marker_line_color=self.config_obj.colors_list[series.idx], - marker_size=self.config_obj.marker_size[series.idx], - ), - secondary_y=False + ax.plot( + x_points_adj, series.series_points['points']['stat_value'].tolist(), + label=series.user_legends, + # marker style + marker=self.config_obj.marker_list[series.idx], + markersize=self.config_obj.marker_size[series.idx], + markeredgecolor=self.config_obj.colors_list[series.idx], + markerfacecolor=self.config_obj.colors_list[series.idx], + # no lines + linestyle='None', ) self.logger.info(f"Finished drawing series: {datetime.now()}") - def _add_xaxis(self) -> None: - """ - Configures and adds x-axis to the plot - """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - ticks="inside", - zeroline=False, - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size} - ) - - def _calc_stag_adjustments(self) -> list: - """ - Calculates the x-axis adjustment for each point if requested. - It needed so the points for each x-axis values don't be placed on top of each other - - :return: the list of the adjustment values - """ - - self.logger.info("Calculating the x-axis adjustment.") - # get the total number of series - num_stag = len(self.config_obj.all_series_y1) - - # init the result with 0 - stag_vals = [0] * num_stag - - # calculate staggering values - if self.config_obj.indy_stagger is True: - dbl_adj_scale = (len(self.config_obj.indy_vals) - 1) / 150 - stag_vals = np.linspace(-(num_stag / 2) * dbl_adj_scale, - (num_stag / 2) * dbl_adj_scale, - num_stag, - True) - stag_vals = stag_vals + dbl_adj_scale / 2 - return stag_vals - def write_output_file(self) -> None: """ Formats y1 series point data and saves them to the files diff --git a/metplotpy/plots/revision_series/revision_series_config.py b/metplotpy/plots/revision_series/revision_series_config.py index d031be4f5..3b0a1541b 100644 --- a/metplotpy/plots/revision_series/revision_series_config.py +++ b/metplotpy/plots/revision_series/revision_series_config.py @@ -49,7 +49,6 @@ def __init__(self, parameters: dict) -> None: ############################################## # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -62,11 +61,11 @@ def __init__(self, parameters: dict) -> None: ############################################## # x-axis parameters self.x_title_font_size = self.parameters['xlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.x_tickangle = self.parameters['xtlab_orient'] - if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): - self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] + # self.x_tickangle = self.parameters['xtlab_orient'] + # if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): + # self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] + self.x_tickangle = 90 self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) ############################################## # series parameters @@ -103,106 +102,6 @@ def __init__(self, parameters: dict) -> None: self.revision_run = self._get_bool('revision_run') self.indy_stagger = self._get_bool('indy_stagger_1') - def _get_plot_disp(self) -> list: - """ - Retrieve the values that determine whether to display a particular series - and convert them to bool if needed - - Args: - - Returns: - A list of boolean values indicating whether or not to - display the corresponding series - """ - - plot_display_config_vals = self.get_config_value('plot_disp') - plot_display_bools = [] - for val in plot_display_config_vals: - if isinstance(val, bool): - plot_display_bools.append(val) - - if isinstance(val, str): - plot_display_bools.append(val.upper() == 'TRUE') - - return self.create_list_by_series_ordering(plot_display_bools) - - def _get_markers(self) -> list: - """ - Retrieve all the markers. Convert marker names from - the config file into plotly python's marker names. - - Args: - - Returns: - markers: a list of the plotly markers - """ - markers = self.get_config_value('series_symbols') - markers_list = [] - for marker in markers: - if marker in constants.AVAILABLE_PLOTLY_MARKERS_LIST: - # the recognized plotly marker names: - # circle-open (for small circle), circle, triangle-up, - # square, diamond, or hexagon - markers_list.append(marker) - else: - markers_list.append(constants.PCH_TO_PLOTLY_MARKER[marker]) - return self.create_list_by_series_ordering(markers_list) - - def _get_markers_size(self) -> list: - """ - Retrieve all the markers. Convert marker names from - the config file into plotly python's marker names. - - Args: - - Returns: - markers: a list of the plotly markers - """ - markers = self.get_config_value('series_symbols') - markers_size = [] - for marker in markers: - if marker in constants.AVAILABLE_PLOTLY_MARKERS_LIST: - markers_size.append(marker) - else: - markers_size.append(constants.PCH_TO_PLOTLY_MARKER_SIZE[marker]) - - return self.create_list_by_series_ordering(markers_size) - - def _config_consistency_check(self) -> bool: - """ - Checks that the number of settings defined for plot_ci, - plot_disp, series_order, user_legend colors, and series_symbols - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - series (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) - - """ - - self.logger.info(f"Begin consistency checK: {datetime.now()}") - # Determine the number of series based on the number of - # permutations from the series_var setting in the - # config file - - # Numbers of values for other settings for series - num_plot_disp = len(self.plot_disp) - num_markers = len(self.marker_list) - num_series_ord = len(self.series_ordering) - num_colors = len(self.colors_list) - num_legends = len(self.user_legends) - status = False - - if self.num_series == num_plot_disp == \ - num_markers == num_series_ord == num_colors \ - == num_legends: - status = True - return status - def _get_user_legends(self, legend_label_type: str = '') -> list: """ Retrieve the text that is to be displayed in the legend at the bottom of the plot. @@ -247,8 +146,8 @@ def get_series_y(self) -> list: for x in reversed(list(all_fields_values_orig.keys())): all_fields_values[x] = all_fields_values_orig.get(x) - if self._get_fcst_vars(1): - all_fields_values['fcst_var'] = list(self._get_fcst_vars(1).keys()) + if self.get_fcst_vars_keys(1): + all_fields_values['fcst_var'] = self.get_fcst_vars_keys(1) stat_name = self.get_config_value('list_stat_1') if stat_name is not None: @@ -279,10 +178,7 @@ def calculate_number_of_series(self) -> int: """ # Retrieve the lists from the series_val_1 dictionary series_vals_list = self.series_vals_1.copy() - if isinstance(self.fcst_var_val_1, list) is True: - fcst_vals = self.fcst_var_val_1 - elif isinstance(self.fcst_var_val_1, dict) is True: - fcst_vals = list(self.fcst_var_val_1.values()) + fcst_vals = list(self.fcst_var_val_1.values()) fcst_vals_flat = [item for sublist in fcst_vals for item in sublist] series_vals_list.append(fcst_vals_flat) diff --git a/metplotpy/plots/roc_diagram/roc_diagram.py b/metplotpy/plots/roc_diagram/roc_diagram.py index 01bcdd811..f7fd2ce88 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram.py +++ b/metplotpy/plots/roc_diagram/roc_diagram.py @@ -17,20 +17,18 @@ from datetime import datetime import re import warnings -# with warnings.catch_warnings(): -# warnings.simplefilter("ignore", category="DeprecationWarning") -# warnings.simplefilter("ignore", category="ResourceWarning") import pandas as pd -import plotly.graph_objects as go -from plotly.subplots import make_subplots + +from matplotlib import pyplot as plt + from metplotpy.plots import util from metplotpy.plots import constants from metplotpy.plots.base_plot import BasePlot from metplotpy.plots.roc_diagram.roc_diagram_config import ROCDiagramConfig from metplotpy.plots.roc_diagram.roc_diagram_series import ROCDiagramSeries + import metcalcpy.util.utils as calc_util -from metplotpy.plots.util import prepare_pct_roc, prepare_ctc_roc class ROCDiagram(BasePlot): @@ -96,16 +94,11 @@ def __init__(self, parameters): # line width, and criteria needed to subset the input dataframe. self.series_list = self._create_series(self.input_df) - # create figure - # pylint:disable=assignment-from-no-return - # Need to have a self.figure that we can pass along to - # the methods in base_plot.py (BasePlot class methods) to - # create binary versions of the plot. - self.figure = self._create_figure() + self._create_figure() # add custom lines - if len(self.series_list) > 0: - self._add_lines(self.config_obj) + # if len(self.series_list) > 0: + # self._add_lines(self.config_obj) def _read_input_data(self) -> pd.DataFrame: """ @@ -201,76 +194,83 @@ def _create_series(self, input_data): series_obj = ROCDiagramSeries(self.config_obj, i, input_data) series_list.append(series_obj) - if self.config_obj.summary_curve != 'none': - # add Summary Curve bassd on teh summary dataframes of each ROCDiagramSeries - df_sum_main = None - for idx, series in enumerate(series_list): - # create a main summary frame from series summary frames + if self.config_obj.summary_curve == 'none': + return series_list + + # add Summary Curve based on teh summary dataframes of each ROCDiagramSeries + df_sum_main = None + for idx, series in enumerate(series_list): + # create a main summary frame from series summary frames + if df_sum_main is None: if self.config_obj.linetype_ctc: - if df_sum_main is None: - df_sum_main = pd.DataFrame(columns=['fcst_thresh', 'fy_oy', 'fy_on', 'fn_oy', 'fn_on']) + df_sum_main = pd.DataFrame(columns=['fcst_thresh', 'fy_oy', 'fy_on', 'fn_oy', 'fn_on']) elif self.config_obj.linetype_pct: - if df_sum_main is None: - df_sum_main = pd.DataFrame(columns=['thresh_i', 'i_value', 'on_i', 'oy_i']) - - df_sum_main = pd.concat([df_sum_main, series.series_points[3]], axis=0) - - if self.config_obj.linetype_ctc: - df_summary_curve = pd.DataFrame(columns=['fcst_thresh', 'fy_oy', 'fy_on', 'fn_oy', 'fn_on']) - fcst_thresh_list = df_sum_main['fcst_thresh'].unique() - for thresh in fcst_thresh_list: - if self.config_obj.summary_curve == 'median': - group_stats_fy_oy = df_sum_main['fy_oy'][df_sum_main['fcst_thresh'] == thresh].median() - group_stats_fn_oy = df_sum_main['fn_oy'][df_sum_main['fcst_thresh'] == thresh].median() - group_stats_fy_on = df_sum_main['fy_on'][df_sum_main['fcst_thresh'] == thresh].median() - group_stats_fn_on = df_sum_main['fn_on'][df_sum_main['fcst_thresh'] == thresh].median() - else: - group_stats_fy_oy = df_sum_main['fy_oy'][df_sum_main['fcst_thresh'] == thresh].mean() - group_stats_fn_oy = df_sum_main['fn_oy'][df_sum_main['fcst_thresh'] == thresh].mean() - group_stats_fy_on = df_sum_main['fy_on'][df_sum_main['fcst_thresh'] == thresh].mean() - group_stats_fn_on = df_sum_main['fn_on'][df_sum_main['fcst_thresh'] == thresh].mean() - df_summary_curve.loc[len(df_summary_curve)] = {'fcst_thresh': thresh, - 'fy_oy': group_stats_fy_oy, - 'fn_oy': group_stats_fn_oy, - 'fy_on': group_stats_fy_on, - 'fn_on': group_stats_fn_on, - } - df_summary_curve.reset_index() - pody, pofd, thresh = prepare_ctc_roc(df_summary_curve,self.config_obj.ctc_ascending) - else: - df_summary_curve = pd.DataFrame(columns=['thresh_i', 'on_i', 'oy_i']) - thresh_i_list = df_sum_main['thresh_i'].unique() - for index, thresh in enumerate(thresh_i_list): - if self.config_obj.summary_curve == 'median': - on_i_sum = df_sum_main['on_i'][df_sum_main['thresh_i'] == thresh].median() - oy_i_sum = df_sum_main['oy_i'][df_sum_main['thresh_i'] == thresh].median() - else: - on_i_sum = df_sum_main['on_i'][df_sum_main['thresh_i'] == thresh].mean() - oy_i_sum = df_sum_main['oy_i'][df_sum_main['thresh_i'] == thresh].mean() - df_summary_curve.loc[len(df_summary_curve)] = {'thresh_i': thresh, 'on_i': on_i_sum, - 'oy_i': oy_i_sum, } - df_summary_curve.reset_index() - pody, pofd, thresh = prepare_pct_roc(df_summary_curve) - - series_obj = ROCDiagramSeries(self.config_obj, num_series -1, None) - series_obj.series_points = (pofd, pody, thresh, None) + df_sum_main = pd.DataFrame(columns=['thresh_i', 'i_value', 'on_i', 'oy_i']) - series_list.append(series_obj) + df_sum_main = pd.concat([df_sum_main, series.series_points[3]], axis=0) - return series_list + if self.config_obj.linetype_ctc: + pofd, pody, thresh = self._handle_ctc(df_sum_main) + else: + pofd, pody, thresh = self._handle_pct(df_sum_main) - def remove_file(self): - """ - Removes previously made image file . Invoked by the parent class before self.output_file - attribute can be created, but overridden here. - """ + series_obj = ROCDiagramSeries(self.config_obj, num_series -1, None) + series_obj.series_points = (pofd, pody, thresh, None) + + series_list.append(series_obj) - image_name = self.get_config_value('plot_filename') - warnings.filterwarnings("ignore", category=DeprecationWarning) + return series_list - # remove the old file if it exist - if os.path.exists(image_name): - os.remove(image_name) + def _handle_ctc(self, df_sum_main): + df_summary_curve = pd.DataFrame(columns=['fcst_thresh', 'fy_oy', 'fy_on', 'fn_oy', 'fn_on']) + fcst_thresh_list = df_sum_main['fcst_thresh'].unique() + for thresh in fcst_thresh_list: + if self.config_obj.summary_curve == 'median': + group_stats_fy_oy = df_sum_main['fy_oy'][ + df_sum_main['fcst_thresh'] == thresh].median() + group_stats_fn_oy = df_sum_main['fn_oy'][ + df_sum_main['fcst_thresh'] == thresh].median() + group_stats_fy_on = df_sum_main['fy_on'][ + df_sum_main['fcst_thresh'] == thresh].median() + group_stats_fn_on = df_sum_main['fn_on'][ + df_sum_main['fcst_thresh'] == thresh].median() + else: + group_stats_fy_oy = df_sum_main['fy_oy'][ + df_sum_main['fcst_thresh'] == thresh].mean() + group_stats_fn_oy = df_sum_main['fn_oy'][ + df_sum_main['fcst_thresh'] == thresh].mean() + group_stats_fy_on = df_sum_main['fy_on'][ + df_sum_main['fcst_thresh'] == thresh].mean() + group_stats_fn_on = df_sum_main['fn_on'][ + df_sum_main['fcst_thresh'] == thresh].mean() + df_summary_curve.loc[len(df_summary_curve)] = {'fcst_thresh': thresh, + 'fy_oy': group_stats_fy_oy, + 'fn_oy': group_stats_fn_oy, + 'fy_on': group_stats_fy_on, + 'fn_on': group_stats_fn_on, + } + df_summary_curve.reset_index() + pody, pofd, thresh = util.prepare_ctc_roc(df_summary_curve, self.config_obj.ctc_ascending) + return pofd, pody, thresh + + def _handle_pct(self, df_sum_main): + df_summary_curve = pd.DataFrame(columns=['thresh_i', 'on_i', 'oy_i']) + thresh_i_list = df_sum_main['thresh_i'].unique() + for index, thresh in enumerate(thresh_i_list): + if self.config_obj.summary_curve == 'median': + on_i_sum = df_sum_main['on_i'][df_sum_main['thresh_i'] == thresh].median() + oy_i_sum = df_sum_main['oy_i'][df_sum_main['thresh_i'] == thresh].median() + else: + on_i_sum = df_sum_main['on_i'][df_sum_main['thresh_i'] == thresh].mean() + oy_i_sum = df_sum_main['oy_i'][df_sum_main['thresh_i'] == thresh].mean() + df_summary_curve.loc[len(df_summary_curve)] = { + 'thresh_i': thresh, + 'on_i': on_i_sum, + 'oy_i': oy_i_sum, + } + df_summary_curve.reset_index() + pody, pofd, thresh = util.prepare_pct_roc(df_summary_curve) + return pofd, pody, thresh def _create_figure(self): """ @@ -278,207 +278,80 @@ def _create_figure(self): (Success Rate) values. Hard-coding of labels for CSI lines and bias lines, and contour colors for the CSI curves. - Args: - Returns: ROC diagram """ - self.logger.info(f"Begin creating figure: {datetime.now()}") - fig = make_subplots(specs=[[{"secondary_y": True}]]) - - # Set plot height and width in pixel value - width = self.config_obj.plot_width - height = self.config_obj.plot_height - # fig.update_layout(width=width, height=height, paper_bgcolor="white") - fig.update_layout(width=width, height=height) - - # Add figure title - # fig.update_layout( - # title={'text': self.config_obj.title, - # 'y': 0.95, - # 'x': 0.5, - # 'xanchor': "center", - # 'yanchor': "top"}, - # plot_bgcolor="#FFF" - # - # ) - - # create title - title = {'text': util.apply_weight_style(self.config_obj.title, - self.config_obj.parameters['title_weight']), - 'font': { - 'size': self.config_obj.title_font_size, - }, - 'y': self.config_obj.title_offset, - 'x': self.config_obj.parameters['title_align'], - 'xanchor': 'center', - 'yanchor': 'top', - 'xref': 'paper' - } - fig.update_layout(title=title, plot_bgcolor="#FFF") - - # fig.update_xaxes(title_text=self.config_obj.xaxis, linecolor="black", linewidth=2, showgrid=False, - # range=[0.0, 1.0], dtick=0.1) - - # Set y-axes titles - # fig.update_yaxes(title_text="primary yaxis title", secondary_y=False) - fig.update_yaxes(title_text=self.config_obj.yaxis_1, secondary_y=False, linecolor="black", linewidth=2, - showgrid=False, zeroline=False, range=[0.0, 1.0], dtick=0.1) - # fig.update_yaxes(title_text=self.config_obj.yaxis_2, secondary_y=True, linecolor="black", linewidth=2, - # showgrid=False, zeroline=False, range=[0.0, 1.0], dtick=0.1) - - # set the range of the x-axis and y-axis to range from 0 to 1 - fig.update_layout(xaxis=dict(range=[0., 1.])) - fig.update_layout(yaxis=dict(range=[0., 1.])) - # plot the no-skill line - x = [0., 1.] - y = [0., 1.] - fig.add_trace(go.Scatter(x=x, y=y, line=dict(color='grey', - width=1.2, - dash='dash' - ), - name='no skill line', - showlegend=False - )) - - # style the legend box - if self.config_obj.draw_box: - fig.update_layout(legend=dict(x=self.config_obj.bbox_x, - y=self.config_obj.bbox_y, - bordercolor="black", - borderwidth=2 - )) + # create and draw the plot + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) - else: - fig.update_layout(legend=dict(x=self.config_obj.bbox_x, - y=self.config_obj.bbox_y - )) - - # can't support number of columns in legend, can only choose - # between horizontal or vertical alignment of legend labels - # so only support vertical legends (ie num columns = 1) - fig.update_layout(legend=dict(x=self.config_obj.bbox_x, - y=self.config_obj.bbox_y, - bordercolor="black", - borderwidth=2 - )) - - # caption styling - annotation = [ - {'text': util.apply_weight_style(self.config_obj.parameters['plot_caption'], - self.config_obj.parameters['caption_weight']), - 'align': 'left', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': self.config_obj.parameters['caption_align'], - 'y': self.config_obj.caption_offset, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - }] - - # Set x-axis title - # fig.update_xaxes(title_text=self.config_obj.xaxis, linecolor="black", linewidth=2, showgrid=False, - # dtick=0.1, tickmode='linear', tick0=0.0) - fig.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=constants.PLOTLY_AXIS_LINE_COLOR, - linewidth=constants.PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - dtick=0.1, - tick0=0.0, - tickmode='linear', - zeroline=False, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - ticks="inside", - title_standoff=abs(self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size} - ) - fig.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_1, - self.config_obj.parameters['ylab_weight']), - secondary_y=False, - linecolor=constants.PLOTLY_AXIS_LINE_COLOR, - linewidth=constants.PLOTLY_AXIS_LINE_WIDTH, - zeroline=False, - title_font={ - 'size': self.config_obj.y_title_font_size - }, - ticks="inside", - title_standoff=abs(self.config_obj.parameters['ylab_offset']), - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size} - ) - - fig.update_layout(annotations=annotation) - - thresh_list = [] - - - - - # "Dump" False Detection Rate (POFD) and PODY points to an output - # file based on the output image filename (useful in debugging) - # This output file is used by METviewer and not necessary for other uses. - if self.config_obj.dump_points_1 == True : - self.write_output_file() + wts_size_styles = self.get_weights_size_styles() - for idx, series in enumerate(self.series_list): - for i, thresh_val in enumerate(series.series_points[2]): - thresh_list.append(str(thresh_val)) - - # Don't generate the plot for this series if - # it isn't requested (as set in the config file) - if series.plot_disp: - pofd_points = series.series_points[0] - pody_points = series.series_points[1] - legend_label = self.config_obj.user_legends[idx] - - # add the plot - self.logger.info("Adding traces for markers and legend.") - fig.add_trace( - go.Scatter(mode="lines+markers", x=pofd_points, y=pody_points, - showlegend=self.config_obj.show_legend[series.idx] == 1, - text=thresh_list, textposition="top right", name=legend_label, - line=dict(color=self.config_obj.colors_list[idx], - width=self.config_obj.linewidth_list[idx]), - marker_symbol=self.config_obj.marker_list[idx]), - secondary_y=False - ) + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) + self._add_series(ax) - def add_trace_copy(trace): - """Adds separate traces for markers and a legend. - This is a fix for not printing 'Aa' in the legend - Args: - Returns: - """ + self._add_xaxis(ax, wts_size_styles['xlab']) + ax.set_xlim(0, 1) + self._add_yaxis(ax, wts_size_styles['ylab']) + ax.set_ylim(0, 1) - fig.add_traces(trace) - new_trace = fig.data[-1] - # if self.config_obj.add_point_thresholds: - # new_trace.update(textfont_color=trace.marker.color, textposition='top center', - # mode="text", showlegend=False) - new_trace.update(textfont_color=trace.marker.color, textposition='top center', - mode="text", showlegend=False) - trace.update(mode="lines+markers") + self._add_legend(ax) - if self.config_obj.add_point_thresholds: - fig.for_each_trace(add_trace_copy) + # add custom lines if lines are defined in config + if len(self.series_list) > 0: + self._add_lines(ax, self.config_obj, self.config_obj.indy_vals) + plt.tight_layout() self.logger.info(f"Finished creating figure: {datetime.now()}") - return fig - + def _add_series(self, ax): + # plot the no-skill line + ax.plot([0., 1.], [0., 1.], color='grey', zorder=0, linewidth=1.2, linestyle='--') + for idx, series in enumerate(self.series_list): + # Don't generate the plot for this series if + # it isn't requested (as set in the config file) + if not series.plot_disp: + continue + + pofd_points = series.series_points[0] + pody_points = series.series_points[1] + + # set arguments for the plot + plot_args = { + 'marker': self.config_obj.marker_list[idx], + 'label': self.config_obj.user_legends[idx], + 'color': self.config_obj.colors_list[idx], + 'linewidth': self.config_obj.linewidth_list[idx], + } + if self.config_obj.marker_open_list[idx]: + plot_args['markerfacecolor'] = 'none' + plot_args['markeredgecolor'] = self.config_obj.colors_list[idx] + + ax.plot(pofd_points, pody_points, **plot_args) + + # add thresholds if defined and requested + if not self.config_obj.add_point_thresholds: + continue + + for pofd_point, pody_point, thresh_val in zip(pofd_points, pody_points, series.series_points[2]): + + if not thresh_val or pofd_point is None or pody_point is None: + continue + + ax.annotate( + str(thresh_val), + (pofd_point, pody_point), + xytext=(-10, 2), + textcoords="offset points", + ha='left', + va='bottom', + ) def write_output_file(self): """ @@ -486,61 +359,50 @@ def write_output_file(self): being plotted """ + if not self.config_obj.dump_points_1: + return - self.logger.info("Writing output file") # if points_path parameter doesn't exist, # open file, name it based on the stat_input config setting, # (the input data file) except replace the .data # extension with .points1 extension # otherwise use points_path path match = re.match(r'(.*)(.data)', self.config_obj.parameters['stat_input']) - if self.config_obj.dump_points_1 is True and match: - filename = match.group(1) - # replace the default path with the custom - if self.config_obj.points_path is not None: - # get the file name - path = filename.split(os.path.sep) - if len(path) > 0: - filename = path[-1] - else: - filename = '.' + os.path.sep - filename = self.config_obj.points_path + os.path.sep + filename - - output_file = filename + '.points1' - os.makedirs(os.path.dirname(output_file), exist_ok=True) - if os.path.exists(output_file): - os.remove(output_file) - - with open(output_file, 'a') as fileobj: - header_str = "pofd\t pody\n" - fileobj.write(header_str) - all_pody = [] - all_pofd = [] - for series in self.series_list: - pody_points = series.series_points[1] - pofd_points = series.series_points[0] - all_pody.extend(pody_points) - all_pofd.extend(pofd_points) - - all_points = zip(all_pofd, all_pody) - for idx, pts in enumerate(all_points): - data_str = str(pts[0]) + "\t" + str(pts[1]) + "\n" - fileobj.write(data_str) - - - def write_html(self) -> None: - """ - Is needed - creates and saves the html representation of the plot WITHOUT Plotly.js - """ + if not match: + return - self.logger.info("Writing HTML file") - if self.config_obj.create_html is True: - # construct the fle name from plot_filename - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" + self.logger.info("Writing output file") + filename = match.group(1) + # replace the default path with the custom + if self.config_obj.points_path is not None: + # get the file name + path = filename.split(os.path.sep) + if len(path) > 0: + filename = path[-1] + else: + filename = '.' + os.path.sep + filename = self.config_obj.points_path + os.path.sep + filename + + output_file = filename + '.points1' + os.makedirs(os.path.dirname(output_file), exist_ok=True) + if os.path.exists(output_file): + os.remove(output_file) + + with open(output_file, 'a') as fileobj: + header_str = "pofd\t pody\n" + fileobj.write(header_str) + all_pody = [] + all_pofd = [] + for series in self.series_list: + pody_points = series.series_points[1] + pofd_points = series.series_points[0] + all_pody.extend(pody_points) + all_pofd.extend(pofd_points) - # save html - self.figure.write_html(html_name, include_plotlyjs=False) + all_points = zip(all_pofd, all_pody) + for idx, pts in enumerate(all_points): + data_str = str(pts[0]) + "\t" + str(pts[1]) + "\n" + fileobj.write(data_str) def main(config_filename=None): @@ -554,17 +416,7 @@ def main(config_filename=None): @param config_filename: default is None, the name of the custom config file to apply Returns: """ - params = util.get_params(config_filename) - try: - r = ROCDiagram(params) - r.save_to_file() - - r.write_html() - r.logger.info(f"Finished ROC diagram: {datetime.now()}") - - #r.show_in_browser() - except ValueError as ve: - print(ve) + util.make_plot(config_filename, ROCDiagram) if __name__ == "__main__": diff --git a/metplotpy/plots/roc_diagram/roc_diagram_config.py b/metplotpy/plots/roc_diagram/roc_diagram_config.py index 3f11e0b85..f24b2fbc5 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram_config.py +++ b/metplotpy/plots/roc_diagram/roc_diagram_config.py @@ -15,8 +15,6 @@ """ __author__ = 'Minna Win' - -import sys from ..config import Config from .. import util from .. import constants @@ -63,10 +61,11 @@ def __init__(self, parameters): self.linetype_ctc = self.get_config_value('roc_ctc') # Probability contingency table count line type self.linetype_pct = self.get_config_value('roc_pct') + # TODO: it looks like both roc_ctc and roc_pct cannot be set - add error check? # Supported values for stat_curve are none, mean, and median self.plot_stat = self.get_config_value('stat_curve') - self.plot_width = self.calculate_plot_dimension('plot_width', 'pixels') - self.plot_height = self.calculate_plot_dimension('plot_height', 'pixels') + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') self.plot_resolution = self._get_plot_resolution() reverse_ctc_connection = str(self.get_config_value('reverse_connection_order')) if reverse_ctc_connection.upper() == "FALSE": @@ -76,21 +75,20 @@ def __init__(self, parameters): # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE # Caption settings self.caption = self.get_config_value('plot_caption') - self.caption_weight = self.get_config_value('caption_weight') self.caption_color = self.get_config_value('caption_col') # caption size is a magnification value self.caption_size = float(self.get_config_value('caption_size')) * constants.DEFAULT_CAPTION_FONTSIZE - self.caption_offset = self.get_config_value('caption_offset') - 3.1 + self.caption_offset = self.get_config_value('caption_offset') * constants.DEFAULT_CAPTION_Y_OFFSET self.caption_align = self.get_config_value('caption_align') self.caption = self.get_config_value('plot_caption') self.colors_list = self._get_colors() self.marker_list = self._get_markers() + self.marker_open_list = self._get_markers_open() self.linewidth_list = self._get_linewidths() self.linestyles_list = self._get_linestyles() self.user_legends = self._get_user_legends("ROC Curve") @@ -103,20 +101,21 @@ def __init__(self, parameters): # the location of the bounding box which defines # the legend. self.bbox_x = float(user_settings['bbox_x']) - # set legend box lower by .18 pixels of the default value - # set in METviewer to prevent obstructing the x-axis. - self.bbox_y = float(user_settings['bbox_y']) - 0.18 + self.bbox_y = float(user_settings['bbox_y']) legend_magnification = user_settings['legend_size'] self.legend_size = int(constants.DEFAULT_LEGEND_FONTSIZE * legend_magnification) self.legend_ncol = self.get_config_value('legend_ncol') + self.legend_orientation = 'v' # TODO: should this be always vertical? legend_box = self.get_config_value('legend_box').lower() if legend_box == 'n': # Don't draw a box around legend labels self.draw_box = False + self.legend_border_width = 0 else: # Other choice is 'o' # Enclose legend labels in a box self.draw_box = True + self.legend_border_width = 2 # x-axis parameters self.x_title_font_size = self.parameters['xlab_size'] * constants.DEFAULT_TITLE_FONT_SIZE @@ -124,7 +123,6 @@ def __init__(self, parameters): if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] self.x_tickfont_size = self.parameters['xtlab_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) # y-axis parameters self.y_tickangle = self.parameters['ytlab_orient'] @@ -133,8 +131,8 @@ def __init__(self, parameters): self.y_tickfont_size = self.parameters['ytlab_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.plot_width = self.calculate_plot_dimension('plot_width', 'pixels') - self.plot_height = self.calculate_plot_dimension('plot_height', 'pixels') + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') self.show_legend = self._get_show_legend() if 'summary_curve' in self.parameters.keys(): @@ -232,33 +230,9 @@ def _get_series_order(self): """ ordinals = self.get_config_value('series_order') - series_order_list = [ord for ord in ordinals] + series_order_list = list(ordinals) return series_order_list - - def _get_plot_disp(self): - """ - Retrieve the boolean values that determine whether to display a particular series - - Args: - - Returns: - A list of boolean values indicating whether or not to - display the corresponding series - """ - - plot_display_config_vals = self.get_config_value('plot_disp') - plot_display_bools = [] - for p in plot_display_config_vals: - if str(p).upper() == "TRUE": - plot_display_bools.append(True) - else: - plot_display_bools.append(False) - - plot_display_bools_ordered = self.create_list_by_series_ordering(plot_display_bools) - return plot_display_bools_ordered - - def _get_point_thresh(self): """ Retrieve the value (true/false) of the add_point_threshold @@ -280,34 +254,3 @@ def _get_point_thresh(self): return True else: return False - - - def _get_markers(self): - """ - Retrieve all the markers. Convert marker names from - the config file into plotly python's marker names. - - Args: - - Returns: - markers: a list of the plotly markers - """ - markers = self.get_config_value('series_symbols') - markers_list = [] - for marker in markers: - if marker in constants.AVAILABLE_PLOTLY_MARKERS_LIST: - # the recognized plotly marker names: - # circle-open (for small circle), circle, triangle-up, - # square, diamond, or hexagon - markers_list.append(marker) - else: - # markers are indicated by name: circle-open (for small circle), - # circle, triangle-up, - # diamond, hexagon, square - m = marker.lower() - markers_list.append(constants.PCH_TO_PLOTLY_MARKER[m]) - markers_list_ordered = self.create_list_by_series_ordering(markers_list) - return markers_list_ordered - - - diff --git a/metplotpy/plots/scatter/scatter.py b/metplotpy/plots/scatter/scatter.py index 2f13b5888..99e7be52f 100644 --- a/metplotpy/plots/scatter/scatter.py +++ b/metplotpy/plots/scatter/scatter.py @@ -11,18 +11,17 @@ """ __author__ = 'Minna Win' +import sys import os from datetime import datetime import matplotlib.pyplot as plt import numpy as np from matplotlib.font_manager import FontProperties -import yaml + import pandas as pd from metplotpy.plots.base_plot import BasePlot from metplotpy.plots.scatter.scatter_config import ScatterConfig from metplotpy.plots import util -from metplotpy.plots.util import get_params -from metcalcpy.util.read_env_vars_in_config import parse_config class Scatter(BasePlot): """ @@ -186,7 +185,7 @@ def create_figure(self, parms) -> None: # Save the plot plot_filename = self.config_obj.plot_filename self.logger.info(f"Saving scatter plot as {plot_filename}") - plt.savefig(plot_filename) + self.save_to_file() time_to_plot = datetime.now() - start self.logger.info(f"Total time for generating the scatter plot: {time_to_plot} seconds") @@ -201,15 +200,18 @@ def main(config_filename=None): Returns: None """ - docs = get_params(config_filename) + docs = util.get_params(config_filename) try: plot = Scatter(docs) plot.create_figure(docs) plot.logger.info(f"Finished generating scatter plot: {datetime.now()}") - except ValueError as ve: - print(ve) + except Exception as err: + plot.logger.error("Exception occurred in scatter plot: %s", err) + plot.logger.debug("Exception details:", exc_info=True) + return False -if __name__ == "__main__": - main() + return True +if __name__ == "__main__": + sys.exit(0 if main() else 1) diff --git a/metplotpy/plots/scatter/scatter_config.py b/metplotpy/plots/scatter/scatter_config.py index 0e5c5665c..6e7b4314f 100644 --- a/metplotpy/plots/scatter/scatter_config.py +++ b/metplotpy/plots/scatter/scatter_config.py @@ -15,7 +15,6 @@ from .. import constants from .. import util -import metcalcpy.util.utils as utils class ScatterConfig(Config): """ Configuration object for the scatter plot. @@ -81,7 +80,6 @@ def __init__(self, parameters): ############################################## # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -99,8 +97,8 @@ def __init__(self, parameters): if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) - + xlab_weight = self.parameters['xlab_weight'] + self.xlab_weight = constants.MV_TO_MPL_CAPTION_STYLE[xlab_weight] ############################################## self.marker_symbol = self._get_marker() diff --git a/metplotpy/plots/skew_t/tcdiag_env.yaml b/metplotpy/plots/skew_t/tcdiag_env.yaml index dd92d0e33..1c9de80e3 100644 --- a/metplotpy/plots/skew_t/tcdiag_env.yaml +++ b/metplotpy/plots/skew_t/tcdiag_env.yaml @@ -19,5 +19,3 @@ dependencies: - openssl - jupyterlab - jupyter - - python-kaleido - - plotly diff --git a/metplotpy/plots/taylor_diagram/taylor_diagram.py b/metplotpy/plots/taylor_diagram/taylor_diagram.py index b6b7bc54c..146c6e9e9 100644 --- a/metplotpy/plots/taylor_diagram/taylor_diagram.py +++ b/metplotpy/plots/taylor_diagram/taylor_diagram.py @@ -279,34 +279,26 @@ def _create_figure(self) -> None: self.ax.plot(np.arccos(correlation), stdev, marker=marker, ms=10, ls='', color=marker_colors, label=legend) - # use FontProperties to re-create the weights used in METviewer - fontobj = FontProperties() - font_title = fontobj.copy() - font_title.set_size(self.config_obj.title_size) - style = self.config_obj.title_weight[0] - wt = self.config_obj.title_weight[1] - font_title.set_style(style) - font_title.set_weight(wt) - - plt.title(self.config_obj.title, - fontproperties=font_title, - color=constants.DEFAULT_TITLE_COLOR, - pad=28) - - # Plot the caption, leverage FontProperties to re-create the 'weights' menu in - # METviewer (i.e. use a combination of style and weight to create the bold - # italic - # caption weight in METviewer) - fontobj = FontProperties() - font = fontobj.copy() - font.set_size(self.config_obj.caption_size) - style = self.config_obj.caption_weight[0] - wt = self.config_obj.caption_weight[1] - font.set_style(style) - font.set_weight(wt) - plt.figtext(self.config_obj.caption_align, self.config_obj.caption_offset, - self.config_obj.plot_caption, - fontproperties=font, color=self.config_obj.caption_color) + # get the weights, sizes, and style for the title, caption, x-axis label, and + # y-axis label + wts_size_styles = self.get_weights_size_styles() + + # Plot the title + plt.title( + self.config_obj.title, + fontproperties=wts_size_styles['title'], + color=constants.DEFAULT_TITLE_COLOR, + pad=28 + ) + + # Plot the caption + caption = wts_size_styles['caption'] + + plt.figtext( + self.config_obj.caption_align, self.config_obj.caption_offset, + self.config_obj.plot_caption, + fontproperties=caption, color=self.config_obj.caption_color + ) # Add a figure legend @@ -329,25 +321,21 @@ def _create_figure(self) -> None: frameon=self.config_obj.draw_box) plt.tight_layout() - plt.plot() - # Save the figure, based on whether we are displaying only positive - # correlations or all - # correlations. os.makedirs(os.path.dirname(self.config_obj.output_image), exist_ok=True) + + # Save the figure, based on whether we are displaying only positive + # correlations or all correlations. + plot_args = {} if pos_correlation_only: # Setting the bbox_inches keeps the legend box always within the plot - # boundaries. This *may* result - # in a distorted plot. - plt.savefig(self.config_obj.output_image, - dpi=self.config_obj.plot_resolution, bbox_inches="tight") - else: + # boundaries. This *may* result in a distorted plot. # setting bbox_inches causes a loss in the title, especially when there - # are numerous legend - # items. The legend inset 'y' value will likely need to + # are numerous legend items. The legend inset 'y' value will likely need to # be modified to keep all legend items on the plot. - plt.savefig(self.config_obj.output_image, - dpi=self.config_obj.plot_resolution) + plot_args['bbox_inches'] = "tight" + + self.save_to_file(**plot_args) def main(config_filename=None): diff --git a/metplotpy/plots/taylor_diagram/taylor_diagram_config.py b/metplotpy/plots/taylor_diagram/taylor_diagram_config.py index fd556cb40..9e94efe69 100644 --- a/metplotpy/plots/taylor_diagram/taylor_diagram_config.py +++ b/metplotpy/plots/taylor_diagram/taylor_diagram_config.py @@ -64,8 +64,8 @@ def __init__(self, parameters: dict) -> None: # Convert the plot height and width to inches if units aren't in # inches. if self.plot_units.lower() != 'in': - self.plot_width = self.calculate_plot_dimension('plot_width', 'in') - self.plot_height = self.calculate_plot_dimension('plot_height', 'in') + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') else: self.plot_width = self.get_config_value('plot_width') self.plot_height = self.get_config_value('plot_height') @@ -233,54 +233,6 @@ def _get_series_order(self): series_order_list = [ord for ord in ordinals] return series_order_list - def _get_plot_disp(self) -> list: - """ - Retrieve the boolean values that determine whether to display a particular series - - Args: - - Returns: - A list of boolean values indicating whether or not to - display the corresponding series - """ - - plot_display_vals = self.get_config_value('plot_disp') - plot_display_strings = [pd for pd in plot_display_vals] - plot_display_strings_ordered = self.create_list_by_series_ordering(plot_display_strings) - - # Convert each string to the boolean representation - plot_display_bools_ordered = [] - for cur in plot_display_strings_ordered: - bool_str = str(cur).lower() - if bool_str == 'true': - plot_display_bools_ordered.append(True) - else: - plot_display_bools_ordered.append(False) - - return plot_display_bools_ordered - - def _get_markers(self) -> list: - """ - Retrieve all the markers. - - Args: - - Returns: - markers: a list of the markers - """ - markers = self.get_config_value('series_symbols') - markers_list = [] - for marker in markers: - if marker in constants.AVAILABLE_MARKERS_LIST: - # markers is the matplotlib symbol: .,o, ^, d, H, or s - markers_list.append(marker) - else: - # markers are indicated by name: small circle, circle, triangle, - # diamond, hexagon, square - markers_list.append(constants.PCH_TO_MATPLOTLIB_MARKER[marker.lower()]) - markers_list_ordered = self.create_list_by_series_ordering(list(markers_list)) - return markers_list_ordered - def _config_consistency_check(self) -> bool: """ Checks that the number of settings defined for diff --git a/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py b/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py index ec0861118..b10e16b85 100755 --- a/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py +++ b/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py @@ -1,7 +1,6 @@ import os from datetime import datetime - -import plotly.graph_objects as go +import numpy as np from metplotpy.plots.tcmpr_plots.box.tcmpr_box_point import TcmprBoxPoint from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries @@ -15,9 +14,9 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, baseline_d # Set up Logging self.box_logger = util.get_common_logger(self.config_obj.log_level, self.config_obj.log_filename) - self.box_logger.info(f"--------------------------------------------------------\n") + self.box_logger.info("--------------------------------------------------------\n") self.box_logger.info(f"Plotting BOXPLOT time series by {self.config_obj.series_val_names[0]}") - self._adjust_titles(stat_name) + self._adjust_titles(stat_name, 'Boxplots of') self.series_list = self._create_series(self.input_df, stat_name) self.case_data = None self.cur_baseline = baseline_data['cur_baseline'] @@ -36,68 +35,65 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, baseline_d os.remove(self.plot_filename) self._create_figure() - def _adjust_titles(self, stat_name): - if self.yaxis_1 is None or len(self.yaxis_1) == 0: - # ToDo Remove when done DEBUGGING - # self.yaxis_1 = self.config_obj.list_stat_1[0] + '(' + self.col['units'] + ')' - self.yaxis_1 = stat_name + '(' + self.col['units'] + ')' - - if self.title is None or len(self.title) == 0: - self.title = 'Boxplots of ' + self.col['desc'] + ' by ' \ - + self.column_info[self.column_info['COLUMN'] == self.config_obj.series_val_names[0]][ - "DESCRIPTION"].tolist()[0] - - def _draw_series(self, series: TcmprSeries) -> None: + def _draw_series(self, series: TcmprSeries): """ Draws the boxes on the plot :param series: Line series object with data and parameters """ - start_time = datetime.now() - # defaults markers and colors for the regular box plot - line_color = dict(color='rgb(0,0,0)') - marker_color = series.color - marker_line_color = series.color - if len([x for x in series.series_data['PLOT'].tolist() if x is not None]) < self.config_obj.n_min: - line_color = dict(color='rgba(0,0,0,0)') - fillcolor = 'rgba(0,0,0,0)' - marker_symbol = 'circle' + # Calculate positions for grouping + x_points = self.config_obj.indy_vals + x_locs, width = self._get_x_locs_and_width(x_points, series.idx) + + # Prepare data for boxplot + data_to_plot = [] + for x in x_points: + point_data = series.series_data.loc[series.series_data['LEAD_HR'] == x, 'PLOT'].tolist() + # remove None/NaN + data_to_plot.append([v for v in point_data if v is not None and not np.isnan(v)]) + + if series.color == 'rgb(0,0,0)' or series.color == 'black' or series.color == '#000000': + fillcolor = '#ffffff' else: - if series.color == 'rgb(0,0,0)' or series.color == 'black' or series.color == '#000000': - fillcolor = '#ffffff' - else: - fillcolor = series.color - marker_symbol = 'circle-open' - - # create a trace - self.figure.add_trace( - go.Box(x=series.series_data['LEAD_HR'], - y=series.series_data['PLOT'], - mean=series.series_points['mean'], - notched=self.config_obj.box_notch, - line=line_color, - fillcolor=fillcolor, - name=series.user_legends, - showlegend=True, - # quartilemethod='linear', #"exclusive", "inclusive", or "linear" - boxmean=self.config_obj.box_avg, - boxpoints='outliers', # outliers, all, False - pointpos=0, - marker=dict(size=4, - color=marker_color, - line=dict( - width=1, - color=marker_line_color - ), - symbol=marker_symbol, - ), - jitter=0 - ), - secondary_y=series.y_axis != 1 - ) + fillcolor = series.color + + ax = self.ax if series.y_axis == 1 else self.ax2 + + # Define properties for median and mean lines + median_props = { + 'color': 'black', + 'linewidth': 1, + } + mean_props = { + 'linestyle': ':', + 'color': 'black', + 'linewidth': 1, + } + + if len([x for x in series.series_data['PLOT'].tolist() if x is not None]) < self.config_obj.n_min: + return None + + boxplot = ax.boxplot(data_to_plot, positions=x_locs, widths=width, + patch_artist=True, + label=self.config_obj.user_legends[series.idx], + notch=self.config_obj.box_notch, + showmeans=self.config_obj.box_avg, + meanline=self.config_obj.box_avg, + medianprops=median_props, + meanprops=mean_props, + flierprops={'markeredgecolor': series.color}, + ) + + for patch in boxplot['boxes']: + patch.set_facecolor(fillcolor) + patch.set_edgecolor('black') + + # add a proxy for legend + #ax.plot([], [], color=fillcolor, label=series.user_legends, marker='s', linestyle='None') end_time = datetime.now() total_time = end_time - start_time self.box_logger.debug(f"Drawing series points took {total_time} millisecs") + return boxplot['boxes'][0] diff --git a/metplotpy/plots/tcmpr_plots/box/tcmpr_box_point.py b/metplotpy/plots/tcmpr_plots/box/tcmpr_box_point.py index bff7b448f..478ef4286 100755 --- a/metplotpy/plots/tcmpr_plots/box/tcmpr_box_point.py +++ b/metplotpy/plots/tcmpr_plots/box/tcmpr_box_point.py @@ -19,66 +19,47 @@ def _init_hfip_baseline_for_plot(self): self.cur_baseline_data = self.cur_baseline_data[(self.cur_baseline_data['TYPE'] == 'CONS')] self.box_point_logger.info(f"Plot HFIP Baseline: {self.cur_baseline.replace('Error ', '')}") - def _draw_series(self, series: TcmprSeries) -> None: - pass - def _create_figure(self): """ Create a box plot from default and custom parameters""" start_time = datetime.datetime.now() - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() + handles_and_labels = [] + + super()._create_figure() # placeholder for the min and max values for y-axis yaxis_min = None yaxis_max = None - if self.config_obj.xaxis_reverse is True: - self.series_list.reverse() - - # add x ticks for line plots - self.figure.update_layout( - xaxis={ - 'tickmode': 'array', - 'tickvals': self.config_obj.indy_vals, - 'ticktext': self.config_obj.indy_label - } - ) - for series in self.series_list: # Don't generate the plot for this series if # it isn't requested (as set in the config file) if series.plot_disp: # collect min-max if we need to sync axis yaxis_min, yaxis_max = self.find_min_max(series, yaxis_min, yaxis_max) - self._draw_series(series) + handle = self._draw_series(series) + if handle is not None: + handles_and_labels.append((handle, handle.get_label())) - self.box_point_logger.info(f'Range of {self.config_obj.list_stat_1[0]}: {yaxis_min}, {yaxis_max}') - self._add_hfip_baseline() + self._add_xaxis() + self._add_yaxis() + self._add_legend(self.ax, handles_and_labels) - self.figure.update_layout(shapes=[dict( - type='line', - yref='y', y0=0, y1=0, - xref='paper', x0=0, x1=0.95, - line={'color': '#727273', - 'dash': 'dot', - 'width': 1}, - )]) + self.box_point_logger.info(f'Range of {self.stat_name}: {yaxis_min}, {yaxis_max}') + self._add_hfip_baseline(self.ax) - self.figure.update_layout(boxmode='group') + self.ax.axhline(0, color='#727273', linestyle=':', linewidth=1) # add custom lines - if len(self.series_list) > 0: + if len(self.series_list) > 0 and self.config_obj.indy_var != '': self._add_lines( + self.ax, self.config_obj, sorted(self.series_list[0].series_data[self.config_obj.indy_var].unique()) ) - # apply y axis limits - self._yaxis_limits() # add x2 axis - self._add_x2axis(self.config_obj.indy_vals) + self._add_x2axis() + end_time = datetime.datetime.now() total_time = end_time - start_time self.box_point_logger.info(f"Took {total_time} millisecs to create a boxplot") diff --git a/metplotpy/plots/tcmpr_plots/box/tcmpr_point.py b/metplotpy/plots/tcmpr_plots/box/tcmpr_point.py index 44cab41b3..44c91e327 100755 --- a/metplotpy/plots/tcmpr_plots/box/tcmpr_point.py +++ b/metplotpy/plots/tcmpr_plots/box/tcmpr_point.py @@ -1,9 +1,7 @@ import os from datetime import datetime -import plotly.graph_objects as go - -from metplotpy.plots import util +from metplotpy.plots import util as util from metplotpy.plots.tcmpr_plots.box.tcmpr_box_point import TcmprBoxPoint from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries @@ -18,7 +16,7 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, baseline_d self.point_logger.info(f"Plotting POINT time series by {self.config_obj.series_val_names[0]}") start = datetime.now() - self._adjust_titles(stat_name) + self._adjust_titles(stat_name, 'Point Plots of') self.series_list = self._create_series(self.input_df, stat_name) self.case_data = None self.cur_baseline = baseline_data['cur_baseline'] @@ -37,15 +35,6 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, baseline_d self.point_logger.info(f"Finished generating the TCMPR points in {datetime.now() - start} ms") - def _adjust_titles(self, stat_name): - if self.yaxis_1 is None or len(self.yaxis_1) == 0: - self.yaxis_1 = stat_name + '(' + self.col['units'] + ')' - - if self.title is None or len(self.title) == 0: - self.title = 'Point Plots of ' + self.col['desc'] + ' by ' \ - + self.column_info[self.column_info['COLUMN'] == self.config_obj.series_val_names[0]][ - "DESCRIPTION"].tolist()[0] - def _draw_series(self, series: TcmprSeries) -> None: """ Draws the boxes on the plot @@ -53,90 +42,21 @@ def _draw_series(self, series: TcmprSeries) -> None: :param series: Line series object with data and parameters """ - # defaults markers and colors for the regular box plot - marker_color = series.color - marker_line_color = series.color - # Point plot - - line_color = dict(color='rgba(0,0,0,0)') - fillcolor = 'rgba(0,0,0,0)' - marker_symbol = 'circle' - boxpoints = 'all' - - # create a trace + ax = self.ax if series.y_axis == 1 else self.ax2 # line plot, when connect_points is False in config file - if 'point' in self.config_obj.plot_type_list: - if self.config_obj.connect_points: - # line plot - mode = 'lines+markers' - else: - # points only - mode = 'markers' - # Create a point plot - - # Ensure that the size of the list of x and y values - # are the same, or the resulting plot will be incorrect. - # This mismatch occurs when the x_list represents the - # available lead hours in the series data and the - # series_points has None where there isn't data corresponding - # to lead hours in the series_points dataframe. - # - y_list = series.series_points['mean'] - x_list = series.series_data['LEAD_HR'] - if len(x_list) != len(y_list): - # Clean up None values in the series.series_points['mean'] list - # The None values are assigned by the _create_series_points() method. - y_list = [y_values for y_values in y_list if y_values is not None] - - self.figure.add_trace( - go.Scatter(x=x_list, - y=y_list, - showlegend=True, - mode=mode, - name=self.config_obj.user_legends[series.idx], - marker=dict( - color=marker_line_color, - size=8, - opacity=0.7, - line=dict( - color=self.config_obj.colors_list[series.idx], - width=1 - ) - ), - ), - secondary_y=series.y_axis != 1 - ) - - # When a line plot is requested, connect any gaps - if self.config_obj.connect_points: - self.figure.update_traces(connectgaps=True) - - else: - # Boxplot - self.figure.add_trace( - go.Box(x=series.series_data['LEAD_HR'], - y=series.series_data['PLOT'], - mean=series.series_points['mean'], - notched=self.config_obj.box_notch, - line=line_color, - fillcolor=fillcolor, - name=series.user_legends, - showlegend=True, - boxmean=self.config_obj.box_avg, - boxpoints=boxpoints, # outliers, all, False - pointpos=0, - marker=dict(size=4, - color=marker_color, - line=dict( - width=1, - color=marker_line_color - ), - symbol=marker_symbol, - ), - jitter=0 - ), - secondary_y=series.y_axis != 1 - ) - + if 'point' not in self.config_obj.plot_type_list: + return None + + y_list = series.series_points['mean'] + x_list = series.series_data['LEAD_HR'] + if len(x_list) != len(y_list): + # Clean up None values in the series.series_points['mean'] list + y_list = [y_values for y_values in y_list if y_values is not None] + + plot_obj = ax.plot(x_list, y_list, marker='o', label=series.user_legends, + linestyle='-' if self.config_obj.connect_points else 'None', + color=series.color, + ) + return plot_obj[0] diff --git a/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_line_mean.py b/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_line_mean.py index e73a9608c..e7aaf41af 100755 --- a/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_line_mean.py +++ b/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_line_mean.py @@ -3,7 +3,7 @@ from metplotpy.plots.tcmpr_plots.line.mean.tcmpr_series_line_mean import TcmprSeriesLineMean from metplotpy.plots.tcmpr_plots.line.tcmpr_line import TcmprLine -import metplotpy.plots.util as util +from metplotpy.plots import util as util class TcmprLineMean(TcmprLine): @@ -13,10 +13,10 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, baseline_d # Set up Logging self.line_logger = util.get_common_logger(self.config_obj.log_level, self.config_obj.log_filename) - self.line_logger.info(f"--------------------------------------------------------") + self.line_logger.info("--------------------------------------------------------") self.line_logger.info(f"Plotting MEAN time series by {self.config_obj.series_val_names[0]}") - self._adjust_titles(stat_name) + self._adjust_titles(stat_name, 'Mean of') self.series_list = self._create_series(self.input_df, stat_name) self.case_data = None self.cur_baseline = baseline_data['cur_baseline'] @@ -32,16 +32,6 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, baseline_d os.remove(self.plot_filename) self._create_figure(stat_name) - def _adjust_titles(self, stat_name): - if self.yaxis_1 is None or len(self.yaxis_1) == 0: - # self.yaxis_1 = self.config_obj.list_stat_1[0] + '(' + self.col['units'] + ')' - self.yaxis_1 = stat_name + '(' + self.col['units'] + ')' - - if self.title is None or len(self.title) == 0: - self.title = 'Mean of ' + self.col['desc'] + ' by ' \ - + self.column_info[self.column_info['COLUMN'] == self.config_obj.series_val_names[0]][ - "DESCRIPTION"].tolist()[0] - def _init_hfip_baseline_for_plot(self): if 'Water Only' in self.config_obj.title or self.cur_baseline == 'no': self.line_logger.info(f"Plot HFIP Baseline: {self.cur_baseline}") @@ -104,6 +94,10 @@ def _create_series(self, input_data, stat_name): # reorder series series_list = self.config_obj.create_list_by_series_ordering(series_list) + # reverse series list if config is set to reverse x-axis + if self.config_obj.xaxis_reverse: + series_list.reverse() + end_time = datetime.now() total_time = end_time - start_time self.line_logger.info(f"Took {total_time} milliseconds to create series for {stat_name}") diff --git a/metplotpy/plots/tcmpr_plots/line/median/tcmpr_line_median.py b/metplotpy/plots/tcmpr_plots/line/median/tcmpr_line_median.py index a5cced495..bb90e8b04 100755 --- a/metplotpy/plots/tcmpr_plots/line/median/tcmpr_line_median.py +++ b/metplotpy/plots/tcmpr_plots/line/median/tcmpr_line_median.py @@ -13,11 +13,11 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name) # Set up Logging self.linemd_logger = util.get_common_logger(self.config_obj.log_level, self.config_obj.log_filename) - self.linemd_logger.info(f"--------------------------------------------------------") + self.linemd_logger.info("--------------------------------------------------------") self.linemd_logger.info(f"Plotting MEDIAN time series by {self.config_obj.series_val_names[0]}") self.linemd_logger.info(f"Plot HFIP Baseline: {self.cur_baseline}") - self._adjust_titles(stat_name) + self._adjust_titles(stat_name, 'Median of') self.series_list = self._create_series(self.input_df, stat_name) self.case_data = None if self.config_obj.prefix is None or len(self.config_obj.prefix) == 0: @@ -29,15 +29,6 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name) os.remove(self.plot_filename) self._create_figure(stat_name) - def _adjust_titles(self, stat_name): - if self.yaxis_1 is None or len(self.yaxis_1) == 0: - self.yaxis_1 = stat_name + '(' + self.col['units'] + ')' - - if self.title is None or len(self.title) == 0: - self.title = 'Median of ' + self.col['desc'] + ' by ' \ - + self.column_info[self.column_info['COLUMN'] == self.config_obj.series_val_names[0]][ - "DESCRIPTION"].tolist()[0] - def _create_series(self, input_data, stat_name): """ Generate all the series objects that are to be displayed as specified by the plot_disp @@ -90,6 +81,10 @@ def _create_series(self, input_data, stat_name): # reorder series series_list = self.config_obj.create_list_by_series_ordering(series_list) + # reverse series list if config is set to reverse x-axis + if self.config_obj.xaxis_reverse: + series_list.reverse() + end_time = datetime.now() total_time = end_time - start_time self.linemd_logger.info(f"Took {total_time} milliseconds to create series for {stat_name}") diff --git a/metplotpy/plots/tcmpr_plots/line/tcmpr_line.py b/metplotpy/plots/tcmpr_plots/line/tcmpr_line.py index f759e839f..879785248 100755 --- a/metplotpy/plots/tcmpr_plots/line/tcmpr_line.py +++ b/metplotpy/plots/tcmpr_plots/line/tcmpr_line.py @@ -1,9 +1,8 @@ -import plotly.graph_objects as go from datetime import datetime from metplotpy.plots.tcmpr_plots.tcmpr import Tcmpr from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries -import metplotpy.plots.util as util +from metplotpy.plots import util as util class TcmprLine(Tcmpr): def __init__(self, config_obj, column_info, col, case_data, input_df, baseline_data, stat_name): @@ -14,73 +13,56 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, baseline_d def _create_figure(self, stat_name): """ Create a box plot from default and custom parameters""" - start_time = datetime.now() + handles_and_labels = [] - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() + super()._create_figure() # placeholder for the min and max values for y-axis yaxis_min = None yaxis_max = None - if self.config_obj.xaxis_reverse is True: - self.series_list.reverse() - # calculate stag adjustments - stag_adjustments = self._calc_stag_adjustments() - - x_points_index = list(range(0, len(self.config_obj.indy_vals))) - # add x ticks for line plots - odered_indy_label = self.config_obj.create_list_by_plot_val_ordering(self.config_obj.indy_label) - self.figure.update_layout( - xaxis={ - 'tickmode': 'array', - 'tickvals': x_points_index, - 'ticktext': odered_indy_label - } - ) - for series in self.series_list: # Don't generate the plot for this series if # it isn't requested (as set in the config file) - if series.plot_disp: - # collect min-max if we need to sync axis - yaxis_min, yaxis_max = self.find_min_max(series, yaxis_min, yaxis_max) - x_points_index_adj = x_points_index + stag_adjustments[series.idx] - self._draw_series(series, x_points_index_adj) + if not series.plot_disp: + continue + + # collect min-max if we need to sync axis + yaxis_min, yaxis_max = self.find_min_max(series, yaxis_min, yaxis_max) + x_points_index_adj, _ = self._get_x_locs_and_width(self.config_obj.indy_vals, + series.idx, + stagger_scale=0.1) + handle = self._draw_series(series, x_points_index_adj) + if handle is not None: + handles_and_labels.append((handle, handle.get_label())) self.line_logger.info(f'Range of {stat_name}: {yaxis_min}, {yaxis_max}') - self._add_hfip_baseline() + self._add_hfip_baseline(self.ax) - self.figure.update_layout(shapes=[dict( - type='line', - yref='y', y0=0, y1=0, - xref='paper', x0=0, x1=0.95, - line={'color': '#727273', - 'dash': 'dot', - 'width': 1}, - )]) + self.ax.axhline(0, color='#727273', linestyle=':', linewidth=1) # add custom lines if len(self.series_list) > 0: self._add_lines( + self.ax, self.config_obj, sorted(self.series_list[0].series_data[self.config_obj.indy_var].unique()) ) - # apply y axis limits - self._yaxis_limits() + + self._add_xaxis() + self._add_yaxis() + self._add_legend(self.ax, handles_and_labels) # add x2 axis - self._add_x2axis(list(range(0, len(self.config_obj.indy_vals)))) + self._add_x2axis() end_time = datetime.now() total_time = end_time - start_time self.line_logger.info(f"Took {total_time} milliseconds to create figure for {stat_name}") - def _draw_series(self, series: TcmprSeries, x_points_index_adj: list) -> None: + def _draw_series(self, series: TcmprSeries, x_points_index_adj: list): """ Draws the boxes on the plot @@ -95,35 +77,42 @@ def _draw_series(self, series: TcmprSeries, x_points_index_adj: list) -> None: no_ci_up = all(v == 0 for v in series.series_points['ncu']) no_ci_lo = all(v == 0 for v in series.series_points['ncl']) error_y_visible = True - if (no_ci_up is True and no_ci_lo is True) or self.config_obj.series_ci[series.idx] == 'NONE' or \ - self.config_obj.series_ci[series.idx] is False: + if ((no_ci_up and no_ci_lo) + or self.config_obj.series_ci[series.idx] == 'NONE' + or not self.config_obj.series_ci[series.idx]): error_y_visible = False - # create a trace - self.figure.add_trace( - go.Scatter(x=x_points_index_adj, - y=y_points, - showlegend=True, - mode='lines+markers', - textposition="top right", - name=self.config_obj.user_legends[series.idx], - line={'color': self.config_obj.colors_list[series.idx], - 'width': self.config_obj.linewidth_list[series.idx], - 'dash': self.config_obj.linestyles_list[series.idx]}, - marker_symbol=self.config_obj.marker_list[series.idx], - marker_color=self.config_obj.colors_list[series.idx], - marker_line_color=self.config_obj.colors_list[series.idx], - marker_size=self.config_obj.marker_size[series.idx], - error_y={'type': 'data', - 'symmetric': False, - 'array': series.series_points['ncu'], - 'arrayminus': series.series_points['ncl'], - 'visible': error_y_visible, - 'thickness': self.config_obj.linewidth_list[series.idx]} - ), - secondary_y=series.y_axis != 1 + + ax = self.ax if series.y_axis == 1 else self.ax2 + + yerr = None + if error_y_visible: + yerr = [series.series_points['ncl'], series.series_points['ncu']] + + markerfacecolor = self.config_obj.colors_list[series.idx] + if self.config_obj.marker_open_list[series.idx]: + markerfacecolor = 'none' + + plot = ax.errorbar( + x=x_points_index_adj, + y=y_points, + label=self.config_obj.user_legends[series.idx], + # line style + color=self.config_obj.colors_list[series.idx], + linewidth=self.config_obj.linewidth_list[series.idx], + linestyle=self.config_obj.linestyles_list[series.idx], + # marker style + marker=self.config_obj.marker_list[series.idx], + markersize=self.config_obj.marker_size[series.idx], + markeredgecolor=self.config_obj.colors_list[series.idx], + markerfacecolor=markerfacecolor, + # error bar + yerr=yerr, + elinewidth=self.config_obj.linewidth_list[series.idx], + capsize=5, ) end_time = datetime.now() total_time = end_time - start_time self.line_logger.info( - f"Took {total_time} milliseconds to draw the series for one of the series values in: {series.series_vals_1}") \ No newline at end of file + f"Took {total_time} milliseconds to draw the series for one of the series values in: {series.series_vals_1}") + return plot[0] diff --git a/metplotpy/plots/tcmpr_plots/rank/tcmpr_rank.py b/metplotpy/plots/tcmpr_plots/rank/tcmpr_rank.py index 8d348dccc..60e81dacd 100755 --- a/metplotpy/plots/tcmpr_plots/rank/tcmpr_rank.py +++ b/metplotpy/plots/tcmpr_plots/rank/tcmpr_rank.py @@ -13,14 +13,12 @@ import os import datetime - -import plotly.graph_objects as go +import matplotlib.pyplot as plt from metplotpy.plots.tcmpr_plots.tcmpr import Tcmpr -from metplotpy.plots.tcmpr_plots.tcmpr_config import TcmprConfig from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries from metplotpy.plots.tcmpr_plots.tcmpr_util import get_case_data -import metplotpy.plots.util as util +from metplotpy.plots import util as util class TcmprRank(Tcmpr): @@ -43,10 +41,10 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name) # Set up Logging self.rank_logger = util.get_common_logger(self.config_obj.log_level, self.config_obj.log_filename) - self.rank_logger.info(f"--------------------------------------------------------") + self.rank_logger.info("--------------------------------------------------------") if not self.config_obj.use_ee: - raise Exception("ERROR: Cannot plot relative rank frequency when event equalization is disabled.") + raise ValueError("ERROR: Cannot plot relative rank frequency when event equalization is disabled.") self.rank_logger.info("Creating Rank plot") self.rank_logger.info("Plot HFIP Baseline:" + self.cur_baseline) @@ -63,115 +61,89 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name) self.case_data = get_case_data(self.input_df, self.config_obj.series_vals_1, self.config_obj.indy_vals, self.config_obj.rp_diff, len(self.series_list)) - if self.config_obj.prefix is None or len(self.config_obj.prefix) == 0: - self.plot_filename = f"{self.config_obj.plot_dir}{os.path.sep}{stat_name}_rank.png" - else: - self.plot_filename = f"{self.config_obj.plot_dir}{os.path.sep}{self.config_obj.prefix}_{stat_name}_rank.png" + self.plot_filename = f"{stat_name}_rank.png" + if self.config_obj.prefix: + self.plot_filename = f"{self.config_obj.prefix}_{self.plot_filename}" + self.plot_filename = os.path.join(self.config_obj.plot_dir, self.plot_filename) self.rank_logger.info(f"Plot will be saved as {self.plot_filename}" ) # remove the old file if it exists if os.path.exists(self.plot_filename): os.remove(self.plot_filename) - # create figure - # pylint:disable=assignment-from-no-return - # Need to have a self.figure that we can pass along to - # the methods in base_plot.py (BasePlot class methods) to - # create binary versions of the plot. + self.rank_logger.info(f"Creating figure {datetime.datetime.now()}") self._create_figure(stat_name) - - def _adjust_titles(self, stat_name): - if self.yaxis_1 is None or len(self.yaxis_1) == 0: - self.yaxis_1 = f'Percent of Cases for {stat_name}' - - if self.title is None or len(self.title) == 0: - self.title = self.config_obj.series_vals_1[0][0] + ' ' + \ - self.column_info[ - self.column_info['COLUMN'] == self.config_obj.series_val_names[0]][ - "DESCRIPTION"].tolist()[0] + ' ' \ - + self.col['desc'] + 'Rank Frequency' + def _adjust_titles(self, y_label, title_prefix=None, title_suffix=None, add_units=True): + series_val_name = self.column_info[self.column_info['COLUMN'] == self.config_obj.series_val_names[0]]["DESCRIPTION"].tolist()[0] + title_prefix = f"{self.config_obj.series_vals_1[0][0]} {series_val_name}" if title_prefix is None else title_prefix + title_suffix = "Rank Frequency" if title_suffix is None else title_suffix + super()._adjust_titles(f"Percent of Cases for {y_label}", title_prefix, title_suffix, add_units=False) def _create_figure(self, stat_name): - - self.rank_logger.info(f"Creating the rank plot figure...") """ Create a box plot from default and custom parameters""" + self.rank_logger.info("Creating the rank plot figure...") + handles_and_labels = [] start_time = datetime.datetime.now() - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() - - if self.config_obj.xaxis_reverse is True: - self.series_list.reverse() - # calculate stag adjustments - stag_adjustments = self._calc_stag_adjustments() - - x_points_index = list(range(0, len(self.config_obj.indy_vals))) - # add x ticks for line plots - self.figure.update_layout( - xaxis={ - 'tickmode': 'array', - 'tickvals': x_points_index, - 'ticktext': self.config_obj.indy_label - } - ) + super()._create_figure() rank_str = ["Best", "2nd", "3rd", "Worst"] n_series = len(self.config_obj.get_series_y(1)) rank_str_index = min(3, n_series - 1) legend_str = rank_str[0: rank_str_index] if n_series >= 5: - for i in range(4, n_series - 1, -1): + for i in range(4, n_series, 1): legend_str.append(str(i) + "th") - legend_str.append(rank_str[3]) + if n_series > 3: + legend_str.append(rank_str[3]) self.config_obj.user_legends = legend_str + yaxis_min = None yaxis_max = None for idx, series in enumerate(self.series_list): # Don't generate the plot for this series if # it isn't requested (as set in the config file) - if series.plot_disp: - x_points_index_adj = x_points_index + stag_adjustments[series.idx] - series.create_rank_points(self.case_data) - yaxis_min, yaxis_max = self.find_min_max(series, yaxis_min, yaxis_max) - self.rank_logger.info(f"Drawing series for {stat_name} and {series.series_vals_1[idx-1][idx]}") - self._draw_series(series, x_points_index_adj) + if not series.plot_disp: + continue + + x_points_index_adj, _ = self._get_x_locs_and_width(self.config_obj.indy_vals, + series.idx, + stagger_scale=0.1) + series.create_rank_points(self.case_data) + yaxis_min, yaxis_max = self.find_min_max(series, yaxis_min, yaxis_max) + self.rank_logger.info(f"Drawing series for {stat_name}") + handle = self._draw_series(series, x_points_index_adj) + if handle is not None: + handles_and_labels.append((handle, handle.get_label())) # Draw a reference line at 100/n_series - self.figure.add_hline(y=100 / len(self.series_list), line_width=1, line_dash="solid", line_color="#e5e7e9") + self.ax.axhline(y=100 / len(self.series_list), color="#e5e7e9", linestyle="-", linewidth=1) self.rank_logger.info(f'Range of {stat_name}: {yaxis_min}, {yaxis_max}') - # Draw an invisible line to create a CI legend - self.figure.add_trace( - go.Scatter(x=[0], - y=[0], - showlegend=True, - mode='lines', - visible='legendonly', - line={'color': '#7b7d7d', - 'width': 1, - 'dash': 'dot'}, - name=str(int(100 * (1 - self.config_obj.alpha))) + '% CI' - ) - ) + + # add CI legend proxy + self.ax.plot([], [], color='#7b7d7d', linestyle=':', linewidth=1, label=str(int(100 * (1 - self.config_obj.alpha))) + '% CI') # add custom lines if len(self.series_list) > 0: self._add_lines( + self.ax, self.config_obj, sorted(self.series_list[0].series_data[self.config_obj.indy_var].unique()) ) - # apply y axis limits - self._yaxis_limits() + + self._add_xaxis() + self._add_yaxis() + self._add_legend(self.ax, handles_and_labels) + # add x2 axis - self._add_x2axis(list(range(0, len(self.config_obj.indy_vals)))) + self._add_x2axis() end_time = datetime.datetime.now() total_time = end_time - start_time self.rank_logger.info(f"Creating rank plot figure took {total_time} milliseconds") - def _draw_series(self, series: TcmprSeries, x_points_index_adj: list) -> None: + def _draw_series(self, series: TcmprSeries, x_points_index_adj: list): """ Draws the line on the plot @@ -190,74 +162,34 @@ def _draw_series(self, series: TcmprSeries, x_points_index_adj: list) -> None: no_ci_up = all(v is None or v == 0 for v in series.series_points['ncu']) no_ci_lo = all(v is None or v == 0 for v in series.series_points['ncl']) error_y_visible = True - if (no_ci_up is True and no_ci_lo is True) or series_ci is False: + if (no_ci_up and no_ci_lo) or not series_ci: error_y_visible = False - rank_min_text = [str(series.idx + 1)] * len(x_points_index_adj) - - # create a trace - self.figure.add_trace( - go.Scatter(x=x_points_index_adj, - y=y_points, - showlegend=True, - mode='lines+text', - textposition="middle center", - name=self.config_obj.user_legends[series.idx], - line={'color': color, - 'width': width, - 'dash': dash}, - text=rank_min_text, - textfont={ - 'size': 18, - 'color': color - } - ) - ) - - if error_y_visible is True: - # add ci lo - self.figure.add_trace( - go.Scatter(x=x_points_index_adj, - y=series.series_points['ncl'], - showlegend=False, - mode='lines', - name=self.config_obj.user_legends[series.idx], - line={'color': color, - 'width': width, - 'dash': 'dot'} - ) - ) + ax = self.ax if series.y_axis == 1 else self.ax2 - # add ci up - self.figure.add_trace( - go.Scatter(x=x_points_index_adj, - y=series.series_points['ncu'], - showlegend=False, - mode='lines', - name=self.config_obj.user_legends[series.idx], - line={'color': color, - 'width': width, - 'dash': 'dot'} - ) - ) + # Plot the main line with text markers + plot_obj = ax.plot(x_points_index_adj, y_points, label=self.config_obj.user_legends[series.idx], + color=color, linewidth=width, linestyle=dash) + + # Add text markers + for i, y in enumerate(y_points): + if y is not None: + ax.text(x_points_index_adj[i], y, str(series.idx + 1), + color=color, fontsize=12, ha='center', va='center', weight='bold') + + if error_y_visible: + # add ci lo and up as dotted lines + ax.plot(x_points_index_adj, series.series_points['ncl'], color=color, linewidth=width, linestyle=':') + ax.plot(x_points_index_adj, series.series_points['ncu'], color=color, linewidth=width, linestyle=':') # For the BEST and WORST series, plot the RANK_MIN values if len(series.rank_min_val) > 0: - self.figure.add_trace( - go.Scatter( - x=x_points_index_adj, - y=series.rank_min_val, - showlegend=False, - mode="text", - name="RANK MIN", - text=rank_min_text, - textfont={ - 'size': 18 - }, - textposition="middle center" - ), - secondary_y=series.y_axis != 1 - ) + for i, y in enumerate(series.rank_min_val): + if y is not None: + ax.text(x_points_index_adj[i], y, str(series.idx + 1), + color='black', fontsize=12, ha='center', va='center') + end_time = datetime.datetime.now() total_time = end_time - start_time - self.rank_logger.debug(f"Drawing series points took {total_time} millisecs") \ No newline at end of file + self.rank_logger.debug(f"Drawing series points took {total_time} millisecs") + return plot_obj[0] diff --git a/metplotpy/plots/tcmpr_plots/relperf/tcmpr_relperf.py b/metplotpy/plots/tcmpr_plots/relperf/tcmpr_relperf.py index f6c350896..6c85d029c 100755 --- a/metplotpy/plots/tcmpr_plots/relperf/tcmpr_relperf.py +++ b/metplotpy/plots/tcmpr_plots/relperf/tcmpr_relperf.py @@ -1,18 +1,12 @@ import os -from typing import Union from datetime import datetime import numpy as np -from pandas import DataFrame -import plotly.graph_objects as go - from metcalcpy.util import utils -from metplotpy.plots.series import Series from metplotpy.plots.tcmpr_plots.tcmpr import Tcmpr from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries from metplotpy.plots.tcmpr_plots.tcmpr_util import get_case_data -import metplotpy.plots.util as util - +from metplotpy.plots import util as util class TcmprRelPerf(Tcmpr): @@ -21,11 +15,11 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name) # Set up Logging self.relperf_logger = util.get_common_logger(self.config_obj.log_level, self.config_obj.log_filename) - self.relperf_logger.info(f"--------------------------------------------------------") + self.relperf_logger.info("--------------------------------------------------------") if not self.config_obj.use_ee: - self.relpef_logger.error(f"Plotting RELPERF time series by {self.config_obj.series_val_names[0]}") - raise Exception("ERROR: Cannot plot relative performance when event equalization is disabled.") + self.relperf_logger.error(f"Plotting RELPERF time series by {self.config_obj.series_val_names[0]}") + raise ValueError("ERROR: Cannot plot relative performance when event equalization is disabled.") self.relperf_logger.info(f"Plotting RELPERF time series by {stat_name}") @@ -81,26 +75,10 @@ def _create_user_legends(self, stat_name): def _create_figure(self, stat_name): """ Create a box plot from default and custom parameters""" - start_time = datetime.now() - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() + handles_and_labels = [] + super()._create_figure() - if self.config_obj.xaxis_reverse is True: - self.series_list.reverse() - - x_points_index = list(range(0, len(self.config_obj.indy_vals))) - # add x ticks for line plots - - self.figure.update_layout( - xaxis={ - 'tickmode': 'array', - 'tickvals': x_points_index, - 'ticktext': self.config_obj.indy_label - } - ) yaxis_min = None yaxis_max = None @@ -110,7 +88,11 @@ def _create_figure(self, stat_name): if series.plot_disp: # collect min-max if we need to sync axis yaxis_min, yaxis_max = self.find_min_max(series, yaxis_min, yaxis_max) - self._draw_series(series, x_points_index) + x_points_index_adj, _ = self._get_x_locs_and_width(self.config_obj.indy_vals, + series.idx, + stagger_scale=0.1) + handle = self._draw_series(series, x_points_index_adj) + handles_and_labels.append((handle, handle.get_label())) series = TcmprSeries(self.config_obj, len(self.series_list), self.input_df, [], ['TIE'], stat_name) # Reset some series values. Series should be grouped by the plot type and not by the series_val @@ -123,51 +105,40 @@ def _create_figure(self, stat_name): 'name': 'TIE', 'line_width': 1, 'line_dash': 'solid', - 'marker_symbol': 'asterisk-open', + 'marker_symbol': '*', 'marker_size': self.config_obj.marker_size[-1], 'series_ci': True } - self._draw_series(series, x_points_index, tie_conf) - self.figure.update_layout(shapes=[dict( - type='line', - yref='y', y0=0, y1=0, - xref='paper', x0=0, x1=0.95, - line={'color': '#e5e7e9', - 'dash': 'solid', - 'width': 1}, - )]) - - # Draw an invisible line to create a CI legend - self.figure.add_trace( - go.Scatter(x=[0], - y=[0], - showlegend=True, - mode='lines', - visible='legendonly', - line={'color': '#7b7d7d', - 'width': 1, - 'dash': 'dot'}, - name=str(int(100 * (1 - self.config_obj.alpha))) + '% CI' - ) - ) + x_points_index_adj, _ = self._get_x_locs_and_width(self.config_obj.indy_vals, + series.idx, + stagger_scale=0.1) + handle = self._draw_series(series, x_points_index_adj, tie_conf) + handles_and_labels.append((handle, handle.get_label())) + self.ax.axhline(y=0, color='#e5e7e9', linestyle='-', linewidth=1) + + # add CI legend proxy + self.ax.plot([], [], color='#7b7d7d', linestyle=':', linewidth=1, label=str(int(100 * (1 - self.config_obj.alpha))) + '% CI') # add custom lines if len(self.series_list) > 0: self._add_lines( + self.ax, self.config_obj, sorted(self.series_list[0].series_data[self.config_obj.indy_var].unique()) ) - # apply y axis limits - self._yaxis_limits() + + self._add_xaxis() + self._add_yaxis() + self._add_legend(self.ax, handles_and_labels) # add x2 axis - self._add_x2axis(list(range(0, len(self.config_obj.indy_vals)))) + self._add_x2axis() end_time = datetime.now() total_time = end_time - start_time self.relperf_logger.info(f"Took {total_time} milliseconds to create the relative performance figure") - def _draw_series(self, series: TcmprSeries, x_points_index_adj: list, tie_conf=None) -> None: + def _draw_series(self, series: TcmprSeries, x_points_index_adj: list, tie_conf=None): """ Draws the boxes on the plot @@ -191,26 +162,28 @@ def _draw_series(self, series: TcmprSeries, x_points_index_adj: list, tie_conf=N marker_size = tie_conf['marker_size'] name = tie_conf['name'] + markerfacecolor = color + markeredgecolor = color + if tie_conf is None and self.config_obj.marker_open_list[series.idx]: + markerfacecolor = 'none' + markeredgecolor = self.config_obj.colors_list[series.idx] + y_points = series.series_points['val'] - # create a trace - self.figure.add_trace( - go.Scatter(x=x_points_index_adj, - y=y_points, - showlegend=True, - mode='lines+markers', - textposition="top right", - name=name, - line={'color': color, - 'width': width, - 'dash': dash}, - marker_symbol=marker_symbol, - marker_color=color, - marker_line_color=color, - marker_size=marker_size - ), - secondary_y=series.y_axis != 1 - ) + ax = self.ax if series.y_axis == 1 else self.ax2 + + plot_obj = ax.plot(x_points_index_adj, y_points, + label=name, + # line style + color=color, + linewidth=width, + linestyle=dash, + # marker style + marker=marker_symbol, + markersize=marker_size, + markeredgecolor=markeredgecolor, + markerfacecolor=markerfacecolor, + ) # Plot relative performance confidence intervals if series.idx >= series.series_len: @@ -218,42 +191,21 @@ def _draw_series(self, series: TcmprSeries, x_points_index_adj: list, tie_conf=N else: idx = series.idx if self.config_obj.series_ci[idx]: - self.figure.add_trace( - go.Scatter(x=x_points_index_adj, - y=series.series_points['ncu'], - showlegend=False, - mode='lines', - line={'color': color, - 'width': width, - 'dash': 'dot'}, - ), - secondary_y=series.y_axis != 1 - ) - self.figure.add_trace( - go.Scatter(x=x_points_index_adj, - y=series.series_points['ncl'], - showlegend=False, - mode='lines', - line={'color': color, - 'width': width, - 'dash': 'dot'}, - ), - secondary_y=series.y_axis != 1 - ) + ax.plot(x_points_index_adj, series.series_points['ncl'], color=color, linewidth=width, linestyle=':') + ax.plot(x_points_index_adj, series.series_points['ncu'], color=color, linewidth=width, linestyle=':') end_time = datetime.now() total_time = end_time - start_time self.relperf_logger.info(f"Took {total_time} milliseconds to draw the series") - - def _adjust_titles(self): - if self.yaxis_1 is None or len(self.yaxis_1) == 0: - self.yaxis_1 = 'Percent of Cases' - - if self.title is None or len(self.title) == 0: - # self.plot_filename = f"{self.config_obj.plot_dir}{os.path.sep}{self.config_obj.prefix}.png" - - self.title = f"Relative Performance of {self.col['desc']}" - if len(np.unique(self.config_obj.rp_diff)) == 1: - self.title = f"{self.title} Difference {self.config_obj.rp_diff[0]}{self.col['units']}" - self.title = f'{self.title} by {self.column_info[self.column_info["COLUMN"] == self.config_obj.series_val_names[0]]["DESCRIPTION"].tolist()[0]}' - + return plot_obj[0] + + def _adjust_titles(self, y_label=None, title_prefix=None, title_suffix=None, add_units=False): + series_val_name = self.column_info[self.column_info["COLUMN"] == self.config_obj.series_val_names[0]]["DESCRIPTION"].tolist()[0] + title_suffix = f"by {series_val_name}" + if len(np.unique(self.config_obj.rp_diff)) == 1: + title_suffix = f"Difference {self.config_obj.rp_diff[0]}{self.col['units']} {title_suffix}" + + super()._adjust_titles(y_label="Percent of Cases", + title_prefix="Relative Performance of", + title_suffix=title_suffix, + add_units=False) diff --git a/metplotpy/plots/tcmpr_plots/scatter/tcmpr_scatter.py b/metplotpy/plots/tcmpr_plots/scatter/tcmpr_scatter.py index 54c80f801..77b0fd5ff 100755 --- a/metplotpy/plots/tcmpr_plots/scatter/tcmpr_scatter.py +++ b/metplotpy/plots/tcmpr_plots/scatter/tcmpr_scatter.py @@ -13,9 +13,6 @@ import os -import plotly.graph_objects as go - -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR from metplotpy.plots.tcmpr_plots.scatter.tcmpr_series_scatter import TcmprSeriesScatter from metplotpy.plots.tcmpr_plots.tcmpr import Tcmpr from metplotpy.plots.tcmpr_plots.tcmpr_util import get_dep_column @@ -26,7 +23,7 @@ class TcmprScatter(Tcmpr): where each box is represented by a text point data file. """ - def __init__(self, config_obj, column_info, col, case_data, input_df): + def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name): """ Creates a box plot, based on settings indicated by parameters. @@ -35,14 +32,24 @@ def __init__(self, config_obj, column_info, col, case_data, input_df): """ # init common layout - super().__init__(config_obj, column_info, col, case_data, input_df) + super().__init__(config_obj, column_info, col, case_data, input_df, stat_name) print("--------------------------------------------------------") print("Creating Scatter plot") print("Plot HFIP Baseline:" + self.cur_baseline) + if not self.config_obj.scatter_x: + raise ValueError("scatter_x values are not specified") + + if not self.config_obj.scatter_y: + raise ValueError("scatter_y values are not specified") + is_series_valid = len(self.config_obj.series_val_names) == 1 and self.config_obj.series_val_names[0] == 'LEAD' is_indy_valid = self.config_obj.indy_var == 'LEAD' - if is_series_valid is False and is_indy_valid is True: + + if not is_series_valid and not is_indy_valid: + raise ValueError("LEAD values are not specified") + + if not is_series_valid and is_indy_valid: self.config_obj.parameters['series_val_1'] = {} self.config_obj.parameters['series_val_1'][self.config_obj.indy_var] = self.config_obj.indy_vals self.config_obj.series_vals_1 = [self.config_obj.indy_vals] @@ -61,22 +68,18 @@ def __init__(self, config_obj, column_info, col, case_data, input_df): if len(self.config_obj.user_legends) != len(self.config_obj.all_series_y1): self.config_obj.user_legends = [str(i) for i in self.config_obj.all_series_vals[0]] if len(self.config_obj.colors_list) != len(self.config_obj.all_series_y1): - self.config_obj.colors_list = self.config_obj.scatter_color_list[0: len(self.config_obj.all_series_y1)] - elif is_series_valid is True: + self.config_obj.colors_list = self.config_obj.colors_list[0: len(self.config_obj.all_series_y1)] + elif is_series_valid: self.indy_vals = [] self.indy_var = '' self.list_stat_1 = [] self.all_series_y1 = self.config_obj._get_all_series_y(1) - else: - raise ValueError("LEAD values are not specified") out_file_x = self.config_obj.scatter_x[-1].replace(')', '').replace('(', '_') out_file_y = self.config_obj.scatter_y[-1].replace(')', '').replace('(', '_') - if self.config_obj.prefix is None or len(self.config_obj.prefix) == 0: - self.plot_filename = f"{self.config_obj.plot_dir}{os.path.sep}{out_file_x}_vs_{out_file_y}_scatter.png" - else: - self.plot_filename = f"{self.config_obj.plot_dir}{os.path.sep}{self.config_obj.prefix}_scatter.png" + filename_prefix = self.config_obj.prefix if self.config_obj.prefix else f'{out_file_x}_vs_{out_file_y}' + self.plot_filename = os.path.join(self.config_obj.plot_dir, f'{filename_prefix}_scatter.png') # remove the old file if it exist if os.path.exists(self.plot_filename): @@ -84,17 +87,8 @@ def __init__(self, config_obj, column_info, col, case_data, input_df): self._adjust_titles() - # Create a list of series objects. - # Each series object contains all the necessary information for plotting, - # such as line color, marker symbol, - # line width, and criteria needed to subset the input dataframe. self.series_list = self._create_series(self.input_df) - # create figure - # pylint:disable=assignment-from-no-return - # Need to have a self.figure that we can pass along to - # the methods in base_plot.py (BasePlot class methods) to - # create binary versions of the plot. self._create_figure() def _adjust_titles(self): @@ -107,13 +101,14 @@ def _adjust_titles(self): col_y = get_dep_column(self.config_obj.scatter_y[ind], self.column_info, self.input_df) self.input_df['SCATTER_Y'] = col_y['val'] - if self.yaxis_1 is None or len(self.yaxis_1) == 0: + if not self.yaxis_1: self.yaxis_1 = self.config_obj.scatter_y[ind] + " (" + col_y['units'] + ')' + if self.config_obj.xaxis == 'test x_label': self.config_obj.xaxis = scatter_x_val + " (" + col_x['units'] + ')' - if self.title is None or len(self.title) == 0: - self.title = "Scatter plot of
" + col_x['desc'] + '
versus ' + col_y['desc'] + if not self.title: + self.title = "Scatter plot of \n" + col_x['desc'] + '\nversus ' + col_y['desc'] def _create_series(self, input_data): """ @@ -143,21 +138,24 @@ def _create_series(self, input_data): # reorder series series_list = self.config_obj.create_list_by_series_ordering(series_list) + # reverse series list if config is set to reverse x-axis + if self.config_obj.xaxis_reverse: + series_list.reverse() + return series_list def _create_figure(self): """ Create a box plot from default and custom parameters""" - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() + super()._create_figure() for series in self.series_list: # Don't generate the plot for this series if # it isn't requested (as set in the config file) - if series.plot_disp: - self._draw_series(series) + if not series.plot_disp: + continue + + self._draw_series(series) values = [*self.input_df['SCATTER_X'], *self.input_df['SCATTER_Y']] # Draw a 1 to 1 reference line @@ -168,73 +166,40 @@ def _create_figure(self): xrange = [min(values) - 1, max(values) + 1] yrange = [min(values) - 1, max(values) + 1] - self.figure.update_layout(yaxis={'range': yrange, 'autorange': False}) - self.figure.update_layout(xaxis={'range': xrange, 'autorange': False}) - - self.figure.add_trace( - go.Scatter(x=xrange, - y=yrange, - line={'color': '#7b7d7d', - 'dash': 'dash', - 'width': 1}, - showlegend=False, - mode='lines', - name='No-Skill' - )) + + self.ax.scatter(xrange, yrange, + color='#7b7d7d', + linestyle='--', + linewidth=1, + label='_No-Skill_', + ) + else: xrange = [min(self.input_df['SCATTER_X']) - 1, max(self.input_df['SCATTER_X']) + 1] yrange = [min(self.input_df['SCATTER_Y']) - 1, max(self.input_df['SCATTER_Y']) + 1] - self.figure.update_layout(yaxis={'range': yrange, 'autorange': False}) - self.figure.update_layout(xaxis={'range': xrange, 'autorange': False}) + # Draw a reference line at 0 - self.figure.add_hline(y=yrange[0], line_width=1, line_dash="dash", line_color="#7b7d7d") + self.add_horizontal_line(self.ax, yrange[0], {'line_width': 1, 'line_dash': "dash", 'line_color': "#7b7d7d"}) - def _draw_series(self, series: TcmprSeriesScatter) -> None: + # set x and y limits unless they are set in the config + if not self.config_obj.parameters['xlim']: + self.ax.set_xlim(xrange) + if not self.config_obj.parameters['ylim']: + self.ax.set_ylim(yrange) + self._add_xaxis() + self._add_yaxis() + self._add_legend(self.ax) + + def _draw_series(self, series: TcmprSeriesScatter) -> None: # Create a point plot - self.figure.add_trace( - go.Scatter(x=series.series_data['SCATTER_X'], - y=series.series_data['SCATTER_Y'], - showlegend=True, - mode='markers', - name=self.config_obj.user_legends[series.idx], - marker=dict( - color=PLOTLY_PAPER_BGCOOR, - size=8, - line=dict( - color=self.config_obj.colors_list[series.idx], - width=1 - ) - ), - ), - secondary_y=series.y_axis != 1 - ) - - def _add_xaxis(self) -> None: - """ - Configures and adds x-axis to the plot - """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - ticks="outside", - zeroline=False, - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size}, - tickformat='d', - tickmode='auto' - ) - # reverse xaxis if needed - if hasattr(self.config_obj, 'xaxis_reverse') and self.config_obj.xaxis_reverse is True: - self.figure.update_xaxes(autorange="reversed") + ax = self.ax if series.y_axis == 1 else self.ax2 + ax.scatter(series.series_data['SCATTER_X'], series.series_data['SCATTER_Y'], + size=self.config_obj.marker_size, + color=self.config_obj.colors_list[series.idx], + colormap=self.config_obj.marker_color, + label=self.config_obj.user_legends[series.idx], + ) @staticmethod def elements_with_string(list_of_str, pattern): diff --git a/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_skill_mean.py b/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_skill_mean.py index 1f11bffc5..7ed7b34c4 100755 --- a/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_skill_mean.py +++ b/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_skill_mean.py @@ -2,12 +2,11 @@ from datetime import datetime import numpy as np -import plotly.graph_objects as go from metcalcpy.util import utils from metplotpy.plots.tcmpr_plots.skill.mean.tcmpr_series_skill_mean import TcmprSeriesSkillMean from metplotpy.plots.tcmpr_plots.skill.tcmpr_skill import TcmprSkill -import metplotpy.plots.util as util +from metplotpy.plots import util as util class TcmprSkillMean(TcmprSkill): @@ -20,7 +19,7 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name, self.skill_logger.info("--------------------------------------------------------") self.skill_logger.info(f"Plotting SKILL_MN time series by {self.config_obj.series_val_names[0]}") - self._adjust_titles(stat_name) + self._adjust_titles(f"Skill for {stat_name}", title_prefix="Mean Skill Scores of") self.cur_baseline = baseline_data['cur_baseline'] self.cur_baseline_data = baseline_data['cur_baseline_data'] self._init_hfip_baseline_for_plot() @@ -36,66 +35,45 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name, os.remove(self.plot_filename) self._create_figure(stat_name) - def _adjust_titles(self, stat_name): - if self.yaxis_1 is None or len(self.yaxis_1) == 0: - self.yaxis_1 = 'Skill for ' + stat_name + ' (' + self.col['units'] + ')' - - if self.title is None or len(self.title) == 0: - self.title = 'Mean Skill Scores of ' + self.col['desc'] + ' by ' \ - + self.column_info[self.column_info['COLUMN'] == self.config_obj.series_val_names[0]][ - "DESCRIPTION"].tolist()[0] - def _init_hfip_baseline_for_plot(self): if 'Water Only' in self.title: - self.skill_logge.info(f"Plot HFIP Baseline: {self.cur_baseline}") + self.skill_logger.info(f"Plot HFIP Baseline: {self.cur_baseline}") else: self.cur_baseline = self.cur_baseline.replace('Error', 'Skill') self.cur_baseline = self.cur_baseline.replace('HFIP Baseline ', 'HFIP Skill Baseline') self.skill_logger.info(f"Plot HFIP Baseline: {self.cur_baseline.replace('Error ', '')}") - def _add_hfip_baseline(self): + def _add_hfip_baseline(self, ax): + if self.cur_baseline_data is None: + return + # Add HFIP baseline for each lead time - if self.cur_baseline_data is not None: - self.skill_logger.info(f"Adding HFIP baseline: {datetime.now()}") - baseline_x_values = [] - baseline_y_values = [] - lead_times = np.unique(self.series_list[0].series_data[self.config_obj.indy_var].tolist()) - lead_times.sort() - for ind, lead in enumerate(lead_times): - if lead != 0: - ocd5_data = self.cur_baseline_data.loc[ - (self.cur_baseline_data['LEAD'] == lead) & (self.cur_baseline_data['TYPE'] == "OCD5")][ - 'VALUE'].tolist() - ocd5_data = ocd5_data[0] - cons_data = self.cur_baseline_data.loc[ - (self.cur_baseline_data['LEAD'] == lead) & (self.cur_baseline_data['TYPE'] == "CONS")][ - 'VALUE'].tolist() - if len(cons_data) > 1: - raise ValueError( - f"ERROR: Can't create HFIP baseline for lead time {lead} : too many values of CONS in .dat file") - cons_data = cons_data[0] - - baseline_lead = utils.round_half_up(100 * (ocd5_data - cons_data) / ocd5_data, 1) - baseline_x_values.append(ind) - baseline_y_values.append(baseline_lead) - - self.figure.add_trace( - go.Scatter(x=baseline_x_values, - y=baseline_y_values, - showlegend=True, - mode='markers', - textposition="top right", - name=self.cur_baseline, - marker=dict(size=8, - color='rgb(0,0,255)', - line=dict( - width=1, - color='rgb(0,0,255)' - ), - symbol='diamond-cross-open', - ) - ) - ) + self.skill_logger.info(f"Adding HFIP baseline: {datetime.now()}") + baseline_x_values = [] + baseline_y_values = [] + lead_times = np.unique(self.series_list[0].series_data[self.config_obj.indy_var].tolist()) + lead_times.sort() + for ind, lead in enumerate(lead_times): + if not lead: + continue + + ocd5_data = self.cur_baseline_data.loc[ + (self.cur_baseline_data['LEAD'] == lead) & (self.cur_baseline_data['TYPE'] == "OCD5")][ + 'VALUE'].tolist() + ocd5_data = ocd5_data[0] + cons_data = self.cur_baseline_data.loc[ + (self.cur_baseline_data['LEAD'] == lead) & (self.cur_baseline_data['TYPE'] == "CONS")][ + 'VALUE'].tolist() + if len(cons_data) > 1: + raise ValueError( + f"ERROR: Can't create HFIP baseline for lead time {lead} : too many values of CONS in .dat file") + cons_data = cons_data[0] + + baseline_lead = utils.round_half_up(100 * (ocd5_data - cons_data) / ocd5_data, 1) + baseline_x_values.append(ind) + baseline_y_values.append(baseline_lead) + + ax.scatter(baseline_x_values, baseline_y_values, marker='d', facecolors='none', edgecolors='blue', s=30, label=self.cur_baseline) def _create_series(self, input_data, stat_name): """ @@ -155,6 +133,10 @@ def _create_series(self, input_data, stat_name): # reorder series series_list = self.config_obj.create_list_by_series_ordering(series_list) + # reverse series list if config is set to reverse x-axis + if self.config_obj.xaxis_reverse: + series_list.reverse() + end_time = datetime.now() total_time = end_time - start_time self.skill_logger.info(f"Total time to create series list for {stat_name}: {total_time} milliseconds ") diff --git a/metplotpy/plots/tcmpr_plots/skill/median/tcmpr_skill_median.py b/metplotpy/plots/tcmpr_plots/skill/median/tcmpr_skill_median.py index cc0d9406c..8ff8e4e24 100755 --- a/metplotpy/plots/tcmpr_plots/skill/median/tcmpr_skill_median.py +++ b/metplotpy/plots/tcmpr_plots/skill/median/tcmpr_skill_median.py @@ -1,15 +1,17 @@ import os from datetime import datetime +import matplotlib.pyplot as plt + from metcalcpy.util import utils from metplotpy.plots.tcmpr_plots.skill.median.tcmpr_series_skill_median import TcmprSeriesSkillMedian from metplotpy.plots.tcmpr_plots.skill.tcmpr_skill import TcmprSkill -import metplotpy.plots.util as util +from metplotpy.plots import util as util class TcmprSkillMedian(TcmprSkill): - def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name): - super().__init__(config_obj, column_info, col, case_data, input_df, stat_name, None) + def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name, baseline_data=None): + super().__init__(config_obj, column_info, col, case_data, input_df, baseline_data, stat_name) # Set up Logging self.skillmd_logger = util.get_common_logger(self.config_obj.log_level, self.config_obj.log_filename) @@ -19,7 +21,7 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name) self.skillmd_logger.info("Plot HFIP Baseline:" + self.cur_baseline) - self._adjust_titles(stat_name) + self._adjust_titles(f"Skill for {stat_name}", title_prefix="Median Skill Scores of") self.series_list = self._create_series(self.input_df, stat_name) self.case_data = None @@ -33,15 +35,6 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name) os.remove(self.plot_filename) self._create_figure(stat_name) - def _adjust_titles(self, stat_name): - if self.yaxis_1 is None or len(self.yaxis_1) == 0: - self.yaxis_1 = 'Skill for ' + stat_name + ' (' + self.col['units'] + ')' - - if self.title is None or len(self.title) == 0: - self.title = 'Median Skill Scores of ' + self.col['desc'] + ' by ' \ - + self.column_info[self.column_info['COLUMN'] == self.config_obj.series_val_names[0]][ - "DESCRIPTION"].tolist()[0] - def _create_series(self, input_data, stat_name): """ Generate all the series objects that are to be displayed as specified by the plot_disp @@ -99,6 +92,11 @@ def _create_series(self, input_data, stat_name): # reorder series series_list = self.config_obj.create_list_by_series_ordering(series_list) + + # reverse series list if config is set to reverse x-axis + if self.config_obj.xaxis_reverse: + series_list.reverse() + end_time = datetime.now() total_time = end_time - start_time self.skillmd_logger.info(f"Took {total_time} milliseconds to create series for {stat_name}") diff --git a/metplotpy/plots/tcmpr_plots/skill/tcmpr_skill.py b/metplotpy/plots/tcmpr_plots/skill/tcmpr_skill.py index 795dbe7e4..236c3d786 100755 --- a/metplotpy/plots/tcmpr_plots/skill/tcmpr_skill.py +++ b/metplotpy/plots/tcmpr_plots/skill/tcmpr_skill.py @@ -1,11 +1,11 @@ from typing import Union -import plotly.graph_objects as go +import matplotlib.pyplot as plt from datetime import datetime from metplotpy.plots.tcmpr_plots.tcmpr import Tcmpr from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries from metcalcpy.util import utils -import metplotpy.plots.util as util +from metplotpy.plots import util as util class TcmprSkill(Tcmpr): @@ -24,33 +24,14 @@ def _create_figure(self, stat_name): """ - start_time = datetime.now() - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() + handles_and_labels = [] + super()._create_figure() # placeholder for the min and max values for y-axis yaxis_min = None yaxis_max = None - if self.config_obj.xaxis_reverse is True: - self.series_list.reverse() - # calculate stag adjustments - stag_adjustments = self._calc_stag_adjustments() - - x_points_index = list(range(0, len(self.config_obj.indy_vals))) - # add x ticks for line plots - odered_indy_label = self.config_obj.create_list_by_plot_val_ordering(self.config_obj.indy_label) - self.figure.update_layout( - xaxis={ - 'tickmode': 'array', - 'tickvals': x_points_index, - 'ticktext': odered_indy_label - } - ) - for series in self.series_list: # Don't generate the plot for this series if # it isn't requested (as set in the config file) @@ -58,43 +39,42 @@ def _create_figure(self, stat_name): # collect min-max if we need to sync axis yaxis_min, yaxis_max = self.find_min_max(series, yaxis_min, yaxis_max) - x_points_index_adj = x_points_index + stag_adjustments[series.idx] - self._draw_series(series, x_points_index_adj) + x_points_index_adj, _ = self._get_x_locs_and_width(self.config_obj.indy_vals, + series.idx, + stagger_scale=0.1) + handle = self._draw_series(series, x_points_index_adj) + handles_and_labels.append((handle, handle.get_label())) self.skill_logger.info(f'Range of {stat_name}: {yaxis_min}, {yaxis_max}') if self.config_obj.hfip_bsln != 'no': # This will be a valid value for hfip_bsln. This has been # validated/vetted in the configuration code (tcmpr_config.py). - super()._add_hfip_baseline() + super()._add_hfip_baseline(self.ax) - self.figure.update_layout(shapes=[dict( - type='line', - yref='y', y0=0, y1=0, - xref='paper', x0=0, x1=0.95, - line={'color': '#727273', - 'dash': 'dot', - 'width': 1}, - )]) + self.ax.axhline(y=0, color='#727273', linestyle=':', linewidth=1) # add custom lines num_series = len(self.series_list) if num_series > 0: for idx in range(num_series): - self._add_lines(self.config_obj, + self._add_lines(self.ax, self.config_obj, sorted(self.series_list[idx].series_data[self.config_obj.indy_var].unique()) ) - # apply y axis limits - self._yaxis_limits() + + + self._add_xaxis() + self._add_yaxis() + self._add_legend(self.ax, handles_and_labels) # add x2 axis - self._add_x2axis(list(range(0, len(self.config_obj.indy_vals)))) + self._add_x2axis() end_time = datetime.now() total_time = end_time - start_time self.skill_logger.info(f"Took {total_time} milliseconds to create the figure for {stat_name}") - def _draw_series(self, series: TcmprSeries, x_points_index_adj: list) -> None: + def _draw_series(self, series: TcmprSeries, x_points_index_adj: list): """ Draws the boxes on the plot @@ -103,25 +83,23 @@ def _draw_series(self, series: TcmprSeries, x_points_index_adj: list) -> None: y_points = series.series_points['val'] - # create a trace - self.figure.add_trace( - go.Scatter(x=x_points_index_adj, - y=y_points, - showlegend=True, - mode='lines+markers', - textposition="top right", - name=self.config_obj.user_legends[series.idx], - line={'color': self.config_obj.colors_list[series.idx], - 'width': self.config_obj.linewidth_list[series.idx], - 'dash': self.config_obj.linestyles_list[series.idx]}, - marker_symbol=self.config_obj.marker_list[series.idx], - marker_color=self.config_obj.colors_list[series.idx], - marker_line_color=self.config_obj.colors_list[series.idx], - marker_size=self.config_obj.marker_size[series.idx], - - ), - secondary_y=series.y_axis != 1 + ax = self.ax if series.y_axis == 1 else self.ax2 + + markerfacecolor = self.config_obj.colors_list[series.idx] + if self.config_obj.marker_open_list[series.idx]: + markerfacecolor = 'none' + + plot = ax.plot( + x_points_index_adj, y_points, + label=self.config_obj.user_legends[series.idx], + color=self.config_obj.colors_list[series.idx], + linewidth=self.config_obj.linewidth_list[series.idx], + linestyle=self.config_obj.linestyles_list[series.idx], + marker=self.config_obj.marker_list[series.idx], + markersize=self.config_obj.marker_size[series.idx], + markerfacecolor=markerfacecolor, ) + return plot[0] def _find_min_max(self, series: TcmprSeries, yaxis_min: Union[float, None], yaxis_max: Union[float, None]) -> tuple: diff --git a/metplotpy/plots/tcmpr_plots/tcmpr.py b/metplotpy/plots/tcmpr_plots/tcmpr.py index 41ddfb957..26bf45e51 100755 --- a/metplotpy/plots/tcmpr_plots/tcmpr.py +++ b/metplotpy/plots/tcmpr_plots/tcmpr.py @@ -19,18 +19,15 @@ # Ignore DeprecationWarning for pyarrow in Pandas3 for now import warnings warnings.filterwarnings('ignore') + import numpy as np import pandas as pd -import plotly.graph_objects as go -import yaml -from plotly.graph_objects import Figure -from plotly.subplots import make_subplots +import matplotlib.pyplot as plt import metcalcpy.util.utils as calc_util from metcalcpy.event_equalize import event_equalize -from metplotpy.plots import util +from metplotpy.plots import util as util from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR from metplotpy.plots.tcmpr_plots.tcmpr_config import TcmprConfig from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries from metplotpy.plots.tcmpr_plots.tcmpr_util import init_hfip_baseline, common_member, get_dep_column @@ -39,7 +36,7 @@ class Tcmpr(BasePlot): - """ Generates a Plotly plot for 1 or more traces + """ Generates a Matplotlib plot for 1 or more traces where each box is represented by a text point data file. """ @@ -53,7 +50,7 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name) """ # init common layout - super().__init__(None, "tcmpr_defaults.yaml") + super().__init__(config_obj.parameters, "tcmpr_defaults.yaml") # Set up Logging self.logger = util.get_common_logger(config_obj.log_level, config_obj.log_filename) @@ -76,6 +73,7 @@ def __init__(self, config_obj, column_info, col, case_data, input_df, stat_name) self.case_data = case_data self.col = col + self.stat_name = stat_name self.title = self.config_obj.title self.baseline_lead_time = 'lead' self.yaxis_1 = self.config_obj.yaxis_1 @@ -130,7 +128,6 @@ def _create_series(self, input_data, stat_name): for i, name in enumerate(series_by_stat): if not isinstance(name, list): name = [name] - # cur_plot_type = self.config_obj.get_config_value('plot_type_list') series_obj = TcmprSeries(self.config_obj, i, input_data, series_list, name, stat_name) series_list.append(series_obj) @@ -151,31 +148,15 @@ def _create_series(self, input_data, stat_name): # reorder series series_list = self.config_obj.create_list_by_series_ordering(series_list) - self.logger.info(f"Series list created: {datetime.now()}") - return series_list - - def _calc_stag_adjustments(self) -> list: - """ - Calculates the x-axis adjustment for each point if requested. - It needed so the points and CIs for each x-axis values don't be placed on top of each other - - :return: the list of the adjustment values - """ - - # get the total number of series - num_stag = len(self.config_obj.all_series_y1) - # calculate staggering values + # reverse series list if config is set to reverse x-axis + if self.config_obj.xaxis_reverse: + series_list.reverse() - dbl_adj_scale = (len(self.config_obj.indy_vals) - 1) / 100 - stag_vals = np.linspace(-(num_stag / 2) * dbl_adj_scale, - (num_stag / 2) * dbl_adj_scale, - num_stag, - True) - stag_vals = stag_vals + dbl_adj_scale / 2 - return stag_vals + self.logger.info(f"Series list created: {datetime.now()}") + return series_list - def _add_hfip_baseline(self): + def _add_hfip_baseline(self, ax): self.logger.info(f"Adding the hfip baseline: {datetime.now()}") # Add baseline for each lead time @@ -196,219 +177,98 @@ def _add_hfip_baseline(self): baseline_x_values.extend(current_leads) baseline_y_values.extend(baseline_lead) - self.figure.add_trace( - go.Scatter(x=baseline_x_values, - y=baseline_y_values, - showlegend=True, - mode='markers', - textposition="top right", - name=self.cur_baseline, - marker=dict(size=8, - color='rgb(0,0,255)', - line=dict( - width=1, - color='rgb(0,0,255)' - ), - symbol='diamond-cross-open', - ) - ) - ) - - def _yaxis_limits(self) -> None: + ax.scatter(baseline_x_values, baseline_y_values, + marker='d', + facecolors='none', + edgecolors='blue', + s=30, + label=self.cur_baseline) + + def _create_figure(self): """ - Apply limits on y2 axis if needed + Create a box plot from defaults and custom parameters """ - if len(self.config_obj.parameters['ylim']) > 0: - self.figure.update_layout(yaxis={'range': [self.config_obj.parameters['ylim'][0], - self.config_obj.parameters['ylim'][1]], - 'autorange': False}) + # create and draw the plot + self.fig, self.ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height), + )#layout="constrained") - def _create_layout(self) -> Figure: - """ - Creates a new layout based on the properties from the config file - including plots size, annotation and title + # for secondary y axis + self.ax2 = self.ax.twinx() if any(s.y_axis != 1 for s in self.series_list) else None - :return: Figure object - """ - # create annotation - annotation_caption = {'text': util.apply_weight_style(self.config_obj.parameters['plot_caption'], - self.config_obj.parameters['caption_weight']), - 'align': 'left', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': self.config_obj.parameters['caption_align'], - 'y': self.config_obj.caption_offset, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - } - annotation_subtitle = {'text': util.apply_weight_style(self.config_obj.subtitle, - 1), - 'align': 'center', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': 0.5, - 'y': -0.26, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - } - - # create title - title = {'text': util.apply_weight_style(self.title, - self.config_obj.parameters['title_weight']), - 'font': { - 'size': self.config_obj.title_font_size, - }, - 'y': self.config_obj.title_offset, - 'x': self.config_obj.parameters['title_align'], - 'xanchor': 'center', - 'yanchor': 'top', - 'xref': 'paper' - } - - # create a layout and allow y2 axis - fig = make_subplots(specs=[[{"secondary_y": True}]]) - - # add size, annotation, title - fig.update_layout( - width=self.config_obj.plot_width, - height=self.config_obj.plot_height, - margin=self.config_obj.plot_margins, - paper_bgcolor=PLOTLY_PAPER_BGCOOR, - annotations=[annotation_caption, annotation_subtitle], - title=title, - plot_bgcolor=PLOTLY_PAPER_BGCOOR - ) - - return fig - - def _add_xaxis(self) -> None: + wts_size_styles = self.get_weights_size_styles() + + self._add_title(self.ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) + + def _add_title(self, ax, fontproperties, title_override=None): + super()._add_title(ax, fontproperties, title_override=self.title) + + def _add_xaxis(self, ax=None, fontproperties=None, label=None, grid_on=None) -> None: """ Configures and adds x-axis to the plot """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - ticks="outside", - zeroline=False, - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size}, - tickformat='d' - ) - # reverse xaxis if needed - if hasattr(self.config_obj, 'xaxis_reverse') and self.config_obj.xaxis_reverse is True: - self.figure.update_xaxes(autorange="reversed") - - def _add_yaxis(self) -> None: + if ax is None: + ax = self.ax + wts_size_styles = self.get_weights_size_styles() + super()._add_xaxis(ax, wts_size_styles['xlab']) + + def _add_yaxis(self, ax=None, fontproperties=None, label=None, grid_on=None) -> None: """ Configures and adds y-axis to the plot """ - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.yaxis_1, - self.config_obj.parameters['ylab_weight']), - secondary_y=False, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - zeroline=False, - ticks="inside", - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.y_title_font_size - }, - title_standoff=self.config_obj.parameters['ylab_offset'], - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size}, - exponentformat='none' - ) - - def _add_x2axis(self, vals) -> None: + if ax is None: + ax = self.ax + wts_size_styles = self.get_weights_size_styles() + super()._add_yaxis(ax, wts_size_styles['ylab'], label=self.yaxis_1) + + def _add_x2axis(self, ax=None, fontproperties=None) -> None: """ Creates x2axis based on the properties from the config file and attaches it to the initial Figure """ - if self.config_obj.show_nstats: - # new_list = ['' +str(x) +''+'
AAA' for x in n_stats + if not self.config_obj.show_nstats: + return - n_stats = [''] * len(self.config_obj.indy_vals) + wts_size_styles = self.get_weights_size_styles() + super()._add_x2axis(self.ax, wts_size_styles['x2lab']) - for ind, val_for_indy in enumerate(n_stats): - if self.config_obj.use_ee is True and len(self.series_list) > 0: - n_stats[ind] = n_stats[ind] + '' + str( - self.series_list[0].series_points['nstat'][ind]) + '
' - else: - for series in self.series_list: - n_stats[ind] = n_stats[ind] + '' + str( - series.series_points['nstat'][ind]) + '
' - - self.figure.update_layout(xaxis2={'title_text': util.apply_weight_style('', - self.config_obj.parameters[ - 'x2lab_weight'] - ), - 'linecolor': PLOTLY_AXIS_LINE_COLOR, - 'linewidth': PLOTLY_AXIS_LINE_WIDTH, - 'overlaying': 'x', - 'side': 'top', - 'showgrid': False, - 'zeroline': False, - 'title_font': {'size': self.config_obj.x2_title_font_size}, - 'title_standoff': abs(self.config_obj.parameters['x2lab_offset']), - 'tickmode': 'array', - 'tickvals': vals, - 'ticktext': n_stats, - 'tickangle': self.config_obj.x2_tickangle, - 'tickfont': {'size': self.config_obj.x2_tickfont_size}, - 'scaleanchor': 'x' - } - ) - # reverse x2axis if needed - if self.config_obj.xaxis_reverse is True: - self.figure.update_layout(xaxis2={'autorange': "reversed"}) - - # need to add an invisible line with all values = None - self.figure.add_trace( - go.Scatter(y=[None] * len(vals), x=vals, - xaxis='x2', showlegend=False) - ) - - def _add_legend(self) -> None: + def _add_legend(self, ax=None, handles_and_labels=None) -> None: """ Creates a plot legend based on the properties from the config file and attaches it to the initial Figure """ - self.figure.update_layout(legend={'x': self.config_obj.bbox_x, - 'y': self.config_obj.bbox_y, - 'xanchor': 'center', - 'yanchor': 'top', - 'bordercolor': self.config_obj.legend_border_color, - 'borderwidth': self.config_obj.legend_border_width, - 'orientation': self.config_obj.legend_orientation, - 'font': { - 'size': self.config_obj.legend_size, - 'color': "black" - }, - 'traceorder': 'normal' - }) - if hasattr(self.config_obj, 'xaxis_reverse') and self.config_obj.xaxis_reverse is True: - self.figure.update_layout(legend={'traceorder': 'reversed'}) - - def save_to_file(self): + if ax is None: + ax = self.ax + super()._add_legend(ax) + + def _get_nstats(self) -> list: + """ + Calculates n_stats for the x2 axis as a structured list for multi-colored display. + Returns a list of lists of dictionaries. + """ + n_stats = [] + for ind in range(len(self.config_obj.indy_vals)): + # if event equalization is used, get count from first series only + if self.config_obj.use_ee and len(self.series_list) > 0: + n_stats.append(self.series_list[0].series_points["nstat"][ind]) + continue + + # get color-coded number of stats for each series + tick_stats = [] + for series in self.series_list: + if not series.plot_disp: + continue + tick_stats.append({ + "val": str(series.series_points["nstat"][ind]), + "color": series.color + }) + + n_stats.append(tick_stats) + + return n_stats + + def save_to_file(self, plot_filename: str = None, **kwargs): """Saves the image to a file specified in the config file. Prints a message if fails @@ -417,27 +277,8 @@ def save_to_file(self): Returns: """ - - # Create the directory for the output plot if it doesn't already exist - dirname = os.path.dirname(os.path.abspath(self.plot_filename)) - try: - os.makedirs(dirname, exist_ok=True) - except FileExistsError: - pass - - self.logger.info(f'Saving the image file: {self.plot_filename}') - if self.figure: - try: - self.figure.write_image(file=self.plot_filename, format='png', - width=self.config_obj.plot_width, - height=self.config_obj.plot_height, - scale=2) - except FileNotFoundError: - self.logger.error(f"Cannot save to file {self.plot_filename}") - except ValueError as ex: - print(ex) - else: - self.logger.error(f"The figure wasn't created. Nothing to save") + # TODO: consider setting bbox_inches='tight' for all plots to ensure nothing is cut off + super().save_to_file(self.plot_filename, bbox_inches='tight', **kwargs) @staticmethod def find_min_max(series: TcmprSeries, yaxis_min: Union[float, None], @@ -458,18 +299,23 @@ def find_min_max(series: TcmprSeries, yaxis_min: Union[float, None], return yaxis_min, yaxis_max # Get the values to be plotted for this lead times - all_values = series.series_points['val'] + if 'val' in series.series_points and len(series.series_points['val']) > 0: + all_values = series.series_points['val'] + if 'ncl' in series.series_points: + all_values = all_values + series.series_points['ncl'] + if 'ncu' in series.series_points: + all_values = all_values + series.series_points['ncu'] + else: + all_values = series.series_data['PLOT'].tolist() - if 'ncl' in series.series_points: - all_values = all_values + series.series_points['ncl'] - if 'ncu' in series.series_points: - all_values = all_values + series.series_points['ncu'] + # remove None/NaN + all_values = [v for v in all_values if v is not None and not np.isnan(v)] if len(all_values) == 0: return yaxis_min, yaxis_max - low_range = min([v for v in all_values if v is not None]) - upper_range = max([v for v in all_values if v is not None]) + low_range = min(all_values) + upper_range = max(all_values) # find min max if yaxis_min is None or yaxis_max is None: @@ -477,6 +323,27 @@ def find_min_max(series: TcmprSeries, yaxis_min: Union[float, None], return min(yaxis_min, low_range), max(yaxis_max, upper_range) + def _adjust_titles(self, y_label, title_prefix, title_suffix=None, add_units=True): + if not self.yaxis_1: + y_label_text = y_label + if add_units: + y_label_text += f" ({self.col['units']})" + self.yaxis_1 = y_label_text + + if self.title: + return + + desc = self.col['desc'] + if title_suffix is None: + series_val_name = \ + self.column_info[self.column_info['COLUMN'] == self.config_obj.series_val_names[0]][ + "DESCRIPTION"].tolist()[0] + title_suffix = f"by {series_val_name}" + + delimeter = '\n' if len(title_prefix) + len(desc) + len(title_suffix) > 70 else ' ' + desc = f"{delimeter}{desc}{delimeter}" + self.title = f"{title_prefix}{desc}{title_suffix}" + def perform_event_equalization(input_df:pd.DataFrame, is_skill:bool, config_obj:dict) -> pd.DataFrame: ''' Performs event equalization. The skill_mn and skill_md plots require the skill_ref value to be included. @@ -514,7 +381,7 @@ def perform_event_equalization(input_df:pd.DataFrame, is_skill:bool, config_obj: return output_data -def main(config_filename=None): +def main(config_filename=None) -> bool: """ Generates a sample, default, TCMPR plot using a combination of default and custom config files on sample data found in this directory. @@ -540,10 +407,10 @@ def main(config_filename=None): config_obj = TcmprConfig(docs) # Create the requested plot(s) - create_plot(config_obj) + return create_plot(config_obj) -def create_plot(config_obj: dict) -> None: +def create_plot(config_obj) -> bool: """ One or more TCMPR plots is generated. Event equalization is performed if it was requested by a setting in the yaml configuration file. @@ -586,22 +453,17 @@ def create_plot(config_obj: dict) -> None: logger = util.get_common_logger(config_obj.log_level, config_obj.log_filename) + success = True for plot_type in config_obj.plot_type_list: + logger.info("Plot type: %s", plot_type) # Apply event equalization, if requested # Event equalization is different for the skill_mn and skill_md - is_skill = False if config_obj.use_ee: - if plot_type == 'skill_mn' or plot_type == 'skill_md': - is_skill = True - # perform event equalization on the skill_mn|skill_md plot type - logger.info(f"Perform event equalization for {plot_type}: {datetime.now()}") - output_result = perform_event_equalization(orig_input_df, is_skill, config_obj) - input_df = output_result - else: - logger.info(f"Perform event equalization for {plot_type}: {datetime.now()}") - output_result = perform_event_equalization(orig_input_df, is_skill, config_obj) - input_df = output_result + is_skill = plot_type == 'skill_mn' or plot_type == 'skill_md' + logger.info(f"Perform event equalization for {plot_type}: {datetime.now()}") + output_result = perform_event_equalization(orig_input_df, is_skill, config_obj) + input_df = output_result input_df.rename({'equalize': 'CASE'}, axis=1, inplace=True) # Sort the data by the CASE column @@ -610,7 +472,6 @@ def create_plot(config_obj: dict) -> None: for cur_stat in config_obj.list_stat_1: logger.info(f"Statistic of interest: {cur_stat}") - # col_to_plot = get_dep_column(config_obj.list_stat_1[0], column_info, input_df) col_to_plot = get_dep_column(cur_stat, column_info, input_df) input_df['PLOT'] = col_to_plot['val'] @@ -654,13 +515,15 @@ def create_plot(config_obj: dict) -> None: plot = TcmprSkillMedian(config_obj, column_info, col_to_plot, common_case_data, input_df, cur_stat) plot.save_to_file() - # plot.show_in_browser() if common_case_data is None: common_case_data = plot.case_data - except (ValueError, Exception) as ve: - print(ve) + except Exception as err: + logger.error("Exception occurred in %s plot: %s", plot_type, err) + logger.debug("Exception details:", exc_info=True) + success = False + return success def print_data_info(input_df, series): # Print information about the dataset. @@ -732,4 +595,5 @@ def read_tcst_files(config_obj, tcst_files): if __name__ == "__main__": - main() + if not main(): + sys.exit(1) diff --git a/metplotpy/plots/tcmpr_plots/tcmpr_config.py b/metplotpy/plots/tcmpr_plots/tcmpr_config.py index 0d75e358f..da7fafc1a 100755 --- a/metplotpy/plots/tcmpr_plots/tcmpr_config.py +++ b/metplotpy/plots/tcmpr_plots/tcmpr_config.py @@ -17,8 +17,7 @@ import itertools import metcalcpy.util.utils as utils -from .. import constants -from .. import util +from .. import constants as constants from ..config import Config import metplotpy.plots.util as util @@ -27,7 +26,17 @@ class TcmprConfig(Config): """ Prepares and organises Line plot parameters """ - SUPPORTED_PLOT_TYPES = ['boxplot', 'point', 'mean', 'median', 'relperf', 'rank', 'skill_mn', 'skill_md'] + SUPPORTED_PLOT_TYPES = [ + 'boxplot', + 'point', + 'mean', + 'median', + 'relperf', + 'rank', + 'skill_mn', + 'skill_md', + 'scatter', + ] def __init__(self, parameters: dict) -> None: """ Reads in the plot settings from a box plot config file. @@ -68,8 +77,8 @@ def __init__(self, parameters: dict) -> None: # plot parameters self.grid_on = self._get_bool('grid_on') - self.plot_width = self.calculate_plot_dimension('plot_width', 'pixels') - self.plot_height = self.calculate_plot_dimension('plot_height', 'pixels') + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') self.plot_margins = self.parameters['mar'] self.blended_grid_col = util.alpha_blending(self.parameters['grid_col'], 0.5) self.plot_stat = self._get_plot_stat() @@ -82,12 +91,11 @@ def __init__(self, parameters: dict) -> None: # caption parameters self.caption_size = int(constants.DEFAULT_CAPTION_FONTSIZE * self.get_config_value('caption_size')) - self.caption_offset = self.parameters['caption_offset'] - 3.1 + self.caption_offset = self.parameters['caption_offset'] * constants.DEFAULT_CAPTION_Y_OFFSET ############################################## # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -105,7 +113,6 @@ def __init__(self, parameters: dict) -> None: if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) ############################################## # x2-axis parameters @@ -121,14 +128,16 @@ def __init__(self, parameters: dict) -> None: # Make the series ordering zero-based self.series_ordering_zb = [sorder - 1 for sorder in self.series_ordering] self.plot_disp = self._get_plot_disp() + self.num_series = self.calculate_number_of_series() + self.show_legend = self._get_show_legend() self.series_ci = self._get_series_ci() self.colors_list = self._get_colors() self.all_series_y1 = self._get_all_series_y(1) - self.num_series = self.calculate_number_of_series() self.linewidth_list = self._get_linewidths() self.linestyles_list = self._get_linestyles() self.marker_list = self._get_markers() self.marker_size = self._get_markers_size() + self.marker_open_list = self._get_markers_open() ############################################## # legend parameters @@ -175,66 +184,32 @@ def _get_subtitle(self): def _get_tcst_dir(self): return self.get_config_value('tcst_dir') - def _get_linestyles(self) -> list: - """ - Retrieve all the line styles. Convert line style names from - the config file into plotly python's line style names. - - Args: - - Returns: - line_styles: a list of the plotly line styles - """ - line_styles = self.get_config_value('series_line_style') - line_style_list = [] - for line_style in line_styles: - if line_style in constants.LINE_STYLE_TO_PLOTLY_DASH.keys(): - line_style_list.append(constants.LINE_STYLE_TO_PLOTLY_DASH[line_style]) - else: - line_style_list.append(None) - return self.create_list_by_series_ordering(line_style_list) - def _get_markers(self) -> list: + def _get_show_legend(self) -> list: """ - Retrieve all the markers. Convert marker names from - the config file into plotly python's marker names. - + Retrieves the 'show_legend' values used for displaying or + not the legend of a trace in the legend box, from the + config file. If 'show_legend' is not provided - uses True for all series Args: Returns: - markers: a list of the plotly markers - """ - markers = self.get_config_value('series_symbols') - markers_list = [] - for marker in markers: - if marker in constants.AVAILABLE_PLOTLY_MARKERS_LIST: - # the recognized plotly marker names: - # circle-open (for small circle), circle, triangle-up, - # square, diamond, or hexagon - markers_list.append(marker) - else: - markers_list.append(constants.PCH_TO_PLOTLY_MARKER[marker]) - return self.create_list_by_series_ordering(markers_list) - - def _get_markers_size(self) -> list: + show_legend_list or show_legend_from_config: a list of 1 and/or 0 to + be used for the traces """ - Retrieve all the markers. Convert marker names from - the config file into plotly python's marker names. + show_legend_settings = self.get_config_value('show_legend') - Args: + if show_legend_settings is None: + return [True] * self.num_series - Returns: - markers: a list of the plotly markers - """ - markers_size = self.get_config_value('series_symbols_size') - return self.create_list_by_series_ordering(markers_size) + return super()._get_show_legend() def _get_plot(self) -> list: plot_type_list = self.get_config_value('plot_type_list') for cur_plot_type in plot_type_list: if cur_plot_type not in self.SUPPORTED_PLOT_TYPES: - raise ValueError("Requesting an unsupported plot type. Supported types: boxplot, " - "point, mean, median, relperf, rank, skill_mn, and skill_md ") + raise ValueError( + f"Requesting an unsupported plot type ({cur_plot_type})." + f" Supported types: {', '.join(self.SUPPORTED_PLOT_TYPES)}") return plot_type_list def _get_tcst_files(self) -> list: diff --git a/metplotpy/plots/util.py b/metplotpy/plots/util.py index ad7c99549..543b5739f 100644 --- a/metplotpy/plots/util.py +++ b/metplotpy/plots/util.py @@ -23,7 +23,8 @@ import numpy as np from typing import Union import pandas as pd -from plotly.graph_objects import Figure +import matplotlib.pyplot as plt + from metplotpy.plots.context_filter import ContextFilter as cf import metcalcpy.util.pstd_statistics as pstats import metcalcpy.util.ctc_statistics as cstats @@ -95,22 +96,23 @@ def make_plot(config_filename, plot_class): # Retrieve the contents of the custom config file to over-ride # or augment settings defined by the default config file. params = get_params(config_filename) + plot = None try: plot = plot_class(params) plot.save_to_file() - #if plot.config_obj.show_in_browser: - # plot.show_in_browser() - plot.write_html() plot.write_output_file() name = plot_class.__name__ if not hasattr(plot_class, 'LONG_NAME') else plot_class.LONG_NAME plot.logger.info(f"Finished {name} plot at {datetime.now()}") return plot - except ValueError as val_er: - print(val_er) + except Exception as err: + if plot: + plot.logger.error("Exception occurred in plot: %s", err) + plot.logger.debug("Exception details:", exc_info=True) + else: + raise return None - def alpha_blending(hex_color: str, alpha: float) -> str: """ Alpha color blending as if on the white background. Useful for gridlines @@ -125,29 +127,6 @@ def alpha_blending(hex_color: str, alpha: float) -> str: final = tuple((1. - alpha) + foreground_arr * alpha) return matplotlib.colors.rgb2hex(final) - -def apply_weight_style(text: str, weight: int) -> str: - """ - Applied HTML style weight to text: - 1 - none - 2 - bold - 3 - italic - 4 - bold italic - - :param text: text to style - :param weight: - int representation of the style - :return: styled text - """ - if len(text) > 0: - if weight == 2: - return '' + text + '' - if weight == 3: - return '' + text + '' - if weight == 4: - return '' + text + '' - return text - - def nicenumber(x, to_round): """ Calculates a close nice number, i. e. a number with simple decimals. @@ -200,38 +179,6 @@ def pretty(low, high, number_of_intervals) -> Union[np.ndarray, list]: return np.arange(miny, maxy + 0.5 * d, d) -def add_horizontal_line(figure: Figure, y: float, line_properties: dict) -> None: - """ - Adds a horizontal line to the Plotly Figure - :param figure: Plotly plot to add a line to - :param y: y value for the line - :param line_properties: dictionary with line properties like color, width, dash - :return: - """ - figure.add_shape( - type='line', - yref='y', y0=y, y1=y, - xref='paper', x0=0, x1=1, - line=line_properties, - ) - - -def add_vertical_line(figure: Figure, x: float, line_properties: dict) -> None: - """ - Adds a vertical line to the Plotly Figure - :param figure: Plotly plot to add a line to - :param x: x value for the line - :param line_properties: dictionary with line properties like color, width, dash - :return: - """ - figure.add_shape( - type='line', - yref='paper', y0=0, y1=1, - xref='x', x0=x, x1=x, - line=line_properties, - ) - - def abline(x_value: float, intercept: float, slope: float) -> float: """ Calculates y coordinate based on x-value, intercept and slope @@ -353,36 +300,39 @@ def get_common_logger(log_level, log_filename): common_logger: the logger common to all the METplotpy modules that are currently in use by a plot type. ''' - # If directory for logfile doesn't exist, create it log_dir = os.path.dirname(log_filename) - try: - os.makedirs(log_dir, exist_ok=True) - except OSError: - pass + if log_dir: + os.makedirs(log_dir, exist_ok=True) # Supported log levels. log_level = log_level.upper() - log_levels = {'DEBUG': logging.DEBUG, 'INFO': logging.INFO, - 'WARNING': logging.WARNING, 'ERROR': logging.ERROR, - 'CRITICAL': logging.CRITICAL} - + log_levels = { + 'DEBUG': logging.DEBUG, + 'INFO': logging.INFO, + 'WARNING': logging.WARNING, + 'ERROR': logging.ERROR, + 'CRITICAL': logging.CRITICAL, + } + log_level = log_levels.get(log_level) + if log_level is None: + print(f'WARNING: Invalid log level: {log_level}. Using INFO') + log_level = logging.INFO + + log_args = { + 'level': log_level, + 'format': '%(asctime)s||User:%(user)s||%(funcName)s|| [%(levelname)s]: %(message)s', + 'datefmt': '%Y-%m-%d %H:%M:%S', + } if log_filename.lower() == 'stdout': - logging.basicConfig(level=log_levels[log_level], - format='%(asctime)s||User:%(' - 'user)s||%(funcName)s|| [%(levelname)s]: %(' - 'message)s', - datefmt='%Y-%m-%d %H:%M:%S', - stream=sys.stdout) + log_args['stream'] = sys.stdout else: + log_args['filename'] = log_filename + log_args['filemode'] = 'w' + + # Note: the log level is ignored if logging has already been initialized + logging.basicConfig(**log_args) - logging.basicConfig(level=log_levels[log_level], - format='%(asctime)s||User:%(' - 'user)s||%(funcName)s|| [%(levelname)s]: %(' - 'message)s', - datefmt='%Y-%m-%d %H:%M:%S', - filename=log_filename, - filemode='w') logging.getLogger(name='matplotlib').setLevel(logging.CRITICAL) common_logger = logging.getLogger(__name__) f = cf() diff --git a/metplotpy/plots/wind_rose/wind_rose.py b/metplotpy/plots/wind_rose/wind_rose.py index 34e4c1f1f..6ca625ba9 100644 --- a/metplotpy/plots/wind_rose/wind_rose.py +++ b/metplotpy/plots/wind_rose/wind_rose.py @@ -21,14 +21,12 @@ import pandas as pd import numpy as np import re -from pathlib import Path -import plotly.graph_objects as go -from plotly.subplots import make_subplots +from matplotlib import pyplot as plt +import matplotlib.ticker as mtick from metplotpy.plots.base_plot import BasePlot from metplotpy.plots.wind_rose.wind_rose_config import WindRoseConfig -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR from metplotpy.plots import util @@ -46,7 +44,7 @@ class WindRosePlot(BasePlot): This class works with MET v.9.1+ output """ def __init__(self, parameters: dict, u_wind_data: Union[pd.DataFrame, None] = None, - v_wind_data: Union[pd.DataFrame, None] = None): + v_wind_data: Union[pd.DataFrame, None] = None, ax: plt.Axes = None): default_conf_filename = "wind_rose_defaults.yaml" @@ -70,19 +68,7 @@ def __init__(self, parameters: dict, u_wind_data: Union[pd.DataFrame, None] = No self.u_wind_data = u_wind_data self.v_wind_data = v_wind_data - # wind rose traces - self.traces = [] - - # create wind rose traces - self._create_traces() - - # create figure if needed - # pylint:disable=assignment-from-no-return - # Need to have a self.figure that we can pass along to - # the methods in base_plot.py (BasePlot class methods) to - # create binary versions of the plot. - if self.config_obj.create_figure: - self.figure = self._create_figure() + self._create_figure(ax) def _read_input_data(self): """ @@ -98,87 +84,64 @@ def _read_input_data(self): self.u_wind_data = input_df[input_df['FCST_VAR'] == 'UGRD'] self.v_wind_data = input_df[input_df['FCST_VAR'] == 'VGRD'] - def _create_figure(self): + def _create_figure(self, ax: plt.Axes = None): """ Initialise the figure and add Wnd roses traces Args: - + ax: existing Axes object to use for the plot Returns: - Wind rose plot as Plotly figure + Wind rose plot as Matplotlib figure """ - self.logger.info(f"Creating figure: {datetime.now()}") - fig = make_subplots(specs=[[{"secondary_y": False}]]) + if ax is None: + fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}, + figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) + self.figure = fig + else: + self.figure = ax.get_figure() - # Set plot height and width in pixel value - fig.update_layout(width=self.config_obj.plot_width, height=self.config_obj.plot_height) + wts_size_styles = self.get_weights_size_styles() + self._add_title(ax, wts_size_styles['title']) - # Add figure title - fig.update_layout( - title={'text': self.config_obj.title, - 'y': 0.95, - 'x': 0.5, - 'xanchor': "center", - 'yanchor': "top"}, - plot_bgcolor="#FFF" + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) - ) + # create wind rose traces + self._create_traces(ax) - fig.update_xaxes( - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - ticks="outside", - zeroline=False, - automargin=True - ) + # set north = 0 degrees + ax.set_theta_zero_location('N') - fig.update_yaxes( - secondary_y=False, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - zeroline=False, - ticks="outside", - automargin=True - ) + # clockwise + ax.set_theta_direction(-1) - if self.config_obj.radialaxis_range is None: - fig.update_polars( - bgcolor=PLOTLY_PAPER_BGCOOR, hole=0.08, angularaxis_thetaunit="degrees", angularaxis_rotation=90, - angularaxis_direction='clockwise', angularaxis_gridcolor=PLOTLY_AXIS_LINE_COLOR, - angularaxis_tickvals=self.config_obj.angularaxis_tickvals, - angularaxis_ticktext=self.config_obj.angularaxis_ticktext, angularaxis_tickmode='array', - radialaxis_angle=135, radialaxis_tickmode='linear', radialaxis_tickangle=100, radialaxis_tick0=5, - radialaxis_dtick=self.config_obj.radialaxis_step, radialaxis_gridcolor=PLOTLY_AXIS_LINE_COLOR, - radialaxis_showticklabels=True, - radialaxis_ticksuffix='%', radialaxis_type="-", ) - else: - fig.update_polars( - bgcolor=PLOTLY_PAPER_BGCOOR, hole=0.08, angularaxis_thetaunit="degrees", angularaxis_rotation=90, - angularaxis_direction='clockwise', angularaxis_gridcolor=PLOTLY_AXIS_LINE_COLOR, - angularaxis_tickvals=self.config_obj.angularaxis_tickvals, - angularaxis_ticktext=self.config_obj.angularaxis_ticktext, angularaxis_tickmode='array', - radialaxis_angle=135, radialaxis_tickmode='linear', radialaxis_tickangle=100, - radialaxis_tick0=self.config_obj.radialaxis_range[0], - radialaxis_dtick=self.config_obj.radialaxis_step, radialaxis_gridcolor=PLOTLY_AXIS_LINE_COLOR, - radialaxis_showticklabels=True, - radialaxis_ticksuffix='%', radialaxis_type="-", radialaxis_range=self.config_obj.radialaxis_range, ) - - fig.update_layout( - showlegend=self.config_obj.show_legend, - plot_bgcolor=PLOTLY_PAPER_BGCOOR, - ) + # add hole in center of plot + ax.set_rorigin(-1) - # add traces - for trace in self.traces: - fig.add_trace(trace) + # set location of radial labels (northwest) + ax.set_rlabel_position(-45) + + # add % symbol to radial labels + ax.yaxis.set_major_formatter(mtick.FormatStrFormatter('%g%%')) - return fig + # add angular axis ticks and labels, (N, E, S, W) + ax.set_xticks(np.deg2rad(self.config_obj.angularaxis_tickvals)) + ax.set_xticklabels(self.config_obj.angularaxis_ticktext, rotation=45, ha='right') - def _create_traces(self): + if self.config_obj.radialaxis_range is not None: + ax.set_ylim(self.config_obj.radialaxis_range) + start, stop = self.config_obj.radialaxis_range + step = self.config_obj.radialaxis_step + ax.set_yticks(np.arange(start, stop + step, step)) + + # turn off outermost circle (spine) + ax.spines['polar'].set_visible(False) + + self._add_legend(ax, loc='upper left') + + def _create_traces(self, ax): """ Creates wind rose traces based on the u and v data. Number of traces is equal to the length of wind_rose_breaks @@ -188,8 +151,8 @@ def _create_traces(self): Args: Returns: """ - self.logger.info(f"Creating wind rose traces: {datetime.now()}") + self.traces = [] # init data based on type if self.config_obj.type == 'FCST-OBS': u_wind_data = (self.u_wind_data['FCST'] - self.u_wind_data['OBS']).tolist() @@ -210,18 +173,7 @@ def _create_traces(self): # calculate the wind dir in degrees for each row and bin it to angles self.logger.info("Calculating the wind direction.") - wind_dir_deg = [] - for i, v_wind in enumerate(v_wind_data): - if wind_speed[i] == 0: - wind_dir_deg.append(None) - else: - # calculate the wind dir - wd = math.atan2(u_wind_data[i] / wind_speed[i], v_wind / wind_speed[i]) * 180 / math.pi - if wd < 0: - wind_dir_deg.append(None) - else: - wind_dir_deg.append( - self.config_obj.wind_rose_angle * math.ceil(wd / self.config_obj.wind_rose_angle - 0.5)) + wind_dir_deg = self._get_wind_dir_deg(u_wind_data, v_wind_data, wind_speed) # join wind_speed and wind_dir in one array wind_speed_dir = np.vstack((wind_speed, wind_dir_deg)).T @@ -234,16 +186,7 @@ def _create_traces(self): # remove rows where wind direction is None # and converting data between 348.75 and 360 to negative - wind_speed_dir_processed = np.empty((0, 2), float) - for i, wspd in enumerate(wind_speed_dir): - if wspd[1] is not None: - if angles[-1] + step <= wspd[1] and wspd[1] < 360: - wind_speed_dir_processed = np.append(wind_speed_dir_processed, np.array( - [[wspd[0], wspd[1] - 360, ]]), axis=0) - else: - wind_speed_dir_processed = np.append(wind_speed_dir_processed, - np.array([[wspd[0], wspd[1], ]]), - axis=0) + wind_speed_dir_processed = self._process_wind_speed_dir(wind_speed_dir, angles, step) # determining the direction bins bin_edges_dir = np.append(angles - step, [angles[-1] + step]) @@ -265,7 +208,6 @@ def _create_traces(self): breaks = breaks[:-1] breaks.append(max(wind_speed)) - # loop selecting given bins and calculate frequencies for i in range(len(breaks) - 1): # initialise speed bins strings @@ -289,6 +231,7 @@ def _create_traces(self): [speed_bins, angles], names=['wind_speed_bins', 'wind_direction_bins'] ) + # create a data frame from permutations of speed_bins # and angles with the additional 'frequency' column frequencies_df = pd.DataFrame(0, perm_speedbins_angles, ['frequency']) @@ -296,36 +239,60 @@ def _create_traces(self): # updating the frequencies in the dataframe frequencies_df.frequency = frequencies * 100 # [%] + theta = np.deg2rad(angles) + width = (2 * np.pi) / len(angles) + + bottom = np.zeros(len(angles)) + # create traces for i, speed_bin in enumerate(speed_bins): - trace = go.Barpolar( - r=frequencies_df.loc[(speed_bin), 'frequency'], - name=f'Wind {speed_bin}', - marker_color=self.config_obj.wind_rose_marker_colors[i] + r_values = frequencies_df.loc[speed_bin, 'frequency'].values + + ax.bar( + theta, + r_values, + width=width, + bottom=bottom, + color=self.config_obj.wind_rose_marker_colors[i], + label=f"Wind {speed_bin}", + linewidth=0.5, + zorder=3, # above the grid lines ) - self.traces.append(trace) + + bottom += r_values + self.logger.info(f"Finished creating traces: {datetime.now()}") - def save_to_file(self) -> None: - """ Saves the image to a file specified in the config file. - Prints a message if fails + def _get_wind_dir_deg(self, u_wind_data, v_wind_data, wind_speed): + wind_dir_deg = [] + for i, v_wind in enumerate(v_wind_data): + if wind_speed[i] == 0: + wind_dir_deg.append(None) + else: + # calculate the wind dir + wd = math.atan2(u_wind_data[i] / wind_speed[i], v_wind / wind_speed[i]) * 180 / math.pi + if wd < 0: + wind_dir_deg.append(None) + else: + wind_dir_deg.append( + self.config_obj.wind_rose_angle * math.ceil(wd / self.config_obj.wind_rose_angle - 0.5)) + + return wind_dir_deg - Args: + @staticmethod + def _process_wind_speed_dir(wind_speed_dir, angles, step): + wind_speed_dir_processed = np.empty((0, 2), float) + for i, wspd in enumerate(wind_speed_dir): + if wspd[1] is not None: + if angles[-1] + step <= wspd[1] and wspd[1] < 360: + wind_speed_dir_processed = np.append(wind_speed_dir_processed, np.array( + [[wspd[0], wspd[1] - 360, ]]), axis=0) + else: + wind_speed_dir_processed = np.append(wind_speed_dir_processed, + np.array([[wspd[0], wspd[1], ]]), + axis=0) - Returns: - """ - image_name = self.get_config_value('plot_filename') - if self.figure: - try: - os.makedirs(os.path.dirname(image_name), exist_ok=True) - self.figure.write_image(image_name) - - except FileNotFoundError: - self.logger.error("Can't save to file " + image_name) - except ValueError as ex: - self.logger.error(ex) - else: - self.logger.error("Oops! The figure was not created. Can't save.") + return wind_speed_dir_processed @staticmethod def _boundary_filter(boundary_lower_speed: float, @@ -366,7 +333,7 @@ def write_output_file(self) -> None: # (the input data file) except replace the .data # extension with .points1 extension # otherwise use points_path path - points = dict() + points = {} for trace in self.traces: points[trace.name] = trace.r @@ -397,7 +364,7 @@ def _save_points(points: dict, output_file: str) -> None: :param output_file: the name of the output file """ try: - all_points_formatted = dict() + all_points_formatted = {} for key, value in points.items(): data_formatted = '' for val in value: @@ -410,10 +377,9 @@ def _save_points(points: dict, output_file: str) -> None: with open(output_file, "w+") as f: for key, value in all_points_formatted.items(): f.write('%s:%s\n' % (key, value)) - f.close() except TypeError: - print('Can\'t save points to a file') + print("Can't save points to a file") def main(config_filename=None): @@ -423,22 +389,7 @@ def main(config_filename=None): The location of the input data is defined in either the default or custom config file. """ - params = util.get_params(config_filename) - - # point to data file in the test dir - if 'stat_input' not in params: - params['stat_input'] = str(Path(__file__).parent.parent.parent.parent) + '/test/wind_rose/point_stat_mpr.txt' - - try: - plot = WindRosePlot(params) - plot.save_to_file() - if plot.config_obj.show_in_browser: - plot.show_in_browser() - plot.write_output_file() - plot.logger.info(f"Finished wind rose plot: {datetime.now()}") - - except ValueError as ve: - print(ve) + util.make_plot(config_filename, WindRosePlot) if __name__ == "__main__": diff --git a/metplotpy/plots/wind_rose/wind_rose_config.py b/metplotpy/plots/wind_rose/wind_rose_config.py index ffca30afd..abacc8fb6 100644 --- a/metplotpy/plots/wind_rose/wind_rose_config.py +++ b/metplotpy/plots/wind_rose/wind_rose_config.py @@ -1,20 +1,21 @@ # ============================* - # ** Copyright UCAR (c) 2021 - # ** University Corporation for Atmospheric Research (UCAR) - # ** National Center for Atmospheric Research (NCAR) - # ** Research Applications Lab (RAL) - # ** P.O.Box 3000, Boulder, Colorado, 80307-3000, USA - # ============================* - - - +# ** Copyright UCAR (c) 2021 +# ** University Corporation for Atmospheric Research (UCAR) +# ** National Center for Atmospheric Research (NCAR) +# ** Research Applications Lab (RAL) +# ** P.O.Box 3000, Boulder, Colorado, 80307-3000, USA +# ============================* + + """ Class Name: wind_rose_config.py Holds values set in the Wind Rose config file(s) """ -from ..config import Config +from pathlib import Path + +from ..config import Config class WindRoseConfig(Config): def __init__(self, parameters): @@ -26,7 +27,7 @@ def __init__(self, parameters): Returns: """ - default_conf_filename = "wind_rose_defaults.yaml" + #default_conf_filename = "wind_rose_defaults.yaml" # init common layout super().__init__(parameters) @@ -34,12 +35,11 @@ def __init__(self, parameters): self.title = self.get_config_value('title') self.wind_rose_breaks = self.get_config_value('wind_rose_breaks') self.wind_rose_angle = self.get_config_value('wind_rose_angle') - self.wind_rose_marker_colors = self.get_config_value('wind_rose_marker_colors') + self.wind_rose_marker_colors = self._get_colors('wind_rose_marker_colors') if len(self.wind_rose_marker_colors) != len(self.wind_rose_breaks) : raise ValueError('wind_rose_marker_colors must have the same size as wind_rose_breaks') - self.create_figure = self.get_config_value('create_figure') self.show_legend = self.get_config_value('show_legend') self.angularaxis_tickvals = self.get_config_value('angularaxis_tickvals') self.angularaxis_ticktext = self.get_config_value('angularaxis_ticktext') @@ -51,14 +51,14 @@ def __init__(self, parameters): self.radialaxis_step = self.get_config_value('radialaxis_step') self.stat_input = self.get_config_value('stat_input') - self.plot_width = self.get_config_value('plot_width') - self.plot_height = self.get_config_value('plot_height') + # point to data file in the test dir + if not self.stat_input: + self.stat_input = str(Path(__file__).parent.parent.parent.parent) + '/test/wind_rose/point_stat_mpr.txt' + + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') self.dump_points = self.get_config_value('dump_points') # Optional setting, indicates *where* to save the dump_points_1 file # used by METviewer self.points_path = self.get_config_value('points_path') - self.show_in_browser = self.get_config_value('show_in_browser') - - - diff --git a/nco_requirements.txt b/nco_requirements.txt index 4156ef860..438d18f56 100644 --- a/nco_requirements.txt +++ b/nco_requirements.txt @@ -1,4 +1,3 @@ -kaleido>=1.0.0 matplotlib>=3.10.0 metpy>=1.6.3 netcdf4>=1.7.2 @@ -6,7 +5,6 @@ numpy==2.2.2 scipy>=1.15.1 pandas>=2.2.3 pint>=0.24.4 -plotly>=6.1.1 pytest>=8.3.4 pyyaml>=6.0.2 xarray>=2025.1.2 diff --git a/pyproject.toml b/pyproject.toml index 0bafdc06e..e49338918 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,13 +38,14 @@ testpaths = ["test"] filterwarnings = ["ignore::RuntimeWarning"] [tool.coverage.run] -source = ["metplotpy/plots"] +source = ["metplotpy/plots" + ] omit = [ "config.py", - "config-3.py", + "config-3.py", ] relative_files = true - + [tool.coverage.report] exclude_also = [ "def __repr__", diff --git a/requirements.txt b/requirements.txt index eeb792d5f..10d2f75cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ imageio>=2.37.0 imutils>=0.5.4 -kaleido>=1.0.0 matplotlib>=3.10.0 metpy>=1.6.3 netcdf4>=1.7.2 @@ -9,7 +8,6 @@ numpy>=2.2.2 opencv-python>=4.10.0 pandas>=2.2.3 pint>=0.24.4 -plotly>=6.1.1 pytest>=8.3.4 pyyaml>=6.0.2 scikit-image>=0.25.1 diff --git a/test/bar/bar_with_nones.yaml b/test/bar/bar_with_nones.yaml index 1ddb42d2c..d6b93ee3c 100644 --- a/test/bar/bar_with_nones.yaml +++ b/test/bar/bar_with_nones.yaml @@ -161,4 +161,4 @@ ytlab_perp: 0.5 ytlab_size: 1 show_legend: - -True \ No newline at end of file +- True \ No newline at end of file diff --git a/test/bar/custom_bar.yaml b/test/bar/custom_bar.yaml index eb9df890e..65b035815 100644 --- a/test/bar/custom_bar.yaml +++ b/test/bar/custom_bar.yaml @@ -1,4 +1,5 @@ alpha: 0.05 +plot_caption: 'test_caption' caption_align: 0.0 caption_col: '#333333' caption_offset: 3.0 @@ -55,7 +56,6 @@ mgp: - 0 num_iterations: 1 num_threads: -1 -plot_caption: '' plot_disp: - 'True' - 'True' @@ -142,5 +142,5 @@ plot_filename: !ENV '${TEST_OUTPUT}/bar.png' # Debug and info log level will produce more log output. #log_level: WARNING show_legend: - -True - -True \ No newline at end of file +- True +- True diff --git a/test/bar/threshold_bar.yaml b/test/bar/threshold_bar.yaml index 561d7ccd1..79e553711 100644 --- a/test/bar/threshold_bar.yaml +++ b/test/bar/threshold_bar.yaml @@ -166,4 +166,4 @@ ytlab_orient: 1 ytlab_perp: 0.5 ytlab_size: 3 show_legend: - -True +- True diff --git a/test/box/custom_box.yaml b/test/box/custom_box.yaml index a3749b9bd..8f034170b 100644 --- a/test/box/custom_box.yaml +++ b/test/box/custom_box.yaml @@ -6,7 +6,7 @@ box_outline: 'True' box_pts: 'False' caption_align: 0.0 caption_col: '#333333' -caption_offset: 3.0 +caption_offset: 8.0 caption_size: 0.8 caption_weight: 1 cex: 1 @@ -65,7 +65,7 @@ legend_box: o legend_inset: x: 0.0 y: -0.25 -legend_ncol: 3 +legend_ncol: 1 legend_size: 0.8 line_type: None list_stat_1: @@ -186,7 +186,7 @@ plot_filename: !ENV '${TEST_OUTPUT}/box.png' # Debug and info log level will produce more log output. #log_level: WARNING show_legend: - -True - -True - -True - -True \ No newline at end of file +- True +- True +- True +- True \ No newline at end of file diff --git a/test/conftest.py b/test/conftest.py index 97196fd42..b51ce6d49 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -114,6 +114,40 @@ def module_setup_env(request): # Optional: cleanup after all tests in the module complete +@pytest.fixture(scope="module") +def module_setup_env(request): + """Module-scoped fixture that sets up environment variables once per test module. + + This fixture automatically determines the test directory from the test module's location. + """ + test_dir = str(request.node.path.parent) + print("Setting up environment") + os.environ['TEST_DIR'] = test_dir + + # handle optional test input data + os.environ['TEST_INPUT_EXTRA'] = os.environ.get('METPLOTPY_TEST_INPUT', test_dir) + + # handle multiple test_*.py files in a single directory + # create a subdirectory named after the test file if it doesn't match the test directory + test_name = str(request.node.name).replace('test_', '').replace('.py', '') + if test_name != os.path.basename(test_dir): + test_name = os.path.join(os.path.basename(test_dir), test_name) + + # write test output under METPLOTPY_TEST_OUTPUT if set, otherwise write to test/test_output + # write to a subdirectory named after the plot type + output_dir = os.environ.get('METPLOTPY_TEST_OUTPUT', os.path.join(test_dir, os.pardir)) + output_dir = os.path.join(output_dir, 'test_output', test_name) + + # remove output directory for plot type if it already exists to ensure clean test environment + if os.path.exists(output_dir): + print(f"Removing existing output directory: {output_dir}") + shutil.rmtree(output_dir) + + os.environ['TEST_OUTPUT'] = output_dir + yield + # Optional: cleanup after all tests in the module complete + + @pytest.fixture() def remove_files(): def remove_the_files(test_dir, file_list): @@ -170,4 +204,4 @@ def nc_test_file(tmp_path_factory): @pytest.fixture(autouse=True) def setup_logging(caplog): - caplog.set_level(logging.INFO) + caplog.set_level(logging.DEBUG) diff --git a/test/eclv/custom_eclv.yaml b/test/eclv/custom_eclv.yaml index c79150f3f..dd0a55056 100644 --- a/test/eclv/custom_eclv.yaml +++ b/test/eclv/custom_eclv.yaml @@ -123,5 +123,5 @@ plot_filename: !ENV '${TEST_OUTPUT}/eclv.png' # Debug and info log level will produce more log output. #log_level: WARNING show_legend: - -True - -True \ No newline at end of file +- True +- True \ No newline at end of file diff --git a/test/eclv/custom_eclv_ctc.yaml b/test/eclv/custom_eclv_ctc.yaml index dfae7fea5..0311bf7a3 100644 --- a/test/eclv/custom_eclv_ctc.yaml +++ b/test/eclv/custom_eclv_ctc.yaml @@ -115,5 +115,5 @@ stat_input: !ENV '${TEST_DIR}/eclv_ctc.data' plot_filename: !ENV '${TEST_OUTPUT}/eclv_ctc.png' show_legend: - -True - -True \ No newline at end of file +- True +- True \ No newline at end of file diff --git a/test/eclv/custom_eclv_pct.yaml b/test/eclv/custom_eclv_pct.yaml index bb8a3dc66..c65dbbdde 100644 --- a/test/eclv/custom_eclv_pct.yaml +++ b/test/eclv/custom_eclv_pct.yaml @@ -105,4 +105,4 @@ ytlab_size: 1 stat_input: !ENV '${TEST_DIR}/eclv_pct.data' plot_filename: !ENV '${TEST_OUTPUT}/eclv_pct.png' show_legend: - -True \ No newline at end of file +- True \ No newline at end of file diff --git a/test/ens_ss/custom_ens_ss.yaml b/test/ens_ss/custom_ens_ss.yaml index 5d4dd0034..0d04e1f53 100644 --- a/test/ens_ss/custom_ens_ss.yaml +++ b/test/ens_ss/custom_ens_ss.yaml @@ -1,3 +1,8 @@ +stat_input: !ENV '${TEST_DIR}/ens_ss.data' +plot_filename: !ENV '${TEST_OUTPUT}/ens_ss.png' + +points_path: !ENV '${TEST_OUTPUT}/intermed_files' + caption_align: 0.0 caption_col: '#333333' caption_offset: 3.0 @@ -54,8 +59,6 @@ plot_type: png16m plot_units: in plot_width: 11.0 -points_path: !ENV '${TEST_OUTPUT}/intermed_files' - series_line_style: - '-' - '-' @@ -115,9 +118,6 @@ y2tlab_orient: 1 y2tlab_perp: 1 y2tlab_size: 1.0 -stat_input: !ENV '${TEST_DIR}/ens_ss.data' -plot_filename: !ENV '${TEST_OUTPUT}/ens_ss.png' - # To save your log output to a file, specify a path and filename and uncomment the line below. Make sure you have # permissions to the directory you specify. The default, as specified in the default config file is stdout. #log_filename: ./ens_ss.log @@ -126,5 +126,5 @@ plot_filename: !ENV '${TEST_OUTPUT}/ens_ss.png' # Debug and info log level will produce more log output. #log_level: WARNING show_legend: - -True - -True \ No newline at end of file +- True +- True \ No newline at end of file diff --git a/test/histogram/prob_hist.yaml b/test/histogram/prob_hist.yaml index f4610ca00..40ae551d8 100644 --- a/test/histogram/prob_hist.yaml +++ b/test/histogram/prob_hist.yaml @@ -16,7 +16,7 @@ fixed_vars_vals_input: {} grid_col: '#cccccc' grid_lty: 3 grid_lwd: 1 -grid_on: 'True' +grid_on: 'False' grid_x: listX indy_label: [] indy_plot_val: [] @@ -96,4 +96,4 @@ ytlab_size: 1 # Debug and info log level will produce more log output. #log_level: WARNING show_legend: - -True \ No newline at end of file +- True \ No newline at end of file diff --git a/test/histogram/rank_hist.yaml b/test/histogram/rank_hist.yaml index f1899b450..276742c10 100644 --- a/test/histogram/rank_hist.yaml +++ b/test/histogram/rank_hist.yaml @@ -110,4 +110,6 @@ ytlab_size: 1 # Debug and info log level will produce more log output. #log_level: WARNING show_legend: - -True \ No newline at end of file +- True +- True +- True \ No newline at end of file diff --git a/test/histogram/rel_hist.yaml b/test/histogram/rel_hist.yaml index e68063075..181e855f1 100644 --- a/test/histogram/rel_hist.yaml +++ b/test/histogram/rel_hist.yaml @@ -99,4 +99,4 @@ ytlab_size: 1 # Debug and info log level will produce more log output. #log_level: WARNING show_legend: - -True \ No newline at end of file +- True \ No newline at end of file diff --git a/test/histogram/test_histogram.py b/test/histogram/test_histogram.py deleted file mode 100644 index 246addca0..000000000 --- a/test/histogram/test_histogram.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Tests used to establish the expected behavior of the plotting histogram. -""" - -import numpy as np -import pytest - -from metplotpy.plots.histogram.histogram import Histogram - -@pytest.mark.skip("needs updating to reflect changes to histogram code") -def test_histogram_show_in_browser(): - first_marker = np.random.randn(500) - second_marker = np.random.randn(500) + 1 - data = [] - data.append(first_marker) - data.append(second_marker) - histogram = Histogram(None, data) - assert histogram.get_yaxis_title()['text'] == 'y Axis' - histogram.show_in_browser() - - -@pytest.mark.skip("needs updating to reflect changes to histogram code") -def test_get_settings(settings): - assert settings.figure._data[0]['marker']['color'] == 'white' - assert settings.figure._layout['annotations'][1]['text'] == 'y Axis' - assert settings.figure._data[0]['name'] == 'Series 1' - - -@pytest.fixture -def settings(): - """Initialise values for testing. - - Returns: - dictionary with values of different type - """ - first_marker = np.random.randn(500) - second_marker = np.random.randn(500) + 1 - data = [first_marker, second_marker] - histogram = Histogram(None, data) - return histogram - - -@pytest.mark.skip("needs updating to reflect changes to histogram code") -def test_get_array_dimensions(): - data = None - assert Histogram.get_array_dimensions(data) is None - - first_marker = np.random.randn(500) - second_marker = np.random.randn(500) + 1 - assert Histogram.get_array_dimensions(first_marker) == 1 - - data = [first_marker, second_marker] - assert Histogram.get_array_dimensions(data) == 2 - - -if __name__ == "__main__": - test_get_array_dimensions() - test_histogram_show_in_browser() - diff --git a/test/hovmoeller/custom_hovmoeller.yaml b/test/hovmoeller/custom_hovmoeller.yaml index 4a3917e54..89dafe0c6 100644 --- a/test/hovmoeller/custom_hovmoeller.yaml +++ b/test/hovmoeller/custom_hovmoeller.yaml @@ -1,16 +1,16 @@ plot_filename: !ENV '${TEST_OUTPUT}/hovmoeller_custom_plot.png' -input_data_file: /path/to/WorkingDir/precip.erai.sfc.1p0.2x.2014-2016.nc -plot_height: 400 -plot_width: 600 -title: Custom ERAI Precip -title_size: 10 -xy_label_font_size: 10 +input_data_file: !ENV '${TEST_INPUT_EXTRA}/hovmoeller/precip.erai.sfc.1p0.2x.2014-2016.nc' +plot_height: 800 +plot_width: 1200 +title: ERAI Precipitation +title_size: 20 +xy_label_font_size: 20 -date_start: 2016-02-01 -date_end: 2016-03-03 +date_start: 2016-01-01 +date_end: 2016-03-31 -lat_max: 4 -lat_min: -4 +lat_max: 5 +lat_min: -5 var_name: precip var_units: mm / day diff --git a/test/hovmoeller/test_hovmoeller.py b/test/hovmoeller/test_hovmoeller.py index f9e1bf3af..300bd9bd7 100644 --- a/test/hovmoeller/test_hovmoeller.py +++ b/test/hovmoeller/test_hovmoeller.py @@ -14,7 +14,7 @@ def dict_to_yaml(data_dict, output_yaml): return output_yaml -@pytest.mark.skip() +@pytest.mark.skip("Test data unavailable") def test_default_plot_images_match(module_setup_env, remove_files): ''' Compare an expected plot with the @@ -38,8 +38,13 @@ def test_default_plot_images_match(module_setup_env, remove_files): # assert comparison.mssim == 1 -@pytest.mark.skip("needs large netCDF file to run") def test_custom_plot_created(module_setup_env, remove_files): + if not os.environ.get('METPLOTPY_TEST_INPUT'): + pytest.skip( + "METPLOTPY_TEST_INPUT not set. " + "This is needed to get the large input data file to run this test" + ) + expected_file = "hovmoeller_custom_plot.png" remove_files(os.environ['TEST_OUTPUT'], [expected_file]) @@ -67,14 +72,13 @@ def make_config(nc_file, out_file): "date_end": "2024-09-26", "contour_min": 0.1, "contour_max": 10, - "unit_converion": 1, + "unit_conversion": 250, "title": "test plot", - "create_html": "true", } return config -def test_hovmoeller(module_setup_env, remove_files, nc_test_file, assert_json_equal, tmp_path_factory): +def test_hovmoeller(module_setup_env, remove_files, nc_test_file, tmp_path_factory): output_dir = os.environ['TEST_OUTPUT'] out_file = os.path.join(output_dir, "hovmoeller_test.png") @@ -98,18 +102,6 @@ def test_hovmoeller(module_setup_env, remove_files, nc_test_file, assert_json_eq plot_obj.save_to_file() assert os.path.isfile(out_file) - # check html write out - plot_obj.write_html() - base_name, _ = os.path.splitext(config['plot_filename']) - out_html = f"{base_name}.html" - assert os.path.isfile(out_html) - - # finally check json plot values - # to regenerate json file run: - json_output = os.path.join(output_dir, "hovmoeller_test.json") - plot_obj.figure.write_json(json_output) - assert_json_equal(plot_obj.figure, json_output) - def test_get_lat_str(module_setup_env, nc_test_file, tmp_path_factory): output_yaml = tmp_path_factory.mktemp("data") / "test_hovmoeller.yaml" diff --git a/test/line/test_line_plot.py b/test/line/test_line_plot.py index 648b6dae5..e3f1f2ef2 100644 --- a/test/line/test_line_plot.py +++ b/test/line/test_line_plot.py @@ -37,7 +37,6 @@ "line_groups.png", "line_groups.points1", "line_groups.points2", - "line_groups.html", ]), ("custom_line_groups2.yaml", [ "line_groups2.png", diff --git a/test/pytest.ini b/test/pytest.ini index 8fdc98ba0..627c393c9 100644 --- a/test/pytest.ini +++ b/test/pytest.ini @@ -1,3 +1,4 @@ [pytest] - +log_cli = true +log_cli_level = DEBUG addopts = -p no:warnings diff --git a/test/reliability_diagram/custom_reliability_points1.yaml b/test/reliability_diagram/custom_reliability_points1.yaml index 8ea996820..657093ba7 100644 --- a/test/reliability_diagram/custom_reliability_points1.yaml +++ b/test/reliability_diagram/custom_reliability_points1.yaml @@ -42,7 +42,7 @@ fixed_vars_vals_input: {} grid_col: '#cccccc' grid_lty: 3 grid_lwd: 1 -grid_on: 'True' +grid_on: 'False' grid_x: listX indy_label: [] indy_stagger_1: 'True' @@ -58,6 +58,7 @@ indy_vals: - '0.7' - '0.8' - '0.9' +- '1.0' indy_var: thresh_i inset_hist: 'True' legend_box: o diff --git a/test/reliability_diagram/custom_reliability_use_defaults.yaml b/test/reliability_diagram/custom_reliability_use_defaults.yaml deleted file mode 100644 index b2a2d711a..000000000 --- a/test/reliability_diagram/custom_reliability_use_defaults.yaml +++ /dev/null @@ -1,187 +0,0 @@ -add_noskill_line: 'True' -add_reference_line: 'True' -add_skill_line: 'True' -alpha: 0.05 -box_avg: 'False' -box_boxwex: 0.2 -box_notch: 'False' -box_outline: 'True' -box_pts: 'False' -caption_align: 0.0 -caption_col: '#ff1493' -caption_offset: 3.0 -caption_size: 1.0 -caption_weight: 1 -cex: 1 -colors: -- '#ff0000' -- '#00ff7f' -- '#8000ff' -con_series: -- 1 -- 1 -- 1 -create_html: 'False' -derived_series_1: [] -derived_series_2: [] -dump_points_1: 'True' -dump_points_2: 'False' -event_equal: 'True' -fcst_var_val_1: - APCP_03_ENS_FREQ_ge12.7: - - PSTD_CALIBRATION - - PSTD_BASER - - PSTD_NI -fcst_var_val_2: null -fixed_vars_vals_input: {} -grid_col: '#cccccc' -grid_lty: 3 -grid_lwd: 1 -grid_on: 'True' -grid_x: listX -indy_label: [] -indy_stagger_1: 'True' -indy_stagger_2: 'False' -indy_vals: -- '0' -- '0.1' -- '0.2' -- '0.3' -- '0.4' -- '0.5' -- '0.6' -- '0.7' -- '0.8' -- '0.9' -indy_var: thresh_i -inset_hist: 'True' -legend_box: o -legend_inset: - x: 0.0 - y: -0.25 -legend_ncol: 3 -legend_size: 0.8 -line_type: pct -list_stat_1: -- PSTD_CALIBRATION -- PSTD_BASER -- PSTD_NI -list_stat_2: [] -list_static_val: - fcst_var: APCP_03_ENS_FREQ_ge12.7 -mar: -- 8 -- 4 -- 5 -- 4 -method: perc -mgp: -- 1 -- 1 -- 0 -num_iterations: 1000 -num_threads: -1 -plot_caption: capcap uuu -plot_ci: -- boot -- boot -- none -plot_disp: -- 'True' -- 'True' -- 'True' -plot_height: 8.5 -plot_res: 72 -plot_stat: median -plot_type: png16m -plot_units: in -plot_width: 11.0 -random_seed: null -rely_event_hist: 'True' -series_line_style: -- '-' -- '-' -- '-' -series_line_width: -- 1 -- 1 -- 1 -series_order: -- 1 -- 2 -- 3 -series_symbols: -- . -- . -- . -series_type: -- b -- b -- b -series_val_1: - model: - - rap0_3_spptens - - rap0_7_spptens -series_val_2: {} -show_nstats: 'False' -show_signif: -- 'False' -- 'False' -- 'False' -summary_curves: -- mean -sync_yaxes: 'False' -title: The
test -title_align: 0.5 -title_offset: -2 -title_size: 1.6 -title_weight: 4.0 -user_legend: [] -variance_inflation_factor: 'False' -vert_plot: 'False' -x2lab_align: 0.5 -x2lab_offset: -0.5 -x2lab_size: 0.8 -x2lab_weight: 1 -x2tlab_horiz: 0.5 -x2tlab_orient: 1 -x2tlab_perp: 1 -x2tlab_size: 0.8 -xaxis: some x -xaxis_reverse: 'False' -xlab_align: 0.5 -xlab_offset: 2 -xlab_size: 1 -xlab_weight: 2 -xlim: [] -xtlab_decim: 0 -xtlab_horiz: 0.5 -xtlab_orient: 1 -xtlab_perp: -0.75 -xtlab_size: 1 -y2lab_align: 0.5 -y2lab_offset: 1 -y2lab_size: 1 -y2lab_weight: 1 -y2lim: [] -y2tlab_horiz: 0.5 -y2tlab_orient: 1 -y2tlab_perp: 1 -y2tlab_size: 1.0 -yaxis_1: some y -yaxis_2: '' -ylab_align: 0.5 -ylab_offset: -2 -ylab_size: 2 -ylab_weight: 3 -ylim: [] -ytlab_horiz: 0.5 -ytlab_orient: 1 -ytlab_perp: 0.5 -ytlab_size: 1 -plot_filename: !ENV '${TEST_DIR}/reliability.png' -stat_input: !ENV '${TEST_DIR}/reliability.data' -show_legend: -- 'True' -- 'True' -- 'True' diff --git a/test/revision_box/custom_revision_box.yaml b/test/revision_box/custom_revision_box.yaml index 2ae6a252e..c3fae52dd 100644 --- a/test/revision_box/custom_revision_box.yaml +++ b/test/revision_box/custom_revision_box.yaml @@ -7,7 +7,7 @@ alpha: 0.05 box_avg: 'False' box_boxwex: 0.2 box_notch: 'False' -box_outline: 'True' +box_outline: 'False' box_pts: 'False' caption_align: 0.0 caption_col: '#333333' diff --git a/test/revision_series/custom_revision_series.yaml b/test/revision_series/custom_revision_series.yaml index 66b38deb5..6b304ea89 100644 --- a/test/revision_series/custom_revision_series.yaml +++ b/test/revision_series/custom_revision_series.yaml @@ -32,13 +32,13 @@ grid_lwd: 1 grid_on: 'True' grid_x: listX indy_label: -- '2011-07-02 03:00:00' -- '2011-07-02 06:00:00' -- '2011-07-02 09:00:00' -- '2011-07-02 12:00:00' -- '2011-07-02 15:00:00' -- '2011-07-02 18:00:00' -- '2011-07-02 21:00:00' +- '07-02 03' +- '07-02 06' +- '07-02 09' +- '07-02 12' +- '07-02 15' +- '07-02 18' +- '07-02 21' indy_plot_val: [] indy_vals: - '2011-07-02 03:00:00' @@ -53,7 +53,7 @@ legend_box: o legend_inset: x: 0.0 y: -0.25 -legend_ncol: 3 +legend_ncol: 1 legend_size: 0.8 line_type: None list_stat_1: diff --git a/test/roc_diagram/CTC_ROC_thresh.yaml b/test/roc_diagram/CTC_ROC_thresh.yaml index 9a1c5bb65..3f4bc3760 100644 --- a/test/roc_diagram/CTC_ROC_thresh.yaml +++ b/test/roc_diagram/CTC_ROC_thresh.yaml @@ -32,7 +32,7 @@ fixed_vars_vals_input: {} grid_col: '#cccccc' grid_lty: 3 grid_lwd: 1 -grid_on: 'True' +grid_on: 'False' grid_x: listX indy_label: [] indy_stagger_1: 'False' diff --git a/test/roc_diagram/custom_roc_diagram.yaml b/test/roc_diagram/custom_roc_diagram.yaml index 3a1e463d5..6831bad8f 100644 --- a/test/roc_diagram/custom_roc_diagram.yaml +++ b/test/roc_diagram/custom_roc_diagram.yaml @@ -1,4 +1,6 @@ ---- +stat_input: !ENV '${TEST_DIR}/plot_20200507_074426.data' +plot_filename: !ENV '${TEST_OUTPUT}/roc_diagram_custom.png' + # Write points file. Set to True for METviewer use, # False otherwise dump_points_1: 'False' @@ -98,8 +100,6 @@ reverse_connection_order: False # Make the plot generated in METviewer interactive create_html: 'True' -stat_input: !ENV '${TEST_DIR}/plot_20200507_074426.data' -plot_filename: !ENV '${TEST_DIR}/roc_diagram_custom.png' # To save your log output to a file, specify a path and filename and uncomment the line below. Make sure you have # permissions to the directory you specify. The default, as specified in the default config file is stdout. diff --git a/test/roc_diagram/test_roc_diagram.py b/test/roc_diagram/test_roc_diagram.py index b44ea918e..4569d356e 100644 --- a/test/roc_diagram/test_roc_diagram.py +++ b/test/roc_diagram/test_roc_diagram.py @@ -16,8 +16,9 @@ ("CTC_ROC_thresh_dump_pts.yaml", ["CTC_ROC_thresh_dump_pts.png", "intermed_files/CTC_ROC_thresh.points1"]), ("CTC_ROC_summary.yaml", ["CTC_ROC_summary.png", "intermed_files/CTC_ROC_summary.points1"]), ("CTC_ROC_thresh_reverse_pts.yaml", ["CTC_ROC_thresh_reverse_pts.png", "intermed_files_reverse_pts/CTC_ROC_thresh.points1"]), - ("PCT_ROC.yaml", ["PCT_ROC.png", "PCT_ROC.html"]), - ("CTC_wind_reformatted.yaml", ["CTC_wind_reformatted.png", "CTC_wind_reformatted.html"]), + ("PCT_ROC.yaml", ["PCT_ROC.png"]), + ("CTC_wind_reformatted.yaml", ["CTC_wind_reformatted.png"]), + ("custom_roc_diagram.yaml", ["roc_diagram_custom.png"]), ]) def test_roc_diagram(module_setup_env, remove_files, input_yaml, expected_files): """Checking that the plot file is getting created but the points1 file is NOT""" @@ -115,7 +116,7 @@ def test_ee_returns_empty_df(module_setup_env, capsys, remove_files): "INFO: No resulting data after performing event equalization of axis 1 INFO: No points to plot (most likely as a result of event equalization). " """ - expected_files = ['CTC_ROC_ee.png', 'CTC_ROC_ee.html'] + expected_files = ['CTC_ROC_ee.png'] remove_files(os.environ['TEST_OUTPUT'], expected_files) custom_config_filename = f"{cwd}/CTC_ROC_ee.yaml" @@ -136,7 +137,7 @@ def test_pct_no_warnings(module_setup_env, remove_files): Verify that the ROC diagram is generated without FutureWarnings ''' - remove_files(os.environ['TEST_OUTPUT'], ['PCT_ROC.png', 'PCT_ROC.html']) + remove_files(os.environ['TEST_OUTPUT'], ['PCT_ROC.png']) custom_config_filename = f"{cwd}/PCT_ROC.yaml" print("\n Testing for FutureWarning..") diff --git a/test/scatter/test_scatter.py b/test/scatter/test_scatter.py index f4d963f20..c392f74c6 100644 --- a/test/scatter/test_scatter.py +++ b/test/scatter/test_scatter.py @@ -23,7 +23,7 @@ def test_scatter(module_setup_env, remove_files): ] remove_files(os.environ['TEST_OUTPUT'], expected_files) - scatter.main(f"{os.environ['TEST_DIR']}/test_scatter_mpr.yaml") + assert scatter.main(f"{os.environ['TEST_DIR']}/test_scatter_mpr.yaml") for expected_file in expected_files: assert os.path.isfile(f"{os.environ['TEST_OUTPUT']}/{expected_file}") diff --git a/test/skew_t/test_skew_t.py b/test/skew_t/test_skew_t.py index 878537067..1f46b68d8 100644 --- a/test/skew_t/test_skew_t.py +++ b/test/skew_t/test_skew_t.py @@ -1,129 +1,35 @@ import pytest import os -import re from metplotpy.plots.skew_t import skew_t as skew_t def test_skew_t(module_setup_env): + expected_times = { + '2023010100': range(0, 61, 6), + '2023010106': range(0, 49, 6), + } + expected_files = [] + for init, leads in expected_times.items(): + for lead in leads: + expected_files.append(f'ssh052023_avno_doper_{init}_diag_{lead}_hr.png') + custom_config_filename = os.path.join(os.environ['TEST_DIR'], "test_skew_t.yaml") skew_t.main(custom_config_filename) - # Verify that files for the ssh052023 data exists for the 0,6, 12,18,24, 30, 42, - # 48, 54, and 60 hour data. - output_dir = os.environ['TEST_OUTPUT'] + # Verify that files for the ssh052023 data exists for + # the 0, 6, 12, 18, 24, 30, 42, 48, 54, and 60 hour data. + # Some of these data files have incomplete data so check for the expected hour plots. - # Some of these data files have incomplete data so check for the expected hour - # plots. - - print(f"Output dir: {output_dir}") file_ext = '.png' files_of_interest = [] - for root, _, files in os.walk(output_dir): + for root, _, files in os.walk(os.environ['TEST_OUTPUT']): for item in files: if item.endswith(file_ext): - # print(f"Item of interest: {item}") full_file = os.path.join(root, item) base_file = os.path.basename(full_file) files_of_interest.append(base_file) - _check_files_exist(files_of_interest) - _check_files_not_created(files_of_interest) - _check_empty_input(files_of_interest) - - -def _check_files_exist(files_of_interest): - ''' - Checking that only the expected plot files are getting created and - input files with only fill/missing data are not created. - ''' - # List of files for the sh052023 data (which is missing data for hours 66-240). - # Config file is requesting all the available sounding hours - data_some_missing_data = { - '2023010100': range(0, 61, 6), - '2023010106': range(0, 49, 6), - } - - # Create a list of expected base file names with their expected hours. - expected_base_filenames = [] - # Expected base for expected plot output name of format: - # ssh_052023_avno_doper_202301010[0|6]_diag_[0-9]{1,2}_hr - for filetime, expected_hours in data_some_missing_data.items(): - for cur_hr in expected_hours: - base_hr = f'ssh052023_avno_doper_{filetime}_diag_{cur_hr}_hr' - expected_base_filenames.append(base_hr) - - # Subset only the files that correspond to the sh052023 data - subset_files_of_interest = [] - for cur_file in files_of_interest: - match_found = re.match(r'(ssh052023_.*).png', cur_file) - if match_found: - subset_files_of_interest.append(match_found.group(1)) - - # Verify that the expected plots were generated. - num_found = 0 - for expected in expected_base_filenames: - if expected in subset_files_of_interest: - num_found += 1 - - assert len(expected_base_filenames) == num_found - - -def _check_files_not_created(files_of_interest): - ''' - Checking that input files with only fill/missing data are not created. - ''' - # List of files with no sounding data (9999 for all fields and times) - no_sounding_data = ['ssh162023_avno_doper_2023022712_diag', - 'ssh162023_avno_doper_2023022800_diag', - 'ssh162023_avno_doper_2023022806_diag', - 'ssh162023_avno_doper_2023030706_diag'] - - # Subset the files of interest to just sh162023 output. - subsetted_files_of_interest = [] - for cur in files_of_interest: - match = re.match(r'^ssh162023', cur) - if match: - subsetted_files_of_interest.append(cur) - - # Verify that there aren't any plots created for the files with missing sounding - # data. First, create a list of the base names of the plots that were created and - # that correspond to the input data of interest (i.e. the sh162023_*.dat data). - subsetted_basenames = [] - for cur_plot in subsetted_files_of_interest: - match = re.match(r'(ssh162023_avno_doper_20230[0-9]{5}_diag)_*._hr.png', - cur_plot) - if match: - subsetted_basenames.append(match.group(1)) - - # Count how often we find a basename of a plot that we didn't expect to create with - # the list of base names of plots that were created. - fail_counter = 0 - for cur in no_sounding_data: - if cur in subsetted_basenames: - fail_counter += 1 - - assert fail_counter == 0 - - -def _check_empty_input(files_of_interest): - ''' - Checking that empty input file is not creating any plots. - ''' - # List of empty files - no_data_empty_file = ['sal092022_avno_doper_2022092800_diag'] - - # Verify that there aren't any plots created for the file with missing sounding - # data. - - # First, subset the files of interest to just sal0920223 output. - subsetted_files_of_interest = [] - for cur in files_of_interest: - match = re.match(r'^sal092022', cur) - if match: - subsetted_files_of_interest.append(cur) - - match_found = re.match(r'^sal092022_avno_doper_2022092800_diag', - no_data_empty_file[0]) - # The output file was created when it shouldn't have been, fail. - assert match_found not in subsetted_files_of_interest + assert len(expected_files) == len(files_of_interest) + for expected_file in expected_files: + assert expected_file in files_of_interest diff --git a/test/tcmpr_plots/tcmpr_multi.yaml b/test/tcmpr_plots/tcmpr_multi.yaml index 0fb6fa0f3..78c4b2db4 100755 --- a/test/tcmpr_plots/tcmpr_multi.yaml +++ b/test/tcmpr_plots/tcmpr_multi.yaml @@ -132,7 +132,6 @@ plot_type_list: - 'median' - 'rank' - rp_diff: # - '>=1' - '>=100' diff --git a/test/wind_rose/wind_rose_custom.yaml b/test/wind_rose/wind_rose_custom.yaml index c046047ad..f72758b05 100644 --- a/test/wind_rose/wind_rose_custom.yaml +++ b/test/wind_rose/wind_rose_custom.yaml @@ -24,7 +24,6 @@ wind_rose_marker_colors: - 'rgb(253,193,115)' - 'rgb(236,94,74)' - 'rgb(159,11,68)' -create_figure: True show_legend: True angularaxis_tickvals: - 0 @@ -37,8 +36,6 @@ angularaxis_ticktext: - 'S' - 'W' -show_in_browser: False - # To save your log output to a file, specify a path and filename and uncomment the line below. Make sure you have # permissions to the directory you specify. The default, as specified in the default config file is stdout. #log_filename: ./wind_rose.log diff --git a/test_requirements.txt b/test_requirements.txt deleted file mode 100644 index eeb792d5f..000000000 --- a/test_requirements.txt +++ /dev/null @@ -1,21 +0,0 @@ - -imageio>=2.37.0 -imutils>=0.5.4 -kaleido>=1.0.0 -matplotlib>=3.10.0 -metpy>=1.6.3 -netcdf4>=1.7.2 -numpy>=2.2.2 -opencv-python>=4.10.0 -pandas>=2.2.3 -pint>=0.24.4 -plotly>=6.1.1 -pytest>=8.3.4 -pyyaml>=6.0.2 -scikit-image>=0.25.1 -scipy>=1.15.1 -xarray>=2025.1.2 -eofs>=2.0.0 -cartopy>=0.24.0 -scikit-learn>=1.6.1 -cmocean>=4.0.3