From 234d4e81f28e57c93d432308ef6a32fa1fca84ca Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 30 Sep 2025 17:50:48 -0600 Subject: [PATCH 01/92] update version for rc1 release --- metplotpy/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metplotpy/_version.py b/metplotpy/_version.py index e749bd8c..a5c31d3b 100644 --- a/metplotpy/_version.py +++ b/metplotpy/_version.py @@ -1 +1 @@ -__version__ = "3.1.0-beta1-dev" +__version__ = "3.2.0-rc1" From 329fb557228143f963f7a3e1037dc847b837db78 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:01:38 -0600 Subject: [PATCH 02/92] remove hard-coded version because it is read from metplotpy/_version.py --- docs/conf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 71c89eff..1bb37e21 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,6 @@ copyright = '2025, NSF NCAR' author = 'UCAR/NSF NCAR, NOAA, CSU/CIRA, and CU/CIRES' author_list = 'Adriaansen, D., C. Kalb, D. Fillmore, T. Jensen, L. Goodrich, M. Win-Gildenmeister, T. Burek, and H. Fisher' -version = '3.2.0-rc1' verinfo = version release = f'{version}' release_year = '2025' From f7e7e8521a78d60fcf879fd737f9bb9563f27519 Mon Sep 17 00:00:00 2001 From: MWin <3753118+bikegeek@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:24:06 -0700 Subject: [PATCH 03/92] Feature 550 v3.2.0 (#551) * Updated the release_date * Removed the -rc1 suffix for coordinated release * Updated for coordinated release * Rotate authorship for coordinated release * Update year of copyright date * Per #550, modify formatting of release notes header * Per #550, attempting to resolve SonarQube error with regard to coverage Add source and omit settings for coverage tool * Per #550, correcting syntax Updated source paths for coverage tool and adjusted omit settings. * Per #550, update coverage omit pattern for _netCDF4.pyx * Per #550, another attempt to resolve the SonarQube error * Per #550, another attempt to resolve the SonarQube error Updated coverage configuration to omit specific files. * Update pyproject.toml move the omit from the tool.coverage.report to the tool.coverage.run to see if the histogram_2d/src/netCDF4 can be omitted from the trace * Update pyproject.toml try [run] source=. to prevent measuring code outside of project * remove the [run] block, this causes issues with another GHA * add source=. to only check the METplotpy source code and not third party libraries * Use different syntax to omit the histogram_2d/src from report * Return to original state, moved omit to the .coveragec config * Update pyproject.toml remove extraneous line * Update release-notes.rst * Update conf.py --------- Co-authored-by: Julie Prestopnik --- .coveragerc | 1 + docs/Users_Guide/release-notes.rst | 5 +++-- docs/conf.py | 4 ++-- docs/copyright.txt | 2 +- metplotpy/_version.py | 2 +- pyproject.toml | 7 ++++--- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.coveragerc b/.coveragerc index 4edd7b1a..230ba599 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,3 @@ [run] relative_files = True +omit=*/test/histogram_2d/src/* \ No newline at end of file diff --git a/docs/Users_Guide/release-notes.rst b/docs/Users_Guide/release-notes.rst index 9f4a6ad8..57dd341c 100644 --- a/docs/Users_Guide/release-notes.rst +++ b/docs/Users_Guide/release-notes.rst @@ -7,8 +7,9 @@ describes the bugfix, enhancement, or new feature: `METplotpy GitHub issues. `_ -METplotpy Version 3.2.0-RC1 release notes (20250930) -==================================================== + +METplotpy Version 3.2.0 Release Notes (20251114) +================================================ .. dropdown:: New Plots diff --git a/docs/conf.py b/docs/conf.py index 1bb37e21..13677c70 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,12 +24,12 @@ project = 'METplotpy' copyright = '2025, NSF NCAR' author = 'UCAR/NSF NCAR, NOAA, CSU/CIRA, and CU/CIRES' -author_list = 'Adriaansen, D., C. Kalb, D. Fillmore, T. Jensen, L. Goodrich, M. Win-Gildenmeister, T. Burek, and H. Fisher' +author_list = 'Kalb, C., D. Fillmore, T. Jensen, L. Goodrich, M. Win-Gildenmeister, T. Burek, H. Fisher, D. Adriaansen' verinfo = version release = f'{version}' release_year = '2025' -release_date = f'{release_year}-09-30' +release_date = f'{release_year}-11-14' copyright = f'{release_year}, {author}' diff --git a/docs/copyright.txt b/docs/copyright.txt index 75eec2d4..88479cb9 100644 --- a/docs/copyright.txt +++ b/docs/copyright.txt @@ -1,5 +1,5 @@ # ============================* -# ** Copyright UCAR (c) 1992 - 2024 +# ** Copyright UCAR (c) 1992 - 2025 # ** University Corporation for Atmospheric Research (UCAR) # ** National Center for Atmospheric Research (NCAR) # ** Research Applications Lab (RAL) diff --git a/metplotpy/_version.py b/metplotpy/_version.py index a5c31d3b..11731085 100644 --- a/metplotpy/_version.py +++ b/metplotpy/_version.py @@ -1 +1 @@ -__version__ = "3.2.0-rc1" +__version__ = "3.2.0" diff --git a/pyproject.toml b/pyproject.toml index 0bafdc06..e4933891 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__", From 2667b4c0b30a39e583f6a2df3635c7058a6bb876 Mon Sep 17 00:00:00 2001 From: jprestop Date: Tue, 9 Dec 2025 13:43:40 -0700 Subject: [PATCH 04/92] Update for 3.2 release and Python 3.12 --- .../installation/modulefiles/3.1.0_derecho | 28 ----------------- .../installation/modulefiles/3.1.0_gaea | 25 ---------------- .../installation/modulefiles/3.1.0_hera | 25 ---------------- .../installation/modulefiles/3.1.0_jet | 25 ---------------- .../installation/modulefiles/3.1.0_ursa | 25 ---------------- .../installation/modulefiles/3.1.0_wcoss2 | 30 ------------------- .../{3.1.0_casper => 3.2.0_casper} | 26 ++++++++-------- .../installation/modulefiles/3.2.0_gaea | 25 ++++++++++++++++ .../installation/modulefiles/3.2.0_hera | 25 ++++++++++++++++ .../{3.1.0_hercules => 3.2.0_hercules} | 30 +++++++++---------- .../installation/modulefiles/3.2.0_jet | 25 ++++++++++++++++ .../modulefiles/{3.1.0_orion => 3.2.0_orion} | 28 ++++++++--------- .../installation/modulefiles/3.2.0_wcoss2 | 30 +++++++++++++++++++ 13 files changed, 147 insertions(+), 200 deletions(-) delete mode 100644 internal/scripts/installation/modulefiles/3.1.0_derecho delete mode 100644 internal/scripts/installation/modulefiles/3.1.0_gaea delete mode 100644 internal/scripts/installation/modulefiles/3.1.0_hera delete mode 100644 internal/scripts/installation/modulefiles/3.1.0_jet delete mode 100644 internal/scripts/installation/modulefiles/3.1.0_ursa delete mode 100644 internal/scripts/installation/modulefiles/3.1.0_wcoss2 rename internal/scripts/installation/modulefiles/{3.1.0_casper => 3.2.0_casper} (65%) create mode 100644 internal/scripts/installation/modulefiles/3.2.0_gaea create mode 100644 internal/scripts/installation/modulefiles/3.2.0_hera rename internal/scripts/installation/modulefiles/{3.1.0_hercules => 3.2.0_hercules} (59%) create mode 100644 internal/scripts/installation/modulefiles/3.2.0_jet rename internal/scripts/installation/modulefiles/{3.1.0_orion => 3.2.0_orion} (60%) create mode 100644 internal/scripts/installation/modulefiles/3.2.0_wcoss2 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 8d271645..00000000 --- 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 cc26d653..00000000 --- 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 07f1c960..00000000 --- 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 85bf61c7..00000000 --- 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 9ffb356e..00000000 --- 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 ea6ea5b4..00000000 --- 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 2d786c42..ea67c53d 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 00000000..16ec5975 --- /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 00000000..b94c8609 --- /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 2a424ebe..26d40f34 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 00000000..40f620f9 --- /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 b233f0a9..98302409 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_wcoss2 b/internal/scripts/installation/modulefiles/3.2.0_wcoss2 new file mode 100644 index 00000000..d20fed4b --- /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 From aa0bc124d4e8c122400a6f15432866851c482799 Mon Sep 17 00:00:00 2001 From: Julie Prestopnik Date: Mon, 15 Dec 2025 20:34:28 -0700 Subject: [PATCH 05/92] Add modulefile for smac-c5 --- .../installation/modulefiles/3.2.0_smac-c5 | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 internal/scripts/installation/modulefiles/3.2.0_smac-c5 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 00000000..f6cc6acf --- /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 From dba3babcdd4f7292e4d8119502db8ce0bf720c8f Mon Sep 17 00:00:00 2001 From: John Halley Gotway Date: Tue, 23 Dec 2025 11:12:34 -0700 Subject: [PATCH 06/92] Hotfix to add missing METcalcpy links to the RTD navigation. --- docs/index.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index 730be49c..87641ca9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -176,6 +176,13 @@ To cite this documentation in publications, please refer to the METplotpy User's Users_Guide/index Contributors_Guide/index +.. toctree:: + :hidden: + :caption: METcalcpy + + User's Guide + Contributor's Guide + .. toctree:: :hidden: :caption: METdataio From 0e233516439501e8a1eff6630ec5cc8d3b9b4d71 Mon Sep 17 00:00:00 2001 From: bikegeek Date: Wed, 21 Jan 2026 13:53:04 -0700 Subject: [PATCH 07/92] Issue #556 this is the original BasePlot class, containing Plotly and Matplotlib support --- metplotpy/plots/base_plot_plotly.py | 495 ++++++++++++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 metplotpy/plots/base_plot_plotly.py diff --git a/metplotpy/plots/base_plot_plotly.py b/metplotpy/plots/base_plot_plotly.py new file mode 100644 index 00000000..03a84ef1 --- /dev/null +++ b/metplotpy/plots/base_plot_plotly.py @@ -0,0 +1,495 @@ +# ============================* + # ** 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 + # ============================* + + + +# !/usr/bin/env conda run -n blenny_363 python +""" +Class Name: base_plot.py + """ +__author__ = 'Tatiana Burek' + +import os +import logging +import warnings +import numpy as np +import yaml +from typing import Union +import kaleido +import metplotpy.plots.util +from metplotpy.plots.util import strtobool +from .config import Config +from metplotpy.plots.context_filter import ContextFilter + +# 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. + +# 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 + +# Check if the PRE_LOAD_CHROME env variable exists +aquire_chrome = False + +turn_on_logging = strtobool('LOG_BASE_PLOT') +# Log when Chrome is downloaded at runtime +if turn_on_logging is True: + log = logging.getLogger("base_plot") + log.setLevel(logging.INFO) + + formatter = logging.Formatter("%(asctime)s [%(levelname)s] | %(name)s | %(message)s") + + # 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: + 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 + like title, axis, legend. + + To use: + 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 + + Args: + @param parameters - dictionary containing user defined parameters + @param default_conf_filename - the name of the default config file + for the plot type that is a subclass. + + + """ + + # Determine location of the default YAML config files and then + # read defaults stored in YAML formatted file into the dictionary + if 'METPLOTPY_BASE' in os.environ: + location = os.path.join(os.environ['METPLOTPY_BASE'], 'metplotpy/plots/config') + else: + location = os.path.realpath(os.path.join(os.path.dirname(__file__), 'config')) + + with open(os.path.join(location, default_conf_filename), 'r') as stream: + try: + defaults = yaml.load(stream, Loader=yaml.FullLoader) + except yaml.YAMLError as exc: + print(exc) + + # merge user defined parameters into defaults if they exist + if parameters: + self.parameters = {**defaults, **parameters} + else: + self.parameters = defaults + + self.figure = None + 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 + in the METviewer tool + + Args: + + Returns: + - a dictionary that holds the legend settings that + are set in METviewer + """ + legend_box = self.get_config_value('legend_box').lower() + if legend_box == 'o': + # Draws a box around the legend + borderwidth = 1 + elif legend_box == 'n': + # Do not draw border around the legend labels. + borderwidth = 0 + + legend_ncol = self.get_config_value('legend_ncol') + if legend_ncol > 1: + legend_orientation = "h" + else: + 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) + + 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 + """ + 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 + + 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 + """ + 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: + + 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. + Looks for parameter in the user parameter dictionary + + Args: + @ param args - chain of keys that defines a key to the parameter + + Returns: + - a value for the parameter of None + """ + + return self._get_nested(self.parameters, args) + + def _get_nested(self, data, args): + """Recursive function that uses the tuple with keys to find a value + in multidimensional dictionary. + + Args: + @data - dictionary for the lookup + @args - a tuple with keys + + Returns: + - a value for the parameter of None + """ + + if args and data: + + # get value for the first key + element = args[0] + if element: + value = data.get(element) + + # if the size of key tuple is 1 - the search is over + if len(args) == 1: + return value + + # if the size of key tuple is > 1 - search using other keys + return self._get_nested(value, args[1:]) + return None + + def get_img_bytes(self): + """Returns an image as a bytes object in a format specified in the config file + + Args: + + Returns: + - an image as a bytes object + """ + if self.figure: + return self.figure.to_image(format=self.get_config_value('image_format'), + width=self.get_config_value('width'), + height=self.get_config_value('height'), + scale=self.get_config_value('scale')) + + 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)) + if not os.path.exists(dirname): + os.mkdir(dirname) + 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 remove_file(self): + """Removes previously made image file . + """ + image_name = self.get_config_value('plot_filename') + + # remove the old file if it exist + 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: + + Returns: + + """ + 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") + + 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: + """ + 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) + + @staticmethod + def get_array_dimensions(data): + """Returns the dimension of the array + + Args: + @param data - input array + Returns: + - an integer representing the array's dimension or None + """ + if data is None: + return None + + np_array = np.array(data) + return len(np_array.shape) From df0dc9e375f6db94111e8af913d0f7d5f9303350 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 22 Jan 2026 09:25:06 -0700 Subject: [PATCH 08/92] Per #556, create _plotly versions of util.py, constants.py, and config.py. Updated plots that use plotly to import from those versions so it is clear which plots still rely on plotly and need to be updated. This will also allow optional support of plotly for certain plots if we are not able to fully get rid of the plotly dependency in this development cycle. Also removed some unused imports. Replaced util.py function apply_weight_style with get_font_params since the existing version will not be able to be used with matplotlib --- metplotpy/plots/bar/bar.py | 4 +- metplotpy/plots/bar/bar_config.py | 6 +- metplotpy/plots/bar/bar_series.py | 2 +- metplotpy/plots/base_plot_plotly.py | 4 +- metplotpy/plots/box/box.py | 6 +- metplotpy/plots/box/box_config.py | 6 +- metplotpy/plots/box/box_series.py | 4 +- metplotpy/plots/config.py | 7 - metplotpy/plots/config_plotly.py | 897 ++++++++++++++++++ metplotpy/plots/constants.py | 4 +- metplotpy/plots/constants_plotly.py | 100 ++ metplotpy/plots/contour/contour.py | 6 +- metplotpy/plots/contour/contour_config.py | 6 +- metplotpy/plots/contour/contour_series.py | 5 +- metplotpy/plots/eclv/eclv.py | 6 +- metplotpy/plots/eclv/eclv_series.py | 5 +- metplotpy/plots/ens_ss/ens_ss.py | 4 +- metplotpy/plots/ens_ss/ens_ss_config.py | 6 +- metplotpy/plots/ens_ss/ens_ss_series.py | 5 +- .../equivalence_testing_bounds.py | 7 +- .../equivalence_testing_bounds_series.py | 8 +- metplotpy/plots/histogram/hist.py | 6 +- metplotpy/plots/histogram/hist_config.py | 6 +- metplotpy/plots/histogram/hist_series.py | 5 +- metplotpy/plots/histogram/histogram.py | 3 +- metplotpy/plots/histogram/prob_hist.py | 2 +- metplotpy/plots/histogram/rank_hist.py | 3 +- metplotpy/plots/histogram/rel_hist.py | 3 +- metplotpy/plots/histogram_2d/histogram_2d.py | 4 +- metplotpy/plots/line/line.py | 7 +- metplotpy/plots/line/line_config.py | 6 +- metplotpy/plots/line/line_series.py | 18 +- metplotpy/plots/mpr_plot/mpr_plot.py | 4 +- metplotpy/plots/polar_plot/polar_plot.py | 4 +- .../plots/reliability_diagram/reliability.py | 6 +- .../reliability_diagram/reliability_config.py | 6 +- metplotpy/plots/revision_box/revision_box.py | 6 +- .../plots/revision_box/revision_box_config.py | 6 +- .../plots/revision_series/revision_series.py | 7 +- .../revision_series/revision_series_config.py | 6 +- metplotpy/plots/roc_diagram/roc_diagram.py | 16 +- .../plots/roc_diagram/roc_diagram_config.py | 8 +- .../plots/roc_diagram/roc_diagram_series.py | 2 +- metplotpy/plots/scatter/scatter.py | 6 +- metplotpy/plots/scatter/scatter_config.py | 1 - metplotpy/plots/tcmpr_plots/box/tcmpr_box.py | 2 +- .../plots/tcmpr_plots/box/tcmpr_box_point.py | 2 +- .../plots/tcmpr_plots/box/tcmpr_point.py | 2 +- .../tcmpr_plots/line/mean/tcmpr_line_mean.py | 2 +- .../line/mean/tcmpr_series_line_mean.py | 2 +- .../line/median/tcmpr_line_median.py | 2 +- .../plots/tcmpr_plots/line/tcmpr_line.py | 2 +- .../plots/tcmpr_plots/rank/tcmpr_rank.py | 3 +- .../tcmpr_plots/relperf/tcmpr_relperf.py | 5 +- .../skill/mean/tcmpr_series_skill_mean.py | 2 +- .../skill/mean/tcmpr_skill_mean.py | 2 +- .../skill/median/tcmpr_skill_median.py | 2 +- .../plots/tcmpr_plots/skill/tcmpr_skill.py | 2 +- metplotpy/plots/tcmpr_plots/tcmpr.py | 10 +- metplotpy/plots/tcmpr_plots/tcmpr_config.py | 7 +- metplotpy/plots/tcmpr_plots/tcmpr_series.py | 2 +- metplotpy/plots/util.py | 82 +- metplotpy/plots/util_plotly.py | 698 ++++++++++++++ metplotpy/plots/wind_rose/wind_rose.py | 4 +- 64 files changed, 1858 insertions(+), 214 deletions(-) create mode 100644 metplotpy/plots/config_plotly.py create mode 100644 metplotpy/plots/constants_plotly.py create mode 100644 metplotpy/plots/util_plotly.py diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index cb881886..f62dd456 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -23,10 +23,10 @@ from plotly.subplots import make_subplots import metcalcpy.util.utils as calc_util -from metplotpy.plots import util +from metplotpy.plots import util_plotly as 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.base_plot_plotly import BasePlot from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ PLOTLY_PAPER_BGCOOR diff --git a/metplotpy/plots/bar/bar_config.py b/metplotpy/plots/bar/bar_config.py index 5a6436ac..091d1214 100644 --- a/metplotpy/plots/bar/bar_config.py +++ b/metplotpy/plots/bar/bar_config.py @@ -17,9 +17,9 @@ import itertools -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/bar/bar_series.py b/metplotpy/plots/bar/bar_series.py index f455bd15..aa73bd23 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_plotly as util from .. import GROUP_SEPARATOR from ..series import Series diff --git a/metplotpy/plots/base_plot_plotly.py b/metplotpy/plots/base_plot_plotly.py index 03a84ef1..ccb915ed 100644 --- a/metplotpy/plots/base_plot_plotly.py +++ b/metplotpy/plots/base_plot_plotly.py @@ -21,8 +21,8 @@ import yaml from typing import Union import kaleido -import metplotpy.plots.util -from metplotpy.plots.util import strtobool + +from metplotpy.plots.util_plotly import strtobool from .config import Config from metplotpy.plots.context_filter import ContextFilter diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 6c41762a..e798fc39 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -28,11 +28,11 @@ import metcalcpy.util.utils as calc_util -from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots.base_plot_plotly import BasePlot 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 import util_plotly as util +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR class Box(BasePlot): diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index 410fc8c3..a576ef65 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -17,9 +17,9 @@ import itertools -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/box/box_series.py b/metplotpy/plots/box/box_series.py index 9546be9a..a0434f3a 100644 --- a/metplotpy/plots/box/box_series.py +++ b/metplotpy/plots/box/box_series.py @@ -22,7 +22,7 @@ from pandas import DataFrame import metcalcpy.util.utils as utils -import metplotpy.plots.util +import metplotpy.plots.util_plotly as util from ..series import Series @@ -215,7 +215,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: " diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 9f1f9f7f..d35dc70e 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -881,13 +881,6 @@ def _get_lines(self) -> Union[list, None]: # 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']) diff --git a/metplotpy/plots/config_plotly.py b/metplotpy/plots/config_plotly.py new file mode 100644 index 00000000..874d20be --- /dev/null +++ b/metplotpy/plots/config_plotly.py @@ -0,0 +1,897 @@ +# ============================* + # ** 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: config.py + +Holds values set in the config file(s) +""" +__author__ = 'Minna Win' + +import itertools +from typing import Union + +import metcalcpy.util.utils as utils +import metplotpy.plots.util_plotly as util +from . import constants_plotly as constants + + +class Config: + """ + Handles reading in and organizing configuration settings in the yaml configuration file. + """ + + def __init__(self, parameters): + + self.parameters = parameters + + # Logging + self.log_filename = self.get_config_value('log_filename') + self.log_level = self.get_config_value('log_level') + self.logger = util.get_common_logger(self.log_level, self.log_filename) + + # + # Configuration settings that apply to the plot + # + self.output_image = self.get_config_value('plot_filename') + self.title_font = constants.DEFAULT_TITLE_FONT + self.title_color = constants.DEFAULT_TITLE_COLOR + self.xaxis = self.get_config_value('xaxis') + self.yaxis_1 = self.get_config_value('yaxis_1') + self.yaxis_2 = self.get_config_value('yaxis_2') + self.title = self.get_config_value('title') + self.use_ee = self._get_bool('event_equal') + self.indy_vals = self.get_config_value('indy_vals') + self.indy_label = self._get_indy_label() + 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) + 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_color = self.get_config_value('caption_col') + # relative magnification + self.caption_size = self.get_config_value('caption_size') + + # up-down location relative to the x-axis line + self.caption_offset = self.get_config_value('caption_offset') + # left-right position + self.caption_align = self.get_config_value('caption_align') + + # legend style settings as defined in METviewer + user_settings = self._get_legend_style() + + # list of the x, y + # bbox_to_anchor() setting used in determining + # the location of the bounding box which defines + # the legend. + + bbox_x = user_settings.get('bbox_x') + if bbox_x is not None: + self.bbox_x = float(user_settings['bbox_x']) + + bbox_y = user_settings.get('bbox_y') + if bbox_y is not None: + self.bbox_y = float(user_settings['bbox_y']) + + legend_magnification = user_settings.get('legend_size') + if legend_magnification is not None: + self.legend_size = int(constants.DEFAULT_LEGEND_FONTSIZE * legend_magnification) + + self.legend_ncol = self.get_config_value('legend_ncol') + legend_box = self.get_config_value('legend_box') + self.draw_box = False + if legend_box is not None: + legend_box = legend_box.lower() + if legend_box == 'o': + # Don't draw a box around legend labels + self.draw_box = True + + + # some settings used by some but not all plot types + + # Plotly plots often require offsets to the margins + 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.grid_col = self.get_config_value('grid_col') + if self.grid_col: + self.blended_grid_col = util.alpha_blending(self.grid_col, 0.5) + self.show_nstats = self._get_bool('show_nstats') + self.indy_stagger = self._get_bool('indy_stagger') + + # Some of the plot types use Matplotlib, these settings are only relevant + # for plots implemented with Matplotlib. + + # left-right location of x-axis label/title relative to the y-axis line + # make adjustments between METviewer default and Matplotlib's center + # METviewer default value of 2 corresponds to Matplotlib value of .5 + # + mv_x_title_offset = self.get_config_value('xlab_offset') + if mv_x_title_offset: + self.x_title_offset = float(mv_x_title_offset) - 1.5 + + + # up-down of x-axis label/title position + # make adjustments between METviewer default and Matplotlib's center + # METviewer default is .5, Matplotlib center is 0.05, so subtract 0.55 from the + # METviewer setting to get the correct Matplotlib y-value (up/down) + # for the x-title position + mv_x_title_align = self.get_config_value('xlab_align') + if mv_x_title_align: + self.x_title_align = float(mv_x_title_align) - .55 + + # Need to use a combination of Matplotlib's font weight and font style to + + # 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] + + 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.MPL_FONT_SIZE_DEFAULT + + # y-axis labels and y-axis ticks + self.y_title_font_size = self.parameters['ylab_size'] * constants.DEFAULT_CAPTION_FONTSIZE + 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.MPL_FONT_SIZE_DEFAULT + + # left-right position of y-axis label/title position + # make adjustments between METviewer default and Matplotlib's center + # METviewer default is .5, Matplotlib center is -0.05 + mv_y_title_align = self.get_config_value('ylab_align') + self.y_title_align = float(mv_y_title_align) - 0.55 + + # up-down location of y-axis label/title relative to the x-axis line + # make adjustments between METviewer default and Matplotlib's center + # METviewer default value of -2 corresponds to Matplotlib value of 0.4 + # + mv_y_title_offset = self.get_config_value('ylab_offset') + self.y_title_offset = float(mv_y_title_offset) + 2.4 + + # Need to use a combination of Matplotlib's font weight and font style to + # re-create the METviewer ylab_weight. Use the + # 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] + + # Adjust the caption left/right relative to the y-axis + # METviewer default is set to 0, corresponds to y=0.05 in Matplotlib + mv_caption_align = self.get_config_value('caption_align') + self.caption_align = float(mv_caption_align) + 0.13 + + # The plot's title size, title weight, and positioning in left-right and up-down directions + mv_title_size = self.get_config_value('title_size') + self.title_size = mv_title_size * constants.MPL_FONT_SIZE_DEFAULT + + 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] + + # 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) + + # legend style settings as defined in METviewer + user_settings = self._get_legend_style() + + # list of the x, y, and loc values for the + # bbox_to_anchor() setting used in determining + + # the location of the bounding box which defines + # the legend. + # adjust METviewer values to be consistent with the Matplotlib scale + # The METviewer x default is set to 0, which corresponds to a Matplotlib + # x-value of 0.5 (roughly centered with respect to the x-axis) + mv_bbox_x = float(user_settings['bbox_x']) + self.bbox_x = mv_bbox_x + 0.5 + + # METviewer legend box y-value is set to -.25 by default, which corresponds + # to a Matplotlib y-value of -.1 + mv_bbox_y = float(user_settings['bbox_y']) + self.bbox_y = mv_bbox_y + .15 + 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') + + # 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 + + # These are the inner keys to the series_val setting, and + # they represent the series variables of + # interest. The keys correspond to the column names + # in the input dataframe. + self.series_vals_1 = self._get_series_vals(1) + self.series_vals_2 = self._get_series_vals(2) + self.all_series_vals = self.series_vals_1.copy() + if self.series_vals_2: + self.all_series_vals.extend(self.series_vals_2) + + # Represent the names of the forecast variables (inner keys) to the fcst_var_val setting. + # These are the names of the columns in the input dataframe. + self.fcst_var_val_1 = self._get_fcst_vars(1) + self.fcst_var_val_2 = self._get_fcst_vars(2) + + # Get the list of the statistics of interest + self.list_stat_1 = self.get_config_value('list_stat_1') + self.list_stat_2 = self.get_config_value('list_stat_2') + + # These are the inner values to the series_val setting (these correspond to the + # keys returned in self.series_vals above). These are the specific variable values to + # be used in subsetting the input dataframe (e.g. for key='model', and value='SH_CMORPH', + # we want to subset data where column name is 'model', with coincident rows of 'SH_CMORPH'. + self.series_val_names = self._get_series_val_names() + self.series_ordering = None + self.indy_plot_val = self.get_config_value('indy_plot_val') + self.lines = self._get_lines() + + + def get_config_value(self, *args:Union[str,int,float]): + """Gets the value of a configuration parameter. + Looks for parameter in the user parameter dictionary + + Args: + @ param args - chain of keys that defines a key to the parameter + + Returns: + - a value for the parameter of None + """ + + return self._get_nested(self.parameters, args) + + def _get_nested(self, data:dict, args:tuple): + """Recursive function that uses the tuple with keys to find a value + in multidimensional dictionary. + + Args: + @data - dictionary for the lookup + @args - a tuple with keys + + Returns: + - a value for the parameter of None + """ + + if args and data: + + # get value for the first key + element = args[0] + if element: + value = data.get(element) + + # if the size of key tuple is 1 - the search is over + if len(args) == 1: + return value + + # if the size of key tuple is > 1 - search using other keys + return self._get_nested(value, args[1:]) + return None + + def _get_legend_style(self) -> dict: + """ + Retrieve the legend style settings that are set + in the METviewer tool + + Args: + + Returns: + - a dictionary that holds the legend settings that + are set in METviewer + """ + legend_box = self.get_config_value('legend_box') + if legend_box: + legend_box = legend_box.lower() + + legend_ncol = self.get_config_value('legend_ncol') + legend_inset = self.get_config_value('legend_inset') + if legend_inset: + 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) + else: + legend_settings = dict() + + return legend_settings + + def _get_series_vals(self, index:int) -> list: + """ + Get a tuple of lists of all the variable values that correspond to the inner + key of the series_val dictionaries (series_val_1 and series_val_2). + These values will be used with lists of other config values to + create filtering criteria. This is useful to subset the input data + to assist in identifying the data points for this series. + + Args: + index: The number defining which of series_vals_1 or series_vals_2 to consider + + Returns: + lists of *all* the values of the inner dictionary + of the series_vals dictionaries + + """ + + if index == 1: + # evaluate series_val_1 setting + series_val_dict = self.get_config_value('series_val_1') + elif index == 2: + # evaluate series_val_2 setting + series_val_dict = self.get_config_value('series_val_2') + else: + raise ValueError('Index value must be either 1 or 2.') + + # check for empty setting. If so, return an empty list + if series_val_dict: + val_dict_list = [*series_val_dict.values()] + else: + val_dict_list = [] + + # Unpack and access the values corresponding to the inner keys + # (series_var1, series_var2, ..., series_varn). + return val_dict_list + + def _get_series_columns(self, index): + ''' Retrieve the column name that corresponds to this ''' + + def _get_fcst_vars(self, index: int) -> list: + """ + 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 + 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 == 1: + fcst_var_val_dict = self.get_config_value('fcst_var_val_1') + if fcst_var_val_dict: + all_fcst_vars = [*fcst_var_val_dict.keys()] + else: + all_fcst_vars = [] + elif index == 2: + fcst_var_val_dict = self.get_config_value('fcst_var_val_2') + if fcst_var_val_dict: + all_fcst_vars = [*fcst_var_val_dict.keys()] + else: + all_fcst_vars = [] + else: + all_fcst_vars = [] + + return all_fcst_vars + + def _get_series_val_names(self) -> list: + """ + Get a list of all the variable value names (i.e. inner key of the + series_val dictionary). These values will be used with lists of + other config values to create filtering criteria. This is useful + to subset the input data to assist in identifying the data points + for this series. + + Args: + + Returns: + a "list of lists" of *all* the keys to the inner dictionary of + the series_val dictionary + + """ + + series_val_dict = self.get_config_value('series_val_1') + + # Unpack and access the values corresponding to the inner keys + # (series_var1, series_var2, ..., series_varn). + if series_val_dict: + return [*series_val_dict.keys()] + return [] + + 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 + + # 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)] + + return len(permutations) + + def _get_colors(self) -> list: + """ + Retrieves the colors used for lines and markers, from the + config file (default or custom). + Args: + + Returns: + colors_list or colors_from_config: a list of the colors to be used for the lines + (and their corresponding marker symbols) + """ + + colors_settings = self.get_config_value('colors') + return self.create_list_by_series_ordering(list(colors_settings)) + + def _get_con_series(self) -> list: + """ + Retrieves the 'connect across NA' values used for lines and markers, from the + config file (default or custom). + Args: + + Returns: + con_series_list or con_series_from_config: a list of 1 and/or 0 to + be used for the lines + """ + con_series_settings = self.get_config_value('con_series') + return self.create_list_by_series_ordering(list(con_series_settings)) + + def _get_show_legend(self) -> list: + """ + 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 - throws an error + Args: + + Returns: + show_legend_list or show_legend_from_config: a list of 1 and/or 0 to + be used for the traces + """ + show_legend_settings = self.get_config_value('show_legend') + + # Support all variations of setting the show_legend: '1', 1, "true" (any combination of cases), True (boolean) + updated_show_legend_settings = [] + for legend_setting in show_legend_settings: + legend_setting = str(legend_setting).lower() + if legend_setting == '1' or legend_setting == 'true' or legend_setting == 1 or legend_setting is True: + updated_show_legend_settings.append(int(1)) + else: + updated_show_legend_settings.append(int(0)) + + + if show_legend_settings is None: + raise ValueError("ERROR: show_legend parameter is not provided.") + + return self.create_list_by_series_ordering(list(updated_show_legend_settings)) + + def _get_markers(self): + """ + 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 _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 + + Args: + + Returns: + linewidth_list: a list of linewidths corresponding to each line (model) + """ + linewidths = self.get_config_value('series_line_width') + if linewidths is not None: + return self.create_list_by_series_ordering(list(linewidths)) + else: + return None + + def _get_linestyles(self) -> list: + """ + Retrieve all the linestyles from the config file. + + Args: + + Returns: + list of line styles, each line style corresponds to a particular series + """ + linestyles = self.get_config_value('series_line_style') + linestyle_list_ordered = self.create_list_by_series_ordering(list(linestyles)) + return linestyle_list_ordered + + + 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. + + Args: + @parm legend_label_type: The legend label, such as 'Performance', + used when the user hasn't indicated a legend in the + configuration file. + + Returns: + 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) + + ll_list = [] + series_list = self.all_series_vals + + # Some diagrams don't require a series_val1 value, hence + # resulting in a zero-sized series_list. In this case, + # the legend label will just be the legend_label_type. + if len(series_list) == 0 and legend_label_unspecified: + # 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] + + perms = utils.create_permutations(series_list) + for idx,ll in enumerate(legends_list): + if ll == ' ': + if len(series_list) > 1: + label_parts = [perms[idx][0], ' ', perms[idx][1], ' ', legend_label_type] + else: + label_parts = [perms[idx][0], ' ', legend_label_type] + legend_label = ''.join(label_parts) + ll_list.append(legend_label) + else: + ll_list.append(ll) + if 'summary_curve' in self.parameters.keys() and self.parameters['summary_curve'] != 'none': + ll_list.append(self.parameters['summary_curve'] + ' ' + legend_label_type) + + legends_list_ordered = self.create_list_by_series_ordering(ll_list) + return legends_list_ordered + + def _get_plot_resolution(self) -> int: + """ + Retrieve the plot_res and plot_unit to determine the dpi + setting in matplotlib. + + Args: + + Returns: + plot resolution in units of dpi (dots per inch) + + """ + # 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 + + 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 + return resolution + + # no plot_res value is set, return the default + # dpi used by matplotlib + return dpi + + def create_list_by_series_ordering(self, setting_to_order) -> list: + """ + Generate a list of series plotting settings based on what is set + in series_order in the config file. + If the series_order is specified: + series_order: + -3 + -1 + -2 + + and color is set: + color: + -red + -blue + -green + + + Then the following is expected: + the first series' color is 'blue' + the second series' color is 'green' + the third series' color is 'red' + + This allows the user the flexibility to change marker symbols, colors, and + other line qualities between the series (lines) without having to re-order + *all* the values. + + Args: + + setting_to_order: the name of the setting (eg axis_line_width) to be + ordered based on the order indicated + in the config file under the series_order setting. + + Returns: + a list reflecting the order that is consistent with what was set in series_order + + """ + + # create a natural order if series_ordering is missing + if self.series_ordering is None: + self.series_ordering = list(range(1, len(setting_to_order) + 1)) + + # Make the series ordering list zero-based to sync with Python's zero-based counting + series_ordered_zb = [sorder - 1 for sorder in self.series_ordering] + + if len(setting_to_order) == len(series_ordered_zb): + # Reorder the settings according to the zero based series order. + settings_reordered = [setting_to_order[i] for i in series_ordered_zb] + return settings_reordered + + return setting_to_order + + + def create_list_by_plot_val_ordering(self, setting_to_order: str) -> list: + """ + Generate a list of indy parameters settings based on what is set + in indy_plot_val in the config file. + If the is specified: + -3 + -1 + -2 + + and indy_vals is set: + indy_vals: + -120000 + -150000 + -180000 + + Then the following is expected: + the first indy_val is 1850000 + the second indy_val is 120000 + the third indy_val is 150000 + + + Args: + + setting_to_order: the name of the setting (eg indy_vals) to be + ordered based on the order indicated + in the config file under the indy_plot_val setting. + + Returns: + a list reflecting the order that is consistent with what was set in indy_plot_val + """ + + # order the input list according to the series_order setting + ordered_settings_list = [] + # create a natural order if series_ordering is missing + if self.indy_plot_val is None or len(self.indy_plot_val) == 0: + self.indy_plot_val = list(range(1, len(setting_to_order) + 1)) + + # Make the series ordering list zero-based to sync with Python's zero-based counting + indy_ordered_zb = [sorder - 1 for sorder in self.indy_plot_val] + for idx, indy in enumerate(indy_ordered_zb): + ordered_settings_list.insert(indy, setting_to_order[idx]) + + return ordered_settings_list + + + def calculate_plot_dimension(self, config_value: str , output_units: 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 + height, so conversion from mm to inches or mm to pixels is necessary, depending + on the requested output units, output_units. + + Args: + @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. + 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 converted_value + + def _get_bool(self, param: str) -> Union[bool, None]: + """ + Validates the value of the parameter and returns a boolean + Args: + :param param: name of the parameter + Returns: + :return: boolean value or None + """ + + param_val = self.get_config_value(param) + if isinstance(param_val, bool): + return param_val + + if isinstance(param_val, str): + return param_val.upper() == 'TRUE' + + return None + + def _get_indy_label(self): + if 'indy_label' in self.parameters.keys(): + return self.get_config_value('indy_label') + return self.indy_vals + + def _get_lines(self) -> Union[list, None]: + """ + Initialises the custom lines properties and returns a validated list + Args: + + Returns: + :return: list of lines properties or None + """ + + # get property value from the parameters + lines = self.get_config_value('lines') + + # 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') + 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 + + return lines diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 2c8fb35c..0717fe4d 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -79,10 +79,10 @@ '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} +# approximated from plotly marker size to matplotlib marker size +PCH_TO_MATPLOTLIB_MARKER_SIZE = {'.': 14, 'o': 36, 's': 20, '^': 36, 'd': 20, 'H': 28} TYPE_TO_PLOTLY_MODE = {'b': 'lines+markers', 'p': 'markers', 'l': 'lines'} -LINE_STYLE_TO_PLOTLY_DASH = {'-': None, '--': 'dash', ':': 'dot', '-:': 'dashdot'} XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} diff --git a/metplotpy/plots/constants_plotly.py b/metplotpy/plots/constants_plotly.py new file mode 100644 index 00000000..2c8fb35c --- /dev/null +++ b/metplotpy/plots/constants_plotly.py @@ -0,0 +1,100 @@ +# ============================* + # ** 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 + # ============================* + + + +""" +Module Name: constants.py + +Mapping of constants used in plotting, as dictionaries. +METviewer values are keys, Matplotlib representations are the values. + +""" +__author__ = 'Minna Win' + +# CONVERSION FACTORS + +# used to convert plot units in mm to +# inches, so we can pass in dpi to matplotlib +MM_TO_INCHES = 0.03937008 + +# Available Matplotlib Line styles +# ':' ... +# '-.' _._. +# '--' ----- +# '-' ______ (solid line) +# ' ' no line + +# METviewer drop-down choices: +# p points (...) +# l lines (---, dashed line) +# o overplotted (_._ mix of dash and dots) +# b joined lines (____ solid line) +# s stairsteps (same as overplotted) +# h histogram like (no line style, this is unsupported) +# n none (no line style) + +# linestyles can be indicated by "long" name (points, lines, etc.) or +# by single letter designation ('p', 'n', etc) +LINESTYLE_BY_NAMES = {'solid': '-', 'points': ':', 'lines': '--', 'overplotted': '-.', + 'joined lines': '-', 'stairstep': '-.', + 'histogram': ' ', 'none': ' ', 'p': ':', + 'l': '--', 'o': '-.', 'b': '-', + 's': '-.', 'h': ' ', 'n': ' '} + +ACCEPTABLE_CI_VALS = ['NONE', 'BOOT', "STD", 'MET_PRM', 'MET_BOOT'] + +DEFAULT_TITLE_FONT = 'sans-serif' +DEFAULT_TITLE_COLOR = 'black' +DEFAULT_TITLE_FONTSIZE = 10 + +# Default size used in plotly legend text +DEFAULT_LEGEND_FONTSIZE = 12 +DEFAULT_CAPTION_FONTSIZE = 14 +DEFAULT_CAPTION_Y_OFFSET = -3.1 +DEFAULT_TITLE_FONT_SIZE = 11 +DEFAULT_TITLE_OFFSET = (-0.48) + + +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'} +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')} + +# Matplotlib constants +MPL_FONT_SIZE_DEFAULT = 11 diff --git a/metplotpy/plots/contour/contour.py b/metplotpy/plots/contour/contour.py index 353a1d72..b12cdb66 100644 --- a/metplotpy/plots/contour/contour.py +++ b/metplotpy/plots/contour/contour.py @@ -24,9 +24,9 @@ from plotly.subplots import make_subplots from plotly.graph_objects import Figure -from metplotpy.plots.constants import PLOTLY_PAPER_BGCOOR -from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots import util +from metplotpy.plots.constants_plotly import PLOTLY_PAPER_BGCOOR +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots import util_plotly as util from metplotpy.plots.contour.contour_config import ContourConfig from metplotpy.plots.contour.contour_series import ContourSeries from metplotpy.plots.series import Series diff --git a/metplotpy/plots/contour/contour_config.py b/metplotpy/plots/contour/contour_config.py index 6d28d4c7..4336c1e3 100644 --- a/metplotpy/plots/contour/contour_config.py +++ b/metplotpy/plots/contour/contour_config.py @@ -15,9 +15,9 @@ """ __author__ = 'Tatiana Burek' -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/contour/contour_series.py b/metplotpy/plots/contour/contour_series.py index 024bd3de..5f55acc6 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_plotly 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) diff --git a/metplotpy/plots/eclv/eclv.py b/metplotpy/plots/eclv/eclv.py index 5860b572..446e4e2a 100644 --- a/metplotpy/plots/eclv/eclv.py +++ b/metplotpy/plots/eclv/eclv.py @@ -24,12 +24,12 @@ 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.base_plot_plotly import BasePlot +from metplotpy.plots.constants_plotly 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 -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util from metplotpy.plots.series import Series diff --git a/metplotpy/plots/eclv/eclv_series.py b/metplotpy/plots/eclv/eclv_series.py index 94433a3a..0c34becf 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_plotly 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 d6d153ee..2cbf9ad9 100644 --- a/metplotpy/plots/ens_ss/ens_ss.py +++ b/metplotpy/plots/ens_ss/ens_ss.py @@ -29,8 +29,8 @@ 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 -import metplotpy.plots.util as util +from metplotpy.plots.base_plot_plotly import BasePlot +import metplotpy.plots.util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/ens_ss/ens_ss_config.py b/metplotpy/plots/ens_ss/ens_ss_config.py index a1939721..298428c5 100644 --- a/metplotpy/plots/ens_ss/ens_ss_config.py +++ b/metplotpy/plots/ens_ss/ens_ss_config.py @@ -17,9 +17,9 @@ import itertools -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/ens_ss/ens_ss_series.py b/metplotpy/plots/ens_ss/ens_ss_series.py index 508a6e51..28d599c6 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_plotly 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 5d3798c0..957db07f 100644 --- a/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds.py +++ b/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds.py @@ -17,21 +17,20 @@ import re import csv -import yaml import pandas as pd import plotly.graph_objects as go from plotly.subplots import make_subplots from plotly.graph_objects import Figure -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ +from metplotpy.plots.constants_plotly 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 from metplotpy.plots.line.line_series import LineSeries -from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots import util +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots import util_plotly as util import metcalcpy.util.utils as calc_util 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 1548dc9c..f91aa8be 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_plotly 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 a3012a5c..b504e3aa 100644 --- a/metplotpy/plots/histogram/hist.py +++ b/metplotpy/plots/histogram/hist.py @@ -27,11 +27,11 @@ from plotly.graph_objects import Figure from metplotpy.plots.histogram import hist_config -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ PLOTLY_PAPER_BGCOOR from metplotpy.plots.histogram.hist_series import HistSeries -from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots import util +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots import util_plotly as util import metcalcpy.util.utils as utils from metcalcpy.event_equalize import event_equalize diff --git a/metplotpy/plots/histogram/hist_config.py b/metplotpy/plots/histogram/hist_config.py index 16945ab6..5016e347 100644 --- a/metplotpy/plots/histogram/hist_config.py +++ b/metplotpy/plots/histogram/hist_config.py @@ -16,9 +16,9 @@ import itertools -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/histogram/hist_series.py b/metplotpy/plots/histogram/hist_series.py index 38335ba2..1e30222d 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_plotly as util from ..series import Series @@ -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 = [] diff --git a/metplotpy/plots/histogram/histogram.py b/metplotpy/plots/histogram/histogram.py index 231d333f..4ef52dea 100644 --- a/metplotpy/plots/histogram/histogram.py +++ b/metplotpy/plots/histogram/histogram.py @@ -13,13 +13,12 @@ """ __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 +from metplotpy.plots.base_plot_plotly import BasePlot class Histogram(BasePlot): diff --git a/metplotpy/plots/histogram/prob_hist.py b/metplotpy/plots/histogram/prob_hist.py index d4cd7aae..c9e2ba57 100644 --- a/metplotpy/plots/histogram/prob_hist.py +++ b/metplotpy/plots/histogram/prob_hist.py @@ -18,7 +18,7 @@ from metplotpy.plots.histogram.hist import Hist from metplotpy.plots.histogram.hist_series import HistSeries -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util class ProbHist(Hist): diff --git a/metplotpy/plots/histogram/rank_hist.py b/metplotpy/plots/histogram/rank_hist.py index a9a054b9..0a54ee6f 100644 --- a/metplotpy/plots/histogram/rank_hist.py +++ b/metplotpy/plots/histogram/rank_hist.py @@ -13,10 +13,9 @@ """ __author__ = 'Tatiana Burek' -import yaml from datetime import datetime from metplotpy.plots.histogram.hist import Hist -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util from metplotpy.plots.histogram.hist_series import HistSeries diff --git a/metplotpy/plots/histogram/rel_hist.py b/metplotpy/plots/histogram/rel_hist.py index 3040451e..5035fcd1 100644 --- a/metplotpy/plots/histogram/rel_hist.py +++ b/metplotpy/plots/histogram/rel_hist.py @@ -13,10 +13,9 @@ """ __author__ = 'Tatiana Burek' -import yaml from datetime import datetime -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util from metplotpy.plots.histogram.hist import Hist from metplotpy.plots.histogram.hist_series import HistSeries diff --git a/metplotpy/plots/histogram_2d/histogram_2d.py b/metplotpy/plots/histogram_2d/histogram_2d.py index 3f22aa06..4dff7b3d 100644 --- a/metplotpy/plots/histogram_2d/histogram_2d.py +++ b/metplotpy/plots/histogram_2d/histogram_2d.py @@ -29,12 +29,12 @@ import xarray as xr import plotly.graph_objects as go -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util """ Import BasePlot class """ -from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots.base_plot_plotly import BasePlot class Histogram_2d(BasePlot): diff --git a/metplotpy/plots/line/line.py b/metplotpy/plots/line/line.py index c49c3614..dd71a617 100644 --- a/metplotpy/plots/line/line.py +++ b/metplotpy/plots/line/line.py @@ -20,7 +20,6 @@ from typing import Union from itertools import chain -import yaml import numpy as np import pandas as pd @@ -28,12 +27,12 @@ from plotly.subplots import make_subplots from plotly.graph_objects import Figure -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ +from metplotpy.plots.constants_plotly 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 -from metplotpy.plots import util +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots import util_plotly as util from metplotpy.plots.series import Series import metcalcpy.util.utils as calc_util diff --git a/metplotpy/plots/line/line_config.py b/metplotpy/plots/line/line_config.py index d4acbe88..d1031846 100644 --- a/metplotpy/plots/line/line_config.py +++ b/metplotpy/plots/line/line_config.py @@ -17,9 +17,9 @@ import itertools -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/line/line_series.py b/metplotpy/plots/line/line_series.py index 04c644e4..cea0dd7a 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_plotly 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 329d5b27..391169ed 100644 --- a/metplotpy/plots/mpr_plot/mpr_plot.py +++ b/metplotpy/plots/mpr_plot/mpr_plot.py @@ -23,12 +23,12 @@ from plotly.subplots import make_subplots import plotly.io as pio -from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots.base_plot_plotly 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 -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util class MprPlotInfo(): diff --git a/metplotpy/plots/polar_plot/polar_plot.py b/metplotpy/plots/polar_plot/polar_plot.py index 4b5a9f99..0703d1e0 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 b83f9572..bb40daba 100644 --- a/metplotpy/plots/reliability_diagram/reliability.py +++ b/metplotpy/plots/reliability_diagram/reliability.py @@ -26,9 +26,9 @@ from plotly.subplots import make_subplots from plotly.graph_objects import Figure -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_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots import util_plotly as util from metplotpy.plots.reliability_diagram.reliability_config import ReliabilityConfig from metplotpy.plots.reliability_diagram.reliability_series import ReliabilitySeries diff --git a/metplotpy/plots/reliability_diagram/reliability_config.py b/metplotpy/plots/reliability_diagram/reliability_config.py index d011c054..3f1175d1 100644 --- a/metplotpy/plots/reliability_diagram/reliability_config.py +++ b/metplotpy/plots/reliability_diagram/reliability_config.py @@ -18,9 +18,9 @@ import itertools from datetime import datetime -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util class ReliabilityConfig(Config): diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index d9336712..8dd029ff 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -15,13 +15,13 @@ from datetime import datetime import plotly.graph_objects as go -from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots.base_plot_plotly import BasePlot from metplotpy.plots.box.box import Box -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util import metcalcpy.util.utils as calc_util -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH +from metplotpy.plots.constants_plotly 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 diff --git a/metplotpy/plots/revision_box/revision_box_config.py b/metplotpy/plots/revision_box/revision_box_config.py index 8f74d50f..873100a1 100644 --- a/metplotpy/plots/revision_box/revision_box_config.py +++ b/metplotpy/plots/revision_box/revision_box_config.py @@ -14,9 +14,9 @@ """ import itertools -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/revision_series/revision_series.py b/metplotpy/plots/revision_series/revision_series.py index acf7621e..ea015463 100644 --- a/metplotpy/plots/revision_series/revision_series.py +++ b/metplotpy/plots/revision_series/revision_series.py @@ -17,16 +17,15 @@ from typing import Union -import yaml import numpy as np 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.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH +from metplotpy.plots.base_plot_plotly import BasePlot from metplotpy.plots.line.line import Line -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util from metplotpy.plots.series import Series import metcalcpy.util.utils as calc_util diff --git a/metplotpy/plots/revision_series/revision_series_config.py b/metplotpy/plots/revision_series/revision_series_config.py index d031be4f..f7e5a0d8 100644 --- a/metplotpy/plots/revision_series/revision_series_config.py +++ b/metplotpy/plots/revision_series/revision_series_config.py @@ -14,9 +14,9 @@ """ import itertools from datetime import datetime -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/roc_diagram/roc_diagram.py b/metplotpy/plots/roc_diagram/roc_diagram.py index c728e0c3..1832faa7 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 metplotpy.plots import util -from metplotpy.plots import constants -from metplotpy.plots.base_plot import BasePlot + +from metplotpy.plots import util_plotly as util +from metplotpy.plots import constants_plotly as constants +from metplotpy.plots.base_plot_plotly 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): @@ -236,7 +234,7 @@ def _create_series(self, input_data): '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) + pody, pofd, thresh = util.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() @@ -250,7 +248,7 @@ def _create_series(self, input_data): 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) + pody, pofd, thresh = util.prepare_pct_roc(df_summary_curve) series_obj = ROCDiagramSeries(self.config_obj, num_series -1, None) series_obj.series_points = (pofd, pody, thresh, None) diff --git a/metplotpy/plots/roc_diagram/roc_diagram_config.py b/metplotpy/plots/roc_diagram/roc_diagram_config.py index 3f11e0b8..a2ca8a01 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram_config.py +++ b/metplotpy/plots/roc_diagram/roc_diagram_config.py @@ -15,11 +15,9 @@ """ __author__ = 'Minna Win' - -import sys -from ..config import Config -from .. import util -from .. import constants +from ..config_plotly import Config +from .. import util_plotly as util +from .. import constants_plotly as constants class ROCDiagramConfig(Config): def __init__(self, parameters): diff --git a/metplotpy/plots/roc_diagram/roc_diagram_series.py b/metplotpy/plots/roc_diagram/roc_diagram_series.py index 9d3f59c2..81be29fe 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram_series.py +++ b/metplotpy/plots/roc_diagram/roc_diagram_series.py @@ -17,7 +17,7 @@ import pandas as pd import metcalcpy.util.utils as utils from ..series import Series -from ..util import prepare_pct_roc, prepare_ctc_roc +from ..util_plotly import prepare_pct_roc, prepare_ctc_roc class ROCDiagramSeries(Series): diff --git a/metplotpy/plots/scatter/scatter.py b/metplotpy/plots/scatter/scatter.py index 2f13b588..3abaead7 100644 --- a/metplotpy/plots/scatter/scatter.py +++ b/metplotpy/plots/scatter/scatter.py @@ -16,13 +16,11 @@ 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): """ @@ -201,7 +199,7 @@ def main(config_filename=None): Returns: None """ - docs = get_params(config_filename) + docs = util.get_params(config_filename) try: plot = Scatter(docs) diff --git a/metplotpy/plots/scatter/scatter_config.py b/metplotpy/plots/scatter/scatter_config.py index 0e5c5665..817c7c95 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. diff --git a/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py b/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py index ec086111..f4367821 100755 --- a/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py +++ b/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py @@ -5,7 +5,7 @@ from metplotpy.plots.tcmpr_plots.box.tcmpr_box_point import TcmprBoxPoint from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprBox(TcmprBoxPoint): diff --git a/metplotpy/plots/tcmpr_plots/box/tcmpr_box_point.py b/metplotpy/plots/tcmpr_plots/box/tcmpr_box_point.py index bff7b448..f39de279 100755 --- a/metplotpy/plots/tcmpr_plots/box/tcmpr_box_point.py +++ b/metplotpy/plots/tcmpr_plots/box/tcmpr_box_point.py @@ -2,7 +2,7 @@ from metplotpy.plots.tcmpr_plots.tcmpr import Tcmpr from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprBoxPoint(Tcmpr): def __init__(self, config_obj, column_info, col, case_data, input_df, baseline_data, stat_name): diff --git a/metplotpy/plots/tcmpr_plots/box/tcmpr_point.py b/metplotpy/plots/tcmpr_plots/box/tcmpr_point.py index 44cab41b..c4488999 100755 --- a/metplotpy/plots/tcmpr_plots/box/tcmpr_point.py +++ b/metplotpy/plots/tcmpr_plots/box/tcmpr_point.py @@ -3,7 +3,7 @@ import plotly.graph_objects as go -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util from metplotpy.plots.tcmpr_plots.box.tcmpr_box_point import TcmprBoxPoint from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries 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 e73a9608..20c6fea5 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 +import metplotpy.plots.util_plotly as util class TcmprLineMean(TcmprLine): diff --git a/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_series_line_mean.py b/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_series_line_mean.py index cc013ca6..fc533388 100755 --- a/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_series_line_mean.py +++ b/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_series_line_mean.py @@ -19,7 +19,7 @@ import metcalcpy.util.utils as utils from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries from metplotpy.plots.tcmpr_plots.tcmpr_util import get_mean_ci -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprSeriesLineMean(TcmprSeries): 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 a5cced49..4352c990 100755 --- a/metplotpy/plots/tcmpr_plots/line/median/tcmpr_line_median.py +++ b/metplotpy/plots/tcmpr_plots/line/median/tcmpr_line_median.py @@ -3,7 +3,7 @@ from metplotpy.plots.tcmpr_plots.line.median.tcmpr_series_line_median import TcmprSeriesLineMedian from metplotpy.plots.tcmpr_plots.line.tcmpr_line import TcmprLine -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprLineMedian(TcmprLine): diff --git a/metplotpy/plots/tcmpr_plots/line/tcmpr_line.py b/metplotpy/plots/tcmpr_plots/line/tcmpr_line.py index f759e839..b43593cd 100755 --- a/metplotpy/plots/tcmpr_plots/line/tcmpr_line.py +++ b/metplotpy/plots/tcmpr_plots/line/tcmpr_line.py @@ -3,7 +3,7 @@ from metplotpy.plots.tcmpr_plots.tcmpr import Tcmpr from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprLine(Tcmpr): def __init__(self, config_obj, column_info, col, case_data, input_df, baseline_data, stat_name): diff --git a/metplotpy/plots/tcmpr_plots/rank/tcmpr_rank.py b/metplotpy/plots/tcmpr_plots/rank/tcmpr_rank.py index 8d348dcc..49dcb265 100755 --- a/metplotpy/plots/tcmpr_plots/rank/tcmpr_rank.py +++ b/metplotpy/plots/tcmpr_plots/rank/tcmpr_rank.py @@ -17,10 +17,9 @@ import plotly.graph_objects as go 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 +import metplotpy.plots.util_plotly as util class TcmprRank(Tcmpr): diff --git a/metplotpy/plots/tcmpr_plots/relperf/tcmpr_relperf.py b/metplotpy/plots/tcmpr_plots/relperf/tcmpr_relperf.py index f6c35089..377121db 100755 --- a/metplotpy/plots/tcmpr_plots/relperf/tcmpr_relperf.py +++ b/metplotpy/plots/tcmpr_plots/relperf/tcmpr_relperf.py @@ -1,17 +1,14 @@ 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 +import metplotpy.plots.util_plotly as util diff --git a/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_series_skill_mean.py b/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_series_skill_mean.py index d7632c17..06de9ce4 100755 --- a/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_series_skill_mean.py +++ b/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_series_skill_mean.py @@ -16,7 +16,7 @@ import numpy as np from pandas import DataFrame from datetime import datetime -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util import metcalcpy.util.utils as utils from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries 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 1f11bffc..500c7f50 100755 --- a/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_skill_mean.py +++ b/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_skill_mean.py @@ -7,7 +7,7 @@ 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 +import metplotpy.plots.util_plotly as util class TcmprSkillMean(TcmprSkill): 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 cc0d9406..bdd2872d 100755 --- a/metplotpy/plots/tcmpr_plots/skill/median/tcmpr_skill_median.py +++ b/metplotpy/plots/tcmpr_plots/skill/median/tcmpr_skill_median.py @@ -4,7 +4,7 @@ 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 +import metplotpy.plots.util_plotly as util class TcmprSkillMedian(TcmprSkill): diff --git a/metplotpy/plots/tcmpr_plots/skill/tcmpr_skill.py b/metplotpy/plots/tcmpr_plots/skill/tcmpr_skill.py index 795dbe7e..ef6d6797 100755 --- a/metplotpy/plots/tcmpr_plots/skill/tcmpr_skill.py +++ b/metplotpy/plots/tcmpr_plots/skill/tcmpr_skill.py @@ -5,7 +5,7 @@ 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 +import metplotpy.plots.util_plotly as util class TcmprSkill(Tcmpr): diff --git a/metplotpy/plots/tcmpr_plots/tcmpr.py b/metplotpy/plots/tcmpr_plots/tcmpr.py index 9988e064..32d92684 100755 --- a/metplotpy/plots/tcmpr_plots/tcmpr.py +++ b/metplotpy/plots/tcmpr_plots/tcmpr.py @@ -22,15 +22,15 @@ 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 metcalcpy.util.utils as calc_util from metcalcpy.event_equalize import event_equalize -from metplotpy.plots import 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 import util_plotly as util +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots.constants_plotly 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 @@ -585,7 +585,7 @@ def create_plot(config_obj: dict) -> None: quotechar='"', skipinitialspace=True, encoding='utf-8') logger = util.get_common_logger(config_obj.log_level, config_obj.log_filename) -\ + for plot_type in config_obj.plot_type_list: # Apply event equalization, if requested diff --git a/metplotpy/plots/tcmpr_plots/tcmpr_config.py b/metplotpy/plots/tcmpr_plots/tcmpr_config.py index 0d75e358..7fc6de59 100755 --- a/metplotpy/plots/tcmpr_plots/tcmpr_config.py +++ b/metplotpy/plots/tcmpr_plots/tcmpr_config.py @@ -17,10 +17,9 @@ import itertools import metcalcpy.util.utils as utils -from .. import constants -from .. import util -from ..config import Config -import metplotpy.plots.util as util +from .. import constants_plotly as constants +from ..config_plotly import Config +import metplotpy.plots.util_plotly as util class TcmprConfig(Config): diff --git a/metplotpy/plots/tcmpr_plots/tcmpr_series.py b/metplotpy/plots/tcmpr_plots/tcmpr_series.py index 3d88bfef..f3dcab37 100755 --- a/metplotpy/plots/tcmpr_plots/tcmpr_series.py +++ b/metplotpy/plots/tcmpr_plots/tcmpr_series.py @@ -22,7 +22,7 @@ import metcalcpy.util.utils as utils from .tcmpr_util import get_prop_ci from ..series import Series -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprSeries(Series): diff --git a/metplotpy/plots/util.py b/metplotpy/plots/util.py index ad7c9954..c219edc4 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 @@ -98,8 +99,6 @@ def make_plot(config_filename, plot_class): 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 @@ -126,26 +125,25 @@ def alpha_blending(hex_color: str, alpha: float) -> str: 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 +def get_font_params(weight: int) -> dict: + """Convert integer font style/weight value to a dictionary of + font properties, fontweight for bold and fontstyle for italic. + 1=plain text, 2=bold, 3=italic, 4=bold italic + REMOVE: Replaces apply_weight_style function used for plotly. + + @param weight integer representation of the style/weight + @returns dictionary containing font properties like fontweight and fontstyle """ - if len(text) > 0: - if weight == 2: - return '' + text + '' - if weight == 3: - return '' + text + '' - if weight == 4: - return '' + text + '' - return text + font_params = { + 'fontweight': 'normal', + 'fontstyle': 'normal', + } + if weight in (2, 4): + font_params['fontweight'] = 'bold' + if weight in (3, 4): + font_params['fontstyle'] = 'italic' + + return font_params def nicenumber(x, to_round): @@ -200,36 +198,24 @@ 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: +def add_horizontal_line(y: float, line_properties: dict) -> None: + """Adds a horizontal line to the matplotlib plot + + @param y y value for the line + @param line_properties dictionary with line properties like color, width, dash + @returns None """ - figure.add_shape( - type='line', - yref='y', y0=y, y1=y, - xref='paper', x0=0, x1=1, - line=line_properties, - ) + plt.axhline(y=y, xmin=0, xmax=1, **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: +def add_vertical_line(x: float, line_properties: dict) -> None: + """Adds a vertical line to the matplotlib plot + + @param x x value for the line + @param line_properties dictionary with line properties like color, width, dash + @returns None """ - figure.add_shape( - type='line', - yref='paper', y0=0, y1=1, - xref='x', x0=x, x1=x, - line=line_properties, - ) + plt.axvline(x=x, ymin=0, ymax=1, **line_properties) def abline(x_value: float, intercept: float, slope: float) -> float: diff --git a/metplotpy/plots/util_plotly.py b/metplotpy/plots/util_plotly.py new file mode 100644 index 00000000..ad7c9954 --- /dev/null +++ b/metplotpy/plots/util_plotly.py @@ -0,0 +1,698 @@ +# ============================* +# ** 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 +# ============================* + + +""" + Collection of utility functions used by multiple plotting classes +""" +__author__ = 'Minna Win' + +import argparse +import sys +import os +import logging +import gc +import re +from datetime import datetime +import matplotlib +import numpy as np +from typing import Union +import pandas as pd +from plotly.graph_objects import Figure +from metplotpy.plots.context_filter import ContextFilter as cf +import metcalcpy.util.pstd_statistics as pstats +import metcalcpy.util.ctc_statistics as cstats +from metcalcpy.util.read_env_vars_in_config import parse_config + +COLORSCALES = { + 'green_red': ['#E6FFE2', '#B3FAAD', '#74F578', '#30D244', '#00A01E', '#F6A1A2', + '#E26667', '#C93F41', '#A42526'], + 'blue_white_brown': ['#1962CF', '#3E94F2', '#B4F0F9', '#00A01E', '#4AF058', + '#C7FFC0', '#FFFFFF', '#FFE97F', + '#FF3A20', '#A50C0F', '#E1BFB5', '#A0786F', '#643D34'], + 'cm_colors': ["#80FFFF", "#95FFFF", "#AAFFFF", "#BFFFFF", "#D4FFFF", "#EAFFFF", + "#FFFFFF", "#FFEAFF", "#FFD5FF", + "#FFBFFF", "#FFAAFF", "#FF95FF", "#FF80FF"], + 'topo_colors': ["#4C00FF", "#0000FF", "#004CFF", "#0099FF", "#00E5FF", "#00FF4D", + "#1AFF00", "#80FF00", "#E6FF00", + "#FFFF00", "#FFE53B", "#FFDB77", "#FFE0B3"], + 'terrain_colors': ["#00A600", "#24B300", "#4CBF00", "#7ACC00", "#ADD900", "#E6E600", + "#E7CB21", "#E9BA43", + "#EBB165", "#EDB387", "#EFBEAA", "#F0D3CE", "#F2F2F2"], + 'heat_colors': ["#FF0000", "#FF1C00", "#FF3900", "#FF5500", "#FF7100", "#FF8E00", + "#FFAA00", "#FFC600", "#FFE300", + "#FFFF00", "#FFFF2A", "#FFFF80", "#FFFFD5"], + 'rainbow': ["#FF0000", "#FF7600", "#FFEB00", "#9DFF00", "#27FF00", "#00FF4E", + "#00FFC4", "#00C4FF", "#004EFF", + "#2700FF", "#9D00FF", "#FF00EB", "#FF0076"] +} + + +def read_config_from_command_line(): + """ + Read the "custom" config file from the command line + + Args: + + Returns: + The full path to the config file + """ + # Create Parser + parser = argparse.ArgumentParser(description='Read in config file') + + # Add arguments + parser.add_argument('Path', metavar='path', type=str, + help='the full path to config file') + + # Execute the parse_args() method + args = parser.parse_args() + return args.Path + + +def get_params(config_filename): + """!Read config_filename or get config file from command line, then parse + config file and return it as a dictionary. + + @param config_filename The full path to the config file or None + @returns dictionary containing parameters for plot + """ + config_file = config_filename if config_filename else read_config_from_command_line() + return parse_config(config_file) + + +def make_plot(config_filename, plot_class): + """!Get plot parameters and create the plot. + + @param config_filename The full path to the config or None + @param plot_class class of plot to produce, e.g. Bar or Box + @returns plot class object or None if something went wrong + """ + # 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) + 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) + + return None + + +def alpha_blending(hex_color: str, alpha: float) -> str: + """ Alpha color blending as if on the white background. + Useful for gridlines + + Args: + @param hex_color - the color in hex + @param alpha - Alpha value between 0 and 1 + Returns: blended hex color + """ + foreground_tuple = matplotlib.colors.hex2color(hex_color) + foreground_arr = np.array(foreground_tuple) + 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. + :param x: A number + :param to_round: Should the number be rounded? + :return: A number with simple decimals + """ + exp = np.floor(np.log10(x)) + f = x / 10 ** exp + + if to_round: + if f < 1.5: + nf = 1. + elif f < 3.: + nf = 2. + elif f < 7.: + nf = 5. + else: + nf = 10. + else: + if f <= 1.: + nf = 1. + elif f <= 2.: + nf = 2. + elif f <= 5.: + nf = 5. + else: + nf = 10. + + return nf * 10. ** exp + + +def pretty(low, high, number_of_intervals) -> Union[np.ndarray, list]: + """ + Compute a sequence of about n+1 equally spaced ‘round’ values which cover the + range of the values in x + Can be used to create axis labels or bins + :param low: min value + :param high: max value + :param number_of_intervals: number of intervals + :return: + """ + if number_of_intervals == 1: + return [-1, 0] + + num_range = nicenumber(high - low, False) + d = nicenumber(num_range / (number_of_intervals - 1), True) + miny = np.floor(low / d) * d + maxy = np.ceil(high / d) * d + 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 + :param x_value: x coordinate + :param intercept: intercept + :param slope: slope + :return: y value + """ + return slope * x_value + intercept + + +def is_threshold_value(values: Union[pd.core.series.Series, list]): + """ + Determines if a pandas Series of values are threshold values (e.g. '>=1', '<5.0', + '>21') + + Args: + @param values: pandas Series of independent variables comprising the x-axis + + Returns: + A tuple of boolean values: + True if any of these values is a threshold (ie. operator and number) and True if + these are mixed threshold + (==SFP50,==FBIAS1, etc. ). False otherwise. + + """ + + thresh_ctr = 0 + percent_thresh_ctr = 0 + is_percent_thresh = False + is_thresh = False + # Check all the threshold values, there may be some threshold values that do not + # have an equality operator when equality is implied. + for cur_value in values: + match_pct = re.match( + r'(\<|\<=|\==|\>=|\>)(\s)*(SFP|SOP|SCP|USP|CDP|FBIAS)(\s)*([+-]?([0-9]*[' + r'.])?[0-9]+)', + str(cur_value)) + match_thresh = re.match(r'(\<|\<=|\==|\>=|\>)(\s)*([+-]?([0-9]*[.])?[0-9]+)', + str(cur_value)) + if match_pct: + # This is a percent threshold, with values like '==FBIAS1'. + percent_thresh_ctr += 1 + elif match_thresh: + thresh_ctr += 1 + + if thresh_ctr >= 1: + is_thresh = True + if percent_thresh_ctr >= 1: + is_percent_thresh = True + + return is_thresh, is_percent_thresh + + +def sort_threshold_values(thresh_values: pd.core.series.Series) -> list: + """ + Sort the threshold values based on operator and numerical value + + Args: + @param thresh_values: a pandas Series of threshold values (operator + number) + + Return: + sorted_thresholds: A list of threshold values as strings (operator+numerical + value) + """ + + operators = [] + values = [] + for cur_val in thresh_values: + # treat the thresh value as comprised of two groups, one + # for the operator and the other for the value (which can be a + # negative value) + match = re.match(r'(\<|\<=|\==|\>=|\>)(\s)*([+-]?([0-9]*[.])?[0-9]+)', + str(cur_val)) + if match: + operators.append(match.group(1)) + value = float(match.group(3)) + values.append(value) + else: + # This is a bare number (float or int) + operators.append(None) + values.append(float(cur_val)) + + # Apply weights to the operators + wt_maps = {'<': 1, '<=': 2, '==': 3, '>=': 4, '>': 5} + wts = [] + + for operator in operators: + # assign weight for == if no + # operator is indicated, assuming + # that a fcst_thresh of 5 is the same as + # ==5 + # otherwise, assign the appropriate weight to + # the operator + if operator is None: + wts.append(3) + else: + wts.append(wt_maps[operator]) + + # Create a pandas dataframe to use the ability to sort by multiple columns + thresh_dict = {'thresh': thresh_values, 'thresh_values': values, 'op_wts': wts} + df = pd.DataFrame(thresh_dict) + + # cols is the list of columns upon which we should sort + twocols = ['thresh_values', 'op_wts'] + sorted_val_wt = df.sort_values(by=twocols, inplace=False, ascending=True, + ignore_index=True) + + # now the dataframe has the xyz_thresh values sorted appropriately + return sorted_val_wt['thresh'] + + +def get_common_logger(log_level, log_filename): + ''' + Args: + @param log_level: The log level + @param log_filename: The full path to the log file + filename + Returns: + 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 + + # 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} + + 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) + else: + + 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() + common_logger.addFilter(f) + + return common_logger + + +def is_thresh_column(column_name: str) -> bool: + ''' + Determines if a column is a threshold column, i.e. cov_thresh, fcst_thresh, + or obs_thresh. + + Args: + + @param column_name: A string representation of the column name + + Returns: True if this column is a threshold column, False otherwise + ''' + + match = re.match(r'.*_thresh.*', column_name) + if match: + return True + else: + return False + + +def filter_by_fixed_vars(input_df: pd.DataFrame, settings_dict: dict) -> pd.DataFrame: + """ + Filter the input data based on values in the settings_dict dictionary. + For each key (corresponding to a column in the input_df dataframe), + create a query string. Use that query string to filter the input dataframe. + Repeat for all the keys and their corresponding values in the settings_dict. + + Use the pandas query() to perform database-like syntax for filtering the data: + col in ('a', 'b', '3', ...,'z') + + where col is the name of the key and the values in the parens represent + the values corresponding to that key. + + Since Python handles nan values in an unexpected way, if 'NA' is a value in the + list of values corresponding to a key, then different syntax will be required: + + col.isnull() | col in ('a', 'b', ..., 'z') + + Args: + input_df: The input dataframe to be subset. This is needed to check for + valid columns. + settings_dict: The dictionary representation of the settings in the YAML + configuration file + + Returns: + filtered_df: The filtered dataframe + """ + + # check if columns (keys) in the settings_dict exist in the dataframe before + # attempting to subset. If the settings_dict has keys that do not have + # corresponding column values in the dataframe, return the input dataframe. + valid_columns = [col for col in settings_dict if col in input_df.columns] + + if len(valid_columns) == 0: + print( + "No columns in data match what is requested for filtering by fixed variable. Input dataframe will be " + "returned") + return input_df + + # The pandas query method does not work as expected if + # one of the values in the list is 'NA'. When 'NA' is an element in the list + # use the col.isnull() syntax with the col in ('a', 'b', ..., 'z') syntax + # for the remaining values. + + # Create a query string for each column and save in a list + query_string_list = [] + + # Use an intermediate dataframe for filtering iteratively by column + filtered_df = input_df.copy(deep=True) + + for idx, col in enumerate(valid_columns): + # Variables for creating the query string + prev_val_string = "" + single_quote = "'" + list_sep = ", " + list_start = "(" + list_terminator = ")" + or_token = "| " + in_token = " in " + isnull_token = ".isnull()" + is_last_val = False + na_found = False + updated_vals = [] + + # Remove NA from the list of values and create a new + # list of values containing the remaining non-NA values. + values = settings_dict[col] + + # Check for incorrectly formatted fixed_vars_vals_input that is generated + # by the MVBatch.java: + # fixed_vars_vals_input: + # vx_mask: regionA + # + # the correct format: + # fixed_vars_vals_input: + # vx_mask: [regionA] + # + # OR + # + # fixed_vars_vals_input: + # vx_mask: + # - regionA + # + # + # Check if the value to the key (i.e. vx_mask, etc) is a string and convert it to a list + # i.e.: + # correct_value = [value] + # + # where value corresponds to regionA in example above + # + if type(values) is str: + values = [values] + + for val in values: + if val == 'NA': + na_found = True + else: + updated_vals.append(val) + + # Create the query string based on whether NA values exist. + if na_found: + if len(updated_vals) == 0: + # NA was the only value for this column, create the query + # then move onto the next column + prev_val_string = col + isnull_token + query_string_list.append(prev_val_string) + + else: + # At least one non-NA value in the list of values + prev_val_string = col + isnull_token + or_token + is_last_val = False + # Build remaining portion of the query (ie the col in ('a', 'b', + # 'c')) + for val_idx, val in enumerate(updated_vals): + # Identify when the last value in the list + # has been reached to avoid adding a ',' after + # the last value. + last_val = val_idx + 1 + + if last_val == len(updated_vals): + is_last_val = True + + # Create the 'col in' portion of the query + if val_idx == 0 and is_last_val: + # Both the first and last element in the list (i.e. list of one + # element) + prev_val_string = prev_val_string + col + in_token + \ + list_start + single_quote + val + \ + single_quote + list_terminator + + elif val_idx == 0 and (not is_last_val): + # First value of a list of values + prev_val_string = prev_val_string + col + in_token + \ + list_start + single_quote + val + \ + single_quote + list_sep + + + + elif val_idx > 0 and not is_last_val: + # One of the middle values in the list + query_string = prev_val_string + single_quote + val + \ + single_quote + list_sep + + else: + # The last value in the list + prev_val_string = prev_val_string + single_quote + val + \ + single_quote + list_terminator + + query_string_list.append(prev_val_string) + + + else: + + # No NA's found in values. Create the query: col in ('a', 'b', 'c') + prev_val_string = "" + is_last_val = False + + for val_idx, val in enumerate(updated_vals): + + # Identify when the last value in the list + # has been reached to avoid adding a ',' after + # the last value. + last_val = val_idx + 1 + + if last_val == len(updated_vals): + is_last_val = True + + # Only one value in the values list (both first and last element) + if val_idx == 0 and is_last_val: + prev_val_string = prev_val_string + col + in_token + list_start \ + + single_quote + val + single_quote + \ + list_terminator + + elif val_idx == 0 and (not is_last_val): + # First value of a list of values + prev_val_string = prev_val_string + col + in_token + \ + list_start + single_quote + val + \ + single_quote + list_sep + + elif val_idx > 0 and not is_last_val: + # One of the middle values in the list + prev_val_string = prev_val_string + single_quote + val + single_quote + list_sep + else: + # Last value in the list + prev_val_string = prev_val_string + single_quote + val + single_quote + list_terminator + + query_string_list.append(prev_val_string) + + # Perform query for each column (key) + for cur_query in query_string_list: + working_df = filtered_df.query(cur_query) + filtered_df = working_df.copy(deep=True) + + # clean up + del working_df + gc.collect() + + return filtered_df + + +def prepare_pct_roc(subset_df): + """ + Initialize the PCT ROC plot data, appends a beginning and end point + :param subset_df: PCT data + :return: PCT ROC plot data + """ + roc_df = pstats._calc_pct_roc(subset_df) + pody = roc_df['pody'] + pody = pd.concat([pd.Series([1]), pody], ignore_index=True) + pody = pd.concat([pody, pd.Series([0])]) + pofd = roc_df['pofd'] + pofd = pd.concat([pd.Series([1]), pofd], ignore_index=True) + pofd = pd.concat([pofd, pd.Series([0])], ignore_index=True) + thresh = roc_df['thresh'] + thresh = pd.concat([pd.Series(['']), thresh], ignore_index=True) + thresh = pd.concat([thresh, pd.Series([''])], ignore_index=True) + + return pody, pofd, thresh + + +def prepare_ctc_roc(subset_df, is_ascending): + """ + Initialize the CTC ROC plot data, appends a beginning and end point + :param subset_df: CTC data + :param is_ascending: thresh order + :return: CTC ROC plot data + """ + df_roc = cstats.calculate_ctc_roc(subset_df, ascending=is_ascending) + pody = df_roc['pody'] + pody = pd.concat([pd.Series([1]), pody], ignore_index=True) + pody = pd.concat([pody, pd.Series([0])], ignore_index=True) + pofd = df_roc['pofd'] + pofd = pd.concat([pd.Series([1]), pofd], ignore_index=True) + pofd = pd.concat([pofd, pd.Series([0])], ignore_index=True) + thresh = df_roc['thresh'] + thresh = pd.concat([pd.Series(['']), thresh], ignore_index=True) + thresh = pd.concat([thresh, pd.Series([''])], ignore_index=True) + + return pody, pofd, thresh + + +def strtobool(env_var:str)->bool: + """ + Since distutils.util.strtobool was deprecated in Python 3.12, implement + our own version. + + In the distutils.util.strtobool, a simple one line command was used to determine + whether an environment variable was set to True or False. In this + example, the default value is set to False in the event that the environment + variable is not defined: + + turn_on_logging = strtobool(os.getenv('LOG_BASE_PLOT', 'False') ) + + Environment variables can be set as string or bool. Evaluate whether a string + value for true or false (support case-insensitive text) is True/False and + set the default value. + + Args: + @parm env_vars: string name of the environment variable to evaluate + + turn_on_logging = strtobool(os.getenv('LOG_BASE_PLOT') ) + """ + + true_list = ['true', 't', '1',] + false_list = ['false', 'f', '0' ] + # if the environment variable does not exist, then return False + try: + val = os.environ[env_var] + except KeyError: + return False + + # If the environment variable is None, return false + if val is None: + return False + else: + # Check for variations of truth values + lower = val.lower() + if lower in true_list: + return True + elif lower in false_list: + return False + else: + msg = "Value does not represent a truth value (i.e. true or false)" + raise ValueError(msg) + + diff --git a/metplotpy/plots/wind_rose/wind_rose.py b/metplotpy/plots/wind_rose/wind_rose.py index fea5d568..ca6a7cff 100644 --- a/metplotpy/plots/wind_rose/wind_rose.py +++ b/metplotpy/plots/wind_rose/wind_rose.py @@ -26,10 +26,10 @@ import plotly.graph_objects as go from plotly.subplots import make_subplots -from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots.base_plot_plotly 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 +from metplotpy.plots import util_plotly as util class WindRosePlot(BasePlot): From 1a38b17f5abc997f4462e5c2beebd81f4fffd6db Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 22 Jan 2026 09:40:53 -0700 Subject: [PATCH 09/92] Do not use util.apply_weight_style -- it adds html which isn't used by matplotlib. Instead set xaxis label weight similar to taylor_diagram logic --- metplotpy/plots/scatter/scatter_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/metplotpy/plots/scatter/scatter_config.py b/metplotpy/plots/scatter/scatter_config.py index 817c7c95..1d471ef3 100644 --- a/metplotpy/plots/scatter/scatter_config.py +++ b/metplotpy/plots/scatter/scatter_config.py @@ -98,8 +98,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() From ceb58021d3a251d942d6e0ed2b786d6a030b7ac0 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:46:16 -0700 Subject: [PATCH 10/92] add fix for creating parent directories to plotly version of base plot --- metplotpy/plots/base_plot_plotly.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/metplotpy/plots/base_plot_plotly.py b/metplotpy/plots/base_plot_plotly.py index ccb915ed..197720e9 100644 --- a/metplotpy/plots/base_plot_plotly.py +++ b/metplotpy/plots/base_plot_plotly.py @@ -387,8 +387,7 @@ def save_to_file(self): # Create the directory for the output plot if it doesn't already exist dirname = os.path.dirname(os.path.abspath(image_name)) - if not os.path.exists(dirname): - os.mkdir(dirname) + os.makedirs(dirname, exist_ok=True) if self.figure: try: self.figure.write_image(image_name) From 69d5b386a33472f059adadb1534d525f12e8c24b Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:33:08 -0700 Subject: [PATCH 11/92] hotfix: fix handling of missing data by changing replace value from string 9999 to integer 9999 and use np.nan instead of string 'NA' --- metplotpy/plots/skew_t/skew_t.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metplotpy/plots/skew_t/skew_t.py b/metplotpy/plots/skew_t/skew_t.py index 1c171f57..e6713117 100644 --- a/metplotpy/plots/skew_t/skew_t.py +++ b/metplotpy/plots/skew_t/skew_t.py @@ -67,7 +67,7 @@ def extract_sounding_data(input_file, output_directory): # Read in the current sounding data file, replacing any 9999 values with NaN. df_raw: pandas.DataFrame = pd.read_csv(sounding_data_file, sep=r'\s+', skiprows=1, engine='python') - df_raw.replace('9999', 'NA', inplace=True) + df_raw.replace(9999, np.nan, inplace=True) # Rename some columns so they are more descriptive df: pandas.DataFrame = df_raw.rename(columns={'TIME': 'FIELD', '(HR)': 'UNITS'}) From 150a3c77b2e0acf4260eefd9c7d6ca31bd49c0de Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:33:51 -0700 Subject: [PATCH 12/92] hotfix: remove string representing color that causes UserWarning and causes yaml configurations for lines to be ignored --- metplotpy/plots/skew_t/skew_t.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/metplotpy/plots/skew_t/skew_t.py b/metplotpy/plots/skew_t/skew_t.py index e6713117..2402ffc4 100644 --- a/metplotpy/plots/skew_t/skew_t.py +++ b/metplotpy/plots/skew_t/skew_t.py @@ -590,14 +590,14 @@ def create_skew_t(input_file: str, config: dict, logger: logging) -> None: temp_linewidth = config['temp_line_thickness'] temp_linestyle = config['temp_line_style'] temp_linecolor = config['temp_line_color'] - skew.plot(pressure, temperature, 'r', linewidth=temp_linewidth, + skew.plot(pressure, temperature, linewidth=temp_linewidth, linestyle=temp_linestyle, color=temp_linecolor) dewpt_linewidth = config['dewpt_line_thickness'] dewpt_linestyle = config['dewpt_line_style'] dewpt_linecolor = config['dewpt_line_color'] logger.info(f"Generate the dew point line for {cur_time} hour") - skew.plot(pressure, dew_pt, 'g', linewidth=dewpt_linewidth, + skew.plot(pressure, dew_pt, linewidth=dewpt_linewidth, linestyle=dewpt_linestyle, color=dewpt_linecolor) # Adiabat and mixing lines. From 4ac83eb164c98d310bd251172fea7d92a1f4dc60 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:33:34 -0700 Subject: [PATCH 13/92] add workflow dispatch option to unit tests so development branches can use them --- .github/workflows/unit_tests.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index 702a7ec8..4dd32eca 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -21,7 +21,11 @@ on: 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: build: From b6647403df78c9158dc6ab878d9bc8045c5d7ab0 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:36:06 -0700 Subject: [PATCH 14/92] remove more plotly-specific stuff from matplotlib version --- metplotpy/plots/base_plot.py | 46 +++--------------------------------- 1 file changed, 3 insertions(+), 43 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 6925ce64..5048ff62 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -20,27 +20,15 @@ import numpy as np import yaml from typing import Union -import kaleido + import metplotpy.plots.util from metplotpy.plots.util import strtobool from .config import Config from metplotpy.plots.context_filter import ContextFilter -# 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. - -# 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 - -# Check if the PRE_LOAD_CHROME env variable exists -aquire_chrome = 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 +37,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 @@ -414,21 +389,6 @@ 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: - - Returns: - - """ - 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") - 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 From 479c172b0707df5de90590baabb19acfb321424c Mon Sep 17 00:00:00 2001 From: MWin <3753118+bikegeek@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:48:04 -0700 Subject: [PATCH 15/92] Update copyright.txt update copyright year --- docs/copyright.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/copyright.txt b/docs/copyright.txt index 88479cb9..19a595bd 100644 --- a/docs/copyright.txt +++ b/docs/copyright.txt @@ -1,5 +1,5 @@ # ============================* -# ** Copyright UCAR (c) 1992 - 2025 +# ** Copyright UCAR (c) 1992 - 2026 # ** University Corporation for Atmospheric Research (UCAR) # ** National Center for Atmospheric Research (NCAR) # ** Research Applications Lab (RAL) From 07cf5bd48ecbb2af3cd4ebdad11411fb62dae87e Mon Sep 17 00:00:00 2001 From: bikegeek Date: Thu, 5 Feb 2026 13:29:29 -0700 Subject: [PATCH 16/92] Issue #556 Remove any Plotly-specific code and imports. Update copyright date and information. --- metplotpy/plots/base_plot.py | 147 +---------------------------------- 1 file changed, 2 insertions(+), 145 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 5048ff62..8a088f38 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 """ @@ -20,11 +19,8 @@ import numpy as np import yaml from typing import Union - -import metplotpy.plots.util from metplotpy.plots.util import strtobool from .config import Config -from metplotpy.plots.context_filter import ContextFilter turn_on_logging = strtobool('LOG_BASE_PLOT') # Log when Chrome is downloaded at runtime @@ -114,31 +110,7 @@ def get_image_format(self): 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): """ @@ -174,121 +146,6 @@ def get_legend_style(self): 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 - """ - 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 - - 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 - """ - 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: - - 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. From b616fcd6d5e04259f5aab8f4e00a4f259d2736dd Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:11:06 -0700 Subject: [PATCH 17/92] clean up logic to calculate plot dimensions to always use matplotlib units. added helper function to reduce duplication for logic to convert units --- metplotpy/plots/config.py | 68 ++++++------------- .../performance_diagram_config.py | 4 +- .../taylor_diagram/taylor_diagram_config.py | 4 +- 3 files changed, 24 insertions(+), 52 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 7f21dbbf..95a98a9e 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -54,12 +54,9 @@ 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') @@ -656,17 +653,7 @@ def _get_plot_resolution(self) -> int: # 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 - - 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 + return self._convert_units_to_inches(resolution, self.get_config_value('plot_units')) # units not indicated, assume # we are dealing with inches @@ -676,6 +663,19 @@ def _get_plot_resolution(self) -> int: # dpi used by matplotlib return dpi + 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 * 0.1 * constants.MM_TO_INCHES + + # 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, or cm. 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,55 +773,27 @@ 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. Args: @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. 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]: """ diff --git a/metplotpy/plots/performance_diagram/performance_diagram_config.py b/metplotpy/plots/performance_diagram/performance_diagram_config.py index 263227f8..1863b985 100644 --- a/metplotpy/plots/performance_diagram/performance_diagram_config.py +++ b/metplotpy/plots/performance_diagram/performance_diagram_config.py @@ -67,8 +67,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 diff --git a/metplotpy/plots/taylor_diagram/taylor_diagram_config.py b/metplotpy/plots/taylor_diagram/taylor_diagram_config.py index fd556cb4..c7696cfa 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') From 3bc22fd0e71a2191768ee2337d1c382562333f6e Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:46:08 -0700 Subject: [PATCH 18/92] remove plotly-specific variables and update incorrect imports to use plotly version for now --- metplotpy/plots/bar/bar.py | 2 +- metplotpy/plots/constants.py | 16 ---------------- metplotpy/plots/ens_ss/ens_ss.py | 2 +- metplotpy/plots/mpr_plot/mpr_plot.py | 2 +- metplotpy/plots/wind_rose/wind_rose.py | 2 +- 5 files changed, 4 insertions(+), 20 deletions(-) diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index f7e8507a..771ffbf4 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -27,7 +27,7 @@ from metplotpy.plots.bar.bar_config import BarConfig from metplotpy.plots.bar.bar_series import BarSeries from metplotpy.plots.base_plot_plotly import BasePlot -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ PLOTLY_PAPER_BGCOOR diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 0717fe4d..4e2f3755 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -62,34 +62,18 @@ 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'} - # approximated from plotly marker size to matplotlib marker size PCH_TO_MATPLOTLIB_MARKER_SIZE = {'.': 14, 'o': 36, 's': 20, '^': 36, 'd': 20, 'H': 28} -TYPE_TO_PLOTLY_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 diff --git a/metplotpy/plots/ens_ss/ens_ss.py b/metplotpy/plots/ens_ss/ens_ss.py index 06b0dc8c..a037ab29 100644 --- a/metplotpy/plots/ens_ss/ens_ss.py +++ b/metplotpy/plots/ens_ss/ens_ss.py @@ -26,7 +26,7 @@ from plotly.graph_objects import Figure 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.constants_plotly 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_plotly import BasePlot diff --git a/metplotpy/plots/mpr_plot/mpr_plot.py b/metplotpy/plots/mpr_plot/mpr_plot.py index 0398876e..5cfbeb0a 100644 --- a/metplotpy/plots/mpr_plot/mpr_plot.py +++ b/metplotpy/plots/mpr_plot/mpr_plot.py @@ -24,7 +24,7 @@ import plotly.io as pio from metplotpy.plots.base_plot_plotly import BasePlot -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR +from metplotpy.plots.constants_plotly 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 diff --git a/metplotpy/plots/wind_rose/wind_rose.py b/metplotpy/plots/wind_rose/wind_rose.py index 88ae596b..cd386c42 100644 --- a/metplotpy/plots/wind_rose/wind_rose.py +++ b/metplotpy/plots/wind_rose/wind_rose.py @@ -28,7 +28,7 @@ from metplotpy.plots.base_plot_plotly 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.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR from metplotpy.plots import util_plotly as util From fda41c53205061fc4ed7f871734de1e6d901b856 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:46:26 -0700 Subject: [PATCH 19/92] resolve SonarQube complaints and clean up logic --- metplotpy/plots/base_plot.py | 117 ++++++++++++++++++----------------- metplotpy/plots/config.py | 2 +- metplotpy/plots/constants.py | 1 + 3 files changed, 61 insertions(+), 59 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 8a088f38..e9a18539 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -107,7 +107,7 @@ def get_image_format(self): return strings[-1] # print the message if invalid and return default - print('Unrecognised image format. png will be used') + print(f'Unrecognised image format. {self.DEFAULT_IMAGE_FORMAT} will be used') return self.DEFAULT_IMAGE_FORMAT @@ -124,12 +124,10 @@ 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 - elif legend_box == 'n': - # Do not draw border around the legend labels. - borderwidth = 0 legend_ncol = self.get_config_value('legend_ncol') if legend_ncol > 1: @@ -138,11 +136,15 @@ 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 @@ -224,17 +226,11 @@ def save_to_file(self): 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}") - + self.logger.error(f"FileNotFoundError: Cannot save to file {image_name}") except ValueError as ex: - self.logger.error(f"ValueError: Could not save output file.") + self.logger.error(f"ValueError: Could not save output file. {ex}") else: - self.logger.error(f"The figure {dirname} cannot be saved.") + self.logger.error(f"The figure {image_name} cannot be saved.") print("Oops! The figure was not created. Can't save.") def remove_file(self): @@ -254,46 +250,51 @@ def _add_lines(self, config_obj: Config, x_points_index: Union[list, None] = Non @x_points_index - list of x-values that are used to create a plot Returns: """ - 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) + if not hasattr(config_obj, 'lines') or config_obj.lines is None: + return + + shapes = [] + for line in config_obj.lines: + # draw horizontal line + if line['type'] == 'horiz_line': + shapes.append({ + '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({ + '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"] + msg = f"Vertical line with position {line_position} cannot be created." + self.logger.warning(msg) + print(msg) + # ignore everything else + + # draw lines + self.figure.update_layout(shapes=shapes) @staticmethod def get_array_dimensions(data): diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 95a98a9e..821ae9a3 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -668,7 +668,7 @@ def _convert_units_to_inches(self, value, units): if units_lower == 'mm': return value * constants.MM_TO_INCHES if units_lower == 'cm': - return value * 0.1 * constants.MM_TO_INCHES + return value * constants.CM_TO_INCHES # if unsupported units are specified, log a warning but assume inches if units_lower != 'in': diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 4e2f3755..68e992ab 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -22,6 +22,7 @@ # 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 # Available Matplotlib Line styles # ':' ... From 392ee95e5a2f4a9c7e18b98f6dc3bf498aca9bfc Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:08:33 -0700 Subject: [PATCH 20/92] more SonarQube issues resolved --- metplotpy/plots/config.py | 162 ++++++++++++++++++++------------------ 1 file changed, 85 insertions(+), 77 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 821ae9a3..f06df04a 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -105,16 +105,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') @@ -322,13 +323,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 @@ -443,7 +446,7 @@ 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) @@ -557,6 +560,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 @@ -566,41 +579,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 @@ -612,8 +591,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): @@ -632,6 +610,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 @@ -829,34 +836,35 @@ def _get_lines(self) -> Union[list, 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_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 return lines From 0ab752812fd269c7642f2c935debd494d5ae7eff Mon Sep 17 00:00:00 2001 From: bikegeek Date: Fri, 6 Feb 2026 16:58:36 -0700 Subject: [PATCH 21/92] Issue #556 Updates to base_plot to support title,caption, x-axis label and y-axis label style, weight, and font size. Moved the add_horizontal_line() and add_vertical_line() code from the util.py module to this module as this will be needed for all plot types. TODO comments are used to denote code that will need to be removed when all plot types have migratee to Matplotlib. --- metplotpy/plots/base_plot.py | 87 ++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 8a088f38..08586724 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -17,11 +17,13 @@ import logging import warnings import numpy as np +from matplotlib.font_manager import FontProperties import yaml from typing import Union from metplotpy.plots.util import strtobool from .config import Config + turn_on_logging = strtobool('LOG_BASE_PLOT') # Log when Chrome is downloaded at runtime if turn_on_logging: @@ -146,6 +148,57 @@ def get_legend_style(self): return legend_settings + def get_weights_size_styles(self): + """ + 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. + + Returns: + weights_size_styles: A dictionary containing the font property information + for the title, captions, x-axis label, and y-axis label + """ + 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 + + return weights_size_styles + + def get_config_value(self, *args): """Gets the value of a configuration parameter. @@ -203,6 +256,7 @@ def get_img_bytes(self): return None + # TODO Plotly-specific method, NOT needed for Matplotlib def save_to_file(self): """Saves the image to a file specified in the config file. Prints a message if fails @@ -214,8 +268,9 @@ def save_to_file(self): """ 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) + # Catch deprecation warnings from third-party packages as + # errors and log the message. + warnings.filterwarnings("error", category=DeprecationWarning) # Create the directory for the output plot if it doesn't already exist dirname = os.path.dirname(os.path.abspath(image_name)) @@ -227,9 +282,11 @@ def save_to_file(self): 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" + except ResourceWarning as rw: + self.logger.warning(f"ResourceWarning {rw}: in " f" {image_name}") + except DeprecationWarning as dw: + self.logger.warning(f"DeprecationWarning {dw} in: {image_name}") except ValueError as ex: self.logger.error(f"ValueError: Could not save output file.") @@ -246,6 +303,8 @@ def remove_file(self): if image_name is not None and os.path.exists(image_name): os.remove(image_name) +# TODO Remove Plotly specific, use add_horizontal_line() and add_vertical_line() below +# Plotly-specific, 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 @@ -295,6 +354,26 @@ def _add_lines(self, config_obj: Config, x_points_index: Union[list, None] = Non # draw lines self.figure.update_layout(shapes=shapes) + def add_horizontal_line(plt,y: float, line_properties: dict) -> None: + """Adds a horizontal line to the matplotlib plot + + @param plt: Matplotlib pyplot object + @param y y value for the line + @param line_properties dictionary with line properties like color, width, dash + @returns None + """ + plt.axhline(y=y, xmin=0, xmax=1, **line_properties) + + def add_vertical_line(plt, x: float, line_properties: dict) -> None: + """Adds a vertical line to the matplotlib plot + + @param plt: Matplotlib pyplot object + @param x x value for the line + @param line_properties dictionary with line properties like color, width, dash + @returns None + """ + plt.axvline(x=x, ymin=0, ymax=1, **line_properties) + @staticmethod def get_array_dimensions(data): """Returns the dimension of the array From 14469fb6707ae39349586175b2727697ef3a8e77 Mon Sep 17 00:00:00 2001 From: bikegeek Date: Fri, 6 Feb 2026 17:22:09 -0700 Subject: [PATCH 22/92] Issue #556 use the get_weights_size_style() from base_plot.py to plot title and caption --- .../plots/taylor_diagram/taylor_diagram.py | 49 ++++++++----------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/metplotpy/plots/taylor_diagram/taylor_diagram.py b/metplotpy/plots/taylor_diagram/taylor_diagram.py index b6b7bc54..77882284 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 @@ -331,6 +323,7 @@ def _create_figure(self) -> None: plt.tight_layout() plt.plot() + # Save the figure, based on whether we are displaying only positive # correlations or all # correlations. From ad3c90f46238c67b190d4b9867511d126f5e17c2 Mon Sep 17 00:00:00 2001 From: bikegeek Date: Fri, 6 Feb 2026 17:32:21 -0700 Subject: [PATCH 23/92] Fixed comment to remove Plotly reference in --- metplotpy/plots/config.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index f06df04a..8863c279 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -780,7 +780,7 @@ 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) -> int: + def calculate_plot_dimension(self, config_value: str, output_units:str) -> int: ''' To calculate the width or height that defines the size of the plot. Matplotlib defines these values in inches. METviewer accepts units of inches or mm for width and @@ -790,14 +790,29 @@ def calculate_plot_dimension(self, config_value: str) -> int: Args: @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. 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 + 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) return self._convert_units_to_inches(value2convert, units) From 1c0d319e440ddfa6a459850037f538dcfaff1409 Mon Sep 17 00:00:00 2001 From: bikegeek Date: Fri, 6 Feb 2026 17:48:40 -0700 Subject: [PATCH 24/92] Revert to previous version, which already removed Plotly-specific code in calculate_plot_dimension --- metplotpy/plots/config.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 8863c279..a1dec79f 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -56,7 +56,7 @@ def __init__(self, parameters): # 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_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') @@ -780,7 +780,7 @@ 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. METviewer accepts units of inches or mm for width and @@ -798,21 +798,8 @@ def calculate_plot_dimension(self, config_value: str, output_units:str) -> int: ''' 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 - 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) return self._convert_units_to_inches(value2convert, units) From 6f68ae8ecf7d5849338da8bd24ee3f7308f31a60 Mon Sep 17 00:00:00 2001 From: bikegeek Date: Fri, 6 Feb 2026 17:50:02 -0700 Subject: [PATCH 25/92] Added TODO for code that should be removed when all plots have been migrated to Matplotlib --- metplotpy/plots/util.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/metplotpy/plots/util.py b/metplotpy/plots/util.py index c219edc4..7ec21b3e 100644 --- a/metplotpy/plots/util.py +++ b/metplotpy/plots/util.py @@ -24,6 +24,7 @@ from typing import Union import pandas as pd import matplotlib.pyplot as plt +from jinja2.lexer import TOKEN_DOT from metplotpy.plots.context_filter import ContextFilter as cf import metcalcpy.util.pstd_statistics as pstats @@ -86,6 +87,10 @@ def get_params(config_filename): return parse_config(config_file) + +# TODO Remove, Plotly specific +# Matplotlib only needs to do a plt.savefig() +# command def make_plot(config_filename, plot_class): """!Get plot parameters and create the plot. @@ -99,7 +104,7 @@ def make_plot(config_filename, plot_class): try: plot = plot_class(params) plot.save_to_file() - plot.write_html() + # 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()}") @@ -198,6 +203,7 @@ def pretty(low, high, number_of_intervals) -> Union[np.ndarray, list]: return np.arange(miny, maxy + 0.5 * d, d) +# TODO remove, moved to base_plot.py def add_horizontal_line(y: float, line_properties: dict) -> None: """Adds a horizontal line to the matplotlib plot @@ -208,6 +214,7 @@ def add_horizontal_line(y: float, line_properties: dict) -> None: plt.axhline(y=y, xmin=0, xmax=1, **line_properties) +# TODO remove, moved to base_plot.py def add_vertical_line(x: float, line_properties: dict) -> None: """Adds a vertical line to the matplotlib plot From f92761a241d1cfdaac84c927c6e46d28414ced10 Mon Sep 17 00:00:00 2001 From: bikegeek Date: Fri, 6 Feb 2026 17:50:52 -0700 Subject: [PATCH 26/92] Added TODO comments to identify code that will need to be removed when all plots have migrated to Matplotlib --- metplotpy/plots/constants.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 68e992ab..91542257 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -64,6 +64,12 @@ AVAILABLE_MARKERS_LIST = ["o", "^", "s", "d", "H", ".", "h"] +# TODO Remove, Plotly specific +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', @@ -75,6 +81,30 @@ XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} +# TODO REMOVE PLOTLY-specific +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'} + +# approximated from plotly marker size to matplotlib marker size +PCH_TO_MATPLOTLIB_MARKER_SIZE = {'.': 14, 'o': 36, 's': 20, '^': 36, 'd': 20, 'H': 28} + +# TODO Remove, Plotly specific +TYPE_TO_PLOTLY_MODE = {'b': 'lines+markers', 'p': 'markers', 'l': 'lines'} + +# used for tick angles +XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} +YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} + +# TODO Remove these three lines, Plotly specific +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 From b7e827d963889bfd60d1cfe0ec2fe0914496826d Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:11:07 -0700 Subject: [PATCH 27/92] remove plotly variables from matplotlib version --- metplotpy/plots/constants.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 91542257..8e822b53 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -64,12 +64,6 @@ AVAILABLE_MARKERS_LIST = ["o", "^", "s", "d", "H", ".", "h"] -# TODO Remove, Plotly specific -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', @@ -81,30 +75,13 @@ XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} -# TODO REMOVE PLOTLY-specific -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'} - # approximated from plotly marker size to matplotlib marker size PCH_TO_MATPLOTLIB_MARKER_SIZE = {'.': 14, 'o': 36, 's': 20, '^': 36, 'd': 20, 'H': 28} -# TODO Remove, Plotly specific -TYPE_TO_PLOTLY_MODE = {'b': 'lines+markers', 'p': 'markers', 'l': 'lines'} - # used for tick angles XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} -# TODO Remove these three lines, Plotly specific -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 From 4467a905a567e8b0450fd164a6dce1edf14b5905 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:11:16 -0700 Subject: [PATCH 28/92] remove unneeded import --- metplotpy/plots/util.py | 1 - 1 file changed, 1 deletion(-) diff --git a/metplotpy/plots/util.py b/metplotpy/plots/util.py index 7ec21b3e..29e3dd2a 100644 --- a/metplotpy/plots/util.py +++ b/metplotpy/plots/util.py @@ -24,7 +24,6 @@ from typing import Union import pandas as pd import matplotlib.pyplot as plt -from jinja2.lexer import TOKEN_DOT from metplotpy.plots.context_filter import ContextFilter as cf import metcalcpy.util.pstd_statistics as pstats From f10a8a062d5ca595bd3e992797e860b06fb83f5b Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:10:00 -0700 Subject: [PATCH 29/92] Per #558, refactor bar plot to use matplotlib instead of plotly --- metplotpy/plots/bar/bar.py | 353 +++++++++--------------------- metplotpy/plots/bar/bar_config.py | 77 +++---- metplotpy/plots/bar/bar_series.py | 6 +- metplotpy/plots/base_plot.py | 31 ++- metplotpy/plots/config.py | 12 +- metplotpy/plots/constants.py | 6 +- test/bar/bar_with_nones.yaml | 2 +- test/bar/custom_bar.yaml | 6 +- test/bar/threshold_bar.yaml | 2 +- 9 files changed, 184 insertions(+), 311 deletions(-) diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index 771ffbf4..5a5d9fe4 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -17,18 +17,18 @@ import re from operator import add +import numpy as np 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 + +from matplotlib.font_manager import FontProperties import metcalcpy.util.utils as calc_util -from metplotpy.plots import util_plotly as util +from metplotpy.plots import util +from metplotpy.plots import constants from metplotpy.plots.bar.bar_config import BarConfig from metplotpy.plots.bar.bar_series import BarSeries -from metplotpy.plots.base_plot_plotly import BasePlot -from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ - PLOTLY_PAPER_BGCOOR +from metplotpy.plots.base_plot import BasePlot class Bar(BasePlot): @@ -57,7 +57,7 @@ def __init__(self, parameters: dict) -> None: # 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() + 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 " @@ -159,50 +159,54 @@ 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): """ Create a bar plot from defaults and custom parameters """ + self._n_visible_series = sum(1 for s in self.series_list if s.plot_disp) + self._group_width = 0.8 # matplotlib default + # 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']) + + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) + n_stats = self._add_series(ax) + self._add_x2axis(ax, n_stats, wts_size_styles['x2lab']) + + self._add_legend(ax) + + plt.tight_layout() + + def _add_series(self, ax): # placeholder for the number of stats n_stats = [0] * len(self.config_obj.indy_vals) - if self.config_obj.xaxis_reverse is True: - self.series_list.reverse() - # add series lines - for series in self.series_list: + 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: - self._draw_series(series) + self._draw_series(ax, series, idx) # aggregate number of stats n_stats = list(map(add, n_stats, series.series_points['nstat'])) - # 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()) - ) + return n_stats - # apply y axis limits - self._yaxis_limits() - - # add x2 axis - self._add_x2axis(n_stats) - - 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 +214,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 +223,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,161 +239,78 @@ 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 - - :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, - } - ) + base = np.arange(len(x_points)) + n = max(self._n_visible_series, 1) + width = self._group_width / n + offset = (idx - (n - 1) / 2.0) * width + x_locs = base + offset - return fig + # 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 _add_xaxis(self) -> None: + def _add_xaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> 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 + ax.set_xlabel(self.config_obj.xaxis, fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['xlab_offset']) * constants.PIXELS_TO_POINTS) + xtick_locs = np.arange(len(self.config_obj.indy_label)) + 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 self.config_obj.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 self.config_obj.xaxis_reverse is True: - self.figure.update_xaxes(autorange="reversed") + ax.invert_xaxis() - def _add_yaxis(self) -> None: + def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> 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: + ax.set_ylabel(self.config_obj.yaxis_1, 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) + + # set y limits if defined + if len(self.config_obj.parameters['ylim']) > 0: + ax.set_ylim(self.config_obj.parameters['ylim']) + + # add grid lines if requested + if self.config_obj.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) + + def _add_legend(self, ax: plt.Axes) -> 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}) + orientation = "horizontal" if self.config_obj.legend_orientation == 'h' else "vertical" + + handles, labels = ax.get_legend_handles_labels() + if not handles: + print("Warning: No labels found. Use ax.plot(..., label='name')") + + legend = ax.legend( + handles=handles, + labels=labels, + bbox_to_anchor=(self.config_obj.bbox_x, self.config_obj.bbox_y), + loc='upper center', + edgecolor=self.config_obj.legend_border_color, + frameon=True, + 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_x2axis(self, n_stats) -> None: + def _add_x2axis(self, ax, n_stats, fontproperties: FontProperties) -> None: """ Creates x2axis based on the properties from the config file and attaches it to the initial Figure @@ -395,82 +318,14 @@ def _add_x2axis(self, n_stats) -> None: :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" + ax_top = ax.secondary_xaxis('top') + ax_top.set_xlabel('NStats', fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['x2lab_offset']) * constants.PIXELS_TO_POINTS) + current_locs = ax.get_xticks() + ax_top.set_xticks(current_locs, n_stats, size=self.config_obj.x2_tickfont_size) + # this doesn't appear to be working to add ticks at the top + ax_top.tick_params(axis="x", direction="in", labelrotation=self.config_obj.x2_tickangle) - # save html - self.figure.write_html(html_name, include_plotlyjs=False) def write_output_file(self) -> None: """ @@ -504,6 +359,10 @@ def write_output_file(self) -> None: for series in self.series_list: f.write(f"{series.series_points['dbl_med']}\n") + def save_to_file(self) -> None: + image_name = self.get_config_value('plot_filename') + os.makedirs(os.path.dirname(image_name), exist_ok=True) + plt.savefig(image_name, dpi=self.get_config_value('plot_res')) def main(config_filename=None): """ diff --git a/metplotpy/plots/bar/bar_config.py b/metplotpy/plots/bar/bar_config.py index 091d1214..d5b75b39 100644 --- a/metplotpy/plots/bar/bar_config.py +++ b/metplotpy/plots/bar/bar_config.py @@ -17,9 +17,8 @@ import itertools -from ..config_plotly import Config -from .. import constants_plotly as constants -from .. import util_plotly as util +from ..config import Config +from .. import constants as constants import metcalcpy.util.utils as utils @@ -38,6 +37,8 @@ def __init__(self, parameters: dict) -> None: """ super().__init__(parameters) + self.caption_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.get_config_value('caption_weight')] + # Optional setting, indicates *where* to save the dump_points_1 file # used by METviewer self.points_path = self.get_config_value('points_path') @@ -53,12 +54,12 @@ 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.title_offset = 1.0 + abs(self.parameters['title_offset']) * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -76,7 +77,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 @@ -138,26 +138,6 @@ def _get_plot_disp(self) -> list: 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 - 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_plot_stat(self) -> str: """ Retrieves the plot_stat setting from the config file. @@ -183,7 +163,7 @@ def _get_plot_stat(self) -> str: " Supported values are sum, mean, and median.") return stat_to_plot - def _config_consistency_check(self) -> bool: + 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 @@ -198,21 +178,23 @@ def _config_consistency_check(self) -> bool: 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 + lists_to_check = { + "plot_disp": self.plot_disp, + "series_ordering": self.series_ordering, + "colors_list": self.colors_list, + "user_legends": self.user_legends, + } + 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"{name} ({len(list_to_check)}) does not match number of series ({self.num_series})" + ) + status = False + return status def _get_user_legends(self, legend_label_type: str = '') -> list: @@ -267,8 +249,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(1): + all_fields_values['fcst_var'] = self.get_fcst_vars(1) all_fields_values['stat_name'] = self.get_config_value('list_stat_1') return utils.create_permutations_mv(all_fields_values, 0) @@ -301,12 +283,11 @@ 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()) - fcst_vals_flat = [item for sublist in fcst_vals for item in sublist] - series_vals_list.append(fcst_vals_flat) + 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 aa73bd23..74b70bcd 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_plotly as util +from metplotpy.plots import util from .. import GROUP_SEPARATOR from ..series import Series @@ -54,8 +54,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(1): + all_fields_values['fcst_var'] = self.config.get_fcst_vars(1) all_fields_values['stat_name'] = self.config.get_config_value('list_stat_1') all_fields_values_no_indy[1] = all_fields_values diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 60f698ca..17c34690 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -22,6 +22,7 @@ from typing import Union from metplotpy.plots.util import strtobool from .config import Config +from . import constants turn_on_logging = strtobool('LOG_BASE_PLOT') # Log when Chrome is downloaded at runtime @@ -183,7 +184,6 @@ def get_weights_size_styles(self): # 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] @@ -200,6 +200,16 @@ def get_weights_size_styles(self): ylab_property.set_weight(ylab_wt) weights_size_styles['ylab'] = ylab_property + + # For x2axis label if set + if self.config_obj.x2lab_weight: + 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 + return weights_size_styles @@ -393,3 +403,22 @@ def get_array_dimensions(data): np_array = np.array(data) return len(np_array.shape) + + def _add_title(self, ax, font_properties): + ax.set_title( + self.config_obj.title, + 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'], + ) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index a1dec79f..65571e8d 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -145,8 +145,10 @@ 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] + self.xlab_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.get_config_value('xlab_weight')] + self.x2lab_weight = self.get_config_value('x2lab_weight') + if self.x2lab_weight: + self.x2lab_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.x2lab_weight] self.x_tickangle = self.parameters['xtlab_orient'] if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): @@ -245,8 +247,8 @@ def __init__(self, parameters): # Represent the names of the forecast variables (inner keys) to the fcst_var_val setting. # These are the names of the columns in the input dataframe. - self.fcst_var_val_1 = self._get_fcst_vars(1) - self.fcst_var_val_2 = self._get_fcst_vars(2) + self.fcst_var_val_1 = self.get_fcst_vars(1) + self.fcst_var_val_2 = self.get_fcst_vars(2) # Get the list of the statistics of interest self.list_stat_1 = self.get_config_value('list_stat_1') @@ -374,7 +376,7 @@ def _get_series_vals(self, index:int) -> list: def _get_series_columns(self, index): ''' Retrieve the column name that corresponds to this ''' - def _get_fcst_vars(self, index: int) -> list: + def get_fcst_vars(self, index: int) -> list: """ Retrieve a list of the inner keys (fcst_vars) to the fcst_var_val dictionary. diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 8e822b53..0db6458f 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -24,6 +24,8 @@ MM_TO_INCHES = 0.03937008 CM_TO_INCHES = MM_TO_INCHES * 0.1 +PIXELS_TO_POINTS = 0.72 + # Available Matplotlib Line styles # ':' ... # '-.' _._. @@ -57,9 +59,9 @@ # Default size used in plotly 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"] diff --git a/test/bar/bar_with_nones.yaml b/test/bar/bar_with_nones.yaml index 1ddb42d2..d6b93ee3 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 eb9df890..c9787f3e 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 \ No newline at end of file diff --git a/test/bar/threshold_bar.yaml b/test/bar/threshold_bar.yaml index 561d7ccd..79e55371 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 From 5c71d9d835b72678b7e8a6428b163ac6cbf4b3b8 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:20:20 -0700 Subject: [PATCH 30/92] Revert back to using _get_fcst_vars to avoid breaking things, but added functions get_fcst_vars_keys and get_fcst_vars_dict that are more clear of their return values that can eventually be used to replaced calls to the original function that has different return value depending on how it is overridden. Added y2lab weight --- metplotpy/plots/config.py | 45 +++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 65571e8d..154b6244 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -181,6 +181,9 @@ def __init__(self, parameters): # 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] + self.y2lab_weight = self.get_config_value('y2lab_weight') + if self.y2lab_weight: + self.y2lab_weight = constants.MV_TO_MPL_CAPTION_STYLE[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 @@ -229,11 +232,8 @@ 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' # These are the inner keys to the series_val setting, and # they represent the series variables of @@ -247,8 +247,8 @@ def __init__(self, parameters): # Represent the names of the forecast variables (inner keys) to the fcst_var_val setting. # These are the names of the columns in the input dataframe. - self.fcst_var_val_1 = self.get_fcst_vars(1) - self.fcst_var_val_2 = self.get_fcst_vars(2) + self.fcst_var_val_1 = self._get_fcst_vars(1) + self.fcst_var_val_2 = self._get_fcst_vars(2) # Get the list of the statistics of interest self.list_stat_1 = self.get_config_value('list_stat_1') @@ -376,7 +376,7 @@ def _get_series_vals(self, index:int) -> list: def _get_series_columns(self, index): ''' Retrieve the column name that corresponds to this ''' - def get_fcst_vars(self, index: int) -> list: + def _get_fcst_vars(self, index: int) -> list: """ Retrieve a list of the inner keys (fcst_vars) to the fcst_var_val dictionary. @@ -406,6 +406,37 @@ 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 {} + + return self.get_config_value(f'fcst_var_val_{index}') + + 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. + + """ + return list(self.get_fcst_vars_dict(index).keys()) + def _get_series_val_names(self) -> list: """ Get a list of all the variable value names (i.e. inner key of the From 9e0979fe2125fb6d0148ee37c4de9560a2a444a0 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:20:42 -0700 Subject: [PATCH 31/92] added default widths of some matplotlib shapes --- metplotpy/plots/constants.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 0db6458f..5b91b679 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -92,3 +92,6 @@ # Matplotlib constants MPL_FONT_SIZE_DEFAULT = 11 + +MPL_DEFAULT_BAR_WIDTH = 0.8 +MPL_DEFAULT_BOX_WIDTH = 0.5 From d1477d64bb74f5f4039e9e7504a489bbf6da0757 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:23:46 -0700 Subject: [PATCH 32/92] added back _get_fcst_vars override to avoid breaking things for now. Change how fcst vals are read to assume it is always a dict because the values set when it is a list do not match -- list gets keys of fcst_var_val_1/2 which is not the same as the list of lists of the stats, e.g. ME --- metplotpy/plots/bar/bar_config.py | 28 ++++++++++++++++++++++------ metplotpy/plots/bar/bar_series.py | 4 ++-- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/metplotpy/plots/bar/bar_config.py b/metplotpy/plots/bar/bar_config.py index d5b75b39..9ee7bc69 100644 --- a/metplotpy/plots/bar/bar_config.py +++ b/metplotpy/plots/bar/bar_config.py @@ -138,6 +138,24 @@ def _get_plot_disp(self) -> list: 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 + 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_plot_stat(self) -> str: """ Retrieves the plot_stat setting from the config file. @@ -249,8 +267,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'] = self.get_fcst_vars(1) + 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) @@ -283,10 +301,8 @@ 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): - fcst_vals = self.fcst_var_val_1 - elif isinstance(self.fcst_var_val_1, dict): - fcst_vals = list(self.fcst_var_val_1.values()) + 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 diff --git a/metplotpy/plots/bar/bar_series.py b/metplotpy/plots/bar/bar_series.py index 74b70bcd..f455bd15 100644 --- a/metplotpy/plots/bar/bar_series.py +++ b/metplotpy/plots/bar/bar_series.py @@ -54,8 +54,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'] = self.config.get_fcst_vars(1) + if self.config._get_fcst_vars(1): + all_fields_values['fcst_var'] = list(self.config._get_fcst_vars(1).keys()) all_fields_values['stat_name'] = self.config.get_config_value('list_stat_1') all_fields_values_no_indy[1] = all_fields_values From 8917e017578fe4264f50753af0b409f40db06f93 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:24:39 -0700 Subject: [PATCH 33/92] move more common functionality into the base plot class so it can be used by bar and box plots (and likely others) --- metplotpy/plots/bar/bar.py | 93 +------------------- metplotpy/plots/base_plot.py | 159 +++++++++++++++++++++++++++-------- 2 files changed, 128 insertions(+), 124 deletions(-) diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index 5a5d9fe4..0e396d7c 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -21,9 +21,8 @@ import pandas as pd from matplotlib import pyplot as plt -from matplotlib.font_manager import FontProperties - import metcalcpy.util.utils as calc_util + from metplotpy.plots import util from metplotpy.plots import constants from metplotpy.plots.bar.bar_config import BarConfig @@ -168,9 +167,6 @@ def _create_figure(self): """ Create a bar plot from defaults and custom parameters """ - self._n_visible_series = sum(1 for s in self.series_list if s.plot_disp) - self._group_width = 0.8 # matplotlib default - # create and draw the plot _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) @@ -240,8 +236,9 @@ def _draw_series(self, ax: plt.Axes, series: BarSeries, idx: int) -> None: x_points = sorted(series.series_data[self.config_obj.indy_var].unique()) base = np.arange(len(x_points)) - n = max(self._n_visible_series, 1) - width = self._group_width / n + n_visible_series = sum(1 for s in self.series_list if s.plot_disp) + n = max(n_visible_series, 1) + width = constants.MPL_DEFAULT_BAR_WIDTH / n offset = (idx - (n - 1) / 2.0) * width x_locs = base + offset @@ -249,84 +246,6 @@ def _draw_series(self, ax: plt.Axes, series: BarSeries, idx: int) -> None: 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 _add_xaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: - """ - Configures and adds x-axis to the plot - """ - ax.set_xlabel(self.config_obj.xaxis, fontproperties=fontproperties, - labelpad=abs(self.config_obj.parameters['xlab_offset']) * constants.PIXELS_TO_POINTS) - xtick_locs = np.arange(len(self.config_obj.indy_label)) - 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 self.config_obj.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 self.config_obj.xaxis_reverse is True: - ax.invert_xaxis() - - def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: - """ - Configures and adds y-axis to the plot - """ - ax.set_ylabel(self.config_obj.yaxis_1, 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) - - # set y limits if defined - if len(self.config_obj.parameters['ylim']) > 0: - ax.set_ylim(self.config_obj.parameters['ylim']) - - # add grid lines if requested - if self.config_obj.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) - - def _add_legend(self, ax: plt.Axes) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure - """ - orientation = "horizontal" if self.config_obj.legend_orientation == 'h' else "vertical" - - handles, labels = ax.get_legend_handles_labels() - if not handles: - print("Warning: No labels found. Use ax.plot(..., label='name')") - - legend = ax.legend( - handles=handles, - labels=labels, - bbox_to_anchor=(self.config_obj.bbox_x, self.config_obj.bbox_y), - loc='upper center', - edgecolor=self.config_obj.legend_border_color, - frameon=True, - 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_x2axis(self, ax, n_stats, fontproperties: FontProperties) -> 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: - ax_top = ax.secondary_xaxis('top') - ax_top.set_xlabel('NStats', fontproperties=fontproperties, - labelpad=abs(self.config_obj.parameters['x2lab_offset']) * constants.PIXELS_TO_POINTS) - current_locs = ax.get_xticks() - ax_top.set_xticks(current_locs, n_stats, size=self.config_obj.x2_tickfont_size) - # this doesn't appear to be working to add ticks at the top - ax_top.tick_params(axis="x", direction="in", labelrotation=self.config_obj.x2_tickangle) - - def write_output_file(self) -> None: """ Formats series point data to the 2-dim arrays and saves them to the files @@ -359,10 +278,6 @@ def write_output_file(self) -> None: for series in self.series_list: f.write(f"{series.series_points['dbl_med']}\n") - def save_to_file(self) -> None: - image_name = self.get_config_value('plot_filename') - os.makedirs(os.path.dirname(image_name), exist_ok=True) - plt.savefig(image_name, dpi=self.get_config_value('plot_res')) def main(config_filename=None): """ diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 17c34690..0b63b78a 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -18,6 +18,7 @@ import warnings import numpy as np from matplotlib.font_manager import FontProperties +from matplotlib import pyplot as plt import yaml from typing import Union from metplotpy.plots.util import strtobool @@ -200,9 +201,8 @@ def get_weights_size_styles(self): ylab_property.set_weight(ylab_wt) weights_size_styles['ylab'] = ylab_property - # For x2axis label if set - if self.config_obj.x2lab_weight: + if hasattr(self.config_obj, 'x2lab_weight') and hasattr(self.config_obj, 'x2_title_font_size'): x2lab_property= FontProperties() x2lab_property.set_size(self.config_obj.x2_title_font_size) x2lab_style, x2lab_wt = self.config_obj.x2lab_weight @@ -210,6 +210,16 @@ def get_weights_size_styles(self): 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 hasattr(self.config_obj, 'y2_title_font_size'): + 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 @@ -270,39 +280,13 @@ 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: - - """ + def save_to_file(self) -> None: 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.") + os.makedirs(os.path.dirname(image_name), exist_ok=True) + try: + plt.savefig(image_name, dpi=self.get_config_value('plot_res')) + except Exception as ex: + self.logger.error(f"Failed to save plot to file: {ex}") def remove_file(self): """Removes previously made image file . @@ -369,7 +353,8 @@ def _add_lines(self, config_obj: Config, x_points_index: Union[list, None] = Non # draw lines self.figure.update_layout(shapes=shapes) - def add_horizontal_line(plt,y: float, line_properties: dict) -> None: + @staticmethod + def add_horizontal_line(plt, y: float, line_properties: dict) -> None: """Adds a horizontal line to the matplotlib plot @param plt: Matplotlib pyplot object @@ -379,6 +364,7 @@ def add_horizontal_line(plt,y: float, line_properties: dict) -> None: """ plt.axhline(y=y, xmin=0, xmax=1, **line_properties) + @staticmethod def add_vertical_line(plt, x: float, line_properties: dict) -> None: """Adds a vertical line to the matplotlib plot @@ -422,3 +408,106 @@ def _add_caption(self, plt, font_properties): fontproperties=font_properties, color=self.config_obj.parameters['caption_col'], ) + + def _add_legend(self, ax: plt.Axes, handles_and_labels=None) -> None: + """ + Creates a plot legend based on the properties from the config file + and attaches it to the initial Figure + """ + 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')") + + # only show legend entries that have show_legend set to True + filtered_handles = [h for h, show in zip(handles, self.config_obj.show_legend) if show == 1] + filtered_labels = [l for l, show in zip(labels, self.config_obj.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='upper center', + edgecolor=self.config_obj.legend_border_color, + frameon=True, + 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) -> None: + """ + Configures and adds x-axis to the plot + """ + ax.set_xlabel(self.config_obj.xaxis, fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['xlab_offset']) * constants.PIXELS_TO_POINTS) + xtick_locs = np.arange(len(self.config_obj.indy_label)) + 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 self.config_obj.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 self.config_obj.xaxis_reverse is True: + ax.invert_xaxis() + + def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: + """ + Configures and adds y-axis to the plot + """ + ax.set_ylabel(self.config_obj.yaxis_1, 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) + + # set y limits if defined in config or if min/max are provided + if len(self.config_obj.parameters['ylim']) > 0: + ax.set_ylim(self.config_obj.parameters['ylim']) + + # add grid lines if requested + if self.config_obj.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) + + def _add_x2axis(self, ax, n_stats, fontproperties: FontProperties) -> 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 not self.config_obj.show_nstats: + return + + ax_top = ax.secondary_xaxis('top') + ax_top.set_xlabel('NStats', fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['x2lab_offset']) * constants.PIXELS_TO_POINTS) + current_locs = ax.get_xticks() + ax_top.set_xticks(current_locs, n_stats, size=self.config_obj.x2_tickfont_size) + # this doesn't appear to be working to add ticks at the top + ax_top.tick_params(axis="x", direction="in", labelrotation=self.config_obj.x2_tickangle) + + def _add_y2axis(self, ax: plt.Axes, fontproperties: FontProperties): + """ + Adds y2-axis if needed + """ + if not self.config_obj.parameters['list_stat_2']: + return None + + ax_right = ax.twinx() + 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 From 021f9232ec7e3e0d5bca2f206f40995900d62d85 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:27:54 -0700 Subject: [PATCH 34/92] Progress towards creating box plots using matplotlib. Adjustment is still needed to render the plots correctly --- metplotpy/plots/base_plot.py | 7 +- metplotpy/plots/box/box.py | 484 ++++++++---------------------- metplotpy/plots/box/box_config.py | 12 +- metplotpy/plots/box/box_series.py | 5 +- 4 files changed, 137 insertions(+), 371 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 0b63b78a..be9ce369 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -479,8 +479,11 @@ def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: def _add_x2axis(self, ax, n_stats, fontproperties: FontProperties) -> None: """ - Creates x2axis based on the properties from the config file - and attaches it to the initial Figure + Creates x2axis based on the properties from the config file. + + 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 """ diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 4747f141..1c3b898d 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -21,19 +21,17 @@ 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 -from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots.base_plot import BasePlot from metplotpy.plots.box.box_config import BoxConfig from metplotpy.plots.box.box_series import BoxSeries -from metplotpy.plots import util_plotly as util -from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR - +from metplotpy.plots import util +from metplotpy.plots import constants class Box(BasePlot): """ Generates a Plotly box plot for 1 or more traces @@ -128,8 +126,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,76 +169,93 @@ 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) + self.logger.info(f"Begin creating the figure: {datetime.now()}") - # placeholder for the min and max values for y-axis - yaxis_min = None - yaxis_max = None + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) - if self.config_obj.xaxis_reverse is True: - self.series_list.reverse() + wts_size_styles = self.get_weights_size_styles() - 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) + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) - self._draw_series(series) + ax_y2 = self._add_y2axis(ax, wts_size_styles['y2lab']) - # aggregate number of stats - n_stats = list(map(add, n_stats, series.series_points['nstat'])) + n_stats, handles_and_labels, yaxis_min, yaxis_max = self._add_series(ax, ax_y2) + + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) # 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()) - ) + # 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() - self._y2axis_limits() + # add x2 axis + self._add_x2axis(ax, n_stats, wts_size_styles['x2lab']) - # sync axis - self._sync_yaxis(yaxis_min, yaxis_max) + self._sync_yaxes(ax, ax_y2, yaxis_min, yaxis_max) + self._add_legend(ax, handles_and_labels) - # add x2 axis - self._add_x2axis(n_stats) - self.figure.update_layout(boxmode='group') + #plt.tight_layout() + #self.figure.update_layout(boxmode='group') - self.logger.info(f"End creating the figure: " - f"{datetime.now()}") + self.logger.info(f"End creating the figure: {datetime.now()}") - def _draw_series(self, series: BoxSeries) -> None: + def _sync_yaxes(self, ax, ax2, yaxis_min: Union[float, None], yaxis_max: Union[float, None]): + if not self.config_obj.sync_yaxes: + 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 _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()}") + self.logger.info(f"Begin drawing the boxes on the plot for {series.series_name}: {datetime.now()}") + + base = np.arange(len(self.config_obj.indy_vals)) + n_visible_series = sum(1 for s in self.series_list if s.plot_disp) + n = max(n_visible_series, 1) + width = constants.MPL_DEFAULT_BOX_WIDTH / n + offset = (idx - (n - 1) / 2.0) * width + x_locs = base + offset + + # Group your 'stat_value' data by 'indy_var' categories first + data_to_plot = [group_data for name, group_data in + series.series_data.groupby(self.config_obj.indy_var)['stat_value']] + + plot_ax = ax + if ax2 and ax2.get_ylabel() in series.series_data.stat_name.values: + plot_ax = ax2 + + boxplot = plot_ax.boxplot(data_to_plot, positions=x_locs, patch_artist=True, widths=width, + label=self.config_obj.user_legends[series.idx]) + for box in boxplot['boxes']: + box.set_facecolor(series.color) + + return boxplot['boxes'][0] # defaults markers and colors for the regular box plot line_color = dict(color='rgb(0,0,0)') fillcolor = series.color @@ -283,11 +297,31 @@ def _draw_series(self, series: BoxSeries) -> None: secondary_y=series.y_axis != 1 ) - self.logger.info(f"End drawing the boxes on the plot: " - f"{datetime.now()}") + self.logger.info(f"End drawing the boxes on the plot: {datetime.now()}") + + def _add_series(self, ax, ax2): + handles_and_labels = [] + n_stats = [0] * len(self.config_obj.indy_vals) + 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())) - @staticmethod - def _find_min_max(series: BoxSeries, yaxis_min: Union[float, None], + # aggregate number of stats + n_stats = list(map(add, n_stats, series.series_points['nstat'])) + + return n_stats, 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 +332,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 +343,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 +358,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 a576ef65..0f1212c3 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -17,9 +17,9 @@ import itertools -from ..config_plotly import Config -from .. import constants_plotly as constants -from .. import util_plotly as util +from ..config import Config +from .. import constants as constants +from .. import util as util import metcalcpy.util.utils as utils @@ -57,12 +57,13 @@ 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 + self.caption_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.get_config_value('caption_weight')] ############################################## # 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.title_offset = 1.0 + abs(self.parameters['title_offset']) * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -87,7 +88,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 diff --git a/metplotpy/plots/box/box_series.py b/metplotpy/plots/box/box_series.py index a0434f3a..a7ca5060 100644 --- a/metplotpy/plots/box/box_series.py +++ b/metplotpy/plots/box/box_series.py @@ -22,7 +22,7 @@ from pandas import DataFrame import metcalcpy.util.utils as utils -import metplotpy.plots.util_plotly as util +import metplotpy.plots.util as util from ..series import Series @@ -265,5 +265,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()}") From a4fcdd95257300895161b0ed7df419e20a77f464 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:28:44 -0700 Subject: [PATCH 35/92] fix incorrectly formatted show_legend values that are interpreted as a single string instead of a list, adjust caption offset --- test/box/custom_box.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/box/custom_box.yaml b/test/box/custom_box.yaml index a3749b9b..76bb0488 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 @@ -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 From 5f816218b7c86775096b16d31fc13debe6d57481 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:10:54 -0700 Subject: [PATCH 36/92] handle multiple test_*.py files in a test directory by checking the name of the test file and creating a subdirectory if it does not match the test directory, e.g. bar/test_bar.py would write to test_output/bar but bar/test_other.py would write to test_output/bar/other. Previously multiple test files would wipe out the other tests' output. Also switched to using request.node.path because request.fspath is deprecated --- test/conftest.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 0a718653..2316788b 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -113,13 +113,20 @@ def module_setup_env(request): This fixture automatically determines the test directory from the test module's location. """ - test_dir = request.fspath.dirname + test_dir = str(request.node.path.parent) print("Setting up environment") os.environ['TEST_DIR'] = 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', os.path.basename(test_dir)) + 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): From 77b09e26382f5352ed6ede8370dfd2c5939c9e99 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:37:10 -0700 Subject: [PATCH 37/92] move caption_weight handling to base config --- metplotpy/plots/bar/bar_config.py | 2 -- metplotpy/plots/box/box_config.py | 1 - metplotpy/plots/config.py | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/metplotpy/plots/bar/bar_config.py b/metplotpy/plots/bar/bar_config.py index 9ee7bc69..326cccae 100644 --- a/metplotpy/plots/bar/bar_config.py +++ b/metplotpy/plots/bar/bar_config.py @@ -37,8 +37,6 @@ def __init__(self, parameters: dict) -> None: """ super().__init__(parameters) - self.caption_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.get_config_value('caption_weight')] - # Optional setting, indicates *where* to save the dump_points_1 file # used by METviewer self.points_path = self.get_config_value('points_path') diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index 0f1212c3..bdc9eea3 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -58,7 +58,6 @@ def __init__(self, parameters: dict) -> None: self.caption_size = int(constants.DEFAULT_CAPTION_FONTSIZE * self.get_config_value('caption_size')) self.caption_offset = self.parameters['caption_offset'] * constants.DEFAULT_CAPTION_Y_OFFSET - self.caption_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.get_config_value('caption_weight')] ############################################## # title parameters diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 154b6244..e6e07696 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -59,7 +59,7 @@ def __init__(self, parameters): 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') From 3faba155a519cc1e97a582a3b0d4bab696f64c03 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:38:21 -0700 Subject: [PATCH 38/92] progress towards getting revision_box working utilizing as much from box as possible --- metplotpy/plots/box/box.py | 42 ++-- metplotpy/plots/revision_box/revision_box.py | 235 +++++++++--------- .../plots/revision_box/revision_box_config.py | 9 +- .../plots/revision_box/revision_box_series.py | 3 +- 4 files changed, 155 insertions(+), 134 deletions(-) diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 1c3b898d..1f920e4a 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -31,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 import constants +from metplotpy.plots.constants import MPL_DEFAULT_BOX_WIDTH class Box(BasePlot): """ Generates a Plotly box plot for 1 or more traces @@ -187,7 +187,9 @@ def _create_figure(self): self._add_title(ax, wts_size_styles['title']) self._add_caption(plt, wts_size_styles['caption']) - ax_y2 = self._add_y2axis(ax, wts_size_styles['y2lab']) + ax_y2 = None + if wts_size_styles.get('y2lab'): + ax_y2 = self._add_y2axis(ax, wts_size_styles['y2lab']) n_stats, handles_and_labels, yaxis_min, yaxis_max = self._add_series(ax, ax_y2) @@ -202,14 +204,13 @@ def _create_figure(self): # ) # add x2 axis - self._add_x2axis(ax, n_stats, wts_size_styles['x2lab']) + if wts_size_styles.get('x2lab'): + self._add_x2axis(ax, n_stats, wts_size_styles['x2lab']) self._sync_yaxes(ax, ax_y2, yaxis_min, yaxis_max) self._add_legend(ax, handles_and_labels) - - #plt.tight_layout() - #self.figure.update_layout(boxmode='group') + plt.tight_layout() self.logger.info(f"End creating the figure: {datetime.now()}") @@ -235,16 +236,11 @@ def _draw_series(self, ax: plt.Axes, ax2, series: BoxSeries, idx: int): self.logger.info(f"Begin drawing the boxes on the plot for {series.series_name}: {datetime.now()}") - base = np.arange(len(self.config_obj.indy_vals)) - n_visible_series = sum(1 for s in self.series_list if s.plot_disp) - n = max(n_visible_series, 1) - width = constants.MPL_DEFAULT_BOX_WIDTH / n - offset = (idx - (n - 1) / 2.0) * width - x_locs = base + offset - # Group your 'stat_value' data by 'indy_var' categories first - data_to_plot = [group_data for name, group_data in - series.series_data.groupby(self.config_obj.indy_var)['stat_value']] + data_to_plot, x_locs, width = self._get_data_to_plot_and_x_locs(series, idx) + + # data_to_plot = [group_data for name, group_data in + # series.series_data.groupby(self.config_obj.indy_var)['stat_value']] plot_ax = ax if ax2 and ax2.get_ylabel() in series.series_data.stat_name.values: @@ -299,6 +295,18 @@ def _draw_series(self, ax: plt.Axes, ax2, series: BoxSeries, idx: int): self.logger.info(f"End drawing the boxes on the plot: {datetime.now()}") + def _get_data_to_plot_and_x_locs(self, series, idx): + base = np.arange(len(self.config_obj.indy_vals)) + n_visible_series = sum(1 for s in self.series_list if s.plot_disp) + n = max(n_visible_series, 1) + width = MPL_DEFAULT_BOX_WIDTH / n + offset = (idx - (n - 1) / 2.0) * width + x_locs = base + offset + + 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 = [] n_stats = [0] * len(self.config_obj.indy_vals) @@ -317,7 +325,9 @@ def _add_series(self, ax, ax2): handles_and_labels.append((handle, handle.get_label())) # aggregate number of stats - n_stats = list(map(add, n_stats, series.series_points['nstat'])) + # do not increment n_stats if it is not set, e.g. for revision_box + if series.series_points.get('nstat'): + n_stats = list(map(add, n_stats, series.series_points['nstat'])) return n_stats, handles_and_labels, yaxis_min, yaxis_max diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index 35aa020b..30d97583 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -9,21 +9,20 @@ """ Class Name: revision_box.py - """ +""" import os import re from datetime import datetime -import plotly.graph_objects as go -from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots.base_plot import BasePlot from metplotpy.plots.box.box import Box -from metplotpy.plots import util_plotly as util +from metplotpy.plots import util import metcalcpy.util.utils as calc_util -from metplotpy.plots.constants_plotly 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): @@ -53,7 +52,7 @@ def __init__(self, parameters: dict) -> None: # 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" + 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," @@ -121,126 +120,136 @@ 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.figure = self._create_layout() + #self._add_xaxis() + #self._add_yaxis() + #self._add_legend() 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'] + #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 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'] - annotation_text_all = annotation_text_all + annotation_text - if inx < len(self.series_list) - 1: - annotation_text_all = annotation_text_all + '
' + annotation_text_all = annotation_text_all + annotation_text + if inx < len(self.series_list) - 1: + annotation_text_all = annotation_text_all + '
' + + self.config_obj.plot_caption = annotation_text_all + + super()._create_figure() # 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()) - ) + # 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() + #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 - - :param series: RevisionBoxSeries object with data and parameters - """ - - 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: - """ - 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, - 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.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 _get_data_to_plot_and_x_locs(self, series, idx): + return [series.series_points['points']['stat_value'].tolist()], None, MPL_DEFAULT_BOX_WIDTH + + # def _draw_series(self, series: RevisionBoxSeries) -> None: + # """ + # Draws the boxes on the plot + # + # :param series: RevisionBoxSeries object with data and parameters + # """ + # + # 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: + # """ + # 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, + # 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' + # ) 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 873100a1..a9af9eb0 100644 --- a/metplotpy/plots/revision_box/revision_box_config.py +++ b/metplotpy/plots/revision_box/revision_box_config.py @@ -14,9 +14,9 @@ """ import itertools -from ..config_plotly import Config -from .. import constants_plotly as constants -from .. import util_plotly as util +from ..config import Config +from .. import constants as constants +from .. import util import metcalcpy.util.utils as utils @@ -40,6 +40,8 @@ def __init__(self, parameters: dict) -> None: # plot parameters self.dump_points_1 = self._get_bool('dump_points_1') self.create_html = self._get_bool('create_html') + self.sync_yaxes = False + self.xaxis_reverse = False ############################################## # caption parameters @@ -67,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']) ############################################## # series parameters diff --git a/metplotpy/plots/revision_box/revision_box_series.py b/metplotpy/plots/revision_box/revision_box_series.py index 4c90ffe3..bbd8f1e6 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: From 7bbe59eda8e61ea136eb36155ac32b4902716659 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:39:19 -0700 Subject: [PATCH 39/92] move default config seetings that are not set by all of the plots to base config --- metplotpy/plots/config.py | 2 ++ metplotpy/plots/revision_box/revision_box_config.py | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index e6e07696..138644d1 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -45,8 +45,10 @@ 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_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') diff --git a/metplotpy/plots/revision_box/revision_box_config.py b/metplotpy/plots/revision_box/revision_box_config.py index a9af9eb0..ffe1f84c 100644 --- a/metplotpy/plots/revision_box/revision_box_config.py +++ b/metplotpy/plots/revision_box/revision_box_config.py @@ -40,8 +40,6 @@ def __init__(self, parameters: dict) -> None: # plot parameters self.dump_points_1 = self._get_bool('dump_points_1') self.create_html = self._get_bool('create_html') - self.sync_yaxes = False - self.xaxis_reverse = False ############################################## # caption parameters From 2c51ea4541e8e2e72a3d74508c2fb0f238f36859 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:46:15 -0700 Subject: [PATCH 40/92] Per #558, update histogram plot to use matplotlib instead of plotly --- metplotpy/plots/histogram/hist.py | 231 +++++------------------ metplotpy/plots/histogram/hist_config.py | 15 +- metplotpy/plots/histogram/hist_series.py | 5 +- metplotpy/plots/histogram/prob_hist.py | 19 +- metplotpy/plots/histogram/rank_hist.py | 2 +- metplotpy/plots/histogram/rel_hist.py | 2 +- 6 files changed, 58 insertions(+), 216 deletions(-) diff --git a/metplotpy/plots/histogram/hist.py b/metplotpy/plots/histogram/hist.py index 0474f1d2..06d44135 100644 --- a/metplotpy/plots/histogram/hist.py +++ b/metplotpy/plots/histogram/hist.py @@ -21,17 +21,14 @@ 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_plotly 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_plotly import BasePlot -from metplotpy.plots import util_plotly as util +from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots import util import metcalcpy.util.utils as utils from metcalcpy.event_equalize import event_equalize @@ -256,199 +253,58 @@ 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]) + + 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.logger.info(f"Finished creating the histogram figure: " - f"{datetime.now()}") + self._add_legend(ax) + plt.tight_layout() - def _draw_series(self, series: HistSeries) -> None: + self.logger.info(f"Finished creating the histogram figure: {datetime.now()}") + + 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 """ - - # 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_points = self._get_x_points(series) + y_points = series.series_points + + base = np.arange(len(x_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 / n + offset = (idx - (n - 1) / 2.0) * width + x_locs = base + offset + + 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 _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 - ) - - 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 +335,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 5016e347..23437f82 100644 --- a/metplotpy/plots/histogram/hist_config.py +++ b/metplotpy/plots/histogram/hist_config.py @@ -16,9 +16,9 @@ import itertools -from ..config_plotly import Config -from .. import constants_plotly as constants -from .. import util_plotly as util +from ..config import Config +from .. import constants +from .. import util import metcalcpy.util.utils as utils @@ -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,12 @@ 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.title_offset = 1.0 + abs(self.parameters['title_offset']) * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -70,7 +70,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 diff --git a/metplotpy/plots/histogram/hist_series.py b/metplotpy/plots/histogram/hist_series.py index 1e30222d..6333b9f1 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_plotly as util +import metplotpy.plots.util as util from ..series import Series @@ -106,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/prob_hist.py b/metplotpy/plots/histogram/prob_hist.py index c9e2ba57..e9839bd6 100644 --- a/metplotpy/plots/histogram/prob_hist.py +++ b/metplotpy/plots/histogram/prob_hist.py @@ -18,7 +18,7 @@ from metplotpy.plots.histogram.hist import Hist from metplotpy.plots.histogram.hist_series import HistSeries -from metplotpy.plots import util_plotly as util +from metplotpy.plots import util class ProbHist(Hist): @@ -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 0a54ee6f..b9bd61f8 100644 --- a/metplotpy/plots/histogram/rank_hist.py +++ b/metplotpy/plots/histogram/rank_hist.py @@ -15,7 +15,7 @@ from datetime import datetime from metplotpy.plots.histogram.hist import Hist -from metplotpy.plots import util_plotly as util +from metplotpy.plots import util from metplotpy.plots.histogram.hist_series import HistSeries diff --git a/metplotpy/plots/histogram/rel_hist.py b/metplotpy/plots/histogram/rel_hist.py index 5035fcd1..dba0cf45 100644 --- a/metplotpy/plots/histogram/rel_hist.py +++ b/metplotpy/plots/histogram/rel_hist.py @@ -15,7 +15,7 @@ from datetime import datetime -from metplotpy.plots import util_plotly as util +from metplotpy.plots import util from metplotpy.plots.histogram.hist import Hist from metplotpy.plots.histogram.hist_series import HistSeries From 369dad3208f99b56ba9a093b21f0b9eb815e60bf Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:31:55 -0700 Subject: [PATCH 41/92] add support for custom lines --- metplotpy/plots/bar/bar.py | 4 ++ metplotpy/plots/base_plot.py | 84 +++++++++++++++++++++++------------- metplotpy/plots/box/box.py | 4 ++ metplotpy/plots/config.py | 6 ++- 4 files changed, 66 insertions(+), 32 deletions(-) diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index 0e396d7c..8a3ab840 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -183,6 +183,10 @@ def _create_figure(self): self._add_legend(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) + plt.tight_layout() def _add_series(self, ax): diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 17306b93..c1942918 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -300,48 +300,26 @@ def remove_file(self): os.remove(image_name) @staticmethod - def add_horizontal_line(plt,y: float, line_properties: dict) -> None: + def add_horizontal_line(ax: plt.Axes, y: float, line_properties: dict) -> None: """Adds a horizontal line to the matplotlib plot - @param plt: Matplotlib pyplot object + @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 """ - plt.axhline(y=y, xmin=0, xmax=1, **line_properties) + ax.axhline(y=y, xmin=0, xmax=1, **line_properties) @staticmethod - def add_vertical_line(plt, x: float, line_properties: dict) -> None: + def add_vertical_line(ax: plt.Axes, x: float, line_properties: dict) -> None: """Adds a vertical line to the matplotlib plot - @param plt: Matplotlib pyplot object + @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 """ - plt.axvline(x=x, ymin=0, ymax=1, **line_properties) - - @staticmethod - def add_horizontal_line(plt, y: float, line_properties: dict) -> None: - """Adds a horizontal line to the matplotlib plot - - @param plt: Matplotlib pyplot object - @param y y value for the line - @param line_properties dictionary with line properties like color, width, dash - @returns None - """ - plt.axhline(y=y, xmin=0, xmax=1, **line_properties) - - @staticmethod - def add_vertical_line(plt, x: float, line_properties: dict) -> None: - """Adds a vertical line to the matplotlib plot - - @param plt: Matplotlib pyplot object - @param x x value for the line - @param line_properties dictionary with line properties like color, width, dash - @returns None - """ - plt.axvline(x=x, ymin=0, ymax=1, **line_properties) + ax.axvline(x=x, ymin=0, ymax=1, **line_properties) @staticmethod def get_array_dimensions(data): @@ -378,9 +356,9 @@ def _add_caption(self, plt, font_properties): ) def _add_legend(self, ax: plt.Axes, handles_and_labels=None) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure + """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" @@ -482,3 +460,47 @@ def _add_y2axis(self, ax: plt.Axes, fontproperties: FontProperties): ax_right.set_ylim(self.config_obj.parameters['y2lim']) return ax_right + + 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}") diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 3bba735a..6c69c4ce 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -211,6 +211,10 @@ def _create_figure(self): self._sync_yaxes(ax, ax_y2, yaxis_min, yaxis_max) self._add_legend(ax, handles_and_labels) + # 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"End creating the figure: {datetime.now()}") diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 138644d1..7db58895 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -868,7 +868,7 @@ 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 @@ -904,4 +904,8 @@ def _get_lines(self) -> Union[list, None]: 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 From d35fe90b676bcc0a945c8190e326e090e60ab693 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:32:25 -0700 Subject: [PATCH 42/92] fix number of columns in legend to match original R plot --- test/box/custom_box.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/box/custom_box.yaml b/test/box/custom_box.yaml index 76bb0488..8f034170 100644 --- a/test/box/custom_box.yaml +++ b/test/box/custom_box.yaml @@ -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: From 4f33a79638d2ebb8afd7a408459ec55a60c9c156 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:32:44 -0700 Subject: [PATCH 43/92] turn off grid to match original R plot --- test/histogram/prob_hist.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/histogram/prob_hist.yaml b/test/histogram/prob_hist.yaml index f4610ca0..2b1f2c01 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: [] From fc2eec79adbd56e65d20808377413dbcaa48e32d Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:28:03 -0700 Subject: [PATCH 44/92] Refactored config consistency checking to be consistent across multiple plots. Handle logging and error raising within helper function. Provided a default list of lists to check against the number of series, with the option to override and change per plot as needed. Improve error messages to describe with specific config option has an inconsistent number of values compared to the number of series to assist with debugging --- metplotpy/plots/bar/bar.py | 13 +---- metplotpy/plots/bar/bar_config.py | 34 ------------ metplotpy/plots/box/box.py | 10 +--- metplotpy/plots/box/box_config.py | 32 ----------- metplotpy/plots/config.py | 53 +++++++++++++++++++ metplotpy/plots/histogram/hist.py | 15 +----- metplotpy/plots/histogram/hist_config.py | 32 ----------- metplotpy/plots/revision_box/revision_box.py | 9 +--- .../plots/revision_box/revision_box_config.py | 32 ----------- 9 files changed, 58 insertions(+), 172 deletions(-) diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index 8a3ab840..a90da4c8 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -54,18 +54,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()}") diff --git a/metplotpy/plots/bar/bar_config.py b/metplotpy/plots/bar/bar_config.py index 326cccae..cd012ec8 100644 --- a/metplotpy/plots/bar/bar_config.py +++ b/metplotpy/plots/bar/bar_config.py @@ -179,40 +179,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) - - """ - lists_to_check = { - "plot_disp": self.plot_disp, - "series_ordering": self.series_ordering, - "colors_list": self.colors_list, - "user_legends": self.user_legends, - } - 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"{name} ({len(list_to_check)}) does not match number of series ({self.num_series})" - ) - status = False - - 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. diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 6c69c4ce..7321efef 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -60,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() diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index bdc9eea3..b4c0cae5 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -208,38 +208,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. diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 7db58895..668a4810 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -17,6 +17,7 @@ import itertools from typing import Union +from datetime import datetime import metcalcpy.util.utils as utils import metplotpy.plots.util @@ -909,3 +910,55 @@ def _get_lines(self) -> Union[list, None]: 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()}") diff --git a/metplotpy/plots/histogram/hist.py b/metplotpy/plots/histogram/hist.py index 06d44135..8e4e9e88 100644 --- a/metplotpy/plots/histogram/hist.py +++ b/metplotpy/plots/histogram/hist.py @@ -66,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() diff --git a/metplotpy/plots/histogram/hist_config.py b/metplotpy/plots/histogram/hist_config.py index 23437f82..932fe4ee 100644 --- a/metplotpy/plots/histogram/hist_config.py +++ b/metplotpy/plots/histogram/hist_config.py @@ -127,38 +127,6 @@ def _get_plot_disp(self) -> list: 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/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index fa9eb6a8..b26ed50f 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -51,14 +51,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() diff --git a/metplotpy/plots/revision_box/revision_box_config.py b/metplotpy/plots/revision_box/revision_box_config.py index ffe1f84c..b8395436 100644 --- a/metplotpy/plots/revision_box/revision_box_config.py +++ b/metplotpy/plots/revision_box/revision_box_config.py @@ -226,35 +226,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 From 58eb4dda630f444bfb515da247c035655a62ca4e Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:31:57 -0700 Subject: [PATCH 45/92] after updating config consistency check logic, fix incorrect yaml values for show_legend that were being interpreted as a list of single characters instead of boolean values --- test/bar/custom_bar.yaml | 2 +- test/histogram/prob_hist.yaml | 2 +- test/histogram/rank_hist.yaml | 4 +++- test/histogram/rel_hist.yaml | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/bar/custom_bar.yaml b/test/bar/custom_bar.yaml index c9787f3e..65b03581 100644 --- a/test/bar/custom_bar.yaml +++ b/test/bar/custom_bar.yaml @@ -143,4 +143,4 @@ plot_filename: !ENV '${TEST_OUTPUT}/bar.png' #log_level: WARNING show_legend: - True -- True \ No newline at end of file +- True diff --git a/test/histogram/prob_hist.yaml b/test/histogram/prob_hist.yaml index 2b1f2c01..40ae551d 100644 --- a/test/histogram/prob_hist.yaml +++ b/test/histogram/prob_hist.yaml @@ -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 f1899b45..276742c1 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 e6806307..181e855f 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 From 94383dd35a685b3e8ef36b20a88295e919bf6fae Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:34:56 -0700 Subject: [PATCH 46/92] added mean line, adjusted median line, and added logic to more closely match plotly version in terms of outliers --- metplotpy/plots/base_plot.py | 7 +++++- metplotpy/plots/box/box.py | 41 +++++++++++++++++++------------ metplotpy/plots/box/box_config.py | 15 +++++------ 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index c1942918..c5cccc26 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -427,11 +427,15 @@ def _add_x2axis(self, ax, n_stats, 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 + :param n_stats labels for the axis """ if not self.config_obj.show_nstats: return @@ -441,6 +445,7 @@ def _add_x2axis(self, ax, n_stats, fontproperties: FontProperties) -> None: labelpad=abs(self.config_obj.parameters['x2lab_offset']) * constants.PIXELS_TO_POINTS) current_locs = ax.get_xticks() ax_top.set_xticks(current_locs, n_stats, size=self.config_obj.x2_tickfont_size) + # this doesn't appear to be working to add ticks at the top ax_top.tick_params(axis="x", direction="in", labelrotation=self.config_obj.x2_tickangle) diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 7321efef..8955eeeb 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -96,11 +96,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): @@ -189,13 +187,6 @@ def _create_figure(self): self._add_xaxis(ax, wts_size_styles['xlab']) self._add_yaxis(ax, wts_size_styles['ylab']) - # 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()) - # ) - # add x2 axis if wts_size_styles.get('x2lab'): self._add_x2axis(ax, n_stats, wts_size_styles['x2lab']) @@ -236,15 +227,33 @@ def _draw_series(self, ax: plt.Axes, ax2, series: BoxSeries, idx: int): # Group your 'stat_value' data by 'indy_var' categories first data_to_plot, x_locs, width = self._get_data_to_plot_and_x_locs(series, idx) - # data_to_plot = [group_data for name, group_data in - # series.series_data.groupby(self.config_obj.indy_var)['stat_value']] - plot_ax = ax if ax2 and ax2.get_ylabel() in series.series_data.stat_name.values: plot_ax = ax2 - boxplot = plot_ax.boxplot(data_to_plot, positions=x_locs, patch_artist=True, widths=width, - label=self.config_obj.user_legends[series.idx]) + # 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) diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index b4c0cae5..b43d8b43 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -125,17 +125,18 @@ def __init__(self, parameters: dict) -> None: 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: - self.boxpoints = 'all' + if self.box_pts: + self.whis = [0, 100] + elif not self._get_bool('box_outline'): + self.showfliers = False def _get_plot_disp(self) -> list: """ From c922238b06e74c7eb183232de5d32dc53284303c Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:35:28 -0700 Subject: [PATCH 47/92] adjusted config value to stretch whisker to outlier point to more closely match plotly version --- test/box/custom_box.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/box/custom_box.yaml b/test/box/custom_box.yaml index 8f034170..d3cfe0e1 100644 --- a/test/box/custom_box.yaml +++ b/test/box/custom_box.yaml @@ -3,7 +3,7 @@ box_avg: 'True' box_boxwex: 0.2 box_notch: 'False' box_outline: 'True' -box_pts: 'False' +box_pts: 'True' caption_align: 0.0 caption_col: '#333333' caption_offset: 8.0 From 3879114edeefd3e794663fe850a81121923c5cdd Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:59:51 -0700 Subject: [PATCH 48/92] improve handling of situation when fcst vars are not set in config --- metplotpy/plots/config.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 668a4810..fc3a8f08 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -424,7 +424,10 @@ def get_fcst_vars_dict(self, index: int) -> dict: if index not in (1, 2): return {} - return self.get_config_value(f'fcst_var_val_{index}') + 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. @@ -438,7 +441,10 @@ def get_fcst_vars_keys(self, index: int) -> list: used to subset the input data that corresponds to a particular series. """ - return list(self.get_fcst_vars_dict(index).keys()) + 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: """ From 13cb39da186fe9c703f24e77366f9d4e2889a27c Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:00:06 -0700 Subject: [PATCH 49/92] use explicit function to get fcst var keys instead of dict --- metplotpy/plots/histogram/hist_series.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/metplotpy/plots/histogram/hist_series.py b/metplotpy/plots/histogram/hist_series.py index 6333b9f1..d404a3f6 100644 --- a/metplotpy/plots/histogram/hist_series.py +++ b/metplotpy/plots/histogram/hist_series.py @@ -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 From 53140c3ea2b9c1b9968449a68b6b5011f1674326 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:36:53 -0700 Subject: [PATCH 50/92] adjust settings for whis and showfliers to more closely match the plotly boxpoints settings --- metplotpy/plots/box/box_config.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index b43d8b43..d4f267db 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -134,9 +134,15 @@ def __init__(self, parameters: dict) -> None: self.box_pts = self._get_bool('box_pts') if self.box_pts: + self.showfliers = False + self.boxpoints = 'all' + elif self._get_bool('box_outline'): + self.whis = 2.5 + self.boxpoints = 'outliers' + else: self.whis = [0, 100] - elif not self._get_bool('box_outline'): self.showfliers = False + self.boxpoints = False def _get_plot_disp(self) -> list: """ From ef51ddf7337530f2ea1d96b979157368608a1d44 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:37:39 -0700 Subject: [PATCH 51/92] change value back to original because plot should create the same formatted box with the old settings --- test/box/custom_box.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/box/custom_box.yaml b/test/box/custom_box.yaml index d3cfe0e1..8f034170 100644 --- a/test/box/custom_box.yaml +++ b/test/box/custom_box.yaml @@ -3,7 +3,7 @@ box_avg: 'True' box_boxwex: 0.2 box_notch: 'False' box_outline: 'True' -box_pts: 'True' +box_pts: 'False' caption_align: 0.0 caption_col: '#333333' caption_offset: 8.0 From d0e3b56cfaf8288302917c7d78ffbc7dda431a42 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:38:19 -0700 Subject: [PATCH 52/92] updates to get revision box plots to render (more) correctly and so that RevisionBoxConfig can inherit from BoxConfig --- metplotpy/plots/base_plot.py | 12 +- metplotpy/plots/box/box.py | 9 +- metplotpy/plots/box/box_config.py | 53 ++++-- metplotpy/plots/revision_box/revision_box.py | 14 +- .../plots/revision_box/revision_box_config.py | 177 ++---------------- 5 files changed, 75 insertions(+), 190 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index c5cccc26..d12efd06 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -205,7 +205,10 @@ def get_weights_size_styles(self): weights_size_styles['ylab'] = ylab_property # For x2axis label if set - if hasattr(self.config_obj, 'x2lab_weight') and hasattr(self.config_obj, 'x2_title_font_size'): + 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 @@ -215,7 +218,10 @@ def get_weights_size_styles(self): # For y2axis label if set - if hasattr(self.config_obj, 'y2lab_weight') and hasattr(self.config_obj, 'y2_title_font_size'): + 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 @@ -403,7 +409,7 @@ def _add_xaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: linestyle='-', linewidth=self.config_obj.parameters['grid_lwd']) ax.set_axisbelow(True) - if self.config_obj.xaxis_reverse is True: + if self.config_obj.xaxis_reverse: ax.invert_xaxis() def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 8955eeeb..7a524ee3 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -194,14 +194,17 @@ def _create_figure(self): self._sync_yaxes(ax, ax_y2, yaxis_min, yaxis_max) self._add_legend(ax, handles_and_labels) - # 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) + self._add_custom_lines(ax) plt.tight_layout() 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 _sync_yaxes(self, ax, ax2, yaxis_min: Union[float, None], yaxis_max: Union[float, None]): if not self.config_obj.sync_yaxes: return diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index d4f267db..dc788ceb 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -73,12 +73,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 @@ -89,12 +95,18 @@ def __init__(self, parameters: dict) -> None: self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## - # 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 @@ -288,15 +300,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: @@ -327,9 +342,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() @@ -343,9 +358,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() @@ -356,7 +371,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/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index b26ed50f..8d37863a 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -14,6 +14,8 @@ import re from datetime import datetime +import numpy as np + from metplotpy.plots.base_plot import BasePlot from metplotpy.plots.box.box import Box @@ -100,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) @@ -144,6 +146,9 @@ def _create_figure(self): self.config_obj.plot_caption = annotation_text_all + # set the x-axis labels to match the user legends + self.config_obj.indy_label = self.config_obj.user_legends + super()._create_figure() # add custom lines @@ -177,8 +182,13 @@ def _create_figure(self): self.logger.info(f"Finished creating figure: {datetime.now()}") + def _add_custom_lines(self, ax): + return + def _get_data_to_plot_and_x_locs(self, series, idx): - return [series.series_points['points']['stat_value'].tolist()], None, MPL_DEFAULT_BOX_WIDTH + base = np.arange(len(self.config_obj.indy_label)) + x_locs = [base[idx]] + return series.series_points['points']['stat_value'].dropna().values, x_locs, MPL_DEFAULT_BOX_WIDTH # def _draw_series(self, series: RevisionBoxSeries) -> None: # """ diff --git a/metplotpy/plots/revision_box/revision_box_config.py b/metplotpy/plots/revision_box/revision_box_config.py index b8395436..f32898a2 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 ..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,172 +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') - - # 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 - - ############################################## - # 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' + # 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 + 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. @@ -217,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))) From 63e91a7461d8b0f55f907aa2ad0e591b7c2eba48 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:43:37 -0700 Subject: [PATCH 53/92] remove commented code and out-of-date comments --- metplotpy/plots/box/box.py | 47 ---------- metplotpy/plots/revision_box/revision_box.py | 99 -------------------- 2 files changed, 146 deletions(-) diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 7a524ee3..fb359f25 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -77,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): @@ -261,48 +256,6 @@ def _draw_series(self, ax: plt.Axes, ax2, series: BoxSeries, idx: int): box.set_facecolor(series.color) return boxplot['boxes'][0] - # 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: {datetime.now()}") def _get_data_to_plot_and_x_locs(self, series, idx): base = np.arange(len(self.config_obj.indy_vals)) diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index 8d37863a..f091616c 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -117,11 +117,6 @@ def _create_figure(self): self.logger.info(f"Begin creating the figure: {datetime.now()}") - #self.figure = self._create_layout() - #self._add_xaxis() - #self._add_yaxis() - #self._add_legend() - annotation_text_all = '' for inx, series in enumerate(self.series_list): # Don't generate the plot for this series if @@ -151,35 +146,6 @@ def _create_figure(self): super()._create_figure() - # 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 _add_custom_lines(self, ax): @@ -190,71 +156,6 @@ def _get_data_to_plot_and_x_locs(self, series, idx): x_locs = [base[idx]] return series.series_points['points']['stat_value'].dropna().values, x_locs, MPL_DEFAULT_BOX_WIDTH - # def _draw_series(self, series: RevisionBoxSeries) -> None: - # """ - # Draws the boxes on the plot - # - # :param series: RevisionBoxSeries object with data and parameters - # """ - # - # 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: - # """ - # 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, - # 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' - # ) - def write_output_file(self) -> None: """ Formats series values and dumps it into the file From 67c5a3fa3a7a387412fa7705180292230d8560c9 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:58:01 -0700 Subject: [PATCH 54/92] test copying files with differences to a diff artifact --- .github/workflows/unit_tests.yaml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index 3bad08fe..a8074f6d 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -118,7 +118,7 @@ jobs: uses: actions/checkout@v4 with: repository: dtcenter/METplus - ref: develop + ref: feature_copy_diff_files sparse-checkout: | metplus/util/diff_util.py sparse-checkout-cone-mode: false @@ -144,15 +144,23 @@ jobs: # 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 + 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 + uses: actions/upload-artifact@v4 + with: + name: diff_${{ matrix.python-version }} + path: ${{ runner.workspace }}/diff_${{ matrix.python-version }} + if-no-files-found: ignore From 1523642e46f6a868d5b5ca10f8e8cb60636dfc2c Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:09:36 -0700 Subject: [PATCH 55/92] create diff artifact even when the step before it fails due to diffs --- .github/workflows/unit_tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index a8074f6d..30a099dc 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -159,6 +159,7 @@ jobs: fi - name: Upload diff artifact + if: always() uses: actions/upload-artifact@v4 with: name: diff_${{ matrix.python-version }} From e53688eaf5d9eee6a1aa9b17a22145fee7a0bcdb Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:51:52 -0700 Subject: [PATCH 56/92] fix issues with hovmoeller test plot by closing global matplotlib plt after creating the image so the plot content is not leftover, potentially being added to other plots. Also update the hovmoeller plot to inherit from the plotly versions of the base components because it uses plotly --- metplotpy/plots/base_plot.py | 2 ++ metplotpy/plots/hovmoeller/hovmoeller.py | 2 +- metplotpy/plots/hovmoeller/hovmoeller_config.py | 2 +- .../plots/performance_diagram/performance_diagram.py | 8 -------- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index d12efd06..edd4634b 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -295,6 +295,8 @@ def save_to_file(self) -> None: plt.savefig(image_name, dpi=self.get_config_value('plot_res')) 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 . diff --git a/metplotpy/plots/hovmoeller/hovmoeller.py b/metplotpy/plots/hovmoeller/hovmoeller.py index 89145be3..8770c974 100644 --- a/metplotpy/plots/hovmoeller/hovmoeller.py +++ b/metplotpy/plots/hovmoeller/hovmoeller.py @@ -36,7 +36,7 @@ """ Import BasePlot class """ -from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots.base_plot_plotly import BasePlot class Hovmoeller(BasePlot): diff --git a/metplotpy/plots/hovmoeller/hovmoeller_config.py b/metplotpy/plots/hovmoeller/hovmoeller_config.py index a6b61fd4..e0e521e5 100644 --- a/metplotpy/plots/hovmoeller/hovmoeller_config.py +++ b/metplotpy/plots/hovmoeller/hovmoeller_config.py @@ -14,7 +14,7 @@ """ __author__ = 'Minna Win' -from ..config import Config +from ..config_plotly import Config class HovmoellerConfig(Config): def __init__(self, parameters): diff --git a/metplotpy/plots/performance_diagram/performance_diagram.py b/metplotpy/plots/performance_diagram/performance_diagram.py index a9d96d30..1b2bc141 100644 --- a/metplotpy/plots/performance_diagram/performance_diagram.py +++ b/metplotpy/plots/performance_diagram/performance_diagram.py @@ -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 From db8b37682df71f0398a8c62bd9b2ec4b1378a8b1 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:10:25 -0700 Subject: [PATCH 57/92] Update matplotlib plots to call self.save_to_file instead of plt.savefig directly so the logic to close the plt object after the image is generated is called consistently and upon failure to write the plot. Add optional arguments in save_to_file to pass to plt.savefig to handle taylor diagram different settings based on plotting just positive or negative/positive --- metplotpy/plots/base_plot.py | 6 ++++-- .../performance_diagram.py | 4 ++-- metplotpy/plots/scatter/scatter.py | 2 +- .../plots/taylor_diagram/taylor_diagram.py | 21 +++++++------------ 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index edd4634b..75ec2ff6 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -288,11 +288,13 @@ def get_img_bytes(self): return None - def save_to_file(self) -> None: + def save_to_file(self, **kwargs) -> None: + """!Saves the plot to a file. + Add any arguments passed to the function directly to plt.savefig.""" image_name = self.get_config_value('plot_filename') os.makedirs(os.path.dirname(image_name), exist_ok=True) try: - plt.savefig(image_name, dpi=self.get_config_value('plot_res')) + plt.savefig(image_name, dpi=self.get_config_value('plot_res'), **kwargs) except Exception as ex: self.logger.error(f"Failed to save plot to file: {ex}") finally: diff --git a/metplotpy/plots/performance_diagram/performance_diagram.py b/metplotpy/plots/performance_diagram/performance_diagram.py index 1b2bc141..d61cafb2 100644 --- a/metplotpy/plots/performance_diagram/performance_diagram.py +++ b/metplotpy/plots/performance_diagram/performance_diagram.py @@ -381,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/scatter/scatter.py b/metplotpy/plots/scatter/scatter.py index 3abaead7..1e36c95d 100644 --- a/metplotpy/plots/scatter/scatter.py +++ b/metplotpy/plots/scatter/scatter.py @@ -184,7 +184,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") diff --git a/metplotpy/plots/taylor_diagram/taylor_diagram.py b/metplotpy/plots/taylor_diagram/taylor_diagram.py index 77882284..146c6e9e 100644 --- a/metplotpy/plots/taylor_diagram/taylor_diagram.py +++ b/metplotpy/plots/taylor_diagram/taylor_diagram.py @@ -321,26 +321,21 @@ def _create_figure(self) -> None: frameon=self.config_obj.draw_box) plt.tight_layout() - plt.plot() + 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. - os.makedirs(os.path.dirname(self.config_obj.output_image), exist_ok=True) + # 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): From ad9f086ebb687043826ac3da5a04f5066c02f416 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:34:49 -0700 Subject: [PATCH 58/92] use develop version of diff util after changes were merged --- .github/workflows/unit_tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index 30a099dc..b168d938 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -118,7 +118,7 @@ jobs: uses: actions/checkout@v4 with: repository: dtcenter/METplus - ref: feature_copy_diff_files + ref: develop sparse-checkout: | metplus/util/diff_util.py sparse-checkout-cone-mode: false From 4a8bb99625c7ee859cff7f0756e04fb1d90c260b Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:15:21 -0700 Subject: [PATCH 59/92] only set xticks if indy_label is set in config object --- metplotpy/plots/base_plot.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 75ec2ff6..11925677 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -405,8 +405,9 @@ def _add_xaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: """ ax.set_xlabel(self.config_obj.xaxis, fontproperties=fontproperties, labelpad=abs(self.config_obj.parameters['xlab_offset']) * constants.PIXELS_TO_POINTS) - xtick_locs = np.arange(len(self.config_obj.indy_label)) - ax.set_xticks(xtick_locs, self.config_obj.indy_label) + if self.config_obj.indy_label: + xtick_locs = np.arange(len(self.config_obj.indy_label)) + 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 self.config_obj.grid_on: ax.grid(True, which='major', axis='x', color=self.config_obj.blended_grid_col, From 05ae5f420f3ec685322e073afbe27ce62d76c43d Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:16:29 -0700 Subject: [PATCH 60/92] set variable in base config instead of in each plot configs --- metplotpy/plots/bar/bar_config.py | 1 - metplotpy/plots/box/box_config.py | 1 - metplotpy/plots/config.py | 19 +++++++++++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/metplotpy/plots/bar/bar_config.py b/metplotpy/plots/bar/bar_config.py index cd012ec8..844564f5 100644 --- a/metplotpy/plots/bar/bar_config.py +++ b/metplotpy/plots/bar/bar_config.py @@ -109,7 +109,6 @@ 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() diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index dc788ceb..6591de0c 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -135,7 +135,6 @@ def __init__(self, parameters: dict) -> None: self.legend_orientation = 'v' else: self.legend_orientation = 'h' - self.legend_border_color = "black" # Default Matplotlib values for whiskers self.whis = 1.5 diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index fc3a8f08..e8e28282 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -237,6 +237,7 @@ def __init__(self, parameters): # Don't draw a box around legend labels unless an 'o' is set legend_box = self.get_config_value('legend_box').lower() 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 @@ -562,12 +563,26 @@ 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_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 From fc68df80f2cbd4b53f68672fe3102b6831c06229 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:17:16 -0700 Subject: [PATCH 61/92] add plotly marker strings to dictionary to convert to matplotlib marker --- metplotpy/plots/constants.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 5b91b679..fc27a145 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -66,10 +66,27 @@ AVAILABLE_MARKERS_LIST = ["o", "^", "s", "d", "H", ".", "h"] -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_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 + '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} From 43191df5de15db076e0c4a6fe5c1b35524adb1bd Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:17:47 -0700 Subject: [PATCH 62/92] clean up formatting and remove duplicate variables that were accidentally added from merge conflict --- metplotpy/plots/constants.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index fc27a145..03adde93 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -94,18 +94,16 @@ XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} -# approximated from plotly marker size to matplotlib marker size -PCH_TO_MATPLOTLIB_MARKER_SIZE = {'.': 14, 'o': 36, 's': 20, '^': 36, 'd': 20, 'H': 28} - -# used for tick angles -XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} -YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} - # 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 From 03f5c7fad72b09927430f909f7774b89e4bf92fa Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:18:10 -0700 Subject: [PATCH 63/92] move another variable to base config --- metplotpy/plots/histogram/hist_config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/metplotpy/plots/histogram/hist_config.py b/metplotpy/plots/histogram/hist_config.py index 932fe4ee..fd1fecdb 100644 --- a/metplotpy/plots/histogram/hist_config.py +++ b/metplotpy/plots/histogram/hist_config.py @@ -97,7 +97,6 @@ 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'] From 3f9e5a9600d1f813f7f7521f0620f2be6e580e5f Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:19:28 -0700 Subject: [PATCH 64/92] Per #569, progress towards replacing plotly with matplotlib for ROC diagram plots --- metplotpy/plots/roc_diagram/roc_diagram.py | 389 ++++++------------ .../plots/roc_diagram/roc_diagram_config.py | 55 +-- .../plots/roc_diagram/roc_diagram_series.py | 2 +- 3 files changed, 138 insertions(+), 308 deletions(-) diff --git a/metplotpy/plots/roc_diagram/roc_diagram.py b/metplotpy/plots/roc_diagram/roc_diagram.py index dfd84035..4b326753 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram.py +++ b/metplotpy/plots/roc_diagram/roc_diagram.py @@ -19,12 +19,12 @@ import warnings import pandas as pd -import plotly.graph_objects as go -from plotly.subplots import make_subplots -from metplotpy.plots import util_plotly as util -from metplotpy.plots import constants_plotly as constants -from metplotpy.plots.base_plot_plotly import BasePlot +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 @@ -94,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: """ @@ -207,8 +202,7 @@ def _create_series(self, input_data): 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']) - elif self.config_obj.linetype_pct: - if df_sum_main is None: + elif self.config_obj.linetype_pct and 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) @@ -257,226 +251,112 @@ def _create_series(self, input_data): return series_list - 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. - """ - - image_name = self.get_config_value('plot_filename') - warnings.filterwarnings("ignore", category=DeprecationWarning) - - # remove the old file if it exist - if os.path.exists(image_name): - os.remove(image_name) - def _create_figure(self): """ Generate the performance diagram of varying number of series with POD and 1-FAR (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.])) + # create and draw the plot + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) - # 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 - )) + wts_size_styles = self.get_weights_size_styles() - # 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 - )) + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) - else: - fig.update_layout(legend=dict(x=self.config_obj.bbox_x, - y=self.config_obj.bbox_y - )) + self._add_series(ax) - # 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() + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) - for idx, series in enumerate(self.series_list): - for i, thresh_val in enumerate(series.series_points[2]): - thresh_list.append(str(thresh_val)) + self._add_legend(ax) - # 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 - ) + # 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()}") - 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: - """ + # 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.])) - 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") - if self.config_obj.add_point_thresholds: - fig.for_each_trace(add_trace_copy) + # 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 + # )) + # + # else: + # fig.update_layout(legend=dict(x=self.config_obj.bbox_x, + # y=self.config_obj.bbox_y + # )) - self.logger.info(f"Finished creating figure: {datetime.now()}") + # 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 + # )) - 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): """ @@ -484,61 +364,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): @@ -552,17 +421,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 a2ca8a01..62891d38 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram_config.py +++ b/metplotpy/plots/roc_diagram/roc_diagram_config.py @@ -15,9 +15,9 @@ """ __author__ = 'Minna Win' -from ..config_plotly import Config -from .. import util_plotly as util -from .. import constants_plotly as constants +from ..config import Config +from .. import util +from .. import constants class ROCDiagramConfig(Config): def __init__(self, parameters): @@ -63,8 +63,8 @@ def __init__(self, parameters): self.linetype_pct = self.get_config_value('roc_pct') # 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": @@ -74,21 +74,21 @@ 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.title_offset = 1.0 + abs(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") @@ -107,14 +107,17 @@ def __init__(self, parameters): 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 @@ -122,7 +125,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'] @@ -131,8 +133,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(): @@ -278,34 +280,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/roc_diagram/roc_diagram_series.py b/metplotpy/plots/roc_diagram/roc_diagram_series.py index 81be29fe..9d3f59c2 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram_series.py +++ b/metplotpy/plots/roc_diagram/roc_diagram_series.py @@ -17,7 +17,7 @@ import pandas as pd import metcalcpy.util.utils as utils from ..series import Series -from ..util_plotly import prepare_pct_roc, prepare_ctc_roc +from ..util import prepare_pct_roc, prepare_ctc_roc class ROCDiagramSeries(Series): From 65a44599677e7eed49c28693429c0bb17dac9fb9 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:19:52 -0700 Subject: [PATCH 65/92] remove redundant config function override that does the same thing as the base config version --- .../taylor_diagram/taylor_diagram_config.py | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/metplotpy/plots/taylor_diagram/taylor_diagram_config.py b/metplotpy/plots/taylor_diagram/taylor_diagram_config.py index c7696cfa..dc182b6c 100644 --- a/metplotpy/plots/taylor_diagram/taylor_diagram_config.py +++ b/metplotpy/plots/taylor_diagram/taylor_diagram_config.py @@ -259,28 +259,6 @@ def _get_plot_disp(self) -> list: 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 From 7ef2aaeeddbf310404d94fbab51769f619aabed0 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:20:22 -0700 Subject: [PATCH 66/92] remove commented code that is no longer used since we are using matplotlib and we don't write html files --- metplotpy/plots/util.py | 1 - 1 file changed, 1 deletion(-) diff --git a/metplotpy/plots/util.py b/metplotpy/plots/util.py index 1f8cf0cd..04971e9f 100644 --- a/metplotpy/plots/util.py +++ b/metplotpy/plots/util.py @@ -99,7 +99,6 @@ def make_plot(config_filename, plot_class): try: plot = plot_class(params) plot.save_to_file() - # 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()}") From 74e10d044ffbb6b516faf6a510b9ca5274ae3a8a Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:20:54 -0700 Subject: [PATCH 67/92] turn off grid to more closely match expected image --- test/roc_diagram/CTC_ROC_thresh.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/roc_diagram/CTC_ROC_thresh.yaml b/test/roc_diagram/CTC_ROC_thresh.yaml index 9a1c5bb6..3f4bc376 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' From df7b582c293b9dab4901231c21ba0ce6ee60f6fe Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:21:15 -0700 Subject: [PATCH 68/92] move path variables to top for each reference --- test/roc_diagram/custom_roc_diagram.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/roc_diagram/custom_roc_diagram.yaml b/test/roc_diagram/custom_roc_diagram.yaml index 3a1e463d..6831bad8 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. From 2ab158447125397d087932a209b0ef7a09c6a503 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:21:38 -0700 Subject: [PATCH 69/92] remove html file checks from tests since we no longer create them --- test/roc_diagram/test_roc_diagram.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/roc_diagram/test_roc_diagram.py b/test/roc_diagram/test_roc_diagram.py index b44ea918..4569d356 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..") From f0707c925803813722597efef82cec7371c81026 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:37:36 -0700 Subject: [PATCH 70/92] fix incorrect location of caption by overriding _add_caption function to place annotation text at top left of plot regardless of config settings --- metplotpy/plots/revision_box/revision_box.py | 33 +++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index f091616c..23756875 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -117,6 +117,20 @@ def _create_figure(self): self.logger.info(f"Begin creating the figure: {datetime.now()}") + 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 @@ -124,7 +138,6 @@ def _create_figure(self): if not series.plot_disp: continue - #self._draw_series(series) # construct annotation text annotation_text = series.user_legends + ': ' if self.config_obj.revision_run: @@ -137,16 +150,20 @@ def _create_figure(self): annotation_text_all = annotation_text_all + annotation_text if inx < len(self.series_list) - 1: - annotation_text_all = annotation_text_all + '
' + annotation_text_all = annotation_text_all + '\n' self.config_obj.plot_caption = annotation_text_all - # 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 _add_caption(self, plt, font_properties): + """ + 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. + """ + 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 From 6086c88d0810b36dd6a140f1bd5f207a9cd45eaa Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:56:11 -0700 Subject: [PATCH 71/92] clean up --- metplotpy/plots/revision_box/revision_box.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index 23756875..61cbfc96 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -139,18 +139,19 @@ def _create_annotation(self): continue # construct annotation text - annotation_text = series.user_legends + ': ' + annotation_text = f"{series.user_legends}: " if self.config_obj.revision_run: - annotation_text = annotation_text + 'WW Runs Test:' + series.series_points['revision_run'] + ' ' + annotation_text += f"WW Runs Test: {series.series_points['revision_run']} " 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'] + annotation_text += ( + f"Auto-Corr Test: p={series.series_points['auto_cor_p']}, " + f"r={series.series_points['auto_cor_r']}" + ) - annotation_text_all = annotation_text_all + annotation_text + annotation_text_all += annotation_text if inx < len(self.series_list) - 1: - annotation_text_all = annotation_text_all + '\n' + annotation_text_all += '\n' self.config_obj.plot_caption = annotation_text_all From 794d0dbaaf307e679235859a7aceb1049f7da046 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:56:59 -0600 Subject: [PATCH 72/92] resolve SonarQube issues --- metplotpy/plots/roc_diagram/roc_diagram.py | 125 ++++++++++-------- .../plots/roc_diagram/roc_diagram_config.py | 2 +- 2 files changed, 74 insertions(+), 53 deletions(-) diff --git a/metplotpy/plots/roc_diagram/roc_diagram.py b/metplotpy/plots/roc_diagram/roc_diagram.py index 4b326753..b090244f 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram.py +++ b/metplotpy/plots/roc_diagram/roc_diagram.py @@ -194,63 +194,84 @@ 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']) - elif self.config_obj.linetype_pct and 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 = util.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 = util.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=['fcst_thresh', 'fy_oy', 'fy_on', 'fn_oy', 'fn_on']) + elif self.config_obj.linetype_pct: + 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) + + 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) + + series_obj = ROCDiagramSeries(self.config_obj, num_series -1, None) + series_obj.series_points = (pofd, pody, thresh, None) + + series_list.append(series_obj) return series_list + 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): """ Generate the performance diagram of varying number of series with POD and 1-FAR diff --git a/metplotpy/plots/roc_diagram/roc_diagram_config.py b/metplotpy/plots/roc_diagram/roc_diagram_config.py index 62891d38..538dfe7f 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram_config.py +++ b/metplotpy/plots/roc_diagram/roc_diagram_config.py @@ -232,7 +232,7 @@ 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 From d1b7ef7d7f98cc32ceeca9441cb19d831658ef19 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:57:31 -0600 Subject: [PATCH 73/92] add a note to review functionality and improve error checking if needed --- metplotpy/plots/roc_diagram/roc_diagram_config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/metplotpy/plots/roc_diagram/roc_diagram_config.py b/metplotpy/plots/roc_diagram/roc_diagram_config.py index 538dfe7f..5a5c6c1c 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram_config.py +++ b/metplotpy/plots/roc_diagram/roc_diagram_config.py @@ -61,6 +61,7 @@ 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') From f6964c192f739f9ac6eef2c3d05d2b4e55496720 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:05:59 -0600 Subject: [PATCH 74/92] handle title that contains html line breaks --- metplotpy/plots/base_plot.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 11925677..09b94811 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -348,7 +348,7 @@ def get_array_dimensions(data): def _add_title(self, ax, font_properties): ax.set_title( - self.config_obj.title, + self.config_obj.title.replace('
', '\n'), fontproperties=font_properties, color=constants.DEFAULT_TITLE_COLOR, pad=28, @@ -399,17 +399,23 @@ def _add_legend(self, ax: plt.Axes, handles_and_labels=None) -> None: frame = legend.get_frame() frame.set_linewidth(self.config_obj.legend_border_width) - def _add_xaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: + def _add_xaxis(self, ax: plt.Axes, fontproperties: FontProperties, label=None, grid_on=None) -> None: """ Configures and adds x-axis to the plot """ - ax.set_xlabel(self.config_obj.xaxis, fontproperties=fontproperties, + if label is None: + label = self.config_obj.xaxis + 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: xtick_locs = np.arange(len(self.config_obj.indy_label)) 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 self.config_obj.grid_on: + 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) @@ -417,11 +423,15 @@ def _add_xaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: if self.config_obj.xaxis_reverse: ax.invert_xaxis() - def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: + def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties, label=None, grid_on=None) -> None: """ Configures and adds y-axis to the plot """ - ax.set_ylabel(self.config_obj.yaxis_1, fontproperties=fontproperties, + if label is None: + label = self.config_obj.yaxis_1 + 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) @@ -430,7 +440,7 @@ def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: ax.set_ylim(self.config_obj.parameters['ylim']) # add grid lines if requested - if self.config_obj.grid_on: + 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) @@ -460,7 +470,7 @@ def _add_x2axis(self, ax, n_stats, fontproperties: FontProperties) -> None: # this doesn't appear to be working to add ticks at the top ax_top.tick_params(axis="x", direction="in", labelrotation=self.config_obj.x2_tickangle) - def _add_y2axis(self, ax: plt.Axes, fontproperties: FontProperties): + def _add_y2axis(self, ax: plt.Axes, fontproperties: Union[FontProperties, None]): """ Adds y2-axis if needed """ From 395e1de1479f2390ceaefb0eca99ec63ae7c4dce Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:12:23 -0600 Subject: [PATCH 75/92] add function to base config to get string to define plot mode, e.g. lines, markers, or lines+markers, that can be parsed to determine how to format plot with matplotlib. This uses the plotly mode naming because it is easy to parse when setting matplotlib settings --- metplotpy/plots/config.py | 18 ++++++++++++++++++ metplotpy/plots/constants.py | 2 ++ 2 files changed, 20 insertions(+) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index e8e28282..509ccadd 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -983,3 +983,21 @@ def _config_compare_lists_to_num_series(self, lists_to_check: dict) -> list: 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/constants.py b/metplotpy/plots/constants.py index 03adde93..fcf716ef 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -91,6 +91,8 @@ # 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} From fd1b546552d4931b556d1158dfcf8aa018e61e8d Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:12:55 -0600 Subject: [PATCH 76/92] progress towards replacing plotly with matplotlib for reliability diagram --- .../plots/reliability_diagram/reliability.py | 893 ++++++++++-------- .../reliability_diagram/reliability_config.py | 159 +--- .../reliability_diagram/reliability_series.py | 8 +- 3 files changed, 525 insertions(+), 535 deletions(-) diff --git a/metplotpy/plots/reliability_diagram/reliability.py b/metplotpy/plots/reliability_diagram/reliability.py index aad7eab9..7a391aed 100644 --- a/metplotpy/plots/reliability_diagram/reliability.py +++ b/metplotpy/plots/reliability_diagram/reliability.py @@ -22,13 +22,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 import ticker -from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR -from metplotpy.plots.base_plot_plotly import BasePlot -from metplotpy.plots import util_plotly as util +from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots import util from metplotpy.plots.reliability_diagram.reliability_config import ReliabilityConfig from metplotpy.plots.reliability_diagram.reliability_series import ReliabilitySeries @@ -58,16 +56,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 +67,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,15 +135,53 @@ 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() + fig, 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)) + else: + ax2 = self._add_y2axis(ax, None) + + self._add_xaxis(ax2, wts_size_styles['xlab']) + 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()) + + # if self.config_obj.rely_event_hist is True and self.config_obj.inset_hist is False: + # ax_y2 = self._add_y2axis(ax, None) + + handles_and_labels = self._add_series(ax, ax2) + + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) + + 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() @@ -177,15 +199,11 @@ 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) + self._draw_series(ax, ax2, series, x_points_index_adj) - 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) -> None: """ Draws the formatted line with CIs if needed on the plot @@ -195,32 +213,37 @@ 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_line(series.series_points['stat_value'][0]) - self._add_perfect_reliability_line() - self._add_noresolution_line(series.series_points['stat_value'][0]) + self._add_noskill_polygon(ax, series.series_points['stat_value'][0]) + + # determine whether to add to the inset plot or the main plot + plot_ax = ax + if self.config_obj.inset_hist: + plot_ax = ax2 + + if self.config_obj.rely_event_hist and 'n_i' in series.series_points: + + plot_ax.bar(x=x_points_index_adj, height=series.series_points['n_i'].tolist(), align='center', + color=self.config_obj.colors_list[series.idx], + label="Absolute_cases") + # 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_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,359 +255,435 @@ 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' + + 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], + ) + # 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]} + # + # ) self.logger.info(f"Finished with bar plot and skill lines :{datetime.now()}") - 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_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, 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_') + + #ax.plot(x, y, label='No-Skill poly', color='#ededed') + # + # self.figure.add_trace( + # go.Scatter(x=, + # y=, + # fill='toself', + # fillcolor='#ededed', + # line={'color': '#ededed'}, + # showlegend=False, + # name='No-Skill poly', + # hoverinfo='skip', + # opacity=0.5 + # ) + # ) + + 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') + if not o_bar: + print(" WARNING: no-skill line can't be created for the series") + return + + # 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.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 + 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, + ) - def _add_perfect_reliability_line(self) -> None: + # 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 + # ) + + def _add_perfect_reliability_line(self, ax) -> None: """ Adds perfect reliability line to the graph if needed - :return: """ + if not self.config_obj.add_skill_line: + 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: + 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) + # 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' + # ) + # ) + ax.text( + 1, util.abline(1, 0, 1), + "Perfect reliability", + size=self.config_obj.x_tickfont_size, + color='#636363', + rotation=270, + transform=ax.transAxes, + ) + + # 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, ax, 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 """ + if not self.config_obj.add_reference_line: + return 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 + 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) + + # 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' + # ) + # ) + + ax.plot(ab_line, end_to_end, label='_No-resolution_', + color=self.config_obj.reference_line_col, linestyle='--', zorder=0, linewidth=1) + + # 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' + # ) + # ) + 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, ) - 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: - """ - Configures and adds y-axis to the plot - """ - 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}, - - ) - # 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" - } - }) + # 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 + # ) + + # 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 + # ) + # 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: + # """ + # Configures and adds y-axis to the plot + # """ + # + # 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}, + # + # ) + # # 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): """ @@ -593,32 +692,6 @@ def remove_file(self): """ 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("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) 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 3f1175d1..a08f04b1 100644 --- a/metplotpy/plots/reliability_diagram/reliability_config.py +++ b/metplotpy/plots/reliability_diagram/reliability_config.py @@ -16,11 +16,10 @@ __author__ = 'Tatiana Burek' import itertools -from datetime import datetime -from ..config_plotly import Config -from .. import constants_plotly as constants -from .. import util_plotly as util +from ..config import Config +from .. import constants +from .. import util class ReliabilityConfig(Config): @@ -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,7 @@ 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.title_offset = 1.0 + abs(self.parameters['title_offset']) * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -92,7 +93,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 @@ -171,127 +171,44 @@ 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. + """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: a list of the plotly markers + 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: - if marker in constants.AVAILABLE_PLOTLY_MARKERS_LIST: - markers_size.append(marker) - else: - markers_size.append(constants.PCH_TO_PLOTLY_MARKER_SIZE[marker]) + markers_size.append(constants.PCH_TO_MATPLOTLIB_MARKER_SIZE.get(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 92be7495..1c09eb3f 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 From 443cc88027311a698a819ffb536f21784c87a238 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:13:14 -0600 Subject: [PATCH 77/92] adjust title in vertical dimension --- metplotpy/plots/roc_diagram/roc_diagram_config.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/metplotpy/plots/roc_diagram/roc_diagram_config.py b/metplotpy/plots/roc_diagram/roc_diagram_config.py index 5a5c6c1c..06855e27 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram_config.py +++ b/metplotpy/plots/roc_diagram/roc_diagram_config.py @@ -102,9 +102,7 @@ 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') From 2972c61a2b9bf8d03fcf70eaa3c103c9664e40d3 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:50:45 -0600 Subject: [PATCH 78/92] Use indy_vals as the x tick locations if they are numeric, otherwise use integers to place them evenly. For reliability, set indy_label to indy_vals if it is empty. This fixes the issue where the 0-1 by 0.1 x-axis ticks cause the no-skill/perfect-reliability lines to only go up to the 1st x tick because it is 0. --- metplotpy/plots/base_plot.py | 11 +++++++++++ .../plots/reliability_diagram/reliability_config.py | 2 ++ 2 files changed, 13 insertions(+) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 09b94811..f5c3e3c9 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -405,13 +405,24 @@ def _add_xaxis(self, ax: plt.Axes, fontproperties: FontProperties, label=None, g """ if label is None: label = self.config_obj.xaxis + 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: + # 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 + 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) diff --git a/metplotpy/plots/reliability_diagram/reliability_config.py b/metplotpy/plots/reliability_diagram/reliability_config.py index a08f04b1..b065f263 100644 --- a/metplotpy/plots/reliability_diagram/reliability_config.py +++ b/metplotpy/plots/reliability_diagram/reliability_config.py @@ -111,6 +111,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 From 5e446cbd599784f7f42341573aa7045eb5901903 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:52:02 -0600 Subject: [PATCH 79/92] ensure the reliability axes go from 0-1 --- metplotpy/plots/reliability_diagram/reliability.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/metplotpy/plots/reliability_diagram/reliability.py b/metplotpy/plots/reliability_diagram/reliability.py index 7a391aed..d94120a7 100644 --- a/metplotpy/plots/reliability_diagram/reliability.py +++ b/metplotpy/plots/reliability_diagram/reliability.py @@ -153,6 +153,7 @@ def _create_figure(self): ax2 = self._add_y2axis(ax, None) self._add_xaxis(ax2, wts_size_styles['xlab']) + ax2.set_xlim(0, 1) self._add_yaxis(ax2, wts_size_styles['ylab'], label="# Forecasts", grid_on=True) # format large numbers like 3 million as 3M @@ -164,7 +165,10 @@ def _create_figure(self): 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) From b84ca35e77d3ba95e83f8493f6b50d24fc4058da Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:52:24 -0600 Subject: [PATCH 80/92] adjust settings to more closely match expected image --- test/reliability_diagram/custom_reliability_points1.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/reliability_diagram/custom_reliability_points1.yaml b/test/reliability_diagram/custom_reliability_points1.yaml index 8ea99682..657093ba 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 From 1a70c02e835ca5f96b37b26d865c94d6add68d4f Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:53:34 -0600 Subject: [PATCH 81/92] Attempt to improve the appearance of the error bars. The example config doesn't provide a great example to work with, but it looks better than it did --- .../plots/reliability_diagram/reliability.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/metplotpy/plots/reliability_diagram/reliability.py b/metplotpy/plots/reliability_diagram/reliability.py index d94120a7..dbeae505 100644 --- a/metplotpy/plots/reliability_diagram/reliability.py +++ b/metplotpy/plots/reliability_diagram/reliability.py @@ -27,6 +27,7 @@ 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 @@ -193,7 +194,7 @@ def _add_series(self, ax, ax2): 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 @@ -203,11 +204,11 @@ def _add_series(self, ax, ax2): # 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, ax2, series, x_points_index_adj) + self._draw_series(ax, ax2, series, x_points_index_adj, index) return handles_and_labels - def _draw_series(self, ax, ax2, 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 @@ -226,9 +227,16 @@ def _draw_series(self, ax, ax2, series: ReliabilitySeries, x_points_index_adj: l if self.config_obj.rely_event_hist and 'n_i' in series.series_points: - plot_ax.bar(x=x_points_index_adj, height=series.series_points['n_i'].tolist(), align='center', - color=self.config_obj.colors_list[series.idx], - label="Absolute_cases") + 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] + + plot_ax.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") # bar_trace = go.Bar( # x=x_points_index_adj, # y=series.series_points['n_i'].tolist(), From 6f99672759d91e14280ee428e41fde547f80fa77 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:57:59 -0600 Subject: [PATCH 82/92] clean up, removing old plotly code that is no longer needed --- .../plots/reliability_diagram/reliability.py | 341 +----------------- 1 file changed, 4 insertions(+), 337 deletions(-) diff --git a/metplotpy/plots/reliability_diagram/reliability.py b/metplotpy/plots/reliability_diagram/reliability.py index dbeae505..022cf420 100644 --- a/metplotpy/plots/reliability_diagram/reliability.py +++ b/metplotpy/plots/reliability_diagram/reliability.py @@ -160,10 +160,7 @@ def _create_figure(self): # format large numbers like 3 million as 3M ax2.yaxis.set_major_formatter(ticker.EngFormatter()) - # if self.config_obj.rely_event_hist is True and self.config_obj.inset_hist is False: - # ax_y2 = self._add_y2axis(ax, None) - - handles_and_labels = self._add_series(ax, ax2) + self._add_series(ax, ax2) self._add_xaxis(ax, wts_size_styles['xlab']) ax.set_xlim(0, 1) @@ -171,7 +168,7 @@ def _create_figure(self): ax.set_ylim(0, 1) ax.set_yticks(np.linspace(0, 1, 11)) - self._add_legend(ax, handles_and_labels) + self._add_legend(ax) self._add_custom_lines(ax) @@ -186,8 +183,6 @@ def _add_custom_lines(self, ax): 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() @@ -206,8 +201,6 @@ def _add_series(self, ax, ax2): if series.plot_disp: self._draw_series(ax, ax2, series, x_points_index_adj, index) - return handles_and_labels - 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 @@ -237,21 +230,6 @@ def _draw_series(self, ax, ax2, series: ReliabilitySeries, x_points_index_adj: l width=width, color=self.config_obj.colors_list[series.idx], label="Absolute_cases") - # 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_line(ax, series.series_points['stat_value'][0]) self._add_perfect_reliability_line(ax) @@ -289,48 +267,9 @@ def _draw_series(self, ax, ax2, series: ReliabilitySeries, x_points_index_adj: l yerr=y_errors if error_y_visible else None, elinewidth=self.config_obj.linewidth_list[series.idx], ) - # 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]} - # - # ) self.logger.info(f"Finished with bar plot and skill lines :{datetime.now()}") - # 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, ax, o_bar: Union[float, None]) -> None: """ Adds no-skill polygon to the graph if needed and o_bar is not None @@ -353,21 +292,6 @@ def _add_noskill_polygon(self, ax, o_bar: Union[float, None]) -> None: alpha=0.5, label='_no-skill-poly_') - #ax.plot(x, y, label='No-Skill poly', color='#ededed') - # - # self.figure.add_trace( - # go.Scatter(x=, - # y=, - # fill='toself', - # fillcolor='#ededed', - # line={'color': '#ededed'}, - # showlegend=False, - # name='No-Skill poly', - # hoverinfo='skip', - # opacity=0.5 - # ) - # ) - 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 @@ -385,17 +309,7 @@ def _add_noskill_line(self, ax, o_bar: Union[float, None]) -> None: 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.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 ax.text( 1, util.abline(1, intercept, 0.5), @@ -406,23 +320,6 @@ def _add_noskill_line(self, ax, o_bar: Union[float, None]) -> None: transform=ax.transAxes, ) - # 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 - # ) - def _add_perfect_reliability_line(self, ax) -> None: """ Adds perfect reliability line to the graph if needed @@ -434,16 +331,7 @@ def _add_perfect_reliability_line(self, ax) -> None: 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) - # 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' - # ) - # ) + ax.text( 1, util.abline(1, 0, 1), "Perfect reliability", @@ -453,23 +341,6 @@ def _add_perfect_reliability_line(self, ax) -> None: transform=ax.transAxes, ) - # 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, ax, o_bar: Union[float, None]) -> None: """ Adds no-resolution line to the graph if needed and o_bar is not None @@ -489,32 +360,9 @@ def _add_noresolution_line(self, ax, o_bar: Union[float, None]) -> None: ax.plot(end_to_end, ab_line, label='_No-resolution_', color=self.config_obj.reference_line_col, linestyle='--', zorder=0, linewidth=1) - # 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' - # ) - # ) - ax.plot(ab_line, end_to_end, label='_No-resolution_', color=self.config_obj.reference_line_col, linestyle='--', zorder=0, linewidth=1) - # 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' - # ) - # ) ax.text( 1, util.abline(1, o_bar, 0), "No-resolution", @@ -524,187 +372,6 @@ def _add_noresolution_line(self, ax, o_bar: Union[float, None]) -> None: transform=ax.transAxes, ) - # 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 - # ) - - # 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 - # ) - # 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: - # """ - # Configures and adds y-axis to the plot - # """ - # - # 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}, - # - # ) - # # 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() - def write_output_file(self) -> None: """ Formats series point data to the 2-dim array and saves it to the files From 58189cb76b85c8eeb03bd92d0aeb1f21f7249998 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:22:17 -0600 Subject: [PATCH 83/92] ignore auto-generated files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index ad8168d4..42b4c42b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ __pycache__/ # files that are generated when package is installed build/ metplotpy.egg-info/ + +.DS_Store From 587571082a734d140c33609ad2901dee2b1c0ed1 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:23:03 -0600 Subject: [PATCH 84/92] move config check that is specific to box plot and was preventing ens_ss from properly adding y2 axis --- metplotpy/plots/base_plot.py | 3 --- metplotpy/plots/box/box.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index f5c3e3c9..4a07ede4 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -485,9 +485,6 @@ def _add_y2axis(self, ax: plt.Axes, fontproperties: Union[FontProperties, None]) """ Adds y2-axis if needed """ - if not self.config_obj.parameters['list_stat_2']: - return None - ax_right = ax.twinx() ax_right.set_ylabel(self.config_obj.yaxis_2, fontproperties=fontproperties, labelpad=abs(self.config_obj.parameters['y2lab_offset']) * constants.PIXELS_TO_POINTS) diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index fb359f25..f3bab0df 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -174,7 +174,7 @@ def _create_figure(self): self._add_caption(plt, wts_size_styles['caption']) ax_y2 = None - if wts_size_styles.get('y2lab'): + if wts_size_styles.get('y2lab') and self.config_obj.parameters['list_stat_2']: ax_y2 = self._add_y2axis(ax, wts_size_styles['y2lab']) n_stats, handles_and_labels, yaxis_min, yaxis_max = self._add_series(ax, ax_y2) From dae8d6958cbe08efa77b7a891c88bfcfddd6cb3d Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:25:18 -0600 Subject: [PATCH 85/92] move marker size function to base config to be used by multiple plots --- metplotpy/plots/config.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 509ccadd..67c84816 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -568,6 +568,23 @@ def _get_markers(self): 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. From 3bc72bea8475f0ad711f7f647e3a2a3ae0417923 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:26:10 -0600 Subject: [PATCH 86/92] convert ens_ss plot from plotly to matplotlib --- metplotpy/plots/ens_ss/ens_ss.py | 437 +++++++----------------- metplotpy/plots/ens_ss/ens_ss_config.py | 155 ++------- metplotpy/plots/ens_ss/ens_ss_series.py | 2 +- 3 files changed, 153 insertions(+), 441 deletions(-) diff --git a/metplotpy/plots/ens_ss/ens_ss.py b/metplotpy/plots/ens_ss/ens_ss.py index a037ab29..ccd9adb6 100644 --- a/metplotpy/plots/ens_ss/ens_ss.py +++ b/metplotpy/plots/ens_ss/ens_ss.py @@ -21,16 +21,14 @@ 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_plotly 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_plotly import BasePlot -import metplotpy.plots.util_plotly as util +from metplotpy.plots.base_plot import BasePlot +import metplotpy.plots.util as util import metcalcpy.util.utils as utils @@ -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,104 @@ 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() + fig, 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) + # format large numbers like 3 million as 3M + ax_y2.yaxis.set_major_formatter(ticker.EngFormatter()) - # apply y axis limits - self._yaxis_limits() - self._y2axis_limits() + handles_and_labels = self._add_series(ax, ax_y2) - self.logger.info(f"Finished creating the figure: {datetime.now()}") + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) - 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_legend(ax, handles_and_labels) - def _draw_series(self, series: EnsSsSeries) -> None: - """ - Draws the formatted line on the plot + self._add_custom_lines(ax) - :param series: EnsSs series object with data and parameters - """ - self.logger.info(f"Begin drawing the series on the plot:" - f" {datetime.now()}") + plt.tight_layout() - # 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 + self.logger.info(f"Finished creating the figure: {datetime.now()}") - :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}) + 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) - 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_series(self, ax, ax2): + handles_and_labels = [] + i = 0 + counter = 1 + if self.config_obj.ensss_pts_disp: + counter = 2 - super().remove_file() - self._remove_html() + # for series in self.series_list: + for idx, series in enumerate(self.series_list): + if not series.plot_disp: + continue - def _remove_html(self) -> None: - """ - Removes previously made HTML file. - """ + 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())) - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" + # while i < len(self.series_list): + # + # # 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: + # handle, handle2 = self._draw_series(ax, ax2, self.series_list[i]) + # handles_and_labels.append((handle, handle.get_label())) + # handles_and_labels.append((handle2, handle2.get_label())) + # i = i + counter - # 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 +316,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 298428c5..ce776c8b 100644 --- a/metplotpy/plots/ens_ss/ens_ss_config.py +++ b/metplotpy/plots/ens_ss/ens_ss_config.py @@ -17,9 +17,8 @@ import itertools -from ..config_plotly import Config -from .. import constants_plotly as constants -from .. import util_plotly as util +from ..config import Config +from .. import constants import metcalcpy.util.utils as utils @@ -59,7 +58,7 @@ 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.title_offset = 1.0 + abs(self.parameters['title_offset']) * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -86,7 +85,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 +102,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() @@ -178,104 +177,26 @@ def _get_plot_disp(self) -> list: 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 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. - def _get_markers(self) -> list: + @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) """ - 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) - - """ - # 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 +223,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 +243,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 +276,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 +285,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 28d599c6..348e744f 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_plotly as util +import metplotpy.plots.util as util from .. import GROUP_SEPARATOR from ..series import Series From bf517e295f423a9e6a9161b2c39be1f0143059ba Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:26:29 -0600 Subject: [PATCH 87/92] remove function that was moved to base config --- .../reliability_diagram/reliability_config.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/metplotpy/plots/reliability_diagram/reliability_config.py b/metplotpy/plots/reliability_diagram/reliability_config.py index b065f263..ad5dafdd 100644 --- a/metplotpy/plots/reliability_diagram/reliability_config.py +++ b/metplotpy/plots/reliability_diagram/reliability_config.py @@ -173,23 +173,6 @@ def _get_fcst_vars(self, index): return fcst_var_val_dict - 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 config_consistency_check(self) -> None: """Checks that the number of settings defined for plot_disp, series_ordering, colors_list, user_legends, and show_legend From 886c42e62411133ede03e512d6abd03054741396 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:26:58 -0600 Subject: [PATCH 88/92] fix badly formatted show_legend values and moved path variables to top for easier review --- test/ens_ss/custom_ens_ss.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/ens_ss/custom_ens_ss.yaml b/test/ens_ss/custom_ens_ss.yaml index 5d4dd003..0d04e1f5 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 From 6339912f8cf4622d1f6811afd20cd021e579a5d4 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:37:03 -0600 Subject: [PATCH 89/92] fix issues with x-axis labels/ticks that was introduced with changes to properly handle the x value locations for some plots to use the actual values instead of an index 0-n --- metplotpy/plots/bar/bar.py | 7 +------ metplotpy/plots/base_plot.py | 31 +++++++++++++++++++++++++++++++ metplotpy/plots/box/box.py | 7 +------ metplotpy/plots/histogram/hist.py | 9 +++------ 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index a90da4c8..9d5ffa86 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -228,12 +228,7 @@ def _draw_series(self, ax: plt.Axes, series: BarSeries, idx: int) -> None: else: x_points = sorted(series.series_data[self.config_obj.indy_var].unique()) - base = np.arange(len(x_points)) - n_visible_series = sum(1 for s in self.series_list if s.plot_disp) - n = max(n_visible_series, 1) - width = constants.MPL_DEFAULT_BAR_WIDTH / n - offset = (idx - (n - 1) / 2.0) * width - x_locs = base + offset + x_locs, width = self._get_x_locs_and_width(x_points, idx) # add the plot ax.bar(x=x_locs, height=y_points, width=width, align='center', color=self.config_obj.colors_list[series.idx], diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 4a07ede4..f7c2f395 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -538,3 +538,34 @@ def _add_lines(self, ax: plt.Axes, config_obj: Config, x_points_index: Union[lis 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): + 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 * constants.MPL_DEFAULT_BAR_WIDTH) / n + offset = (index - (n - 1) / 2.0) * width + x_locs = base + offset + return x_locs, width diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index f3bab0df..15c652f8 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -258,12 +258,7 @@ def _draw_series(self, ax: plt.Axes, ax2, series: BoxSeries, idx: int): return boxplot['boxes'][0] def _get_data_to_plot_and_x_locs(self, series, idx): - base = np.arange(len(self.config_obj.indy_vals)) - n_visible_series = sum(1 for s in self.series_list if s.plot_disp) - n = max(n_visible_series, 1) - width = MPL_DEFAULT_BOX_WIDTH / n - offset = (idx - (n - 1) / 2.0) * width - x_locs = base + offset + 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']] diff --git a/metplotpy/plots/histogram/hist.py b/metplotpy/plots/histogram/hist.py index 8e4e9e88..c38d6ae5 100644 --- a/metplotpy/plots/histogram/hist.py +++ b/metplotpy/plots/histogram/hist.py @@ -256,6 +256,8 @@ def _create_figure(self): # 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(): @@ -279,12 +281,7 @@ def _draw_series(self, ax: plt.Axes, series: HistSeries, idx: int) -> None: x_points = self._get_x_points(series) y_points = series.series_points - base = np.arange(len(x_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 / n - offset = (idx - (n - 1) / 2.0) * width - x_locs = base + offset + x_locs, width = self._get_x_locs_and_width(x_points, idx) ax.bar( x=x_locs, height=y_points, width=width, align='center', From 0ff95a7e57d09f6b40faa55946c17d8b095e67fe Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:44:48 -0600 Subject: [PATCH 90/92] decouple functions that don't need to be perform multiple tasks --- metplotpy/plots/box/box.py | 8 +++++++- metplotpy/plots/revision_box/revision_box.py | 9 ++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 15c652f8..0968938e 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -223,7 +223,8 @@ def _draw_series(self, ax: plt.Axes, ax2, series: BoxSeries, idx: int): 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, x_locs, width = self._get_data_to_plot_and_x_locs(series, idx) + 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: @@ -257,6 +258,11 @@ def _draw_series(self, ax: plt.Axes, ax2, series: BoxSeries, idx: int): 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) diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index 61cbfc96..9a2d3a61 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -169,10 +169,13 @@ def _add_caption(self, plt, font_properties): def _add_custom_lines(self, ax): return - def _get_data_to_plot_and_x_locs(self, series, idx): + 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[idx]] - return series.series_points['points']['stat_value'].dropna().values, x_locs, MPL_DEFAULT_BOX_WIDTH + x_locs = [base[index]] + return x_locs, MPL_DEFAULT_BOX_WIDTH def write_output_file(self) -> None: """ From e191cc1fcd1f40768c95af28a9ed2dba41297414 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 13 Mar 2026 06:56:49 -0600 Subject: [PATCH 91/92] Clean up commented code in ens_ss.py Removed commented-out code for series plotting. --- metplotpy/plots/ens_ss/ens_ss.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/metplotpy/plots/ens_ss/ens_ss.py b/metplotpy/plots/ens_ss/ens_ss.py index ccd9adb6..8ae2e960 100644 --- a/metplotpy/plots/ens_ss/ens_ss.py +++ b/metplotpy/plots/ens_ss/ens_ss.py @@ -256,16 +256,6 @@ def _add_series(self, ax, ax2): handle = self._draw_series(plot_ax, series, idx, is_points_plot) handles_and_labels.append((handle, handle.get_label())) - # while i < len(self.series_list): - # - # # 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: - # handle, handle2 = self._draw_series(ax, ax2, self.series_list[i]) - # handles_and_labels.append((handle, handle.get_label())) - # handles_and_labels.append((handle2, handle2.get_label())) - # i = i + counter - return handles_and_labels def _draw_series(self, ax, series: EnsSsSeries, index: int, is_points_plot: bool) -> None: From 1b77680ed948548e32b3a941fc43d33d7df0fe3a Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:27:42 -0600 Subject: [PATCH 92/92] Per PR review feedback from @bikegeek, remove commented code and add functionality covered in those code blocks -- set x- and y-axis limits to 0-1 for ROC diagram plots and use draw_box config variable to determine if box around legend should be displayed or not --- metplotpy/plots/base_plot.py | 2 +- metplotpy/plots/roc_diagram/roc_diagram.py | 30 ++-------------------- 2 files changed, 3 insertions(+), 29 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index f7c2f395..2253e321 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -390,7 +390,7 @@ def _add_legend(self, ax: plt.Axes, handles_and_labels=None) -> None: bbox_to_anchor=(self.config_obj.bbox_x, self.config_obj.bbox_y), loc='upper center', edgecolor=self.config_obj.legend_border_color, - frameon=True, + frameon=self.config_obj.draw_box, ncol=max(1, len(handles)) if orientation == "horizontal" else 1, fontsize=self.config_obj.legend_size, labelcolor="black" diff --git a/metplotpy/plots/roc_diagram/roc_diagram.py b/metplotpy/plots/roc_diagram/roc_diagram.py index b090244f..f7fd2ce8 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram.py +++ b/metplotpy/plots/roc_diagram/roc_diagram.py @@ -296,7 +296,9 @@ def _create_figure(self): self._add_series(ax) 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) self._add_legend(ax) @@ -307,34 +309,6 @@ def _create_figure(self): plt.tight_layout() self.logger.info(f"Finished creating figure: {datetime.now()}") - # 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.])) - - - # 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 - # )) - # - # 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 - # )) - - def _add_series(self, ax): # plot the no-skill line ax.plot([0., 1.], [0., 1.], color='grey', zorder=0, linewidth=1.2, linestyle='--')