From 2e581b53cc0c676d260ac5aabd0b90f7665c6739 Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:08:55 +0300 Subject: [PATCH 01/20] modify tab names in mkdcos.yml --- mkdocs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 7faa00e..64a147f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,8 +37,8 @@ nav: - User Guide: - SWAT+ Simulation: userguide/swatplus_simulation.md - - Sensitivity Analysis: userguide/sensitivity_analysis.md - - Read Output: userguide/read_output.md + - Sensitivity Interface: userguide/sensitivity_interface.md + - Data Analysis: userguide/data_analysis.md - API Reference: - TxtinoutReader: api/txtinout_reader.md From 70e826ad6e03ce51ed801e959c93aad057f46a44 Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:10:29 +0300 Subject: [PATCH 02/20] change ignoring Deprecated Warning to all Warnings due to Sobol indices calculation --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 47b08b5..d8b6883 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ packages = ["pySWATPlus"] [tool.pytest.ini_options] -addopts = "-rA -Wignore::DeprecationWarning --cov=pySWATPlus --cov-report=html:cov_pySWATPlus --cov-report=term -s" +addopts = "-rA -Wignore::Warning --cov=pySWATPlus --cov-report=html:cov_pySWATPlus --cov-report=term -s" testpaths = [ "tests" ] From accb1e46d6f72a7b502d30d7405e956c6b17382e Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:11:57 +0300 Subject: [PATCH 03/20] change name from read_output.md to data_analysis.md --- docs/userguide/read_output.md | 76 ----------------------------------- 1 file changed, 76 deletions(-) delete mode 100644 docs/userguide/read_output.md diff --git a/docs/userguide/read_output.md b/docs/userguide/read_output.md deleted file mode 100644 index 5f505b1..0000000 --- a/docs/userguide/read_output.md +++ /dev/null @@ -1,76 +0,0 @@ -# Read Output - -This section explains how to read output data generated from `SWAT+` simulations. -It covers accessing results from both standard simulations and Sobol-based sensitivity analyses. - - -## Read Time Series Data - -A standard `SWAT+` simulation generates TXT files with time series columns: `day`, `mon`, and `yr` for day, month, and year, respectively. -The following method creates a time series `DataFrame` that includes a new `date` column with `datetime.date` objects and save the resulting DataFrame to a JSON file. - -```python -import pySWATPlus - -output = pySWATPlus.DataManager().simulated_timeseries_df( - data_file=r"C:\Users\Username\custom_folder\channel_sd_mon.txt", - has_units=True, - begin_date='01-Jun-2011', - end_date='01-Jun-2013', - ref_day=15, - apply_filter={'name': ['cha561']}, - usecols=['name', 'flo_out'], - json_file=r"C:\Users\Username\output_folder\tmp.json" -) - -print(output) -``` - -## Read Sensitivity Simulation Data - -The [sensitivity analysis](https://swat-model.github.io/pySWATPlus/userguide/sensitivity_analysis/) generates a file called `sensitivity_simulation.json` within the simulation folder. -This JSON file stores all information necessary for Sobol simulation analysis, including: - -- `problem`: the Sobol problem definition -- `sample`: the generated sample array -- `simulation`: simulation outputs stored as JSON strings - -To retrieve and process this data for further analysis: - -```python -import json -import numpy -import pandas -import io - -# Path to the JSON file generated by the Sobol interface -json_file = r"C:\Users\Username\simulation_folder\sensitivity_simulation_sobol.json" - -# Load the JSON file -with open(json_file, 'r') as input_json: - sensitivity_dict = json.load(input_json) - -# Retrieve the Sobol problem definition -problem_dict = sensitivity_dict['problem'] - -# Retrieve the Sobol sample array -sample_array = numpy.array(sensitivity_dict['sample']) - -# Retrieve targeted DataFrames from sensitivity simulations -simulation_dict = sensitivity_dict['simulation'] -df_name = 'channel_sd_mon_df' # Example output -df_dict = {} - -for sim_key, sim_val in simulation_dict.items(): - # Convert JSON string to pandas DataFrame - df = pandas.read_json( - path_or_buf=io.StringIO(sim_val[df_name]) - ) - # Ensure 'date' column is in datetime.date format - df['date'] = df['date'].dt.date - df_dict[sim_key] = df -``` - - - - From 1bb7b381dc75316cd1f2d7a42fbda60636638c07 Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:13:20 +0300 Subject: [PATCH 04/20] change name sensitivity_analysis.md to sensitivity_interface.md because it focus only on simulation guide --- docs/userguide/sensitivity_analysis.md | 109 ------------------------- 1 file changed, 109 deletions(-) delete mode 100644 docs/userguide/sensitivity_analysis.md diff --git a/docs/userguide/sensitivity_analysis.md b/docs/userguide/sensitivity_analysis.md deleted file mode 100644 index 9ac886f..0000000 --- a/docs/userguide/sensitivity_analysis.md +++ /dev/null @@ -1,109 +0,0 @@ -# Sensitivity Analysis - -Sensitivity analysis helps quantify how variation in input parameters affects model outputs. This tutorial demonstrates how to perform sensitivity analysis on SWAT+ model parameters. -The parameter sampling is handled by the [SALib](https://github.com/SALib/SALib) Python package using [Sobol](https://doi.org/10.1016/S0378-4754(00)00270-6) sampling from a defined parameter space. - -- **Configuration Settings** - - ```python - import pySWATPlus - - # Initialize the project's TxtInOut folder - txtinout_reader = pySWATPlus.TxtinoutReader( - path=r"C:\Users\Username\project\Scenarios\Default\TxtInOut" - ) - - # Copy required files to an empty custom directory - target_dir = r"C:\Users\Username\custom_folder" - target_dir = txtinout_reader.copy_required_files( - target_dir=target_dir - ) - - # Initialize TxtinoutReader with the custom directory - target_reader = pySWATPlus.TxtinoutReader( - path=target_dir - ) - - # Disable CSV file generation to save time - target_reader.disable_csv_print() - - # Disable daily time series in print.prt (saves time and space) - target_reader.enable_object_in_print_prt( - obj=None, - daily=False, - monthly=True, - yearly=True, - avann=True - ) - - # Run a trial simulation to verify expected time series outputs - target_reader.run_swat( - begin_date='01-Jan-2010', - end_date='31-Dec-2012', - warmup=1, - print_prt_control={ - 'channel_sd': {} - } # enable daily time series for 'channel_sd' - ``` - ---- - -- **Sobol-Based Interface** - - This high-level interface builds on the above configuration to run sensitivity simulations using Sobol sampling. It includes: - - - Automatic generation of Sobol samples for the parameter space - - Parallel computation to speed up simulations - - Output extraction with filtering options (by date, column values, etc.) - - Structured export of results for downstream analysis - - The results can be used to compute performance metrics, compare with observed data, and calculate Sobol indices. - - ```python - # Sensitivity parameter space - parameters = [ - { - 'name': 'esco', - 'change_type': 'absval', - 'lower_bound': 0, - 'upper_bound': 1 - }, - { - 'name': 'perco', - 'change_type': 'absval', - 'lower_bound': 0, - 'upper_bound': 1 - } - ] - - # Target data extraction from sensitivity simulation - simulation_data = { - 'channel_sdmorph_yr.txt': { - 'has_units': True, - 'ref_day': 15, - 'ref_month': 6, - 'apply_filter': {'gis_id': [561]}, - 'usecols': ['gis_id', 'flo_out'] - }, - 'channel_sd_mon.txt': { - 'has_units': True, - 'begin_date': '01-Jun-2011', - 'ref_day': 15, - 'apply_filter': {'name': ['cha561'], 'yr': [2012]}, - 'usecols': ['gis_id', 'flo_out'] - } - } - - # Sensitivity simulation - if __name__ == '__main__': - output = pySWATPlus.SensitivityAnalyzer().simulation_by_sobol_sample( - parameters=parameters, - sample_number=1, - simulation_folder=r"C:\Users\Username\simulation_folder", - txtinout_folder=target_dir, - simulation_data=simulation_data, - clean_setup=True - ) - print(output) - ``` - From 7643b5baa47ddb42b30fb862b5020e8d3e1080cf Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:14:45 +0300 Subject: [PATCH 05/20] Modify content from bullet point structure to section structure in sensitivity_interface.md --- docs/userguide/sensitivity_interface.md | 109 ++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/userguide/sensitivity_interface.md diff --git a/docs/userguide/sensitivity_interface.md b/docs/userguide/sensitivity_interface.md new file mode 100644 index 0000000..f8cd6d5 --- /dev/null +++ b/docs/userguide/sensitivity_interface.md @@ -0,0 +1,109 @@ +# Sensitivity Analysis + +Sensitivity analysis helps quantify how variation in input parameters affects model outputs. This tutorial demonstrates how to perform sensitivity analysis on SWAT+ model parameters. +The parameter sampling is handled by the [SALib](https://github.com/SALib/SALib) Python package using [Sobol](https://doi.org/10.1016/S0378-4754(00)00270-6) sampling from a defined parameter space. + + +## Configuration Settings + +Before running a sensitivity simulation, you must define the necessary configuration settings. +These settings specify key parameters such as the simulation timeline, output print options, and other essential model controls. + + +```python +import pySWATPlus + +# Initialize the project's TxtInOut folder +txtinout_reader = pySWATPlus.TxtinoutReader( + path=r"C:\Users\Username\project\Scenarios\Default\TxtInOut" +) + +# Copy required files to an empty custom directory +target_dir = r"C:\Users\Username\custom_folder" +target_dir = txtinout_reader.copy_required_files( + target_dir=target_dir +) + +# Initialize TxtinoutReader with the custom directory +target_reader = pySWATPlus.TxtinoutReader( + path=target_dir +) + +# Disable CSV file generation to save time +target_reader.disable_csv_print() + +# Disable daily time series in print.prt (saves time and space) +target_reader.enable_object_in_print_prt( + obj=None, + daily=False, + monthly=True, + yearly=True, + avann=True +) + +# Run a trial simulation to verify expected time series outputs +target_reader.run_swat( + begin_date='01-Jan-2010', + end_date='31-Dec-2012', + warmup=1, + print_prt_control={ + 'channel_sd': {} + } # enable daily time series for 'channel_sd' +``` + +## Sobol-Based Interface + +This high-level interface builds on the above configuration to run sensitivity simulations using Sobol sampling. It includes: + +- Automatic generation of Sobol samples for the parameter space +- Parallel computation to speed up simulations +- Output extraction with filtering options +- Structured export of results for downstream analysis + +```python +# Sensitivity parameter space +parameters = [ + { + 'name': 'esco', + 'change_type': 'absval', + 'lower_bound': 0, + 'upper_bound': 1 + }, + { + 'name': 'perco', + 'change_type': 'absval', + 'lower_bound': 0, + 'upper_bound': 1 + } +] + +# Target data extraction from sensitivity simulation +simulation_data = { + 'channel_sdmorph_yr.txt': { + 'has_units': True, + 'ref_day': 15, + 'ref_month': 6, + 'apply_filter': {'gis_id': [561]}, + 'usecols': ['gis_id', 'flo_out'] + }, + 'channel_sd_mon.txt': { + 'has_units': True, + 'begin_date': '01-Jun-2011', + 'ref_day': 15, + 'apply_filter': {'name': ['cha561'], 'yr': [2012]}, + 'usecols': ['gis_id', 'flo_out'] + } +} + +# Sensitivity simulation +if __name__ == '__main__': + output = pySWATPlus.SensitivityAnalyzer().simulation_by_sobol_sample( + parameters=parameters, + sample_number=1, + simulation_folder=r"C:\Users\Username\simulation_folder", + txtinout_folder=target_dir, + simulation_data=simulation_data, + clean_setup=True + ) + print(output) +``` \ No newline at end of file From 35eff013a53319b7dda70f0b5f1d8326137b0e4d Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:16:05 +0300 Subject: [PATCH 06/20] Added content for performance metrics and Sobol indices in data_analysis.md --- docs/userguide/data_analysis.md | 97 +++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/userguide/data_analysis.md diff --git a/docs/userguide/data_analysis.md b/docs/userguide/data_analysis.md new file mode 100644 index 0000000..6687331 --- /dev/null +++ b/docs/userguide/data_analysis.md @@ -0,0 +1,97 @@ +# Data Analysis + +This section explains how to analysis data generated by different interfaces. + +```python +import pySWATPlus +``` + + +## Time Series Data + +A standard `SWAT+` simulation generates TXT files with time series columns: `day`, `mon`, and `yr` for day, month, and year, respectively. +The following method creates a time series `DataFrame` that includes a new `date` column with `datetime.date` objects and save the resulting DataFrame to a JSON file. + +```python +import pySWATPlus + +output = pySWATPlus.DataManager().simulated_timeseries_df( + data_file=r"C:\Users\Username\custom_folder\channel_sd_mon.txt", + has_units=True, + begin_date='01-Jun-2011', + end_date='01-Jun-2013', + ref_day=15, + apply_filter={'name': ['cha561']}, + usecols=['name', 'flo_out'], + json_file=r"C:\Users\Username\output_folder\tmp.json" +) + +print(output) +``` + + +## Read Sensitivity Simulation Data + +The sensitivity analysis performed using the [`simulation_by_sobol_sample`](https://swat-model.github.io/pySWATPlus/api/sensitivity_analyzer/#pySWATPlus.SensitivityAnalyzer.simulation_by_sobol_sample) method generates a file named `sensitivity_simulation.json` within the simulation directory. +This JSON file contains all the information required for Sobol sensitivity analysis, including: + +- `problem`: Sobol problem definition +- `sample`: List of generated samples +- `simulation`: Simulated `DataFrame` corresponding to each sample + +To retrieve the selected `DataFrame` for all scenarios, use: + +```python +output = pySWATPlus.DataManager().read_sensitive_dfs( + sim_file=r"C:\Users\Username\simulation_folder\sensitivity_simulation.json", + df_name='channel_sd_mon_df', + add_problem=True, + add_sample=True +) +``` + +## Performance Metrics + +For a selected `DataFrame`, performance metrics across all scenarios can be computed by comparing model outputs with observed data. + +To view the mapping between performance indicators and their abbreviations: + +```python +indicators = pySWATPlus.PerformanceMetrics().indicator_names +``` + +To compute performance metrics for the desired indicators: + + +```python +output = pySWATPlus.SensitivityAnalyzer().scenario_indicators( + sim_file=r"C:\Users\Username\simulation_folder\sensitivity_simulation.json", + df_name='channel_sd_mon_df', + sim_col='flo_out', + obs_file=r"C:\Users\Username\observed_folder\discharge_monthly.csv", + date_format='%Y-%m-%d', + obs_col='discharge', + indicators=['NSE', 'MSE'], + json_file=r"C:\Users\Username\data_analysis\performance_metrics.json" +) +``` + +## Sobol Indices + +The available indicators can also be used to compute Sobol indices (first, second, and total orders) along with their confidence intervals. + +```python +output = pySWATPlus.SensitivityAnalyzer().sobol_indices( + sim_file=r"C:\Users\Username\simulation_folder\sensitivity_simulation.json", + df_name='channel_sd_mon_df', + sim_col='flo_out', + obs_file=r"C:\Users\Username\observed_folder\discharge_monthly.csv", + date_format='%Y-%m-%d', + obs_col='discharge', + indicators=['KGE', 'RMSE'], + json_file=r"C:\Users\Username\data_analysis\sobol_indices.json" +) +``` + + + From 314bff2642573ef79dd2da6537824f7a656403c4 Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:17:38 +0300 Subject: [PATCH 07/20] Add guide for newly added method in TxtinoutReader class --- docs/userguide/swatplus_simulation.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/userguide/swatplus_simulation.md b/docs/userguide/swatplus_simulation.md index 62221c3..91b3f8f 100644 --- a/docs/userguide/swatplus_simulation.md +++ b/docs/userguide/swatplus_simulation.md @@ -102,6 +102,15 @@ The following steps demonstrate how to configure parameters in a custom director ) ``` +- Set output print interval within the simulation period: + + ```python + # Set ouput print every other day + target_reader.set_print_interval( + interval=2 + ) + ``` + - Run the SWAT+ simulation with a modified `esco` parameter: ```python @@ -144,7 +153,8 @@ txtinout_reader.run_swat( begin_date='01-Jan-2012', # optional end_date= '31-Dec-2016', # optional warmup=1, # optional - print_prt_control={'channel_sd': {'daily': False}} # optional + print_prt_control={'channel_sd': {'daily': False}}, # optional + print_interval=1 # optional ) ``` From 4ae75c9bb23ff5c1278ec6d378c5d87478bd511c Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:18:22 +0300 Subject: [PATCH 08/20] Add content for next release --- docs/changelog.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 15ce4f2..fcbcc85 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,28 @@ # Release Notes +## Version 1.2.0 (Month DD, YYYY, not released yet) + +- Introduced the `pySWATPlus.DataManager` class with the following methods to support data processing workflows: + + - `read_sensitive_dfs`: Reads sensitivity simulation data generated by the `simulation_by_sobol_sample` method in the `pySWATPlus.SensitivityAnalyzer` class. + - `simulated_timeseries_df`: Moved from the `pySWATPlus.SensitivityAnalyzer` class for improved modularity. + +- Introduced the `pySWATPlus.PerformanceMetrics` class to compute performance metrics between simulated and observed values using the following indicators: + + - Nash–Sutcliffe Efficiency + - Kling–Gupta Efficiency + - Mean Squared Error + - Root Mean Squared Error + - Percent Bias + - Mean Absolute Relative Error + +- Added the `sobol_indices` method to the `pySWATPlus.SensitivityAnalyzer`** class for computing Sobol indices using the available indicators in the `pySWATPlus.PerformanceMetrics` class. + +- Added new methods to the `pySWATPlus.TxtinoutReader` class: + + - `set_simulation_timestep`: Modifies the simulation timestep in the `time.sim` file. + - `set_print_interval`: Modifies the print interval in the `print.prt` file. + ## Version 1.1.0 (August 26, 2025) From 72be0072c2ef06356490bac0bfcee655743e368c Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:19:49 +0300 Subject: [PATCH 09/20] Remove test_performance_metrics.py because it is no longer required --- tests/test_performance_metrics.py | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 tests/test_performance_metrics.py diff --git a/tests/test_performance_metrics.py b/tests/test_performance_metrics.py deleted file mode 100644 index d08ada9..0000000 --- a/tests/test_performance_metrics.py +++ /dev/null @@ -1,21 +0,0 @@ -import pySWATPlus -import pytest - - -@pytest.fixture(scope='class') -def performance_metrics(): - - # initialize TxtinoutReader class - performance_metrics = pySWATPlus.PerformanceMetrics() - - yield performance_metrics - - -def test_error_options( - performance_metrics -): - - error_options = performance_metrics.error_options - - assert isinstance(error_options, dict) - assert len(error_options) == 6 From 55f8a50112fd929d64996f0c14981e7356fdccb4 Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:20:52 +0300 Subject: [PATCH 10/20] Add method to read sensitivity simulation output in data_manager.py --- pySWATPlus/data_manager.py | 51 ++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/pySWATPlus/data_manager.py b/pySWATPlus/data_manager.py index c37e7a5..2ae3792 100644 --- a/pySWATPlus/data_manager.py +++ b/pySWATPlus/data_manager.py @@ -173,10 +173,9 @@ def simulated_timeseries_df( if json_file is not None: json_file = pathlib.Path(json_file).resolve() # Raise error for invalid JSON file extension - if json_file.suffix.lower() != '.json': - raise ValueError( - f'Expected ".json" extension for "json_file", but got "{json_file.suffix}"' - ) + validators._json_extension( + json_file=json_file + ) # Write DataFrame to the JSON file copy_df = copy.deepcopy( x=df @@ -184,8 +183,50 @@ def simulated_timeseries_df( copy_df[date_col] = copy_df[date_col].apply(lambda x: x.strftime('%d-%b-%Y')) copy_df.to_json( path_or_buf=json_file, - orient="records", + orient='records', indent=4 ) return df + + def read_sensitive_dfs( + self, + sim_file: pathlib.Path, + df_name: str, + add_problem: bool = False, + add_sample: bool = False + ) -> dict[str, typing.Any]: + ''' + Read sensitivity simulation data generated by the [`simulation_by_sobol_sample`](https://swat-model.github.io/pySWATPlus/api/sensitivity_analyzer/#pySWATPlus.SensitivityAnalyzer.simulation_by_sobol_sample) + method, and return a dictionary mapping each scenario integer to its corresponding `DataFrame`. + + The returned dictionary may include the following keys: + - `scenario` (default): A mapping between each scenario integer and its corresponding DataFrame. + - `problem` (optional): The problem definition. + - `sample` (optional): The sample list used in the sensitivity simulation. + + Args: + sim_file (str | pathlib.Path): Path to the `sensitivity_simulation.json` file generated by `simulation_by_sobol_sample`. + + df_name (str): Name of the `DataFrame` within `sensitivity_simulation.json`. + + add_problem (bool): If `True`, includes the problem definition in the output dictionary under the `problem` key. Defaults to `False`. + + add_sample (bool): If `True`, includes the sample list used in the simulation under the `sample` key. Defaults to `False`. + + Returns: + A dictionary with the following keys: + + - `scenario` (default): A mapping between each scenario integer and its corresponding DataFrame. + - `problem` (optional): The problem definition. + - `sample` (optional): The sample list used in the sensitivity simulation. + ''' + + output = utils._retrieve_sensitivity_output( + sim_file=sim_file, + df_name=df_name, + add_problem=add_problem, + add_sample=add_sample + ) + + return output From 50cded7979483901045f6cb495928d1f9dc59f8a Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:22:06 +0300 Subject: [PATCH 11/20] Add methods to compute performance metrics by several indicators in performance_metrics.py --- pySWATPlus/performance_metrics.py | 355 +++++++++++++++++++++++++++++- 1 file changed, 346 insertions(+), 9 deletions(-) diff --git a/pySWATPlus/performance_metrics.py b/pySWATPlus/performance_metrics.py index 69e808e..9e0f5db 100644 --- a/pySWATPlus/performance_metrics.py +++ b/pySWATPlus/performance_metrics.py @@ -1,21 +1,25 @@ +import pandas +import pathlib +import typing +from . import utils +from . import validators + + class PerformanceMetrics: ''' - WARNING: - This class is currently under development and not recommended for use. - - Provide functionality to compute error and efficiency metrics for the SWAT+ model. + Provide functionality to compute errors between simulated and ovserved values. ''' @property - def error_options( + def indicator_names( self ) -> dict[str, str]: ''' - Return a dictionary of available error options. Keys are the commonly used abbreviations, - and values are the corresponding full error metric names. + Return a dictionary of available indicators. Keys are the commonly used abbreviations, + and values are the corresponding full indicator names. ''' - error_names = { + abbr_name = { 'NSE': 'Nash-Sutcliffe Efficiency', 'KGE': 'Kling-Gupta Efficiency', 'MSE': 'Mean Squared Error', @@ -24,4 +28,337 @@ def error_options( 'MARE': 'Mean Absolute Relative Error' } - return error_names + return abbr_name + + def compute_nse( + self, + df: pandas.DataFrame, + sim_col: str, + obs_col: str + ) -> float: + ''' + Calculate the [`Nash-Sutcliffe Efficiency`](https://doi.org/10.1016/0022-1694(70)90255-6) + metric between simulated and observed values + + Args: + df (pandas.DataFrame): DataFrame containing at least two columns with simulated and observed values. + + sim_col (str): Name of the column containing simulated values. + + obs_col (str): Name of the column containing observed values. + ''' + + # Simulation values + sim_arr = df[sim_col].astype(float) + + # Observed values + obs_arr = df[obs_col].astype(float) + + # Output + numerator = ((sim_arr - obs_arr).pow(2)).sum() + denominator = ((obs_arr - obs_arr.mean()).pow(2)).sum() + output = float(1 - numerator / denominator) + + return output + + def compute_kge( + self, + df: pandas.DataFrame, + sim_col: str, + obs_col: str + ) -> float: + ''' + Calculate the [`Kling-Gupta Efficiency`](https://doi.org/10.1016/j.jhydrol.2009.08.003) + metric between simulated and observed values + + Args: + df (pandas.DataFrame): DataFrame containing at least two columns with simulated and observed values. + + sim_col (str): Name of the column containing simulated values. + + obs_col (str): Name of the column containing observed values. + ''' + + # Simulation values + sim_arr = df[sim_col].astype(float) + + # Observed values + obs_arr = df[obs_col].astype(float) + + # Pearson correlation coefficient (r) + r = sim_arr.corr(obs_arr) + + # Variability of prediction errors + alpha = sim_arr.std() / obs_arr.std() + + # Bias + beta = sim_arr.mean() / obs_arr.mean() + + # Output + output = float(1 - pow(pow(r - 1, 2) + pow(alpha - 1, 2) + pow(beta - 1, 2), 0.5)) + + return output + + def compute_mse( + self, + df: pandas.DataFrame, + sim_col: str, + obs_col: str + ) -> float: + ''' + Calculate the Mean Squared Error metric between simulated and observed values + + Args: + df (pandas.DataFrame): DataFrame containing at least two columns with simulated and observed values. + + sim_col (str): Name of the column containing simulated values. + + obs_col (str): Name of the column containing observed values. + ''' + + # Simulation values + sim_arr = df[sim_col].astype(float) + + # Observed values + obs_arr = df[obs_col].astype(float) + + # Output + output = float(((sim_arr - obs_arr).pow(2)).mean()) + + return output + + def compute_rmse( + self, + df: pandas.DataFrame, + sim_col: str, + obs_col: str + ) -> float: + ''' + Calculate the Root Mean Squared Error metric between simulated and observed values. + + Args: + df (pandas.DataFrame): DataFrame containing at least two columns with simulated and observed values. + + sim_col (str): Name of the column containing simulated values. + + obs_col (str): Name of the column containing observed values. + ''' + + # computer MSE error + mse_value = self.compute_mse( + df=df, + sim_col=sim_col, + obs_col=obs_col + ) + + # Output + output = float(pow(mse_value, 0.5)) + + return output + + def compute_pbias( + self, + df: pandas.DataFrame, + sim_col: str, + obs_col: str + ) -> float: + ''' + Calculate the Percent Bias metric between simulated and observed values. + + Args: + df (pandas.DataFrame): DataFrame containing at least two columns with simulated and observed values. + + sim_col (str): Name of the column containing simulated values. + + obs_col (str): Name of the column containing observed values. + ''' + + # Simulation values + sim_arr = df[sim_col].astype(float) + + # Observed values + obs_arr = df[obs_col].astype(float) + + # Output + output = float(100 * (sim_arr - obs_arr).sum() / obs_arr.sum()) + + return output + + def compute_mare( + self, + df: pandas.DataFrame, + sim_col: str, + obs_col: str + ) -> float: + ''' + Calculate the Mean Absolute Relative Error metric between simulated and observed values + + Args: + df (pandas.DataFrame): DataFrame containing at least two columns with simulated and observed values. + + sim_col (str): Name of the column containing simulated values. + + obs_col (str): Name of the column containing observed values. + ''' + + # Simulation values + sim_arr = df[sim_col].astype(float) + + # Observed values + obs_arr = df[obs_col].astype(float) + + # Output + output = float(((obs_arr - sim_arr) / obs_arr).abs().mean()) + + return output + + def scenario_indicators( + self, + sim_file: str | pathlib.Path, + df_name: str, + sim_col: str, + obs_file: str | pathlib.Path, + date_format: str, + obs_col: str, + indicators: list[str], + json_file: typing.Optional[str | pathlib.Path] = None + ) -> dict[str, typing.Any]: + ''' + Compute performance indicators for sample scenarios obtained using + the [`simulation_by_sobol_sample`](https://swat-model.github.io/pySWATPlus/api/sensitivity_analyzer/#pySWATPlus.SensitivityAnalyzer.simulation_by_sobol_sample) method. + + Before computing the indicators, simulated and observed values are normalized using the formula `(v - min_v) / (max_v - min_v)`, + where `min_v` and `max_v` represent the minimum and maximum of all simulated and observed values combined. + + The method returns a dictionary with two keys: + + - `problem`: The definition dictionary passed to Sobol sampling. + - `indicator`: A `DataFrame` containing the `Scenario` column and one column per indicator, + with scenario indices and corresponding indicator values. + + Args: + sim_file (str | pathlib.Path): Path to the `sensitivity_simulation.json` file produced by `simulation_by_sobol_sample`. + + df_name (str): Name of the `DataFrame` within `sensitivity_simulation.json` from which to compute scenario indicators. + + sim_col (str): Name of the column in `df_name` containing simulated values. + + obs_file (str | pathlib.Path): Path to the CSV file containing observed data. The file must include a + `date` column (used to merge simulated and observed data) and use a comma as the separator. + + date_format (str): Date format of the `date` column in `obs_file`, used to parse `datetime.date` objects from date strings. + + obs_col (str): Name of the column in `obs_file` containing observed data. All negative and `None` observed values are removed + due to the normalization of observed and similated values before computing indicators. + + indicators (list[str]): List of performance indicators to compute. Available options: + + - `NSE`: Nash–Sutcliffe Efficiency + - `KGE`: Kling–Gupta Efficiency + - `MSE`: Mean Squared Error + - `RMSE`: Root Mean Squared Error + - `PBIAS`: Percent Bias + - `MARE`: Mean Absolute Relative Error + + json_file (str | pathlib.Path, optional): Path to a JSON file for saving the output `DataFrame` containing indicator values. + If `None` (default), the `DataFrame` is not saved. + + Returns: + Dictionary with two keys, `problem` and `indicator`, and their corresponding values. + ''' + + # Check input variables type + validators._variable_origin_static_type( + vars_types=typing.get_type_hints( + obj=self.scenario_indicators + ), + vars_values=locals() + ) + + # Check valid name of metric + abbr_indicator = self.indicator_names + for indicator in indicators: + if indicator not in abbr_indicator: + raise ValueError( + f'Invalid name "{indicator}" in "indicatiors" list; expected names are {list(abbr_indicator.keys())}' + ) + + # Observed DataFrame + obs_df = utils._df_observed( + obs_file=pathlib.Path(obs_file).resolve(), + date_format=date_format, + obs_col=obs_col + ) + obs_df.columns = ['date', 'obs'] + + # Retrieve sensitivity output + sensitivity_sim = utils._retrieve_sensitivity_output( + sim_file=pathlib.Path(sim_file).resolve(), + df_name=df_name, + add_problem=True, + add_sample=False + ) + + # Empty DataFrame to store scenario indicators + inct_df = pandas.DataFrame( + columns=indicators + ) + + # Iterate scenarios + for key, df in sensitivity_sim['scenario'].items(): + df = df[['date', sim_col]] + df.columns = ['date', 'sim'] + # Merge scenario DataFrame with observed DataFrame + merge_df = df.merge( + right=obs_df.copy(), + how='inner', + on='date' + ) + # Normalized DataFrame + norm_df = utils._df_normalize( + df=merge_df[['sim', 'obs']] + ) + # Iterate indicators + for indicator in indicators: + # Method from indicator abbreviation + indicator_method = getattr( + self, + f'compute_{indicator.lower()}' + ) + # indicator value + key_indicator = indicator_method( + df=norm_df, + sim_col='sim', + obs_col='obs' + ) + # Store error in DataFrame + inct_df.loc[key, indicator] = key_indicator + + # Reset index to scenario column + scnro_col = 'Scenario' + inct_df = inct_df.reset_index( + names=[scnro_col] + ) + inct_df[scnro_col] = inct_df[scnro_col].astype(int) + + # Save DataFrame + if json_file is not None: + json_file = pathlib.Path(json_file).resolve() + # Raise error for invalid JSON file extension + validators._json_extension( + json_file=json_file + ) + # Write DataFrame to the JSON file + inct_df.to_json( + path_or_buf=json_file, + orient='records', + indent=4 + ) + + # Output dictionary + output = { + 'problem': sensitivity_sim['problem'], + 'indicator': inct_df + } + + return output From 7e727f0fca1a2e68020f9b1077ac914e8bf9c32b Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:23:17 +0300 Subject: [PATCH 12/20] Add a comment in types.py to remove an import module in future when Pyhton 3.10 support is not required --- pySWATPlus/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pySWATPlus/types.py b/pySWATPlus/types.py index a110edd..7fd91db 100644 --- a/pySWATPlus/types.py +++ b/pySWATPlus/types.py @@ -1,5 +1,5 @@ import typing -import typing_extensions +import typing_extensions # only for Python 3.10 and will be removed in future import pydantic From 417d2af9f3e0136fccacbdd7bb418323c2f8ad46 Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:27:24 +0300 Subject: [PATCH 13/20] Add private method to read sensitivity simulation output --- pySWATPlus/utils.py | 101 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/pySWATPlus/utils.py b/pySWATPlus/utils.py index a94b808..670876e 100644 --- a/pySWATPlus/utils.py +++ b/pySWATPlus/utils.py @@ -1,7 +1,9 @@ import pandas +import datetime +import json +import io import pathlib import typing -import datetime from collections.abc import Iterable from collections.abc import Callable from .types import ModifyDict @@ -37,7 +39,7 @@ def _date_str_to_object( date_str: str ) -> datetime.date: ''' - Convert a date string in 'YYYY-MM-DD' format to a `datetime.date` object + Convert a date string in 'DD-Mon-YYYY' format to a `datetime.date` object ''' date_fmt = '%d-%b-%Y' @@ -131,8 +133,9 @@ def _compact_units( Compact a 1-based list of unit IDs into SWAT units syntax. Consecutive unit IDs are represented as a range using negative numbers: - - Single units are listed as positive numbers. - - Consecutive ranges are represented as [start, -end]. + + - Single units are listed as positive numbers. + - Consecutive ranges are represented as [start, -end]. All IDs must be 1-based (Fortran-style). ''' @@ -184,3 +187,93 @@ def _parse_conditions( conditions_parsed.append(f'{parameter:<19}{"=":<15} {0:<16}{key}') return conditions_parsed + + +def _df_observed( + obs_file: pathlib.Path, + date_format: str, + obs_col: str +) -> pandas.DataFrame: + ''' + Read the CSV file specified by `obs_file`, parses the date column using the provided + `date_format`, and returns a `DataFrame` with two columns: `date` (as `datetime.date`) + and `obs_col` (the observed values). + ''' + + # DataFrame + obs_df = pandas.read_csv( + filepath_or_buffer=obs_file, + parse_dates=['date'], + date_format=date_format + ) + + # Date string to datetime.date objects + obs_df = obs_df[['date', obs_col]] + obs_df['date'] = obs_df['date'].dt.date + + # Remove any negative observed data + obs_df = obs_df[obs_df[obs_col] >= 0].reset_index(drop=True) + + return obs_df + + +def _df_normalize( + df: pandas.DataFrame +) -> pandas.DataFrame: + ''' + Normalize the values in the input `DataFrame` using the formula `(df - min) / (max - min)`, + where `min` and `max` represent the minimum and maximum values of the `DataFrame` + prior to normalization. + ''' + + # Minimum and maximum values + df_min = df.min().min() + df_max = df.max().max() + + # Normalized DataFrame + norm_df = (df - df_min) / (df_max - df_min) + + return norm_df + + +def _retrieve_sensitivity_output( + sim_file: pathlib.Path, + df_name: str, + add_problem: bool, + add_sample: bool +) -> dict[str, typing.Any]: + ''' + Retrieve sensitivity simulation data and generate a dictionary containing the following keys: + + - `scenario` (default): A mapping between each scenario integer and its corresponding DataFrame. + - `problem` (optional): The problem definition. + - `sample` (optional): The sample list used in the sensitivity simulation. + ''' + + # Load sensitivity simulation dictionary from JSON file + with open(sim_file, 'r') as input_sim: + sensitivity_sim = json.load(input_sim) + + # Dictionary of sample DataFrames + sample_dfs = {} + for key, val in sensitivity_sim['simulation'].items(): + key_df = pandas.read_json( + path_or_buf=io.StringIO(val[df_name]) + ) + key_df['date'] = key_df['date'].dt.date + sample_dfs[int(key)] = key_df + + # Default output dictionary + output = { + 'scenario': sample_dfs + } + + # Add problem definition in output + if add_problem: + output['problem'] = sensitivity_sim['problem'] + + # Add numpy sample array in output + if add_sample: + output['sample'] = sensitivity_sim['sample'] + + return output From 1ef3e99ff42a0ddd89614a0e40357d69c73f3af6 Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:28:26 +0300 Subject: [PATCH 14/20] Added new method to validate empty directory --- pySWATPlus/validators.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pySWATPlus/validators.py b/pySWATPlus/validators.py index 684405a..8f40e08 100644 --- a/pySWATPlus/validators.py +++ b/pySWATPlus/validators.py @@ -67,6 +67,21 @@ def _path_directory( return None +def _empty_directory( + path: pathlib.Path +) -> None: + ''' + Ensure the input directory is empty. + ''' + + if any(path.iterdir()): + raise FileExistsError( + f'Input directory {str(path)} contains files; expected an empty directory' + ) + + return None + + def _date_begin_earlier_end( begin_date: datetime.date, end_date: datetime.date @@ -292,3 +307,18 @@ def _calibration_parameters( ) return None + + +def _json_extension( + json_file: pathlib.Path +) -> None: + ''' + Validate that the file has a JSON extension. + ''' + + if json_file.suffix.lower() != '.json': + raise ValueError( + f'Expected ".json" extension for "json_file", but got "{json_file.suffix}"' + ) + + return None From 9cfe377328bfc3e8a1075ef3079c2814b237ff25 Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:29:14 +0300 Subject: [PATCH 15/20] Modifed docstrings for newly added methods --- pySWATPlus/txtinout_reader.py | 38 +++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/pySWATPlus/txtinout_reader.py b/pySWATPlus/txtinout_reader.py index 834b628..bb02675 100644 --- a/pySWATPlus/txtinout_reader.py +++ b/pySWATPlus/txtinout_reader.py @@ -24,7 +24,7 @@ def __init__( Create a TxtinoutReader instance for accessing SWAT+ model files. Args: - path (str or Path): Path to the `TxtInOut` folder, which must contain + path (str | pathlib.Path): Path to the `TxtInOut` folder, which must contain exactly one SWAT+ executable `.exe` file. ''' @@ -84,7 +84,7 @@ def enable_object_in_print_prt( generated even when disabled. Args: - obj (str or None): The name of the object to update (e.g., 'channel_sd', 'reservoir'). + obj (str | None): The name of the object to update (e.g., 'channel_sd', 'reservoir'). If `None`, all objects in the `print.prt` file are updated with the specified time frequency settings. daily (bool): If `True`, enable daily frequency output. monthly (bool): If `True`, enable monthly frequency output. @@ -431,7 +431,11 @@ def set_print_interval( interval: int, ) -> None: ''' - Set interval in print.prt file + Set the print interval in the `print.prt` file. + + Args: + interval (int): The output print interval within the simulation period. + For example, if `interval = 2`, output will be printed every other day. ''' # Check input variables type @@ -464,7 +468,10 @@ def set_start_date_print( start_date: str, ) -> None: ''' - Set day_start in print.prt file + Set the start date in the `print.prt` file to define when output files begin recording simulation results. + + Args: + start_date (str): Start date in `DD-Mon-YYYY` format (e.g., 01-Jun-2010). ''' # Check input variables type @@ -510,7 +517,7 @@ def copy_required_files( `TxtinoutReader` instance to the specified directory for SWAT+ simulation. Args: - target_dir (str or Path): Path to the empty directory where the required files will be copied. + target_dir (str | pathlib.Path): Path to the empty directory where the required files will be copied. Returns: The path to the target directory containing the copied files. @@ -532,11 +539,10 @@ def copy_required_files( path=target_dir ) - # Check targe_dir is empty - if any(target_dir.iterdir()): - raise FileExistsError( - f'Input target_dir {str(target_dir)} contains files; expected an empty directory' - ) + # Check target_dir is empty + validators._empty_directory( + path=target_dir + ) # Ignored files _ignored_files_endswith = tuple( @@ -717,8 +723,7 @@ def _apply_swat_configuration( warmup: typing.Optional[int] = None, print_prt_control: typing.Optional[dict[str, dict[str, bool]]] = None, start_date_print: typing.Optional[str] = None, - print_interval: int = 1, - + print_interval: typing.Optional[int] = None ) -> None: ''' Set begin and end year for the simulation, the warm-up period, and toggles the elements in print.prt file @@ -867,14 +872,14 @@ def run_swat( warmup: typing.Optional[int] = None, print_prt_control: typing.Optional[dict[str, dict[str, bool]]] = None, start_date_print: typing.Optional[str] = None, - print_interval: int = 1, + print_interval: typing.Optional[int] = None, skip_validation: bool = False ) -> pathlib.Path: ''' Run the SWAT+ simulation with optional parameter changes. Args: - target_dir (str or pathlib.Path): Path to the directory where the simulation will be done. + target_dir (str | pathlib.Path): Path to the directory where the simulation will be done. If None, the simulation runs directly in the current folder. parameters (ModifyType): List of dictionaries specifying parameter changes in the `calibration.cal` file. @@ -944,8 +949,7 @@ def run_swat( start_date_print (str): Number of years at the beginning of the simulation to not print output - print_interval (int): Print interval within the period. - Example: If interval = 2, output will be printed for every other day. + print_interval (int): Print interval within the period. For example, if interval = 2, output will be printed for every other day. skip_validation (bool): If `True`, skip validation of units and conditions in parameter changes. @@ -990,7 +994,7 @@ def run_swat( warmup=warmup, print_prt_control=print_prt_control, start_date_print=start_date_print, - print_interval=print_interval, + print_interval=print_interval ) # Create calibration.cal file From 64c774a39f26892d51b5f8c12ccd16163bb20b6c Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:30:19 +0300 Subject: [PATCH 16/20] Added new method for computing Sobol indices --- pySWATPlus/sensitivity_analyzer.py | 118 +++++++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 5 deletions(-) diff --git a/pySWATPlus/sensitivity_analyzer.py b/pySWATPlus/sensitivity_analyzer.py index bca516d..c4dbeb8 100644 --- a/pySWATPlus/sensitivity_analyzer.py +++ b/pySWATPlus/sensitivity_analyzer.py @@ -1,5 +1,6 @@ import numpy import SALib.sample.sobol +import SALib.analyze.sobol import functools import concurrent.futures import pathlib @@ -12,6 +13,7 @@ from .txtinout_reader import TxtinoutReader from .data_manager import DataManager from .types import BoundType, BoundDict +from .performance_metrics import PerformanceMetrics from . import validators @@ -399,11 +401,10 @@ def simulation_by_sobol_sample( path=simulation_folder ) - # Check simulation_folder must be empty - if any(simulation_folder.iterdir()): - raise ValueError( - 'Provided simulation_folder must be an empty directory' - ) + # Check simulation_folder is empty + validators._empty_directory( + path=simulation_folder + ) # Validate simulation_data configuration self._validate_simulation_data_config( @@ -515,3 +516,110 @@ def simulation_by_sobol_sample( ) return simulation_output + + def sobol_indices( + self, + sim_file: str | pathlib.Path, + df_name: str, + sim_col: str, + obs_file: str | pathlib.Path, + date_format: str, + obs_col: str, + indicators: list[str], + json_file: typing.Optional[str | pathlib.Path] = None + ) -> dict[str, typing.Any]: + ''' + Compute Sobol sensitivy indices for sample scenarios obtained using + the [`simulation_by_sobol_sample`](https://swat-model.github.io/pySWATPlus/api/sensitivity_analyzer/#pySWATPlus.SensitivityAnalyzer.simulation_by_sobol_sample) method. + + The method returns a dictionary with two keys: + + - `problem`: The definition dictionary passed to Sobol sampling. + - `sobol_indices`: A dictionary where each key is an indicator name and the corresponding value is the computed Sobol sensitivity indices. + + Args: + sim_file (str | pathlib.Path): Path to the `sensitivity_simulation.json` file produced by `simulation_by_sobol_sample`. + + df_name (str): Name of the `DataFrame` within `sensitivity_simulation.json` from which to compute scenario indicators. + + sim_col (str): Name of the column in `df_name` containing simulated values. + + obs_file (str | pathlib.Path): Path to the CSV file containing observed data. The file must include a + `date` column (used to merge simulated and observed data) and use a comma as the separator. + + date_format (str): Date format of the `date` column in `obs_file`, used to parse `datetime.date` objects from date strings. + + obs_col (str): Name of the column in `obs_file` containing observed data. All negative and `None` observed values are removed before analysis. + + indicators (list[str]): List of indicators to compute Sobol indices. Available options: + + - `NSE`: Nash–Sutcliffe Efficiency + - `KGE`: Kling–Gupta Efficiency + - `MSE`: Mean Squared Error + - `RMSE`: Root Mean Squared Error + - `PBIAS`: Percent Bias + - `MARE`: Mean Absolute Relative Error + + json_file (str | pathlib.Path, optional): Path to a JSON file for saving the output dictionary where each key is an indicator name + and the corresponding value is the computed Sobol sensitivity indices. If `None` (default), the dictionary is not saved. + + Returns: + Dictionary with two keys, `problem` and `sobol_indices`, and their corresponding values. + ''' + + # Check input variables type + validators._variable_origin_static_type( + vars_types=typing.get_type_hints( + obj=self.sobol_indices + ), + vars_values=locals() + ) + + # Problem and indicators + prob_inct = PerformanceMetrics().scenario_indicators( + sim_file=sim_file, + df_name=df_name, + sim_col=sim_col, + obs_file=obs_file, + date_format=date_format, + obs_col=obs_col, + indicators=indicators + ) + problem = prob_inct['problem'] + indicator_df = prob_inct['indicator'] + + # Sobol sensitivity indices + sobol_indices = {} + for indicator in indicators: + # Indicator sensitivity indices + indicator_sensitivity = SALib.analyze.sobol.analyze( + problem=copy.deepcopy(problem), + Y=indicator_df[indicator].values + ) + sobol_indices[indicator] = indicator_sensitivity + + # Save the Sobol indices + if json_file is not None: + # Raise error for invalid JSON file extension + json_file = pathlib.Path(json_file).resolve() + validators._json_extension( + json_file=json_file + ) + # Modify sensitivity index to write in the JSON file + copy_indices = copy.deepcopy(sobol_indices) + write_indices = {} + for indicator in indicators: + write_indices[indicator] = { + k: v.tolist() for k, v in copy_indices[indicator].items() + } + # saving output data + with open(json_file, 'w') as output_json: + json.dump(write_indices, output_json, indent=4) + + # Output dictionary + output = { + 'problem': problem, + 'sobol_indices': sobol_indices + } + + return output From 96e5ff4bae551cfdfd8c209b63b78ea677281be3 Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:32:58 +0300 Subject: [PATCH 17/20] Added new file for observed data --- .../TxtInOut/a_observe_discharge_monthly.csv | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/TxtInOut/a_observe_discharge_monthly.csv diff --git a/tests/TxtInOut/a_observe_discharge_monthly.csv b/tests/TxtInOut/a_observe_discharge_monthly.csv new file mode 100644 index 0000000..85497cc --- /dev/null +++ b/tests/TxtInOut/a_observe_discharge_monthly.csv @@ -0,0 +1,61 @@ +date,max,min,mean,std +2010-01-01,7.86,6.23,6.9019354839,0.4447577569 +2010-02-01,6.11,5.68,5.8207142857,0.0985798631 +2010-03-01,6.17,5.27,5.5932258065,0.246392466 +2010-04-01,20.63,5.17,10.564,4.8718067172 +2010-05-01,440.0,25.31,142.8912903226,114.6566813794 +2010-06-01,52.0,14.5,25.8666666667,11.475501141 +2010-07-01,25.0,12.0,17.6129032258,3.6278314859 +2010-08-01,96.0,13.0,32.0516129032,25.4582124401 +2010-09-01,31.0,11.6,16.1533333333,6.537569586 +2010-10-01,24.0,14.5,17.5129032258,2.660168157 +2010-11-01,35.0,8.2,17.3056666667,9.2598360317 +2010-12-01,8.1,5.06,6.3,0.9433592458000001 +2011-01-01,5.29,5.0,5.1661290323,0.10919943280000001 +2011-02-01,5.09,4.31,4.7607142857,0.2867174189 +2011-03-01,4.39,4.08,4.2564516129,0.11998745450000001 +2011-04-01,100.0,4.08,25.715,32.1810280102 +2011-05-01,89.0,34.0,50.7419354839,12.8217724774 +2011-06-01,38.0,15.5,23.5366666667,6.5105475872 +2011-07-01,47.0,16.0,23.6903225806,7.8742768912 +2011-08-01,28.0,10.4,17.6290322581,4.8970871307 +2011-09-01,14.0,8.9,10.5366666667,1.4796279433000001 +2011-10-01,76.0,13.5,37.0451612903,19.112122483 +2011-11-01,28.0,12.82,19.538,5.518846956 +2011-12-01,12.74,8.61,10.7619354839,1.1384856004 +2012-01-01,8.54,6.67,7.6222580645,0.5658015534 +2012-02-01,6.61,6.11,6.3927586207000004,0.1857782194 +2012-03-01,6.06,5.08,5.5203225806,0.3149760189 +2012-04-01,8.61,4.73,5.1603333333,0.8351728433000001 +2012-05-01,373.0,9.7,134.7948387097,112.8979720772 +2012-06-01,65.0,19.4,30.7466666667,9.921475604 +2012-07-01,62.0,17.0,34.6903225806,12.5409025416 +2012-08-01,16.5,10.4,13.4741935484,1.887673655 +2012-09-01,40.0,10.8,19.67,9.9174228438 +2012-10-01,48.0,19.66,37.8277419355,8.3634836082 +2012-11-01,49.52,17.37,24.6553333333,11.26283015 +2012-12-01,38.66,9.92,17.2383870968,8.0582165507 +2013-01-01,9.79,7.61,8.6190322581,0.6561979114000001 +2013-02-01,7.51,6.49,6.8557142857,0.3470236257 +2013-03-01,6.49,5.53,5.9680645161,0.29453261680000004 +2013-04-01,149.0,5.18,39.016,54.1509352675 +2013-05-01,138.0,23.0,85.2903225806,38.0001697789 +2013-06-01,50.4,13.0,24.7183333333,11.6419645467 +2013-07-01,74.0,11.6,29.425483871,19.8846976071 +2013-08-01,17.0,8.5,10.990322580600001,2.1081358019 +2013-09-01,8.5,7.5,7.7466666667,0.3148435116 +2013-10-01,16.5,8.5,10.635483871,1.6361638848 +2013-11-01,21.0,12.73,16.7706666667,2.7495603828 +2013-12-01,14.13,11.35,11.7887096774,0.5879667901 +2014-01-01,16.02,10.83,13.5319354839,1.8417300188999999 +2014-02-01,10.83,10.01,10.2589285714,0.29327448 +2014-03-01,10.17,9.4,9.9261290323,0.1695813948 +2014-04-01,79.0,9.04,23.1703333333,21.5507170028 +2014-05-01,262.0,35.0,105.0967741935,71.0728991195 +2014-06-01,48.0,14.5,22.8733333333,9.899353282 +2014-07-01,15.0,7.8,11.0903225806,2.3614056321 +2014-08-01,32.0,11.2,20.0225806452,7.1618763685 +2014-09-01,48.0,10.4,19.4366666667,10.773930328 +2014-10-01,46.0,11.2,21.9161290323,9.9954021688 +2014-11-01,35.0,9.14,16.0423333333,7.8751572216 +2014-12-01,9.08,7.31,8.0058064516,0.5096912411 From ea0031096b983a2ab8bedce0af0908ca4d07f806 Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:33:42 +0300 Subject: [PATCH 18/20] Updated test functions according to modified codes --- tests/test_sensitivity_analyzer.py | 286 +++++++++++++++++------------ tests/test_txtinout_reader.py | 20 +- tests/test_utils.py | 7 +- tests/test_validators.py | 4 +- 4 files changed, 184 insertions(+), 133 deletions(-) diff --git a/tests/test_sensitivity_analyzer.py b/tests/test_sensitivity_analyzer.py index 43a1fc7..899e564 100644 --- a/tests/test_sensitivity_analyzer.py +++ b/tests/test_sensitivity_analyzer.py @@ -1,5 +1,4 @@ import os -import shutil import pySWATPlus import pytest import tempfile @@ -8,14 +7,24 @@ @pytest.fixture(scope='class') def sensitivity_analyzer(): - # initialize TxtinoutReader class - sensitivity_analyzer = pySWATPlus.SensitivityAnalyzer() + # initialize SensitivityAnalyzer class + output = pySWATPlus.SensitivityAnalyzer() + + yield output + + +@pytest.fixture(scope='class') +def performance_metrics(): - yield sensitivity_analyzer + # initialize PerformanceMetrics class + output = pySWATPlus.PerformanceMetrics() + + yield output def test_simulation_by_sobol_sample( - sensitivity_analyzer + sensitivity_analyzer, + performance_metrics ): # set up TxtInOut folder path @@ -26,6 +35,25 @@ def test_simulation_by_sobol_sample( path=txtinout_folder ) + # Sensitivity parameters + parameters = [ + { + 'name': 'perco', + 'change_type': 'absval', + 'lower_bound': 0, + 'upper_bound': 1 + } + ] + # Target data from sensitivity simulation + simulation_data = { + 'channel_sd_mon.txt': { + 'has_units': True, + 'ref_day': 1, + 'apply_filter': {'name': ['cha561']}, + 'usecols': ['gis_id', 'flo_out'] + } + } + with tempfile.TemporaryDirectory() as tmp1_dir: # Copy required files to a target directory target_dir = txtinout_reader.copy_required_files( @@ -54,32 +82,7 @@ def test_simulation_by_sobol_sample( target_reader.set_warmup_year( warmup=1 ) - # Sensitivity parameters - parameters = [ - { - 'name': 'perco', - 'change_type': 'absval', - 'lower_bound': 0, - 'upper_bound': 1 - } - ] - # Target data from sensitivity simulation - simulation_data = { - 'channel_sdmorph_mon.txt': { - 'has_units': True, - 'ref_day': 15, - 'apply_filter': {'gis_id': [561]}, - 'usecols': ['gis_id', 'flo_out'] - }, - 'channel_sd_yr.txt': { - 'has_units': True, - 'begin_date': '01-Jun-2011', - 'ref_day': 15, - 'ref_month': 6, - 'apply_filter': {'name': ['cha561'], 'yr': [2012]}, - 'usecols': ['gis_id', 'flo_out'] - } - } + # Pass: sensitivity simulation by Sobol sample with tempfile.TemporaryDirectory() as tmp2_dir: output = sensitivity_analyzer.simulation_by_sobol_sample( @@ -105,104 +108,151 @@ def test_simulation_by_sobol_sample( assert isinstance(output['simulation'], dict) assert len(output['simulation']) == 8 + # Pass: read sensitive DataFrame of scenarios + output = pySWATPlus.DataManager().read_sensitive_dfs( + sim_file=os.path.join(tmp2_dir, 'sensitivity_simulation.json'), + df_name='channel_sd_mon_df', + add_problem=True, + add_sample=True + ) + assert isinstance(output, dict) + assert len(output) == 3 + assert len(output['scenario']) == 8 + assert len(output['sample']) == 8 + + # Indicator list + indicators = list(performance_metrics.indicator_names.keys()) + + # Pass: indicator values + output = performance_metrics.scenario_indicators( + sim_file=os.path.join(tmp2_dir, 'sensitivity_simulation.json'), + df_name='channel_sd_mon_df', + sim_col='flo_out', + obs_file=os.path.join(txtinout_folder, 'a_observe_discharge_monthly.csv'), + date_format='%Y-%m-%d', + obs_col='mean', + indicators=indicators, + json_file=os.path.join(tmp2_dir, 'indicators.json') + ) + assert isinstance(output, dict) + assert len(output) == 2 + assert len(output['indicator']) == 8 + + # Pass: Sobol sensitivity indices + output = sensitivity_analyzer.sobol_indices( + sim_file=os.path.join(tmp2_dir, 'sensitivity_simulation.json'), + df_name='channel_sd_mon_df', + sim_col='flo_out', + obs_file=os.path.join(txtinout_folder, 'a_observe_discharge_monthly.csv'), + date_format='%Y-%m-%d', + obs_col='mean', + indicators=indicators, + json_file=os.path.join(tmp2_dir, 'sobol_indices.json') + ) + assert isinstance(output, dict) + assert len(output) == 2 + sobol_indices = output['sobol_indices'] + assert isinstance(sobol_indices, dict) + assert len(sobol_indices) == 6 + assert all([isinstance(sobol_indices[i]['S1'][0], float) for i in indicators]) + + with tempfile.TemporaryDirectory() as tmp_dir: + # Error: invalid simulation_data type + with pytest.raises(Exception) as exc_info: + sensitivity_analyzer.simulation_by_sobol_sample( + parameters=parameters, + sample_number=1, + simulation_folder=tmp_dir, + txtinout_folder=txtinout_folder, + simulation_data=[] + ) + assert exc_info.value.args[0] == 'Expected "simulation_data" to be "dict", but got type "list"' + # Error: invalid data type of value for key in simulation_data + with pytest.raises(Exception) as exc_info: + sensitivity_analyzer.simulation_by_sobol_sample( + parameters=parameters, + sample_number=1, + simulation_folder=tmp_dir, + txtinout_folder=txtinout_folder, + simulation_data={ + 'channel_sd_yr.txt': [] + } + ) + assert exc_info.value.args[0] == 'Expected "channel_sd_yr.txt" in simulation_date must be a dictionary, but got type "list"' + # Error: missing has_units subkey for key in simulation_data + with pytest.raises(Exception) as exc_info: + sensitivity_analyzer.simulation_by_sobol_sample( + parameters=parameters, + sample_number=1, + simulation_folder=tmp_dir, + txtinout_folder=txtinout_folder, + simulation_data={ + 'channel_sd_yr.txt': {} + } + ) + assert exc_info.value.args[0] == 'Key has_units is missing for "channel_sd_yr.txt" in simulation_data' + # Error: invalid sub_key for key in simulation_data + with pytest.raises(Exception) as exc_info: + sensitivity_analyzer.simulation_by_sobol_sample( + parameters=parameters, + sample_number=1, + simulation_folder=tmp_dir, + txtinout_folder=txtinout_folder, + simulation_data={ + 'channel_sd_yr.txt': { + 'has_units': True, + 'begin_datee': None + } + } + ) + assert 'Invalid key "begin_datee" for "channel_sd_yr.txt" in simulation_data' in exc_info.value.args[0] + + +def test_error_scenario_indicators( + performance_metrics +): + + # Error: invalid indicator name + with pytest.raises(Exception) as exc_info: + performance_metrics.scenario_indicators( + sim_file='sensitivity_simulation.json', + df_name='channel_sd_mon_df', + sim_col='flo_out', + obs_file='a_observe_discharge_monthly.csv', + date_format='%Y-%m-%d', + obs_col='mean', + indicators=['NSEE'] + ) + assert 'Invalid name "NSEE" in "indicatiors" list' in exc_info.value.args[0] -def test_error_simulation_by_sobol_sample( + +def test_create_sobol_problem( sensitivity_analyzer ): - # set up TxtInOut folder path - txtinout_folder = os.path.join(os.path.dirname(__file__), 'TxtInOut') - # Sensitivity parameters parameters = [ { 'name': 'perco', + 'change_type': 'absval', + 'lower_bound': 0, + 'upper_bound': 1 + }, + { + 'name': 'perco', + 'change_type': 'absval', 'lower_bound': 0, 'upper_bound': 1, - 'change_type': 'absval' + 'units': [1, 2, 3] } ] - # Sensitivity simulation_data dictionary to extract data - simulation_data = { - 'channel_sd_yr.txt': { - 'has_units': True, - 'apply_filter': {'name': ['cha561'], 'yr': [2012]}, - 'usecols': ['gis_id', 'flo_out'] - } - } + params_bounds = [ + pySWATPlus.types.BoundDict(**param) for param in parameters + ] - # Error: non-empty simulation folder path - with tempfile.TemporaryDirectory() as tmp_dir: - shutil.copy2( - src=os.path.join(txtinout_folder, 'topography.hyd'), - dst=os.path.join(tmp_dir, 'topography.hyd') - ) - with pytest.raises(Exception) as exc_info: - sensitivity_analyzer.simulation_by_sobol_sample( - parameters=parameters, - sample_number=1, - simulation_folder=tmp_dir, - txtinout_folder=txtinout_folder, - simulation_data=simulation_data - ) - assert exc_info.value.args[0] == 'Provided simulation_folder must be an empty directory' + output = sensitivity_analyzer._create_sobol_problem( + params_bounds=params_bounds + ) - with tempfile.TemporaryDirectory() as tmp_dir: - # Error: invalid simulation_data type - with pytest.raises(Exception) as exc_info: - sensitivity_analyzer.simulation_by_sobol_sample( - parameters=parameters, - sample_number=1, - simulation_folder=tmp_dir, - txtinout_folder=txtinout_folder, - simulation_data=[] - ) - assert exc_info.value.args[0] == 'Expected "simulation_data" to be "dict", but got type "list"' - # Error: invalid data type of value for key in simulation_data - with pytest.raises(Exception) as exc_info: - sensitivity_analyzer.simulation_by_sobol_sample( - parameters=parameters, - sample_number=1, - simulation_folder=tmp_dir, - txtinout_folder=txtinout_folder, - simulation_data={ - 'channel_sd_yr.txt': [] - } - ) - assert exc_info.value.args[0] == 'Expected "channel_sd_yr.txt" in simulation_date must be a dictionary, but got type "list"' - # Error: missing has_units subkey for key in simulation_data - with pytest.raises(Exception) as exc_info: - sensitivity_analyzer.simulation_by_sobol_sample( - parameters=parameters, - sample_number=1, - simulation_folder=tmp_dir, - txtinout_folder=txtinout_folder, - simulation_data={ - 'channel_sd_yr.txt': {} - } - ) - assert exc_info.value.args[0] == 'Key has_units is missing for "channel_sd_yr.txt" in simulation_data' - # Error: invalid sub_key for key in simulation_data - valid_subkeys = [ - 'has_units', - 'begin_date', - 'end_date', - 'ref_day', - 'ref_month', - 'apply_filter', - 'usecols' - ] - with pytest.raises(Exception) as exc_info: - sensitivity_analyzer.simulation_by_sobol_sample( - parameters=parameters, - sample_number=1, - simulation_folder=tmp_dir, - txtinout_folder=txtinout_folder, - simulation_data={ - 'channel_sd_yr.txt': { - 'has_units': True, - 'begin_datee': None - } - } - ) - assert exc_info.value.args[0] == f'Invalid key "begin_datee" for "channel_sd_yr.txt" in simulation_data; expected subkeys are {valid_subkeys}' + assert output['names'][0] == 'perco|1' + assert output['names'][1] == 'perco|2' diff --git a/tests/test_txtinout_reader.py b/tests/test_txtinout_reader.py index b09344c..ae8a578 100644 --- a/tests/test_txtinout_reader.py +++ b/tests/test_txtinout_reader.py @@ -13,11 +13,11 @@ def txtinout_reader(): txtinout_folder = os.path.join(os.path.dirname(__file__), 'TxtInOut') # initialize TxtinoutReader class - txtinout_reader = pySWATPlus.TxtinoutReader( + output = pySWATPlus.TxtinoutReader( path=txtinout_folder ) - yield txtinout_reader + yield output def test_run_swat( @@ -73,11 +73,14 @@ def test_run_swat( target_dir=tmp2_dir, begin_date='01-Jan-2010', end_date='01-Jan-2012', + simulation_timestep=0, warmup=1, print_prt_control={ 'channel_sd': {'daily': False}, 'basin_wb': {} - } + }, + start_date_print='01-Feb-2010', + print_interval=1 ) assert os.path.samefile(target_dir, tmp2_dir) @@ -221,7 +224,7 @@ def test_set_begin_and_end_date( assert exc_info.value.args[0] == 'begin_date 01-Jan-2016 must be earlier than end_date 01-Jan-2012' -def set_simulation_timestep( +def test_set_simulation_timestep( txtinout_reader ): @@ -253,18 +256,11 @@ def set_simulation_timestep( assert lines[2] == expected_line, f"Expected:\n{expected_line}\nGot:\n{lines[2]}" # Error: step is invalid - valid_steps = { - 0: '1 day', - 1: '12 hours', - 24: '1 hour', - 96: '15 minutes', - 1440: '1 minute', - } with pytest.raises(ValueError) as exc_info: txtinout_reader.set_simulation_timestep( step=7 ) - assert exc_info.value.args[0] == f'Received invalid step: 7; must be one of the keys in {valid_steps}' + assert 'Received invalid step: 7' in exc_info.value.args[0] def test_set_start_date_print( diff --git a/tests/test_utils.py b/tests/test_utils.py index e2a4e5a..1240619 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -30,7 +30,11 @@ (-12345678901234, ' -12345678901234'), ] ) -def test_format_val_field_edge_cases(value, expected): +def test_format_val_field_edge_cases( + value, + expected +): + result = pySWATPlus.utils._format_val_field(value) # Check total length = 16 @@ -41,6 +45,7 @@ def test_format_val_field_edge_cases(value, expected): def test_compact_units(): + # --- empty input --- assert pySWATPlus.utils._compact_units([]) == [] diff --git a/tests/test_validators.py b/tests/test_validators.py index 08d132e..dd55f05 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -11,11 +11,11 @@ def txtinout_reader(): txtinout_folder = os.path.join(os.path.dirname(__file__), 'TxtInOut') # initialize TxtinoutReader class - txtinout_reader = pySWATPlus.TxtinoutReader( + output = pySWATPlus.TxtinoutReader( path=txtinout_folder ) - yield txtinout_reader + yield output def test_calibration_parameters( From d98c47f9d5e33740d8df3798847ccdb81b5fcad8 Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:43:29 +0300 Subject: [PATCH 19/20] Added changes of SWAT+ simulation by calibration.cal in changelog.md --- docs/changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index fcbcc85..dd23909 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -23,6 +23,8 @@ - `set_simulation_timestep`: Modifies the simulation timestep in the `time.sim` file. - `set_print_interval`: Modifies the print interval in the `print.prt` file. +- All SWAT+ simulations with modified parameters are now configured through the `calibration.cal` file, eliminating the need to read and modify individual input files. + ## Version 1.1.0 (August 26, 2025) From 5a6320811dfa147cabf2aa6066521d1d9b6276f7 Mon Sep 17 00:00:00 2001 From: debpal Date: Thu, 9 Oct 2025 15:44:20 +0300 Subject: [PATCH 20/20] Added features of performance metrics and Sobol indices --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 86ae947..a866944 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ - Modify parameters via the `calibration.cal` file. - Run SWAT+ simulations. - Perform sensitivity analysis on model parameters using the [SALib](https://github.com/SALib/SALib) Python package, with support for parallel computation. +- Compute performance metrics using widely adopted indicators and derive Sobol sensitivity indices. ## 📥 Install pySWATPlus