From 3f68ea01190c8bec3475b900f363f07dde16681c Mon Sep 17 00:00:00 2001 From: Mike McCann Date: Mon, 16 Mar 2026 13:23:33 -0700 Subject: [PATCH 1/6] Rename depth_threshold to distinguish two unrelated uses DEPTH_THRESHOLD and the depth_threshold parameter were shared across two semantically distinct roles: - add_profile(): minimum vertical displacement between adjacent depth peaks required to increment the profile counter. Renamed to PROFILE_MIN_DEPTH_CHANGE / profile_min_depth_change. - correct_biolume_proxies(): minimum depth below the surface to include data points in the fluorescence/bioluminescence proxy correction. Renamed to SURFACE_EXCLUSION_DEPTH_M / surface_exclusion_depth_m. --- src/data/resample.py | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/data/resample.py b/src/data/resample.py index 7ea041b..fd955fd 100755 --- a/src/data/resample.py +++ b/src/data/resample.py @@ -39,7 +39,10 @@ AUVCTD_OPENDAP_BASE = "http://dods.mbari.org/opendap/data/auvctd" LRAUV_OPENDAP_BASE = "http://dods.mbari.org/opendap/data/lrauv" FLASH_THRESHOLD = 1.0e11 -DEPTH_THRESHOLD = 10.0 # meters +PROFILE_MIN_DEPTH_CHANGE = ( + 10.0 # meters - min vertical displacement between peaks to increment profile counter +) +SURFACE_EXCLUSION_DEPTH_M = 2.0 # meters - min depth below surface for proxy correction MAX_INTERPOLATE_LIMIT = 3 # Maximum number of consecutive NaNs to fill during interpolation @@ -824,7 +827,7 @@ def _find_lat_lon_variables(self) -> tuple[str, str]: self.logger.info("Using first available coordinates: %s and %s", lat_var, lon_var) return lat_var, lon_var - def add_profile(self, depth_threshold: float) -> None: + def add_profile(self, profile_min_depth_change: float) -> None: if len(self.resampled_nc["depth"]) == 0: self.logger.warning( "No depth data available to compute profile numbers", @@ -854,7 +857,7 @@ def add_profile(self, depth_threshold: float) -> None: if tv > s_peaks.index[k + 1]: # Encountered a new simple_depth point k += 1 - if abs(s_peaks.iloc[k + 1] - s_peaks.iloc[k]) > depth_threshold: + if abs(s_peaks.iloc[k + 1] - s_peaks.iloc[k]) > profile_min_depth_change: # Completed downcast or upcast count += 1 profiles.append(count) @@ -873,8 +876,9 @@ def add_profile(self, depth_threshold: float) -> None: "comment": ( f"Sequential profile counter identifying individual vertical casts. " f"Profiles are detected from depth vertices using scipy.signal.find_peaks " - f"with prominence={depth_threshold}m threshold. Increments when vehicle " - f"transitions between upcast and downcast with sufficient vertical displacement." + f"with prominence=10m and width=30 samples. Increments when vehicle " + f"transitions between upcast and downcast with >{profile_min_depth_change}m " + f"vertical displacement." ), } @@ -1614,7 +1618,7 @@ def correct_biolume_proxies( # noqa: C901, PLR0912, PLR0913, PLR0915 biolume_fluo: pd.Series, # from add_biolume_proxies or add_wetlabsubat_proxies biolume_sunsets: list[datetime], # from add_biolume_proxies or add_wetlabsubat_proxies biolume_sunrises: list[datetime], # from add_biolume_proxies or add_wetlabsubat_proxies - depth_threshold: float, + surface_exclusion_depth_m: float, adinos_threshold: float = 0.1, correction_threshold: int = 3, fluo_bl_threshold: float = 0.2, @@ -1749,7 +1753,7 @@ def _interval_contains_sunevent( self.df_r.loc[target_indices, f"{prefix}_proxy_hdinos"] = np.nan continue # excludes surface, must be within 5 min of it - ideep = iprofil & (df_p.depth > depth_threshold) + ideep = iprofil & (df_p.depth > surface_exclusion_depth_m) itime = (df_p.index > (df_p.index[ideep].min() - dt_5mins)) & ( df_p.index < (df_p.index[ideep].max() + dt_5mins) ) @@ -1900,7 +1904,7 @@ def resample_variable( # noqa: PLR0913 mission_start: pd.Timestamp, mission_end: pd.Timestamp, instrs_to_pad: dict[str, timedelta], - depth_threshold: float, + surface_exclusion_depth_m: float, ) -> None: # Get the time variable name from the dimension of the variable timevar = self.ds[variable].dims[0] @@ -1919,7 +1923,7 @@ def resample_variable( # noqa: PLR0913 biolume_fluo, biolume_sunsets, biolume_sunrises, - depth_threshold, + surface_exclusion_depth_m, ) elif instr == "wetlabsubat" and variable == "wetlabsubat_digitized_raw_ad_counts": # All wetlabsubat proxy variables are computed from wetlabsubat_digitized_raw_ad_counts @@ -1937,7 +1941,7 @@ def resample_variable( # noqa: PLR0913 wetlabsubat_fluo, wetlabsubat_sunsets, wetlabsubat_sunrises, - depth_threshold, + surface_exclusion_depth_m, ) else: self.df_o[variable] = self.ds[variable].to_pandas() @@ -2103,11 +2107,17 @@ def resample_align_file( # noqa: C901, PLR0912, PLR0915, PLR0913 mf_width: int = MF_WIDTH, freq: str = FREQ, plot_seconds: float = PLOT_SECONDS, - depth_threshold: float = DEPTH_THRESHOLD, + profile_min_depth_change: float = PROFILE_MIN_DEPTH_CHANGE, + surface_exclusion_depth_m: float = SURFACE_EXCLUSION_DEPTH_M, ) -> None: - # Change depth_threshold here should a particular mission require it, e.g.: + # Change profile_min_depth_change or surface_exclusion_depth_m here + # should a particular mission require it, e.g.: # if "2023.192.01" in nc_file: ... - self.logger.info("Using depth_threshold = %.2f m", depth_threshold) + self.logger.info( + "Using profile_min_depth_change = %.2f m, surface_exclusion_depth_m = %.2f m", + profile_min_depth_change, + surface_exclusion_depth_m, + ) pd.options.plotting.backend = "matplotlib" self.ds = xr.open_dataset(nc_file) @@ -2150,7 +2160,7 @@ def resample_align_file( # noqa: C901, PLR0912, PLR0915, PLR0913 self.logger.info("Calling add_profile") if self.plot: self.plot_coordinates(instr, freq, plot_seconds) - self.add_profile(depth_threshold=depth_threshold) + self.add_profile(profile_min_depth_change=profile_min_depth_change) self.logger.info("Coordinates saved and profile added successfully") coordinates_saved = True if instr != last_instr: @@ -2169,7 +2179,7 @@ def resample_align_file( # noqa: C901, PLR0912, PLR0915, PLR0913 mission_start, mission_end, instrs_to_pad, - depth_threshold, + surface_exclusion_depth_m, ) for var in self.df_r: if var not in variables: @@ -2190,7 +2200,7 @@ def resample_align_file( # noqa: C901, PLR0912, PLR0915, PLR0913 mission_start, mission_end, instrs_to_pad, - depth_threshold, + surface_exclusion_depth_m, ) for var in self.df_r: if var not in variables: @@ -2214,7 +2224,7 @@ def resample_align_file( # noqa: C901, PLR0912, PLR0915, PLR0913 mission_start, mission_end, instrs_to_pad, - depth_threshold, + surface_exclusion_depth_m, ) self.resampled_nc[variable] = ( self.df_r[variable].rename_axis("time").to_xarray() From 975bd13cfc690ea0c2b0555b2b7e2106f60ea05a Mon Sep 17 00:00:00 2001 From: Mike McCann Date: Mon, 16 Mar 2026 13:33:29 -0700 Subject: [PATCH 2/6] Update EXPECTED_SIZEs --- src/data/test_process_dorado.py | 8 ++++---- src/data/test_process_i2map.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/data/test_process_dorado.py b/src/data/test_process_dorado.py index 4f26547..c52bc09 100644 --- a/src/data/test_process_dorado.py +++ b/src/data/test_process_dorado.py @@ -31,9 +31,9 @@ def test_process_dorado(complete_dorado_processing): # but it will alert us if a code change unexpectedly changes the file size. # If code changes are expected to change the file size then we should # update the expected size here. - EXPECTED_SIZE_GITHUB = 626561 - EXPECTED_SIZE_ACT = 627345 - EXPECTED_SIZE_LOCAL = 627395 + EXPECTED_SIZE_GITHUB = 626566 + EXPECTED_SIZE_ACT = 627350 + EXPECTED_SIZE_LOCAL = 627400 if str(proc.args.base_path).startswith("/home/runner"): # The size is different in GitHub Actions, maybe due to different metadata assert nc_file.stat().st_size == EXPECTED_SIZE_GITHUB # noqa: S101 @@ -52,7 +52,7 @@ def test_process_dorado(complete_dorado_processing): # Check that the MD5 hash has not changed EXPECTED_MD5_GITHUB = "3be10ffae1bcefb4891d7b7fa7f875ba" EXPECTED_MD5_ACT = "136e130ac434494ab5eb52ff935a73b8" - EXPECTED_MD5_LOCAL = "f17349e97d7ced5f01c7e92c6706c246" + EXPECTED_MD5_LOCAL = "804250739075ee78e31c6c34101009ff" if str(proc.args.base_path).startswith("/home/runner"): # The MD5 hash is different in GitHub Actions, maybe due to different metadata assert hashlib.md5(open(nc_file, "rb").read()).hexdigest() == EXPECTED_MD5_GITHUB # noqa: PTH123, S101, S324, SIM115 diff --git a/src/data/test_process_i2map.py b/src/data/test_process_i2map.py index 26c2479..533ee52 100644 --- a/src/data/test_process_i2map.py +++ b/src/data/test_process_i2map.py @@ -30,9 +30,9 @@ def test_process_i2map(complete_i2map_processing): # but it will alert us if a code change unexpectedly changes the file size. # If code changes are expected to change the file size then we should # update the expected size here. - EXPECTED_SIZE_GITHUB = 63132 - EXPECTED_SIZE_ACT = 63101 - EXPECTED_SIZE_LOCAL = 64637 + EXPECTED_SIZE_GITHUB = 63137 + EXPECTED_SIZE_ACT = 63106 + EXPECTED_SIZE_LOCAL = 64642 if str(proc.args.base_path).startswith("/home/runner"): # The size is different in GitHub Actions, maybe due to different metadata assert nc_file.stat().st_size == EXPECTED_SIZE_GITHUB # noqa: S101 From 26e3147d619e5e03e35a817eaf4d2270a40c53c6 Mon Sep 17 00:00:00 2001 From: Mike McCann Date: Mon, 16 Mar 2026 13:39:17 -0700 Subject: [PATCH 3/6] Update EXPECTED_MD5_GITHUB --- src/data/test_process_dorado.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/test_process_dorado.py b/src/data/test_process_dorado.py index c52bc09..fd4879c 100644 --- a/src/data/test_process_dorado.py +++ b/src/data/test_process_dorado.py @@ -50,7 +50,7 @@ def test_process_dorado(complete_dorado_processing): check_md5 = True if check_md5: # Check that the MD5 hash has not changed - EXPECTED_MD5_GITHUB = "3be10ffae1bcefb4891d7b7fa7f875ba" + EXPECTED_MD5_GITHUB = "228da2af99d854c7ed9f6f3d1bef3ab5" EXPECTED_MD5_ACT = "136e130ac434494ab5eb52ff935a73b8" EXPECTED_MD5_LOCAL = "804250739075ee78e31c6c34101009ff" if str(proc.args.base_path).startswith("/home/runner"): From 612dc3f9d141a57e18f95fa3133cc0345e8ddff5 Mon Sep 17 00:00:00 2001 From: Mike McCann Date: Mon, 16 Mar 2026 14:26:49 -0700 Subject: [PATCH 4/6] Replace plot_biolume() with a working plot_biolume_2column() method. --- src/data/create_products.py | 192 ++++++++++++++++++++++++++---------- src/data/process.py | 2 +- 2 files changed, 139 insertions(+), 55 deletions(-) diff --git a/src/data/create_products.py b/src/data/create_products.py index c4a958f..6bf4e71 100755 --- a/src/data/create_products.py +++ b/src/data/create_products.py @@ -186,6 +186,9 @@ def __init__( # noqa: PLR0913 "hs2_fl676": "algae", "ecopuck_chla": "algae", "biolume_avg_biolume": "cividis", + "proxy_adinos": "algae", + "proxy_diatoms": "Purples", + "proxy_hdinos": "Blues", } def _open_ds(self): @@ -357,6 +360,40 @@ def _get_lrauv_plot_variables(self) -> list: ("wetlabsubat_average_bioluminescence", "log"), ] + def _get_dorado_biolume_variables(self) -> list: + """Get Dorado-specific bioluminescence plot variables for plot_biolume_2column().""" + return [ + ("density", "linear"), + ("biolume_avg_biolume", "log"), + ("biolume_intflash", "linear"), + ("biolume_bg_biolume", "log"), + ("biolume_nbflash_high", "linear"), + ("biolume_nbflash_low", "linear"), + ("biolume_proxy_diatoms", "linear"), + ("biolume_proxy_adinos", "linear"), + ("biolume_proxy_hdinos", "linear"), + ] + + def _get_lrauv_biolume_variables(self) -> list: + """Get LRAUV-specific bioluminescence plot variables for plot_biolume_2column().""" + return [ + ("density", "linear"), + ("wetlabsubat_average_bioluminescence", "log"), + ("wetlabsubat_intflash", "linear"), + ("wetlabsubat_bg_biolume", "log"), + ("wetlabsubat_nbflash_high", "linear"), + ("wetlabsubat_nbflash_low", "linear"), + ("wetlabsubat_proxy_diatoms", "linear"), + ("wetlabsubat_proxy_adinos", "linear"), + ("wetlabsubat_proxy_hdinos", "linear"), + ] + + def _get_biolume_plot_variables(self) -> list: + """Get vehicle-specific bioluminescence variables for plot_biolume_2column().""" + if self._is_lrauv(): + return self._get_lrauv_biolume_variables() + return self._get_dorado_biolume_variables() + def _grid_dims(self) -> tuple: # From Matlab code in plot_sections.m: # auvnav positions are too fine for distance calculations, they resolve @@ -1116,12 +1153,16 @@ def _plot_var_scatter( # noqa: C901, PLR0912, PLR0913, PLR0915 max_val = abs(tick_values).max() # Threshold constants for tick label formatting + VERY_LARGE_VALUE_THRESHOLD = 9_999 LARGE_VALUE_THRESHOLD = 100 LARGE_RANGE_THRESHOLD = 10 MEDIUM_VALUE_THRESHOLD = 10 # Choose format based on magnitude and range - if max_val >= LARGE_VALUE_THRESHOLD or value_range >= LARGE_RANGE_THRESHOLD: + if max_val > VERY_LARGE_VALUE_THRESHOLD: + # Very large values (e.g. flash intensity): use scientific notation + labels = [f"{x:.3g}" for x in tick_values] + elif max_val >= LARGE_VALUE_THRESHOLD or value_range >= LARGE_RANGE_THRESHOLD: # Large values or large range: use integers labels = [f"{int(round(x))}" for x in tick_values] elif max_val >= MEDIUM_VALUE_THRESHOLD: @@ -1388,12 +1429,16 @@ def _plot_var_contour( # noqa: C901, PLR0912, PLR0913, PLR0915 max_val = abs(tick_values).max() # Threshold constants for tick label formatting + VERY_LARGE_VALUE_THRESHOLD = 9_999 LARGE_VALUE_THRESHOLD = 100 LARGE_RANGE_THRESHOLD = 10 MEDIUM_VALUE_THRESHOLD = 10 # Choose format based on magnitude and range - if max_val >= LARGE_VALUE_THRESHOLD or value_range >= LARGE_RANGE_THRESHOLD: + if max_val > VERY_LARGE_VALUE_THRESHOLD: + # Very large values (e.g. flash intensity): use scientific notation + labels = [f"{x:.3g}" for x in tick_values] + elif max_val >= LARGE_VALUE_THRESHOLD or value_range >= LARGE_RANGE_THRESHOLD: # Large values or large range: use integers labels = [f"{int(round(x))}" for x in tick_values] elif max_val >= MEDIUM_VALUE_THRESHOLD: @@ -1570,58 +1615,88 @@ def plot_2column(self) -> str: # noqa: C901, PLR0912, PLR0915 self.logger.info("Saved 2column plot to %s", output_file) return str(output_file) - def plot_biolume(self) -> str: # noqa: C901, PLR0912 - """Create bioluminescence plot showing raw signal and proxy variables""" + def plot_biolume_2column(self) -> str: # noqa: C901, PLR0912, PLR0915 + """Create 2-column bioluminescence plot with map, sigma-t, and all biolume proxy variables. + + Layout (5 rows x 2 columns, column-major order): + (0,0) track map (0,1) density (sigma-t) + (1,0) avg_biolume (1,1) intflash + (2,0) bg_biolume (2,1) nbflash_high + (3,0) nbflash_low (3,1) proxy_diatoms + (4,0) proxy_adinos(4,1) proxy_hdinos + """ # Skip plotting in pytest environment - too many prerequisites for CI if "pytest" in sys.modules: - self.logger.info("Skipping plot_biolume in pytest environment") + self.logger.info("Skipping plot_biolume_2column in pytest environment") return None self._open_ds() - # Check if biolume variables exist - biolume_vars = [v for v in self.ds.variables if v.startswith("biolume_")] - if not biolume_vars: - self.logger.warning("No biolume variables found in dataset") + # Early return if no biolume variables present + biolume_prefix = "wetlabsubat_" if self._is_lrauv() else "biolume_" + if not any(v.startswith(biolume_prefix) for v in self.ds.variables): + self.logger.warning( + "No %s* variables found in dataset, skipping plot_biolume_2column", + biolume_prefix, + ) return None idist, iz, distnav = self._grid_dims() - if idist is None or iz is None or distnav is None: - self.logger.warning("Skipping plot_biolume due to missing gridding dimensions") + if idist.size == 0 or iz.size == 0 or distnav.size == 0: + self.logger.warning("Skipping plot_biolume_2column due to missing gridding dimensions") return None + + fig, ax = plt.subplots(nrows=5, ncols=2, figsize=(18, 10)) + plt.subplots_adjust(hspace=0.15, wspace=0.04, left=0.05, right=0.97, top=0.96, bottom=0.06) + + # Compute density (sigma-t) if not already present + best_ctd = None + if self._is_lrauv(): + self.logger.info("LRAUV mission detected for biolume 2column plot") + self._compute_density_lrauv() + else: + self.logger.info("Dorado mission detected for biolume 2column plot") + best_ctd = self._get_best_ctd() + self._compute_density(best_ctd) + + # Create map in top-left subplot (row=0, col=0), aligned with ax[1,0] below + self._plot_track_map(ax[0, 0], ax[1, 0]) + + # Gulper locations (Dorado only) + if self.auv_name and self.mission: + try: + gulper_locations = self._get_gulper_locations(distnav) + except FileNotFoundError as e: + self.logger.warning("Error retrieving gulper locations: %s", e) # noqa: TRY400 + gulper_locations = {} + else: + gulper_locations = {} + try: profile_bottoms = self._profile_bottoms(distnav) except (TypeError, ValueError) as e: self.logger.warning("Error computing profile bottoms: %s", e) # noqa: TRY400 profile_bottoms = None - # Create figure with subplots for biolume variables - num_plots = min(len(biolume_vars), 6) # Limit to 6 most important variables - fig, ax = plt.subplots(nrows=num_plots, ncols=1, figsize=(18, 12)) - if num_plots == 1: - ax = [ax] - fig.tight_layout(rect=[0, 0.06, 0.99, 0.96]) - - # Priority order for biolume variables to plot - priority_vars = [ - "biolume_avg_biolume", - "biolume_bg_biolume", - "biolume_nbflash_high", - "biolume_nbflash_low", - "biolume_proxy_diatoms", - "biolume_proxy_adinos", - ] + try: + bottom_depths = self._get_bathymetry( + self.ds.cf["longitude"].to_numpy(), + self.ds.cf["latitude"].to_numpy(), + ) + except ValueError as e: # noqa: BLE001 + self.logger.warning("Error retrieving bathymetry: %s", e) # noqa: TRY400 + bottom_depths = None + + row = 1 # Start at row 1, col 0 (below the map) + col = 0 - vars_to_plot = [] - for pvar in priority_vars: - if pvar in self.ds: - scale = "log" if "avg_biolume" in pvar or "bg_biolume" in pvar else "linear" - vars_to_plot.append((pvar, scale)) - if len(vars_to_plot) >= num_plots: - break + plot_variables = self._get_biolume_plot_variables() - for i, (var, scale) in enumerate(vars_to_plot): + for var, scale in plot_variables: self.logger.info("Plotting %s...", var) + if var not in self.ds: + self.logger.warning("%s not in dataset, plotting with no data", var) + self._plot_var( var, idist, @@ -1629,34 +1704,43 @@ def plot_biolume(self) -> str: # noqa: C901, PLR0912 distnav, fig, ax, - i, - 0, + row, + col, profile_bottoms, scale=scale, + gulper_locations=gulper_locations, + bottom_depths=bottom_depths, + best_ctd=best_ctd, ) - if i != num_plots - 1: - ax[i].get_xaxis().set_visible(False) + if row != 4: # noqa: PLR2004 + ax[row, col].get_xaxis().set_visible(False) else: - ax[i].set_xlabel("Distance along track (km)") + ax[row, col].set_xlabel("Distance along track (km)") - # Add title to the figure - title = f"{self.auv_name} {self.mission} - Bioluminescence" - if "title" in self.ds.attrs: - title = f"{self.ds.attrs['title']} - Bioluminescence" - fig.suptitle(title, fontsize=12, fontweight="bold") + # Column-major order: fill down first column, then second column + if row == 4 and col == 0: # noqa: PLR2004 + row = 0 + col = 1 + else: + row += 1 # Save plot to file - images_dir = Path(BASE_PATH, self.auv_name, MISSIONIMAGES, self.mission) - Path(images_dir).mkdir(parents=True, exist_ok=True) - - output_file = Path( - images_dir, - f"{self.auv_name}_{self.mission}_{self.freq}_biolume.png", - ) + if self._is_lrauv(): + netcdfs_dir = Path(BASE_LRAUV_PATH, f"{Path(self.log_file).parent}") + output_file = Path( + netcdfs_dir, f"{Path(self.log_file).stem}_{self.freq}_2column_biolume.png" + ) + else: + images_dir = Path(BASE_PATH, self.auv_name, MISSIONIMAGES, self.mission) + Path(images_dir).mkdir(parents=True, exist_ok=True) + output_file = Path( + images_dir, f"{self.auv_name}_{self.mission}_{self.freq}_2column_biolume.png" + ) plt.savefig(output_file, dpi=100, bbox_inches="tight") + plt.show() plt.close(fig) - self.logger.info("Saved biolume plot to %s", output_file) + self.logger.info("Saved biolume 2column plot to %s", output_file) return str(output_file) def _get_best_ctd(self) -> str: @@ -1905,7 +1989,7 @@ def process_command_line(self): cp.process_command_line() p_start = time.time() cp.plot_2column() - cp.plot_biolume() + cp.plot_biolume_2column() if cp.mission and cp.auv_name: cp.gulper_odv() cp.logger.info("Time to process: %.2f seconds", (time.time() - p_start)) diff --git a/src/data/process.py b/src/data/process.py index 1483914..2b35ece 100755 --- a/src/data/process.py +++ b/src/data/process.py @@ -699,7 +699,7 @@ def create_products(self, mission: str = None, log_file: str = None) -> None: cp.logger.setLevel(self._log_levels[self.config["verbose"]]) cp.logger.addHandler(self.log_handler) - cp.plot_biolume() + cp.plot_biolume_2column() cp.plot_2column() if mission and "dorado" in cp.auv_name.lower(): cp.gulper_odv() From c8e272bf6cd966f241d1a9203e6cd7a06729a15b Mon Sep 17 00:00:00 2001 From: Mike McCann Date: Mon, 16 Mar 2026 15:06:16 -0700 Subject: [PATCH 5/6] Update EXPECTED_MD5_ACT. --- src/data/test_process_dorado.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/test_process_dorado.py b/src/data/test_process_dorado.py index fd4879c..8b6be24 100644 --- a/src/data/test_process_dorado.py +++ b/src/data/test_process_dorado.py @@ -51,7 +51,7 @@ def test_process_dorado(complete_dorado_processing): if check_md5: # Check that the MD5 hash has not changed EXPECTED_MD5_GITHUB = "228da2af99d854c7ed9f6f3d1bef3ab5" - EXPECTED_MD5_ACT = "136e130ac434494ab5eb52ff935a73b8" + EXPECTED_MD5_ACT = "1ca5906b45abd6439ef85da14ea1c5a5" EXPECTED_MD5_LOCAL = "804250739075ee78e31c6c34101009ff" if str(proc.args.base_path).startswith("/home/runner"): # The MD5 hash is different in GitHub Actions, maybe due to different metadata From e80a55880eef20fd030442e6f4a0b1018d88c05a Mon Sep 17 00:00:00 2001 From: Mike McCann Date: Mon, 16 Mar 2026 15:07:51 -0700 Subject: [PATCH 6/6] Add _plot_nighttime_indicator() for biolume plot. --- src/data/create_products.py | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/data/create_products.py b/src/data/create_products.py index 6bf4e71..fd206fb 100755 --- a/src/data/create_products.py +++ b/src/data/create_products.py @@ -394,6 +394,71 @@ def _get_biolume_plot_variables(self) -> list: return self._get_lrauv_biolume_variables() return self._get_dorado_biolume_variables() + def _plot_nighttime_indicator( + self, + fig: matplotlib.figure.Figure, + ref_ax: matplotlib.axes.Axes, + distnav: xr.DataArray, + ) -> None: + """Draw a thin nighttime indicator strip just above ref_ax. + + Fills black bars over the distance axis wherever the sun is below the horizon. + Uses the figure's existing white space without adjusting other subplot dimensions. + """ + from datetime import UTC # noqa: PLC0415 + + try: + from pysolar import solar # noqa: PLC0415 + except ImportError: + self.logger.warning("pysolar not available; skipping nighttime indicator") + return + + times = pd.to_datetime(self.ds.cf["time"].to_numpy()) + lats = self.ds.cf["latitude"].to_numpy() + lons = self.ds.cf["longitude"].to_numpy() + dist_km = distnav.to_numpy() / 1000.0 + + # Subsample for speed (pysolar is slow) + n = len(times) + step = max(1, n // 500) + idx = np.arange(0, n, step) + + is_night = np.zeros(len(idx), dtype=bool) + for k, i in enumerate(idx): + try: + alt = solar.get_altitude( + float(lats[i]), + float(lons[i]), + times[i].to_pydatetime().replace(tzinfo=UTC), + ) + is_night[k] = alt < 0 + except Exception: # noqa: BLE001 + self.logger.debug("pysolar altitude failed at index %d", i) + + sub_dist = dist_km[idx] + + # Create a thin axes above ref_ax using figure-normalized coordinates + bbox = ref_ax.get_position() + indicator_height = 0.004 # ~4 px at 100 dpi on a 10-inch-tall figure + gap = 0.013 + night_ax = fig.add_axes([bbox.x0, bbox.y1 + gap, bbox.width, indicator_height]) + night_ax.set_xlim(sub_dist[0], ref_ax.get_xlim()[1]) + night_ax.set_ylim(0, 1) + night_ax.axis("off") + + # Fill contiguous nighttime spans as black bars + in_night = False + night_start = None + for k in range(len(sub_dist)): + if is_night[k] and not in_night: + night_start = sub_dist[k] + in_night = True + elif not is_night[k] and in_night: + night_ax.axvspan(night_start, sub_dist[k - 1], color="black", lw=0) + in_night = False + if in_night: + night_ax.axvspan(night_start, sub_dist[-1], color="black", lw=0) + def _grid_dims(self) -> tuple: # From Matlab code in plot_sections.m: # auvnav positions are too fine for distance calculations, they resolve @@ -844,6 +909,9 @@ def _wrap_label_text(self, text: str, max_chars: int = 20) -> str: # Pattern 2: Break after 'coeff' before 3-digit number (particulatebackscatteringcoeff470nm) text = re.sub(r"(coeff)(\d{3})", r"\1_\2", text) + # Pattern 3: Break after 'High intensity' or 'Low intensity' in flash labels + text = re.sub(r"\b((?:High|Low) intensity)\s+", r"\1\n", text) + # Split on underscores to find natural break points parts = text.split("_") lines = [] @@ -1724,6 +1792,9 @@ def plot_biolume_2column(self) -> str: # noqa: C901, PLR0912, PLR0915 else: row += 1 + # Draw nighttime indicator strip just above ax[0,1] now that its x-limits are final + self._plot_nighttime_indicator(fig, ax[0, 1], distnav) + # Save plot to file if self._is_lrauv(): netcdfs_dir = Path(BASE_LRAUV_PATH, f"{Path(self.log_file).parent}")