From fd27de4dfce1d88126a33d8d83986852e70f4d2f Mon Sep 17 00:00:00 2001 From: Chain-Frost Date: Tue, 4 Nov 2025 14:45:49 +0800 Subject: [PATCH 1/8] Add data dictionary export for POMM peak reports --- ryan_library/classes/column_definitions.py | 310 +++++++++++++++++++++ ryan_library/scripts/pomm_utils.py | 130 ++++++++- 2 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 ryan_library/classes/column_definitions.py diff --git a/ryan_library/classes/column_definitions.py b/ryan_library/classes/column_definitions.py new file mode 100644 index 00000000..61791cc3 --- /dev/null +++ b/ryan_library/classes/column_definitions.py @@ -0,0 +1,310 @@ +"""Central registry for column descriptions used across exports.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar, Iterable, Mapping + + +@dataclass(frozen=True, slots=True) +class ColumnDefinition: + """Describe the intent of a DataFrame column.""" + + name: str + description: str + value_type: str | None = None + + +class ColumnMetadataRegistry: + """Registry providing consistent column descriptions across exports.""" + + _BASE_DEFINITIONS: ClassVar[Mapping[str, ColumnDefinition]] + _SHEET_SPECIFIC_DEFINITIONS: ClassVar[Mapping[str, Mapping[str, ColumnDefinition]]] + + def __init__( + self, + base_definitions: Mapping[str, ColumnDefinition] | None = None, + sheet_specific: Mapping[str, Mapping[str, ColumnDefinition]] | None = None, + ) -> None: + self._base_definitions: Mapping[str, ColumnDefinition] = base_definitions or {} + self._sheet_specific: Mapping[str, Mapping[str, ColumnDefinition]] = sheet_specific or {} + + def definition_for(self, column_name: str, sheet_name: str | None = None) -> ColumnDefinition: + """Return a :class:`ColumnDefinition` for ``column_name``. + + Sheet-specific definitions override base definitions. If no definition + exists a placeholder entry is returned so that missing descriptions are + easy to spot in the exported workbook. + """ + + if sheet_name and sheet_name in self._sheet_specific: + sheet_def: Mapping[str, ColumnDefinition] = self._sheet_specific[sheet_name] + if column_name in sheet_def: + return sheet_def[column_name] + + if column_name in self._base_definitions: + return self._base_definitions[column_name] + + return ColumnDefinition( + name=column_name, + description=f"TODO: add description for '{column_name}'.", + value_type=None, + ) + + def iter_definitions(self, column_names: Iterable[str], sheet_name: str | None = None) -> list[ColumnDefinition]: + """Return definitions for ``column_names`` preserving order.""" + + return [self.definition_for(column_name=col, sheet_name=sheet_name) for col in column_names] + + @classmethod + def default(cls) -> "ColumnMetadataRegistry": + """Return the default registry instance.""" + + if not hasattr(cls, "_INSTANCE"): + base_definitions = { + "AbsMax": ColumnDefinition( + name="AbsMax", + description="Absolute maximum magnitude observed within the event time-series.", + value_type="float", + ), + "SignedAbsMax": ColumnDefinition( + name="SignedAbsMax", + description="Absolute maximum magnitude preserving the original sign (positive/negative).", + value_type="float", + ), + "Max": ColumnDefinition( + name="Max", + description="Maximum positive value in the event window.", + value_type="float", + ), + "Min": ColumnDefinition( + name="Min", + description="Minimum value in the event window.", + value_type="float", + ), + "Tmax": ColumnDefinition( + name="Tmax", + description="Time (hours) at which the maximum value occurs.", + value_type="float", + ), + "Tmin": ColumnDefinition( + name="Tmin", + description="Time (hours) at which the minimum value occurs.", + value_type="float", + ), + "Location": ColumnDefinition( + name="Location", + description="Model result location identifier from the POMM file.", + value_type="string", + ), + "Type": ColumnDefinition( + name="Type", + description="Quantity type (for example Flow, Water Level, Velocity).", + value_type="string", + ), + "aep_text": ColumnDefinition( + name="aep_text", + description="Annual exceedance probability label parsed from the run code (e.g. '01p').", + value_type="string", + ), + "aep_numeric": ColumnDefinition( + name="aep_numeric", + description="Annual exceedance probability represented as a numeric percentage.", + value_type="float", + ), + "duration_text": ColumnDefinition( + name="duration_text", + description="Storm duration label parsed from the run code (e.g. '03hr').", + value_type="string", + ), + "duration_numeric": ColumnDefinition( + name="duration_numeric", + description="Storm duration represented as a numeric value (hours).", + value_type="float", + ), + "tp_text": ColumnDefinition( + name="tp_text", + description="Temporal pattern identifier parsed from the run code.", + value_type="string", + ), + "tp_numeric": ColumnDefinition( + name="tp_numeric", + description="Temporal pattern identifier represented as a numeric value.", + value_type="int", + ), + "trim_runcode": ColumnDefinition( + name="trim_runcode", + description="Run code without the AEP component, used to group comparable scenarios.", + value_type="string", + ), + "internalName": ColumnDefinition( + name="internalName", + description="Full run code derived from the source file name.", + value_type="string", + ), + "file": ColumnDefinition( + name="file", + description="Name of the source CSV file that contributed the row.", + value_type="string", + ), + "path": ColumnDefinition( + name="path", + description="Absolute path to the source CSV file.", + value_type="string", + ), + "rel_path": ColumnDefinition( + name="rel_path", + description="Source CSV path relative to the working directory when processing.", + value_type="string", + ), + "directory_path": ColumnDefinition( + name="directory_path", + description="Absolute directory containing the source CSV file.", + value_type="string", + ), + "rel_directory": ColumnDefinition( + name="rel_directory", + description="Directory containing the source CSV file relative to the working directory.", + value_type="string", + ), + "R01": ColumnDefinition( + name="R01", + description="First segment of the run code (often the model identifier).", + value_type="string", + ), + "R02": ColumnDefinition( + name="R02", + description="Second segment of the run code (commonly spatial resolution).", + value_type="string", + ), + "R03": ColumnDefinition( + name="R03", + description="Third segment of the run code (commonly the AEP label).", + value_type="string", + ), + "R04": ColumnDefinition( + name="R04", + description="Fourth segment of the run code (commonly the storm duration).", + value_type="string", + ), + "R05": ColumnDefinition( + name="R05", + description="Fifth segment of the run code (commonly the run number).", + value_type="string", + ), + "MedianAbsMax": ColumnDefinition( + name="MedianAbsMax", + description="Median of absolute maxima across temporal patterns for the group.", + value_type="float", + ), + "median_duration": ColumnDefinition( + name="median_duration", + description="Duration associated with the median absolute maximum.", + value_type="string", + ), + "median_TP": ColumnDefinition( + name="median_TP", + description="Temporal pattern associated with the median absolute maximum.", + value_type="string", + ), + "mean_including_zeroes": ColumnDefinition( + name="mean_including_zeroes", + description="Mean of the statistic including zero values within the group.", + value_type="float", + ), + "mean_excluding_zeroes": ColumnDefinition( + name="mean_excluding_zeroes", + description="Mean of the statistic excluding zero values within the group.", + value_type="float", + ), + "mean_PeakFlow": ColumnDefinition( + name="mean_PeakFlow", + description="Peak flow corresponding to the mean storm for the group.", + value_type="float", + ), + "mean_Duration": ColumnDefinition( + name="mean_Duration", + description="Duration associated with the mean storm for the group.", + value_type="string", + ), + "mean_TP": ColumnDefinition( + name="mean_TP", + description="Temporal pattern associated with the mean storm for the group.", + value_type="string", + ), + "low": ColumnDefinition( + name="low", + description="Minimum statistic encountered across all temporal patterns in the group.", + value_type="float", + ), + "high": ColumnDefinition( + name="high", + description="Maximum statistic encountered across all temporal patterns in the group.", + value_type="float", + ), + "count": ColumnDefinition( + name="count", + description="Number of rows contributing to the median statistics for the selected duration.", + value_type="int", + ), + "count_bin": ColumnDefinition( + name="count_bin", + description="Total number of records considered across all durations for the group.", + value_type="int", + ), + "mean_storm_is_median_storm": ColumnDefinition( + name="mean_storm_is_median_storm", + description="Indicates whether the mean storm matches the median storm selection.", + value_type="boolean", + ), + "aep_dur_bin": ColumnDefinition( + name="aep_dur_bin", + description="Count of records in the original AEP/Duration/Location/Type/run combination.", + value_type="int", + ), + "aep_bin": ColumnDefinition( + name="aep_bin", + description="Count of records in the original AEP/Location/Type/run combination.", + value_type="int", + ), + } + + sheet_specific = { + "aep-dur-max": { + "AbsMax": ColumnDefinition( + name="AbsMax", + description="Peak magnitude selected for the AEP/Duration/Location/Type/run grouping.", + value_type="float", + ), + }, + "aep-max": { + "AbsMax": ColumnDefinition( + name="AbsMax", + description="Peak magnitude selected for the AEP/Location/Type/run grouping.", + value_type="float", + ), + }, + "aep-dur-med": { + "MedianAbsMax": ColumnDefinition( + name="MedianAbsMax", + description="Median magnitude for the specific AEP/Duration/Location/Type/run grouping.", + value_type="float", + ), + }, + "aep-med-max": { + "MedianAbsMax": ColumnDefinition( + name="MedianAbsMax", + description="Median magnitude selected as the maximum median per AEP/Location/Type/run grouping.", + value_type="float", + ), + }, + } + + cls._INSTANCE = cls( + base_definitions=base_definitions, + sheet_specific=sheet_specific, + ) + return cls._INSTANCE + + +__all__ = ["ColumnDefinition", "ColumnMetadataRegistry"] diff --git a/ryan_library/scripts/pomm_utils.py b/ryan_library/scripts/pomm_utils.py index 822d50ef..a7b4612e 100644 --- a/ryan_library/scripts/pomm_utils.py +++ b/ryan_library/scripts/pomm_utils.py @@ -3,12 +3,15 @@ from pathlib import Path from multiprocessing import Pool -from collections.abc import Collection, Iterable +from collections.abc import Collection, Iterable, Mapping +from datetime import datetime, timezone +from importlib.metadata import PackageNotFoundError, version from typing import Any import pandas as pd from loguru import logger +from ryan_library.classes.column_definitions import ColumnMetadataRegistry from ryan_library.functions.pandas.median_calc import median_calc from ryan_library.functions.file_utils import ( @@ -25,6 +28,8 @@ NAType = type(pd.NA) +DATA_DICTIONARY_SHEET_NAME: str = "data-dictionary" + def collect_files( paths_to_process: Iterable[Path], @@ -234,9 +239,27 @@ def save_to_excel( aggregated_df: pd.DataFrame, output_path: Path, include_pomm: bool = True, + timestamp: str | None = None, ) -> None: """Save peak DataFrames to an Excel file.""" logger.info(f"Output path: {output_path}") + registry: ColumnMetadataRegistry = ColumnMetadataRegistry.default() + metadata_rows: Mapping[str, str] = _build_metadata_rows( + timestamp=timestamp, + include_pomm=include_pomm, + aep_dur_max=aep_dur_max, + aep_max=aep_max, + aggregated_df=aggregated_df, + ) + data_dictionary_df: pd.DataFrame = _build_data_dictionary( + registry=registry, + sheet_frames={ + "aep-dur-max": aep_dur_max, + "aep-max": aep_max, + }, + metadata_rows=metadata_rows, + ) + with pd.ExcelWriter(output_path) as writer: aep_dur_max.to_excel( excel_writer=writer, @@ -252,10 +275,113 @@ def save_to_excel( index=False, merge_cells=False, ) + data_dictionary_df.to_excel( + excel_writer=writer, + sheet_name=DATA_DICTIONARY_SHEET_NAME, + index=False, + merge_cells=False, + ) logger.info(f"Peak data exported to {output_path}") +def _build_metadata_rows( + timestamp: str | None, + include_pomm: bool, + aep_dur_max: pd.DataFrame, + aep_max: pd.DataFrame, + aggregated_df: pd.DataFrame, +) -> Mapping[str, str]: + """Return ordered metadata rows for the data dictionary sheet.""" + + generated_at: str = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + metadata: dict[str, str] = { + "Generated at": generated_at, + "Filename timestamp": timestamp if timestamp else "not supplied", + "Generator module": __name__, + "ryan_functions version": _resolve_package_version("ryan_functions"), + "Include POMM sheet": "Yes" if include_pomm else "No", + "aep-dur-max rows": str(len(aep_dur_max)), + "aep-max rows": str(len(aep_max)), + } + + if include_pomm: + metadata["POMM rows"] = str(len(aggregated_df)) + + if "directory_path" in aggregated_df.columns: + try: + directories_series = aggregated_df["directory_path"].dropna() + except AttributeError: + directories_series = pd.Series(dtype="string") + unique_directories = sorted({str(Path(dir_value)) for dir_value in directories_series.unique()}) + if unique_directories: + metadata["Source directories"] = "\n".join(unique_directories) + + return metadata + + +def _resolve_package_version(package_name: str) -> str: + """Return the installed version for ``package_name`` if available.""" + + try: + return version(package_name) + except PackageNotFoundError: + return "unknown" + + +def _build_data_dictionary( + registry: ColumnMetadataRegistry, + sheet_frames: Mapping[str, pd.DataFrame], + metadata_rows: Mapping[str, str], +) -> pd.DataFrame: + """Build the DataFrame backing the data dictionary worksheet.""" + + rows: list[dict[str, str]] = [] + for key, value in metadata_rows.items(): + rows.append( + { + "sheet": "metadata", + "column": key, + "description": value, + "value_type": "metadata", + "pandas_dtype": "", + } + ) + + for sheet_name, frame in sheet_frames.items(): + columns: list[str] = list(frame.columns) + if not columns: + rows.append( + { + "sheet": sheet_name, + "column": "", + "description": "Sheet exported without any columns. Review upstream processing.", + "value_type": "", + "pandas_dtype": "", + } + ) + continue + + dtype_map: dict[str, str] = {column: str(dtype) for column, dtype in frame.dtypes.items()} + definitions = registry.iter_definitions(columns, sheet_name=sheet_name) + + for column_name, definition in zip(columns, definitions): + rows.append( + { + "sheet": sheet_name, + "column": column_name, + "description": definition.description, + "value_type": definition.value_type or "", + "pandas_dtype": dtype_map.get(column_name, ""), + } + ) + + return pd.DataFrame( + rows, + columns=["sheet", "column", "description", "value_type", "pandas_dtype"], + ) + + def save_peak_report( aggregated_df: pd.DataFrame, script_directory: Path, @@ -276,6 +402,7 @@ def save_peak_report( aggregated_df=aggregated_df, output_path=output_path, include_pomm=include_pomm, + timestamp=timestamp, ) logger.info(f"Completed peak report export to {output_path}") logger.info(f"Completed peak report export to {output_path}") @@ -489,6 +616,7 @@ def save_peak_report_median( aggregated_df=aggregated_df, output_path=output_path, include_pomm=include_pomm, + timestamp=timestamp, ) logger.info(f"Completed median peak report export to {output_path}") logger.info(f"Completed median peak report export to {output_path}") From 64746a25932b7e1d32e36e515fd8cabb7922811c Mon Sep 17 00:00:00 2001 From: Chain-Frost Date: Tue, 4 Nov 2025 15:00:36 +0800 Subject: [PATCH 2/8] Add mean peak workflow and fix mean stats --- .../TUFLOW-python/POMM-mean-max-aep-dur.py | 43 +++++ ryan_library/scripts/pomm_max_items.py | 46 ++++- ryan_library/scripts/pomm_utils.py | 168 +++++++++++++++++- 3 files changed, 245 insertions(+), 12 deletions(-) create mode 100644 ryan-scripts/TUFLOW-python/POMM-mean-max-aep-dur.py diff --git a/ryan-scripts/TUFLOW-python/POMM-mean-max-aep-dur.py b/ryan-scripts/TUFLOW-python/POMM-mean-max-aep-dur.py new file mode 100644 index 00000000..69efaa1a --- /dev/null +++ b/ryan-scripts/TUFLOW-python/POMM-mean-max-aep-dur.py @@ -0,0 +1,43 @@ +# ryan-scripts\TUFLOW-python\POMM-mean-max-aep-dur.py + +from pathlib import Path +import os + +from ryan_library.scripts.pomm_max_items import run_mean_peak_report +from ryan_library.scripts.wrapper_utils import ( + change_working_directory, + print_library_version, +) + +# Toggle to include the combined POMM sheet in the Excel export. +INCLUDE_POMM: bool = False + +# Update this tuple to restrict processing to specific PO/Location values. +# Leave empty to include every location found in the POMM files. +LOCATIONS_TO_INCLUDE: tuple[str, ...] = () + + +def main() -> None: + """Wrapper script for mean peak reporting.""" + + print_library_version() + console_log_level = "INFO" # or "DEBUG" + script_directory: Path = Path(__file__).absolute().parent + + locations_to_include: tuple[str, ...] | None = LOCATIONS_TO_INCLUDE or None + + if not change_working_directory(target_dir=script_directory): + return + run_mean_peak_report( + script_directory=script_directory, + log_level=console_log_level, + include_pomm=INCLUDE_POMM, + locations_to_include=locations_to_include, + ) + print() + print_library_version() + + +if __name__ == "__main__": + main() + os.system("PAUSE") diff --git a/ryan_library/scripts/pomm_max_items.py b/ryan_library/scripts/pomm_max_items.py index abe9a379..c739c6a4 100644 --- a/ryan_library/scripts/pomm_max_items.py +++ b/ryan_library/scripts/pomm_max_items.py @@ -1,6 +1,6 @@ # ryan_library/scripts/pomm_max_items.py -from collections.abc import Collection +from collections.abc import Collection, Callable from loguru import logger from pathlib import Path from datetime import datetime @@ -8,6 +8,7 @@ from ryan_library.scripts.pomm_utils import ( aggregated_from_paths, + save_peak_report_mean, save_peak_report_median, ) from ryan_library.functions.loguru_helpers import setup_logger @@ -22,13 +23,14 @@ def run_peak_report(script_directory: Path | None = None) -> None: run_median_peak_report() -def run_median_peak_report( +def _run_peak_report( script_directory: Path | None = None, log_level: str = "INFO", include_pomm: bool = True, locations_to_include: Collection[str] | None = None, + save_report: Callable[..., None] | None = None, ) -> None: - """Locate and process POMM files and export median-based peak values.""" + """Core implementation for running a peak report workflow.""" setup_logger(console_log_level=log_level) logger.info(f"Current Working Directory: {Path.cwd()}") @@ -53,9 +55,45 @@ def run_median_peak_report( return timestamp: str = datetime.now().strftime(format="%Y%m%d-%H%M") - save_peak_report_median( + if save_report is None: + save_report = save_peak_report_median + save_report( aggregated_df=aggregated_df, script_directory=script_directory, timestamp=timestamp, include_pomm=include_pomm, ) + + +def run_median_peak_report( + script_directory: Path | None = None, + log_level: str = "INFO", + include_pomm: bool = True, + locations_to_include: Collection[str] | None = None, +) -> None: + """Locate and process POMM files and export median-based peak values.""" + + _run_peak_report( + script_directory=script_directory, + log_level=log_level, + include_pomm=include_pomm, + locations_to_include=locations_to_include, + save_report=save_peak_report_median, + ) + + +def run_mean_peak_report( + script_directory: Path | None = None, + log_level: str = "INFO", + include_pomm: bool = True, + locations_to_include: Collection[str] | None = None, +) -> None: + """Locate and process POMM files and export mean-based peak values.""" + + _run_peak_report( + script_directory=script_directory, + log_level=log_level, + include_pomm=include_pomm, + locations_to_include=locations_to_include, + save_report=save_peak_report_mean, + ) diff --git a/ryan_library/scripts/pomm_utils.py b/ryan_library/scripts/pomm_utils.py index a7b4612e..a3df48cc 100644 --- a/ryan_library/scripts/pomm_utils.py +++ b/ryan_library/scripts/pomm_utils.py @@ -240,6 +240,8 @@ def save_to_excel( output_path: Path, include_pomm: bool = True, timestamp: str | None = None, + aep_dur_sheet_name: str = "aep-dur-max", + aep_sheet_name: str = "aep-max", ) -> None: """Save peak DataFrames to an Excel file.""" logger.info(f"Output path: {output_path}") @@ -250,12 +252,14 @@ def save_to_excel( aep_dur_max=aep_dur_max, aep_max=aep_max, aggregated_df=aggregated_df, + aep_dur_sheet_name=aep_dur_sheet_name, + aep_sheet_name=aep_sheet_name, ) data_dictionary_df: pd.DataFrame = _build_data_dictionary( registry=registry, sheet_frames={ - "aep-dur-max": aep_dur_max, - "aep-max": aep_max, + aep_dur_sheet_name: aep_dur_max, + aep_sheet_name: aep_max, }, metadata_rows=metadata_rows, ) @@ -263,11 +267,16 @@ def save_to_excel( with pd.ExcelWriter(output_path) as writer: aep_dur_max.to_excel( excel_writer=writer, - sheet_name="aep-dur-max", + sheet_name=aep_dur_sheet_name, + index=False, + merge_cells=False, + ) + aep_max.to_excel( + excel_writer=writer, + sheet_name=aep_sheet_name, index=False, merge_cells=False, ) - aep_max.to_excel(excel_writer=writer, sheet_name="aep-max", index=False, merge_cells=False) if include_pomm: aggregated_df.to_excel( excel_writer=writer, @@ -291,6 +300,8 @@ def _build_metadata_rows( aep_dur_max: pd.DataFrame, aep_max: pd.DataFrame, aggregated_df: pd.DataFrame, + aep_dur_sheet_name: str, + aep_sheet_name: str, ) -> Mapping[str, str]: """Return ordered metadata rows for the data dictionary sheet.""" @@ -301,8 +312,8 @@ def _build_metadata_rows( "Generator module": __name__, "ryan_functions version": _resolve_package_version("ryan_functions"), "Include POMM sheet": "Yes" if include_pomm else "No", - "aep-dur-max rows": str(len(aep_dur_max)), - "aep-max rows": str(len(aep_max)), + f"{aep_dur_sheet_name} rows": str(len(aep_dur_max)), + f"{aep_sheet_name} rows": str(len(aep_max)), } if include_pomm: @@ -529,6 +540,33 @@ def find_aep_median_max(aep_dur_median: pd.DataFrame) -> pd.DataFrame: df["aep_bin"] = df.groupby(group_cols, observed=True)["MedianAbsMax"].transform("size") idx = df.groupby(group_cols, observed=True)["MedianAbsMax"].idxmax() aep_med_max: pd.DataFrame = df.loc[idx].reset_index(drop=True) + mean_value_columns: list[str] = [ + column + for column in ( + "mean_including_zeroes", + "mean_excluding_zeroes", + "mean_PeakFlow", + "mean_Duration", + "mean_TP", + ) + if column in aep_dur_median.columns + ] + if mean_value_columns: + mean_df: pd.DataFrame = aep_dur_median.copy() + mean_df["_mean_peakflow_numeric"] = pd.to_numeric(mean_df.get("mean_PeakFlow"), errors="coerce") + if mean_df["_mean_peakflow_numeric"].notna().any(): + idx_mean = ( + mean_df[mean_df["_mean_peakflow_numeric"].notna()] + .groupby(group_cols, observed=True)["_mean_peakflow_numeric"] + .idxmax() + ) + merge_columns: list[str] = mean_value_columns.copy() + if "mean_storm_is_median_storm" in aep_dur_median.columns: + merge_columns.append("mean_storm_is_median_storm") + mean_subset: pd.DataFrame = mean_df.loc[idx_mean, group_cols + merge_columns] + aep_med_max = aep_med_max.drop(columns=merge_columns, errors="ignore") + aep_med_max = aep_med_max.merge(mean_subset, on=group_cols, how="left") + mean_df = mean_df.drop(columns=["_mean_peakflow_numeric"], errors="ignore") if not aep_med_max.empty: id_columns: list[str] = ["aep_text", "duration_text", "Location", "Type", "trim_runcode"] mean_columns: list[str] = [ @@ -596,6 +634,94 @@ def find_aep_median_max(aep_dur_median: pd.DataFrame) -> pd.DataFrame: return aep_med_max +def find_aep_dur_mean(aggregated_df: pd.DataFrame) -> pd.DataFrame: + """Return mean stats for each AEP/Duration/Location/Type/RunCode group.""" + + aep_dur_median: pd.DataFrame = find_aep_dur_median(aggregated_df=aggregated_df) + if aep_dur_median.empty: + return aep_dur_median + + id_columns: list[str] = ["aep_text", "duration_text", "Location", "Type", "trim_runcode"] + mean_columns: list[str] = [ + "mean_including_zeroes", + "mean_excluding_zeroes", + "mean_PeakFlow", + "mean_Duration", + "mean_TP", + ] + info_columns: list[str] = ["low", "high", "count", "count_bin", "mean_storm_is_median_storm"] + + ordered_cols: list[str] = [] + for group in (id_columns, mean_columns): + ordered_cols.extend([col for col in group if col in aep_dur_median.columns]) + + remaining_cols: list[str] = [ + col for col in aep_dur_median.columns if col not in ordered_cols and col not in info_columns + ] + ordered_cols.extend(remaining_cols) + ordered_cols.extend([col for col in info_columns if col in aep_dur_median.columns]) + + return aep_dur_median[ordered_cols] + + +def find_aep_mean_max(aep_dur_mean: pd.DataFrame) -> pd.DataFrame: + """Return rows representing the maximum mean for each AEP/Location/Type/RunCode group.""" + + group_cols: list[str] = ["aep_text", "Location", "Type", "trim_runcode"] + try: + df: pd.DataFrame = aep_dur_mean.copy() + if "mean_PeakFlow" not in df.columns: + logger.error("'mean_PeakFlow' column not present for mean analysis. Returning empty DataFrame.") + return pd.DataFrame() + + df["_mean_peakflow_numeric"] = pd.to_numeric(df["mean_PeakFlow"], errors="coerce") + df["mean_bin"] = df.groupby(group_cols, observed=True)["_mean_peakflow_numeric"].transform("count") + + valid_df: pd.DataFrame = df[df["_mean_peakflow_numeric"].notna()] + if valid_df.empty: + logger.warning("No valid mean peak flow values found. Returning empty DataFrame.") + return pd.DataFrame() + + idx = valid_df.groupby(group_cols, observed=True)["_mean_peakflow_numeric"].idxmax() + aep_mean_max: pd.DataFrame = df.loc[idx].drop(columns=["_mean_peakflow_numeric"]).reset_index(drop=True) + + if not aep_mean_max.empty: + id_columns: list[str] = ["aep_text", "duration_text", "Location", "Type", "trim_runcode"] + mean_columns: list[str] = [ + "mean_including_zeroes", + "mean_excluding_zeroes", + "mean_PeakFlow", + "mean_Duration", + "mean_TP", + ] + info_columns: list[str] = [ + "low", + "high", + "count", + "count_bin", + "mean_storm_is_median_storm", + "mean_bin", + ] + + ordered_cols: list[str] = [] + for group in (id_columns, mean_columns): + ordered_cols.extend([col for col in group if col in aep_mean_max.columns]) + + remaining_cols: list[str] = [ + col for col in aep_mean_max.columns if col not in ordered_cols and col not in info_columns + ] + ordered_cols.extend(remaining_cols) + ordered_cols.extend([col for col in info_columns if col in aep_mean_max.columns]) + + aep_mean_max = aep_mean_max[ordered_cols] + + logger.info("Created 'aep_mean_max' DataFrame with maximum mean records for each AEP group.") + except KeyError as e: + logger.error(f"Missing expected columns for 'aep_mean_max' grouping: {e}") + aep_mean_max = pd.DataFrame() + return aep_mean_max + + def save_peak_report_median( aggregated_df: pd.DataFrame, script_directory: Path, @@ -618,5 +744,31 @@ def save_peak_report_median( include_pomm=include_pomm, timestamp=timestamp, ) - logger.info(f"Completed median peak report export to {output_path}") - logger.info(f"Completed median peak report export to {output_path}") + + +def save_peak_report_mean( + aggregated_df: pd.DataFrame, + script_directory: Path, + timestamp: str, + suffix: str = "_mean_peaks.xlsx", + include_pomm: bool = True, +) -> None: + """Save mean-based peak data tables to an Excel file.""" + + aep_dur_mean: pd.DataFrame = find_aep_dur_mean(aggregated_df=aggregated_df) + aep_mean_max: pd.DataFrame = find_aep_mean_max(aep_dur_mean=aep_dur_mean) + output_filename: str = f"{timestamp}{suffix}" + output_path: Path = script_directory / output_filename + logger.info(f"Starting export of mean peak report to {output_path}") + save_to_excel( + aep_dur_max=aep_dur_mean, + aep_max=aep_mean_max, + aggregated_df=aggregated_df, + output_path=output_path, + include_pomm=include_pomm, + timestamp=timestamp, + aep_dur_sheet_name="aep-dur-mean", + aep_sheet_name="aep-mean-max", + ) + logger.info(f"Completed mean peak report export to {output_path}") + logger.info(f"Completed mean peak report export to {output_path}") From b4f5c485290bbd72aa94076792a3c1b9fe7f7c2f Mon Sep 17 00:00:00 2001 From: Chain-Frost Date: Tue, 4 Nov 2025 16:10:09 +0800 Subject: [PATCH 3/8] [tuflow] Split POMM mean and median report outputs --- .../TUFLOW-python/POMM-mean-max-aep-dur.py | 43 +++ ryan_library/scripts/pomm_max_items.py | 79 ++++- ryan_library/scripts/pomm_utils.py | 304 ++++++++++++------ tests/functions/test_pomm_peak_report.py | 40 ++- tests/scripts/test_pomm_peak_report.py | 37 ++- 5 files changed, 387 insertions(+), 116 deletions(-) create mode 100644 ryan-scripts/TUFLOW-python/POMM-mean-max-aep-dur.py diff --git a/ryan-scripts/TUFLOW-python/POMM-mean-max-aep-dur.py b/ryan-scripts/TUFLOW-python/POMM-mean-max-aep-dur.py new file mode 100644 index 00000000..69efaa1a --- /dev/null +++ b/ryan-scripts/TUFLOW-python/POMM-mean-max-aep-dur.py @@ -0,0 +1,43 @@ +# ryan-scripts\TUFLOW-python\POMM-mean-max-aep-dur.py + +from pathlib import Path +import os + +from ryan_library.scripts.pomm_max_items import run_mean_peak_report +from ryan_library.scripts.wrapper_utils import ( + change_working_directory, + print_library_version, +) + +# Toggle to include the combined POMM sheet in the Excel export. +INCLUDE_POMM: bool = False + +# Update this tuple to restrict processing to specific PO/Location values. +# Leave empty to include every location found in the POMM files. +LOCATIONS_TO_INCLUDE: tuple[str, ...] = () + + +def main() -> None: + """Wrapper script for mean peak reporting.""" + + print_library_version() + console_log_level = "INFO" # or "DEBUG" + script_directory: Path = Path(__file__).absolute().parent + + locations_to_include: tuple[str, ...] | None = LOCATIONS_TO_INCLUDE or None + + if not change_working_directory(target_dir=script_directory): + return + run_mean_peak_report( + script_directory=script_directory, + log_level=console_log_level, + include_pomm=INCLUDE_POMM, + locations_to_include=locations_to_include, + ) + print() + print_library_version() + + +if __name__ == "__main__": + main() + os.system("PAUSE") diff --git a/ryan_library/scripts/pomm_max_items.py b/ryan_library/scripts/pomm_max_items.py index abe9a379..4d8c0390 100644 --- a/ryan_library/scripts/pomm_max_items.py +++ b/ryan_library/scripts/pomm_max_items.py @@ -1,37 +1,41 @@ -# ryan_library/scripts/pomm_max_items.py +"""Peak report workflows for POMM outputs.""" from collections.abc import Collection -from loguru import logger -from pathlib import Path from datetime import datetime +from pathlib import Path + import pandas as pd +from loguru import logger +from ryan_library.functions.loguru_helpers import setup_logger +from ryan_library.processors.tuflow.base_processor import BaseProcessor from ryan_library.scripts.pomm_utils import ( aggregated_from_paths, + save_peak_report_mean, save_peak_report_median, ) -from ryan_library.functions.loguru_helpers import setup_logger -from ryan_library.processors.tuflow.base_processor import BaseProcessor def run_peak_report(script_directory: Path | None = None) -> None: - """Run the peak report generation workflow.""" + """Retained legacy wrapper that now delegates to :func:`run_median_peak_report`.""" + print() print("You are using an old wrapper") print() - run_median_peak_report() + run_median_peak_report(script_directory=script_directory) -def run_median_peak_report( - script_directory: Path | None = None, - log_level: str = "INFO", - include_pomm: bool = True, - locations_to_include: Collection[str] | None = None, -) -> None: - """Locate and process POMM files and export median-based peak values.""" +def _prepare_aggregated_frame( + *, + script_directory: Path | None, + log_level: str, + locations_to_include: Collection[str] | None, +) -> tuple[pd.DataFrame, Path]: + """Common setup shared by the mean and median report entry points.""" setup_logger(console_log_level=log_level) logger.info(f"Current Working Directory: {Path.cwd()}") + if script_directory is None: script_directory = Path.cwd() @@ -50,12 +54,59 @@ def run_median_peak_report( logger.warning("No rows remain after applying the Location filter. Exiting.") else: logger.warning("No POMM CSV files found. Exiting.") + + return aggregated_df, script_directory + + +def run_median_peak_report( + *, + script_directory: Path | None = None, + log_level: str = "INFO", + include_pomm: bool = True, + locations_to_include: Collection[str] | None = None, +) -> None: + """Locate and process POMM files and export median-based peak values.""" + + aggregated_df, resolved_directory = _prepare_aggregated_frame( + script_directory=script_directory, + log_level=log_level, + locations_to_include=locations_to_include, + ) + + if aggregated_df.empty: return timestamp: str = datetime.now().strftime(format="%Y%m%d-%H%M") save_peak_report_median( aggregated_df=aggregated_df, + script_directory=resolved_directory, + timestamp=timestamp, + include_pomm=include_pomm, + ) + + +def run_mean_peak_report( + *, + script_directory: Path | None = None, + log_level: str = "INFO", + include_pomm: bool = True, + locations_to_include: Collection[str] | None = None, +) -> None: + """Locate and process POMM files and export mean-based peak values.""" + + aggregated_df, resolved_directory = _prepare_aggregated_frame( script_directory=script_directory, + log_level=log_level, + locations_to_include=locations_to_include, + ) + + if aggregated_df.empty: + return + + timestamp: str = datetime.now().strftime(format="%Y%m%d-%H%M") + save_peak_report_mean( + aggregated_df=aggregated_df, + script_directory=resolved_directory, timestamp=timestamp, include_pomm=include_pomm, ) diff --git a/ryan_library/scripts/pomm_utils.py b/ryan_library/scripts/pomm_utils.py index a7b4612e..5696c81a 100644 --- a/ryan_library/scripts/pomm_utils.py +++ b/ryan_library/scripts/pomm_utils.py @@ -3,6 +3,7 @@ from pathlib import Path from multiprocessing import Pool +from collections import OrderedDict from collections.abc import Collection, Iterable, Mapping from datetime import datetime, timezone from importlib.metadata import PackageNotFoundError, version @@ -18,7 +19,7 @@ find_files_parallel, is_non_zero_file, ) -from ryan_library.functions.misc_functions import calculate_pool_size +from ryan_library.functions.misc_functions import ExcelExporter, calculate_pool_size from ryan_library.processors.tuflow.base_processor import BaseProcessor from ryan_library.processors.tuflow.processor_collection import ProcessorCollection from ryan_library.classes.suffixes_and_dtypes import SuffixesConfig @@ -26,7 +27,24 @@ NAType = type(pd.NA) -NAType = type(pd.NA) +ID_COLUMNS: list[str] = ["aep_text", "duration_text", "Location", "Type", "trim_runcode"] +MEAN_COLUMNS: list[str] = [ + "mean_including_zeroes", + "mean_excluding_zeroes", + "mean_PeakFlow", + "mean_Duration", + "mean_TP", +] +MEDIAN_COLUMNS: list[str] = ["MedianAbsMax", "median_duration", "median_TP"] +AEP_DUR_INFO_COLUMNS: list[str] = ["low", "high", "count", "count_bin", "mean_storm_is_median_storm"] +AEP_MAX_INFO_COLUMNS: list[str] = [ + "low", + "high", + "count", + "count_bin", + "mean_storm_is_median_storm", + "aep_bin", +] DATA_DICTIONARY_SHEET_NAME: str = "data-dictionary" @@ -395,7 +413,6 @@ def save_peak_report( output_filename: str = f"{timestamp}{suffix}" output_path: Path = script_directory / output_filename logger.info(f"Starting export of peak report to {output_path}") - logger.info(f"Starting export of peak report to {output_path}") save_to_excel( aep_dur_max=aep_dur_max, aep_max=aep_max, @@ -405,10 +422,57 @@ def save_peak_report( timestamp=timestamp, ) logger.info(f"Completed peak report export to {output_path}") - logger.info(f"Completed peak report export to {output_path}") -def find_aep_dur_median(aggregated_df: pd.DataFrame) -> pd.DataFrame: +def _finalize_peak_frame( + frame: pd.DataFrame, + *, + include_mean_columns: bool, + include_median_columns: bool, + info_columns: Iterable[str], +) -> pd.DataFrame: + """Return ``frame`` with filtered and ordered columns for export.""" + + working_frame: pd.DataFrame = frame.copy() + + if not include_mean_columns: + working_frame = working_frame.drop(columns=[c for c in MEAN_COLUMNS if c in working_frame.columns], errors="ignore") + + if not include_median_columns: + working_frame = working_frame.drop( + columns=[c for c in MEDIAN_COLUMNS if c in working_frame.columns], errors="ignore" + ) + + if not (include_mean_columns and include_median_columns): + working_frame = working_frame.drop(columns=["mean_storm_is_median_storm"], errors="ignore") + + ordered_cols: list[str] = [col for col in ID_COLUMNS if col in working_frame.columns] + + if include_mean_columns: + ordered_cols.extend([col for col in MEAN_COLUMNS if col in working_frame.columns]) + + if include_median_columns: + ordered_cols.extend([col for col in MEDIAN_COLUMNS if col in working_frame.columns]) + + filtered_info_columns: list[str] = list(info_columns) + if not (include_mean_columns and include_median_columns): + filtered_info_columns = [col for col in filtered_info_columns if col != "mean_storm_is_median_storm"] + + remaining_cols: list[str] = [ + col for col in working_frame.columns if col not in ordered_cols and col not in filtered_info_columns + ] + ordered_cols.extend(remaining_cols) + ordered_cols.extend([col for col in filtered_info_columns if col in working_frame.columns]) + + return working_frame.loc[:, ordered_cols] + + +def find_aep_dur_median( + aggregated_df: pd.DataFrame, + *, + include_mean_columns: bool = True, + include_median_columns: bool = True, +) -> pd.DataFrame: """Return median stats for each AEP/Duration/Location/Type/RunCode group.""" group_cols: list[str] = [ "aep_text", @@ -491,29 +555,12 @@ def norm_duration(value: object) -> float: mean_storm_matches = (duration_match & tp_match).fillna(False) median_df["mean_storm_is_median_storm"] = mean_storm_matches - - id_columns: list[str] = ["aep_text", "duration_text", "Location", "Type", "trim_runcode"] - mean_columns: list[str] = [ - "mean_including_zeroes", - "mean_excluding_zeroes", - "mean_PeakFlow", - "mean_Duration", - "mean_TP", - ] - median_columns: list[str] = ["MedianAbsMax", "median_duration", "median_TP"] - info_columns: list[str] = ["low", "high", "count", "count_bin", "mean_storm_is_median_storm"] - - ordered_cols: list[str] = [] - for group in (id_columns, mean_columns, median_columns): - ordered_cols.extend([col for col in group if col in median_df.columns]) - - remaining_cols: list[str] = [ - col for col in median_df.columns if col not in ordered_cols and col not in info_columns - ] - ordered_cols.extend(remaining_cols) - ordered_cols.extend([col for col in info_columns if col in median_df.columns]) - - median_df = median_df[ordered_cols] + median_df = _finalize_peak_frame( + median_df, + include_mean_columns=include_mean_columns, + include_median_columns=include_median_columns, + info_columns=AEP_DUR_INFO_COLUMNS, + ) logger.info("Created 'aep_dur_median' DataFrame with median records for each AEP-Duration group.") except KeyError as e: logger.error(f"Missing expected columns for 'aep_dur_median' grouping: {e}") @@ -521,7 +568,12 @@ def norm_duration(value: object) -> float: return median_df -def find_aep_median_max(aep_dur_median: pd.DataFrame) -> pd.DataFrame: +def find_aep_median_max( + aep_dur_median: pd.DataFrame, + *, + include_mean_columns: bool = True, + include_median_columns: bool = True, +) -> pd.DataFrame: """Return rows representing the maximum median for each AEP/Location/Type/RunCode group.""" group_cols: list[str] = ["aep_text", "Location", "Type", "trim_runcode"] try: @@ -530,65 +582,12 @@ def find_aep_median_max(aep_dur_median: pd.DataFrame) -> pd.DataFrame: idx = df.groupby(group_cols, observed=True)["MedianAbsMax"].idxmax() aep_med_max: pd.DataFrame = df.loc[idx].reset_index(drop=True) if not aep_med_max.empty: - id_columns: list[str] = ["aep_text", "duration_text", "Location", "Type", "trim_runcode"] - mean_columns: list[str] = [ - "mean_including_zeroes", - "mean_excluding_zeroes", - "mean_PeakFlow", - "mean_Duration", - "mean_TP", - ] - median_columns: list[str] = ["MedianAbsMax", "median_duration", "median_TP"] - info_columns: list[str] = [ - "low", - "high", - "count", - "count_bin", - "mean_storm_is_median_storm", - "aep_bin", - ] - - ordered_cols: list[str] = [] - for group in (id_columns, mean_columns, median_columns): - ordered_cols.extend([col for col in group if col in aep_med_max.columns]) - - remaining_cols: list[str] = [ - col for col in aep_med_max.columns if col not in ordered_cols and col not in info_columns - ] - ordered_cols.extend(remaining_cols) - ordered_cols.extend([col for col in info_columns if col in aep_med_max.columns]) - - aep_med_max = aep_med_max[ordered_cols] - if not aep_med_max.empty: - id_columns: list[str] = ["aep_text", "duration_text", "Location", "Type", "trim_runcode"] - mean_columns: list[str] = [ - "mean_including_zeroes", - "mean_excluding_zeroes", - "mean_PeakFlow", - "mean_Duration", - "mean_TP", - ] - median_columns: list[str] = ["MedianAbsMax", "median_duration", "median_TP"] - info_columns: list[str] = [ - "low", - "high", - "count", - "count_bin", - "mean_storm_is_median_storm", - "aep_bin", - ] - - ordered_cols: list[str] = [] - for group in (id_columns, mean_columns, median_columns): - ordered_cols.extend([col for col in group if col in aep_med_max.columns]) - - remaining_cols: list[str] = [ - col for col in aep_med_max.columns if col not in ordered_cols and col not in info_columns - ] - ordered_cols.extend(remaining_cols) - ordered_cols.extend([col for col in info_columns if col in aep_med_max.columns]) - - aep_med_max = aep_med_max[ordered_cols] + aep_med_max = _finalize_peak_frame( + aep_med_max, + include_mean_columns=include_mean_columns, + include_median_columns=include_median_columns, + info_columns=AEP_MAX_INFO_COLUMNS, + ) logger.info("Created 'aep_median_max' DataFrame with maximum median records for each AEP group.") except KeyError as e: logger.error(f"Missing expected columns for 'aep_median_max' grouping: {e}") @@ -596,6 +595,70 @@ def find_aep_median_max(aep_dur_median: pd.DataFrame) -> pd.DataFrame: return aep_med_max +def _export_peak_report( + *, + aep_dur_frame: pd.DataFrame, + aep_max_frame: pd.DataFrame, + aggregated_df: pd.DataFrame, + script_directory: Path, + include_pomm: bool, + timestamp: str, + file_name_prefix: str, + suffix: str, +) -> None: + """Export the prepared peak report tables using :class:`ExcelExporter`.""" + + sheet_frames: OrderedDict[str, pd.DataFrame] = OrderedDict() + sheet_frames["aep-dur-max"] = aep_dur_frame + sheet_frames["aep-max"] = aep_max_frame + if include_pomm: + sheet_frames["POMM"] = aggregated_df + + registry: ColumnMetadataRegistry = ColumnMetadataRegistry.default() + metadata_rows: Mapping[str, str] = _build_metadata_rows( + timestamp=timestamp, + include_pomm=include_pomm, + aep_dur_max=aep_dur_frame, + aep_max=aep_max_frame, + aggregated_df=aggregated_df, + ) + data_dictionary_df: pd.DataFrame = _build_data_dictionary( + registry=registry, + sheet_frames=sheet_frames, + metadata_rows=metadata_rows, + ) + sheet_frames[DATA_DICTIONARY_SHEET_NAME] = data_dictionary_df + + exporter = ExcelExporter() + export_dict = { + file_name_prefix: { + "dataframes": list(sheet_frames.values()), + "sheets": list(sheet_frames.keys()), + } + } + exporter.export_dataframes(export_dict=export_dict, output_directory=script_directory) + + desired_filename: str = f"{timestamp}{suffix}" if timestamp else f"{file_name_prefix}.xlsx" + desired_path: Path = script_directory / desired_filename + if desired_path.suffix != ".xlsx": + desired_path = desired_path.with_suffix(".xlsx") + + generated_files: list[Path] = sorted( + script_directory.glob(f"*_{file_name_prefix}.xlsx"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + + if generated_files: + latest_file: Path = generated_files[0] + if latest_file == desired_path: + return + if desired_path.exists(): + desired_path.unlink() + if latest_file != desired_path: + latest_file.replace(desired_path) + + def save_peak_report_median( aggregated_df: pd.DataFrame, script_directory: Path, @@ -604,19 +667,60 @@ def save_peak_report_median( include_pomm: bool = True, ) -> None: """Save median-based peak data tables to an Excel file.""" - aep_dur_med: pd.DataFrame = find_aep_dur_median(aggregated_df=aggregated_df) - aep_med_max: pd.DataFrame = find_aep_median_max(aep_dur_median=aep_dur_med) - output_filename: str = f"{timestamp}{suffix}" - output_path: Path = script_directory / output_filename - logger.info(f"Starting export of median peak report to {output_path}") - logger.info(f"Starting export of median peak report to {output_path}") - save_to_excel( - aep_dur_max=aep_dur_med, - aep_max=aep_med_max, + aep_dur_med: pd.DataFrame = find_aep_dur_median( aggregated_df=aggregated_df, - output_path=output_path, + include_mean_columns=False, + include_median_columns=True, + ) + aep_med_max: pd.DataFrame = find_aep_median_max( + aep_dur_median=aep_dur_med, + include_mean_columns=False, + include_median_columns=True, + ) + file_name_prefix: str = suffix.removesuffix(".xlsx").lstrip("_") or "med_peaks" + logger.info("Starting export of median peak report") + _export_peak_report( + aep_dur_frame=aep_dur_med, + aep_max_frame=aep_med_max, + aggregated_df=aggregated_df, + script_directory=script_directory, + include_pomm=include_pomm, + timestamp=timestamp, + file_name_prefix=file_name_prefix, + suffix=suffix, + ) + logger.info("Completed median peak report export") + + +def save_peak_report_mean( + aggregated_df: pd.DataFrame, + script_directory: Path, + timestamp: str, + suffix: str = "_mean_peaks.xlsx", + include_pomm: bool = True, +) -> None: + """Save mean-based peak data tables to an Excel file.""" + + aep_dur_mean: pd.DataFrame = find_aep_dur_median( + aggregated_df=aggregated_df, + include_mean_columns=True, + include_median_columns=False, + ) + aep_mean_max: pd.DataFrame = find_aep_median_max( + aep_dur_median=aep_dur_mean, + include_mean_columns=True, + include_median_columns=False, + ) + file_name_prefix: str = suffix.removesuffix(".xlsx").lstrip("_") or "mean_peaks" + logger.info("Starting export of mean peak report") + _export_peak_report( + aep_dur_frame=aep_dur_mean, + aep_max_frame=aep_mean_max, + aggregated_df=aggregated_df, + script_directory=script_directory, include_pomm=include_pomm, timestamp=timestamp, + file_name_prefix=file_name_prefix, + suffix=suffix, ) - logger.info(f"Completed median peak report export to {output_path}") - logger.info(f"Completed median peak report export to {output_path}") + logger.info("Completed mean peak report export") diff --git a/tests/functions/test_pomm_peak_report.py b/tests/functions/test_pomm_peak_report.py index 33cc30fd..8f6ac9e7 100644 --- a/tests/functions/test_pomm_peak_report.py +++ b/tests/functions/test_pomm_peak_report.py @@ -12,7 +12,7 @@ find_aep_dur_median, find_aep_median_max, ) -from ryan_library.scripts.pomm_max_items import run_median_peak_report +from ryan_library.scripts.pomm_max_items import run_mean_peak_report, run_median_peak_report DATA_DIR = Path(__file__).absolute().parent.parent / "test_data" / "tuflow" / "tutorials" @@ -48,6 +48,29 @@ def test_find_aep_dur_median_and_max() -> None: assert row_a["duration_text"] == "D2" +def test_find_aep_dur_median_filters_columns() -> None: + df = pd.DataFrame( + { + "aep_text": ["A", "A", "A", "A"], + "duration_text": ["D1", "D1", "D2", "D2"], + "Location": ["L1"] * 4, + "Type": ["Flow"] * 4, + "trim_runcode": ["Run"] * 4, + "AbsMax": [5, 1, 3, 7], + "tp_text": ["TP1", "TP2", "TP1", "TP2"], + } + ) + + median_only = find_aep_dur_median(df, include_mean_columns=False) + assert "MedianAbsMax" in median_only.columns + assert "mean_PeakFlow" not in median_only.columns + assert "mean_storm_is_median_storm" not in median_only.columns + + mean_only = find_aep_dur_median(df, include_median_columns=False) + assert "MedianAbsMax" not in mean_only.columns + assert "mean_PeakFlow" in mean_only.columns + + def test_run_median_peak_report_creates_excel() -> None: src_dir = DATA_DIR / "Module_01" / "results" run_median_peak_report(script_directory=src_dir, log_level="INFO") @@ -55,5 +78,20 @@ def test_run_median_peak_report_creates_excel() -> None: assert excel_files xl = pd.ExcelFile(excel_files[0]) assert set(["aep-dur-max", "aep-max", "POMM"]).issubset(set(xl.sheet_names)) + sheet_df = xl.parse("aep-dur-max") + assert "mean_PeakFlow" not in sheet_df.columns + for f in excel_files: + f.unlink() + + +def test_run_mean_peak_report_creates_excel() -> None: + src_dir = DATA_DIR / "Module_01" / "results" + run_mean_peak_report(script_directory=src_dir, log_level="INFO") + excel_files = list(src_dir.glob("*_mean_peaks.xlsx")) + assert excel_files + xl = pd.ExcelFile(excel_files[0]) + assert set(["aep-dur-max", "aep-max", "POMM"]).issubset(set(xl.sheet_names)) + sheet_df = xl.parse("aep-dur-max") + assert "MedianAbsMax" not in sheet_df.columns for f in excel_files: f.unlink() diff --git a/tests/scripts/test_pomm_peak_report.py b/tests/scripts/test_pomm_peak_report.py index 16ec5ba4..060997a5 100644 --- a/tests/scripts/test_pomm_peak_report.py +++ b/tests/scripts/test_pomm_peak_report.py @@ -11,7 +11,7 @@ find_aep_dur_median, find_aep_median_max, ) -from ryan_library.scripts.pomm_max_items import run_median_peak_report +from ryan_library.scripts.pomm_max_items import run_mean_peak_report, run_median_peak_report DATA_DIR: Path = Path(__file__).absolute().parent.parent / "test_data" / "tuflow" / "tutorials" @@ -49,6 +49,26 @@ def test_find_aep_dur_median_and_max() -> None: assert row_a["duration_text"] == "D2" +def test_find_aep_dur_median_filters_columns() -> None: + df = pd.DataFrame( + { + "aep_text": ["A", "A", "A", "A"], + "duration_text": ["D1", "D1", "D2", "D2"], + "Location": ["L1"] * 4, + "Type": ["Flow"] * 4, + "trim_runcode": ["Run"] * 4, + "AbsMax": [5, 1, 3, 7], + "tp_text": ["TP1", "TP2", "TP1", "TP2"], + } + ) + + median_only: DataFrame = find_aep_dur_median(df, include_mean_columns=False) + assert "mean_PeakFlow" not in median_only.columns + + mean_only: DataFrame = find_aep_dur_median(df, include_median_columns=False) + assert "MedianAbsMax" not in mean_only.columns + + def test_run_median_peak_report_creates_excel() -> None: src_dir: Path = DATA_DIR / "Module_01" / "results" run_median_peak_report(script_directory=src_dir, log_level="INFO") @@ -56,6 +76,8 @@ def test_run_median_peak_report_creates_excel() -> None: assert excel_files xl = pd.ExcelFile(path_or_buffer=excel_files[0]) assert set(["aep-dur-max", "aep-max", "POMM"]).issubset(set(xl.sheet_names)) + sheet_df = xl.parse("aep-dur-max") + assert "mean_PeakFlow" not in sheet_df.columns for f in excel_files: f.unlink() @@ -70,3 +92,16 @@ def test_run_median_peak_report_skips_pomm_sheet_when_disabled() -> None: assert {"aep-dur-max", "aep-max"}.issubset(set(xl.sheet_names)) for f in excel_files: f.unlink() + + +def test_run_mean_peak_report_creates_excel() -> None: + src_dir: Path = DATA_DIR / "Module_01" / "results" + run_mean_peak_report(script_directory=src_dir, log_level="INFO") + excel_files: list[Path] = list(src_dir.glob("*_mean_peaks.xlsx")) + assert excel_files + xl = pd.ExcelFile(path_or_buffer=excel_files[0]) + assert set(["aep-dur-max", "aep-max", "POMM"]).issubset(set(xl.sheet_names)) + sheet_df = xl.parse("aep-dur-max") + assert "MedianAbsMax" not in sheet_df.columns + for f in excel_files: + f.unlink() From 8f45800a675b4ef3f8d668f0356c83fb1cefe2b3 Mon Sep 17 00:00:00 2001 From: Ryan Brook Date: Tue, 4 Nov 2025 16:27:36 +0800 Subject: [PATCH 4/8] initial build --- ...ryan_functions-25.11.4.1-py3-none-any.whl} | Bin 150387 -> 146333 bytes setup.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename dist/{ryan_functions-25.11.3.1-py3-none-any.whl => ryan_functions-25.11.4.1-py3-none-any.whl} (66%) diff --git a/dist/ryan_functions-25.11.3.1-py3-none-any.whl b/dist/ryan_functions-25.11.4.1-py3-none-any.whl similarity index 66% rename from dist/ryan_functions-25.11.3.1-py3-none-any.whl rename to dist/ryan_functions-25.11.4.1-py3-none-any.whl index 6bea5143762ac57560eb8ab4aa66cd42e62b3c1b..1e1f51a1f4396f486ed303f9a661f2abb343f5d7 100644 GIT binary patch delta 36035 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
Zxv0z)yp#b~;t~u2o3P6?mh64EZ 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 Date: Tue, 4 Nov 2025 16:27:52 +0800 Subject: [PATCH 5/8] Revert "[tuflow] Split POMM mean and median report outputs" --- .../TUFLOW-python/POMM-mean-max-aep-dur.py | 43 --- ryan_library/scripts/pomm_max_items.py | 79 +---- ryan_library/scripts/pomm_utils.py | 304 ++++++------------ tests/functions/test_pomm_peak_report.py | 40 +-- tests/scripts/test_pomm_peak_report.py | 37 +-- 5 files changed, 116 insertions(+), 387 deletions(-) delete mode 100644 ryan-scripts/TUFLOW-python/POMM-mean-max-aep-dur.py diff --git a/ryan-scripts/TUFLOW-python/POMM-mean-max-aep-dur.py b/ryan-scripts/TUFLOW-python/POMM-mean-max-aep-dur.py deleted file mode 100644 index 69efaa1a..00000000 --- a/ryan-scripts/TUFLOW-python/POMM-mean-max-aep-dur.py +++ /dev/null @@ -1,43 +0,0 @@ -# ryan-scripts\TUFLOW-python\POMM-mean-max-aep-dur.py - -from pathlib import Path -import os - -from ryan_library.scripts.pomm_max_items import run_mean_peak_report -from ryan_library.scripts.wrapper_utils import ( - change_working_directory, - print_library_version, -) - -# Toggle to include the combined POMM sheet in the Excel export. -INCLUDE_POMM: bool = False - -# Update this tuple to restrict processing to specific PO/Location values. -# Leave empty to include every location found in the POMM files. -LOCATIONS_TO_INCLUDE: tuple[str, ...] = () - - -def main() -> None: - """Wrapper script for mean peak reporting.""" - - print_library_version() - console_log_level = "INFO" # or "DEBUG" - script_directory: Path = Path(__file__).absolute().parent - - locations_to_include: tuple[str, ...] | None = LOCATIONS_TO_INCLUDE or None - - if not change_working_directory(target_dir=script_directory): - return - run_mean_peak_report( - script_directory=script_directory, - log_level=console_log_level, - include_pomm=INCLUDE_POMM, - locations_to_include=locations_to_include, - ) - print() - print_library_version() - - -if __name__ == "__main__": - main() - os.system("PAUSE") diff --git a/ryan_library/scripts/pomm_max_items.py b/ryan_library/scripts/pomm_max_items.py index 4d8c0390..abe9a379 100644 --- a/ryan_library/scripts/pomm_max_items.py +++ b/ryan_library/scripts/pomm_max_items.py @@ -1,41 +1,37 @@ -"""Peak report workflows for POMM outputs.""" +# ryan_library/scripts/pomm_max_items.py from collections.abc import Collection -from datetime import datetime +from loguru import logger from pathlib import Path - +from datetime import datetime import pandas as pd -from loguru import logger -from ryan_library.functions.loguru_helpers import setup_logger -from ryan_library.processors.tuflow.base_processor import BaseProcessor from ryan_library.scripts.pomm_utils import ( aggregated_from_paths, - save_peak_report_mean, save_peak_report_median, ) +from ryan_library.functions.loguru_helpers import setup_logger +from ryan_library.processors.tuflow.base_processor import BaseProcessor def run_peak_report(script_directory: Path | None = None) -> None: - """Retained legacy wrapper that now delegates to :func:`run_median_peak_report`.""" - + """Run the peak report generation workflow.""" print() print("You are using an old wrapper") print() - run_median_peak_report(script_directory=script_directory) + run_median_peak_report() -def _prepare_aggregated_frame( - *, - script_directory: Path | None, - log_level: str, - locations_to_include: Collection[str] | None, -) -> tuple[pd.DataFrame, Path]: - """Common setup shared by the mean and median report entry points.""" +def run_median_peak_report( + script_directory: Path | None = None, + log_level: str = "INFO", + include_pomm: bool = True, + locations_to_include: Collection[str] | None = None, +) -> None: + """Locate and process POMM files and export median-based peak values.""" setup_logger(console_log_level=log_level) logger.info(f"Current Working Directory: {Path.cwd()}") - if script_directory is None: script_directory = Path.cwd() @@ -54,59 +50,12 @@ def _prepare_aggregated_frame( logger.warning("No rows remain after applying the Location filter. Exiting.") else: logger.warning("No POMM CSV files found. Exiting.") - - return aggregated_df, script_directory - - -def run_median_peak_report( - *, - script_directory: Path | None = None, - log_level: str = "INFO", - include_pomm: bool = True, - locations_to_include: Collection[str] | None = None, -) -> None: - """Locate and process POMM files and export median-based peak values.""" - - aggregated_df, resolved_directory = _prepare_aggregated_frame( - script_directory=script_directory, - log_level=log_level, - locations_to_include=locations_to_include, - ) - - if aggregated_df.empty: return timestamp: str = datetime.now().strftime(format="%Y%m%d-%H%M") save_peak_report_median( aggregated_df=aggregated_df, - script_directory=resolved_directory, - timestamp=timestamp, - include_pomm=include_pomm, - ) - - -def run_mean_peak_report( - *, - script_directory: Path | None = None, - log_level: str = "INFO", - include_pomm: bool = True, - locations_to_include: Collection[str] | None = None, -) -> None: - """Locate and process POMM files and export mean-based peak values.""" - - aggregated_df, resolved_directory = _prepare_aggregated_frame( script_directory=script_directory, - log_level=log_level, - locations_to_include=locations_to_include, - ) - - if aggregated_df.empty: - return - - timestamp: str = datetime.now().strftime(format="%Y%m%d-%H%M") - save_peak_report_mean( - aggregated_df=aggregated_df, - script_directory=resolved_directory, timestamp=timestamp, include_pomm=include_pomm, ) diff --git a/ryan_library/scripts/pomm_utils.py b/ryan_library/scripts/pomm_utils.py index 5696c81a..a7b4612e 100644 --- a/ryan_library/scripts/pomm_utils.py +++ b/ryan_library/scripts/pomm_utils.py @@ -3,7 +3,6 @@ from pathlib import Path from multiprocessing import Pool -from collections import OrderedDict from collections.abc import Collection, Iterable, Mapping from datetime import datetime, timezone from importlib.metadata import PackageNotFoundError, version @@ -19,7 +18,7 @@ find_files_parallel, is_non_zero_file, ) -from ryan_library.functions.misc_functions import ExcelExporter, calculate_pool_size +from ryan_library.functions.misc_functions import calculate_pool_size from ryan_library.processors.tuflow.base_processor import BaseProcessor from ryan_library.processors.tuflow.processor_collection import ProcessorCollection from ryan_library.classes.suffixes_and_dtypes import SuffixesConfig @@ -27,24 +26,7 @@ NAType = type(pd.NA) -ID_COLUMNS: list[str] = ["aep_text", "duration_text", "Location", "Type", "trim_runcode"] -MEAN_COLUMNS: list[str] = [ - "mean_including_zeroes", - "mean_excluding_zeroes", - "mean_PeakFlow", - "mean_Duration", - "mean_TP", -] -MEDIAN_COLUMNS: list[str] = ["MedianAbsMax", "median_duration", "median_TP"] -AEP_DUR_INFO_COLUMNS: list[str] = ["low", "high", "count", "count_bin", "mean_storm_is_median_storm"] -AEP_MAX_INFO_COLUMNS: list[str] = [ - "low", - "high", - "count", - "count_bin", - "mean_storm_is_median_storm", - "aep_bin", -] +NAType = type(pd.NA) DATA_DICTIONARY_SHEET_NAME: str = "data-dictionary" @@ -413,6 +395,7 @@ def save_peak_report( output_filename: str = f"{timestamp}{suffix}" output_path: Path = script_directory / output_filename logger.info(f"Starting export of peak report to {output_path}") + logger.info(f"Starting export of peak report to {output_path}") save_to_excel( aep_dur_max=aep_dur_max, aep_max=aep_max, @@ -422,57 +405,10 @@ def save_peak_report( timestamp=timestamp, ) logger.info(f"Completed peak report export to {output_path}") + logger.info(f"Completed peak report export to {output_path}") -def _finalize_peak_frame( - frame: pd.DataFrame, - *, - include_mean_columns: bool, - include_median_columns: bool, - info_columns: Iterable[str], -) -> pd.DataFrame: - """Return ``frame`` with filtered and ordered columns for export.""" - - working_frame: pd.DataFrame = frame.copy() - - if not include_mean_columns: - working_frame = working_frame.drop(columns=[c for c in MEAN_COLUMNS if c in working_frame.columns], errors="ignore") - - if not include_median_columns: - working_frame = working_frame.drop( - columns=[c for c in MEDIAN_COLUMNS if c in working_frame.columns], errors="ignore" - ) - - if not (include_mean_columns and include_median_columns): - working_frame = working_frame.drop(columns=["mean_storm_is_median_storm"], errors="ignore") - - ordered_cols: list[str] = [col for col in ID_COLUMNS if col in working_frame.columns] - - if include_mean_columns: - ordered_cols.extend([col for col in MEAN_COLUMNS if col in working_frame.columns]) - - if include_median_columns: - ordered_cols.extend([col for col in MEDIAN_COLUMNS if col in working_frame.columns]) - - filtered_info_columns: list[str] = list(info_columns) - if not (include_mean_columns and include_median_columns): - filtered_info_columns = [col for col in filtered_info_columns if col != "mean_storm_is_median_storm"] - - remaining_cols: list[str] = [ - col for col in working_frame.columns if col not in ordered_cols and col not in filtered_info_columns - ] - ordered_cols.extend(remaining_cols) - ordered_cols.extend([col for col in filtered_info_columns if col in working_frame.columns]) - - return working_frame.loc[:, ordered_cols] - - -def find_aep_dur_median( - aggregated_df: pd.DataFrame, - *, - include_mean_columns: bool = True, - include_median_columns: bool = True, -) -> pd.DataFrame: +def find_aep_dur_median(aggregated_df: pd.DataFrame) -> pd.DataFrame: """Return median stats for each AEP/Duration/Location/Type/RunCode group.""" group_cols: list[str] = [ "aep_text", @@ -555,12 +491,29 @@ def norm_duration(value: object) -> float: mean_storm_matches = (duration_match & tp_match).fillna(False) median_df["mean_storm_is_median_storm"] = mean_storm_matches - median_df = _finalize_peak_frame( - median_df, - include_mean_columns=include_mean_columns, - include_median_columns=include_median_columns, - info_columns=AEP_DUR_INFO_COLUMNS, - ) + + id_columns: list[str] = ["aep_text", "duration_text", "Location", "Type", "trim_runcode"] + mean_columns: list[str] = [ + "mean_including_zeroes", + "mean_excluding_zeroes", + "mean_PeakFlow", + "mean_Duration", + "mean_TP", + ] + median_columns: list[str] = ["MedianAbsMax", "median_duration", "median_TP"] + info_columns: list[str] = ["low", "high", "count", "count_bin", "mean_storm_is_median_storm"] + + ordered_cols: list[str] = [] + for group in (id_columns, mean_columns, median_columns): + ordered_cols.extend([col for col in group if col in median_df.columns]) + + remaining_cols: list[str] = [ + col for col in median_df.columns if col not in ordered_cols and col not in info_columns + ] + ordered_cols.extend(remaining_cols) + ordered_cols.extend([col for col in info_columns if col in median_df.columns]) + + median_df = median_df[ordered_cols] logger.info("Created 'aep_dur_median' DataFrame with median records for each AEP-Duration group.") except KeyError as e: logger.error(f"Missing expected columns for 'aep_dur_median' grouping: {e}") @@ -568,12 +521,7 @@ def norm_duration(value: object) -> float: return median_df -def find_aep_median_max( - aep_dur_median: pd.DataFrame, - *, - include_mean_columns: bool = True, - include_median_columns: bool = True, -) -> pd.DataFrame: +def find_aep_median_max(aep_dur_median: pd.DataFrame) -> pd.DataFrame: """Return rows representing the maximum median for each AEP/Location/Type/RunCode group.""" group_cols: list[str] = ["aep_text", "Location", "Type", "trim_runcode"] try: @@ -582,12 +530,65 @@ def find_aep_median_max( idx = df.groupby(group_cols, observed=True)["MedianAbsMax"].idxmax() aep_med_max: pd.DataFrame = df.loc[idx].reset_index(drop=True) if not aep_med_max.empty: - aep_med_max = _finalize_peak_frame( - aep_med_max, - include_mean_columns=include_mean_columns, - include_median_columns=include_median_columns, - info_columns=AEP_MAX_INFO_COLUMNS, - ) + id_columns: list[str] = ["aep_text", "duration_text", "Location", "Type", "trim_runcode"] + mean_columns: list[str] = [ + "mean_including_zeroes", + "mean_excluding_zeroes", + "mean_PeakFlow", + "mean_Duration", + "mean_TP", + ] + median_columns: list[str] = ["MedianAbsMax", "median_duration", "median_TP"] + info_columns: list[str] = [ + "low", + "high", + "count", + "count_bin", + "mean_storm_is_median_storm", + "aep_bin", + ] + + ordered_cols: list[str] = [] + for group in (id_columns, mean_columns, median_columns): + ordered_cols.extend([col for col in group if col in aep_med_max.columns]) + + remaining_cols: list[str] = [ + col for col in aep_med_max.columns if col not in ordered_cols and col not in info_columns + ] + ordered_cols.extend(remaining_cols) + ordered_cols.extend([col for col in info_columns if col in aep_med_max.columns]) + + aep_med_max = aep_med_max[ordered_cols] + if not aep_med_max.empty: + id_columns: list[str] = ["aep_text", "duration_text", "Location", "Type", "trim_runcode"] + mean_columns: list[str] = [ + "mean_including_zeroes", + "mean_excluding_zeroes", + "mean_PeakFlow", + "mean_Duration", + "mean_TP", + ] + median_columns: list[str] = ["MedianAbsMax", "median_duration", "median_TP"] + info_columns: list[str] = [ + "low", + "high", + "count", + "count_bin", + "mean_storm_is_median_storm", + "aep_bin", + ] + + ordered_cols: list[str] = [] + for group in (id_columns, mean_columns, median_columns): + ordered_cols.extend([col for col in group if col in aep_med_max.columns]) + + remaining_cols: list[str] = [ + col for col in aep_med_max.columns if col not in ordered_cols and col not in info_columns + ] + ordered_cols.extend(remaining_cols) + ordered_cols.extend([col for col in info_columns if col in aep_med_max.columns]) + + aep_med_max = aep_med_max[ordered_cols] logger.info("Created 'aep_median_max' DataFrame with maximum median records for each AEP group.") except KeyError as e: logger.error(f"Missing expected columns for 'aep_median_max' grouping: {e}") @@ -595,70 +596,6 @@ def find_aep_median_max( return aep_med_max -def _export_peak_report( - *, - aep_dur_frame: pd.DataFrame, - aep_max_frame: pd.DataFrame, - aggregated_df: pd.DataFrame, - script_directory: Path, - include_pomm: bool, - timestamp: str, - file_name_prefix: str, - suffix: str, -) -> None: - """Export the prepared peak report tables using :class:`ExcelExporter`.""" - - sheet_frames: OrderedDict[str, pd.DataFrame] = OrderedDict() - sheet_frames["aep-dur-max"] = aep_dur_frame - sheet_frames["aep-max"] = aep_max_frame - if include_pomm: - sheet_frames["POMM"] = aggregated_df - - registry: ColumnMetadataRegistry = ColumnMetadataRegistry.default() - metadata_rows: Mapping[str, str] = _build_metadata_rows( - timestamp=timestamp, - include_pomm=include_pomm, - aep_dur_max=aep_dur_frame, - aep_max=aep_max_frame, - aggregated_df=aggregated_df, - ) - data_dictionary_df: pd.DataFrame = _build_data_dictionary( - registry=registry, - sheet_frames=sheet_frames, - metadata_rows=metadata_rows, - ) - sheet_frames[DATA_DICTIONARY_SHEET_NAME] = data_dictionary_df - - exporter = ExcelExporter() - export_dict = { - file_name_prefix: { - "dataframes": list(sheet_frames.values()), - "sheets": list(sheet_frames.keys()), - } - } - exporter.export_dataframes(export_dict=export_dict, output_directory=script_directory) - - desired_filename: str = f"{timestamp}{suffix}" if timestamp else f"{file_name_prefix}.xlsx" - desired_path: Path = script_directory / desired_filename - if desired_path.suffix != ".xlsx": - desired_path = desired_path.with_suffix(".xlsx") - - generated_files: list[Path] = sorted( - script_directory.glob(f"*_{file_name_prefix}.xlsx"), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - - if generated_files: - latest_file: Path = generated_files[0] - if latest_file == desired_path: - return - if desired_path.exists(): - desired_path.unlink() - if latest_file != desired_path: - latest_file.replace(desired_path) - - def save_peak_report_median( aggregated_df: pd.DataFrame, script_directory: Path, @@ -667,60 +604,19 @@ def save_peak_report_median( include_pomm: bool = True, ) -> None: """Save median-based peak data tables to an Excel file.""" - aep_dur_med: pd.DataFrame = find_aep_dur_median( - aggregated_df=aggregated_df, - include_mean_columns=False, - include_median_columns=True, - ) - aep_med_max: pd.DataFrame = find_aep_median_max( - aep_dur_median=aep_dur_med, - include_mean_columns=False, - include_median_columns=True, - ) - file_name_prefix: str = suffix.removesuffix(".xlsx").lstrip("_") or "med_peaks" - logger.info("Starting export of median peak report") - _export_peak_report( - aep_dur_frame=aep_dur_med, - aep_max_frame=aep_med_max, - aggregated_df=aggregated_df, - script_directory=script_directory, - include_pomm=include_pomm, - timestamp=timestamp, - file_name_prefix=file_name_prefix, - suffix=suffix, - ) - logger.info("Completed median peak report export") - - -def save_peak_report_mean( - aggregated_df: pd.DataFrame, - script_directory: Path, - timestamp: str, - suffix: str = "_mean_peaks.xlsx", - include_pomm: bool = True, -) -> None: - """Save mean-based peak data tables to an Excel file.""" - - aep_dur_mean: pd.DataFrame = find_aep_dur_median( - aggregated_df=aggregated_df, - include_mean_columns=True, - include_median_columns=False, - ) - aep_mean_max: pd.DataFrame = find_aep_median_max( - aep_dur_median=aep_dur_mean, - include_mean_columns=True, - include_median_columns=False, - ) - file_name_prefix: str = suffix.removesuffix(".xlsx").lstrip("_") or "mean_peaks" - logger.info("Starting export of mean peak report") - _export_peak_report( - aep_dur_frame=aep_dur_mean, - aep_max_frame=aep_mean_max, + aep_dur_med: pd.DataFrame = find_aep_dur_median(aggregated_df=aggregated_df) + aep_med_max: pd.DataFrame = find_aep_median_max(aep_dur_median=aep_dur_med) + output_filename: str = f"{timestamp}{suffix}" + output_path: Path = script_directory / output_filename + logger.info(f"Starting export of median peak report to {output_path}") + logger.info(f"Starting export of median peak report to {output_path}") + save_to_excel( + aep_dur_max=aep_dur_med, + aep_max=aep_med_max, aggregated_df=aggregated_df, - script_directory=script_directory, + output_path=output_path, include_pomm=include_pomm, timestamp=timestamp, - file_name_prefix=file_name_prefix, - suffix=suffix, ) - logger.info("Completed mean peak report export") + logger.info(f"Completed median peak report export to {output_path}") + logger.info(f"Completed median peak report export to {output_path}") diff --git a/tests/functions/test_pomm_peak_report.py b/tests/functions/test_pomm_peak_report.py index 8f6ac9e7..33cc30fd 100644 --- a/tests/functions/test_pomm_peak_report.py +++ b/tests/functions/test_pomm_peak_report.py @@ -12,7 +12,7 @@ find_aep_dur_median, find_aep_median_max, ) -from ryan_library.scripts.pomm_max_items import run_mean_peak_report, run_median_peak_report +from ryan_library.scripts.pomm_max_items import run_median_peak_report DATA_DIR = Path(__file__).absolute().parent.parent / "test_data" / "tuflow" / "tutorials" @@ -48,29 +48,6 @@ def test_find_aep_dur_median_and_max() -> None: assert row_a["duration_text"] == "D2" -def test_find_aep_dur_median_filters_columns() -> None: - df = pd.DataFrame( - { - "aep_text": ["A", "A", "A", "A"], - "duration_text": ["D1", "D1", "D2", "D2"], - "Location": ["L1"] * 4, - "Type": ["Flow"] * 4, - "trim_runcode": ["Run"] * 4, - "AbsMax": [5, 1, 3, 7], - "tp_text": ["TP1", "TP2", "TP1", "TP2"], - } - ) - - median_only = find_aep_dur_median(df, include_mean_columns=False) - assert "MedianAbsMax" in median_only.columns - assert "mean_PeakFlow" not in median_only.columns - assert "mean_storm_is_median_storm" not in median_only.columns - - mean_only = find_aep_dur_median(df, include_median_columns=False) - assert "MedianAbsMax" not in mean_only.columns - assert "mean_PeakFlow" in mean_only.columns - - def test_run_median_peak_report_creates_excel() -> None: src_dir = DATA_DIR / "Module_01" / "results" run_median_peak_report(script_directory=src_dir, log_level="INFO") @@ -78,20 +55,5 @@ def test_run_median_peak_report_creates_excel() -> None: assert excel_files xl = pd.ExcelFile(excel_files[0]) assert set(["aep-dur-max", "aep-max", "POMM"]).issubset(set(xl.sheet_names)) - sheet_df = xl.parse("aep-dur-max") - assert "mean_PeakFlow" not in sheet_df.columns - for f in excel_files: - f.unlink() - - -def test_run_mean_peak_report_creates_excel() -> None: - src_dir = DATA_DIR / "Module_01" / "results" - run_mean_peak_report(script_directory=src_dir, log_level="INFO") - excel_files = list(src_dir.glob("*_mean_peaks.xlsx")) - assert excel_files - xl = pd.ExcelFile(excel_files[0]) - assert set(["aep-dur-max", "aep-max", "POMM"]).issubset(set(xl.sheet_names)) - sheet_df = xl.parse("aep-dur-max") - assert "MedianAbsMax" not in sheet_df.columns for f in excel_files: f.unlink() diff --git a/tests/scripts/test_pomm_peak_report.py b/tests/scripts/test_pomm_peak_report.py index 060997a5..16ec5ba4 100644 --- a/tests/scripts/test_pomm_peak_report.py +++ b/tests/scripts/test_pomm_peak_report.py @@ -11,7 +11,7 @@ find_aep_dur_median, find_aep_median_max, ) -from ryan_library.scripts.pomm_max_items import run_mean_peak_report, run_median_peak_report +from ryan_library.scripts.pomm_max_items import run_median_peak_report DATA_DIR: Path = Path(__file__).absolute().parent.parent / "test_data" / "tuflow" / "tutorials" @@ -49,26 +49,6 @@ def test_find_aep_dur_median_and_max() -> None: assert row_a["duration_text"] == "D2" -def test_find_aep_dur_median_filters_columns() -> None: - df = pd.DataFrame( - { - "aep_text": ["A", "A", "A", "A"], - "duration_text": ["D1", "D1", "D2", "D2"], - "Location": ["L1"] * 4, - "Type": ["Flow"] * 4, - "trim_runcode": ["Run"] * 4, - "AbsMax": [5, 1, 3, 7], - "tp_text": ["TP1", "TP2", "TP1", "TP2"], - } - ) - - median_only: DataFrame = find_aep_dur_median(df, include_mean_columns=False) - assert "mean_PeakFlow" not in median_only.columns - - mean_only: DataFrame = find_aep_dur_median(df, include_median_columns=False) - assert "MedianAbsMax" not in mean_only.columns - - def test_run_median_peak_report_creates_excel() -> None: src_dir: Path = DATA_DIR / "Module_01" / "results" run_median_peak_report(script_directory=src_dir, log_level="INFO") @@ -76,8 +56,6 @@ def test_run_median_peak_report_creates_excel() -> None: assert excel_files xl = pd.ExcelFile(path_or_buffer=excel_files[0]) assert set(["aep-dur-max", "aep-max", "POMM"]).issubset(set(xl.sheet_names)) - sheet_df = xl.parse("aep-dur-max") - assert "mean_PeakFlow" not in sheet_df.columns for f in excel_files: f.unlink() @@ -92,16 +70,3 @@ def test_run_median_peak_report_skips_pomm_sheet_when_disabled() -> None: assert {"aep-dur-max", "aep-max"}.issubset(set(xl.sheet_names)) for f in excel_files: f.unlink() - - -def test_run_mean_peak_report_creates_excel() -> None: - src_dir: Path = DATA_DIR / "Module_01" / "results" - run_mean_peak_report(script_directory=src_dir, log_level="INFO") - excel_files: list[Path] = list(src_dir.glob("*_mean_peaks.xlsx")) - assert excel_files - xl = pd.ExcelFile(path_or_buffer=excel_files[0]) - assert set(["aep-dur-max", "aep-max", "POMM"]).issubset(set(xl.sheet_names)) - sheet_df = xl.parse("aep-dur-max") - assert "MedianAbsMax" not in sheet_df.columns - for f in excel_files: - f.unlink() From dab4a55f0c423e2c3c6ca16099ac16de30966f9f Mon Sep 17 00:00:00 2001 From: Chain-Frost Date: Tue, 4 Nov 2025 16:47:43 +0800 Subject: [PATCH 6/8] Refine POMM peak exports --- ryan_library/functions/misc_functions.py | 34 ++++++-- ryan_library/scripts/pomm_utils.py | 103 +++++++++++++++-------- tests/scripts/test_pomm_peak_report.py | 29 ++++++- 3 files changed, 123 insertions(+), 43 deletions(-) diff --git a/ryan_library/functions/misc_functions.py b/ryan_library/functions/misc_functions.py index 783c4608..046490e7 100644 --- a/ryan_library/functions/misc_functions.py +++ b/ryan_library/functions/misc_functions.py @@ -107,6 +107,7 @@ def export_dataframes( output_directory: Path | None = None, column_widths: dict[str, dict[str, float]] | None = None, auto_adjust_width: bool = True, + file_name: str | None = None, ) -> None: """Export multiple DataFrames to Excel files with optional column widths. Args: @@ -128,6 +129,11 @@ def export_dataframes( auto_adjust_width (bool, optional): If set to True, automatically adjusts the column widths based on the maximum length of the data in each column. Defaults to True. + file_name (str | None, optional): + Explicit workbook name to use when exporting a single entry from + ``export_dict``. When provided, the auto-generated timestamp prefix is + skipped and ``file_name`` is written exactly (``.xlsx`` appended when + missing). Raises: ValueError: If the number of DataFrames doesn't match the number of sheets. InvalidFileException: If there's an issue with writing the Excel file. @@ -146,17 +152,24 @@ def export_dataframes( """ datetime_string: str = datetime.now().strftime(format="%Y%m%d-%H%M") - for file_name, content in export_dict.items(): + if file_name is not None and len(export_dict) != 1: + raise ValueError("'file_name' can only be provided when exporting a single workbook.") + + for export_key, content in export_dict.items(): dataframes: list[pd.DataFrame] = content.get("dataframes", []) sheets: list[str] = content.get("sheets", []) if len(dataframes) != len(sheets): + file_label: str = file_name if file_name is not None else export_key raise ValueError( - f"For file '{file_name}', the number of dataframes ({len(dataframes)}) and sheets ({len(sheets)}) must match." + f"For file '{file_label}', the number of dataframes ({len(dataframes)}) and sheets ({len(sheets)}) must match." ) # Determine the export path - export_filename: str = f"{datetime_string}_{file_name}.xlsx" + if file_name is not None: + export_filename = file_name if file_name.lower().endswith(".xlsx") else f"{file_name}.xlsx" + else: + export_filename = f"{datetime_string}_{export_key}.xlsx" export_path: Path = ( (output_directory / export_filename) if output_directory else Path(export_filename) # Defaults to CWD ) @@ -203,7 +216,7 @@ def export_dataframes( column_widths=column_widths[sheet], ) - logging.info(f"Finished exporting '{file_name}' to '{export_path}'") + logging.info(f"Finished exporting '{export_filename}' to '{export_path}'") except InvalidFileException as e: logging.error(f"Failed to write to '{export_path}': {e}") raise @@ -216,6 +229,7 @@ def save_to_excel( output_directory: Path | None = None, column_widths: dict[str, float] | None = None, auto_adjust_width: bool = True, + file_name: str | None = None, ) -> None: """Export a single DataFrame to an Excel file with a single sheet and optional column widths. @@ -232,7 +246,11 @@ def save_to_excel( {"Name": 20, "Age": 10} auto_adjust_width (bool, optional): If set to True, automatically adjusts the column widths based on the - maximum length of the data in each column. Defaults to True.""" + maximum length of the data in each column. Defaults to True. + file_name (str | None, optional): + Explicit file name to use for the exported workbook. When provided the + timestamp-based prefix is skipped and ``file_name`` is written exactly as + supplied (``.xlsx`` is appended automatically when missing).""" export_dict: dict[str, ExportContent] = {file_name_prefix: {"dataframes": [data_frame], "sheets": [sheet_name]}} # Prepare column_widths in the required format @@ -245,6 +263,7 @@ def save_to_excel( output_directory=output_directory, column_widths=prepared_column_widths, auto_adjust_width=auto_adjust_width, + file_name=file_name, ) def calculate_column_widths(self, df: pd.DataFrame) -> dict[str, float]: @@ -338,6 +357,8 @@ def save_to_excel( file_name_prefix: str = "Export", sheet_name: str = "Export", output_directory: Path | None = None, + *, + file_name: str | None = None, ) -> None: """Backwards-compatible function that delegates to ExcelExporter. Args: @@ -352,4 +373,7 @@ def save_to_excel( file_name_prefix=file_name_prefix, sheet_name=sheet_name, output_directory=output_directory, + column_widths=None, + auto_adjust_width=True, + file_name=file_name, ) diff --git a/ryan_library/scripts/pomm_utils.py b/ryan_library/scripts/pomm_utils.py index a3df48cc..f6049c96 100644 --- a/ryan_library/scripts/pomm_utils.py +++ b/ryan_library/scripts/pomm_utils.py @@ -18,7 +18,7 @@ find_files_parallel, is_non_zero_file, ) -from ryan_library.functions.misc_functions import calculate_pool_size +from ryan_library.functions.misc_functions import ExcelExporter, calculate_pool_size from ryan_library.processors.tuflow.base_processor import BaseProcessor from ryan_library.processors.tuflow.processor_collection import ProcessorCollection from ryan_library.classes.suffixes_and_dtypes import SuffixesConfig @@ -255,41 +255,38 @@ def save_to_excel( aep_dur_sheet_name=aep_dur_sheet_name, aep_sheet_name=aep_sheet_name, ) + + sheet_frames: dict[str, pd.DataFrame] = { + aep_dur_sheet_name: aep_dur_max, + aep_sheet_name: aep_max, + } + sheet_order: list[str] = [aep_dur_sheet_name, aep_sheet_name] + sheet_dfs: list[pd.DataFrame] = [aep_dur_max, aep_max] + + if include_pomm: + sheet_frames["POMM"] = aggregated_df + sheet_order.append("POMM") + sheet_dfs.append(aggregated_df) + data_dictionary_df: pd.DataFrame = _build_data_dictionary( registry=registry, - sheet_frames={ - aep_dur_sheet_name: aep_dur_max, - aep_sheet_name: aep_max, - }, + sheet_frames=sheet_frames, metadata_rows=metadata_rows, ) - with pd.ExcelWriter(output_path) as writer: - aep_dur_max.to_excel( - excel_writer=writer, - sheet_name=aep_dur_sheet_name, - index=False, - merge_cells=False, - ) - aep_max.to_excel( - excel_writer=writer, - sheet_name=aep_sheet_name, - index=False, - merge_cells=False, - ) - if include_pomm: - aggregated_df.to_excel( - excel_writer=writer, - sheet_name="POMM", - index=False, - merge_cells=False, - ) - data_dictionary_df.to_excel( - excel_writer=writer, - sheet_name=DATA_DICTIONARY_SHEET_NAME, - index=False, - merge_cells=False, - ) + sheet_order.append(DATA_DICTIONARY_SHEET_NAME) + sheet_dfs.append(data_dictionary_df) + + ExcelExporter().export_dataframes( + export_dict={ + output_path.stem: { + "dataframes": sheet_dfs, + "sheets": sheet_order, + } + }, + output_directory=output_path.parent, + file_name=output_path.name, + ) logger.info(f"Peak data exported to {output_path}") @@ -722,6 +719,35 @@ def find_aep_mean_max(aep_dur_mean: pd.DataFrame) -> pd.DataFrame: return aep_mean_max +def _remove_columns_containing(df: pd.DataFrame, substrings: tuple[str, ...]) -> pd.DataFrame: + """Return ``df`` without columns that include any ``substrings``.""" + + filtered_df: pd.DataFrame = df.copy() + if filtered_df.empty: + return filtered_df + + columns_to_drop: list[str] = [ + column + for column in filtered_df.columns + if any(substring in column.lower() for substring in substrings) + ] + if columns_to_drop: + filtered_df = filtered_df.drop(columns=columns_to_drop, errors="ignore") + return filtered_df + + +def _median_only_columns(df: pd.DataFrame) -> pd.DataFrame: + """Return a DataFrame containing only median-focused columns.""" + + return _remove_columns_containing(df=df, substrings=("mean",)) + + +def _mean_only_columns(df: pd.DataFrame) -> pd.DataFrame: + """Return a DataFrame containing only mean-focused columns.""" + + return _remove_columns_containing(df=df, substrings=("median",)) + + def save_peak_report_median( aggregated_df: pd.DataFrame, script_directory: Path, @@ -735,15 +761,17 @@ def save_peak_report_median( output_filename: str = f"{timestamp}{suffix}" output_path: Path = script_directory / output_filename logger.info(f"Starting export of median peak report to {output_path}") - logger.info(f"Starting export of median peak report to {output_path}") + aep_dur_med_filtered: pd.DataFrame = _median_only_columns(df=aep_dur_med) + aep_med_max_filtered: pd.DataFrame = _median_only_columns(df=aep_med_max) save_to_excel( - aep_dur_max=aep_dur_med, - aep_max=aep_med_max, + aep_dur_max=aep_dur_med_filtered, + aep_max=aep_med_max_filtered, aggregated_df=aggregated_df, output_path=output_path, include_pomm=include_pomm, timestamp=timestamp, ) + logger.info(f"Completed median peak report export to {output_path}") def save_peak_report_mean( @@ -760,9 +788,11 @@ def save_peak_report_mean( output_filename: str = f"{timestamp}{suffix}" output_path: Path = script_directory / output_filename logger.info(f"Starting export of mean peak report to {output_path}") + aep_dur_mean_filtered: pd.DataFrame = _mean_only_columns(df=aep_dur_mean) + aep_mean_max_filtered: pd.DataFrame = _mean_only_columns(df=aep_mean_max) save_to_excel( - aep_dur_max=aep_dur_mean, - aep_max=aep_mean_max, + aep_dur_max=aep_dur_mean_filtered, + aep_max=aep_mean_max_filtered, aggregated_df=aggregated_df, output_path=output_path, include_pomm=include_pomm, @@ -771,4 +801,3 @@ def save_peak_report_mean( aep_sheet_name="aep-mean-max", ) logger.info(f"Completed mean peak report export to {output_path}") - logger.info(f"Completed mean peak report export to {output_path}") diff --git a/tests/scripts/test_pomm_peak_report.py b/tests/scripts/test_pomm_peak_report.py index 16ec5ba4..025071ab 100644 --- a/tests/scripts/test_pomm_peak_report.py +++ b/tests/scripts/test_pomm_peak_report.py @@ -11,7 +11,7 @@ find_aep_dur_median, find_aep_median_max, ) -from ryan_library.scripts.pomm_max_items import run_median_peak_report +from ryan_library.scripts.pomm_max_items import run_mean_peak_report, run_median_peak_report DATA_DIR: Path = Path(__file__).absolute().parent.parent / "test_data" / "tuflow" / "tutorials" @@ -56,6 +56,14 @@ def test_run_median_peak_report_creates_excel() -> None: assert excel_files xl = pd.ExcelFile(path_or_buffer=excel_files[0]) assert set(["aep-dur-max", "aep-max", "POMM"]).issubset(set(xl.sheet_names)) + aep_dur_df = xl.parse(sheet_name="aep-dur-max") + assert all("mean" not in col.lower() for col in aep_dur_df.columns) + if not aep_dur_df.empty: + assert "MedianAbsMax" in aep_dur_df.columns + aep_max_df = xl.parse(sheet_name="aep-max") + assert all("mean" not in col.lower() for col in aep_max_df.columns) + if not aep_max_df.empty: + assert "MedianAbsMax" in aep_max_df.columns for f in excel_files: f.unlink() @@ -70,3 +78,22 @@ def test_run_median_peak_report_skips_pomm_sheet_when_disabled() -> None: assert {"aep-dur-max", "aep-max"}.issubset(set(xl.sheet_names)) for f in excel_files: f.unlink() + + +def test_run_mean_peak_report_creates_excel_with_mean_only_columns() -> None: + src_dir: Path = DATA_DIR / "Module_01" / "results" + run_mean_peak_report(script_directory=src_dir, log_level="INFO") + excel_files: list[Path] = list(src_dir.glob("*_mean_peaks.xlsx")) + assert excel_files + xl = pd.ExcelFile(path_or_buffer=excel_files[0]) + assert {"aep-dur-mean", "aep-mean-max"}.issubset(set(xl.sheet_names)) + aep_dur_df = xl.parse(sheet_name="aep-dur-mean") + assert all("median" not in col.lower() for col in aep_dur_df.columns) + if not aep_dur_df.empty: + assert "mean_PeakFlow" in aep_dur_df.columns + aep_max_df = xl.parse(sheet_name="aep-mean-max") + assert all("median" not in col.lower() for col in aep_max_df.columns) + if not aep_max_df.empty: + assert "mean_PeakFlow" in aep_max_df.columns + for f in excel_files: + f.unlink() From 3dadda47ef97f8480aaa80f9b72f6bac62685a43 Mon Sep 17 00:00:00 2001 From: Ryan Brook Date: Fri, 7 Nov 2025 14:11:28 +0800 Subject: [PATCH 7/8] update build, minor tweaks --- ...ryan_functions-25.11.7.1-py3-none-any.whl} | Bin 146333 -> 147001 bytes ryan_library/classes/column_definitions.py | 58 ++++++++++-------- ryan_library/scripts/pomm_utils.py | 7 +-- setup.py | 2 +- 4 files changed, 35 insertions(+), 32 deletions(-) rename dist/{ryan_functions-25.11.4.1-py3-none-any.whl => ryan_functions-25.11.7.1-py3-none-any.whl} (82%) diff --git a/dist/ryan_functions-25.11.4.1-py3-none-any.whl b/dist/ryan_functions-25.11.7.1-py3-none-any.whl similarity index 82% rename from dist/ryan_functions-25.11.4.1-py3-none-any.whl rename to dist/ryan_functions-25.11.7.1-py3-none-any.whl index 1e1f51a1f4396f486ed303f9a661f2abb343f5d7..f7dbbea42ff33e6544acb81c2607b3c2aab45e9f 100644 GIT binary patch delta 20062 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 R@^6o$1R%riDhZI`{{as-h#vp| delta 19412 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

QB(Sqe{`^-Bnv8oW11{NxYYew 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 "ColumnMetadataRegistry": """Return the default registry instance.""" if not hasattr(cls, "_INSTANCE"): - base_definitions = { + base_definitions: dict[str, ColumnDefinition] = { "AbsMax": ColumnDefinition( name="AbsMax", description="Absolute maximum magnitude observed within the event time-series.", @@ -74,7 +75,7 @@ def default(cls) -> "ColumnMetadataRegistry": ), "Max": ColumnDefinition( name="Max", - description="Maximum positive value in the event window.", + description="Maximum value in the event window.", value_type="float", ), "Min": ColumnDefinition( @@ -94,12 +95,17 @@ def default(cls) -> "ColumnMetadataRegistry": ), "Location": ColumnDefinition( name="Location", - description="Model result location identifier from the POMM file.", + description="Model result location identifier from the 2d_po file.", + value_type="string", + ), + "Chan ID": ColumnDefinition( + name="Chan ID", + description="Channel identifier from the 1d_nwk file.", value_type="string", ), "Type": ColumnDefinition( name="Type", - description="Quantity type (for example Flow, Water Level, Velocity).", + description="2d_po quantity type (for example Flow, Water Level, Velocity).", value_type="string", ), "aep_text": ColumnDefinition( @@ -109,32 +115,32 @@ def default(cls) -> "ColumnMetadataRegistry": ), "aep_numeric": ColumnDefinition( name="aep_numeric", - description="Annual exceedance probability represented as a numeric percentage.", + description="Annual exceedance probability represented as a numeric percentage e.g 1.", value_type="float", ), "duration_text": ColumnDefinition( name="duration_text", - description="Storm duration label parsed from the run code (e.g. '03hr').", + description="Storm duration label parsed from the run code (e.g. '00030m').", value_type="string", ), "duration_numeric": ColumnDefinition( name="duration_numeric", - description="Storm duration represented as a numeric value (hours).", + description="Storm duration represented as a numeric value (mins - tuflow style).", value_type="float", ), "tp_text": ColumnDefinition( name="tp_text", - description="Temporal pattern identifier parsed from the run code.", + description="Temporal pattern identifier parsed from the run code. e.g. TP07", value_type="string", ), "tp_numeric": ColumnDefinition( name="tp_numeric", - description="Temporal pattern identifier represented as a numeric value.", + description="Temporal pattern identifier represented as a numeric value. e.g. 1", value_type="int", ), "trim_runcode": ColumnDefinition( name="trim_runcode", - description="Run code without the AEP component, used to group comparable scenarios.", + description="Run code without the AEP, TP and Duration component. Used to group comparable scenarios.", value_type="string", ), "internalName": ColumnDefinition( @@ -169,42 +175,42 @@ def default(cls) -> "ColumnMetadataRegistry": ), "R01": ColumnDefinition( name="R01", - description="First segment of the run code (often the model identifier).", + description="First segment of the run code.", value_type="string", ), "R02": ColumnDefinition( name="R02", - description="Second segment of the run code (commonly spatial resolution).", + description="Second segment of the run code.", value_type="string", ), "R03": ColumnDefinition( name="R03", - description="Third segment of the run code (commonly the AEP label).", + description="Third segment of the run code.", value_type="string", ), "R04": ColumnDefinition( name="R04", - description="Fourth segment of the run code (commonly the storm duration).", + description="Fourth segment of the run code.", value_type="string", ), "R05": ColumnDefinition( name="R05", - description="Fifth segment of the run code (commonly the run number).", + description="Fifth segment of the run code.", value_type="string", ), "MedianAbsMax": ColumnDefinition( name="MedianAbsMax", - description="Median of absolute maxima across temporal patterns for the group.", + description="Absolute maxima across median of temporal patterns for the group.", value_type="float", ), "median_duration": ColumnDefinition( name="median_duration", - description="Duration associated with the median absolute maximum.", + description="Duration associated with the MedianAbsMax.", value_type="string", ), "median_TP": ColumnDefinition( name="median_TP", - description="Temporal pattern associated with the median absolute maximum.", + description="Temporal pattern associated with the MedianAbsMax.", value_type="string", ), "mean_including_zeroes": ColumnDefinition( @@ -254,7 +260,7 @@ def default(cls) -> "ColumnMetadataRegistry": ), "mean_storm_is_median_storm": ColumnDefinition( name="mean_storm_is_median_storm", - description="Indicates whether the mean storm matches the median storm selection.", + description="Deprecated. Don't use. Indicates whether the mean storm matches the median storm selection.", value_type="boolean", ), "aep_dur_bin": ColumnDefinition( @@ -269,32 +275,32 @@ def default(cls) -> "ColumnMetadataRegistry": ), } - sheet_specific = { + sheet_specific: dict[str, dict[str, ColumnDefinition]] = { "aep-dur-max": { "AbsMax": ColumnDefinition( name="AbsMax", - description="Peak magnitude selected for the AEP/Duration/Location/Type/run grouping.", + description="Peaks for the AEP/Duration/Location/Type/run grouping.", value_type="float", ), }, "aep-max": { "AbsMax": ColumnDefinition( name="AbsMax", - description="Peak magnitude selected for the AEP/Location/Type/run grouping.", + description="Peaks for the AEP/Location/Type/run grouping.", value_type="float", ), }, "aep-dur-med": { "MedianAbsMax": ColumnDefinition( name="MedianAbsMax", - description="Median magnitude for the specific AEP/Duration/Location/Type/run grouping.", + description="Medians for the specific AEP/Duration/Location/Type/run grouping.", value_type="float", ), }, "aep-med-max": { "MedianAbsMax": ColumnDefinition( name="MedianAbsMax", - description="Median magnitude selected as the maximum median per AEP/Location/Type/run grouping.", + description="Medians the maximum median per AEP/Location/Type/run grouping.", value_type="float", ), }, @@ -307,4 +313,4 @@ def default(cls) -> "ColumnMetadataRegistry": return cls._INSTANCE -__all__ = ["ColumnDefinition", "ColumnMetadataRegistry"] +__all__: list[str] = ["ColumnDefinition", "ColumnMetadataRegistry"] diff --git a/ryan_library/scripts/pomm_utils.py b/ryan_library/scripts/pomm_utils.py index f6049c96..eb41e390 100644 --- a/ryan_library/scripts/pomm_utils.py +++ b/ryan_library/scripts/pomm_utils.py @@ -26,7 +26,6 @@ NAType = type(pd.NA) -NAType = type(pd.NA) DATA_DICTIONARY_SHEET_NAME: str = "data-dictionary" @@ -550,7 +549,7 @@ def find_aep_median_max(aep_dur_median: pd.DataFrame) -> pd.DataFrame: ] if mean_value_columns: mean_df: pd.DataFrame = aep_dur_median.copy() - mean_df["_mean_peakflow_numeric"] = pd.to_numeric(mean_df.get("mean_PeakFlow"), errors="coerce") + mean_df["_mean_peakflow_numeric"] = pd.to_numeric(mean_df.get("mean_PeakFlow"), errors="coerce") # type: ignore if mean_df["_mean_peakflow_numeric"].notna().any(): idx_mean = ( mean_df[mean_df["_mean_peakflow_numeric"].notna()] @@ -727,9 +726,7 @@ def _remove_columns_containing(df: pd.DataFrame, substrings: tuple[str, ...]) -> return filtered_df columns_to_drop: list[str] = [ - column - for column in filtered_df.columns - if any(substring in column.lower() for substring in substrings) + column for column in filtered_df.columns if any(substring in column.lower() for substring in substrings) ] if columns_to_drop: filtered_df = filtered_df.drop(columns=columns_to_drop, errors="ignore") diff --git a/setup.py b/setup.py index 8542bdbd..ded6c129 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ name="ryan_functions", # Version scheme: yy.mm.dd.release_number # Increment when publishing new wheels - version="25.11.04.1", + version="25.11.07.1", packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), include_package_data=True, # Include package data as specified in MANIFEST.in # package_data={"ryan_library": ["py.typed"]}, From a002d7e5c317e773a8f4fca9fae593f9fee31a45 Mon Sep 17 00:00:00 2001 From: Ryan Brook Date: Fri, 7 Nov 2025 14:14:10 +0800 Subject: [PATCH 8/8] build library --- ...ryan_functions-25.11.7.2-py3-none-any.whl} | Bin 147001 -> 148134 bytes setup.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename dist/{ryan_functions-25.11.7.1-py3-none-any.whl => ryan_functions-25.11.7.2-py3-none-any.whl} (87%) diff --git a/dist/ryan_functions-25.11.7.1-py3-none-any.whl b/dist/ryan_functions-25.11.7.2-py3-none-any.whl similarity index 87% rename from dist/ryan_functions-25.11.7.1-py3-none-any.whl rename to dist/ryan_functions-25.11.7.2-py3-none-any.whl index f7dbbea42ff33e6544acb81c2607b3c2aab45e9f..08966f27c496675851bf611da56a7ee7a2caf6b3 100644 GIT binary patch delta 13306 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 MLO_gD0N>I71Fbc%iU0rr delta 12058 zcmZX4WmFtZ*DVY(xCVE3cXxMp4el;M1`jSlhTy^7-Q6t^Ah^3r@Py0rv9vG70@9z`4 zHE#CU;|P`wq^F2X%iBeIOU}@lv^S%9&`Gh!{^alZw&_`mtrqH4!7}-BB*au2K|=b> zUzPLd$GsS8KD0==ZUXP&&VLad#`!jssD@$&2i1Hu#;{xnyS)8OtB?%xwjJIqSLRzf zP&L&hr{n3_)f;0#E3$*9G-qGHSP5eJ$;4z>$k5yT0o@RYCZy7+f|-z1_@p^!DBYm9 zi;*(C1_*4#=RcWUEN&zW+CciDmf5HoZtVR53VAuN(On>8g zEta~1(jZncW-9Wd@KccMut%L>DK@0^s?xUk*ddEZM)P6)%k|G_s43*X7+b}

{Fz`ip6mr@y`?oX-4wA&xCJ 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 S2K^@)Bn}{hH6{hThyM??J}4Cc diff --git a/setup.py b/setup.py index ded6c129..d7ecdcae 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ name="ryan_functions", # Version scheme: yy.mm.dd.release_number # Increment when publishing new wheels - version="25.11.07.1", + version="25.11.07.2", packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), include_package_data=True, # Include package data as specified in MANIFEST.in # package_data={"ryan_library": ["py.typed"]},