diff --git a/hydrofunctions/charts.py b/hydrofunctions/charts.py
index 8e41052..aba1b8e 100644
--- a/hydrofunctions/charts.py
+++ b/hydrofunctions/charts.py
@@ -20,7 +20,7 @@ from matplotlib.ticker import NullFormatter
 import numpy as np
 
 
-def flow_duration(Qdf, xscale='logit', yscale='log', ylabel='Stream Discharge (m³/s)', symbol='.', legend=True):
+def flow_duration(Qdf, xscale='logit', yscale='log', ylabel='Stream Discharge (m³/s)', symbol='.', legend=True, legend_loc='best', title=''):
     """Creates a flow duration chart from a dataframe of discharges.
 
     Args:
@@ -56,6 +56,17 @@ def flow_duration(Qdf, xscale='logit', yscale='log', ylabel='Stream Discharge (m
 
         legend (bool, default: True): whether the legend should be plotted.
 
+        legend_loc (str, default: 'best'): the location of the legend.
+
+            * 'best': Automatically choose the option below with the least overlap.
+            * 'upper left', 'upper right', 'lower left', 'lower right': place the legend at the corresponding corner of the axes/figure.
+            * 'upper center', 'lower center', 'center left', 'center right': place the legend at the center of the corresponding edge of the axes/figure.
+            * 'center': place the legend at the center of the axes/figure.
+            * The location can also be a 2-tuple giving the coordinates of the lower-left corner of the legend in axes coordinates.
+
+        title (str, default: ''): text to use as a figure title. If no text
+            is provided, no title will be created (default).
+
     Returns:
         fig (matplotlib.figure.Figure):
             a matplotlib figure. This will plot immediately in a Jupyter
@@ -76,14 +87,16 @@ def flow_duration(Qdf, xscale='logit', yscale='log', ylabel='Stream Discharge (m
     ax.set_yscale(yscale)
     ax.set_ylabel(ylabel)
     if legend:
-        ax.legend()
+        ax.legend(loc=legend_loc)
+    if title:
+        ax.title.set_text(title)
     # A pyplot bug causes a valueError value if the xlabel is set.
     #ax.set_xlabel('Probability of Exceedence')
     ax.xaxis.set_minor_formatter(NullFormatter())
     return fig, ax
 
 
-def cycleplot(Qseries, cycle='diurnal', compare=None, y_label='Discharge (ft³/s)'):
+def cycleplot(Qseries, cycle='diurnal', compare=None, y_label='Discharge (ft³/s)', legend=True, legend_loc='best', title=''):
     """Creates a chart to illustrate annual and diurnal cycles.
 
     This chart will use the pandas groupby method to plot the mean and median
@@ -138,6 +151,19 @@ def cycleplot(Qseries, cycle='diurnal', compare=None, y_label='Discharge (ft³/s
 
         y_label (str): The label for the y axis.
 
+        legend (bool, default: True): whether the legend should be plotted.
+
+        legend_loc (str, default: 'best'): the location of the legend.
+
+            * 'best': Automatically choose the option below with the least overlap.
+            * 'upper left', 'upper right', 'lower left', 'lower right': place the legend at the corresponding corner of the axes/figure.
+            * 'upper center', 'lower center', 'center left', 'center right': place the legend at the center of the corresponding edge of the axes/figure.
+            * 'center': place the legend at the center of the axes/figure.
+            * The location can also be a 2-tuple giving the coordinates of the lower-left corner of the legend in axes coordinates.
+
+        title (str, default: ''): text to use as a figure title. If no text
+            is provided, no title will be created (default).
+
     Returns:
         fig (matplotlib.figure.Figure):
             a matplotlib figure. This will plot immediately in a Jupyter
@@ -187,9 +213,7 @@ def cycleplot(Qseries, cycle='diurnal', compare=None, y_label='Discharge (ft³/s
         cycleby = Qseries.index.hour
         x_label = ' (hour of the day)'
     else:
-        print("The cycle label '", cycle, "' is not recognized as an option. Using cycle='diurnal' instead.")
-        cycleby = Qseries.index.hour
-        x_label = ' (hour of the day)'
+        raise ValueError("The cycle label '", cycle, "' is not recognized as an option.")
 
     if compare is None:
         # Don't make a comparison plot.
@@ -269,7 +293,8 @@ def cycleplot(Qseries, cycle='diurnal', compare=None, y_label='Discharge (ft³/s
         # axs[i].xaxis.set_major_formatter(matplotlib.dates.DateFormatter('%H'))
 
     # Set the legend on either the ax or fig.
-    axs[0].legend(loc='best', fancybox=True, framealpha=0.5)
+    if legend:
+        axs[0].legend(loc=legend_loc, fancybox=True, framealpha=0.5)
     # fig.legend(loc='upper center', shadow=True, frameon=True, fancybox=True, framealpha=0.5)
 
     # Get the yaxis limits, set bottom to zero.
@@ -278,5 +303,7 @@ def cycleplot(Qseries, cycle='diurnal', compare=None, y_label='Discharge (ft³/s
     axs[0].set_ylabel(y_label)
     axs[0].set_xlabel('Time' + x_label)
     plt.tight_layout()
+    if title:
+        fig.suptitle(title)
 
     return fig, axs

Add ability to modify the legend and titles
* HydroFunctions version: 0.1.8dev
* Python version: 3.7

### Description

Hydrofunction's built-in charts should have the ability to set a title or create a legend.


### Potential Solutions:

1) add new parameters `legend` and `title`

    - `legend` default could be `False`; otherwise you could provide a value for legend.
    - This approach might require a `legend_loc` parameter too.
    - `title` could be set to `False` or text.

2) Use **kwargs and pass these on
3) What does Pandas do??
diff --git a/tests/test_charts.py b/tests/test_charts.py
index 9219578..a74174d 100644
--- a/tests/test_charts.py
+++ b/tests/test_charts.py
@@ -34,18 +34,28 @@ class TestFlowDuration(unittest.TestCase):
         actual_yscale = actual_ax.yaxis.get_scale()
         actual_ylabel = actual_ax.yaxis.get_label_text()
         actual_marker = actual_ax.get_lines()[0].get_marker()
+        actual_legend = actual_ax.get_legend()
+        actual_legend_loc = actual_legend._loc
+        actual_title = actual_ax.get_title()
 
         self.assertEqual(actual_xscale, 'logit')
         self.assertEqual(actual_yscale, 'log')
         self.assertEqual(actual_ylabel, 'Stream Discharge (m³/s)')
         self.assertEqual(actual_marker, '.')
+        self.assertTrue(actual_legend)
+        self.assertEqual(actual_legend_loc, 0) # '0' is internal code for 'best'.
+        self.assertEqual(actual_title, '')
 
     def test_charts_flowduration_accepts_params(self):
         expected = pd.DataFrame(data=dummy)
         params = {'xscale': 'linear',
                   'yscale': 'linear',
                   'ylabel': 'test value',
-                  'symbol': ','}
+                  'symbol': ',',
+                  'legend': False,
+                  'legend_loc': 'center',
+                  'title': 'Test Title',
+                  }
 
         actual_fig, actual_ax = charts.flow_duration(expected, **params)
 
@@ -53,12 +63,19 @@ class TestFlowDuration(unittest.TestCase):
         actual_yscale = actual_ax.yaxis.get_scale()
         actual_ylabel = actual_ax.yaxis.get_label_text()
         actual_marker = actual_ax.get_lines()[0].get_marker()
+        actual_legend = actual_ax.get_legend()
+        # There is no legend in this test, so there is no legend property.
+        #actual_legend_loc = actual_legend._loc
+        actual_title = actual_ax.get_title()
 
         self.assertEqual(actual_xscale, 'linear')
         self.assertEqual(actual_yscale, 'linear')
         self.assertEqual(actual_ylabel, 'test value')
         self.assertEqual(actual_marker, ',')
-
+        self.assertIsNone(actual_legend)
+        # There is no legend, so there is no legend location property.
+        #self.assertEqual(actual_legend_loc, 10) # 'center' is equal to 10.
+        self.assertEqual(actual_title, 'Test Title')
 
 class TestCyclePlot(unittest.TestCase):
 
@@ -80,15 +97,21 @@ class TestCyclePlot(unittest.TestCase):
     def test_charts_cycleplot_parts(self):
         expected_df, expected_dict = hf.extract_nwis_df(test_json, interpolate=False)
 
-        actual_fig, actual_ax = charts.cycleplot(expected_df)
+        actual_fig, actual_ax = charts.cycleplot(expected_df, legend_loc='center', title='test title')
 
         actual_xscale = actual_ax[0].xaxis.get_scale()
         actual_yscale = actual_ax[0].yaxis.get_scale()
         actual_ylabel = actual_ax[0].yaxis.get_label_text()
+        actual_legend = actual_ax[0].get_legend()
+        actual_legend_loc = actual_legend._loc
+        actual_title = actual_fig._suptitle.get_text() # unofficial title accessor! A little wonky.
 
         self.assertEqual(actual_xscale, 'linear')
         self.assertEqual(actual_yscale, 'linear')
         self.assertEqual(actual_ylabel, 'Discharge (ft³/s)')
+        self.assertTrue(actual_legend)
+        self.assertEqual(actual_legend_loc, 10) # '10' is internal code for legend(loc = 'center')
+        self.assertEqual(actual_title, 'test title')
 
     def test_charts_cycleplot_compare_month(self):
         expected_df, expected_dict = hf.extract_nwis_df(test_json, interpolate=False)
@@ -102,5 +125,46 @@ class TestCyclePlot(unittest.TestCase):
         self.assertIsInstance(actual_fig, matplotlib.figure.Figure)
         self.assertIsInstance(actual_ax[0], matplotlib.axes.Axes)
 
+    def test_charts_cycleplot_cycle_annual(self):
+        expected_df, expected_dict = hf.extract_nwis_df(test_json, interpolate=False)
+        actual_fig, actual_ax = charts.cycleplot(expected_df, 'annual')
+        self.assertIsInstance(actual_fig, matplotlib.figure.Figure)
+        self.assertIsInstance(actual_ax[0], matplotlib.axes.Axes)
+
+    def test_charts_cycleplot_cycle_annualdate(self):
+        expected_df, expected_dict = hf.extract_nwis_df(test_json, interpolate=False)
+        actual_fig, actual_ax = charts.cycleplot(expected_df, 'annual-date')
+        self.assertIsInstance(actual_fig, matplotlib.figure.Figure)
+        self.assertIsInstance(actual_ax[0], matplotlib.axes.Axes)
+
+    def test_charts_cycleplot_cycle_annualmonth(self):
+        expected_df, expected_dict = hf.extract_nwis_df(test_json, interpolate=False)
+        actual_fig, actual_ax = charts.cycleplot(expected_df, 'annual-month')
+        self.assertIsInstance(actual_fig, matplotlib.figure.Figure)
+        self.assertIsInstance(actual_ax[0], matplotlib.axes.Axes)
+
+    def test_charts_cycleplot_cycle_weekly(self):
+        expected_df, expected_dict = hf.extract_nwis_df(test_json, interpolate=False)
+        actual_fig, actual_ax = charts.cycleplot(expected_df, 'weekly')
+        self.assertIsInstance(actual_fig, matplotlib.figure.Figure)
+        self.assertIsInstance(actual_ax[0], matplotlib.axes.Axes)
+
+    def test_charts_cycleplot_cycle_diurnalsmallest(self):
+        expected_df, expected_dict = hf.extract_nwis_df(test_json, interpolate=False)
+        actual_fig, actual_ax = charts.cycleplot(expected_df, 'diurnal-smallest')
+        self.assertIsInstance(actual_fig, matplotlib.figure.Figure)
+        self.assertIsInstance(actual_ax[0], matplotlib.axes.Axes)
+
+    def test_charts_cycleplot_cycle_diurnalhour(self):
+        expected_df, expected_dict = hf.extract_nwis_df(test_json, interpolate=False)
+        actual_fig, actual_ax = charts.cycleplot(expected_df, 'diurnal-hour')
+        self.assertIsInstance(actual_fig, matplotlib.figure.Figure)
+        self.assertIsInstance(actual_ax[0], matplotlib.axes.Axes)
+
+    def test_charts_cycleplot_cycle_nonsense_raises_ValueError(self):
+        expected_df, expected_dict = hf.extract_nwis_df(test_json, interpolate=False)
+        with self.assertRaises(ValueError):
+            actual_fig, actual_ax = charts.cycleplot(expected_df, 'nonsense')
+
 if __name__ == '__main__':
     unittest.main(verbosity=2)

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest pytest-cov pytest-xdist pytest-mock pytest-asyncio
3.7
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
attrs==24.2.0
Babel==2.14.0
backcall==0.2.0
beautifulsoup4==4.13.3
bleach==6.0.0
certifi @ file:///croot/certifi_1671487769961/work/certifi
charset-normalizer==3.4.1
coverage==7.2.7
cycler==0.11.0
debugpy==1.7.0
decorator==5.1.1
defusedxml==0.7.1
docutils==0.19
entrypoints==0.4
exceptiongroup==1.2.2
execnet==2.0.2
fastjsonschema==2.21.1
fonttools==4.38.0
-e git+https://github.com/mroberge/hydrofunctions.git@bf1f296f4a2e31a0df9bd07860bbfa90e71e7890#egg=hydrofunctions
idna==3.10
imagesize==1.4.1
importlib-metadata==6.7.0
importlib-resources==5.12.0
iniconfig==2.0.0
ipykernel==6.16.2
ipython==7.34.0
jedi==0.19.2
Jinja2==3.1.6
jsonschema==4.17.3
jupyter_client==7.4.9
jupyter_core==4.12.0
jupyterlab-pygments==0.2.2
kiwisolver==1.4.5
MarkupSafe==2.1.5
matplotlib==3.5.3
matplotlib-inline==0.1.6
mistune==3.0.2
nbclient==0.7.4
nbconvert==7.6.0
nbformat==5.8.0
nbsphinx==0.9.7
nest-asyncio==1.6.0
numpy==1.21.6
packaging==24.0
pandas==1.3.5
pandocfilters==1.5.1
parso==0.8.4
pexpect==4.9.0
pickleshare==0.7.5
Pillow==9.5.0
pkgutil_resolve_name==1.3.10
pluggy==1.2.0
prompt_toolkit==3.0.48
psutil==7.0.0
ptyprocess==0.7.0
pyarrow==12.0.1
Pygments==2.17.2
pyparsing==3.1.4
pyrsistent==0.19.3
pytest==7.4.4
pytest-asyncio==0.21.2
pytest-cov==4.1.0
pytest-mock==3.11.1
pytest-xdist==3.5.0
python-dateutil==2.9.0.post0
pytz==2025.2
pyzmq==26.2.1
requests==2.31.0
six==1.17.0
snowballstemmer==2.2.0
soupsieve==2.4.1
Sphinx==5.3.0
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
tinycss2==1.2.1
tomli==2.0.1
tornado==6.2
traitlets==5.9.0
typing_extensions==4.7.1
urllib3==2.0.7
wcwidth==0.2.13
webencodings==0.5.1
zipp==3.15.0

name: hydrofunctions
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=22.3.1=py37h06a4308_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.38.4=py37h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.13
      - attrs==24.2.0
      - babel==2.14.0
      - backcall==0.2.0
      - beautifulsoup4==4.13.3
      - bleach==6.0.0
      - charset-normalizer==3.4.1
      - coverage==7.2.7
      - cycler==0.11.0
      - debugpy==1.7.0
      - decorator==5.1.1
      - defusedxml==0.7.1
      - docutils==0.19
      - entrypoints==0.4
      - exceptiongroup==1.2.2
      - execnet==2.0.2
      - fastjsonschema==2.21.1
      - fonttools==4.38.0
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==6.7.0
      - importlib-resources==5.12.0
      - iniconfig==2.0.0
      - ipykernel==6.16.2
      - ipython==7.34.0
      - jedi==0.19.2
      - jinja2==3.1.6
      - jsonschema==4.17.3
      - jupyter-client==7.4.9
      - jupyter-core==4.12.0
      - jupyterlab-pygments==0.2.2
      - kiwisolver==1.4.5
      - markupsafe==2.1.5
      - matplotlib==3.5.3
      - matplotlib-inline==0.1.6
      - mistune==3.0.2
      - nbclient==0.7.4
      - nbconvert==7.6.0
      - nbformat==5.8.0
      - nbsphinx==0.9.7
      - nest-asyncio==1.6.0
      - numpy==1.21.6
      - packaging==24.0
      - pandas==1.3.5
      - pandocfilters==1.5.1
      - parso==0.8.4
      - pexpect==4.9.0
      - pickleshare==0.7.5
      - pillow==9.5.0
      - pkgutil-resolve-name==1.3.10
      - pluggy==1.2.0
      - prompt-toolkit==3.0.48
      - psutil==7.0.0
      - ptyprocess==0.7.0
      - pyarrow==12.0.1
      - pygments==2.17.2
      - pyparsing==3.1.4
      - pyrsistent==0.19.3
      - pytest==7.4.4
      - pytest-asyncio==0.21.2
      - pytest-cov==4.1.0
      - pytest-mock==3.11.1
      - pytest-xdist==3.5.0
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - pyzmq==26.2.1
      - requests==2.31.0
      - six==1.17.0
      - snowballstemmer==2.2.0
      - soupsieve==2.4.1
      - sphinx==5.3.0
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - tinycss2==1.2.1
      - tomli==2.0.1
      - tornado==6.2
      - traitlets==5.9.0
      - typing-extensions==4.7.1
      - urllib3==2.0.7
      - wcwidth==0.2.13
      - webencodings==0.5.1
      - zipp==3.15.0
prefix: /opt/conda/envs/hydrofunctions

tests/test_charts.py::TestFlowDuration::test_charts_flowduration_accepts_params tests/test_charts.py::TestCyclePlot::test_charts_cycleplot_cycle_nonsense_raises_ValueError tests/test_charts.py::TestCyclePlot::test_charts_cycleplot_parts
tests/test_charts.py::TestCyclePlot::test_charts_cycleplot_cycle_diurnalsmallest
tests/test_charts.py::TestFlowDuration::test_charts_flowduration_defaults tests/test_charts.py::TestFlowDuration::test_charts_flowduration_exists tests/test_charts.py::TestCyclePlot::test_charts_cycleplot_compare_month tests/test_charts.py::TestCyclePlot::test_charts_cycleplot_cycle_annual tests/test_charts.py::TestCyclePlot::test_charts_cycleplot_cycle_annualdate tests/test_charts.py::TestCyclePlot::test_charts_cycleplot_cycle_annualmonth tests/test_charts.py::TestCyclePlot::test_charts_cycleplot_cycle_annualweek tests/test_charts.py::TestCyclePlot::test_charts_cycleplot_cycle_diurnalhour tests/test_charts.py::TestCyclePlot::test_charts_cycleplot_cycle_weekly tests/test_charts.py::TestCyclePlot::test_charts_cycleplot_exists tests/test_charts.py::TestCyclePlot::test_charts_groupby_not_object_dtype
hydrofunctions/charts.py
hydrofunctions/charts.py