Source code for agroecometrics.visualizations

from pathlib import Path
from typing import Optional
import numpy as np
import matplotlib.pyplot as plt


from agroecometrics import equations

# Gets the acutal labels of columns based on the user settings


####    UTIL FUNCTIONS    ####

def __check_png_filename(file_path: Path):
    """
    Validates that the provided file path ends with '.png' and that the directory exists.

    Args:
        file_path: A Path object representing the output file path.

    Returns:
        True if the file_path is a png file whose parent folder exists
    
    Raises:
        TypeError: If file_path is not a Path object.
        ValueError: If the file extension is not '.png'.
        FileNotFoundError: If the parent directory does not exist.
    """
    if not isinstance(file_path, Path):
        raise TypeError("file_path must be a pathlib.Path object.")
    if file_path.suffix.lower() != ".png":
        raise ValueError("The filename must end with '.png'.")
    if file_path.parent and not file_path.parent.exists():
        raise FileNotFoundError(f"The directory '{file_path.parent}' does not exist.")
    return True

def __save_plot(file_path: Path) -> Path:
    """
    Prepares plot to be saved and saves it.

    Adds labels to plt figure if they exist, saves the plot to the given file_path, and
        closes the plt plot after saving

    Args:
        file_path: A Path object representing the output file path.

    Returns:
        The resolved file path where the plot was saved

    Raises:
        TypeError: If file_path is not a Path object.
        ValueError: If the file extension is not '.png'.
        FileNotFoundError: If the parent directory does not exist.
    """
    # Validate input parameters
    __check_png_filename(file_path)
    
    handles, labels = plt.gca().get_legend_handles_labels()
    if handles:  # Only add legend if there are labeled elements
        plt.legend()
    plt.savefig(file_path, dpi=300, bbox_inches="tight", pad_inches=0.4)
    plt.close()

    return file_path.resolve()


####    AIR TEMPERATURE PLOTS    ####

[docs] def plot_air_temp( air_temps: np.ndarray, pred_temps: np.ndarray, date_times: np.ndarray, file_path: Path ) -> Path: """ Creates a plot of air temperature and predicted air temperatures over the time. The actual air temperature are plotted as a scatter plot and the predicted air temperatures are ploted as a graph. Args: air_temps: A numpy array of actual air temperatures. (°C) pred_temps: A numpy array of the predicted air temperatures. (°C) date_times: A numpy array of date times corrosponding to the air temperatures. file_path: A Path object representing the output file path. Returns: The resolved file path where the plot was saved. Raises: TypeError: If file_path is not a Path object. ValueError: If the file extension is not '.png'. FileNotFoundError: If the parent directory does not exist. """ __check_png_filename(file_path) # Plot actual vs predicted temperature plt.figure(figsize=(8, 4)) plt.scatter(date_times, air_temps, s=5, color="gray", label="Observed") plt.plot(date_times, pred_temps, label="Predicted", color="red", linewidth=1) plt.ylabel("Air temperature (Celsius)") plt.xlabel("Date") return __save_plot(file_path)
#### SOIL TEMPERATURE PLOTS ####
[docs] def plot_yearly_soil_temp( soil_temps: np.ndarray, file_path: Path ) -> Path: """ Creates a plot of modeled soil temperature over a year's time Args: soil_temps: A numpy array of soil temperatures for each day of the year. (°C) file_path: A Path object representing the output file path. Returns: The filename where the plot was saved Raises: TypeError: If file_path is not a Path object. ValueError: If the file extension is not '.png'. FileNotFoundError: If the parent directory does not exist. """ # Check Parmeters __check_png_filename(file_path) # Create the soil temperature plot doy = np.arange(0,365) plt.figure() plt.plot(doy,soil_temps) plt.ylabel("Surface Soil Temperature (Celsius)") plt.xlabel("Day of Year") return __save_plot(file_path)
[docs] def plot_daily_soil_temp( soil_temps: np.ndarray, depth: int, file_path: Path, ) -> Path: """ Creates a plot of modeled soil temperature at a given depth. Args: soil_temp: A numpy array of the predicted soil temperatures. (°C) depth: The depth that the soil temperature predictions are made at. (m) file_path: A Path object representing the output file path. Returns: The filename where the plot was saved Raises: TypeError: If file_path is not a Path object. ValueError: If the file extension is not '.png'. FileNotFoundError: If the parent directory does not exist. """ # Check Parameters __check_png_filename(file_path) # Create Plot plt.figure() plt.plot(depth, soil_temps) plt.ylabel("Soil temperature (Celsius)") plt.xlabel("Soil Depth (Meters)") return __save_plot(file_path)
[docs] def plot_yearly_3d_soil_temp( doy_grid: np.ndarray, depth_grid: np.ndarray, temp_grid: np.ndarray, file_path: Path ) -> Path: """ Creates a 3d plot of soil temperature at different depths over the course of a year. Args: doy_gird: A 2d np.ndarray with shape (Nz, 365) containing the day of year for each plot point. z_grid: A 2d np.ndarray with shape (Nz, 365) containing the soil depth for each plot point. (m) t_grid: A 2d np.ndarray with shape (Nz, 365) containing the soil temperature for each plot point. (°C) file_path: A Path object representing the output file path. Returns: The filename where the plot was saved Raises: TypeError: If file_path is not a Path object. ValueError: If the file extension is not '.png'. FileNotFoundError: If the parent directory does not exist. """ # Check Parameters __check_png_filename(file_path) # Create Plot fig = plt.figure(figsize=(10, 6), dpi=80, constrained_layout=True) # 10 inch by 6 inch dpi = dots per inch ax = fig.add_subplot(111, projection="3d") surf = ax.plot_surface(doy_grid, depth_grid, temp_grid, cmap="viridis", antialiased=False) fig.colorbar(surf, shrink=0.5, aspect=20) frame = surf = ax.plot_wireframe(doy_grid, depth_grid, temp_grid, linewidth=0.5, color="k", alpha=0.5) # Label x,y, and z axis ax.set_xlabel("Day of the year") ax.set_ylabel("Soil depth (Meters)") ax.set_zlabel("Soil temperature (Celsius)") # Set position of the 3D plot ax.view_init(elev=30, azim=35) return __save_plot(file_path)
[docs] def plot_modeled_soil_temp( air_temp: np.ndarray, pred_temps: np.ndarray, depths: np.ndarray, file_path: Path, soil_temp: Optional[np.ndarray] = None, colors: list[str] = ["#0072b2", "#009e73", "#cc79a7", "#d55e00", "#F0E442"], ) -> Path: """ Creates a plot of air temperature and the predicted soil temperature on a particular date Creates a plot showing the given air temperature as a scatter plot, the actual soil temperature as a scatter plot if provided, and a graphed approximation of the predicted soil temperature at each of the provided depths. The first provided color is used for the air temperature, the next is used for actual soil temperatures if provided, and the next available is used for the predicted soil temperatures. Args: air_temp: A numpy array of the air temperature collected over a single day. (°C) pred_temps: A 2d numpy array where each row is a set of predicted soil temperatures corrosponding to the provided air temperatures. Each row corrospondes to the depth in depths. (°C) depths: The depth that the soil temperature was predicted at. (m) file_path: A Path object representing the output file path. soil_temp: A numpy array of the collected soil temperature at the same interval as air temperature. colors: A list of strings providing the color codes to use for the plot. 5 color blind friendly colors are provided. Returns: The filename where the plot was saved Raises: TypeError: If file_path is not a Path object. ValueError: If the file extension is not '.png'. FileNotFoundError: If the parent directory does not exist. ValueError: If the number of predicted temperatures per set is not equal to the number of provided air temperatures. ValueError: If the number of predicted temperature sets is not equal to the number of provided depths. ValueError: If the number of required colors is greater than the number of available colors. """ # Validate input parameters __check_png_filename(file_path) if(pred_temps.shape[1] != len(air_temp)): raise ValueError(f"{len(air_temp)} air temperatures provided, but {pred_temps.shape[1]} predicted temps provided per depth") if(pred_temps.shape[0] != len(depths)): raise ValueError(f"{len(depths)} depths provided, but {pred_temps.shape[0]} predicted temps provided") if(soil_temp): if(pred_temps.shape[0] + 2 > len(colors)): raise ValueError("Not enought colors provided") elif(pred_temps.shape[0] + 1 > len(colors)): raise ValueError("Not enought colors provided") # Create list of measurement times times = np.arange(0, 1440, 1440/len(air_temp)) color_num = 0 plt.figure(figsize=(8, 4)) # Add air temperature scatter plot plt.scatter(times, air_temp, s=5, color=colors[color_num], label="Air Temperature") color_num += 1 # Add actual soil temperature scatter plot if soil_temp is not None: plt.scatter(times, soil_temp, s=5, color=colors[color_num], label="Soil Temp") color_num += 1 # Create Soil Temperature Graph for i in range(len(depths)): depth = depths[i] pred_temp = pred_temps[i] plt.plot(times, pred_temp, label=f"Predicted: {depth}m", color=colors[color_num], linewidth=1) color_num += 1 # Set labels plt.ylabel("Temperature (Celsius)") plt.xlabel("Time (Hours)") plt.xticks(ticks=[0, 360, 720, 1080, 1440], labels=["12 AM", "6 AM", "12 PM", "6 PM", "12 AM"]) return __save_plot(file_path)
[docs] def plot_3d_modeled_soil_temp( time_grid: np.ndarray, depth_grid: np.ndarray, temp_grid: np.ndarray, file_path: Path, ) -> Path: """ Creates a 3D plot of predicted soil temperature over the course of a single day at different depths. Args: time_grid: A 2D numpy array of shape (n_depths, n_times), where each value is time. (min) depth_grid: A 2D numpy array matching time_grid, where each value is the depth. (m) temp_grid: A 2D numpy array of predicted soil temperatures (same shape as time_grid). (°C) file_path: A Path object representing the output file path. Returns: The filename where the plot was saved Raises: TypeError: If file_path is not a Path object. ValueError: If the file extension is not '.png' or if the needed label(s) isn't found in the df FileNotFoundError: If the parent directory does not exist. """ __check_png_filename(file_path) # Convert minutes to hours for plotting hours_grid = time_grid / 60 depth_grid = -depth_grid # Flip so depth increases downward # Create figure and axis fig = plt.figure(figsize=(10, 6), dpi=100, constrained_layout=True) ax = fig.add_subplot(111, projection='3d') # Surface plot surf = ax.plot_surface(hours_grid, depth_grid, temp_grid, cmap="viridis", antialiased=False) fig.colorbar(surf, shrink=0.5, aspect=20) # Optional: wireframe overlay ax.plot_wireframe(hours_grid, depth_grid, temp_grid, linewidth=0.5, color='k', alpha=0.3) # Axis labels ax.set_xlabel("Hour of Day") ax.set_ylabel("Soil Depth (Meters)") ax.set_zlabel("Soil Temperature (°C)") # Set viewing angle ax.view_init(elev=30, azim=35) # Save and return path return __save_plot(file_path)
#### EVAPOTRANSPIRATION PLOTS ####
[docs] def plot_evapo_data( pred_evapos: np.ndarray, date_times: np.ndarray, model_labels: list[str], file_path: Path, colors: list[str] = ["#0072b2", "#009e73", "#cc79a7", "#d55e00", "#F0E442"], ) -> Path: """ Creates a plot of different evapotraspiration data over time.. Args: pred_evapos: A 2d Numpy array of evapotranspirations, each row represents a different model. (mm/day) date_times: A numpy array of date times corrosponding to the evapotranspiration data of each model. file_path: A Path object representing the output file path. model_labels: A list of labels corrosponding to each row of the provided evapotranspiration data. colors: A list of strings providing the color codes to use for the plot. 5 color blind friendly colors are provided. Returns: The filename where the plot was saved Raises: TypeError: If file_path is not a Path object. ValueError: If the file extension is not '.png'. FileNotFoundError: If the parent directory does not exist. ValueError: If the length of evapotranspiration data per depth is not equal to the number of date times provided. ValueError: IF the number of model labels is not equal to the number of evapotranspiration models provided. ValueError: If the number of of depths provided is greater than the number of colors provided. """ # Check params __check_png_filename(file_path) if(pred_evapos.shape[1] != len(date_times)): raise ValueError(f"{len(date_times)} Date times were provided, but {pred_evapos.shape[1]} data points for each evapotranspiration models provided.") if(pred_evapos.shape[0] != len(model_labels)): raise ValueError(f"Data for {pred_evapos.shape[0]} evapotransipation models provided, but {len(model_labels)} model labels provided.") if(pred_evapos.shape[0] > len(colors)): raise ValueError("Not enough colors provided") # Generates a new figure plt.figure(figsize=(10,4)) # Add Evapotranspiration models to the figure for i in range(len(model_labels)): pred_evapo = pred_evapos[i] label = model_labels[i] plt.plot(date_times, pred_evapo, label=label) # Adds plot label plt.ylabel("Evapotranspiration (mm/day)") plt.xlabel("Date") return __save_plot(file_path)
#### RAILFALL PLOTS ####
[docs] def plot_rainfall( rainfall: np.ndarray, runoff: np.ndarray, date_times: np.ndarray, file_path: Path ) -> Path: """ Creates a plot of rainfall and runoff over time. Args: df: DataFrame with cumulative rainfall and runoff file_path: A Path object representing the output file path. Returns: The filename where the plot was saved Raises: TypeError: If file_path is not a Path object. ValueError: If the file extension is not '.png'. FileNotFoundError: If the parent directory does not exist. """ __check_png_filename(file_path) # Create plot with rain and runoff data plt.figure(figsize=(6, 4)) plt.plot(date_times, rainfall, color="blue", label="Rainfall") plt.plot(date_times, runoff, color="red", label="Runoff") plt.ylabel("Rainfall or Runoff (mm)") return __save_plot(file_path)
#### GROWING DEGREE DAYS PLOTS ####
[docs] def plot_gdd( gdd: np.ndarray, date_times: np.ndarray, file_path: Path ) -> Path: """ Creates a plot of the Growing Degree Days that occured over each time segment in the data Args: gdd: The Growing Degree Days that occured date_times: The datetime objects corresponding to the each actual and predicted temp. file_path: A Path object representing the output file path. Returns: The filename where the plot was saved Raises: TypeError: If file_path is not a Path object. ValueError: If the file extension is not '.png' or if the needed label(s) isn't found in the df FileNotFoundError: If the parent directory does not exist. """ __check_png_filename(file_path) # Create plot plt.figure(figsize=(6, 4)) plt.plot(date_times, gdd) plt.xlabel("Date") plt.ylabel(f'Growing degree days {chr(176)}C-d)') return __save_plot(file_path)
[docs] def plot_gdd_sum( gdd_sum: np.ndarray, date_times: np.ndarray, file_path: Path ) -> Path: """ Creates a plot of the Cumulative Growing Degree Days that occured over the data Args: gdd_sum: The cummulative Growing Degree Days that have occured since the start of the data. date_times: The datetime objects corresponding to the each actual and predicted temp. file_path: A Path object representing the output file path. Returns: The filename where the plot was saved Raises: TypeError: If file_path is not a Path object. ValueError: If the file extension is not '.png' or if the needed label isn't found in the df FileNotFoundError: If the parent directory does not exist. """ __check_png_filename(file_path) # Create plot plt.figure(figsize=(6,2)) plt.plot(date_times, gdd_sum) plt.xlabel("Date") plt.ylabel(f'Growing degree days sum {chr(176)}C-d)') return __save_plot(file_path)
#### PHOTOPERIOD PLOTS ####
[docs] def plot_yearly_photoperiod(lat: float, file_path: Path): """ Creates a plot of the photoperiod at a specified latitude over a year's time. Not accurate near polar regions. Args: latitude: Latitude in decimal degress. Where the northern hemisphere is . positive and the southern hemisphere is negative. file_path: A Path object representing the output file path. Returns: The filename where the plot was saved Raises: TypeError: If file_path is not a Path object. ValueError: If the file extension is not '.png'. FileNotFoundError: If the parent directory does not exist. """ # Check Parameters __check_png_filename(file_path) # Set up plot with Title and axes doy = np.arange(0,366) plt.figure(figsize=(6,4)) plt.title('Latitude:' + str(lat)) plt.xlabel('Day of the year', size=14) plt.ylabel('Photoperiod (hours per day)', size=14) # Calulate photoperiods and adds them to the plot photoperiods, __, __, __, __, __ = equations.photoperiod_at_lat(lat, doy) plt.plot(doy, photoperiods, color='k') return __save_plot(file_path)
[docs] def plot_daily_photoperiod(doys: np.ndarray, file_path: Path): """ Creates a plot of the photoperiod from -45 to 45 degree latitude on the given day of year Args: doys: A np.ndarray of the days of year (0-365) where January 1st is 0 and 365 to perform the calculation file_path: A Path object representing the output file path. Returns: The filename where the plot was saved Raises: TypeError: If file_path is not a Path object. ValueError: If the file extension is not '.png'. FileNotFoundError: If the parent directory does not exist. """ # Check Parameters __check_png_filename(file_path) # Set up plot with Title and axes plt.figure(figsize=(6,4)) plt.xlabel('Latitude (decimal degrees)', size=14) plt.ylabel('Photoperiod (hours per day)', size=14) # Calculated photoperiods and adds them to the plot latitudes = np.linspace(-45, 45, num=180) # Loop over each day and plot for doy in doys: photoperiod, *_ = equations.photoperiod_on_day(latitudes, doy) plt.plot(latitudes, photoperiod, label=f'DOY {doy}') return __save_plot(file_path)