diff --git a/README.md b/README.md index f98a8cb..d5d7eb1 100755 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ pip install -e ".[dev]" ## Quick Start +### Data Retrieval ```python from datetime import datetime from murgtools.getdata import getObs @@ -53,6 +54,22 @@ wind_data = obs.getWind() wl_data = obs.getWL() ``` +### Plotting Survey Conditions +```python +from datetime import datetime +from murgtools import conditions_plot + +# Define survey dates +dates = [datetime(2023, 6, 15), datetime(2023, 7, 20)] +start = datetime(2023, 1, 1) +end = datetime(2023, 12, 31) + +# Create conditions plot showing Hs vs Tp with climatology +fig, axes = conditions_plot(dates, start, end, ofname='conditions.png') +``` + +See [plotting/README.md](murgtools/plotting/README.md) for more plotting examples. + ## Package Structure ``` @@ -61,12 +78,16 @@ murgtools/ │ ├── getDataFRF.py # FRF observational and model data │ ├── getOutsideData.py # External data sources │ └── getPlotData.py # Plotting utilities +├── plotting/ # Plotting subpackage +│ └── conditions_plot.py # Survey/operational conditions visualization └── utils/ # Utility subpackage ├── geoprocess.py # Coordinate transformations ├── sblib.py # Base utility functions └── ... ``` +See [plotting/README.md](murgtools/plotting/README.md) for detailed documentation on plotting capabilities. + ## Main Classes ### `getObs` diff --git a/examples/example_asv_conditions.py b/examples/example_asv_conditions.py new file mode 100644 index 0000000..61d639a --- /dev/null +++ b/examples/example_asv_conditions.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python +"""Example script demonstrating conditions_plot with ASV survey data. + +This example replicates the analysis from https://github.com/kkoetje/asv_conditions_plot +using murgtools' conditions_plot functionality. It visualizes wave conditions during +Jaiabot and Yellowfin autonomous surface vehicle (ASV) surveys at the FRF. + +Survey dates sourced from: +- all_jaiabot_dates.txt +- all_yellowfin_dates.txt +""" +import datetime as dt +from murgtools.plotting import conditions_plot + + +# Jaiabot survey dates (from kkoetje/asv_conditions_plot) +JAIABOT_DATES = [ + '20230315', '20230404', '20230406', '20230418', '20230427', '20230501', + '20230503', '20230510', '20230517', '20230712', '20230713', '20230810', + '20230823', '20230824', '20230830', '20230831', '20230905', '20230906', + '20230912', '20231004', '20231005', '20231010', '20231011', '20231018', + '20231019', '20231109', '20240130', '20240131', '20240205', '20240206', + '20240208', '20240212', '20240222', '20240305', '20240306', '20240307', + '20240521', '20240522', '20240528', '20240529', '20240530', '20240627', + '20240701', '20240702', '20240703', '20240710', '20240711', '20240716', + '20240717', '20240718', '20240724', '20240730', '20240731', '20240806', + '20240807', '20240814', '20250430', '20250512', '20250602', '20250617' +] + +# Yellowfin survey dates (from kkoetje/asv_conditions_plot) +YELLOWFIN_DATES = [ + '20230216', '20230327', '20230417', '20230504', '20230507', '20230512', + '20230518', '20230623', '20230705', '20230816', '20230913', '20231017', + '20231109', '20231130', '20240103', '20240124', '20240227', '20240229', + '20240312', '20240418', '20240528', '20240626', '20240716', '20240724', + '20240813', '20240815', '20240816', '20240828', '20240930', '20241030', + '20241120', '20241209', '20241217', '20250414', '20250415', '20250617' +] + + +def example_jaiabot_only(): + """Plot wave conditions for Jaiabot surveys only.""" + print("Creating Jaiabot survey conditions plot...") + + # Use a subset of dates for faster processing (2023 data) + dates_2023 = [d for d in JAIABOT_DATES if d.startswith('2023')] + + fig, axes = conditions_plot( + dates_2023, + start_date=dt.datetime(2023, 3, 1), + end_date=dt.datetime(2023, 12, 31), + gauge='waverider-17m', + x_var='Hs', + y_var='Tp', + bin_size=0.25, + sampling_hours=1, + start_hour=13, + title='Jaiabot Survey Conditions (2023)', + ofname='jaiabot_wave_conditions_2023.png' + ) + print("Saved: jaiabot_wave_conditions_2023.png") + return fig, axes + + +def example_yellowfin_only(): + """Plot wave conditions for Yellowfin surveys only.""" + print("Creating Yellowfin survey conditions plot...") + + # Use a subset of dates for faster processing (2023 data) + dates_2023 = [d for d in YELLOWFIN_DATES if d.startswith('2023')] + + fig, axes = conditions_plot( + dates_2023, + start_date=dt.datetime(2023, 2, 1), + end_date=dt.datetime(2023, 12, 31), + gauge='waverider-17m', + x_var='Hs', + y_var='Tp', + bin_size=0.25, + sampling_hours=1, + start_hour=13, + title='Yellowfin Survey Conditions (2023)', + ofname='yellowfin_wave_conditions_2023.png' + ) + print("Saved: yellowfin_wave_conditions_2023.png") + return fig, axes + + +def example_combined_platforms(): + """Compare Jaiabot and Yellowfin survey conditions side by side. + + This replicates the combined plot from kkoetje/asv_conditions_plot + showing both platforms with different colors and markers. + """ + print("Creating combined ASV survey conditions plot...") + + # Use 2023 data for both platforms + jb_2023 = [d for d in JAIABOT_DATES if d.startswith('2023')] + yf_2023 = [d for d in YELLOWFIN_DATES if d.startswith('2023')] + + # Define date groups for each platform + date_groups = [ + { + 'dates': jb_2023, + 'label': 'Jaiabot', + 'color': '#1f77b4', # Blue + 'marker': 'o' + }, + { + 'dates': yf_2023, + 'label': 'Yellowfin', + 'color': '#ff7f0e', # Orange + 'marker': 's' + } + ] + + # Combine all dates for time_list parameter + all_dates = jb_2023 + yf_2023 + + fig, axes = conditions_plot( + all_dates, + start_date=dt.datetime(2023, 2, 1), + end_date=dt.datetime(2023, 12, 31), + gauge='waverider-17m', + x_var='Hs', + y_var='Tp', + date_groups=date_groups, + bin_size=0.25, + sampling_hours=1, + start_hour=13, + x_limits=[0, 3], + y_limits=[4, 16], + title='ASV Survey Conditions - Jaiabot & Yellowfin (2023)', + ofname='combined_asv_wave_conditions_2023.png' + ) + print("Saved: combined_asv_wave_conditions_2023.png") + return fig, axes + + +def example_color_by_direction(): + """Color survey points by wave direction.""" + print("Creating conditions plot colored by wave direction...") + + jb_2023 = [d for d in JAIABOT_DATES if d.startswith('2023')] + + fig, axes = conditions_plot( + jb_2023, + start_date=dt.datetime(2023, 3, 1), + end_date=dt.datetime(2023, 12, 31), + gauge='waverider-17m', + x_var='Hs', + y_var='Tp', + color_var='waveDirectionPeak', # Color by wave direction + bin_size=0.25, + sampling_hours=1, + start_hour=13, + title='Jaiabot Survey Conditions - Colored by Wave Direction', + ofname='jaiabot_wave_conditions_by_direction.png' + ) + print("Saved: jaiabot_wave_conditions_by_direction.png") + return fig, axes + + +def example_full_record(): + """Plot full survey record (2023-2024) for both platforms. + + Note: This requires more data and may take longer to run. + """ + print("Creating full record ASV survey conditions plot...") + + # Use all available dates through 2024 + jb_dates = [d for d in JAIABOT_DATES if d.startswith(('2023', '2024'))] + yf_dates = [d for d in YELLOWFIN_DATES if d.startswith(('2023', '2024'))] + + date_groups = [ + { + 'dates': jb_dates, + 'label': 'Jaiabot', + 'color': '#1f77b4', + 'marker': 'o' + }, + { + 'dates': yf_dates, + 'label': 'Yellowfin', + 'color': '#ff7f0e', + 'marker': 's' + } + ] + + all_dates = jb_dates + yf_dates + + fig, axes = conditions_plot( + all_dates, + start_date=dt.datetime(2023, 1, 1), + end_date=dt.datetime(2024, 12, 31), + gauge='waverider-17m', + x_var='Hs', + y_var='Tp', + date_groups=date_groups, + bin_size=0.25, + sampling_hours=1, + start_hour=13, + x_limits=[0, 4], + y_limits=[4, 18], + title='ASV Survey Conditions - Full Record (2023-2024)', + ofname='all_asv_wave_conditions.png' + ) + print("Saved: all_asv_wave_conditions.png") + return fig, axes + + +if __name__ == '__main__': + print("=" * 65) + print("ASV SURVEY CONDITIONS PLOT EXAMPLES") + print("Based on data from: https://github.com/kkoetje/asv_conditions_plot") + print("=" * 65) + print("\nNOTE: These examples require access to FRF THREDDS servers.") + print("\nAvailable examples:") + print(" - example_jaiabot_only() : Jaiabot surveys (2023)") + print(" - example_yellowfin_only() : Yellowfin surveys (2023)") + print(" - example_combined_platforms(): Both platforms compared") + print(" - example_color_by_direction(): Points colored by wave direction") + print(" - example_full_record() : Full 2023-2024 record") + print("\nTo run an example:") + print(" >>> from example_asv_conditions import example_combined_platforms") + print(" >>> fig, axes = example_combined_platforms()") + print("=" * 65) diff --git a/examples/example_conditions_plot.py b/examples/example_conditions_plot.py new file mode 100644 index 0000000..436fdc9 --- /dev/null +++ b/examples/example_conditions_plot.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python +"""Example script demonstrating the conditions_plot functionality. + +This example shows how to create a conditions plot for visualizing +environmental conditions during surveys or operations. + +NOTE: These examples require access to FRF THREDDS servers and valid date +ranges with data. To run, update the dates to match available data periods. +""" +import datetime as dt + + +def example_basic(): + """Example 1: Basic usage with default wave height and period.""" + from murgtools.plotting import conditions_plot + + print("Example 1: Basic conditions plot") + dates = [ + dt.datetime(2023, 6, 15), + dt.datetime(2023, 7, 20), + dt.datetime(2023, 8, 10) + ] + start = dt.datetime(2023, 6, 1) + end = dt.datetime(2023, 8, 31) + + fig, axes = conditions_plot(dates, start, end, ofname='example_basic.png') + print("Plot saved to example_basic.png") + return fig, axes + + +def example_multiple_groups(): + """Example 2: Multiple survey groups with different colors.""" + from murgtools.plotting import conditions_plot + + print("\nExample 2: Multiple survey groups") + date_groups = [ + { + 'dates': [dt.datetime(2023, 6, 15), dt.datetime(2023, 6, 20)], + 'label': 'Yellowfin Survey', + 'color': '#ff7f0e', + 'marker': 'D' + }, + { + 'dates': [dt.datetime(2023, 7, 10), dt.datetime(2023, 7, 15)], + 'label': 'Autonomous Vessel', + 'color': '#1f77b4', + 'marker': 'o' + } + ] + all_dates = [dt.datetime(2023, 6, 15), dt.datetime(2023, 6, 20), + dt.datetime(2023, 7, 10), dt.datetime(2023, 7, 15)] + start = dt.datetime(2023, 6, 1) + end = dt.datetime(2023, 8, 31) + + fig, axes = conditions_plot( + all_dates, start, end, + date_groups=date_groups, + sampling_hours=2, # Sample 2 hours per survey + start_hour=13, # Start at 1 PM UTC + ofname='example_groups.png', + title='Survey Conditions - Multiple Platforms' + ) + print("Plot saved to example_groups.png") + return fig, axes + + +def example_custom_variables(): + """Example 3: Custom variables (different from default Hs and Tp).""" + from murgtools.plotting import conditions_plot + + print("\nExample 3: Custom variables") + dates = [dt.datetime(2023, 6, 15), dt.datetime(2023, 7, 20)] + start = dt.datetime(2023, 6, 1) + end = dt.datetime(2023, 8, 31) + + # Plot peak period vs wave height (reversed from default) + fig, axes = conditions_plot( + dates, start, end, + x_var='Tp', # X-axis: peak period + y_var='Hs', # Y-axis: wave height + x_limits=[5, 20], + y_limits=[0, 3], + ofname='example_custom_vars.png' + ) + print("Plot saved to example_custom_vars.png") + return fig, axes + + +def example_date_strings(): + """Example 4: Date strings instead of datetime objects.""" + from murgtools.plotting import conditions_plot + + print("\nExample 4: Using date strings") + date_strings = ['20230615', '20230720', '20230810'] + + fig, axes = conditions_plot( + date_strings, + '20230601', + '20230831', + ofname='example_date_strings.png' + ) + print("Plot saved to example_date_strings.png") + return fig, axes + + +def example_different_gauge(): + """Example 5: Using different gauge.""" + from murgtools.plotting import conditions_plot + + print("\nExample 5: Different gauge") + dates = [dt.datetime(2023, 6, 15), dt.datetime(2023, 7, 20)] + start = dt.datetime(2023, 6, 1) + end = dt.datetime(2023, 8, 31) + + fig, axes = conditions_plot( + dates, start, end, + gauge='waverider-26m', # Use 26m waverider instead of default 17m + ofname='example_different_gauge.png' + ) + print("Plot saved to example_different_gauge.png") + return fig, axes + + +def example_color_by_scalar(): + """Example 6: Color by scalar value (if available in data).""" + from murgtools.plotting import conditions_plot + + print("\nExample 6: Color by scalar value") + dates = [dt.datetime(2023, 6, 15), dt.datetime(2023, 7, 20)] + start = dt.datetime(2023, 6, 1) + end = dt.datetime(2023, 8, 31) + + fig, axes = conditions_plot( + dates, start, end, + color_var='waveDirectionPeak', # Color points by wave direction + ofname='example_color_scalar.png', + title='Survey Conditions - Colored by Wave Direction' + ) + print("Plot saved to example_color_scalar.png") + return fig, axes + + +if __name__ == '__main__': + print("=" * 60) + print("CONDITIONS PLOT EXAMPLES") + print("=" * 60) + print("\nNOTE: These examples require access to FRF THREDDS servers") + print("and valid date ranges with available data.") + print("\nTo run a specific example, call the function directly, e.g.:") + print(" python -c 'from example_conditions_plot import example_basic; example_basic()'") + print("\nAvailable examples:") + print(" - example_basic()") + print(" - example_multiple_groups()") + print(" - example_custom_variables()") + print(" - example_date_strings()") + print(" - example_different_gauge()") + print(" - example_color_by_scalar()") + print("=" * 60) + diff --git a/murgtools/__init__.py b/murgtools/__init__.py index d278cc9..53b56d0 100644 --- a/murgtools/__init__.py +++ b/murgtools/__init__.py @@ -3,6 +3,7 @@ from .getdata import getObs, getDataTestBed, gettime, getnc, removeDuplicatesFromDictionary from .getdata import forecastData, getSatelliteImagery, alt_PlotData from .getdata import getArgusImagery, threadGetArgusImagery +from .plotting import conditions_plot, bin_data __all__ = [ "getObs", @@ -15,4 +16,6 @@ "getArgusImagery", "threadGetArgusImagery", "alt_PlotData", + "conditions_plot", + "bin_data", ] diff --git a/murgtools/plotting/README.md b/murgtools/plotting/README.md new file mode 100644 index 0000000..c7c4fe5 --- /dev/null +++ b/murgtools/plotting/README.md @@ -0,0 +1,139 @@ +# Plotting Module + +This module provides plotting utilities for visualizing coastal environmental conditions. + +## Conditions Plot + +The `conditions_plot` function creates a comprehensive visualization of environmental conditions during surveys or operations. It's inspired by operational analysis needs for autonomous surface vehicles and field campaigns. + +### Features + +- **Flexible variable selection**: Plot any combination of variables (default: wave height vs. peak period) +- **Multiple survey groups**: Support for multiple groups with different colors and markers +- **Climatological context**: Background distributions show typical conditions +- **Color coding**: Optional color coding by scalar values +- **Date flexibility**: Accept both datetime objects and date strings +- **Customizable styling**: Control axis limits, bin sizes, markers, and more + +### Basic Usage + +```python +import datetime as dt +from murgtools.plotting import conditions_plot + +# Define survey dates +dates = [ + dt.datetime(2023, 6, 15), + dt.datetime(2023, 7, 20) +] + +# Define time range for background climatology +start = dt.datetime(2023, 1, 1) +end = dt.datetime(2023, 12, 31) + +# Create the plot +fig, axes = conditions_plot(dates, start, end, ofname='survey_conditions.png') +``` + +### Advanced Usage + +#### Multiple Survey Groups + +```python +date_groups = [ + { + 'dates': [dt.datetime(2023, 6, 15), dt.datetime(2023, 6, 20)], + 'label': 'Survey A', + 'color': '#ff7f0e', + 'marker': 'D' + }, + { + 'dates': [dt.datetime(2023, 7, 10)], + 'label': 'Survey B', + 'color': '#1f77b4', + 'marker': 'o' + } +] + +all_dates = [dt.datetime(2023, 6, 15), dt.datetime(2023, 6, 20), + dt.datetime(2023, 7, 10)] + +fig, axes = conditions_plot( + all_dates, start, end, + date_groups=date_groups, + sampling_hours=2, + start_hour=13, + ofname='multi_group_conditions.png' +) +``` + +#### Custom Variables + +```python +# Plot peak period vs wave height +fig, axes = conditions_plot( + dates, start, end, + x_var='Tp', + y_var='Hs', + x_limits=[5, 20], + y_limits=[0, 3], + ofname='custom_vars.png' +) +``` + +#### Color by Scalar Value + +```python +# Color points by wave direction +fig, axes = conditions_plot( + dates, start, end, + color_var='waveDirectionPeak', + ofname='colored_by_direction.png' +) +``` + +### Function Reference + +#### `conditions_plot(time_list, start_date, end_date, **kwargs)` + +Create a two-panel conditions plot. + +**Parameters:** +- `time_list` (list): List of datetime objects or date strings (YYYYMMDD format) +- `start_date` (datetime): Start date for background climatology +- `end_date` (datetime): End date for background climatology +- `gauge` (str): Gauge name (default: 'waverider-17m') +- `x_var` (str): X-axis variable (default: 'Hs') +- `y_var` (str): Y-axis variable (default: 'Tp') +- `color_var` (str, optional): Variable for color coding +- `date_groups` (list of dict, optional): Groups of dates with labels and styling +- `sampling_hours` (int): Hours to sample per date (default: 1) +- `start_hour` (int): Starting hour UTC (default: 13) +- `bin_size` (float): Bin size for climatology (default: 0.25) +- `x_limits` (list, optional): [min, max] for x-axis +- `y_limits` (list, optional): [min, max] for y-axis +- `ofname` (str, optional): Output filename +- `title` (str): Plot title (default: 'Survey Conditions') +- `server` (str, optional): THREDDS server ('FRF' or 'CHL') + +**Returns:** +- `tuple`: (fig, axes) - matplotlib Figure and list of Axes objects + +#### `bin_data(data_to_be_binned, bin_size=0.1)` + +Bin data into specified bin sizes. + +**Parameters:** +- `data_to_be_binned` (array-like): Data to bin +- `bin_size` (float): Size of bins (default: 0.1) + +**Returns:** +- `tuple`: (bin_indices, bins) - bin assignments and bin edges + +## Examples + +See `examples/example_conditions_plot.py` for complete usage examples. + +## Reference + +This implementation is based on operational plotting needs for autonomous vehicle surveys at the USACE Field Research Facility, as documented in [kkoetje/asv_conditions_plot](https://github.com/kkoetje/asv_conditions_plot). diff --git a/murgtools/plotting/__init__.py b/murgtools/plotting/__init__.py new file mode 100644 index 0000000..8738ed8 --- /dev/null +++ b/murgtools/plotting/__init__.py @@ -0,0 +1,11 @@ +"""Plotting utilities for murgtools. + +This module provides plotting functionality for coastal data visualization. +""" + +from .conditions_plot import conditions_plot, bin_data + +__all__ = [ + "conditions_plot", + "bin_data", +] diff --git a/murgtools/plotting/conditions_plot.py b/murgtools/plotting/conditions_plot.py new file mode 100644 index 0000000..5e287dc --- /dev/null +++ b/murgtools/plotting/conditions_plot.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8 -*- +"""Conditions plot functionality for visualizing survey or operational conditions. + +This module provides flexible plotting tools for visualizing coastal conditions +during surveys or operations, including wave height, period, and other environmental +variables with optional color coding by scalar values. + +@author: SBFRF +@organization: USACE CHL FRF +""" +import datetime as dt +import numpy as np +from matplotlib import pyplot as plt +from murgtools.getdata import getObs + +# Color constants for consistent styling +BACKGROUND_COLOR = '#4d4d4d' # Gray for time series background +DEFAULT_GROUP_COLOR = '#1f77b4' # Blue for default data group + + +def _validate_date_groups(date_groups): + """Validate that date_groups contains required keys. + + Args: + date_groups (list): List of dictionaries defining groups of dates. + + Raises: + ValueError: If any required keys are missing from a group. + """ + required_keys = ['dates', 'label', 'color'] + for i, group in enumerate(date_groups): + missing_keys = [key for key in required_keys if key not in group] + if missing_keys: + raise ValueError( + f"date_groups[{i}] is missing required keys: {missing_keys}. " + f"Each group must have 'dates' (list), 'label' (str), and 'color' (str)." + ) + if not isinstance(group['dates'], list): + raise ValueError( + f"date_groups[{i}]['dates'] must be a list, got {type(group['dates']).__name__}" + ) + + +def bin_data(data_to_be_binned, bin_size=0.1): + """Bin data into specified bin sizes. + + Args: + data_to_be_binned (array-like): Data to be binned (e.g., wave heights). + bin_size (float): Size of bins. Default is 0.1. + + Returns: + tuple: (bin_indices, bins) where: + - bin_indices: Array of 0-based bin indices for each data point + - bins: Array of bin edges + + Examples: + >>> data = np.array([0.5, 1.0, 1.5, 2.0]) + >>> indices, bins = bin_data(data, bin_size=0.5) + """ + # Convert input to numpy array for consistent handling + data_array = np.asarray(data_to_be_binned) + + # Validate that there is at least one non-NaN value to bin + if data_array.size == 0 or not np.any(~np.isnan(data_array)): + raise ValueError( + "bin_data: data_to_be_binned must contain at least one non-NaN value." + ) + + # Ensure the upper bound includes all data by adding bin_size + bins = np.arange(np.nanmin(data_array), + np.ceil(np.nanmax(data_array)) + bin_size, + bin_size) + # np.digitize returns 1-based indices, convert to 0-based for Python consistency + bin_indices = np.digitize(data_array, bins=bins, right=False) - 1 + # Guard against values equal to the minimum falling below the first bin (index -1) + bin_indices[bin_indices < 0] = 0 + return bin_indices, bins + + +def conditions_plot(time_list, start_date, end_date, gauge='waverider-17m', + x_var='Hs', y_var='Tp', color_var=None, + date_groups=None, sampling_hours=1, start_hour=13, + bin_size=0.25, x_limits=None, y_limits=None, + ofname=None, title='Survey Conditions', server=None): + """Create a conditions plot showing environmental conditions during specified times. + + This function creates a two-panel plot: + 1. Top panel: Time series of the primary variable (default: wave height) + 2. Bottom panel: Scatter plot of two variables (default: Hs vs Tp) during specified times, + with background showing climatological distributions + + Args: + time_list (list): List of datetime objects or date strings (YYYYMMDD format) when + data should be extracted for plotting. + start_date (datetime): Start date for retrieving background climatology data. + end_date (datetime): End date for retrieving background climatology data. + gauge (str): Name of the gauge/station to retrieve data from. + Default is 'waverider-17m'. + x_var (str): Variable name for x-axis. Default is 'Hs' (wave height). + Common options: 'Hs', 'Tp', 'waveDirectionPeak', etc. + y_var (str): Variable name for y-axis. Default is 'Tp' (peak period). + If 'Tp' is requested but not available, will compute from 'peakf'. + color_var (str, optional): Variable name to use for color coding scatter points. + If None, points will be colored by group. Default is None. + date_groups (list of dict, optional): List of dictionaries defining groups of dates. + Each dict should have: + - 'dates': list of datetime objects or date strings + - 'label': str, label for the legend + - 'color': str, matplotlib color for this group + - 'marker': str, matplotlib marker style (default: 'o') + If None, all dates in time_list will be plotted as a single group. + sampling_hours (int): Number of hours to sample for each date in time_list. + Default is 1. + start_hour (int): Starting hour (UTC) for sampling on each date. Default is 13. + bin_size (float): Size of bins for climatological distribution. Default is 0.25. + x_limits (list, optional): [min, max] limits for x-axis. If None, auto-determined. + y_limits (list, optional): [min, max] limits for y-axis. If None, auto-determined. + ofname (str, optional): Output filename for saving the figure. If None, figure + is displayed but not saved. + title (str): Title for the overall figure. Default is 'Survey Conditions'. + server (str, optional): THREDDS server to use ('FRF' or 'CHL'). If None, auto-detected. + + Returns: + tuple: (fig, axes) where fig is the matplotlib Figure object and axes is a list + of the two Axes objects [ax1, ax2]. + + Examples: + Basic usage with default wave height and period: + >>> import datetime as dt + >>> dates = [dt.datetime(2023, 6, 15), dt.datetime(2023, 7, 20)] + >>> start = dt.datetime(2023, 1, 1) + >>> end = dt.datetime(2023, 12, 31) + >>> fig, axes = conditions_plot(dates, start, end) + + With multiple groups and custom styling: + >>> date_groups = [ + ... {'dates': [dt.datetime(2023, 6, 15)], 'label': 'Survey A', + ... 'color': 'blue', 'marker': 'o'}, + ... {'dates': [dt.datetime(2023, 7, 20)], 'label': 'Survey B', + ... 'color': 'red', 'marker': 's'} + ... ] + >>> fig, axes = conditions_plot(dates, start, end, date_groups=date_groups, + ... ofname='conditions.png') + + With custom variables: + >>> fig, axes = conditions_plot(dates, start, end, x_var='Tp', y_var='Hs', + ... gauge='waverider-26m') + """ + # Convert start_date and end_date to datetime if needed + if not isinstance(start_date, dt.datetime): + start_date = dt.datetime.strptime(str(start_date), '%Y%m%d') + if not isinstance(end_date, dt.datetime): + end_date = dt.datetime.strptime(str(end_date), '%Y%m%d') + + # Get wave data for the entire period + gd = getObs(start_date, end_date, server=server) + all_data = gd.getWaveData(gauge, spec=False) + + # Validate that we have data + if len(all_data['time']) == 0: + raise ValueError(f"No wave data available for gauge '{gauge}' in the specified time range") + + # Convert times to datetime.datetime for easier manipulation + # Handle both datetime objects and netCDF4 datetime objects + if hasattr(all_data['time'][0], 'timetuple'): + # Already datetime-compatible, just ensure they're regular datetime objects + all_data_dates = np.array([ + dt.datetime(d.year, d.month, d.day, d.hour, d.minute) + for d in all_data['time'] + ]) + else: + # If they're some other type, try to convert directly + all_data_dates = np.array(all_data['time']) + + # Helper function to ensure Tp exists when needed + def _ensure_tp_available(): + """Compute Tp from peakf if not already available.""" + if 'Tp' not in all_data: + if 'peakf' in all_data: + # Safely compute Tp, avoiding divide-by-zero and infinite values + with np.errstate(divide='ignore', invalid='ignore'): + tp = np.divide(1.0, all_data['peakf']) + # Replace non-finite results (inf, -inf, nan) with nan + tp[~np.isfinite(tp)] = np.nan + all_data['Tp'] = tp + else: + raise ValueError("Cannot compute Tp: 'peakf' not found in wave data") + + # Handle Tp if requested for either axis + if x_var == 'Tp' or y_var == 'Tp': + _ensure_tp_available() + + # Validate that requested variables exist + if x_var not in all_data: + raise ValueError(f"Variable '{x_var}' not found in wave data") + if y_var not in all_data: + raise ValueError(f"Variable '{y_var}' not found in wave data") + if color_var is not None and color_var not in all_data: + raise ValueError(f"Variable '{color_var}' not found in wave data") + + # Compute climatological statistics for background + # Bin the x variable and compute mean/std of y variable in each bin + idx_bins, bins = bin_data(all_data[x_var], bin_size=bin_size) + y_std, y_mean = [], [] + # Loop through bins (indices are 0-based, so range is 0 to len(bins)-1) + for ii in range(len(bins) - 1): + mask = idx_bins == ii + if np.sum(mask) > 0: + y_std.append(np.nanstd(all_data[y_var][mask])) + y_mean.append(np.nanmean(all_data[y_var][mask])) + else: + y_std.append(np.nan) + y_mean.append(np.nan) + y_mean = np.array(y_mean) + y_std = np.array(y_std) + + # Setup date groups if not provided + if date_groups is None: + # Process time_list to ensure all are datetime objects + processed_dates = [] + for d in time_list: + if isinstance(d, str): + processed_dates.append(dt.datetime.strptime(d, '%Y%m%d')) + elif isinstance(d, dt.datetime): + processed_dates.append(d) + else: + raise ValueError(f"Unsupported date format: {type(d)}") + + date_groups = [{ + 'dates': processed_dates, + 'label': 'Data', + 'color': DEFAULT_GROUP_COLOR, + 'marker': 'o' + }] + else: + # Validate date_groups structure + _validate_date_groups(date_groups) + + # Process dates in each group + for group in date_groups: + processed_dates = [] + for d in group['dates']: + if isinstance(d, str): + processed_dates.append(dt.datetime.strptime(d, '%Y%m%d')) + elif isinstance(d, dt.datetime): + processed_dates.append(d) + else: + raise ValueError(f"Unsupported date format: {type(d)}") + group['dates'] = processed_dates + # Set default marker if not provided + if 'marker' not in group: + group['marker'] = 'o' + + # Create figure + fig = plt.figure(figsize=(10, 6)) + fig.suptitle(title, fontweight='bold') + + # Panel 1: Time series of primary variable + ax1 = plt.subplot2grid((3, 2), (0, 0), colspan=2) + ax1.set_title(f'{gauge} {x_var}') + ax1.plot(all_data['time'], all_data[x_var], color=BACKGROUND_COLOR, linewidth=0.5) + ax1.set_ylabel(f'{x_var}') + ax1.set_xlabel('Date') + + # Add vertical lines for survey dates + for group in date_groups: + for i, d in enumerate(group['dates']): + label = group['label'] if i == 0 else None # Only label first occurrence + ax1.axvline(d, color=group['color'], linestyle='--', linewidth=1, label=label) + + if len(date_groups) > 1: + ax1.legend(loc='upper right') + + # Panel 2: Scatter plot of conditions during surveys + ax2 = plt.subplot2grid((3, 2), (1, 0), colspan=2, rowspan=2) + ax2.set_title(f'{y_var} vs {x_var} During Specified Times') + + # Plot climatological distribution (mean +/- std) + # Use the left edge of each bin for x-coordinates (bins[:-1] since we have len(bins)-1 stats) + valid_mask = ~np.isnan(y_mean) & ~np.isnan(y_std) + valid_bins = bins[:-1][valid_mask] # Use left edges of bins + valid_mean = y_mean[valid_mask] + valid_std = y_std[valid_mask] + + if len(valid_bins) > 0: + ax2.fill_between(valid_bins, valid_mean + valid_std, valid_mean - valid_std, + alpha=0.25, color='black', label='67% (1σ)') + ax2.fill_between(valid_bins, valid_mean + 2 * valid_std, valid_mean - 2 * valid_std, + alpha=0.15, color='black', label='95% (2σ)') + + # Plot data for each group + scatter_collection = None # Keep track of scatter for colorbar + for group in date_groups: + group_x, group_y, group_colors = [], [], [] + + for d in group['dates']: + # Create times for this date + min_time = d + dt.timedelta(hours=start_hour) + sample_times = [min_time + dt.timedelta(hours=i) for i in range(sampling_hours)] + + # Find matching times in data + mask = np.isin(all_data_dates, sample_times) + group_x.extend(all_data[x_var][mask]) + group_y.extend(all_data[y_var][mask]) + + if color_var is not None: + group_colors.extend(all_data[color_var][mask]) + + # Plot the group + if len(group_x) > 0: + if color_var is not None: + scatter = ax2.scatter(group_x, group_y, marker=group['marker'], + c=group_colors, s=50, edgecolor='k', + label=group['label'], cmap='viridis') + # Keep first scatter for colorbar + if scatter_collection is None: + scatter_collection = scatter + else: + ax2.scatter(group_x, group_y, marker=group['marker'], + c=group['color'], s=50, edgecolor='k', + label=group['label']) + + # Add colorbar if color_var was used + if color_var is not None and scatter_collection is not None: + cbar = plt.colorbar(scatter_collection, ax=ax2) + cbar.set_label(color_var) + + ax2.set_xlabel(f'{x_var}') + ax2.set_ylabel(f'{y_var}') + + # Set axis limits if provided + if x_limits is not None: + ax2.set_xlim(x_limits) + if y_limits is not None: + ax2.set_ylim(y_limits) + + ax2.legend(loc='upper right') + plt.tight_layout(rect=[0.02, 0.02, 0.99, 0.98]) + + # Save figure if filename provided + if ofname is not None: + plt.savefig(ofname, dpi=150, bbox_inches='tight') + print(f"Figure saved to {ofname}") + plt.close(fig) + + return fig, [ax1, ax2] diff --git a/tests/test_conditions_plot.py b/tests/test_conditions_plot.py new file mode 100644 index 0000000..72d17d2 --- /dev/null +++ b/tests/test_conditions_plot.py @@ -0,0 +1,81 @@ +import sys +import unittest +from unittest.mock import patch, MagicMock +import numpy as np + +from murgtools.plotting import bin_data, conditions_plot + +# Get the actual module from sys.modules (not the re-exported function) +_conditions_plot_module = sys.modules['murgtools.plotting.conditions_plot'] + + +class TestBinData(unittest.TestCase): + """Tests for the bin_data function - no mocking needed.""" + + def test_bin_data_case1(self): + """Test basic binning functionality.""" + data = np.array([0.5, 1.0, 1.5, 2.0]) + indices, bins = bin_data(data, bin_size=0.5) + self.assertEqual(len(indices), len(data)) + self.assertTrue(len(bins) > 0) + + def test_bin_data_case2(self): + """Test binning with default bin_size.""" + data = np.array([0.1, 0.2, 0.3, 0.4, 0.5]) + indices, bins = bin_data(data) + self.assertEqual(len(indices), len(data)) + + def test_bin_data_case3(self): + """Test that minimum values don't get negative indices.""" + data = np.array([1.0, 1.0, 1.0]) # All same value at minimum + indices, bins = bin_data(data, bin_size=0.5) + self.assertTrue(np.all(indices >= 0)) + + def test_bin_data_case4(self): + """Test binning with NaN values raises error for all-NaN input.""" + data = np.array([np.nan, np.nan]) + with self.assertRaises(ValueError): + bin_data(data) + + +class TestConditionsPlot(unittest.TestCase): + """Tests for the conditions_plot function - requires mocking getObs.""" + + @patch.object(_conditions_plot_module, 'getObs') + def test_conditions_plot_basic(self, mock_getObs): + """Test placeholder - actual implementation needed.""" + pass + + @patch.object(_conditions_plot_module, 'getObs') + def test_conditions_plot_with_date_string(self, mock_getObs): + """Test placeholder - actual implementation needed.""" + pass + + @patch.object(_conditions_plot_module, 'getObs') + def test_conditions_plot_with_groups(self, mock_getObs): + """Test placeholder - actual implementation needed.""" + pass + + @patch.object(_conditions_plot_module, 'getObs') + def test_conditions_plot_tp_computation(self, mock_getObs): + """Test placeholder - actual implementation needed.""" + pass + + @patch.object(_conditions_plot_module, 'getObs') + def test_conditions_plot_missing_variable(self, mock_getObs): + """Test placeholder - actual implementation needed.""" + pass + + @patch.object(_conditions_plot_module, 'getObs') + def test_conditions_plot_custom_limits(self, mock_getObs): + """Test placeholder - actual implementation needed.""" + pass + + @patch.object(_conditions_plot_module, 'getObs') + def test_conditions_plot_save_file(self, mock_getObs): + """Test placeholder - actual implementation needed.""" + pass + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file