Source code for Hydrological_model_validator.Plotting.bfm_plots

###############################################################################
##                                                                           ##
##                               LIBRARIES                                   ##
##                                                                           ##
###############################################################################

# Data handling and utilities
import numpy as np
import pandas as pd
import itertools
from typing import Any, Dict, List
from pathlib import Path
from datetime import datetime
import os

# Plotting libraries and visualization
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
from matplotlib.colors import BoundaryNorm
from matplotlib.cm import ScalarMappable
import matplotlib.dates as mdates
import seaborn as sns
import plotly.graph_objects as go
import plotly.io as pio
import cmocean

# Geospatial plotting
import cartopy.crs as ccrs
import cartopy.feature as cfeature

# Module imports: formatting utilities and default plot options
from .formatting import (
    swifs_colormap,
    format_unit,
    style_axes_spines,
    get_benthic_plot_parameters,
    cmocean_to_plotly,
    invert_colorscale,
)
from Hydrological_model_validator.Plotting.default_bfm_plot_options import (
    DEFAULT_BENTHIC_PLOT_OPTIONS,
    DEFAULT_BENTHIC_PHYSICAL_PLOT_OPTIONS,
    DEFAULT_BENTHIC_CHEMICAL_PLOT_OPTIONS,
)
from Hydrological_model_validator.Processing.utils import extract_options


###############################################################################
##                                                                           ##
##                               FUNCTIONS                                   ##
##                                                                           ##
###############################################################################


[docs] def Benthic_depth(Bmost: np.ndarray, geo_coords: dict, output_path: Path, **user_opts: Any) -> None: """ Plot the benthic layer depth from a 2D bottom index array using Cartopy, with geolocalized coordinates for spatial referencing. Parameters ---------- Bmost : np.ndarray 2D array with bottom layer indices. geo_coords : dict Dictionary with geolocalized coordinates and extents, including keys: - 'latp', 'lonp' : 2D arrays of latitudes and longitudes. - 'Epsilon' : Float, spatial offset for plotting. - 'MinLambda', 'MaxLambda' : Longitude bounds for extent. - 'MinPhi', 'MaxPhi' : Latitude bounds for extent. output_path : str or Path Required. Directory path where the figure PNG will be saved. Keyword Arguments ----------------- figsize : tuple of float, optional Size of the figure in inches (default: (10, 10)). projection : str, optional Cartopy projection name as string (default: "PlateCarree"). contour_levels : int, optional Number of contour levels (default: 26). cmap : str or Colormap, optional Colormap to use for the contour plot (default: "jet"). contour_extend : str, optional Extend option for contour ('both', 'neither', etc., default: "both"). coastline_linewidth : float, optional Line width of coastlines (default: 1.5). borders_linestyle : str, optional Line style for country borders (default: ":"). gridline_color : str, optional Color of grid lines (default: "gray"). gridline_linestyle : str, optional Line style of grid lines (default: "--"). grid_draw_labels : bool, optional Whether to draw grid labels (default: True). grid_dms : bool, optional Display grid labels in degrees, minutes, seconds (default: True). grid_x_inline : bool, optional Whether x-axis grid labels are inline (default: False). grid_y_inline : bool, optional Whether y-axis grid labels are inline (default: False). colorbar_width : float, optional Width of the colorbar (default: 0.65). colorbar_height : float, optional Height of the colorbar (default: 0.025). colorbar_left : float or None, optional Left position of the colorbar axes (default: None, computed automatically). colorbar_bottom : float, optional Bottom position of the colorbar axes (default: 0.175). colorbar_label : str, optional Label for the colorbar (default: "[m]"). colorbar_labelsize : int, optional Font size for colorbar label (default: 12). colorbar_tick_length : int, optional Length of colorbar ticks (default: 18). colorbar_tick_labelsize : int, optional Font size for colorbar tick labels (default: 10). colorbar_ticks : list or None, optional List of ticks on colorbar (default: None, computed automatically). spine_linewidth : float, optional Line width for plot spines (default: 2). spine_edgecolor : str, optional Edge color for plot spines (default: "black"). title : str, optional Title of the plot (default: "Benthic Layer Depth"). title_fontsize : int, optional Font size of the title (default: 16). title_fontweight : str, optional Font weight of the title (default: "bold"). dpi : int, optional DPI resolution for saved figure (default: 150). filename : str, optional Filename for saved plot (default: "NA - Benthic Depth.png"). """ # ----- VALIDATION ----- if not isinstance(Bmost, np.ndarray) or Bmost.ndim != 2: raise ValueError("Bmost must be a 2D NumPy array") # ----- OPTIONS ----- options = extract_options(user_opts, DEFAULT_BENTHIC_PLOT_OPTIONS) # ----- DEFAULTS ----- if options['colorbar_left'] is None: options['colorbar_left'] = (1 - options['colorbar_width']) / 2 # ----- DEPTH CONVERT ----- Bmost_depth = np.where(Bmost == 0, np.nan, Bmost * 2) cmap = getattr(cmocean.cm, 'deep') # ----- COORDINATES ----- latp = geo_coords['latp'] lonp = geo_coords['lonp'] epsilon = geo_coords.get('Epsilon', 0.06) min_lon, max_lon = geo_coords['MinLambda'], geo_coords['MaxLambda'] min_lat, max_lat = geo_coords['MinPhi'], geo_coords['MaxPhi'] # ----- FIGURE SETUP ----- plt.figure(figsize=(10, 10)) ax = plt.axes(projection=ccrs.PlateCarree()) ax.set_extent([min_lon, max_lon, min_lat + epsilon, max_lat], crs=ccrs.PlateCarree()) # ----- CONTOUR PLOT ----- contour_levels = np.linspace(np.nanmin(Bmost_depth), np.nanmax(Bmost_depth), 26) contour = ax.contourf( lonp + (0.4 * epsilon), latp + (0.1 * epsilon), Bmost_depth, levels=contour_levels, cmap=cmap, extend='both', transform=ccrs.PlateCarree() ) # ----- FEATURES ----- ax.coastlines(linewidth=1.5) ax.add_feature(cfeature.BORDERS, linestyle=':') # ----- TITLE ----- ax.set_title("Benthic Layer Depth", fontsize=options['title_fontsize'], fontweight='bold') # ----- GRIDLINES ----- ax.gridlines( draw_labels=True, dms=True, x_inline=False, y_inline=False, color='gray', linestyle='--' ) # ----- COLORBAR ----- cbar_ax = plt.gcf().add_axes([ options['colorbar_left'], options['colorbar_bottom'], options['colorbar_width'], options['colorbar_height'], ]) norm = mcolors.BoundaryNorm( np.linspace(np.nanmin(Bmost_depth), np.nanmax(Bmost_depth), 11), contour.cmap.N ) cbar = plt.colorbar(contour, cax=cbar_ax, orientation='horizontal', norm=norm, extend='both') cbar.set_label('[m]', fontsize=options['colorbar_labelsize']) # ----- COLORBAR TICKS ----- if options['colorbar_ticks'] is None: ticks = np.linspace(np.nanmin(Bmost_depth), np.nanmax(Bmost_depth), 6).astype(int) else: ticks = options['colorbar_ticks'] cbar.set_ticks(ticks) cbar.ax.tick_params( direction='in', length=options['colorbar_tick_length'], labelsize=options['colorbar_ticklabelsize'] ) # ----- STYLING ----- style_axes_spines(ax, linewidth=options['spine_linewidth'], edgecolor=options['spine_edgecolor']) # ----- SAVE FIGURE ----- filename = "NA - Benthic Depth.png" save_path = Path(output_path, filename) plt.savefig(save_path) plt.close()
############################################################################### ###############################################################################
[docs] def plot_benthic_3d_mesh(Bmost, geo_coords, layer_thickness=2, plot_type='surface', save_path=None): """ Plot a 3D surface or mesh of benthic depth with interactive rotation. Parameters ---------- Bmost : np.ndarray (2D) Array of bottom layer indices. geo_coords : dict Dictionary containing: - 'latp' : 2D array of latitudes. - 'lonp' : 2D array of longitudes. layer_thickness : float, optional Thickness of each depth layer in meters (default: 2). plot_type : str, optional Type of 3D plot: 'surface' or 'mesh3d' (default: 'surface'). save_path : str or Path, optional Directory path where the interactive HTML plot will be saved. """ # ----- COORDINATES ----- latp = geo_coords['latp'] lonp = geo_coords['lonp'] # ----- DEPTH CALC ----- depth = -Bmost * layer_thickness # Negative for plotting convention # ----- COLORSCALE ----- plotly_cscale = cmocean_to_plotly('deep') plotly_cscale_inverted = invert_colorscale(plotly_cscale) # ----- SURFACE PLOT ----- if plot_type == 'surface': depth = depth.astype(float) # ----- MASK SOUTH EDGE ----- min_lat = np.min(latp) tol = 1e-6 depth_masked = depth.copy() depth_masked[np.abs(latp - min_lat) < tol] = np.nan fig = go.Figure(data=[go.Surface( x=lonp, y=latp, z=depth_masked, colorscale=plotly_cscale_inverted, colorbar=dict(title='Depth (m)'), showscale=True, )]) fig.update_layout( title='3D Basin Depth Surface', scene=dict( xaxis_title='Longitude (°)', yaxis_title='Latitude (°)', zaxis_title='Depth (m)', zaxis=dict(autorange=True), aspectratio=dict(x=1, y=1, z=0.5) ), margin=dict(l=0, r=0, b=0, t=30), ) # ----- MESH3D PLOT ----- elif plot_type == 'mesh3d': x = lonp.flatten() y = latp.flatten() z = depth.flatten() min_lat = np.min(latp) tol = 1e-6 # ----- COLOR NORMALIZATION ----- norm = mcolors.Normalize(vmin=np.min(z), vmax=np.max(z)) colormap = plt.colormaps['viridis'] # ----- VERTEX COLORS ----- vertex_colors = [] for yi, zi in zip(y, z): if abs(yi - min_lat) < tol: vertex_colors.append('rgba(0,0,0,0)') # Transparent else: rgba = colormap(norm(zi)) rgba_255 = tuple(int(255 * c) for c in rgba[:3]) + (255,) vertex_colors.append(f'rgba{rgba_255}') fig = go.Figure(data=[go.Mesh3d( x=x, y=y, z=z, vertexcolor=vertex_colors, opacity=1.0, flatshading=True, showscale=False, )]) fig.update_layout( title='3D Basin Depth Mesh', scene=dict( xaxis_title='Longitude (°)', yaxis_title='Latitude (°)', zaxis_title='Depth (m)', aspectratio=dict(x=1, y=1, z=0.5) ), margin=dict(l=0, r=0, b=0, t=30), ) # ----- INVALID TYPE ----- else: raise ValueError("plot_type must be 'surface' or 'mesh3d'") # ----- SAVE HTML FILE ----- filename = f"3D Basin Depth {plot_type}.html" if save_path: os.makedirs(save_path, exist_ok=True) filename = Path(save_path, filename) pio.write_html(fig, filename, auto_open=True)
############################################################################### ###############################################################################
[docs] def Benthic_physical_plot(var_dataframe: dict, geo_coord: dict, **kwargs) -> None: """ Plot monthly benthic physical variable maps (e.g., temperature, salinity) across years. Parameters ---------- var_dataframe : dict Dictionary with years as keys (int), each containing a list of 12 monthly 2D arrays (shape: [Y, X]). geo_coord : dict Dictionary with 2D coordinate arrays: - 'lonp' : 2D array of longitudes. - 'latp' : 2D array of latitudes. Keyword Arguments ----------------- bfm2plot : str, optional Variable name to plot (default is 'votemper'). unit : str, optional Unit of measurement (default is '°C'). description : str, optional Variable description for plot title (default is 'Bottom Temperature'). output_path : str or Path, optional Directory to save output plots (default is 'output'). figsize : tuple of float, optional Size of each figure in inches (default is (10, 10)). dpi : int, optional Resolution of saved figures (default is 150). coastline_linewidth : float, optional Width of coastlines (default is 2). border_linestyle : str, optional Country border linestyle (default is ':'). gridline_color : str, optional Color of gridlines (default is 'gray'). gridline_linestyle : str, optional Gridline linestyle (default is '--'). title_fontsize : int, optional Font size for plot title (default is 16). title_fontweight : str, optional Font weight for title (default is 'bold'). colorbar_position : list, optional Colorbar axes [left, bottom, width, height] (default is [0.175, 0.175, 0.65, 0.025]). colorbar_labelsize : int, optional Label font size for colorbar (default is 14). colorbar_tick_length : int, optional Tick length for colorbar (default is 18). colorbar_tick_labelsize : int, optional Font size of tick labels (default is 10). """ # ----- LOAD OPTIONS ----- opts = extract_options(kwargs, DEFAULT_BENTHIC_PHYSICAL_PLOT_OPTIONS) bfm2plot = opts["bfm2plot"] unit = opts["unit"] description = opts["description"] output_path = Path(opts["output_path"]) figsize = opts["figsize"] dpi = opts["dpi"] coastline_linewidth = opts["coastline_linewidth"] border_linestyle = opts["border_linestyle"] gridline_color = opts["gridline_color"] gridline_linestyle = opts["gridline_linestyle"] title_fontsize = opts["title_fontsize"] title_fontweight = opts["title_fontweight"] colorbar_position = opts["colorbar_position"] colorbar_labelsize = opts["colorbar_labelsize"] colorbar_tick_length = opts["colorbar_tick_length"] colorbar_tick_labelsize = opts["colorbar_tick_labelsize"] # ----- COORDINATES ----- lonp, latp = geo_coord["lonp"], geo_coord["latp"] MinLambda, MaxLambda = lonp.min(), lonp.max() MinPhi, MaxPhi = latp.min(), latp.max() epsilon = 0.06 # ----- PLOTTING PARAMETERS ----- vmin, vmax, levels, num_ticks, cmap, *_ = get_benthic_plot_parameters( bfm2plot, var_dataframe, opts ) if num_ticks is None or num_ticks < 2: num_ticks = 5 extend = "both" timestamp = datetime.now().strftime("run_%Y-%m-%d") # ----- LOOP YEARS & MONTHS ----- for year, month_idx in itertools.product(var_dataframe.keys(), range(12)): data2D = var_dataframe[year][month_idx] if data2D is None or np.all(np.isnan(data2D)): print(f"Skipping {year}-{month_idx+1:02d}: no data") continue month_name = datetime(1900, month_idx + 1, 1).strftime("%B") print(f"Plotting {description} for {month_name} {year}") # ----- SETUP FIGURE ----- fig, ax = plt.subplots(figsize=figsize, subplot_kw={"projection": ccrs.PlateCarree()}) ax.set_extent([MinLambda, MaxLambda, MinPhi, MaxPhi], crs=ccrs.PlateCarree()) # ----- FILLED CONTOUR ----- ax.contourf( lonp + 0.4 * epsilon, latp + 0.2 * epsilon, data2D, levels=levels, cmap=cmap if isinstance(cmap, str) else cmap, vmin=vmin, vmax=vmax, extend=extend, transform=ccrs.PlateCarree(), ) # ----- DENSE WATER MASK ----- if bfm2plot == "dense_water": mask = ~np.isnan(data2D) ax.contour( lonp + 0.025, latp + 0.015, mask.astype(float), levels=[0.5], colors="black", linewidths=1.5, transform=ccrs.PlateCarree(), ) # ----- FEATURES ----- ax.coastlines(linewidth=coastline_linewidth, resolution='10m') ax.add_feature(cfeature.BORDERS, linestyle=border_linestyle) ax.add_feature(cfeature.LAND, facecolor='lightgray') # ----- GRIDLINES ----- gl = ax.gridlines(draw_labels=True, dms=True, color=gridline_color, linestyle=gridline_linestyle) gl.top_labels = False gl.right_labels = False # ----- TITLE ----- ax.set_title(f"{description} | {year} - {month_name}", fontsize=title_fontsize, fontweight=title_fontweight) # ----- COLORBAR ----- cmap_obj = plt.colormaps[cmap] if isinstance(cmap, str) else cmap norm = BoundaryNorm(levels, ncolors=cmap_obj.N) mappable = ScalarMappable(norm=norm, cmap=cmap_obj) cbar_ax = fig.add_axes(colorbar_position) cbar = plt.colorbar(mappable, cax=cbar_ax, orientation="horizontal", extend=extend) field_units = format_unit(unit) cbar.set_label(rf"$\left[{field_units[1:-1]}\right]$", fontsize=colorbar_labelsize) ticks = np.linspace(vmin, vmax, num_ticks) cbar.set_ticks(ticks) cbar.ax.set_xticklabels([f"{tick:.1f}" for tick in ticks]) cbar.ax.tick_params(direction="in", length=colorbar_tick_length, labelsize=colorbar_tick_labelsize) # ----- STYLE AXES ----- style_axes_spines(ax, linewidth=2, edgecolor="black") # ----- SAVE FIGURE ----- save_dir = output_path / timestamp / str(year) save_dir.mkdir(parents=True, exist_ok=True) filename = f"Benthic_{bfm2plot}_{year}_{month_name}.png" plt.savefig(save_dir / filename, bbox_inches="tight", dpi=dpi) plt.close() print('-' * 45)
############################################################################### ###############################################################################
[docs] def Benthic_chemical_plot(var_dataframe, geo_coord, location=None, **kwargs): """ Plot benthic variable maps (e.g., temperature, chlorophyll) across all years and months. Parameters ---------- var_dataframe : dict Dict of years (int), each containing a list of 12 monthly 2D arrays (Y, X). geo_coord : dict Dictionary with geolocalized coordinates, keys: - 'lonp' (2D array of longitudes) - 'latp' (2D array of latitudes) location : str or None, optional Optional location string to include in plot titles (default is None). Keyword Arguments ----------------- bfm2plot : str, optional Variable name to plot (default is 'votemper'). unit : str, optional Unit of measurement (default is '°C'). description : str, optional Variable description for plot titles (default is 'Bottom Temperature'). output_path : str or Path, optional Directory to save output plots (default is 'output'). epsilon : float, optional Coordinate offset applied to lon/lat (default is 0.06). figsize : tuple of float, optional Size of each figure in inches (default is (10, 10)). dpi : int, optional Resolution of saved figures (default is 150). coastline_linewidth : float, optional Width of coastlines (default is 2). border_linestyle : str, optional Country border linestyle (default is ':'). gridline_color : str, optional Color of gridlines (default is 'gray'). gridline_linestyle : str, optional Gridline linestyle (default is '--'). title_fontsize : int, optional Font size for plot titles (default is 16). title_fontweight : str, optional Font weight for titles (default is 'bold'). colorbar_position : list, optional Colorbar axes [left, bottom, width, height] (default is [0.175, 0.175, 0.65, 0.025]). colorbar_labelsize : int, optional Font size for colorbar label (default is 14). colorbar_tick_length : int, optional Length of colorbar ticks (default is 18). colorbar_tick_labelsize : int, optional Font size for colorbar tick labels (default is 10). """ # ----- OPTIONS ----- opts = extract_options(kwargs, DEFAULT_BENTHIC_CHEMICAL_PLOT_OPTIONS, prefix="") bfm2plot = opts["bfm2plot"] unit = opts["unit"] description = opts["description"] output_path = Path(opts["output_path"]) epsilon = opts["epsilon"] figsize = opts["figsize"] dpi = opts["dpi"] coastline_linewidth = opts["coastline_linewidth"] border_linestyle = opts["border_linestyle"] gridline_color = opts["gridline_color"] gridline_linestyle = opts["gridline_linestyle"] title_fontsize = opts["title_fontsize"] title_fontweight = opts["title_fontweight"] colorbar_position = opts["colorbar_position"] colorbar_labelsize = opts["colorbar_labelsize"] colorbar_tick_length = opts["colorbar_tick_length"] colorbar_tick_labelsize = opts["colorbar_tick_labelsize"] # ----- EXTENT ----- timestamp = datetime.now().strftime("run_%Y-%m-%d") lonp, latp = geo_coord['lonp'], geo_coord['latp'] extent = [lonp.min(), lonp.max(), latp.min(), latp.max()] # ----- PARAMETERS ----- (vmin, vmax, levels, num_ticks, cmap_name, use_custom_cmap, hypoxia_threshold, hyperoxia_threshold) = get_benthic_plot_parameters( bfm2plot, var_dataframe, opts ) # ----- PLOTTING LOOP ----- for year, monthly_data in var_dataframe.items(): for month_idx, data2D in enumerate(monthly_data): # ----- SKIP EMPTY ----- if data2D is None or np.all(np.isnan(data2D)): continue month_name = datetime(1900, month_idx + 1, 1).strftime('%B') print(f"Plotting {month_name}, year {year}...") # ----- FIGURE ----- fig, ax = plt.subplots(figsize=figsize, subplot_kw=dict(projection=ccrs.PlateCarree())) ax.set_extent(extent, crs=ccrs.PlateCarree()) # ----- CONTOUR ----- if use_custom_cmap: _, cmap_obj, norm, ticks, tick_labels = swifs_colormap(data2D, bfm2plot) cs = ax.contourf( lonp + 0.4 * epsilon, latp + 0.2 * epsilon, data2D, levels=ticks, cmap=cmap_obj, norm=norm, extend='both', transform=ccrs.PlateCarree() ) else: cmap_obj = plt.get_cmap(cmap_name) cs = ax.contourf( lonp + 0.4 * epsilon, latp + 0.2 * epsilon, data2D, levels=levels, cmap=cmap_obj, vmin=vmin, vmax=vmax, extend='both', transform=ccrs.PlateCarree() ) # ----- FEATURES ----- ax.coastlines(linewidth=coastline_linewidth, resolution='10m') ax.add_feature(cfeature.BORDERS, linestyle=border_linestyle) ax.add_feature(cfeature.LAND, facecolor='lightgray') # ----- TITLE ----- title_str = f"{description} | {year} - {month_name}" if location: title_str = f"{location} " + title_str ax.set_title(title_str, fontsize=title_fontsize, fontweight=title_fontweight) # ----- GRID ----- gl = ax.gridlines(draw_labels=True, dms=True, color=gridline_color, linestyle=gridline_linestyle) gl.top_labels = False gl.right_labels = False # ----- COLORBAR ----- cbar_ax = fig.add_axes(colorbar_position) if use_custom_cmap: cbar = plt.colorbar(cs, cax=cbar_ax, orientation='horizontal', extend='both') cbar.set_ticks(ticks) cbar.set_ticklabels(tick_labels) else: norm = BoundaryNorm(levels, ncolors=cmap_obj.N) mappable = ScalarMappable(norm=norm, cmap=cmap_obj) cbar = plt.colorbar(mappable, cax=cbar_ax, orientation='horizontal', extend='both') cbar.set_ticks(np.linspace(vmin, vmax, num_ticks or 15)) # ----- THRESHOLDS ----- if hypoxia_threshold is not None and vmin < hypoxia_threshold < vmax: x_min, x_max = cbar_ax.get_xlim() norm_val = (hypoxia_threshold - vmin) / (vmax - vmin) x_pos = x_min + norm_val * (x_max - x_min) cbar_ax.axvline(x_pos, color='red', linestyle='--', linewidth=2) cbar_ax.text(x_pos, 1.25, 'Hypoxia', color='red', ha='center', va='bottom', fontsize=10) cbar_ax.text(x_pos, -1.25, '62.5', color='red', ha='center', va='bottom', fontsize=10) if hyperoxia_threshold is not None and vmin < hyperoxia_threshold < vmax: x_min, x_max = cbar_ax.get_xlim() norm_val = (hyperoxia_threshold - vmin) / (vmax - vmin) x_pos = x_min + norm_val * (x_max - x_min) cbar_ax.axvline(x_pos, color='#B8860B', linestyle='--', linewidth=2) cbar_ax.text(x_pos, 1.25, 'Hyperoxia', color='#B8860B', ha='center', va='bottom', fontsize=10) cbar_ax.text(x_pos, -1.25, '312.5', color='#B8860B', ha='center', va='bottom', fontsize=10) cbar.ax.tick_params(direction='in', length=colorbar_tick_length, labelsize=colorbar_tick_labelsize) # ----- UNITS ----- units_clean = format_unit(unit)[1:-1] cbar.set_label(rf'$\left[{units_clean}\right]$', fontsize=colorbar_labelsize) # ----- SPINES ----- style_axes_spines(ax, linewidth=2, edgecolor="black") # ----- SAVE ----- save_dir = output_path / timestamp / str(year) save_dir.mkdir(parents=True, exist_ok=True) plt.savefig(save_dir / f"Benthic_{bfm2plot}_{year}_{month_name}.png", bbox_inches='tight', dpi=dpi) plt.close()
############################################################################### ###############################################################################
[docs] def dense_water_timeseries( data_lists: Dict[str, List[dict]], title: str = "Dense Water Volume Time Series", xlabel: str = "Time", ylabel: str = "Dense Water Volume (km³)", figsize: tuple = (14, 6), legend_loc: str = "best", date_format: str = "%Y-%m", output_path = None ): """ Plots a time series of dense water volumes for multiple data series. Args: data_lists (dict): Dictionary with keys as series labels and values as lists of dicts with 'date' and 'volume_m3'. title (str): Plot title. xlabel (str): X-axis label (date). ylabel (str): Y-axis label (km³). figsize (tuple): Figure size. legend_loc (str): Location of legend. date_format (str): Date format for x-axis labels. """ # ----- CHECK DATA ----- if not data_lists: print("No data provided. Skipping plot.") return # ----- STYLE ----- sns.set(style="whitegrid", context='notebook') sns.set_style("ticks") # ----- FIGURE ----- plt.figure(figsize=figsize) # ----- COMBINE DATA ----- combined_df = pd.DataFrame() for label, data_list in data_lists.items(): if not data_list: continue dates = [entry['date'] for entry in data_list] volumes_km3 = [entry['volume_m3'] / 1e9 for entry in data_list] df = pd.DataFrame({ 'date': pd.to_datetime(dates), 'volume_km3': volumes_km3, 'series': label }) combined_df = pd.concat([combined_df, df], ignore_index=True) # ----- CHECK COMBINED ----- if combined_df.empty: print("No valid data in data_lists. Skipping plot.") return # ----- PLOT LINES ----- ax1 = plt.gca() sns.lineplot(data=combined_df, x='date', y='volume_km3', hue='series', marker='o', ax=ax1) # ----- FILL SEASONS ----- for label in combined_df['series'].unique(): df_series = combined_df[combined_df['series'] == label].copy() df_series['year'] = df_series['date'].dt.year for year in df_series['year'].unique(): start = pd.Timestamp(year=year - 1, month=12, day=1) end = pd.Timestamp(year=year, month=6, day=30) mask = (df_series['date'] >= start) & (df_series['date'] <= end) df_fill = df_series.loc[mask] if len(df_fill) >= 2: ax1.fill_between(df_fill['date'], df_fill['volume_km3'], color='purple', alpha=0.15) # ----- AXIS LABELS ----- ax1.set_xlabel(xlabel) ax1.set_ylabel(ylabel) ax1.grid(True) ax1.set_title(title, fontsize=18, fontweight='bold') # ----- SECONDARY AXIS ----- ax2 = ax1.twinx() def km3_to_sv(km3): seconds_per_month = 30 * 24 * 3600 return (km3 * 1e9) / seconds_per_month / 1e6 y1_min, y1_max = ax1.get_ylim() ax2.set_ylim(km3_to_sv(y1_min), km3_to_sv(y1_max)) ax2.set_ylabel("Dense Water Volume (Sverdrup)", color='black') ax2.tick_params(axis='y', colors='black') ax2.spines['right'].set_color('black') ax2.grid(True, axis='y', linestyle='--', color='gray', alpha=0.7) # ----- LEGEND & FORMAT ----- ax1.legend(loc=legend_loc) ax1.xaxis.set_major_formatter(mdates.DateFormatter(date_format)) plt.gcf().autofmt_xdate() plt.tight_layout() # ----- SAVE ----- save_dir = Path(output_path) save_dir.mkdir(parents=True, exist_ok=True) plt.savefig(save_dir / "Dense_water_timeseries") plt.close()
###############################################################################