Project Structure (files included):
├── .gitattributes
├── .gitignore
├── CLAUDE.md
├── LICENSE
├── RAS Commander MCP brainstorming.txt
├── RAS Commander MCP.txt
├── README.md
├── TRADEMARKS.md
├── api.md
├── claude_desktop_config.json
├── example_client.py
├── instructions.txt
├── package.json
├── ras-commander API to use for developing tool calls.txt
├── requirements.txt
├── server.py
└── test_server.py

File: c:\GH\ras-commander-mcp\.gitattributes
==================================================
# Auto detect text files and perform LF normalization
* text=auto

==================================================

File: c:\GH\ras-commander-mcp\.gitignore
==================================================
/testdata
/__pycache__

==================================================

File: c:\GH\ras-commander-mcp\api.md
==================================================
# RAS Commander API Documentation

This document provides a detailed reference for the public Application Programming Interface (API) of the `ras_commander` library. It lists all public classes and functions available for interacting with HEC-RAS projects.

## Introduction to Decorators

Many functions within the `ras_commander` library utilize decorators to provide common functionality like logging and input standardization. Understanding these decorators is key to using the API effectively.

### `@log_call`

*   **Purpose:** Automatically logs the entry and exit of a function call at the DEBUG level using the library's configured logger.
*   **Usage:** Applied to most public methods in the `Ras*` and `Hdf*` classes.
*   **Benefit:** Reduces boilerplate logging code and provides a consistent way to trace function execution for debugging purposes. You can configure the overall logging level using `logging.getLogger('ras_commander').setLevel(logging.LEVEL)`.

### `@standardize_input(file_type='plan_hdf'|'geom_hdf')`

*   **Purpose:** Standardizes the input for functions that operate on HEC-RAS HDF files (`.hdf`). It ensures that the function receives a validated `pathlib.Path` object pointing to the correct HDF file, regardless of the input format provided by the user.
*   **Usage:** Primarily used by methods within the `Hdf*` classes.
*   **Accepted Inputs:** The decorator can handle various input types for the HDF file path argument (usually the first argument or `hdf_path` keyword):
    *   `str`: A plan/geometry number (e.g., "01"), a plan prefix number (e.g., "p01"), or a full file path.
    *   `int`: A plan/geometry number (e.g., 1).
    *   `pathlib.Path`: A Path object pointing to the HDF file.
    *   `h5py.File`: An opened h5py File object (the decorator extracts the filename).
*   **`file_type` Argument:**
    *   `'plan_hdf'`: When resolving numbers, the decorator looks for the corresponding plan results HDF file (e.g., `ProjectName.p01.hdf`). This is the default.
    *   `'geom_hdf'`: When resolving numbers, the decorator looks for the corresponding geometry HDF file (e.g., `ProjectName.g01.hdf`).
*   **RAS Object Context:** The decorator uses the provided `ras_object` (or the global `ras` instance) to look up file paths when numbers are given as input. Ensure the relevant `RasPrj` object is initialized.
*   **Validation:** The decorator verifies that the resulting path points to an existing file before passing it to the decorated function.

---

## Class: RasPrj

Manages HEC-RAS project data and state. Provides access to project files, plans, geometries, flows, and boundary conditions. Can be used as a global `ras` object or instantiated for multi-project workflows.

### `RasPrj.initialize(project_folder, ras_exe_path, suppress_logging=True)`

*   **Purpose:** Initializes a `RasPrj` instance. **Note:** Users should typically call `init_ras_project()` instead.
*   **Parameters:**
    *   `project_folder` (`str` or `Path`): Path to the HEC-RAS project folder.
    *   `ras_exe_path` (`str` or `Path`): Path to the HEC-RAS executable.
    *   `suppress_logging` (`bool`, optional, default=`True`): Suppresses detailed initialization logs if True.
*   **Returns:** `None`. Modifies the instance in place.
*   **Raises:** `ValueError` if no `.prj` file is found.

### `RasPrj.check_initialized()`

*   **Purpose:** Checks if the `RasPrj` instance has been initialized.
*   **Parameters:** None.
*   **Returns:** `None`.
*   **Raises:** `RuntimeError` if the project is not initialized.

### `RasPrj.find_ras_prj(folder_path)`

*   **Purpose:** (Static method) Finds the main HEC-RAS project file (`.prj`) within a folder using various heuristics.
*   **Parameters:**
    *   `folder_path` (`str` or `Path`): Path to the folder to search.
*   **Returns:** `Path` object for the found `.prj` file, or `None` if not found.

### `RasPrj.get_project_name()`

*   **Purpose:** Gets the name of the initialized HEC-RAS project (filename without extension).
*   **Parameters:** None.
*   **Returns:** (`str`): The project name.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPrj.get_prj_entries(entry_type)`

*   **Purpose:** Retrieves entries (plans, flows, geoms, unsteady) listed in the project file (`.prj`).
*   **Parameters:**
    *   `entry_type` (`str`): Type of entry ('Plan', 'Flow', 'Unsteady', 'Geom').
*   **Returns:** `pd.DataFrame`: DataFrame containing information about the specified entry type found in the project.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPrj.get_plan_entries()`

*   **Purpose:** Retrieves all plan file entries (`.p*`) listed in the project file.
*   **Parameters:** None.
*   **Returns:** `pd.DataFrame`: DataFrame of plan entries with details parsed from plan files.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPrj.get_flow_entries()`

*   **Purpose:** Retrieves all steady flow file entries (`.f*`) listed in the project file.
*   **Parameters:** None.
*   **Returns:** `pd.DataFrame`: DataFrame of steady flow entries.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPrj.get_unsteady_entries()`

*   **Purpose:** Retrieves all unsteady flow file entries (`.u*`) listed in the project file.
*   **Parameters:** None.
*   **Returns:** `pd.DataFrame`: DataFrame of unsteady flow entries with details parsed from unsteady files.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPrj.get_geom_entries()`

*   **Purpose:** Retrieves all geometry file entries (`.g*`) listed in the project file.
*   **Parameters:** None.
*   **Returns:** `pd.DataFrame`: DataFrame of geometry entries including paths to associated `.hdf` files.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPrj.get_hdf_entries()`

*   **Purpose:** Retrieves plan entries that have a corresponding HDF results file (`.p*.hdf`).
*   **Parameters:** None.
*   **Returns:** `pd.DataFrame`: Filtered DataFrame of plan entries with existing HDF results files.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPrj.print_data()`

*   **Purpose:** Prints a summary of the initialized project data (paths, file counts, dataframes) to the log (INFO level).
*   **Parameters:** None.
*   **Returns:** `None`.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPrj.get_plan_value(plan_number_or_path, key, ras_object=None)`

*   **Purpose:** (Static method, but often called on instance) Retrieves a specific value for a given key from a plan file.
*   **Parameters:**
    *   `plan_number_or_path` (`str` or `Path`): Plan number (e.g., "01") or full path to the plan file.
    *   `key` (`str`): The keyword in the plan file (e.g., 'Computation Interval', 'Short Identifier').
    *   `ras_object` (`RasPrj`, optional): Instance to use context from (project path). Defaults to global `ras`.
*   **Returns:** (`Any`): The value associated with the key, or `None` if not found. Type depends on the key (str, int).
*   **Raises:** `ValueError` if plan file not found, `IOError` on read error.

### `RasPrj.get_boundary_conditions()`

*   **Purpose:** Parses all unsteady flow files in the project to extract and structure boundary condition information.
*   **Parameters:** None.
*   **Returns:** `pd.DataFrame`: DataFrame containing detailed boundary condition data (location, type, parameters, associated unsteady file info). Returns empty DataFrame if no unsteady files or boundaries are found.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPrj` Attributes

*   `project_folder` (`Path`): Path to the project folder.
*   `project_name` (`str`): Name of the project.
*   `prj_file` (`Path`): Path to the project file.
*   `ras_exe_path` (`str`): Path to the HEC-RAS executable.
*   `plan_df` (`pd.DataFrame`): DataFrame containing plan file information.
*   `flow_df` (`pd.DataFrame`): DataFrame containing flow file information.
*   `unsteady_df` (`pd.DataFrame`): DataFrame containing unsteady flow file information.
*   `geom_df` (`pd.DataFrame`): DataFrame containing geometry file information.
*   `boundaries_df` (`pd.DataFrame`): DataFrame containing boundary condition information.
*   `rasmap_df` (`pd.DataFrame`): DataFrame containing RASMapper configuration data including paths to terrain, soil layer, infiltration, and land cover data.

---

## Standalone Functions

These functions are available directly under the `ras_commander` import.

### `init_ras_project(ras_project_folder, ras_version=None, ras_object=None)`

*   **Purpose:** Primary function to initialize a `RasPrj` object (either global `ras` or a custom instance) for a specific HEC-RAS project.
*   **Parameters:**
    *   `ras_project_folder` (`str` or `Path`): Path to the HEC-RAS project folder.
    *   `ras_version` (`str`, optional): HEC-RAS version (e.g., "6.6") or full path to `Ras.exe`. Defaults to auto-detection or global setting.
    *   `ras_object` (`RasPrj`, optional): If `None`, initializes the global `ras` object. If a `RasPrj` instance, initializes that instance. If any other value (e.g., a string like "new"), creates and returns a *new* `RasPrj` instance. **Also updates the global `ras` object regardless.**
*   **Returns:** (`RasPrj`): The initialized `RasPrj` instance (either the one passed in, the global `ras`, or a newly created one).
*   **Raises:** `FileNotFoundError` if folder doesn't exist, `ValueError` if `.prj` file not found.

### `get_ras_exe(ras_version=None)`

*   **Purpose:** Determines the full path to the HEC-RAS executable based on version number or explicit path.
*   **Parameters:**
    *   `ras_version` (`str`, optional): Version string (e.g., "6.5") or full path to `Ras.exe`. If `None`, uses global `ras` object's path or defaults to "Ras.exe".
*   **Returns:** (`str`): Full path to the `Ras.exe` executable. Returns "Ras.exe" if lookup fails.

---

## Class: RasPlan

Contains static methods for operating on HEC-RAS plan files (`.p*`). Assumes a `RasPrj` object (defaulting to global `ras`) is initialized for context.

### `RasPlan.set_geom(plan_number, new_geom, ras_object=None)`

*   **Purpose:** Updates a plan file to use a different geometry file.
*   **Parameters:**
    *   `plan_number` (`str` or `int`): Plan number to modify (e.g., "01", 1).
    *   `new_geom` (`str` or `int`): Geometry number to assign (e.g., "02", 2).
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `pd.DataFrame`: The updated *geometry* DataFrame of the `ras_object`.
*   **Raises:** `ValueError` if `new_geom` not found, `FileNotFoundError`, `IOError`.

### `RasPlan.set_steady(plan_number, new_steady_flow_number, ras_object=None)`

*   **Purpose:** Updates a plan file to use a specific steady flow file.
*   **Parameters:**
    *   `plan_number` (`str`): Plan number (e.g., "01").
    *   `new_steady_flow_number` (`str`): Steady flow number (e.g., "01").
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the plan file and updates the `ras_object`.
*   **Raises:** `ValueError` if `new_steady_flow_number` not found, `FileNotFoundError`, `IOError`.

### `RasPlan.set_unsteady(plan_number, new_unsteady_flow_number, ras_object=None)`

*   **Purpose:** Updates a plan file to use a specific unsteady flow file.
*   **Parameters:**
    *   `plan_number` (`str`): Plan number (e.g., "01").
    *   `new_unsteady_flow_number` (`str`): Unsteady flow number (e.g., "02").
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the plan file and updates the `ras_object`.
*   **Raises:** `ValueError` if `new_unsteady_flow_number` not found, `FileNotFoundError`, `IOError`.

### `RasPlan.set_num_cores(plan_number_or_path, num_cores, ras_object=None)`

*   **Purpose:** Sets the number of cores (`UNET D1 Cores`, `UNET D2 Cores`, `PS Cores`) in a plan file.
*   **Parameters:**
    *   `plan_number_or_path` (`str` or `Path`): Plan number or full path.
    *   `num_cores` (`int`): Number of cores to set (0 for 'All Available').
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the plan file and updates the `ras_object`.
*   **Raises:** `FileNotFoundError`, `IOError`.

### `RasPlan.set_geom_preprocessor(file_path, run_htab, use_ib_tables, ras_object=None)`

*   **Purpose:** Modifies the `Run HTab` and `UNET Use Existing IB Tables` settings in a plan file.
*   **Parameters:**
    *   `file_path` (`str` or `Path`): Full path to the plan file.
    *   `run_htab` (`int`): `0` (use existing) or `-1` (force recompute).
    *   `use_ib_tables` (`int`): `0` (use existing) or `-1` (force recompute).
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the plan file and updates the `ras_object`.
*   **Raises:** `ValueError` for invalid flag values, `FileNotFoundError`, `IOError`.

### `RasPlan.clone_plan(template_plan, new_plan_shortid=None, ras_object=None)`

*   **Purpose:** Creates a new plan file by copying a template plan, assigns the next available plan number, optionally updates the Short Identifier, and updates the project file.
*   **Parameters:**
    *   `template_plan` (`str`): Plan number to use as template (e.g., "01").
    *   `new_plan_shortid` (`str`, optional): New Short Identifier (max 24 chars). If `None`, appends "_copy".
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`str`): The number of the newly created plan (e.g., "03").
*   **Raises:** `FileNotFoundError` if template not found, `IOError`.

### `RasPlan.clone_unsteady(template_unsteady, ras_object=None)`

*   **Purpose:** Creates a new unsteady flow file (`.u*` and associated `.hdf`) by copying a template, assigns the next available number, and updates the project file.
*   **Parameters:**
    *   `template_unsteady` (`str`): Unsteady flow number to use as template (e.g., "02").
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`str`): The number of the newly created unsteady flow file (e.g., "03").
*   **Raises:** `FileNotFoundError` if template not found, `IOError`.

### `RasPlan.clone_steady(template_flow, ras_object=None)`

*   **Purpose:** Creates a new steady flow file (`.f*`) by copying a template, assigns the next available number, and updates the project file.
*   **Parameters:**
    *   `template_flow` (`str`): Steady flow number to use as template (e.g., "01").
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`str`): The number of the newly created steady flow file (e.g., "02").
*   **Raises:** `FileNotFoundError` if template not found, `IOError`.

### `RasPlan.clone_geom(template_geom, ras_object=None)`

*   **Purpose:** Creates a new geometry file (`.g*` and associated `.hdf`) by copying a template, assigns the next available number, and updates the project file.
*   **Parameters:**
    *   `template_geom` (`str`): Geometry number to use as template (e.g., "01").
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`str`): The number of the newly created geometry file (e.g., "03").
*   **Raises:** `FileNotFoundError` if template not found, `IOError`.

### `RasPlan.get_next_number(existing_numbers)`

*   **Purpose:** (Static utility) Finds the smallest unused positive integer number given a list of existing numbers (as strings), returned as a zero-padded string.
*   **Parameters:**
    *   `existing_numbers` (`list` of `str`): List of existing numbers (e.g., ['01', '03']).
*   **Returns:** (`str`): The next available number (e.g., "02").

### `RasPlan.get_plan_value(plan_number_or_path, key, ras_object=None)`

*   **Purpose:** Retrieves a specific value for a given key from a plan file. (See also `RasPrj.get_plan_value`).
*   **Parameters:**
    *   `plan_number_or_path` (`str` or `Path`): Plan number or full path.
    *   `key` (`str`): Keyword in the plan file (e.g., 'Computation Interval').
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`Any`): The value associated with the key, or `None` if not found. Type depends on the key.
*   **Raises:** `ValueError` if plan file not found, `IOError`.

### `RasPlan.get_results_path(plan_number, ras_object=None)`

*   **Purpose:** Gets the expected path to the HDF results file (`.p*.hdf`) for a given plan number. Checks if the file exists.
*   **Parameters:**
    *   `plan_number` (`str`): Plan number (e.g., "01").
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`str` or `None`): Full path to the HDF results file if it exists, otherwise `None`.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPlan.get_plan_path(plan_number, ras_object=None)`

*   **Purpose:** Gets the full path to a plan file (`.p*`) given its number.
*   **Parameters:**
    *   `plan_number` (`str`): Plan number (e.g., "01").
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`str` or `None`): Full path to the plan file, or `None` if not found in the project.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPlan.get_flow_path(flow_number, ras_object=None)`

*   **Purpose:** Gets the full path to a steady flow file (`.f*`) given its number.
*   **Parameters:**
    *   `flow_number` (`str`): Steady flow number (e.g., "01").
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`str` or `None`): Full path to the flow file, or `None` if not found.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPlan.get_unsteady_path(unsteady_number, ras_object=None)`

*   **Purpose:** Gets the full path to an unsteady flow file (`.u*`) given its number.
*   **Parameters:**
    *   `unsteady_number` (`str`): Unsteady flow number (e.g., "02").
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`str` or `None`): Full path to the unsteady file, or `None` if not found.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPlan.get_geom_path(geom_number, ras_object=None)`

*   **Purpose:** Gets the full path to a geometry file (`.g*`) given its number.
*   **Parameters:**
    *   `geom_number` (`str`): Geometry number (e.g., "01").
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`str` or `None`): Full path to the geometry file, or `None` if not found.
*   **Raises:** `RuntimeError` if not initialized.

### `RasPlan.update_run_flags(plan_number_or_path, geometry_preprocessor=None, unsteady_flow_simulation=None, run_sediment=None, post_processor=None, floodplain_mapping=None, ras_object=None)`

*   **Purpose:** Updates the run flags (e.g., `Run HTab`, `Run UNet`, `Run RASMapper`) in a plan file.
*   **Parameters:**
    *   `plan_number_or_path` (`str` or `Path`): Plan number or full path.
    *   `geometry_preprocessor` (`bool`, optional): Set `Run HTab` (True=1, False=0).
    *   `unsteady_flow_simulation` (`bool`, optional): Set `Run UNet` (True=1, False=0).
    *   `run_sediment` (`bool`, optional): Set `Run Sediment` (True=1, False=0).
    *   `post_processor` (`bool`, optional): Set `Run PostProcess` (True=1, False=0).
    *   `floodplain_mapping` (`bool`, optional): Set `Run RASMapper` (True=0, False=-1). **Note inverted logic for RASMapper**.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the plan file.
*   **Raises:** `ValueError` if plan not found, `IOError`.

### `RasPlan.update_plan_intervals(plan_number_or_path, computation_interval=None, output_interval=None, instantaneous_interval=None, mapping_interval=None, ras_object=None)`

*   **Purpose:** Updates time intervals (computation, output, mapping, etc.) in a plan file.
*   **Parameters:**
    *   `plan_number_or_path` (`str` or `Path`): Plan number or full path.
    *   `computation_interval` (`str`, optional): E.g., "1MIN", "10SEC", "1HOUR".
    *   `output_interval` (`str`, optional): E.g., "1HOUR", "30MIN".
    *   `instantaneous_interval` (`str`, optional): E.g., "1HOUR", "15MIN".
    *   `mapping_interval` (`str`, optional): E.g., "1HOUR", "15MIN".
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the plan file.
*   **Raises:** `ValueError` if plan not found or interval invalid, `IOError`.

### `RasPlan.update_plan_description(plan_number_or_path, description, ras_object=None)`

*   **Purpose:** Updates the multi-line description block within a plan file.
*   **Parameters:**
    *   `plan_number_or_path` (`str` or `Path`): Plan number or full path.
    *   `description` (`str`): The new description text (can be multi-line).
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the plan file and updates the `ras_object`.
*   **Raises:** `ValueError` if plan not found, `IOError`.

### `RasPlan.read_plan_description(plan_number_or_path, ras_object=None)`

*   **Purpose:** Reads the multi-line description block from a plan file.
*   **Parameters:**
    *   `plan_number_or_path` (`str` or `Path`): Plan number or full path.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`str`): The description text, or "" if not found.
*   **Raises:** `ValueError` if plan not found, `IOError`.

### `RasPlan.update_simulation_date(plan_number_or_path, start_date, end_date, ras_object=None)`

*   **Purpose:** Updates the simulation start and end date/time in a plan file.
*   **Parameters:**
    *   `plan_number_or_path` (`str` or `Path`): Plan number or full path.
    *   `start_date` (`datetime`): Simulation start datetime object.
    *   `end_date` (`datetime`): Simulation end datetime object.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the plan file and updates the `ras_object`.
*   **Raises:** `ValueError` if plan not found, `IOError`.

### `RasPlan.get_shortid(plan_number_or_path, ras_object=None)`

*   **Purpose:** Gets the 'Short Identifier' value from a plan file.
*   **Parameters:**
    *   `plan_number_or_path` (`str` or `Path`): Plan number or full path.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`str`): The Short Identifier, or "" if not found.
*   **Raises:** `ValueError` if plan not found, `IOError`.

### `RasPlan.set_shortid(plan_number_or_path, new_shortid, ras_object=None)`

*   **Purpose:** Sets the 'Short Identifier' value in a plan file (max 24 chars).
*   **Parameters:**
    *   `plan_number_or_path` (`str` or `Path`): Plan number or full path.
    *   `new_shortid` (`str`): New identifier (will be truncated if > 24 chars).
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the plan file.
*   **Raises:** `ValueError` if plan not found, `IOError`.

### `RasPlan.get_plan_title(plan_number_or_path, ras_object=None)`

*   **Purpose:** Gets the 'Plan Title' value from a plan file.
*   **Parameters:**
    *   `plan_number_or_path` (`str` or `Path`): Plan number or full path.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`str`): The Plan Title, or "" if not found.
*   **Raises:** `ValueError` if plan not found, `IOError`.

### `RasPlan.set_plan_title(plan_number_or_path, new_title, ras_object=None)`

*   **Purpose:** Sets the 'Plan Title' value in a plan file.
*   **Parameters:**
    *   `plan_number_or_path` (`str` or `Path`): Plan number or full path.
    *   `new_title` (`str`): New title.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the plan file.
*   **Raises:** `ValueError` if plan not found, `IOError`.

---

## Class: RasGeo

Contains static methods for operating on HEC-RAS geometry files (`.g*`) and associated preprocessor files. Assumes a `RasPrj` object (defaulting to global `ras`) is initialized.

### `RasGeo.clear_geompre_files(plan_files=None, ras_object=None)`

*   **Purpose:** Deletes geometry preprocessor files (`.c*`) associated with specified plan files. This forces HEC-RAS to recompute hydraulic tables based on the geometry. **Note:** Does not currently clear IB tables or HDF geometry tables.
*   **Parameters:**
    *   `plan_files` (`str`, `Path`, `List[Union[str, Path]]`, optional): Plan file path(s) or number(s). If `None`, clears for all plans in the project.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Deletes files and updates the `ras_object`'s geometry DataFrame.
*   **Raises:** `PermissionError`, `OSError`.

### `RasGeo.get_mannings_baseoverrides(geom_file_path)`

*   **Purpose:** Reads the base Manning's n table from a HEC-RAS geometry file.
*   **Parameters:**
    *   `geom_file_path` (Input handled by `@standardize_input`): Path identifier for the geometry file (.g##).
*   **Returns:** `pd.DataFrame`: DataFrame with Table Number, Land Cover Name, and Base Manning's n Value.

### `RasGeo.get_mannings_regionoverrides(geom_file_path)`

*   **Purpose:** Reads the Manning's n region overrides from a HEC-RAS geometry file.
*   **Parameters:**
    *   `geom_file_path` (Input handled by `@standardize_input`): Path identifier for the geometry file (.g##).
*   **Returns:** `pd.DataFrame`: DataFrame with Table Number, Land Cover Name, MainChannel value, and Region Name.

### `RasGeo.set_mannings_baseoverrides(geom_file_path, mannings_data)`

*   **Purpose:** Writes base Manning's n values to a HEC-RAS geometry file.
*   **Parameters:**
    *   `geom_file_path` (Input handled by `@standardize_input`): Path identifier for the geometry file (.g##).
    *   `mannings_data` (`pd.DataFrame`): DataFrame with columns 'Table Number', 'Land Cover Name', and 'Base Manning\'s n Value'.
*   **Returns:** `bool`: True if successful.

### `RasGeo.set_mannings_regionoverrides(geom_file_path, mannings_data)`

*   **Purpose:** Writes regional Manning's n overrides to a HEC-RAS geometry file.
*   **Parameters:**
    *   `geom_file_path` (Input handled by `@standardize_input`): Path identifier for the geometry file (.g##).
    *   `mannings_data` (`pd.DataFrame`): DataFrame with columns 'Table Number', 'Land Cover Name', 'MainChannel', and 'Region Name'.
*   **Returns:** `bool`: True if successful.

---

## Class: RasUnsteady

Contains static methods for operating on HEC-RAS unsteady flow files (`.u*`). Assumes a `RasPrj` object (defaulting to global `ras`) is initialized.

### `RasUnsteady.update_flow_title(unsteady_file, new_title, ras_object=None)`

*   **Purpose:** Updates the 'Flow Title' line within an unsteady flow file (max 24 chars).
*   **Parameters:**
    *   `unsteady_file` (`str` or `Path`): Unsteady flow number or full path.
    *   `new_title` (`str`): The new title (will be truncated if > 24 chars).
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the file and updates the `ras_object`.
*   **Raises:** `FileNotFoundError`, `PermissionError`, `IOError`.

### `RasUnsteady.update_restart_settings(unsteady_file, use_restart, restart_filename=None, ras_object=None)`

*   **Purpose:** Enables or disables the use of a restart file (`.rst`) in an unsteady flow file.
*   **Parameters:**
    *   `unsteady_file` (`str` or `Path`): Unsteady flow number or full path.
    *   `use_restart` (`bool`): `True` to enable restart, `False` to disable.
    *   `restart_filename` (`str`, optional): Path to the `.rst` file (required if `use_restart` is `True`).
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the file and updates the `ras_object`.
*   **Raises:** `ValueError` if `restart_filename` missing, `FileNotFoundError`, `PermissionError`, `IOError`.

### `RasUnsteady.extract_boundary_and_tables(unsteady_file, ras_object=None)`

*   **Purpose:** Parses an unsteady flow file to extract boundary condition definitions and associated time-series data tables (e.g., Flow Hydrograph).
*   **Parameters:**
    *   `unsteady_file` (`str` or `Path`): Unsteady flow number or full path.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `pd.DataFrame`: DataFrame where each row represents a boundary condition. Includes columns for location (`River Name`, `Reach Name`, etc.), `DSS File`, and a `Tables` column containing a dictionary of `pd.DataFrame` objects for each time-series table found.
*   **Raises:** `FileNotFoundError`, `PermissionError`.

### `RasUnsteady.print_boundaries_and_tables(boundaries_df)`

*   **Purpose:** Prints the boundary conditions and tables (extracted by `extract_boundary_and_tables`) to the console in a readable format.
*   **Parameters:**
    *   `boundaries_df` (`pd.DataFrame`): DataFrame returned by `extract_boundary_and_tables`.
*   **Returns:** `None`.

### `RasUnsteady.identify_tables(lines)`

*   **Purpose:** (Static utility) Scans lines from an unsteady file and identifies the name and line ranges of data tables.
*   **Parameters:**
    *   `lines` (`List[str]`): List of lines read from the unsteady file.
*   **Returns:** `List[Tuple[str, int, int]]`: List of tuples `(table_name, start_line_index, end_line_index)`.

### `RasUnsteady.parse_fixed_width_table(lines, start, end)`

*   **Purpose:** (Static utility) Parses the fixed-width numeric data within identified table lines.
*   **Parameters:**
    *   `lines` (`List[str]`): List of lines read from the unsteady file.
    *   `start` (`int`): Starting line index (inclusive) of the table data.
    *   `end` (`int`): Ending line index (exclusive) of the table data.
*   **Returns:** `pd.DataFrame`: DataFrame with a single 'Value' column containing the numeric data.

### `RasUnsteady.extract_tables(unsteady_file, ras_object=None)`

*   **Purpose:** Extracts all numeric data tables (like hydrographs, gate openings) from an unsteady file into a dictionary of DataFrames.
*   **Parameters:**
    *   `unsteady_file` (`str` or `Path`): Unsteady flow number or full path.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `Dict[str, pd.DataFrame]`: Dictionary mapping table names (e.g., 'Flow Hydrograph=') to DataFrames containing the table values.
*   **Raises:** `FileNotFoundError`, `PermissionError`.

### `RasUnsteady.write_table_to_file(unsteady_file, table_name, df, start_line, ras_object=None)`

*   **Purpose:** Writes a modified DataFrame back into an unsteady flow file, formatting it correctly into the fixed-width structure required by HEC-RAS.
*   **Parameters:**
    *   `unsteady_file` (`str` or `Path`): Unsteady flow number or full path.
    *   `table_name` (`str`): Name of the table being written (must match the header in the file).
    *   `df` (`pd.DataFrame`): DataFrame containing the new values (must have a 'Value' column).
    *   `start_line` (`int`): Line index where the original table data started.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the file.
*   **Raises:** `FileNotFoundError`, `PermissionError`, `IOError`.

---

## Class: RasUtils

Contains general static utility functions used across the `ras_commander` library.

### `RasUtils.create_directory(directory_path, ras_object=None)`

*   **Purpose:** Ensures a directory exists, creating it (and any parent directories) if necessary.
*   **Parameters:**
    *   `directory_path` (`Path`): The directory path to ensure.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`Path`): The ensured directory path.
*   **Raises:** `OSError` on creation failure.

### `RasUtils.find_files_by_extension(extension, ras_object=None)`

*   **Purpose:** Lists all files within the initialized project directory matching a specific extension.
*   **Parameters:**
    *   `extension` (`str`): The file extension (e.g., '.prj', '.p*').
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`list` of `str`): List of full file paths.

### `RasUtils.get_file_size(file_path, ras_object=None)`

*   **Purpose:** Gets the size of a file in bytes.
*   **Parameters:**
    *   `file_path` (`Path`): Path to the file.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`int` or `None`): File size in bytes, or `None` if file not found.

### `RasUtils.get_file_modification_time(file_path, ras_object=None)`

*   **Purpose:** Gets the last modification timestamp of a file.
*   **Parameters:**
    *   `file_path` (`Path`): Path to the file.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`float` or `None`): Unix timestamp of last modification, or `None` if file not found.

### `RasUtils.get_plan_path(current_plan_number_or_path, ras_object=None)`

*   **Purpose:** Resolves a plan number or path string into a full, validated `Path` object for a plan file.
*   **Parameters:**
    *   `current_plan_number_or_path` (`str` or `Path`): Plan number (1-99) or full path.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`Path`): The validated path to the plan file.
*   **Raises:** `ValueError`, `TypeError`, `FileNotFoundError`.

### `RasUtils.remove_with_retry(path, max_attempts=5, initial_delay=1.0, is_folder=True, ras_object=None)`

*   **Purpose:** Safely removes a file or folder, retrying with exponential backoff if a `PermissionError` occurs.
*   **Parameters:**
    *   `path` (`Path`): Path to remove.
    *   `max_attempts` (`int`, optional): Max removal attempts. Default is 5.
    *   `initial_delay` (`float`, optional): Initial delay in seconds. Default is 1.0.
    *   `is_folder` (`bool`, optional): `True` if path is a folder, `False` if a file. Default is `True`.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** (`bool`): `True` if removal succeeded, `False` otherwise.

### `RasUtils.update_plan_file(plan_number_or_path, file_type, entry_number, ras_object=None)`

*   **Purpose:** Updates a line in a plan file to reference a different associated file (geometry, flow, unsteady).
*   **Parameters:**
    *   `plan_number_or_path` (`str` or `Path`): Plan number or full path.
    *   `file_type` (`str`): Type of file link to update ('Geom', 'Flow', 'Unsteady').
    *   `entry_number` (`int`): The number (1-99) of the new associated file.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the plan file and refreshes the `ras_object`.
*   **Raises:** `ValueError`, `FileNotFoundError`.

### `RasUtils.check_file_access(file_path, mode='r')`

*   **Purpose:** Verifies if a file exists and can be accessed with the specified mode ('r', 'w', etc.).
*   **Parameters:**
    *   `file_path` (`Path`): Path to the file.
    *   `mode` (`str`, optional): Access mode to check. Default is 'r'.
*   **Returns:** `None`.
*   **Raises:** `FileNotFoundError`, `PermissionError`.

### `RasUtils.convert_to_dataframe(data_source, **kwargs)`

*   **Purpose:** Converts various inputs (existing DataFrame, CSV, Excel, TSV, Parquet file path) into a pandas DataFrame.
*   **Parameters:**
    *   `data_source` (`pd.DataFrame` or `Path`): Input data or file path.
    *   `**kwargs`: Additional arguments for pandas read functions (e.g., `sheet_name` for Excel).
*   **Returns:** `pd.DataFrame`.
*   **Raises:** `NotImplementedError` for unsupported types.

### `RasUtils.save_to_excel(dataframe, excel_path, **kwargs)`

*   **Purpose:** Saves a DataFrame to an Excel file, with retries to handle potential file locking issues.
*   **Parameters:**
    *   `dataframe` (`pd.DataFrame`): DataFrame to save.
    *   `excel_path` (`Path`): Output Excel file path.
    *   `**kwargs`: Additional arguments for `DataFrame.to_excel()`.
*   **Returns:** `None`.
*   **Raises:** `IOError` if saving fails after retries.

### `RasUtils.calculate_rmse(observed_values, predicted_values, normalized=True)`

*   **Purpose:** Calculates Root Mean Squared Error (RMSE), optionally normalized.
*   **Parameters:**
    *   `observed_values` (`np.ndarray`): Array of actual values.
    *   `predicted_values` (`np.ndarray`): Array of predicted values.
    *   `normalized` (`bool`, optional): If `True`, normalize by the mean of observed values. Default is `True`.
*   **Returns:** (`float`): Calculated RMSE.

### `RasUtils.calculate_percent_bias(observed_values, predicted_values, as_percentage=False)`

*   **Purpose:** Calculates Percent Bias (PBIAS).
*   **Parameters:**
    *   `observed_values` (`np.ndarray`): Array of actual values.
    *   `predicted_values` (`np.ndarray`): Array of predicted values.
    *   `as_percentage` (`bool`, optional): If `True`, returns result multiplied by 100. Default is `False`.
*   **Returns:** (`float`): Calculated Percent Bias.

### `RasUtils.calculate_error_metrics(observed_values, predicted_values)`

*   **Purpose:** Calculates a dictionary of common error metrics (correlation, RMSE, Percent Bias).
*   **Parameters:**
    *   `observed_values` (`np.ndarray`): Array of actual values.
    *   `predicted_values` (`np.ndarray`): Array of predicted values.
*   **Returns:** `Dict[str, float]`: Dictionary with keys 'cor', 'rmse', 'pb'.

### `RasUtils.update_file(file_path, update_function, *args)`

*   **Purpose:** Generic function to read a file, apply a modification function to its lines, and write it back.
*   **Parameters:**
    *   `file_path` (`Path`): Path to the file.
    *   `update_function` (`Callable`): A function that takes a list of lines (and optionally `*args`) and returns a modified list of lines.
    *   `*args`: Additional arguments passed to `update_function`.
*   **Returns:** `None`.
*   **Raises:** Exceptions from file I/O or `update_function`.

### `RasUtils.get_next_number(existing_numbers)`

*   **Purpose:** Finds the smallest unused positive integer number given a list of existing numbers (as strings), returned as a zero-padded string. (Same as `RasPlan.get_next_number`)
*   **Parameters:**
    *   `existing_numbers` (`list` of `str`): List of existing numbers (e.g., ['01', '03']).
*   **Returns:** (`str`): The next available number (e.g., "02").

### `RasUtils.clone_file(template_path, new_path, update_function=None, *args)`

*   **Purpose:** Copies a template file to a new path and optionally applies a modification function to the new file's content.
*   **Parameters:**
    *   `template_path` (`Path`): Path to the source file.
    *   `new_path` (`Path`): Path for the new copied file.
    *   `update_function` (`Callable`, optional): Function to modify the lines of the new file. Takes a list of lines (and optionally `*args`).
    *   `*args`: Additional arguments for `update_function`.
*   **Returns:** `None`.
*   **Raises:** `FileNotFoundError` if template doesn't exist.

### `RasUtils.update_project_file(prj_file, file_type, new_num, ras_object=None)`

*   **Purpose:** Appends a new file entry line (e.g., `Plan File=p03`) to the project file (`.prj`).
*   **Parameters:**
    *   `prj_file` (`Path`): Path to the `.prj` file.
    *   `file_type` (`str`): Type of entry ('Plan', 'Geom', 'Flow', 'Unsteady').
    *   `new_num` (`str`): The two-digit number for the new entry (e.g., "03").
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `None`. Modifies the project file.

### `RasUtils.decode_byte_strings(dataframe)`

*   **Purpose:** Decodes all byte string (`b'...'`) columns in a DataFrame to UTF-8 strings.
*   **Parameters:**
    *   `dataframe` (`pd.DataFrame`): Input DataFrame.
*   **Returns:** `pd.DataFrame`: DataFrame with byte strings decoded.

### `RasUtils.perform_kdtree_query(reference_points, query_points, max_distance=2.0)`

*   **Purpose:** Finds the nearest point in `reference_points` for each point in `query_points` using KDTree, within a maximum distance.
*   **Parameters:**
    *   `reference_points` (`np.ndarray`): NxD array of reference points.
    *   `query_points` (`np.ndarray`): MxD array of query points.
    *   `max_distance` (`float`, optional): Maximum distance for a match. Default 2.0.
*   **Returns:** (`np.ndarray`): Array of length M containing indices of nearest reference points. Index is -1 if no point found within `max_distance`.

### `RasUtils.find_nearest_neighbors(points, max_distance=2.0)`

*   **Purpose:** Finds the nearest neighbor for each point within the same dataset using KDTree, excluding self-matches and points beyond `max_distance`.
*   **Parameters:**
    *   `points` (`np.ndarray`): NxD array of points.
    *   `max_distance` (`float`, optional): Maximum distance for a match. Default 2.0.
*   **Returns:** (`np.ndarray`): Array of length N containing indices of the nearest neighbor for each point. Index is -1 if no neighbor found within `max_distance`.

### `RasUtils.consolidate_dataframe(dataframe, group_by=None, pivot_columns=None, level=None, n_dimensional=False, aggregation_method='list')`

*   **Purpose:** Aggregates rows in a DataFrame based on grouping criteria, typically merging values into lists.
*   **Parameters:**
    *   `dataframe` (`pd.DataFrame`): Input DataFrame.
    *   `group_by` (`str` or `List[str]`, optional): Column(s) or index level(s) to group by.
    *   `pivot_columns` (`str` or `List[str]`, optional): Column(s) to use for pivoting (if `n_dimensional`).
    *   `level` (`int`, optional): Index level to group by.
    *   `n_dimensional` (`bool`, optional): Use `pivot_table` if `True`. Default `False`.
    *   `aggregation_method` (`str` or `Callable`, optional): How to aggregate ('list', 'sum', 'mean', etc.). Default 'list'.
*   **Returns:** `pd.DataFrame`: The consolidated DataFrame.

### `RasUtils.find_nearest_value(array, target_value)`

*   **Purpose:** Finds the element in an array that is numerically closest to a target value.
*   **Parameters:**
    *   `array` (`list` or `np.ndarray`): Array of numbers to search within.
    *   `target_value` (`int` or `float`): The value to find the nearest match for.
*   **Returns:** (`int` or `float`): The value from the array closest to `target_value`.

### `RasUtils.horizontal_distance(coord1, coord2)`

*   **Purpose:** Calculates the 2D Euclidean distance between two points.
*   **Parameters:**
    *   `coord1` (`np.ndarray`): [X, Y] coordinates of the first point.
    *   `coord2` (`np.ndarray`): [X, Y] coordinates of the second point.
*   **Returns:** (`float`): The horizontal distance.

---

## Class: RasExamples

Provides methods to download, manage, and access HEC-RAS example projects included with the official HEC-RAS releases. Useful for testing and demonstration.

### `RasExamples.get_example_projects(version_number='6.6')`

*   **Purpose:** Downloads the example projects zip file for a specific HEC-RAS version if it doesn't already exist locally. Initializes the class to read the zip file structure.
*   **Parameters:**
    *   `version_number` (`str`, optional): HEC-RAS version string (e.g., "6.6", "6.5"). Default is "6.6".
*   **Returns:** (`Path`): Path to the directory where projects will be extracted (`example_projects`).
*   **Raises:** `ValueError` for invalid version, `requests.exceptions.RequestException` on download failure.

### `RasExamples.list_categories()`

*   **Purpose:** Lists the categories (top-level folders) available in the example projects zip file.
*   **Parameters:** None.
*   **Returns:** (`List[str]`): List of category names.

### `RasExamples.list_projects(category=None)`

*   **Purpose:** Lists the project names available, optionally filtered by category.
*   **Parameters:**
    *   `category` (`str`, optional): If provided, lists projects only within this category.
*   **Returns:** (`List[str]`): List of project names.

### `RasExamples.extract_project(project_names)`

*   **Purpose:** Extracts one or more specified projects from the zip file into the `example_projects` directory. Overwrites if already extracted.
*   **Parameters:**
    *   `project_names` (`str` or `List[str]`): Name(s) of the project(s) to extract.
*   **Returns:** (`Path` or `List[Path]`): Path(s) to the extracted project folder(s). Returns a single `Path` if one name was given, a list otherwise.
*   **Raises:** `ValueError` if a project name is not found.

### `RasExamples.is_project_extracted(project_name)`

*   **Purpose:** Checks if a specific project has already been extracted into the `example_projects` directory.
*   **Parameters:**
    *   `project_name` (`str`): Name of the project to check.
*   **Returns:** (`bool`): `True` if the project folder exists, `False` otherwise.

### `RasExamples.clean_projects_directory()`

*   **Purpose:** Removes the entire `example_projects` directory and its contents, then recreates the empty directory.
*   **Parameters:** None.
*   **Returns:** `None`.

### `RasExamples.download_fema_ble_model(huc8, output_dir=None)`

*   **Purpose:** (Placeholder) Intended to download FEMA Base Level Engineering models. *Currently not implemented.*
*   **Parameters:**
    *   `huc8` (`str`): 8-digit HUC code.
    *   `output_dir` (`str`, optional): Directory to save files.
*   **Returns:** (`str`): Path to the extracted model directory.

---

## Class: RasCmdr

Contains static methods for executing HEC-RAS simulations. Assumes a `RasPrj` object (defaulting to global `ras`) is initialized.

### `RasCmdr.compute_plan(plan_number, dest_folder=None, ras_object=None, clear_geompre=False, num_cores=None, overwrite_dest=False)`

*   **Purpose:** Executes a single HEC-RAS plan computation using the command line. Optionally copies the project to a destination folder first.
*   **Parameters:**
    *   `plan_number` (`str` or `Path`): Plan number (e.g., "01") or full path to plan file.
    *   `dest_folder` (`str` or `Path`, optional): Folder name (relative to project parent) or full path for computation. If `None`, runs in the original project folder.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
    *   `clear_geompre` (`bool`, optional): Clear `.c*` files before running. Default `False`.
    *   `num_cores` (`int`, optional): Number of cores to set in the plan file before running. Default `None` (use plan's current setting).
    *   `overwrite_dest` (`bool`, optional): If `True`, overwrite `dest_folder` if it exists. Default `False`.
*   **Returns:** (`bool`): `True` if execution succeeded (process completed without error), `False` otherwise.
*   **Raises:** `ValueError` if `dest_folder` exists and `overwrite_dest` is False, `FileNotFoundError`, `PermissionError`, `subprocess.CalledProcessError`.

### `RasCmdr.compute_parallel(plan_number=None, max_workers=2, num_cores=2, clear_geompre=False, ras_object=None, dest_folder=None, overwrite_dest=False)`

*   **Purpose:** Executes multiple HEC-RAS plans in parallel by creating temporary worker copies of the project. Consolidates results into a final destination folder.
*   **Parameters:**
    *   `plan_number` (`str` or `List[str]`, optional): Plan number(s) to run. If `None`, runs all plans in the project.
    *   `max_workers` (`int`, optional): Max number of parallel HEC-RAS instances. Default 2.
    *   `num_cores` (`int`, optional): Cores assigned to *each* worker instance. Default 2.
    *   `clear_geompre` (`bool`, optional): Clear `.c*` files in worker folders. Default `False`.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
    *   `dest_folder` (`str` or `Path`, optional): Final folder for consolidated results. If `None`, creates `ProjectName [Computed]` folder.
    *   `overwrite_dest` (`bool`, optional): Overwrite `dest_folder` if it exists. Default `False`.
*   **Returns:** `Dict[str, bool]`: Dictionary mapping plan numbers to their execution success status (`True`/`False`).
*   **Raises:** `ValueError`, `FileNotFoundError`, `PermissionError`, `RuntimeError`.

### `RasCmdr.compute_test_mode(plan_number=None, dest_folder_suffix="[Test]", clear_geompre=False, num_cores=None, ras_object=None, overwrite_dest=False)`

*   **Purpose:** Executes specified HEC-RAS plans sequentially within a dedicated test folder (a copy of the project).
*   **Parameters:**
    *   `plan_number` (`str` or `List[str]`, optional): Plan number(s) to run. If `None`, runs all plans.
    *   `dest_folder_suffix` (`str`, optional): Suffix for the test folder name (e.g., `ProjectName [Test]`). Default "[Test]".
    *   `clear_geompre` (`bool`, optional): Clear `.c*` files before running each plan. Default `False`.
    *   `num_cores` (`int`, optional): Cores to set for each plan execution. Default `None`.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
    *   `overwrite_dest` (`bool`, optional): Overwrite test folder if it exists. Default `False`.
*   **Returns:** `Dict[str, bool]`: Dictionary mapping plan numbers to their execution success status (`True`/`False`).
*   **Raises:** `ValueError`, `FileNotFoundError`, `PermissionError`.

---

## Class: HdfBase

Contains fundamental static methods for interacting with HEC-RAS HDF files. Used by other `Hdf*` classes. Requires an open `h5py.File` object or uses `@standardize_input`.

### `HdfBase.get_simulation_start_time(hdf_file)`

*   **Purpose:** Extracts the simulation start time attribute from the Plan Information group.
*   **Parameters:**
    *   `hdf_file` (`h5py.File`): Open HDF file object.
*   **Returns:** (`datetime`): Simulation start time.
*   **Raises:** `ValueError` if path not found or time parsing fails.

### `HdfBase.get_unsteady_timestamps(hdf_file)`

*   **Purpose:** Extracts the list of unsteady output timestamps (usually in milliseconds format) and converts them to datetime objects.
*   **Parameters:**
    *   `hdf_file` (`h5py.File`): Open HDF file object.
*   **Returns:** `List[datetime]`: List of datetime objects for each output time step.

### `HdfBase.get_2d_flow_area_names_and_counts(hdf_path)`

*   **Purpose:** Gets the names and cell counts of all 2D Flow Areas defined in the geometry HDF.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file (usually geometry HDF).
*   **Returns:** `List[Tuple[str, int]]`: List of tuples `(area_name, cell_count)`.
*   **Raises:** `ValueError` on read errors.

### `HdfBase.get_projection(hdf_path)`

*   **Purpose:** Retrieves the spatial projection information (WKT string) from the HDF file attributes or associated `.rasmap` file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file.
*   **Returns:** (`str` or `None`): Well-Known Text (WKT) string of the projection, or `None` if not found.

### `HdfBase.get_attrs(hdf_file, attr_path)`

*   **Purpose:** Retrieves all attributes from a specific group or dataset within the HDF file.
*   **Parameters:**
    *   `hdf_file` (`h5py.File`): Open HDF file object.
    *   `attr_path` (`str`): Internal HDF path to the group/dataset (e.g., "Plan Data/Plan Information").
*   **Returns:** `Dict[str, Any]`: Dictionary of attributes. Returns empty dict if path not found.

### `HdfBase.get_dataset_info(file_path, group_path='/')`

*   **Purpose:** Prints a recursive listing of the structure (groups, datasets, attributes, shapes, dtypes) within an HDF5 file, starting from `group_path`.
*   **Parameters:**
    *   `file_path` (Input handled by `@standardize_input`): Path identifier for the HDF file.
    *   `group_path` (`str`, optional): Internal HDF path to start exploration from. Default is root ('/').
*   **Returns:** `None`. Prints to console.

### `HdfBase.get_polylines_from_parts(hdf_path, path, info_name="Polyline Info", parts_name="Polyline Parts", points_name="Polyline Points")`

*   **Purpose:** Reconstructs Shapely LineString or MultiLineString geometries from HEC-RAS's standard polyline representation in HDF (using Info, Parts, Points datasets).
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file.
    *   `path` (`str`): Internal HDF base path containing the polyline datasets (e.g., "Geometry/River Centerlines").
    *   `info_name` (`str`, optional): Name of the dataset containing polyline start/count info. Default "Polyline Info".
    *   `parts_name` (`str`, optional): Name of the dataset defining parts for multi-part lines. Default "Polyline Parts".
    *   `points_name` (`str`, optional): Name of the dataset containing all point coordinates. Default "Polyline Points".
*   **Returns:** `List[LineString or MultiLineString]`: List of reconstructed Shapely geometries.

### `HdfBase.print_attrs(name, obj)`

*   **Purpose:** Helper method to print the attributes of an HDF5 object (Group or Dataset) during exploration (used by `get_dataset_info`).
*   **Parameters:**
    *   `name` (`str`): Name of the HDF5 object.
    *   `obj` (`h5py.Group` or `h5py.Dataset`): The HDF5 object.
*   **Returns:** `None`. Prints to console.

---

## Class: HdfBndry

Contains static methods for extracting boundary-related *geometry* features (BC Lines, Breaklines, Refinement Regions, Reference Lines/Points) from HEC-RAS HDF files (typically geometry HDF). Returns GeoDataFrames.

### `HdfBndry.get_bc_lines(hdf_path)`

*   **Purpose:** Extracts 2D Flow Area Boundary Condition Lines.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier (usually geometry HDF).
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with LineString geometries and attributes (Name, SA-2D, Type, etc.).

### `HdfBndry.get_breaklines(hdf_path)`

*   **Purpose:** Extracts 2D Flow Area Break Lines. Skips invalid (zero-length, single-point) breaklines.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier (usually geometry HDF).
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with LineString/MultiLineString geometries and attributes (bl_id, Name).

### `HdfBndry.get_refinement_regions(hdf_path)`

*   **Purpose:** Extracts 2D Flow Area Refinement Regions.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier (usually geometry HDF).
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with Polygon/MultiPolygon geometries and attributes (rr_id, Name).

### `HdfBndry.get_reference_lines(hdf_path, mesh_name=None)`

*   **Purpose:** Extracts Reference Lines used for profile output, optionally filtering by mesh name.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier (usually geometry HDF).
    *   `mesh_name` (`str`, optional): Filter results to this specific mesh area.
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with LineString/MultiLineString geometries and attributes (refln_id, Name, mesh_name, Type).

### `HdfBndry.get_reference_points(hdf_path, mesh_name=None)`

*   **Purpose:** Extracts Reference Points used for point output, optionally filtering by mesh name.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier (usually geometry HDF).
    *   `mesh_name` (`str`, optional): Filter results to this specific mesh area.
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with Point geometries and attributes (refpt_id, Name, mesh_name, Cell Index).

---

## Class: HdfFluvialPluvial

Contains static methods for analyzing fluvial-pluvial boundaries based on simulation results.

### `HdfFluvialPluvial.calculate_fluvial_pluvial_boundary(hdf_path, delta_t=12)`

*   **Purpose:** Calculates the boundary line between areas dominated by fluvial (riverine) vs. pluvial (rainfall/local) flooding, based on the timing difference of maximum water surface elevation between adjacent 2D cells. Attempts to join adjacent boundary segments.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF file.
    *   `delta_t` (`float`, optional): Time difference threshold in hours. Adjacent cells with max WSE time differences greater than this are considered part of the boundary. Default is 12.
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame containing LineString geometries representing the calculated boundary. CRS matches the input HDF.
*   **Raises:** `ValueError` if required mesh or results data is missing.

---

## Class: HdfInfiltration

Contains static methods for handling infiltration data within HEC-RAS HDF files (typically geometry HDF).

### `HdfInfiltration.get_infiltration_baseoverrides(hdf_path: Path) -> Optional[pd.DataFrame]`

*   **Purpose:** Retrieves current infiltration parameters from a HEC-RAS geometry HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
*   **Returns:** `Optional[pd.DataFrame]`: DataFrame containing infiltration parameters if successful, None if operation fails.

### `HdfInfiltration.get_infiltration_layer_data(hdf_path: Path) -> Optional[pd.DataFrame]`

*   **Purpose:** Retrieves current infiltration parameters from a HEC-RAS infiltration layer HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the infiltration layer HDF.
*   **Returns:** `Optional[pd.DataFrame]`: DataFrame containing infiltration parameters if successful, None if operation fails.

### `HdfInfiltration.set_infiltration_layer_data(hdf_path: Path, infiltration_df: pd.DataFrame) -> Optional[pd.DataFrame]`

*   **Purpose:** Sets infiltration layer data in the infiltration layer HDF file directly from the provided DataFrame.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the infiltration layer HDF.
    *   `infiltration_df` (`pd.DataFrame`): DataFrame containing infiltration parameters.
*   **Returns:** `Optional[pd.DataFrame]`: The infiltration DataFrame if successful, None if operation fails.

### `HdfInfiltration.scale_infiltration_data(hdf_path: Path, infiltration_df: pd.DataFrame, scale_factors: Dict[str, float]) -> Optional[pd.DataFrame]`

*   **Purpose:** Updates infiltration parameters in the HDF file with scaling factors.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
    *   `infiltration_df` (`pd.DataFrame`): DataFrame containing infiltration parameters.
    *   `scale_factors` (`Dict[str, float]`): Dictionary mapping column names to their scaling factors.
*   **Returns:** `Optional[pd.DataFrame]`: The updated infiltration DataFrame if successful, None if operation fails.

### `HdfInfiltration.get_infiltration_map(hdf_path: Path = None, ras_object: Any = None) -> dict`

*   **Purpose:** Reads the infiltration raster map from HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file. If not provided, uses first infiltration_hdf_path from rasmap_df.
    *   `ras_object` (`RasPrj`, optional): Specific RAS object to use. If None, uses the global ras instance.
*   **Returns:** `dict`: Dictionary mapping raster values to mukeys.

### `HdfInfiltration.calculate_soil_statistics(zonal_stats: list, raster_map: dict) -> pd.DataFrame`

*   **Purpose:** Calculates soil statistics from zonal statistics.
*   **Parameters:**
    *   `zonal_stats` (`list`): List of zonal statistics.
    *   `raster_map` (`dict`): Dictionary mapping raster values to mukeys.
*   **Returns:** `pd.DataFrame`: DataFrame with soil statistics including percentages and areas.

### `HdfInfiltration.get_significant_mukeys(soil_stats: pd.DataFrame, threshold: float = 1.0) -> pd.DataFrame`

*   **Purpose:** Gets mukeys with percentage greater than threshold.
*   **Parameters:**
    *   `soil_stats` (`pd.DataFrame`): DataFrame with soil statistics.
    *   `threshold` (`float`, optional): Minimum percentage threshold. Default 1.0.
*   **Returns:** `pd.DataFrame`: DataFrame with significant mukeys and their statistics.

### `HdfInfiltration.calculate_total_significant_percentage(significant_mukeys: pd.DataFrame) -> float`

*   **Purpose:** Calculates total percentage covered by significant mukeys.
*   **Parameters:**
    *   `significant_mukeys` (`pd.DataFrame`): DataFrame of significant mukeys.
*   **Returns:** `float`: Total percentage covered by significant mukeys.

### `HdfInfiltration.save_statistics(soil_stats: pd.DataFrame, output_path: Path, include_timestamp: bool = True)`

*   **Purpose:** Saves soil statistics to CSV.
*   **Parameters:**
    *   `soil_stats` (`pd.DataFrame`): DataFrame with soil statistics.
    *   `output_path` (`Path`): Path to save CSV file.
    *   `include_timestamp` (`bool`, optional): Whether to include timestamp in filename. Default True.
*   **Returns:** None

### `HdfInfiltration.get_infiltration_parameters(hdf_path: Path = None, mukey: str = None, ras_object: Any = None) -> dict`

*   **Purpose:** Gets infiltration parameters for a specific mukey from HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file. If not provided, uses first infiltration_hdf_path from rasmap_df.
    *   `mukey` (`str`): Mukey identifier.
    *   `ras_object` (`RasPrj`, optional): Specific RAS object to use. If None, uses the global ras instance.
*   **Returns:** `dict`: Dictionary of infiltration parameters.

### `HdfInfiltration.calculate_weighted_parameters(soil_stats: pd.DataFrame, infiltration_params: dict) -> dict`

*   **Purpose:** Calculates weighted infiltration parameters based on soil statistics.
*   **Parameters:**
    *   `soil_stats` (`pd.DataFrame`): DataFrame with soil statistics.
    *   `infiltration_params` (`dict`): Dictionary of infiltration parameters by mukey.
*   **Returns:** `dict`: Dictionary of weighted average infiltration parameters.

---

## Class: HdfMesh

Contains static methods for extracting 2D mesh geometry information from HEC-RAS HDF files (typically geometry or plan HDF).

### `HdfMesh.get_mesh_area_names(hdf_path)`

*   **Purpose:** Retrieves the names of all 2D Flow Areas defined in the HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file.
*   **Returns:** `List[str]`: List of 2D Flow Area names.

### `HdfMesh.get_mesh_areas(hdf_path)`

*   **Purpose:** Extracts the outer perimeter polygons for each 2D Flow Area.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with Polygon geometries and 'mesh_name' attribute.

### `HdfMesh.get_mesh_cell_polygons(hdf_path)`

*   **Purpose:** Reconstructs the individual cell polygons for all 2D Flow Areas by assembling cell faces.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with Polygon geometries and attributes 'mesh_name', 'cell_id'.

### `HdfMesh.get_mesh_cell_points(hdf_path)`

*   **Purpose:** Extracts the center point coordinates for each cell in all 2D Flow Areas.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file.
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with Point geometries and attributes 'mesh_name', 'cell_id'.

### `HdfMesh.get_mesh_cell_faces(hdf_path)`

*   **Purpose:** Extracts the face line segments that form the boundaries of the mesh cells.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file.
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with LineString geometries and attributes 'mesh_name', 'face_id'.

### `HdfMesh.get_mesh_area_attributes(hdf_path)`

*   **Purpose:** Retrieves the main attributes associated with the 2D Flow Areas group in the geometry HDF.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
*   **Returns:** `pd.DataFrame`: DataFrame containing the attributes (e.g., Manning's n values).

### `HdfMesh.get_mesh_face_property_tables(hdf_path)`

*   **Purpose:** Extracts the detailed hydraulic property tables (Elevation vs. Area, Wetted Perimeter, Roughness) associated with each *face* in each 2D Flow Area.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
*   **Returns:** `Dict[str, pd.DataFrame]`: Dictionary mapping mesh names to DataFrames. Each DataFrame contains columns ['Face ID', 'Z', 'Area', 'Wetted Perimeter', "Manning's n"].

### `HdfMesh.get_mesh_cell_property_tables(hdf_path)`

*   **Purpose:** Extracts the detailed hydraulic property tables (Elevation vs. Volume, Surface Area) associated with each *cell* in each 2D Flow Area.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
*   **Returns:** `Dict[str, pd.DataFrame]`: Dictionary mapping mesh names to DataFrames. Each DataFrame contains columns ['Cell ID', 'Z', 'Volume', 'Surface Area'].

---

## Class: HdfPipe

Contains static methods for handling pipe network geometry and results data from HEC-RAS HDF files.

### `HdfPipe.get_pipe_conduits(hdf_path, crs="EPSG:4326")`

*   **Purpose:** Extracts pipe conduit centerlines, attributes, and terrain profiles from the geometry HDF.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file (usually geometry or plan HDF).
    *   `crs` (`str`, optional): Coordinate Reference System string. Default "EPSG:4326".
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with LineString geometries ('Polyline'), attributes, and 'Terrain_Profiles' (list of (station, elevation) tuples).

### `HdfPipe.get_pipe_nodes(hdf_path)`

*   **Purpose:** Extracts pipe node locations and attributes from the geometry HDF.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file (usually geometry or plan HDF).
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with Point geometries and attributes.

### `HdfPipe.get_pipe_network(hdf_path, pipe_network_name=None, crs="EPSG:4326")`

*   **Purpose:** Extracts the detailed geometry of a specific pipe network, including cell polygons, faces, nodes, and connectivity information from the geometry HDF.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file (usually geometry or plan HDF).
    *   `pipe_network_name` (`str`, optional): Name of the network. If `None`, uses the first network found.
    *   `crs` (`str`, optional): Coordinate Reference System string. Default "EPSG:4326".
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame primarily representing cells (Polygon geometry), with related face and node info included as attributes or object columns.
*   **Raises:** `ValueError` if `pipe_network_name` not found.

### `HdfPipe.get_pipe_profile(hdf_path, conduit_id)`

*   **Purpose:** Extracts the station-elevation terrain profile data for a specific pipe conduit from the geometry HDF.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file (usually geometry or plan HDF).
    *   `conduit_id` (`int`): Zero-based index of the conduit.
*   **Returns:** `pd.DataFrame`: DataFrame with columns ['Station', 'Elevation'].
*   **Raises:** `KeyError`, `IndexError`.

### `HdfPipe.get_pipe_network_timeseries(hdf_path, variable)`

*   **Purpose:** Extracts time series results for a specified variable across all elements (cells, faces, pipes, nodes) of a pipe network.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `variable` (`str`): The results variable name (e.g., "Cell Water Surface", "Pipes/Pipe Flow DS", "Nodes/Depth").
*   **Returns:** `xr.DataArray`: DataArray with dimensions ('time', 'location') containing the time series values. Includes units attribute.
*   **Raises:** `ValueError` for invalid variable name, `KeyError`.

### `HdfPipe.get_pipe_network_summary(hdf_path)`

*   **Purpose:** Extracts summary statistics (min/max values, timing) for pipe network results from the plan results HDF.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
*   **Returns:** `pd.DataFrame`: DataFrame containing the summary statistics. Returns empty DataFrame if data not found.
*   **Raises:** `KeyError`.

### `HdfPipe.extract_timeseries_for_node(plan_hdf_path, node_id)`

*   **Purpose:** Extracts time series data specifically for a single pipe node (Depth, Drop Inlet Flow, Water Surface).
*   **Parameters:**
    *   `plan_hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `node_id` (`int`): Zero-based index of the node.
*   **Returns:** `Dict[str, xr.DataArray]`: Dictionary mapping variable names to their respective DataArrays (time dimension only).

### `HdfPipe.extract_timeseries_for_conduit(plan_hdf_path, conduit_id)`

*   **Purpose:** Extracts time series data specifically for a single pipe conduit (Flow US/DS, Velocity US/DS).
*   **Parameters:**
    *   `plan_hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `conduit_id` (`int`): Zero-based index of the conduit.
*   **Returns:** `Dict[str, xr.DataArray]`: Dictionary mapping variable names to their respective DataArrays (time dimension only).

---

## Class: HdfPlan

Contains static methods for extracting general plan-level information and attributes from HEC-RAS HDF files (plan or geometry HDF).

### `HdfPlan.get_plan_start_time(hdf_path)`

*   **Purpose:** Gets the simulation start time from the plan HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan HDF.
*   **Returns:** (`datetime`): Simulation start time.
*   **Raises:** `ValueError`.

### `HdfPlan.get_plan_end_time(hdf_path)`

*   **Purpose:** Gets the simulation end time from the plan HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan HDF.
*   **Returns:** (`datetime`): Simulation end time.
*   **Raises:** `ValueError`.

### `HdfPlan.get_plan_timestamps_list(hdf_path)`

*   **Purpose:** Gets the list of simulation output timestamps from the plan HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan HDF.
*   **Returns:** `List[datetime]`: List of output datetime objects.
*   **Raises:** `ValueError`.

### `HdfPlan.get_plan_information(hdf_path)`

*   **Purpose:** Extracts all attributes from the 'Plan Data/Plan Information' group in the plan HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan HDF.
*   **Returns:** `Dict[str, Any]`: Dictionary of plan information attributes.
*   **Raises:** `ValueError`.

### `HdfPlan.get_plan_parameters(hdf_path)`

*   **Purpose:** Extracts all attributes from the 'Plan Data/Plan Parameters' group in the plan HDF file and returns them as a DataFrame. Includes the plan number extracted from the filename.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan HDF.
*   **Returns:** `pd.DataFrame`: DataFrame with columns ['Plan', 'Parameter', 'Value'].
*   **Raises:** `ValueError`.

### `HdfPlan.get_plan_met_precip(hdf_path)`

*   **Purpose:** Extracts precipitation attributes from the 'Event Conditions/Meteorology/Precipitation' group in the plan HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan HDF.
*   **Returns:** `Dict[str, Any]`: Dictionary of precipitation attributes. Returns empty dict if not found.

### `HdfPlan.get_geometry_information(hdf_path)`

*   **Purpose:** Extracts root-level attributes (like Version, Units, Projection) from the 'Geometry' group in a geometry HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
*   **Returns:** `pd.DataFrame`: DataFrame with columns ['Value'] and index ['Attribute Name'].
*   **Raises:** `ValueError`.

---

## Class: HdfPlot

Contains static methods for creating basic plots from HEC-RAS HDF data using `matplotlib`.

### `HdfPlot.plot_mesh_cells(cell_polygons_df, projection, title='2D Flow Area Mesh Cells', figsize=(12, 8))`

*   **Purpose:** Plots 2D mesh cell outlines from a GeoDataFrame.
*   **Parameters:**
    *   `cell_polygons_df` (`gpd.GeoDataFrame`): GeoDataFrame containing cell polygons (requires 'geometry' column).
    *   `projection` (`str`): CRS string to assign if `cell_polygons_df` doesn't have one.
    *   `title` (`str`, optional): Plot title. Default '2D Flow Area Mesh Cells'.
    *   `figsize` (`Tuple[int, int]`, optional): Figure size. Default (12, 8).
*   **Returns:** (`gpd.GeoDataFrame` or `None`): The input GeoDataFrame (with CRS possibly assigned), or `None` if input was empty. Displays the plot.

### `HdfPlot.plot_time_series(df, x_col, y_col, title=None, figsize=(12, 6))`

*   **Purpose:** Creates a simple line plot for time series data from a DataFrame.
*   **Parameters:**
    *   `df` (`pd.DataFrame`): DataFrame containing the data.
    *   `x_col` (`str`): Column name for the x-axis (usually time).
    *   `y_col` (`str`): Column name for the y-axis.
    *   `title` (`str`, optional): Plot title. Default `None`.
    *   `figsize` (`Tuple[int, int]`, optional): Figure size. Default (12, 6).
*   **Returns:** `None`. Displays the plot.

---

## Class: HdfPump

Contains static methods for handling pump station geometry and results data from HEC-RAS HDF files.

### `HdfPump.get_pump_stations(hdf_path)`

*   **Purpose:** Extracts pump station locations and attributes from the geometry HDF.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file (usually geometry or plan HDF).
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with Point geometries and attributes including 'station_id'.
*   **Raises:** `KeyError`.

### `HdfPump.get_pump_groups(hdf_path)`

*   **Purpose:** Extracts pump group attributes and efficiency curve data from the geometry HDF.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`): Path identifier for the HDF file (usually geometry or plan HDF).
*   **Returns:** `pd.DataFrame`: DataFrame containing pump group attributes and 'efficiency_curve' data (list of values).
*   **Raises:** `KeyError`.

### `HdfPump.get_pump_station_timeseries(hdf_path, pump_station)`

*   **Purpose:** Extracts time series results (Flow, Stage HW, Stage TW, Pumps On) for a specific pump station from the plan results HDF.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `pump_station` (`str`): Name of the pump station as defined in HEC-RAS.
*   **Returns:** `xr.DataArray`: DataArray with dimensions ('time', 'variable') containing the time series. Includes units attribute.
*   **Raises:** `KeyError`, `ValueError` if pump station not found.

### `HdfPump.get_pump_station_summary(hdf_path)`

*   **Purpose:** Extracts summary statistics (min/max values, volumes, durations) for all pump stations from the plan results HDF.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
*   **Returns:** `pd.DataFrame`: DataFrame containing the summary statistics. Returns empty DataFrame if data not found.
*   **Raises:** `KeyError`.

### `HdfPump.get_pump_operation_timeseries(hdf_path, pump_station)`

*   **Purpose:** Extracts detailed pump operation time series data (similar to `get_pump_station_timeseries` but often from a different HDF group, potentially DSS Profile Output) for a specific pump station. Returns as a DataFrame.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `pump_station` (`str`): Name of the pump station.
*   **Returns:** `pd.DataFrame`: DataFrame with columns ['Time', 'Flow', 'Stage HW', 'Stage TW', 'Pump Station', 'Pumps on'].
*   **Raises:** `KeyError`, `ValueError` if pump station not found.

---

## Class: HdfResultsMesh

Contains static methods for extracting and analyzing 2D mesh *results* data from HEC-RAS plan HDF files.

### `HdfResultsMesh.get_mesh_summary(hdf_path, var, round_to="100ms")`

*   **Purpose:** Extracts summary output (e.g., max/min values and times) for a specific variable across all cells/faces in all 2D areas. Merges with geometry (points for cells, lines for faces).
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `var` (`str`): The summary variable name (e.g., "Maximum Water Surface", "Maximum Face Velocity", "Cell Last Iteration").
    *   `round_to` (`str`, optional): Time rounding precision for timestamps. Default "100ms".
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame containing the summary results, geometry, and mesh/element IDs.
*   **Raises:** `ValueError`.

### `HdfResultsMesh.get_mesh_timeseries(hdf_path, mesh_name, var, truncate=True)`

*   **Purpose:** Extracts the full time series for a specific variable for all cells or faces within a *single* specified 2D mesh area.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `mesh_name` (`str`): Name of the 2D Flow Area.
    *   `var` (`str`): Results variable name (e.g., "Water Surface", "Face Velocity", "Depth").
    *   `truncate` (`bool`, optional): If `True`, remove trailing zero-value time steps. Default `True`.
*   **Returns:** `xr.DataArray`: DataArray with dimensions ('time', 'cell_id' or 'face_id') containing the time series. Includes units attribute.
*   **Raises:** `ValueError`.

### `HdfResultsMesh.get_mesh_faces_timeseries(hdf_path, mesh_name)`

*   **Purpose:** Extracts time series for all standard *face-based* variables ("Face Velocity", "Face Flow") for a specific mesh area.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `mesh_name` (`str`): Name of the 2D Flow Area.
*   **Returns:** `xr.Dataset`: Dataset containing DataArrays for each face variable, indexed by time and face_id.

### `HdfResultsMesh.get_mesh_cells_timeseries(hdf_path, mesh_names=None, var=None, truncate=False, ras_object=None)`

*   **Purpose:** Extracts time series for specified (or all) *cell-based* variables for specified (or all) mesh areas.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `mesh_names` (`str` or `List[str]`, optional): Name(s) of mesh area(s). If `None`, processes all.
    *   `var` (`str`, optional): Specific variable name. If `None`, retrieves all available cell and face variables.
    *   `truncate` (`bool`, optional): Remove trailing zero time steps. Default `False`.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `Dict[str, xr.Dataset]`: Dictionary mapping mesh names to Datasets containing the requested variable(s) as DataArrays, indexed by time and cell_id/face_id.
*   **Raises:** `ValueError`.

### `HdfResultsMesh.get_mesh_last_iter(hdf_path)`

*   **Purpose:** Shortcut to get the summary output for "Cell Last Iteration".
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
*   **Returns:** `pd.DataFrame`: DataFrame containing the last iteration count for each cell (via `get_mesh_summary`).

### `HdfResultsMesh.get_mesh_max_ws(hdf_path, round_to="100ms")`

*   **Purpose:** Shortcut to get the summary output for "Maximum Water Surface".
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `round_to` (`str`, optional): Time rounding precision. Default "100ms".
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame containing max WSE and time for each cell (via `get_mesh_summary`).

### `HdfResultsMesh.get_mesh_min_ws(hdf_path, round_to="100ms")`

*   **Purpose:** Shortcut to get the summary output for "Minimum Water Surface".
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `round_to` (`str`, optional): Time rounding precision. Default "100ms".
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame containing min WSE and time for each cell (via `get_mesh_summary`).

### `HdfResultsMesh.get_mesh_max_face_v(hdf_path, round_to="100ms")`

*   **Purpose:** Shortcut to get the summary output for "Maximum Face Velocity".
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `round_to` (`str`, optional): Time rounding precision. Default "100ms".
*   **Returns:** `pd.DataFrame`: DataFrame containing max velocity and time for each face (via `get_mesh_summary`).

### `HdfResultsMesh.get_mesh_min_face_v(hdf_path, round_to="100ms")`

*   **Purpose:** Shortcut to get the summary output for "Minimum Face Velocity".
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `round_to` (`str`, optional): Time rounding precision. Default "100ms".
*   **Returns:** `pd.DataFrame`: DataFrame containing min velocity and time for each face (via `get_mesh_summary`).

### `HdfResultsMesh.get_mesh_max_ws_err(hdf_path, round_to="100ms")`

*   **Purpose:** Shortcut to get the summary output for "Cell Maximum Water Surface Error".
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `round_to` (`str`, optional): Time rounding precision. Default "100ms".
*   **Returns:** `pd.DataFrame`: DataFrame containing max WSE error and time for each cell (via `get_mesh_summary`).

### `HdfResultsMesh.get_mesh_max_iter(hdf_path, round_to="100ms")`

*   **Purpose:** Shortcut to get the summary output for "Cell Last Iteration" (often used as max iteration indicator).
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `round_to` (`str`, optional): Time rounding precision. Default "100ms".
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame containing max iteration count and time for each cell (via `get_mesh_summary`).

---

## Class: HdfResultsPlan

Contains static methods for extracting general plan-level *results* and summary information from HEC-RAS plan HDF files.

### `HdfResultsPlan.get_unsteady_info(hdf_path)`

*   **Purpose:** Extracts attributes from the 'Results/Unsteady' group in the plan HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
*   **Returns:** `pd.DataFrame`: Single-row DataFrame containing the unsteady results attributes.
*   **Raises:** `FileNotFoundError`, `KeyError`, `RuntimeError`.

### `HdfResultsPlan.get_unsteady_summary(hdf_path)`

*   **Purpose:** Extracts attributes from the 'Results/Unsteady/Summary' group in the plan HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
*   **Returns:** `pd.DataFrame`: Single-row DataFrame containing the unsteady summary attributes.
*   **Raises:** `FileNotFoundError`, `KeyError`, `RuntimeError`.

### `HdfResultsPlan.get_volume_accounting(hdf_path)`

*   **Purpose:** Extracts attributes from the 'Results/Unsteady/Summary/Volume Accounting' group in the plan HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
*   **Returns:** (`pd.DataFrame` or `None`): Single-row DataFrame containing volume accounting attributes, or `None` if the group doesn't exist.
*   **Raises:** `FileNotFoundError`, `RuntimeError`.

### `HdfResultsPlan.get_runtime_data(hdf_path)`

*   **Purpose:** Extracts detailed computational performance metrics (durations, speeds) for different simulation processes (Geometry, Preprocessing, Unsteady Flow) from the plan HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
*   **Returns:** (`pd.DataFrame` or `None`): Single-row DataFrame containing runtime statistics, or `None` if data is missing or parsing fails.

### `HdfResultsPlan.get_reference_timeseries(hdf_path, reftype)`

*   **Purpose:** Extracts time series results for all Reference Lines or Reference Points from the plan HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `reftype` (`str`): Type of reference feature ('lines' or 'points').
*   **Returns:** `pd.DataFrame`: DataFrame containing time series data for the specified reference type. Each column represents a reference feature, indexed by time step. Returns empty DataFrame if data not found.

### `HdfResultsPlan.get_reference_summary(hdf_path, reftype)`

*   **Purpose:** Extracts summary results (e.g., max/min values) for all Reference Lines or Reference Points from the plan HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
    *   `reftype` (`str`): Type of reference feature ('lines' or 'points').
*   **Returns:** `pd.DataFrame`: DataFrame containing summary data for the specified reference type. Returns empty DataFrame if data not found.

---

## Class: HdfResultsPlot

Contains static methods for plotting specific HEC-RAS *results* data using `matplotlib`.

### `HdfResultsPlot.plot_results_max_wsel(max_ws_df)`

*   **Purpose:** Creates a scatter plot showing the spatial distribution of maximum water surface elevation (WSE) per mesh cell.
*   **Parameters:**
    *   `max_ws_df` (`gpd.GeoDataFrame`): GeoDataFrame containing max WSE results (requires 'geometry' and 'maximum_water_surface' columns, typically from `HdfResultsMesh.get_mesh_max_ws`).
*   **Returns:** `None`. Displays the plot.

### `HdfResultsPlot.plot_results_max_wsel_time(max_ws_df)`

*   **Purpose:** Creates a scatter plot showing the spatial distribution of the *time* at which maximum water surface elevation occurred for each mesh cell. Also prints timing statistics.
*   **Parameters:**
    *   `max_ws_df` (`gpd.GeoDataFrame`): GeoDataFrame containing max WSE results (requires 'geometry' and 'maximum_water_surface_time' columns, typically from `HdfResultsMesh.get_mesh_max_ws`).
*   **Returns:** `None`. Displays the plot and prints statistics.

### `HdfResultsPlot.plot_results_mesh_variable(variable_df, variable_name, colormap='viridis', point_size=10)`

*   **Purpose:** Creates a generic scatter plot for visualizing any scalar mesh variable (e.g., max depth, max velocity) spatially across cell points.
*   **Parameters:**
    *   `variable_df` (`gpd.GeoDataFrame` or `pd.DataFrame`): (Geo)DataFrame containing the variable data and either a 'geometry' column (Point) or 'x', 'y' columns.
    *   `variable_name` (`str`): The name of the column in `variable_df` containing the data to plot and label.
    *   `colormap` (`str`, optional): Matplotlib colormap name. Default 'viridis'.
    *   `point_size` (`int`, optional): Size of scatter plot points. Default 10.
*   **Returns:** `None`. Displays the plot.
*   **Raises:** `ValueError` if coordinates or variable column are missing.

---

## Class: HdfResultsXsec

Contains static methods for extracting 1D cross-section and related *results* data from HEC-RAS plan HDF files.

### `HdfResultsXsec.get_xsec_timeseries(hdf_path)`

*   **Purpose:** Extracts time series results (Water Surface, Velocity, Flow, etc.) for all 1D cross-sections. Includes cross-section attributes (River, Reach, Station) and calculated maximum values as coordinates/variables.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
*   **Returns:** `xr.Dataset`: Dataset containing DataArrays for each variable, indexed by time and cross_section name/identifier. Includes coordinates for attributes and max values.
*   **Raises:** `KeyError`.

### `HdfResultsXsec.get_ref_lines_timeseries(hdf_path)`

*   **Purpose:** Extracts time series results (Flow, Velocity, Water Surface) for all 1D Reference Lines.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
*   **Returns:** `xr.Dataset`: Dataset containing DataArrays for each variable, indexed by time and reference line ID/name. Returns empty dataset if data not found.
*   **Raises:** `FileNotFoundError`, `KeyError`.

### `HdfResultsXsec.get_ref_points_timeseries(hdf_path)`

*   **Purpose:** Extracts time series results (Flow, Velocity, Water Surface) for all 1D Reference Points.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='plan_hdf'`): Path identifier for the plan results HDF.
*   **Returns:** `xr.Dataset`: Dataset containing DataArrays for each variable, indexed by time and reference point ID/name. Returns empty dataset if data not found.
*   **Raises:** `FileNotFoundError`, `KeyError`.

---

## Class: HdfStruc

Contains static methods for extracting hydraulic structure *geometry* data from HEC-RAS HDF files (typically geometry HDF).

### `HdfStruc.get_structures(hdf_path, datetime_to_str=False)`

*   **Purpose:** Extracts geometry and attributes for all structures (bridges, culverts, inline structures, lateral structures) defined in the geometry HDF. Includes centerline geometry, profile data, and other specific attributes like bridge coefficients.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
    *   `datetime_to_str` (`bool`, optional): Convert datetime attributes to ISO strings. Default `False`.
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with LineString geometries (centerlines) and numerous attribute columns, including nested profile data ('Profile_Data'). Returns empty GeoDataFrame if no structures found.

### `HdfStruc.get_geom_structures_attrs(hdf_path)`

*   **Purpose:** Extracts the top-level attributes associated with the 'Geometry/Structures' group in the geometry HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
*   **Returns:** `pd.DataFrame`: Single-row DataFrame containing the group attributes. Returns empty DataFrame if group not found.

---

## Class: HdfUtils

Contains general static utility methods used for HDF processing, data conversion, and calculations.

### `HdfUtils.convert_ras_string(value)`

*   **Purpose:** Converts byte strings or regular strings potentially containing HEC-RAS specific formats (dates, durations, booleans) into appropriate Python objects (`bool`, `datetime`, `List[datetime]`, `timedelta`, `str`).
*   **Parameters:**
    *   `value` (`str` or `bytes`): Input string or byte string.
*   **Returns:** (`bool`, `datetime`, `List[datetime]`, `timedelta`, `str`): Converted Python object.

### `HdfUtils.convert_ras_hdf_value(value)`

*   **Purpose:** General converter for values read directly from HDF datasets (handles `np.nan`, byte strings, numpy types).
*   **Parameters:**
    *   `value` (`Any`): Value read from HDF.
*   **Returns:** (`None`, `bool`, `str`, `List[str]`, `int`, `float`, `List[int]`, `List[float]`): Converted Python object.

### `HdfUtils.convert_df_datetimes_to_str(df)`

*   **Purpose:** Converts all columns of dtype `datetime64` in a DataFrame to ISO format strings (`YYYY-MM-DD HH:MM:SS`).
*   **Parameters:**
    *   `df` (`pd.DataFrame`): Input DataFrame.
*   **Returns:** `pd.DataFrame`: DataFrame with datetime columns converted to strings.

### `HdfUtils.convert_hdf5_attrs_to_dict(attrs, prefix=None)`

*   **Purpose:** Converts HDF5 attributes (from `.attrs`) into a Python dictionary, applying `convert_ras_hdf_value` to each value.
*   **Parameters:**
    *   `attrs` (`h5py.AttributeManager` or `Dict`): Attributes object or dictionary.
    *   `prefix` (`str`, optional): Prefix to add to keys in the resulting dictionary.
*   **Returns:** `Dict[str, Any]`: Dictionary of converted attributes.

### `HdfUtils.convert_timesteps_to_datetimes(timesteps, start_time, time_unit="days", round_to="100ms")`

*   **Purpose:** Converts an array of numeric time steps (relative to a start time) into a pandas `DatetimeIndex`.
*   **Parameters:**
    *   `timesteps` (`np.ndarray`): Array of time step values.
    *   `start_time` (`datetime`): The reference start datetime.
    *   `time_unit` (`str`, optional): Unit of the `timesteps` ('days' or 'hours'). Default 'days'.
    *   `round_to` (`str`, optional): Pandas frequency string for rounding. Default '100ms'.
*   **Returns:** `pd.DatetimeIndex`: Index of datetime objects.

### `HdfUtils.perform_kdtree_query(reference_points, query_points, max_distance=2.0)`

*   **Purpose:** Finds nearest point in `reference_points` for each point in `query_points` using KDTree, within `max_distance`. Returns index or -1. (See `RasUtils` for identical function).
*   **Parameters:** See `RasUtils.perform_kdtree_query`.
*   **Returns:** (`np.ndarray`): Array of indices or -1.

### `HdfUtils.find_nearest_neighbors(points, max_distance=2.0)`

*   **Purpose:** Finds nearest neighbor for each point within the same dataset using KDTree, excluding self and points beyond `max_distance`. Returns index or -1. (See `RasUtils` for identical function).
*   **Parameters:** See `RasUtils.find_nearest_neighbors`.
*   **Returns:** (`np.ndarray`): Array of indices or -1.

### `HdfUtils.parse_ras_datetime(datetime_str)`

*   **Purpose:** Parses HEC-RAS standard datetime string format ("ddMMMYYYY HH:MM:SS").
*   **Parameters:**
    *   `datetime_str` (`str`): String to parse.
*   **Returns:** (`datetime`): Parsed datetime object.

### `HdfUtils.parse_ras_window_datetime(datetime_str)`

*   **Purpose:** Parses HEC-RAS simulation window datetime string format ("ddMMMYYYY HHMM").
*   **Parameters:**
    *   `datetime_str` (`str`): String to parse.
*   **Returns:** (`datetime`): Parsed datetime object.

### `HdfUtils.parse_duration(duration_str)`

*   **Purpose:** Parses HEC-RAS duration string format ("HH:MM:SS").
*   **Parameters:**
    *   `duration_str` (`str`): String to parse.
*   **Returns:** (`timedelta`): Parsed timedelta object.

### `HdfUtils.parse_ras_datetime_ms(datetime_str)`

*   **Purpose:** Parses HEC-RAS datetime string format that includes milliseconds ("ddMMMYYYY HH:MM:SS:fff").
*   **Parameters:**
    *   `datetime_str` (`str`): String to parse.
*   **Returns:** (`datetime`): Parsed datetime object with microseconds.

### `HdfUtils.parse_run_time_window(window)`

*   **Purpose:** Parses a HEC-RAS time window string ("datetime1 to datetime2") into start and end datetime objects.
*   **Parameters:**
    *   `window` (`str`): Time window string.
*   **Returns:** `Tuple[datetime, datetime]`: Tuple containing (start_datetime, end_datetime).

### `HdfUtils.decode_byte_strings(dataframe)`

*   **Purpose:** Decodes all byte string (`b'...'`) columns in a DataFrame to UTF-8 strings. (See `RasUtils` for identical function).
*   **Parameters:** See `RasUtils.decode_byte_strings`.
*   **Returns:** `pd.DataFrame`.

### `HdfUtils.consolidate_dataframe(...)`

*   **Purpose:** Aggregates rows in a DataFrame based on grouping criteria. (See `RasUtils` for identical function).
*   **Parameters:** See `RasUtils.consolidate_dataframe`.
*   **Returns:** `pd.DataFrame`.

### `HdfUtils.find_nearest_value(...)`

*   **Purpose:** Finds the element in an array numerically closest to a target value. (See `RasUtils` for identical function).
*   **Parameters:** See `RasUtils.find_nearest_value`.
*   **Returns:** (`int` or `float`).

### `HdfUtils.horizontal_distance(...)`

*   **Purpose:** Calculates the 2D Euclidean distance between two points. (See `RasUtils` for identical function).
*   **Parameters:** See `RasUtils.horizontal_distance`.
*   **Returns:** (`float`).

---

## Class: HdfXsec

Contains static methods for extracting 1D cross-section *geometry* data from HEC-RAS HDF files (typically geometry HDF).

### `HdfXsec.get_cross_sections(hdf_path, datetime_to_str=True, ras_object=None)`

*   **Purpose:** Extracts detailed cross-section geometry, attributes, station-elevation data, Manning's n values, and ineffective flow areas from the geometry HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
    *   `datetime_to_str` (`bool`, optional): Convert datetime attributes to strings. Default `True`.
    *   `ras_object` (`RasPrj`, optional): Instance for context. Defaults to global `ras`.
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with LineString geometries (cross-section cut lines) and numerous attributes including nested lists/dicts for profile data ('station_elevation'), roughness ('mannings_n'), and ineffective areas ('ineffective_blocks').

### `HdfXsec.get_river_centerlines(hdf_path, datetime_to_str=False)`

*   **Purpose:** Extracts river centerline geometries and attributes from the geometry HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
    *   `datetime_to_str` (`bool`, optional): Convert datetime attributes to strings. Default `False`.
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with LineString geometries and attributes like 'River Name', 'Reach Name', 'length'.

### `HdfXsec.get_river_stationing(centerlines_gdf)`

*   **Purpose:** Calculates stationing values along river centerlines, interpolating points and determining direction based on upstream/downstream connections.
*   **Parameters:**
    *   `centerlines_gdf` (`gpd.GeoDataFrame`): GeoDataFrame obtained from `get_river_centerlines`.
*   **Returns:** `gpd.GeoDataFrame`: The input GeoDataFrame with added columns: 'station_start', 'station_end', 'stations' (array), 'points' (array of Shapely Points).

### `HdfXsec.get_river_reaches(hdf_path, datetime_to_str=False)`

*   **Purpose:** Extracts 1D river reach lines (often identical to centerlines but potentially simplified) from the geometry HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
    *   `datetime_to_str` (`bool`, optional): Convert datetime attributes to strings. Default `False`.
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with LineString geometries and attributes.

### `HdfXsec.get_river_edge_lines(hdf_path, datetime_to_str=False)`

*   **Purpose:** Extracts river edge lines (representing the extent of the 1D river schematic) from the geometry HDF file. Usually includes Left and Right edges.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
    *   `datetime_to_str` (`bool`, optional): Convert datetime attributes to strings. Default `False`.
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with LineString geometries and attributes including 'bank_side' ('Left'/'Right').

### `HdfXsec.get_river_bank_lines(hdf_path, datetime_to_str=False)`

*   **Purpose:** Extracts river bank lines (defining the main channel within the cross-section) from the geometry HDF file.
*   **Parameters:**
    *   `hdf_path` (Input handled by `@standardize_input`, `file_type='geom_hdf'`): Path identifier for the geometry HDF.
    *   `datetime_to_str` (`bool`, optional): Convert datetime attributes to strings. Default `False`.
*   **Returns:** `gpd.GeoDataFrame`: GeoDataFrame with LineString geometries and attributes 'bank_id', 'bank_side'.

---

## Logging Configuration Functions

### `get_logger(name)`

*   **Purpose:** Retrieves a configured logger instance for use within the library or user scripts. Ensures logging is set up.
*   **Parameters:**
    *   `name` (`str`): Name for the logger (typically `__name__`).
*   **Returns:** (`logging.Logger`): A standard Python logger instance.

---

## Class: RasMap

Contains static methods for parsing and accessing information from HEC-RAS mapper configuration files (.rasmap) and automating post-processing tasks.

### `RasMap.parse_rasmap(rasmap_path: Union[str, Path], ras_object=None) -> pd.DataFrame`

*   **Purpose:** Parse a .rasmap file and extract relevant information, including paths to terrain, soil layers, land cover, and other spatial datasets.
*   **Parameters:**
    *   `rasmap_path` (`Union[str, Path]`): Path to the .rasmap file.
    *   `ras_object` (`RasPrj`, optional): Specific RAS object to use. If None, uses the global ras instance.
*   **Returns:** `pd.DataFrame`: A single-row DataFrame containing extracted information from the .rasmap file.
*   **Raises:** Various exceptions for file access or parsing failures.

### `RasMap.get_rasmap_path(ras_object=None) -> Optional[Path]`

*   **Purpose:** Get the path to the .rasmap file based on the current project.
*   **Parameters:**
    *   `ras_object` (`RasPrj`, optional): Specific RAS object to use. If None, uses the global ras instance.
*   **Returns:** `Optional[Path]`: Path to the .rasmap file if found, None otherwise.

### `RasMap.initialize_rasmap_df(ras_object=None) -> pd.DataFrame`

*   **Purpose:** Initialize the `rasmap_df` as part of project initialization. This is typically called internally by `init_ras_project`.
*   **Parameters:**
    *   `ras_object` (`RasPrj`, optional): Specific RAS object to use. If None, uses the global ras instance.
*   **Returns:** `pd.DataFrame`: DataFrame containing information from the .rasmap file.

### `RasMap.get_terrain_names(rasmap_path: Union[str, Path]) -> List[str]`
*   **Purpose:** Extracts all terrain layer names from a given `.rasmap` file.
*   **Parameters:**
    *   `rasmap_path` (`Union[str, Path]`): Path to the `.rasmap` file.
*   **Returns:** (`List[str]`): A list of terrain names.
*   **Raises:** `FileNotFoundError`, `ValueError`.

### `RasMap.postprocess_stored_maps(plan_number: str, specify_terrain: Optional[str] = None, layers: Union[str, List[str]] = None, ras_object: Optional[Any] = None) -> bool`
*   **Purpose:** Automates the generation of stored floodplain map outputs (e.g., `.tif` files) for a specific plan.
*   **Parameters:**
    *   `plan_number` (`str`): The plan to generate maps for.
    *   `specify_terrain` (`str`, optional): The name of a specific terrain to use for mapping. If provided, other terrains are temporarily ignored.
    *   `layers` (`Union[str, List[str]]`, optional): A list of map layers to generate. Defaults to `['WSEL', 'Velocity', 'Depth']`.
    *   `ras_object` (`RasPrj`, optional): The RAS project object to use.
*   **Returns:** (`bool`): `True` if the process completed successfully, `False` otherwise.
*   **Workflow:**
    1.  Backs up the original plan and `.rasmap` files.
    2.  Modifies the plan file to only run floodplain mapping.
    3.  Modifies the `.rasmap` file to include the specified stored map layers.
    4.  Runs `RasCmdr.compute_plan` to generate the maps.
    5.  Restores the original plan and `.rasmap` files, but keeps the newly added map layer definitions in the `.rasmap` file for future use.
==================================================

File: c:\GH\ras-commander-mcp\CLAUDE.md
==================================================
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

### Build & Development
- **Install dependencies**: `pip install -r requirements.txt` or `pip install mcp ras-commander pandas`
- **Run MCP server**: `python server.py`
- **Test with example client**: `python example_client.py` (update project_path in the file first)
- **Python environment**: Use Anaconda3 installation in Windows user folder for consistency with ras-commander

### Testing
- **Test data location**: Use `testdata/Muncie/` folder for testing HEC-RAS functionality
- **Verify MCP tools**: Run example_client.py to test all three MCP tools
- **Integration test**: Configure in Claude Desktop and test with actual HEC-RAS queries

## Architecture

### MCP Server Pattern
This repository implements a Model Context Protocol (MCP) server that bridges HEC-RAS hydraulic modeling software with Claude:

1. **server.py**: Async Python MCP server exposing seven tools:
   - `query_hecras_project`: Comprehensive project info (plans, geometries, flows, boundaries)
   - `get_hecras_plans`: Plan information only
   - `get_hecras_geometries`: Geometry information only
   - `get_infiltration_data`: Infiltration layer data and soil statistics
   - `get_plan_results_summary`: Plan results including unsteady info, volume accounting, runtime data
   - `get_hdf_structure`: Explore HDF file structure (groups, datasets, attributes)
   - `get_projection_info`: Spatial projection information (WKT string) from HDF files

2. **Data Flow**:
   - HEC-RAS project files → ras-commander library → pandas DataFrames → formatted text → Claude
   - All DataFrames converted to text format for LLM interaction

3. **Integration Points**:
   - Uses `ras_commander.init_ras_project()` to load HEC-RAS projects
   - Supports HEC-RAS versions 6.5, 6.6 (configurable)
   - Claude Desktop integration via package.json configuration

### Key Dependencies
- **mcp**: Model Context Protocol server framework
- **ras-commander**: HEC-RAS project interface library (requires HEC-RAS installation)
- **pandas**: DataFrame handling for structured data

### Error Handling
- Validates project paths before initialization
- Provides detailed error messages for common issues (missing files, wrong version, etc.)
- Graceful handling of missing data components (plans, geometries, etc.)

## HEC-RAS Integration Notes
- Requires HEC-RAS to be installed at expected location (typically C:\Program Files (x86)\HEC\HEC-RAS\)
- Project paths must point to folder containing .prj files
- Muncie test data includes all necessary components (HDF5 results, terrain, boundaries, etc.)
==================================================

File: c:\GH\ras-commander-mcp\claude_desktop_config.json
==================================================
{
  "mcpServers": {
    "hecras": {
      "command": "C:\\Users\\billk\\anaconda3\\envs\\claude_test_env\\python.exe",
      "args": ["C:\\GH\\ras-commander-mcp\\server.py"],
      "env": {
        "HECRAS_VERSION": "6.6"
      }
    }
  }
}
==================================================

File: c:\GH\ras-commander-mcp\example_client.py
==================================================
#!/usr/bin/env python3
"""
Example client for testing the improved HEC-RAS MCP Server
"""

import asyncio
import json
from pathlib import Path
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main():
    """Example of using the improved HEC-RAS MCP server"""
    
    # Create server parameters - using python from current environment
    server_params = StdioServerParameters(
        command="python",
        args=["./server.py"],
        env=None
    )
    
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            # Initialize the connection
            await session.initialize()
            
            # List available tools
            tools_response = await session.list_tools()
            print("Available tools:")
            # tools_response is a tuple, extract the tools list
            tools_list = tools_response[0] if isinstance(tools_response, tuple) else tools_response
            for tool in tools_list:
                print(f"  - {tool.name}: {tool.description}")
            print()
            
            # Get the absolute path to the Muncie test data
            muncie_path = Path(__file__).parent / "testdata" / "Muncie"
            muncie_path = muncie_path.resolve()
            
            # Example 1: Initialize project with comprehensive summary
            print("Example 1: Getting project summary (initializes context)...")
            result = await session.call_tool(
                "get_ras_projectsummary",
                arguments={
                    "project_path": str(muncie_path),
                    "include_boundaries": False
                }
            )
            print("Result:")
            print(result.content[0].text[:800] + "...")
            print("-" * 80)
            
            # Example 2: Get plan info without specifying project path
            print("\nExample 2: Getting all plans (no project path needed!)...")
            result = await session.call_tool(
                "get_ras_planinfo",
                arguments={}  # No project_path needed!
            )
            print("Plans Result:")
            print(result.content[0].text[:500] + "...")
            print("-" * 80)
            
            # Example 3: Get specific plan info
            print("\nExample 3: Getting specific plan info...")
            result = await session.call_tool(
                "get_ras_planinfo",
                arguments={
                    "plan_number": "04"
                }
            )
            print("Plan 04 Result:")
            print(result.content[0].text[:500] + "...")
            print("-" * 80)
            
            # Example 4: Get infiltration data
            print("\nExample 4: Getting infiltration data...")
            result = await session.call_tool(
                "get_ras_infiltration",
                arguments={
                    "significant_threshold": 5.0
                }
            )
            print("Infiltration Result:")
            print(result.content[0].text[:500] + "...")
            print("-" * 80)
            
            # Example 5: Get results summary for a plan
            print("\nExample 5: Getting results summary for plan...")
            result = await session.call_tool(
                "get_ras_resultssummary",
                arguments={
                    "plan_name": "04"
                }
            )
            print("Results Summary:")
            print(result.content[0].text[:800] + "...")
            print("-" * 80)
            
            # Example 6: Get projection info (auto-detects HDF)
            print("\nExample 6: Getting projection info...")
            result = await session.call_tool(
                "get_projection_info",
                arguments={}  # Will auto-detect HDF from project
            )
            print("Projection Info:")
            print(result.content[0].text)
            
            # Example 7: Get 1D profile results
            print("\nExample 7: Getting 1D profile results...")
            result = await session.call_tool(
                "get_1d_profile_results",
                arguments={
                    "plan_name": "04"
                }
            )
            print("1D Profile Results:")
            print(result.content[0].text[:800] + "...")
            print("-" * 80)
            
            # Example 8: Compare 1D profiles between plans
            print("\nExample 8: Comparing 1D profiles between plans...")
            result = await session.call_tool(
                "compare_1d_profiles",
                arguments={
                    "plan1_name": "03",
                    "plan2_name": "04"
                }
            )
            print("Profile Comparison:")
            print(result.content[0].text[:1000] + "...")
            
            # Example 9: Plot 1D profile
            print("\nExample 9: Plotting 1D profile...")
            result = await session.call_tool(
                "plot_1d_profile",
                arguments={
                    "plan_name": "04"
                }
            )
            print("1D Profile Plot:")
            # For plotting, we expect markdown with embedded base64 image
            if "![1D Profile Plot]" in result.content[0].text:
                print("SUCCESS: Profile plot generated and embedded as image")
                # Extract description after the image
                lines = result.content[0].text.split('\n')
                for line in lines[2:]:  # Skip image line and empty line
                    if line.strip():
                        print(f"Description: {line}")
                        break
            else:
                print(result.content[0].text[:300] + "...")
            print("-" * 80)
            
            # Example 10: Plot 1D profile comparison
            print("\nExample 10: Plotting 1D profile comparison...")
            result = await session.call_tool(
                "plot_1d_profile_comparison",
                arguments={
                    "plan1_name": "03",
                    "plan2_name": "04"
                }
            )
            print("Profile Comparison Plot:")
            if "![1D Profile Comparison]" in result.content[0].text:
                print("SUCCESS: Comparison plot generated with statistics")
                # Extract summary statistics
                lines = result.content[0].text.split('\n')
                in_summary = False
                for line in lines:
                    if 'Summary Statistics:' in line:
                        in_summary = True
                        print(line)
                    elif in_summary and line.strip().startswith('- '):
                        print(line)
            else:
                print(result.content[0].text[:300] + "...")
            
            # Example 11: Get detailed cross-section time series
            print("\nExample 11: Getting detailed cross-section time series...")
            result = await session.call_tool(
                "get_1d_xsec_timeseries",
                arguments={
                    "plan_name": "04",
                    "river_name": "BALD EAGLE CREEK",
                    "reach_name": "REACH-1",
                    "station": "70779.17"
                }
            )
            print("Cross Section Time Series:")
            print(result.content[0].text[:1000] + "...")
            print("-" * 80)
            
            print("\n" + "=" * 80)
            print("DEMO COMPLETE: Notice how we only specified the project path once!")
            print("All subsequent calls used the stored project context.")
            print("New 1D profile tools provide detailed hydraulic analysis capabilities!")
            print("Plotting tools generate embedded images viewable directly in Claude Desktop!")
            print("New cross-section time series tool provides detailed hydraulic variable analysis!")

if __name__ == "__main__":
    asyncio.run(main())
==================================================

File: c:\GH\ras-commander-mcp\instructions.txt
==================================================
I'll modify the comparison logic to handle mismatched stations properly. Here are the changes:

## Instructions for Handling Mismatched Stations

### 1. Modify the `compare_1d_profiles` handler in `handle_call_tool()`

Replace the comparison logic in the `compare_1d_profiles` handler:

```python
elif name == "compare_1d_profiles":
    try:
        project_path = arguments.get("project_path")
        plan1_name = arguments["plan1_name"]
        plan2_name = arguments["plan2_name"]
        river_name = arguments.get("river_name")
        reach_name = arguments.get("reach_name")
        
        # Get or initialize project
        ras = get_or_init_project(project_path)
        
        # Get HDF paths for both plans
        plan1_hdf_path = get_plan_hdf_path(ras, plan1_name)
        plan2_hdf_path = get_plan_hdf_path(ras, plan2_name)
        
        if not plan1_hdf_path:
            return [TextContent(
                type="text",
                text=f"Error: Plan '{plan1_name}' not found or has no results HDF file"
            )]
        
        if not plan2_hdf_path:
            return [TextContent(
                type="text",
                text=f"Error: Plan '{plan2_name}' not found or has no results HDF file"
            )]
        
        response_parts = [
            f"1D Profile Comparison",
            f"Plan 1 (Baseline): {plan1_name}",
            f"Plan 2 (Comparison): {plan2_name}",
        ]
        
        if river_name:
            response_parts.append(f"River: {river_name}")
        if reach_name:
            response_parts.append(f"Reach: {reach_name}")
        
        response_parts.append("=" * 80)
        
        try:
            # Get cross-section data for both plans
            xsec_data1 = HdfResultsXsec.get_xsec_timeseries(plan1_hdf_path)
            xsec_data2 = HdfResultsXsec.get_xsec_timeseries(plan2_hdf_path)
            
            # Format results for both plans
            profile_df1 = format_profile_results(xsec_data1, river_name, reach_name)
            profile_df2 = format_profile_results(xsec_data2, river_name, reach_name)
            
            if profile_df1.empty and profile_df2.empty:
                response_parts.append("\nNo cross-section data found for the specified criteria")
            else:
                # Merge the dataframes on River, Reach, Station with outer join
                comparison_df = pd.merge(
                    profile_df1[['River', 'Reach', 'Station', 'Max WSEL']],
                    profile_df2[['River', 'Reach', 'Station', 'Max WSEL']],
                    on=['River', 'Reach', 'Station'],
                    how='outer',
                    suffixes=('_Plan1', '_Plan2')
                )
                
                # Calculate difference only where both values exist
                comparison_df['WSEL_Difference'] = pd.Series(dtype='float64')
                mask = comparison_df['Max WSEL_Plan1'].notna() & comparison_df['Max WSEL_Plan2'].notna()
                comparison_df.loc[mask, 'WSEL_Difference'] = (
                    comparison_df.loc[mask, 'Max WSEL_Plan1'] - 
                    comparison_df.loc[mask, 'Max WSEL_Plan2']
                )
                
                # Sort by River, Reach, Station
                comparison_df.sort_values(['River', 'Reach', 'Station'], inplace=True)
                
                # Replace NaN with blank strings for display
                display_df = comparison_df.copy()
                display_df = display_df.fillna('')
                
                # Round numeric values for display
                for col in ['Max WSEL_Plan1', 'Max WSEL_Plan2', 'WSEL_Difference']:
                    if col in display_df.columns:
                        # Only round numeric values, leave blanks as blanks
                        mask = display_df[col] != ''
                        if mask.any():
                            display_df.loc[mask, col] = pd.to_numeric(display_df.loc[mask, col]).round(3)
                
                response_parts.append(dataframe_to_text(display_df, "PROFILE COMPARISON"))
                
                # Add summary statistics (only for valid differences)
                if 'WSEL_Difference' in comparison_df.columns:
                    diff_col = comparison_df['WSEL_Difference'].dropna()
                    if not diff_col.empty:
                        response_parts.append("\nSUMMARY STATISTICS (for matching stations only):")
                        response_parts.append(f"  Stations in Plan 1 only: {(comparison_df['Max WSEL_Plan1'].notna() & comparison_df['Max WSEL_Plan2'].isna()).sum()}")
                        response_parts.append(f"  Stations in Plan 2 only: {(comparison_df['Max WSEL_Plan1'].isna() & comparison_df['Max WSEL_Plan2'].notna()).sum()}")
                        response_parts.append(f"  Matching stations: {mask.sum()}")
                        response_parts.append(f"  Mean Difference: {diff_col.mean():.3f}")
                        response_parts.append(f"  Max Increase: {diff_col.max():.3f}")
                        response_parts.append(f"  Max Decrease: {diff_col.min():.3f}")
                        response_parts.append(f"  Std Deviation: {diff_col.std():.3f}")
                
        except Exception as e:
            response_parts.append(f"\nError comparing profiles: {str(e)}")
        
        return [TextContent(
            type="text",
            text="\n".join(response_parts)
        )]
        
    except Exception as e:
        logger.error(f"Error comparing 1D profiles: {str(e)}")
        return [TextContent(
            type="text",
            text=f"Error comparing 1D profiles: {str(e)}"
        )]
```

### 2. Modify the `plot_profile_comparison` function

Replace the `plot_profile_comparison` function to handle NaN values properly:

```python
def plot_profile_comparison(comparison_df, plan1_name, plan2_name, river_name=None, reach_name=None):
    """Create a comparison plot of two profiles."""
    
    # Set up the plot
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8), dpi=80, 
                                   gridspec_kw={'height_ratios': [3, 1]})
    
    # Create title
    title_parts = [f"Profile Comparison - {plan1_name} vs {plan2_name}"]
    if river_name:
        title_parts.append(f"River: {river_name}")
    if reach_name:
        title_parts.append(f"Reach: {reach_name}")
    
    if not comparison_df.empty:
        # Sort by station
        comparison_df = comparison_df.sort_values('Station')
        
        # Separate data for each plan (removing NaN values for plotting)
        plan1_data = comparison_df[['Station', 'Max WSEL_Plan1']].dropna()
        plan2_data = comparison_df[['Station', 'Max WSEL_Plan2']].dropna()
        diff_data = comparison_df[['Station', 'WSEL_Difference']].dropna()
        
        # Top plot - both profiles
        if not plan1_data.empty:
            ax1.plot(plan1_data['Station'], plan1_data['Max WSEL_Plan1'], 
                    'b-o', linewidth=2, markersize=6, label=f'Plan: {plan1_name}')
        
        if not plan2_data.empty:
            ax1.plot(plan2_data['Station'], plan2_data['Max WSEL_Plan2'], 
                    'r--s', linewidth=2, markersize=6, label=f'Plan: {plan2_name}')
        
        ax1.set_ylabel('Water Surface Elevation', fontsize=12)
        ax1.legend(loc='best', fontsize=10)
        ax1.grid(True, alpha=0.3)
        ax1.set_title('\n'.join(title_parts), fontsize=14, fontweight='bold')
        
        # Bottom plot - difference (only where both exist)
        if not diff_data.empty:
            ax2.plot(diff_data['Station'], diff_data['WSEL_Difference'], 
                    'g-^', linewidth=2, markersize=6)
            
            # Add shading for positive/negative differences
            stations = diff_data['Station'].values
            differences = diff_data['WSEL_Difference'].values
            ax2.fill_between(stations, 0, differences, where=(differences > 0), 
                            alpha=0.3, color='blue', label='Plan1 Higher')
            ax2.fill_between(stations, 0, differences, where=(differences < 0), 
                            alpha=0.3, color='red', label='Plan2 Higher')
        
        ax2.axhline(y=0, color='k', linestyle='-', alpha=0.3)
        ax2.set_xlabel('Station', fontsize=12)
        ax2.set_ylabel('Difference (Plan1 - Plan2)', fontsize=12)
        ax2.grid(True, alpha=0.3)
        
        # Add markers for stations that exist in only one plan
        plan1_only = comparison_df[comparison_df['Max WSEL_Plan2'].isna() & comparison_df['Max WSEL_Plan1'].notna()]
        plan2_only = comparison_df[comparison_df['Max WSEL_Plan1'].isna() & comparison_df['Max WSEL_Plan2'].notna()]
        
        if not plan1_only.empty:
            ax1.scatter(plan1_only['Station'], plan1_only['Max WSEL_Plan1'], 
                       color='blue', s=100, marker='v', alpha=0.7, 
                       label='Plan1 only', zorder=5)
        
        if not plan2_only.empty:
            ax1.scatter(plan2_only['Station'], plan2_only['Max WSEL_Plan2'], 
                       color='red', s=100, marker='^', alpha=0.7, 
                       label='Plan2 only', zorder=5)
        
        # Update legend if we added markers
        if not plan1_only.empty or not plan2_only.empty:
            ax1.legend(loc='best', fontsize=10)
    
    plt.tight_layout()
    
    # Save to BytesIO buffer
    buffer = BytesIO()
    plt.savefig(buffer, format='png', dpi=80, bbox_inches='tight', 
                facecolor='white', edgecolor='none')
    plt.close()
    
    # Check size
    buffer.seek(0)
    img_size = len(buffer.getvalue())
    
    if img_size > 1024 * 1024:  # Reduce quality if needed
        buffer = BytesIO()
        fig, ax = plt.subplots(figsize=(8, 5), dpi=72)
        # Simplified plot
        if not plan1_data.empty:
            ax.plot(plan1_data['Station'], plan1_data['Max WSEL_Plan1'], 'b-', label=plan1_name)
        if not plan2_data.empty:
            ax.plot(plan2_data['Station'], plan2_data['Max WSEL_Plan2'], 'r--', label=plan2_name)
        ax.set_title(f"Comparison - {plan1_name} vs {plan2_name}", fontsize=12)
        ax.set_xlabel('Station')
        ax.set_ylabel('WSEL')
        ax.legend()
        ax.grid(True, alpha=0.3)
        plt.tight_layout()
        plt.savefig(buffer, format='png', dpi=72, bbox_inches='tight')
        plt.close()
    
    buffer.seek(0)
    return buffer
```

### 3. Update the plotting handler for comparison

Update the `plot_1d_profile_comparison` handler to use the same logic:

```python
elif name == "plot_1d_profile_comparison":
    try:
        project_path = arguments.get("project_path")
        plan1_name = arguments["plan1_name"]
        plan2_name = arguments["plan2_name"]
        river_name = arguments.get("river_name")
        reach_name = arguments.get("reach_name")
        
        # Get or initialize project
        ras = get_or_init_project(project_path)
        
        # Get HDF paths for both plans
        plan1_hdf_path = get_plan_hdf_path(ras, plan1_name)
        plan2_hdf_path = get_plan_hdf_path(ras, plan2_name)
        
        if not plan1_hdf_path:
            return [TextContent(
                type="text",
                text=f"Error: Plan '{plan1_name}' not found or has no results HDF file"
            )]
        
        if not plan2_hdf_path:
            return [TextContent(
                type="text",
                text=f"Error: Plan '{plan2_name}' not found or has no results HDF file"
            )]
        
        try:
            # Get cross-section data for both plans
            xsec_data1 = HdfResultsXsec.get_xsec_timeseries(plan1_hdf_path)
            xsec_data2 = HdfResultsXsec.get_xsec_timeseries(plan2_hdf_path)
            
            # Format results
            profile_df1 = format_profile_results(xsec_data1, river_name, reach_name)
            profile_df2 = format_profile_results(xsec_data2, river_name, reach_name)
            
            if profile_df1.empty and profile_df2.empty:
                return [TextContent(
                    type="text",
                    text="No cross-section data found for the specified criteria"
                )]
            
            # Merge for comparison
            comparison_df = pd.merge(
                profile_df1[['River', 'Reach', 'Station', 'Max WSEL']],
                profile_df2[['River', 'Reach', 'Station', 'Max WSEL']],
                on=['River', 'Reach', 'Station'],
                how='outer',
                suffixes=('_Plan1', '_Plan2')
            )
            
            # Calculate difference only where both values exist
            comparison_df['WSEL_Difference'] = pd.Series(dtype='float64')
            mask = comparison_df['Max WSEL_Plan1'].notna() & comparison_df['Max WSEL_Plan2'].notna()
            comparison_df.loc[mask, 'WSEL_Difference'] = (
                comparison_df.loc[mask, 'Max WSEL_Plan1'] - 
                comparison_df.loc[mask, 'Max WSEL_Plan2']
            )
            
            # Sort by station
            comparison_df.sort_values(['River', 'Reach', 'Station'], inplace=True)
            
            # Create plot
            img_buffer = plot_profile_comparison(
                comparison_df, plan1_name, plan2_name, river_name, reach_name
            )
            
            # Convert to base64
            img_base64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
            
            # Get summary stats
            diff_col = comparison_df['WSEL_Difference'].dropna()
            summary_text = ""
            if not diff_col.empty:
                n_plan1_only = (comparison_df['Max WSEL_Plan1'].notna() & comparison_df['Max WSEL_Plan2'].isna()).sum()
                n_plan2_only = (comparison_df['Max WSEL_Plan1'].isna() & comparison_df['Max WSEL_Plan2'].notna()).sum()
                n_matching = mask.sum()
                
                summary_text = (
                    f"\n\nStation Summary:\n"
                    f"- Stations in Plan 1 only: {n_plan1_only}\n"
                    f"- Stations in Plan 2 only: {n_plan2_only}\n"
                    f"- Matching stations: {n_matching}\n\n"
                    f"Statistics (for matching stations):\n"
                    f"- Mean Difference: {diff_col.mean():.3f}\n"
                    f"- Max Increase: {diff_col.max():.3f}\n"
                    f"- Max Decrease: {diff_col.min():.3f}\n"
                    f"- Std Deviation: {diff_col.std():.3f}"
                )
            
            # Return as markdown image with summary
            return [TextContent(
                type="text",
                text=f"![1D Profile Comparison](data:image/png;base64,{img_base64})\n\n"
                     f"Profile comparison - {plan1_name} vs {plan2_name}"
                     f"{f' - River: {river_name}' if river_name else ''}"
                     f"{f' - Reach: {reach_name}' if reach_name else ''}"
                     f"{summary_text}"
            )]
            
        except Exception as e:
            return [TextContent(
                type="text",
                text=f"Error creating comparison plot: {str(e)}"
            )]
        
    except Exception as e:
        logger.error(f"Error plotting profile comparison: {str(e)}")
        return [TextContent(
            type="text",
            text=f"Error plotting profile comparison: {str(e)}"
        )]
```

## Summary of Changes

1. **Modified tabular comparison**:
   - Now shows blank cells for stations that don't exist in one of the plans
   - Only calculates differences where both plans have values
   - Added station counts to summary statistics

2. **Enhanced plotting**:
   - Plots handle NaN values properly by filtering them out
   - Each plan's profile is plotted independently by station
   - Added visual markers (triangles) for stations that exist in only one plan
   - Difference plot only shows where both plans have data

3. **Better statistics**:
   - Summary now shows count of matching vs non-matching stations
   - Statistics are calculated only for matching stations
   - Makes it clear when geometries differ between plans

This approach clearly highlights geometry differences between plans while still allowing meaningful comparison where stations match.
==================================================

File: c:\GH\ras-commander-mcp\LICENSE
==================================================
MIT License

Copyright (c) 2025 HEC-RAS MCP

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
==================================================

File: c:\GH\ras-commander-mcp\package.json
==================================================
{
  "name": "hecras-mcp-server",
  "version": "0.1.0",
  "description": "MCP server for querying HEC-RAS project information",
  "main": "server.py",
  "scripts": {
    "start": "python server.py"
  },
  "mcp": {
    "server": {
      "command": "python",
      "args": ["server.py"]
    }
  },
  "dependencies": {
    "mcp": "*",
    "ras-commander": "*",
    "pandas": "*"
  }
}
==================================================

File: c:\GH\ras-commander-mcp\RAS Commander MCP brainstorming.txt
==================================================



MCP Tools for RAS Commander



View Boundaries



1D Results

1D Results Tables (Detailed XSEC Results)
Accessible by River, Reach, and XSEC Number
Includes all 1D variable results for the Cross Section


1D Profile Results (Max WSEL and Velocity for River and Reach)
If a River is specified, return all reaches
If a River and Reach is specified, only return that reach
Provide table with the max wsel and velocity at each cross section and structure (sorted by station number) in that river/reach


1D Cross Section Plots


List 1D Rivers and Reaches

(This will be the input for other tools, where models are large and only a subset of results are needed)

View 1D Cross Section Tables

Include all Attributes
List by River and Reach


View 1D Structures

Include all attributes
List by River and Reach


List 2D Areas


List 2D Breaklines

Include all attributes
Note Minimum and Maximum spacing
Note any zero-length breaklines (these should be removed in RASMapper)


Vew Boundary Condition Lines
Include all attributes


Where RAS Commander provides GeoSpatial Data, don't print all the geometry data, just the table of attributes

















==================================================

File: c:\GH\ras-commander-mcp\RAS Commander MCP.txt
==================================================
I'll help you create an MCP server that uses the ras-commander library to query HEC-RAS project information. Based on the notebook, I can see the key functionality you need.Now let me create the supporting files for the MCP server:I've created a complete MCP server implementation for querying HEC-RAS projects using the ras-commander library. Here's what I've built:


#!/usr/bin/env python3
"""
HEC-RAS MCP Server

An MCP server that provides tools for querying HEC-RAS project information
using the ras-commander library.
"""

import asyncio
import json
import logging
from pathlib import Path
from typing import Any, Sequence
import pandas as pd
import io

from mcp.server.models import InitializationOptions
from mcp.server import NotificationOptions, Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent, ToolNotFoundError

# Import ras-commander
try:
    from ras_commander import init_ras_project
except ImportError:
    raise ImportError("ras-commander is not installed. Please install it with: pip install ras-commander")

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Initialize the MCP server
server = Server("hecras-mcp-server")

def dataframe_to_text(df: pd.DataFrame, name: str) -> str:
    """Convert a pandas DataFrame to a formatted text string."""
    if df is None or df.empty:
        return f"\n{name}: No data available\n"
    
    # Use StringIO to capture the DataFrame string representation
    buffer = io.StringIO()
    df.to_string(buf=buffer, max_rows=100, max_cols=None)
    return f"\n{name}:\n{buffer.getvalue()}\n"

@server.list_tools()
async def handle_list_tools() -> list[Tool]:
    """List available tools."""
    return [
        Tool(
            name="query_hecras_project",
            description="Query a HEC-RAS project and return information about plans, geometries, flows, and boundaries",
            inputSchema={
                "type": "object",
                "properties": {
                    "project_path": {
                        "type": "string",
                        "description": "Full path to the HEC-RAS project folder"
                    },
                    "ras_version": {
                        "type": "string",
                        "description": "HEC-RAS version (e.g., '6.5', '6.6')",
                        "default": "6.6"
                    },
                    "include_boundaries": {
                        "type": "boolean",
                        "description": "Include boundary conditions data (can be large)",
                        "default": False
                    }
                },
                "required": ["project_path"]
            }
        ),
        Tool(
            name="get_hecras_plans",
            description="Get only the plans information from a HEC-RAS project",
            inputSchema={
                "type": "object",
                "properties": {
                    "project_path": {
                        "type": "string",
                        "description": "Full path to the HEC-RAS project folder"
                    },
                    "ras_version": {
                        "type": "string",
                        "description": "HEC-RAS version (e.g., '6.5', '6.6')",
                        "default": "6.6"
                    }
                },
                "required": ["project_path"]
            }
        ),
        Tool(
            name="get_hecras_geometries",
            description="Get only the geometries information from a HEC-RAS project",
            inputSchema={
                "type": "object",
                "properties": {
                    "project_path": {
                        "type": "string",
                        "description": "Full path to the HEC-RAS project folder"
                    },
                    "ras_version": {
                        "type": "string",
                        "description": "HEC-RAS version (e.g., '6.5', '6.6')",
                        "default": "6.6"
                    }
                },
                "required": ["project_path"]
            }
        )
    ]

@server.call_tool()
async def handle_call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
    """Handle tool calls."""
    
    if name == "query_hecras_project":
        try:
            project_path = Path(arguments["project_path"])
            ras_version = arguments.get("ras_version", "6.6")
            include_boundaries = arguments.get("include_boundaries", False)
            
            # Validate project path
            if not project_path.exists() or not project_path.is_dir():
                return [TextContent(
                    type="text",
                    text=f"Error: The specified project folder does not exist or is not a directory: {project_path}"
                )]
            
            # Initialize the RAS project
            logger.info(f"Initializing HEC-RAS project at: {project_path}")
            ras = init_ras_project(project_path, ras_version)
            
            # Build the response
            response_parts = [
                f"HEC-RAS Project: {ras.project_name}",
                f"Project Path: {project_path}",
                f"HEC-RAS Version: {ras_version}",
                "=" * 80
            ]
            
            # Add plan information
            if hasattr(ras, 'plan_df') and ras.plan_df is not None:
                response_parts.append(dataframe_to_text(ras.plan_df, "PLANS"))
            
            # Add geometry information
            if hasattr(ras, 'geom_df') and ras.geom_df is not None:
                response_parts.append(dataframe_to_text(ras.geom_df, "GEOMETRIES"))
            
            # Add flow information
            if hasattr(ras, 'flow_df') and ras.flow_df is not None:
                response_parts.append(dataframe_to_text(ras.flow_df, "STEADY FLOWS"))
                
            if hasattr(ras, 'unsteady_df') and ras.unsteady_df is not None:
                response_parts.append(dataframe_to_text(ras.unsteady_df, "UNSTEADY FLOWS"))
            
            # Add boundary conditions if requested
            if include_boundaries and hasattr(ras, 'boundaries_df') and ras.boundaries_df is not None:
                response_parts.append(dataframe_to_text(ras.boundaries_df, "BOUNDARY CONDITIONS"))
            
            return [TextContent(
                type="text",
                text="\n".join(response_parts)
            )]
            
        except Exception as e:
            logger.error(f"Error querying HEC-RAS project: {str(e)}")
            return [TextContent(
                type="text",
                text=f"Error querying HEC-RAS project: {str(e)}\n\nPlease ensure:\n1. The project path is correct\n2. HEC-RAS version is installed at the expected location\n3. The project files are valid"
            )]
    
    elif name == "get_hecras_plans":
        try:
            project_path = Path(arguments["project_path"])
            ras_version = arguments.get("ras_version", "6.6")
            
            if not project_path.exists() or not project_path.is_dir():
                return [TextContent(
                    type="text",
                    text=f"Error: The specified project folder does not exist: {project_path}"
                )]
            
            ras = init_ras_project(project_path, ras_version)
            
            response_parts = [
                f"HEC-RAS Project: {ras.project_name}",
                f"Project Path: {project_path}",
                "=" * 80
            ]
            
            if hasattr(ras, 'plan_df') and ras.plan_df is not None:
                response_parts.append(dataframe_to_text(ras.plan_df, "PLANS"))
            else:
                response_parts.append("\nNo plan data available")
            
            return [TextContent(
                type="text",
                text="\n".join(response_parts)
            )]
            
        except Exception as e:
            logger.error(f"Error getting plans: {str(e)}")
            return [TextContent(
                type="text",
                text=f"Error getting HEC-RAS plans: {str(e)}"
            )]
    
    elif name == "get_hecras_geometries":
        try:
            project_path = Path(arguments["project_path"])
            ras_version = arguments.get("ras_version", "6.6")
            
            if not project_path.exists() or not project_path.is_dir():
                return [TextContent(
                    type="text",
                    text=f"Error: The specified project folder does not exist: {project_path}"
                )]
            
            ras = init_ras_project(project_path, ras_version)
            
            response_parts = [
                f"HEC-RAS Project: {ras.project_name}",
                f"Project Path: {project_path}",
                "=" * 80
            ]
            
            if hasattr(ras, 'geom_df') and ras.geom_df is not None:
                response_parts.append(dataframe_to_text(ras.geom_df, "GEOMETRIES"))
            else:
                response_parts.append("\nNo geometry data available")
            
            return [TextContent(
                type="text",
                text="\n".join(response_parts)
            )]
            
        except Exception as e:
            logger.error(f"Error getting geometries: {str(e)}")
            return [TextContent(
                type="text",
                text=f"Error getting HEC-RAS geometries: {str(e)}"
            )]
    
    else:
        raise ToolNotFoundError(f"Unknown tool: {name}")

async def main():
    """Main entry point for the MCP server."""
    async with stdio_server() as (read_stream, write_stream):
        logger.info("Starting HEC-RAS MCP Server...")
        
        await server.run(
            read_stream,
            write_stream,
            InitializationOptions(
                server_name="hecras-mcp-server",
                server_version="0.1.0",
                capabilities=server.get_capabilities(
                    notification_options=NotificationOptions(),
                    experimental_capabilities={},
                ),
            ),
        )

if __name__ == "__main__":
    asyncio.run(main())






#!/usr/bin/env python3
"""
Example client for testing the HEC-RAS MCP Server
"""

import asyncio
import json
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main():
    """Example of using the HEC-RAS MCP server"""
    
    # Create server parameters - adjust the path to your server.py
    server_params = StdioServerParameters(
        command="python",
        args=["./server.py"],
        env=None
    )
    
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            # Initialize the connection
            await session.initialize()
            
            # List available tools
            tools = await session.list_tools()
            print("Available tools:")
            for tool in tools:
                print(f"  - {tool.name}: {tool.description}")
            print()
            
            # Example 1: Query comprehensive project information
            print("Example 1: Querying comprehensive HEC-RAS project information...")
            result = await session.call_tool(
                "query_hecras_project",
                arguments={
                    "project_path": r"D:\CLB-NetworkDrive\25-001 HCFCD Standard Benefits Framework\Sharepoint\South Belt Stormwater Detention Basin (A520-03-00-E003)\8_21_2014\2014.08_Comment_Responses_HEC-RAS_models\2014.08_A100+A120\A120-00-00",
                    "ras_version": "6.6",
                    "include_boundaries": False
                }
            )
            print("Result:")
            print(result.content[0].text)
            print("-" * 80)
            
            # Example 2: Get only plans
            print("\nExample 2: Getting only plans information...")
            result = await session.call_tool(
                "get_hecras_plans",
                arguments={
                    "project_path": r"D:\CLB-NetworkDrive\25-001 HCFCD Standard Benefits Framework\Sharepoint\South Belt Stormwater Detention Basin (A520-03-00-E003)\8_21_2014\2014.08_Comment_Responses_HEC-RAS_models\2014.08_A100+A120\A120-00-00",
                    "ras_version": "6.6"
                }
            )
            print("Plans Result:")
            print(result.content[0].text[:500] + "...")  # Show first 500 chars
            print("-" * 80)
            
            # Example 3: Get only geometries
            print("\nExample 3: Getting only geometries information...")
            result = await session.call_tool(
                "get_hecras_geometries",
                arguments={
                    "project_path": r"D:\CLB-NetworkDrive\25-001 HCFCD Standard Benefits Framework\Sharepoint\South Belt Stormwater Detention Basin (A520-03-00-E003)\8_21_2014\2014.08_Comment_Responses_HEC-RAS_models\2014.08_A100+A120\A120-00-00",
                    "ras_version": "6.6"
                }
            )
            print("Geometries Result:")
            print(result.content[0].text[:500] + "...")  # Show first 500 chars

if __name__ == "__main__":
    asyncio.run(main())











## Created Files:

1. **`server.py`** - The main MCP server implementation with three tools:
   - `query_hecras_project` - Returns comprehensive project information
   - `get_hecras_plans` - Returns only plan information
   - `get_hecras_geometries` - Returns only geometry information

2. **`package.json`** - MCP server configuration for Claude Desktop

3. **`README.md`** - Complete documentation and setup instructions

4. **`requirements.txt`** - Python dependencies

5. **`example_client.py`** - Example client code for testing

## Key Features:

- **Flexible querying**: Can get all project info or specific components
- **Version support**: Configurable HEC-RAS version (6.5, 6.6, etc.)
- **Optional boundaries**: Can include/exclude boundary conditions (which can be large)
- **Error handling**: Validates paths and provides helpful error messages
- **Text output**: Converts pandas DataFrames to readable text format

## Setup Instructions:

1. **Install dependencies**:
   ```bash
   pip install mcp ras-commander pandas
   ```

2. **Configure Claude Desktop** by adding to `claude_desktop_config.json`:
   ```json
   {
     "mcpServers": {
       "hecras": {
         "command": "python",
         "args": ["/path/to/server.py"]
       }
     }
   }
   ```

3. **Usage in Claude**: Once configured, you can ask Claude to query your HEC-RAS projects:
   - "Query the HEC-RAS project at C:/MyProject"
   - "Show me the plans in my HEC-RAS project"
   - "Get the geometries from the river model"

The server will return the DataFrames as formatted text, making it easy to analyze HEC-RAS project structure and components directly within Claude Desktop.


Use the anaconda3 installation in the windows user folder to test the MCP server and add it to the current claude code installation. 

The folder "Muncie" is provided to test the MCP server.


Build out a repository and test the MCP server


==================================================

File: c:\GH\ras-commander-mcp\ras-commander API to use for developing tool calls.txt
==================================================
RasPrj Attributes

These should be simple tool calls that provide HEC-RAS project information

plan_df (pd.DataFrame): DataFrame containing plan file information.
flow_df (pd.DataFrame): DataFrame containing flow file information.
unsteady_df (pd.DataFrame): DataFrame containing unsteady flow file information.
geom_df (pd.DataFrame): DataFrame containing geometry file information.
boundaries_df (pd.DataFrame): DataFrame containing boundary condition information.
rasmap_df (pd.DataFrame): DataFrame containing RASMapper configuration data including paths to terrain, soil layer, infiltration, and land cover data.


Generic HDF Utilities:

Class: HdfBase
Contains fundamental static methods for interacting with HEC-RAS HDF files. Used by other Hdf* classes. Requires an open h5py.File object or uses @standardize_input.

HdfBase.get_projection(hdf_path)
Purpose: Retrieves the spatial projection information (WKT string) from the HDF file attributes or associated .rasmap file.
Parameters:
hdf_path (Input handled by @standardize_input): Path identifier for the HDF file.
Returns: (str or None): Well-Known Text (WKT) string of the projection, or None if not found.


HdfBase.get_dataset_info(file_path, group_path='/')
Purpose: Prints a recursive listing of the structure (groups, datasets, attributes, shapes, dtypes) within an HDF5 file, starting from group_path.
Parameters:
file_path (Input handled by @standardize_input): Path identifier for the HDF file.
group_path (str, optional): Internal HDF path to start exploration from. Default is root ('/').
Returns: None. Prints to console.





HEC-RAS Project Infiltration Data

The infiltration and soil HDF paths can be found in the rasmap_df


HdfInfiltration.get_infiltration_layer_data(hdf_path: Path) -> Optional[pd.DataFrame]
Purpose: Retrieves current infiltration parameters from a HEC-RAS infiltration layer HDF file.
Parameters:
hdf_path (Input handled by @standardize_input): Path identifier for the infiltration layer HDF.
Returns: Optional[pd.DataFrame]: DataFrame containing infiltration parameters if successful, None if operation fails.


HdfInfiltration.get_significant_mukeys(soil_stats: pd.DataFrame, threshold: float = 1.0) -> pd.DataFrame
Purpose: Gets mukeys with percentage greater than threshold.
Parameters:
soil_stats (pd.DataFrame): DataFrame with soil statistics.
threshold (float, optional): Minimum percentage threshold. Default 1.0.
Returns: pd.DataFrame: DataFrame with significant mukeys and their statistics.


HdfInfiltration.calculate_total_significant_percentage(significant_mukeys: pd.DataFrame) -> float
Purpose: Calculates total percentage covered by significant mukeys.
Parameters:
significant_mukeys (pd.DataFrame): DataFrame of significant mukeys.
Returns: float: Total percentage covered by significant mukeys.


HdfInfiltration.calculate_weighted_parameters(soil_stats: pd.DataFrame, infiltration_params: dict) -> dict
Purpose: Calculates weighted infiltration parameters based on soil statistics.
Parameters:
soil_stats (pd.DataFrame): DataFrame with soil statistics.
infiltration_params (dict): Dictionary of infiltration parameters by mukey.
Returns: dict: Dictionary of weighted average infiltration parameters.


HdfInfiltration.get_infiltration_baseoverrides(hdf_path: Path) -> Optional[pd.DataFrame]
Purpose: Retrieves current infiltration parameters from a HEC-RAS geometry HDF file.
Parameters:
hdf_path (Input handled by @standardize_input, file_type='geom_hdf'): Path identifier for the geometry HDF.
Returns: Optional[pd.DataFrame]: DataFrame containing infiltration parameters if successful, None if operation fails.



Functions to get Results information from a Plan HDF:

Lump these into a single tool call - get_plan_results_summary



Class: HdfResultsPlan
Contains static methods for extracting general plan-level results and summary information from HEC-RAS plan HDF files.

HdfResultsPlan.get_unsteady_info(hdf_path)
Purpose: Extracts attributes from the 'Results/Unsteady' group in the plan HDF file.
Parameters:
hdf_path (Input handled by @standardize_input, file_type='plan_hdf'): Path identifier for the plan results HDF.
Returns: pd.DataFrame: Single-row DataFrame containing the unsteady results attributes.
Raises: FileNotFoundError, KeyError, RuntimeError.

HdfResultsPlan.get_unsteady_summary(hdf_path)
Purpose: Extracts attributes from the 'Results/Unsteady/Summary' group in the plan HDF file.
Parameters:
hdf_path (Input handled by @standardize_input, file_type='plan_hdf'): Path identifier for the plan results HDF.
Returns: pd.DataFrame: Single-row DataFrame containing the unsteady summary attributes.
Raises: FileNotFoundError, KeyError, RuntimeError.

HdfResultsPlan.get_volume_accounting(hdf_path)
Purpose: Extracts attributes from the 'Results/Unsteady/Summary/Volume Accounting' group in the plan HDF file.
Parameters:
hdf_path (Input handled by @standardize_input, file_type='plan_hdf'): Path identifier for the plan results HDF.
Returns: (pd.DataFrame or None): Single-row DataFrame containing volume accounting attributes, or None if the group doesn't exist.
Raises: FileNotFoundError, RuntimeError.

HdfResultsPlan.get_runtime_data(hdf_path)
Purpose: Extracts detailed computational performance metrics (durations, speeds) for different simulation processes (Geometry, Preprocessing, Unsteady Flow) from the plan HDF file.
Parameters:
hdf_path (Input handled by @standardize_input, file_type='plan_hdf'): Path identifier for the plan results HDF.
Returns: (pd.DataFrame or None): Single-row DataFrame containing runtime statistics, or None if data is missing or parsing fails.














==================================================

File: c:\GH\ras-commander-mcp\README.md
==================================================
# HEC-RAS MCP Server

<div align="center">
  <img src="ras_commander_mcp_logo.svg" alt="RAS Commander MCP Logo" width="70%">
</div>  

The RAS Commander MCP (Model Context Protocol) server provides tools for querying HEC-RAS project information using the ras-commander library. This allows Claude Desktop to interact with HEC-RAS hydraulic modeling projects.

## Key Features

- **Stateful Context**: Initialize a project once, then query it multiple times without repeating the project path
- **Comprehensive Project Summary**: Get all project info including plans, geometries, flows, and projection data
- **Flexible Plan Queries**: Get all plans or specific plan details
- **Infiltration Analysis**: Extract and analyze soil infiltration data
- **Results Summary**: Access unsteady flow results, volume accounting, and runtime statistics
- **Smart Path Handling**: Automatic HDF file detection for projection info
- **Error Handling**: Helpful diagnostics for common issues

## Prerequisites

1. **HEC-RAS Installation**: HEC-RAS must be installed on your system (default expects version 6.6)
2. **Python**: Python 3.8+ with Anaconda recommended
3. **Claude Desktop**: For MCP integration

## Installation

1. Clone this repository:
```bash
git clone <repository-url>
cd ras-commander-mcp
```

2. Install dependencies using the Anaconda environment:
```bash
# Create and activate conda environment
conda create -n hecras-mcp python=3.9
conda activate hecras-mcp
pip install -r requirements.txt
```

## Configuration

### Claude Desktop Integration

Add the following to your Claude Desktop configuration file (`claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "hecras": {
      "command": "python",
      "args": ["path/to/your/ras-commander-mcp/server.py"]
    }
  }
}
```

### HEC-RAS Version Configuration

The MCP server uses HEC-RAS version 6.6 by default. To use a different version:

1. **Set HEC-RAS Version**:
   ```json
   {
     "mcpServers": {
       "hecras": {
         "command": "python",
         "args": ["path/to/your/ras-commander-mcp/server.py"],
         "env": {
           "HECRAS_VERSION": "6.5"
         }
       }
     }
   }
   ```

2. **Set HEC-RAS Path** (for non-standard installations):
   ```json
   {
     "mcpServers": {
       "hecras": {
         "command": "python",
         "args": ["path/to/your/ras-commander-mcp/server.py"],
         "env": {
           "HECRAS_PATH": "C:\\Program Files\\HEC\\HEC-RAS\\6.5\\HEC-RAS.exe"
         }
       }
     }
   }
   ```

## Available Tools

### 1. **get_ras_projectsummary**
Get comprehensive project information including plans, geometries, flows, boundaries, and projection info. This tool initializes the project context for subsequent calls.

**Parameters:**
- `project_path` (optional): Full path to HEC-RAS project folder
- `include_boundaries` (optional): Include boundary conditions data (default: false)

### 2. **get_ras_planinfo**
Get plan information from the project. Can return all plans or a specific plan.

**Parameters:**
- `project_path` (optional): Full path to HEC-RAS project folder
- `plan_number` (optional): Specific plan number (e.g., "01", "02")

### 3. **get_ras_infiltration**
Get infiltration layer data and soil statistics from the project.

**Parameters:**
- `project_path` (optional): Full path to HEC-RAS project folder
- `significant_threshold` (optional): Minimum percentage threshold for significant mukeys (default: 1.0)

### 4. **get_ras_resultssummary**
Get comprehensive results from a specific plan including unsteady info, volume accounting, and runtime data.

**Parameters:**
- `plan_name` (required): Name or number of the plan
- `project_path` (optional): Full path to HEC-RAS project folder

### 5. **get_projection_info**
Get spatial projection information (WKT string) from HDF files.

**Parameters:**
- `hdf_path` (optional): Full path to HDF file (auto-detects if not provided)

### 6. **get_1d_profile_results**
Get maximum water surface elevation and velocity for 1D cross sections in a river/reach.

**Parameters:**
- `plan_name` (required): Name or number of the plan
- `river_name` (optional): River name (if not specified, returns all rivers)
- `reach_name` (optional): Reach name (only used if river_name is specified)
- `project_path` (optional): Full path to HEC-RAS project folder

**Returns:** Formatted table with station, max WSEL, and max velocity for each cross section.

### 7. **compare_1d_profiles**
Compare maximum water surface elevations between two plans for 1D cross sections.

**Parameters:**
- `plan1_name` (required): Name or number of the first plan (baseline)
- `plan2_name` (required): Name or number of the second plan (comparison)
- `river_name` (optional): River name (if not specified, returns all rivers)
- `reach_name` (optional): Reach name (only used if river_name is specified)
- `project_path` (optional): Full path to HEC-RAS project folder

**Returns:** Comparison table with differences and comprehensive statistics including stations unique to each plan.

### 8. **plot_1d_profile**
Plot 1D profile results showing water surface elevation vs station.

**Parameters:**
- `plan_name` (required): Name or number of the plan to plot
- `river_name` (optional): River name (if not specified, shows all rivers)
- `reach_name` (optional): Reach name (only used if river_name is specified)
- `project_path` (optional): Full path to HEC-RAS project folder

**Returns:** Embedded PNG plot as base64 image in markdown format.

### 9. **plot_1d_profile_comparison**
Plot comparison of 1D profiles between two plans.

**Parameters:**
- `plan1_name` (required): Name or number of the first plan (baseline)
- `plan2_name` (required): Name or number of the second plan (comparison)
- `river_name` (optional): River name (if not specified, shows all rivers)
- `reach_name` (optional): Reach name (only used if river_name is specified)
- `project_path` (optional): Full path to HEC-RAS project folder

**Returns:** Embedded PNG comparison plot with thalweg data, annotations, and detailed statistics.

### 10. **get_1d_xsec_timeseries**
Get detailed time series results for a specific 1D cross section including water surface elevation, velocity, flow, and other hydraulic variables.

**Parameters:**
- `plan_name` (required): Name or number of the plan
- `river_name` (required): River name
- `reach_name` (required): Reach name  
- `station` (required): River station (e.g., '27857.47')
- `project_path` (optional): Full path to HEC-RAS project folder

**Returns:** Time series data with summary statistics (max, min, mean, time of max) for each variable.

## Usage Examples

### In Claude Desktop

Once configured, you can ask Claude:

```
"Get the project summary for C:/Projects/MyRiverModel"
"Show me plan 04 details"  (no path needed after initialization!)
"Get infiltration data with 5% threshold"
"Show results summary for plan 04"
"What's the projection info?"
```

### Key Feature: Stateful Context

The server maintains project context between calls:

1. **First call** - Initialize with project path:
   ```
   "Get project summary for C:/Projects/MyRiverModel"
   ```

2. **Subsequent calls** - No path needed:
   ```
   "Show me plan 02"
   "Get infiltration data"
   "Show results for plan 04"
   ```

3. **Switch projects** - Just provide a new path:
   ```
   "Get project summary for C:/Projects/AnotherModel"
   ```

### Testing

Run the example client to test the server:

```bash
# Using the activated conda environment
python example_client.py
```

Or test directly without MCP protocol:

```bash
python test_server.py
```

## Test Data

The `testdata/Muncie/` folder contains a complete HEC-RAS project for testing, including:
- Multiple plan configurations
- HDF5 result files
- Terrain and infiltration data
- Geometry files
- Boundary conditions

## Troubleshooting

1. **ImportError for ras-commander**: Ensure ras-commander is installed and HEC-RAS is properly installed
2. **Project not found**: Verify the project path exists and contains .prj files
3. **Version errors**: Check that the specified HEC-RAS version matches your installation
4. **No context available**: Make sure to initialize a project first with `get_ras_projectsummary`

## Development

To modify or extend the server:

1. Edit `server.py` to add new tools or modify existing ones
2. Test changes with `test_server.py` (direct testing)
3. Test MCP integration with `example_client.py`
4. Update Claude Desktop configuration if needed

## What's New in v0.3.0

- **Enhanced 1D Profile Comparison**: Properly handles mismatched stations between plans
- **Improved Statistics**: Separate statistics for matching vs non-matching stations
- **Better Plotting**: Enhanced plots with thalweg data, annotations, and visual markers
- **New Tool**: `get_1d_xsec_timeseries` for detailed cross-section time series analysis
- **Outer Join Logic**: Shows stations that exist in only one plan during comparisons
- **Thalweg Integration**: Automatic thalweg plotting from geometry data where available
- **Enhanced Annotations**: Plot annotations show maximum differences and locations
- **Better Error Handling**: More robust data extraction and error reporting

## What's New in v0.2.0

- **Stateful Context**: Project persistence between tool calls
- **Improved Tool Names**: More consistent `get_ras_*` naming convention
- **Consolidated Tools**: Geometry info now included in project summary
- **Smart Defaults**: Auto-detection of HDF files for projection info
- **Enhanced Plan Queries**: Support for querying by plan number
- **Better Error Messages**: More helpful context-aware error handling

## License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

## Trademarks

See [TRADEMARKS.md](TRADEMARKS.md) for trademark information and compliance policies.
==================================================

File: c:\GH\ras-commander-mcp\requirements.txt
==================================================
mcp
ras-commander
pandas
==================================================

File: c:\GH\ras-commander-mcp\server.py
==================================================
#!/usr/bin/env python3
"""
HEC-RAS MCP Server

An MCP server that provides tools for querying HEC-RAS project information
using the ras-commander library.
"""

import asyncio
import json
import logging
import os
from pathlib import Path
from typing import Any, Sequence, Optional, Dict
import pandas as pd
import io
import matplotlib
matplotlib.use('Agg')  # Use non-interactive backend
import matplotlib.pyplot as plt
from io import BytesIO
import base64
import numpy as np

from mcp.server.models import InitializationOptions
from mcp.server import NotificationOptions, Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent

# Import ras-commander
try:
    from ras_commander import init_ras_project, HdfBase, HdfInfiltration, HdfResultsPlan, HdfResultsXsec, HdfXsec
except ImportError:
    raise ImportError("ras-commander is not installed. Please install it with: pip install ras-commander")

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Initialize the MCP server
server = Server("hecras-mcp-server")

def get_thalweg_from_geometry(geom_hdf_path, river_name, reach_name):
    """
    Extract thalweg (minimum elevation) data from cross-section geometry.
    
    Args:
        geom_hdf_path: Path to geometry HDF file
        river_name: River name
        reach_name: Reach name
        
    Returns:
        dict: Mapping of station to thalweg elevation
    """
    try:
        # Get cross sections from geometry
        cross_sections_gdf = HdfXsec.get_cross_sections(geom_hdf_path)
        
        # Filter for specific river and reach
        xs_geom_df = cross_sections_gdf[
            (cross_sections_gdf['River'] == river_name) & 
            (cross_sections_gdf['Reach'] == reach_name)
        ].copy()
        
        if xs_geom_df.empty:
            return {}
        
        # Extract thalweg for each cross section
        thalweg_data = {}
        for idx, row in xs_geom_df.iterrows():
            station = float(row['RS'])
            # Get minimum elevation from station-elevation points
            if isinstance(row['station_elevation'], (list, np.ndarray)) and len(row['station_elevation']) > 0:
                min_elev = min(point[1] for point in row['station_elevation'])
                thalweg_data[station] = min_elev
                
        return thalweg_data
        
    except Exception as e:
        logger.error(f"Error extracting thalweg data: {str(e)}")
        return {}


def format_profile_results_enhanced(xsec_data, river_name=None, reach_name=None):
    """
    Enhanced version of format_profile_results with better data extraction.
    
    Args:
        xsec_data: xarray Dataset from HdfResultsXsec.get_xsec_timeseries
        river_name: Optional river name filter
        reach_name: Optional reach name filter
        
    Returns:
        pd.DataFrame: Formatted profile results
    """
    # Check if we have the expected data variables
    if 'Water Surface' not in xsec_data.data_vars:
        return pd.DataFrame()
    
    # Extract coordinates
    xsec_names = xsec_data.coords['cross_section'].values
    rivers = xsec_data.coords.get('River', None)
    reaches = xsec_data.coords.get('Reach', None)
    stations = xsec_data.coords.get('Station', None)
    
    # Extract max values from coordinates
    max_wsel = xsec_data.coords.get('max_Water Surface', None)
    max_velocity = xsec_data.coords.get('max_Total Velocity', None)
    
    # Build results list
    results = []
    for i, xsec in enumerate(xsec_names):
        # Safe extraction with defaults
        river = rivers.values[i] if rivers is not None else "Unknown"
        reach = reaches.values[i] if reaches is not None else "Unknown"
        station = stations.values[i] if stations is not None else 0.0
        
        # Apply filters
        if river_name and river != river_name:
            continue
        if reach_name and reach != reach_name:
            continue
        
        # Get values, handling None cases
        wsel = max_wsel.values[i] if max_wsel is not None else None
        vel = max_velocity.values[i] if max_velocity is not None else None
        
        # Convert to float, handling various input types
        try:
            station_float = float(station)
        except (ValueError, TypeError):
            station_float = 0.0
            
        try:
            wsel_float = float(wsel) if wsel is not None else None
        except (ValueError, TypeError):
            wsel_float = None
            
        try:
            vel_float = float(vel) if vel is not None else None
        except (ValueError, TypeError):
            vel_float = None
        
        results.append({
            'River': river,
            'Reach': reach,
            'Station': station_float,
            'Max WSEL': wsel_float,
            'Max Velocity': vel_float
        })
    
    # Convert to DataFrame and sort
    if results:
        df = pd.DataFrame(results)
        df.sort_values(by=['River', 'Reach', 'Station'], inplace=True)
        return df
    else:
        return pd.DataFrame()

# Configuration
DEFAULT_RAS_VERSION = "6.6"
# Get HEC-RAS version from environment variable or use default
HECRAS_VERSION = os.environ.get("HECRAS_VERSION", DEFAULT_RAS_VERSION)
# Allow specifying a direct path to HEC-RAS executable
HECRAS_PATH = os.environ.get("HECRAS_PATH", None)

# State management - store current project context
PROJECT_CONTEXT: Dict[str, Any] = {
    "project_path": None,
    "ras_object": None,
    "project_name": None,
    "ras_version": None
}

def get_ras_version_info():
    """Get the configured HEC-RAS version or path for display."""
    if HECRAS_PATH:
        return f"HEC-RAS Path: {HECRAS_PATH}"
    else:
        return f"HEC-RAS Version: {HECRAS_VERSION}"

def dataframe_to_text(df: pd.DataFrame, name: str) -> str:
    """Convert a pandas DataFrame to a formatted text string."""
    if df is None or df.empty:
        return f"\n{name}: No data available\n"
    
    # Use StringIO to capture the DataFrame string representation
    buffer = io.StringIO()
    df.to_string(buf=buffer, max_rows=100, max_cols=None)
    return f"\n{name}:\n{buffer.getvalue()}\n"

def get_or_init_project(project_path: Optional[str] = None) -> Any:
    """Get existing project context or initialize a new one."""
    global PROJECT_CONTEXT
    
    # If a new project path is provided, initialize it
    if project_path:
        path = Path(project_path)
        if not path.exists() or not path.is_dir():
            raise ValueError(f"The specified project folder does not exist or is not a directory: {path}")
        
        # Initialize new project
        ras_version = HECRAS_PATH if HECRAS_PATH else HECRAS_VERSION
        logger.info(f"Initializing HEC-RAS project at: {path}")
        ras = init_ras_project(path, ras_version)
        
        # Update context
        PROJECT_CONTEXT["project_path"] = path
        PROJECT_CONTEXT["ras_object"] = ras
        PROJECT_CONTEXT["project_name"] = ras.project_name
        PROJECT_CONTEXT["ras_version"] = ras_version
        
        return ras
    
    # Otherwise, return existing context
    elif PROJECT_CONTEXT["ras_object"] is not None:
        return PROJECT_CONTEXT["ras_object"]
    
    # No context available
    else:
        raise ValueError("No project context available. Please provide a project_path or initialize a project first.")

def get_plan_hdf_path(ras, plan_name):
    """Get the HDF path for a given plan name or number."""
    if hasattr(ras, 'plan_df') and ras.plan_df is not None:
        # Try by plan number first
        plan_row = ras.plan_df[ras.plan_df['Plan'] == plan_name]
        if plan_row.empty:
            plan_row = ras.plan_df[ras.plan_df['Plan'] == plan_name.zfill(2)]
        if plan_row.empty:
            plan_row = ras.plan_df[ras.plan_df['Plan Title'] == plan_name]
        if plan_row.empty:
            plan_row = ras.plan_df[ras.plan_df['Short Identifier'] == plan_name]
        
        if not plan_row.empty and 'HDF_Results_Path' in plan_row.columns:
            hdf_rel_path = plan_row['HDF_Results_Path'].iloc[0]
            if hdf_rel_path:
                plan_hdf_path = Path(PROJECT_CONTEXT['project_path']) / hdf_rel_path
                if plan_hdf_path.exists():
                    return plan_hdf_path
    return None

def format_profile_results(xsec_data, river_name=None, reach_name=None):
    """Format cross-section results into a profile table."""
    return format_profile_results_enhanced(xsec_data, river_name, reach_name)

def plot_profile_results(profile_df, plan_name, river_name=None, reach_name=None):
    """Create an enhanced profile plot of water surface elevation vs station."""
    
    # Set up the plot with a reasonable size
    fig, ax = plt.subplots(figsize=(12, 7), dpi=80)
    
    # Create title
    title_parts = [f"1D Profile Results - Plan: {plan_name}"]
    if river_name:
        title_parts.append(f"River: {river_name}")
    if reach_name:
        title_parts.append(f"Reach: {reach_name}")
    
    if not profile_df.empty:
        # Plot based on scope
        if river_name and reach_name:
            # Single reach
            df_filtered = profile_df[
                (profile_df['River'] == river_name) & 
                (profile_df['Reach'] == reach_name)
            ].sort_values('Station')
            
            if not df_filtered.empty:
                ax.plot(df_filtered['Station'], df_filtered['Max WSEL'], 
                       'b-o', linewidth=2, markersize=5, label='Max WSEL')
                ax.set_xlabel('Station (ft)', fontsize=11)
                ax.set_ylabel('Water Surface Elevation (ft)', fontsize=11)
        
        elif river_name:
            # All reaches in a river
            reaches = profile_df[profile_df['River'] == river_name]['Reach'].unique()
            colors = plt.cm.tab10(np.linspace(0, 1, min(len(reaches), 10)))
            
            for i, reach in enumerate(reaches):
                df_reach = profile_df[
                    (profile_df['River'] == river_name) & 
                    (profile_df['Reach'] == reach)
                ].sort_values('Station')
                
                if not df_reach.empty:
                    color_idx = i % 10
                    ax.plot(df_reach['Station'], df_reach['Max WSEL'], 
                           '-o', color=colors[color_idx], linewidth=2, markersize=4, 
                           label=f'Reach: {reach}')
            
            ax.set_xlabel('Station (ft)', fontsize=11)
            ax.set_ylabel('Water Surface Elevation (ft)', fontsize=11)
            ax.legend(loc='best', fontsize=9, ncol=2 if len(reaches) > 5 else 1)
        
        else:
            # All rivers - summary view
            rivers = profile_df['River'].unique()
            n_rivers = len(rivers)
            
            if n_rivers == 1:
                # Single river, plot all reaches
                river = rivers[0]
                reaches = profile_df[profile_df['River'] == river]['Reach'].unique()
                colors = plt.cm.tab10(np.linspace(0, 1, min(len(reaches), 10)))
                
                for i, reach in enumerate(reaches):
                    df_reach = profile_df[
                        (profile_df['River'] == river) & 
                        (profile_df['Reach'] == reach)
                    ].sort_values('Station')
                    
                    if not df_reach.empty:
                        color_idx = i % 10
                        ax.plot(df_reach['Station'], df_reach['Max WSEL'], 
                               '-o', color=colors[color_idx], linewidth=2, markersize=4, 
                               label=f'{river} - {reach}')
                
                ax.set_xlabel('Station (ft)', fontsize=11)
                ax.set_ylabel('Water Surface Elevation (ft)', fontsize=11)
                ax.legend(loc='best', fontsize=9, ncol=2 if len(reaches) > 5 else 1)
            else:
                # Multiple rivers - show summary
                summary_text = f'Multiple rivers found ({n_rivers}):\n'
                for river in rivers[:5]:  # Show first 5
                    n_reaches = len(profile_df[profile_df['River'] == river]['Reach'].unique())
                    n_stations = len(profile_df[profile_df['River'] == river])
                    summary_text += f'\n{river}: {n_reaches} reaches, {n_stations} stations'
                if n_rivers > 5:
                    summary_text += f'\n... and {n_rivers - 5} more rivers'
                summary_text += '\n\nPlease specify a river name for detailed plot.'
                
                ax.text(0.5, 0.5, summary_text, 
                       transform=ax.transAxes, ha='center', va='center', 
                       fontsize=11, bbox=dict(boxstyle='round,pad=0.5', facecolor='lightyellow'))
                ax.set_xlim(0, 1)
                ax.set_ylim(0, 1)
                ax.set_xticks([])
                ax.set_yticks([])
        
        # Add summary statistics if data is plotted
        if ax.has_data() and river_name:
            df_stats = profile_df[profile_df['River'] == river_name] if river_name else profile_df
            if reach_name:
                df_stats = df_stats[df_stats['Reach'] == reach_name]
            
            if not df_stats.empty and 'Max WSEL' in df_stats.columns:
                stats_text = (f'Stations: {len(df_stats)}\n'
                            f'Max WSEL: {df_stats["Max WSEL"].max():.2f} ft\n'
                            f'Min WSEL: {df_stats["Max WSEL"].min():.2f} ft')
                
                ax.text(0.02, 0.98, stats_text,
                       transform=ax.transAxes,
                       fontsize=9,
                       verticalalignment='top',
                       bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8))
        
        ax.set_title('\n'.join(title_parts), fontsize=12, fontweight='bold')
        ax.grid(True, alpha=0.3)
        if ax.has_data():
            ax.legend(loc='best', fontsize=9)
    
    else:
        ax.text(0.5, 0.5, 'No data to plot', transform=ax.transAxes, 
               ha='center', va='center', fontsize=12)
        ax.set_title('\n'.join(title_parts), fontsize=12, fontweight='bold')
    
    plt.tight_layout()
    
    # Save to BytesIO buffer with optimization for size
    buffer = BytesIO()
    plt.savefig(buffer, format='png', dpi=80, bbox_inches='tight', 
                facecolor='white', edgecolor='none')
    plt.close()
    
    buffer.seek(0)
    return buffer


def plot_profile_comparison(comparison_df, plan1_name, plan2_name, river_name=None, reach_name=None):
    """Create an enhanced comparison plot of two profiles."""
    
    # Set up the plot
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 9), dpi=80, 
                                   gridspec_kw={'height_ratios': [3, 1]})
    
    # Create title
    title_parts = [f"1D Profile Comparison - {plan1_name} vs {plan2_name}"]
    if river_name:
        title_parts.append(f"River: {river_name}")
    if reach_name:
        title_parts.append(f"Reach: {reach_name}")
    
    if not comparison_df.empty:
        # Sort by station
        comparison_df = comparison_df.sort_values('Station')
        
        # Separate data for each plan (removing NaN values for plotting)
        plan1_mask = comparison_df['Max WSEL_Plan1'].notna()
        plan2_mask = comparison_df['Max WSEL_Plan2'].notna()
        both_mask = plan1_mask & plan2_mask
        
        # Top plot - both profiles
        if plan1_mask.any():
            ax1.plot(comparison_df.loc[plan1_mask, 'Station'], 
                    comparison_df.loc[plan1_mask, 'Max WSEL_Plan1'], 
                    'b-o', linewidth=2, markersize=5, label=f'Plan: {plan1_name}', zorder=2)
        
        if plan2_mask.any():
            ax1.plot(comparison_df.loc[plan2_mask, 'Station'], 
                    comparison_df.loc[plan2_mask, 'Max WSEL_Plan2'], 
                    'r--s', linewidth=2, markersize=5, label=f'Plan: {plan2_name}', zorder=2)
        
        # Add markers for stations that exist in only one plan
        plan1_only_mask = plan1_mask & ~plan2_mask
        plan2_only_mask = ~plan1_mask & plan2_mask
        
        if plan1_only_mask.any():
            ax1.scatter(comparison_df.loc[plan1_only_mask, 'Station'], 
                       comparison_df.loc[plan1_only_mask, 'Max WSEL_Plan1'], 
                       color='blue', s=100, marker='v', alpha=0.7, 
                       label=f'Plan {plan1_name} only', zorder=3)
        
        if plan2_only_mask.any():
            ax1.scatter(comparison_df.loc[plan2_only_mask, 'Station'], 
                       comparison_df.loc[plan2_only_mask, 'Max WSEL_Plan2'], 
                       color='red', s=100, marker='^', alpha=0.7, 
                       label=f'Plan {plan2_name} only', zorder=3)
        
        # Add thalweg if available
        if 'Thalweg' in comparison_df.columns and comparison_df['Thalweg'].notna().any():
            thalweg_mask = comparison_df['Thalweg'].notna()
            ax1.plot(comparison_df.loc[thalweg_mask, 'Station'], 
                    comparison_df.loc[thalweg_mask, 'Thalweg'], 
                    'k-', linewidth=2, label='Thalweg', zorder=1)
        
        ax1.set_ylabel('Water Surface Elevation (ft)', fontsize=11)
        ax1.legend(loc='best', fontsize=9)
        ax1.grid(True, alpha=0.3)
        ax1.set_title('\n'.join(title_parts), fontsize=12, fontweight='bold')
        
        # Bottom plot - difference (only where both exist)
        if both_mask.any():
            diff_data = comparison_df.loc[both_mask].copy()
            diff_data['WSEL_Difference'] = diff_data['Max WSEL_Plan1'] - diff_data['Max WSEL_Plan2']
            
            ax2.plot(diff_data['Station'], diff_data['WSEL_Difference'], 
                    'g-^', linewidth=2, markersize=5)
            
            # Add shading for positive/negative differences
            stations = diff_data['Station'].values
            differences = diff_data['WSEL_Difference'].values
            ax2.fill_between(stations, 0, differences, where=(differences > 0), 
                            alpha=0.3, color='blue', label='Plan1 Higher')
            ax2.fill_between(stations, 0, differences, where=(differences < 0), 
                            alpha=0.3, color='red', label='Plan2 Higher')
            
            # Add annotation for max absolute difference
            abs_diff = np.abs(differences)
            if len(abs_diff) > 0:
                max_idx = np.argmax(abs_diff)
                max_station = stations[max_idx]
                max_diff = differences[max_idx]
                ax2.annotate(f'Max |Δ|: {abs(max_diff):.3f} ft\n@ Sta {max_station:.1f}',
                           xy=(max_station, max_diff),
                           xytext=(10, 20),
                           textcoords='offset points',
                           bbox=dict(boxstyle='round,pad=0.3', facecolor='yellow', alpha=0.7),
                           arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0'))
        
        ax2.axhline(y=0, color='k', linestyle='-', alpha=0.3)
        ax2.set_xlabel('Station (ft)', fontsize=11)
        ax2.set_ylabel('Difference (Plan1 - Plan2) (ft)', fontsize=11)
        ax2.grid(True, alpha=0.3)
        
        # Add text box with statistics
        if both_mask.any():
            n_matching = both_mask.sum()
            n_plan1_only = plan1_only_mask.sum()
            n_plan2_only = plan2_only_mask.sum()
            diff_values = comparison_df.loc[both_mask, 'Max WSEL_Plan1'] - comparison_df.loc[both_mask, 'Max WSEL_Plan2']
            
            stats_text = (f'Stations - Matching: {n_matching}, Plan1 only: {n_plan1_only}, Plan2 only: {n_plan2_only}\n'
                         f'Mean Δ: {diff_values.mean():.3f} ft, Std Dev: {diff_values.std():.3f} ft')
            
            ax1.text(0.02, 0.98, stats_text,
                    transform=ax1.transAxes,
                    fontsize=9,
                    verticalalignment='top',
                    bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8))
    
    plt.tight_layout()
    
    # Save to BytesIO buffer
    buffer = BytesIO()
    plt.savefig(buffer, format='png', dpi=80, bbox_inches='tight', 
                facecolor='white', edgecolor='none')
    plt.close()
    
    buffer.seek(0)
    return buffer

@server.list_tools()
async def handle_list_tools() -> list[Tool]:
    """List available tools."""
    return [
        Tool(
            name="get_ras_projectsummary",
            description="Get comprehensive HEC-RAS project summary including plans, geometries, flows, boundaries, and projection info",
            inputSchema={
                "type": "object",
                "properties": {
                    "project_path": {
                        "type": "string",
                        "description": "Full path to the HEC-RAS project folder (optional if already initialized)"
                    },
                    "include_boundaries": {
                        "type": "boolean",
                        "description": "Include boundary conditions data (can be large)",
                        "default": False
                    }
                },
                "required": []
            }
        ),
        Tool(
            name="get_ras_planinfo",
            description="Get plan information from the HEC-RAS project",
            inputSchema={
                "type": "object",
                "properties": {
                    "project_path": {
                        "type": "string",
                        "description": "Full path to the HEC-RAS project folder (optional if already initialized)"
                    },
                    "plan_number": {
                        "type": "string",
                        "description": "Specific plan number to retrieve (e.g., '01', '02'). If not provided, returns all plans."
                    }
                },
                "required": []
            }
        ),
        Tool(
            name="get_ras_infiltration",
            description="Get infiltration layer data and soil statistics from the HEC-RAS project",
            inputSchema={
                "type": "object",
                "properties": {
                    "project_path": {
                        "type": "string",
                        "description": "Full path to the HEC-RAS project folder (optional if already initialized)"
                    },
                    "significant_threshold": {
                        "type": "number",
                        "description": "Minimum percentage threshold for significant mukeys",
                        "default": 1.0
                    }
                },
                "required": []
            }
        ),
        Tool(
            name="get_ras_resultssummary",
            description="Get comprehensive results summary from a HEC-RAS plan including unsteady info, volume accounting, and runtime data",
            inputSchema={
                "type": "object",
                "properties": {
                    "plan_name": {
                        "type": "string",
                        "description": "Name or number of the plan to get results from"
                    },
                    "project_path": {
                        "type": "string",
                        "description": "Full path to the HEC-RAS project folder (optional if already initialized)"
                    }
                },
                "required": ["plan_name"]
            }
        ),
        Tool(
            name="get_projection_info",
            description="Get spatial projection information (WKT string) from a HEC-RAS HDF file",
            inputSchema={
                "type": "object",
                "properties": {
                    "hdf_path": {
                        "type": "string",
                        "description": "Full path to the HDF file (optional - uses first available HDF if not provided)"
                    }
                },
                "required": []
            }
        ),
        Tool(
            name="get_1d_profile_results",
            description="Get maximum water surface elevation and velocity for 1D cross sections and structures in a river/reach",
            inputSchema={
                "type": "object",
                "properties": {
                    "plan_name": {
                        "type": "string",
                        "description": "Name or number of the plan to get results from"
                    },
                    "river_name": {
                        "type": "string",
                        "description": "River name (optional - if not specified, returns all rivers)"
                    },
                    "reach_name": {
                        "type": "string",
                        "description": "Reach name (optional - only used if river_name is specified)"
                    },
                    "project_path": {
                        "type": "string",
                        "description": "Full path to the HEC-RAS project folder (optional if already initialized)"
                    }
                },
                "required": ["plan_name"]
            }
        ),
        Tool(
            name="compare_1d_profiles",
            description="Compare maximum water surface elevations between two plans for 1D cross sections",
            inputSchema={
                "type": "object",
                "properties": {
                    "plan1_name": {
                        "type": "string",
                        "description": "Name or number of the first plan (baseline)"
                    },
                    "plan2_name": {
                        "type": "string",
                        "description": "Name or number of the second plan (comparison)"
                    },
                    "river_name": {
                        "type": "string",
                        "description": "River name (optional - if not specified, returns all rivers)"
                    },
                    "reach_name": {
                        "type": "string",
                        "description": "Reach name (optional - only used if river_name is specified)"
                    },
                    "project_path": {
                        "type": "string",
                        "description": "Full path to the HEC-RAS project folder (optional if already initialized)"
                    }
                },
                "required": ["plan1_name", "plan2_name"]
            }
        ),
        Tool(
            name="plot_1d_profile",
            description="Plot 1D profile results showing water surface elevation vs station",
            inputSchema={
                "type": "object",
                "properties": {
                    "plan_name": {
                        "type": "string",
                        "description": "Name or number of the plan to plot"
                    },
                    "river_name": {
                        "type": "string",
                        "description": "River name (optional - if not specified, shows all rivers)"
                    },
                    "reach_name": {
                        "type": "string",
                        "description": "Reach name (optional - only used if river_name is specified)"
                    },
                    "project_path": {
                        "type": "string",
                        "description": "Full path to the HEC-RAS project folder (optional if already initialized)"
                    }
                },
                "required": ["plan_name"]
            }
        ),
        Tool(
            name="plot_1d_profile_comparison",
            description="Plot comparison of 1D profiles between two plans",
            inputSchema={
                "type": "object",
                "properties": {
                    "plan1_name": {
                        "type": "string",
                        "description": "Name or number of the first plan (baseline)"
                    },
                    "plan2_name": {
                        "type": "string",
                        "description": "Name or number of the second plan (comparison)"
                    },
                    "river_name": {
                        "type": "string",
                        "description": "River name (optional - if not specified, shows all rivers)"
                    },
                    "reach_name": {
                        "type": "string",
                        "description": "Reach name (optional - only used if river_name is specified)"
                    },
                    "project_path": {
                        "type": "string",
                        "description": "Full path to the HEC-RAS project folder (optional if already initialized)"
                    }
                },
                "required": ["plan1_name", "plan2_name"]
            }
        ),
        Tool(
            name="get_1d_xsec_timeseries",
            description="Get detailed time series results for a specific 1D cross section (water surface, velocity, flow, etc.)",
            inputSchema={
                "type": "object",
                "properties": {
                    "plan_name": {
                        "type": "string",
                        "description": "Name or number of the plan to get results from"
                    },
                    "river_name": {
                        "type": "string", 
                        "description": "River name"
                    },
                    "reach_name": {
                        "type": "string",
                        "description": "Reach name"
                    },
                    "station": {
                        "type": "string",
                        "description": "River station (e.g., '27857.47')"
                    },
                    "project_path": {
                        "type": "string",
                        "description": "Full path to the HEC-RAS project folder (optional if already initialized)"
                    }
                },
                "required": ["plan_name", "river_name", "reach_name", "station"]
            }
        )
    ]

@server.call_tool()
async def handle_call_tool(name: str, arguments: Any) -> Sequence[TextContent]:
    """Handle tool calls."""
    
    if name == "get_ras_projectsummary":
        try:
            project_path = arguments.get("project_path")
            include_boundaries = arguments.get("include_boundaries", False)
            
            # Get or initialize project
            ras = get_or_init_project(project_path)
            
            # Build the response
            response_parts = [
                f"HEC-RAS Project: {ras.project_name}",
                f"Project Path: {PROJECT_CONTEXT['project_path']}",
                get_ras_version_info(),
                "=" * 80
            ]
            
            # Add projection information if available
            try:
                # Try to get projection from first available HDF
                hdf_files = list(Path(PROJECT_CONTEXT['project_path']).glob("*.hdf"))
                if hdf_files:
                    projection_wkt = HdfBase.get_projection(hdf_files[0])
                    if projection_wkt:
                        response_parts.append(f"\nPROJECTION INFO:\n{projection_wkt[:200]}..." if len(projection_wkt) > 200 else f"\nPROJECTION INFO:\n{projection_wkt}")
            except:
                pass  # Projection info is optional
            
            # Add plan information
            if hasattr(ras, 'plan_df') and ras.plan_df is not None:
                response_parts.append(dataframe_to_text(ras.plan_df, "PLANS"))
            
            # Add geometry information
            if hasattr(ras, 'geom_df') and ras.geom_df is not None:
                response_parts.append(dataframe_to_text(ras.geom_df, "GEOMETRIES"))
            
            # Add flow information
            if hasattr(ras, 'flow_df') and ras.flow_df is not None:
                response_parts.append(dataframe_to_text(ras.flow_df, "STEADY FLOWS"))
                
            if hasattr(ras, 'unsteady_df') and ras.unsteady_df is not None:
                response_parts.append(dataframe_to_text(ras.unsteady_df, "UNSTEADY FLOWS"))
            
            # Add boundary conditions if requested
            if include_boundaries and hasattr(ras, 'boundaries_df') and ras.boundaries_df is not None:
                response_parts.append(dataframe_to_text(ras.boundaries_df, "BOUNDARY CONDITIONS"))
            
            return [TextContent(
                type="text",
                text="\n".join(response_parts)
            )]
            
        except Exception as e:
            logger.error(f"Error getting project summary: {str(e)}")
            return [TextContent(
                type="text",
                text=f"Error getting project summary: {str(e)}\n\nPlease ensure:\n1. The project path is correct\n2. HEC-RAS version is installed at the expected location\n3. The project files are valid"
            )]
    
    elif name == "get_ras_planinfo":
        try:
            project_path = arguments.get("project_path")
            plan_number = arguments.get("plan_number")
            
            # Get or initialize project
            ras = get_or_init_project(project_path)
            
            response_parts = [
                f"HEC-RAS Project: {ras.project_name}",
                f"Project Path: {PROJECT_CONTEXT['project_path']}",
                get_ras_version_info(),
                "=" * 80
            ]
            
            if hasattr(ras, 'plan_df') and ras.plan_df is not None:
                if plan_number:
                    # Filter for specific plan
                    plan_df = ras.plan_df[ras.plan_df['Plan'] == plan_number]
                    if plan_df.empty:
                        # Try with zero-padded number
                        plan_df = ras.plan_df[ras.plan_df['Plan'] == plan_number.zfill(2)]
                    
                    if not plan_df.empty:
                        response_parts.append(dataframe_to_text(plan_df, f"PLAN {plan_number} INFO"))
                    else:
                        response_parts.append(f"\nPlan {plan_number} not found")
                else:
                    # Return all plans
                    response_parts.append(dataframe_to_text(ras.plan_df, "ALL PLANS"))
            else:
                response_parts.append("\nNo plan data available")
            
            return [TextContent(
                type="text",
                text="\n".join(response_parts)
            )]
            
        except Exception as e:
            logger.error(f"Error getting plan info: {str(e)}")
            return [TextContent(
                type="text",
                text=f"Error getting plan info: {str(e)}"
            )]
    
    elif name == "get_ras_infiltration":
        try:
            project_path = arguments.get("project_path")
            significant_threshold = arguments.get("significant_threshold", 1.0)
            
            # Get or initialize project
            ras = get_or_init_project(project_path)
            
            response_parts = [
                f"HEC-RAS Project: {ras.project_name}",
                f"Project Path: {PROJECT_CONTEXT['project_path']}",
                get_ras_version_info(),
                "=" * 80
            ]
            
            # Check if infiltration data exists in rasmap_df
            if hasattr(ras, 'rasmap_df') and ras.rasmap_df is not None and not ras.rasmap_df.empty:
                # Look for infiltration layer path
                infiltration_path = None
                if 'InfiltrationLayerFilename' in ras.rasmap_df.columns:
                    infiltration_files = ras.rasmap_df['InfiltrationLayerFilename'].dropna()
                    if not infiltration_files.empty:
                        infiltration_path = Path(PROJECT_CONTEXT['project_path']) / infiltration_files.iloc[0]
                
                if infiltration_path and infiltration_path.exists():
                    # Get infiltration data
                    infiltration_data = HdfInfiltration.get_infiltration_layer_data(infiltration_path)
                    if infiltration_data is not None:
                        response_parts.append(dataframe_to_text(infiltration_data, "INFILTRATION LAYER DATA"))
                        
                        # Get significant mukeys if soil stats available
                        if 'percentage' in infiltration_data.columns:
                            significant_mukeys = HdfInfiltration.get_significant_mukeys(
                                infiltration_data, threshold=significant_threshold
                            )
                            if not significant_mukeys.empty:
                                response_parts.append(dataframe_to_text(
                                    significant_mukeys, 
                                    f"SIGNIFICANT MUKEYS (>{significant_threshold}%)"
                                ))
                                total_percentage = HdfInfiltration.calculate_total_significant_percentage(
                                    significant_mukeys
                                )
                                response_parts.append(f"\nTotal significant percentage: {total_percentage:.2f}%")
                    else:
                        response_parts.append("\nNo infiltration data found in layer file")
                else:
                    response_parts.append("\nNo infiltration layer file found in project")
            else:
                response_parts.append("\nNo RASMapper configuration found")
            
            return [TextContent(
                type="text",
                text="\n".join(response_parts)
            )]
            
        except Exception as e:
            logger.error(f"Error getting infiltration data: {str(e)}")
            return [TextContent(
                type="text",
                text=f"Error getting infiltration data: {str(e)}"
            )]
    
    elif name == "get_ras_resultssummary":
        try:
            project_path = arguments.get("project_path")
            plan_name = arguments["plan_name"]
            
            # Get or initialize project
            ras = get_or_init_project(project_path)
            
            # Find the plan HDF file
            plan_hdf_path = None
            if hasattr(ras, 'plan_df') and ras.plan_df is not None:
                # Try by plan number first
                plan_row = ras.plan_df[ras.plan_df['Plan'] == plan_name]
                if plan_row.empty:
                    plan_row = ras.plan_df[ras.plan_df['Plan'] == plan_name.zfill(2)]
                if plan_row.empty:
                    plan_row = ras.plan_df[ras.plan_df['Plan Title'] == plan_name]
                if plan_row.empty:
                    plan_row = ras.plan_df[ras.plan_df['Short Identifier'] == plan_name]
                
                if not plan_row.empty and 'HDF_Results_Path' in plan_row.columns:
                    hdf_rel_path = plan_row['HDF_Results_Path'].iloc[0]
                    if hdf_rel_path:
                        plan_hdf_path = Path(PROJECT_CONTEXT['project_path']) / hdf_rel_path
            
            if not plan_hdf_path or not plan_hdf_path.exists():
                return [TextContent(
                    type="text",
                    text=f"Error: Plan '{plan_name}' not found or has no results HDF file"
                )]
            
            response_parts = [
                f"Plan Results Summary: {plan_name}",
                f"HDF Path: {plan_hdf_path}",
                "=" * 80
            ]
            
            # Get unsteady info
            try:
                unsteady_info = HdfResultsPlan.get_unsteady_info(plan_hdf_path)
                response_parts.append(dataframe_to_text(unsteady_info, "UNSTEADY INFO"))
            except Exception as e:
                response_parts.append(f"\nUnsteady info not available: {str(e)}")
            
            # Get unsteady summary
            try:
                unsteady_summary = HdfResultsPlan.get_unsteady_summary(plan_hdf_path)
                response_parts.append(dataframe_to_text(unsteady_summary, "UNSTEADY SUMMARY"))
            except Exception as e:
                response_parts.append(f"\nUnsteady summary not available: {str(e)}")
            
            # Get volume accounting
            try:
                volume_accounting = HdfResultsPlan.get_volume_accounting(plan_hdf_path)
                if volume_accounting is not None:
                    response_parts.append(dataframe_to_text(volume_accounting, "VOLUME ACCOUNTING"))
                else:
                    response_parts.append("\nVolume accounting not available")
            except Exception as e:
                response_parts.append(f"\nVolume accounting error: {str(e)}")
            
            # Get runtime data
            try:
                runtime_data = HdfResultsPlan.get_runtime_data(plan_hdf_path)
                if runtime_data is not None:
                    response_parts.append(dataframe_to_text(runtime_data, "RUNTIME DATA"))
                else:
                    response_parts.append("\nRuntime data not available")
            except Exception as e:
                response_parts.append(f"\nRuntime data error: {str(e)}")
            
            return [TextContent(
                type="text",
                text="\n".join(response_parts)
            )]
            
        except Exception as e:
            logger.error(f"Error getting results summary: {str(e)}")
            return [TextContent(
                type="text",
                text=f"Error getting results summary: {str(e)}"
            )]
    
    elif name == "get_projection_info":
        try:
            hdf_path = arguments.get("hdf_path")
            
            if hdf_path:
                hdf_path = Path(hdf_path)
                if not hdf_path.exists():
                    return [TextContent(
                        type="text",
                        text=f"Error: The specified HDF file does not exist: {hdf_path}"
                    )]
            else:
                # Try to find an HDF file in the current project
                if PROJECT_CONTEXT["project_path"]:
                    hdf_files = list(Path(PROJECT_CONTEXT['project_path']).glob("*.hdf"))
                    if hdf_files:
                        hdf_path = hdf_files[0]
                    else:
                        return [TextContent(
                            type="text",
                            text="Error: No HDF files found in the current project"
                        )]
                else:
                    return [TextContent(
                        type="text",
                        text="Error: No project initialized and no HDF path provided"
                    )]
            
            projection_wkt = HdfBase.get_projection(hdf_path)
            
            response_parts = [
                f"Projection Info for: {hdf_path}",
                "=" * 80
            ]
            
            if projection_wkt:
                response_parts.append(f"\nWKT String:\n{projection_wkt}")
            else:
                response_parts.append("\nNo projection information found")
            
            return [TextContent(
                type="text",
                text="\n".join(response_parts)
            )]
            
        except Exception as e:
            logger.error(f"Error getting projection info: {str(e)}")
            return [TextContent(
                type="text",
                text=f"Error getting projection info: {str(e)}"
            )]
    
    elif name == "get_1d_profile_results":
        try:
            project_path = arguments.get("project_path")
            plan_name = arguments["plan_name"]
            river_name = arguments.get("river_name")
            reach_name = arguments.get("reach_name")
            
            # Get or initialize project
            ras = get_or_init_project(project_path)
            
            # Get plan HDF path
            plan_hdf_path = get_plan_hdf_path(ras, plan_name)
            if not plan_hdf_path:
                return [TextContent(
                    type="text",
                    text=f"Error: Plan '{plan_name}' not found or has no results HDF file"
                )]
            
            response_parts = [
                f"1D Profile Results for Plan: {plan_name}",
                f"HDF Path: {plan_hdf_path}",
            ]
            
            if river_name:
                response_parts.append(f"River: {river_name}")
            if reach_name:
                response_parts.append(f"Reach: {reach_name}")
            
            response_parts.append("=" * 80)
            
            # Get cross-section time series data
            try:
                xsec_data = HdfResultsXsec.get_xsec_timeseries(plan_hdf_path)
                
                # Format results
                profile_df = format_profile_results(xsec_data, river_name, reach_name)
                
                if not profile_df.empty:
                    response_parts.append(dataframe_to_text(profile_df, "PROFILE RESULTS"))
                else:
                    response_parts.append("\nNo cross-section data found for the specified criteria")
                    
            except Exception as e:
                response_parts.append(f"\nError getting cross-section data: {str(e)}")
            
            return [TextContent(
                type="text",
                text="\n".join(response_parts)
            )]
            
        except Exception as e:
            logger.error(f"Error getting 1D profile results: {str(e)}")
            return [TextContent(
                type="text",
                text=f"Error getting 1D profile results: {str(e)}"
            )]

    elif name == "compare_1d_profiles":
        try:
            project_path = arguments.get("project_path")
            plan1_name = arguments["plan1_name"]
            plan2_name = arguments["plan2_name"]
            river_name = arguments.get("river_name")
            reach_name = arguments.get("reach_name")
            
            # Get or initialize project
            ras = get_or_init_project(project_path)
            
            # Get HDF paths for both plans
            plan1_hdf_path = get_plan_hdf_path(ras, plan1_name)
            plan2_hdf_path = get_plan_hdf_path(ras, plan2_name)
            
            if not plan1_hdf_path:
                return [TextContent(
                    type="text",
                    text=f"Error: Plan '{plan1_name}' not found or has no results HDF file"
                )]
            
            if not plan2_hdf_path:
                return [TextContent(
                    type="text",
                    text=f"Error: Plan '{plan2_name}' not found or has no results HDF file"
                )]
            
            response_parts = [
                f"1D Profile Comparison",
                f"Plan 1 (Baseline): {plan1_name}",
                f"Plan 2 (Comparison): {plan2_name}",
            ]
            
            if river_name:
                response_parts.append(f"River: {river_name}")
            if reach_name:
                response_parts.append(f"Reach: {reach_name}")
            
            response_parts.append("=" * 80)
            
            try:
                # Get cross-section data for both plans
                xsec_data1 = HdfResultsXsec.get_xsec_timeseries(plan1_hdf_path)
                xsec_data2 = HdfResultsXsec.get_xsec_timeseries(plan2_hdf_path)
                
                # Format results for both plans
                profile_df1 = format_profile_results(xsec_data1, river_name, reach_name)
                profile_df2 = format_profile_results(xsec_data2, river_name, reach_name)
                
                if profile_df1.empty and profile_df2.empty:
                    response_parts.append("\nNo cross-section data found for the specified criteria")
                else:
                    # Merge the dataframes on River, Reach, Station with outer join
                    comparison_df = pd.merge(
                        profile_df1[['River', 'Reach', 'Station', 'Max WSEL']],
                        profile_df2[['River', 'Reach', 'Station', 'Max WSEL']],
                        on=['River', 'Reach', 'Station'],
                        how='outer',
                        suffixes=('_Plan1', '_Plan2')
                    )
                    
                    # Calculate difference only where both values exist
                    comparison_df['WSEL_Difference'] = pd.Series(dtype='float64')
                    mask = comparison_df['Max WSEL_Plan1'].notna() & comparison_df['Max WSEL_Plan2'].notna()
                    comparison_df.loc[mask, 'WSEL_Difference'] = (
                        comparison_df.loc[mask, 'Max WSEL_Plan1'] - 
                        comparison_df.loc[mask, 'Max WSEL_Plan2']
                    )
                    
                    # Sort by River, Reach, Station
                    comparison_df.sort_values(['River', 'Reach', 'Station'], inplace=True)
                    
                    # Replace NaN with blank strings for display
                    display_df = comparison_df.copy()
                    display_df = display_df.fillna('')
                    
                    # Round numeric values for display
                    for col in ['Max WSEL_Plan1', 'Max WSEL_Plan2', 'WSEL_Difference']:
                        if col in display_df.columns:
                            # Only round numeric values, leave blanks as blanks
                            mask_numeric = display_df[col] != ''
                            if mask_numeric.any():
                                display_df.loc[mask_numeric, col] = pd.to_numeric(display_df.loc[mask_numeric, col]).round(3)
                    
                    response_parts.append(dataframe_to_text(display_df, "PROFILE COMPARISON"))
                    
                    # Calculate statistics
                    n_plan1_only = (comparison_df['Max WSEL_Plan1'].notna() & comparison_df['Max WSEL_Plan2'].isna()).sum()
                    n_plan2_only = (comparison_df['Max WSEL_Plan1'].isna() & comparison_df['Max WSEL_Plan2'].notna()).sum()
                    n_matching = mask.sum()
                    
                    response_parts.append("\nSUMMARY STATISTICS:")
                    response_parts.append(f"  Total cross sections: {len(comparison_df)}")
                    response_parts.append(f"  Stations in Plan 1 only: {n_plan1_only}")
                    response_parts.append(f"  Stations in Plan 2 only: {n_plan2_only}")
                    response_parts.append(f"  Matching stations: {n_matching}")
                    
                    # Statistics for matching stations only
                    if n_matching > 0:
                        diff_values = comparison_df.loc[mask, 'WSEL_Difference']
                        response_parts.append(f"\n  Statistics for matching stations:")
                        response_parts.append(f"    Mean Difference: {diff_values.mean():.3f} ft")
                        response_parts.append(f"    Max Increase (Plan1 > Plan2): {diff_values.max():.3f} ft")
                        response_parts.append(f"    Max Decrease (Plan1 < Plan2): {diff_values.min():.3f} ft")
                        response_parts.append(f"    Std Deviation: {diff_values.std():.3f} ft")
                        
                        # Find location of max absolute difference
                        abs_diff = diff_values.abs()
                        max_abs_idx = abs_diff.idxmax()
                        max_abs_row = comparison_df.loc[max_abs_idx]
                        response_parts.append(f"    Max |Difference|: {abs_diff.max():.3f} ft at Station {max_abs_row['Station']}")
                
            except Exception as e:
                response_parts.append(f"\nError comparing profiles: {str(e)}")
            
            return [TextContent(
                type="text",
                text="\n".join(response_parts)
            )]
            
        except Exception as e:
            logger.error(f"Error comparing 1D profiles: {str(e)}")
            return [TextContent(
                type="text",
                text=f"Error comparing 1D profiles: {str(e)}"
            )]
    
    elif name == "plot_1d_profile":
        try:
            project_path = arguments.get("project_path")
            plan_name = arguments["plan_name"]
            river_name = arguments.get("river_name")
            reach_name = arguments.get("reach_name")
            
            # Get or initialize project
            ras = get_or_init_project(project_path)
            
            # Get plan HDF path
            plan_hdf_path = get_plan_hdf_path(ras, plan_name)
            if not plan_hdf_path:
                return [TextContent(
                    type="text",
                    text=f"Error: Plan '{plan_name}' not found or has no results HDF file"
                )]
            
            try:
                # Get cross-section data
                xsec_data = HdfResultsXsec.get_xsec_timeseries(plan_hdf_path)
                profile_df = format_profile_results(xsec_data, river_name, reach_name)
                
                if profile_df.empty:
                    return [TextContent(
                        type="text",
                        text="No cross-section data found for the specified criteria"
                    )]
                
                # Create plot
                img_buffer = plot_profile_results(profile_df, plan_name, river_name, reach_name)
                
                # Convert to base64
                img_base64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
                
                # Return as markdown image
                return [TextContent(
                    type="text",
                    text=f"![1D Profile Plot](data:image/png;base64,{img_base64})\n\n"
                         f"Profile plot for Plan: {plan_name}"
                         f"{f' - River: {river_name}' if river_name else ''}"
                         f"{f' - Reach: {reach_name}' if reach_name else ''}"
                )]
                
            except Exception as e:
                return [TextContent(
                    type="text",
                    text=f"Error creating profile plot: {str(e)}"
                )]
            
        except Exception as e:
            logger.error(f"Error plotting 1D profile: {str(e)}")
            return [TextContent(
                type="text",
                text=f"Error plotting 1D profile: {str(e)}"
            )]

    elif name == "plot_1d_profile_comparison":
        try:
            project_path = arguments.get("project_path")
            plan1_name = arguments["plan1_name"]
            plan2_name = arguments["plan2_name"]
            river_name = arguments.get("river_name")
            reach_name = arguments.get("reach_name")
            
            # Get or initialize project
            ras = get_or_init_project(project_path)
            
            # Get HDF paths for both plans
            plan1_hdf_path = get_plan_hdf_path(ras, plan1_name)
            plan2_hdf_path = get_plan_hdf_path(ras, plan2_name)
            
            if not plan1_hdf_path:
                return [TextContent(
                    type="text",
                    text=f"Error: Plan '{plan1_name}' not found or has no results HDF file"
                )]
            
            if not plan2_hdf_path:
                return [TextContent(
                    type="text",
                    text=f"Error: Plan '{plan2_name}' not found or has no results HDF file"
                )]
            
            try:
                # Get cross-section data for both plans
                xsec_data1 = HdfResultsXsec.get_xsec_timeseries(plan1_hdf_path)
                xsec_data2 = HdfResultsXsec.get_xsec_timeseries(plan2_hdf_path)
                
                # Format results
                profile_df1 = format_profile_results(xsec_data1, river_name, reach_name)
                profile_df2 = format_profile_results(xsec_data2, river_name, reach_name)
                
                if profile_df1.empty and profile_df2.empty:
                    return [TextContent(
                        type="text",
                        text="No cross-section data found for the specified criteria"
                    )]
                
                # Merge for comparison with outer join
                comparison_df = pd.merge(
                    profile_df1[['River', 'Reach', 'Station', 'Max WSEL']],
                    profile_df2[['River', 'Reach', 'Station', 'Max WSEL']],
                    on=['River', 'Reach', 'Station'],
                    how='outer',
                    suffixes=('_Plan1', '_Plan2')
                )
                
                # Try to add thalweg data if geometry is available
                if river_name and reach_name:
                    try:
                        # Get geometry HDF path from first plan
                        geom_number = ras.plan_df.loc[ras.plan_df['plan_number'] == plan1_name, 'Geom'].values[0]
                        geom_hdf_path = ras.project_folder / f"{ras.project_name}.g{geom_number.zfill(2)}.hdf"
                        
                        if geom_hdf_path.exists():
                            thalweg_data = get_thalweg_from_geometry(geom_hdf_path, river_name, reach_name)
                            if thalweg_data:
                                comparison_df['Thalweg'] = comparison_df['Station'].map(thalweg_data)
                    except Exception as e:
                        logger.debug(f"Could not add thalweg data: {str(e)}")
                
                # Sort by station
                comparison_df.sort_values(['River', 'Reach', 'Station'], inplace=True)
                
                # Create plot
                img_buffer = plot_profile_comparison(
                    comparison_df, plan1_name, plan2_name, river_name, reach_name
                )
                
                # Convert to base64
                img_base64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
                
                # Calculate summary statistics
                both_mask = comparison_df['Max WSEL_Plan1'].notna() & comparison_df['Max WSEL_Plan2'].notna()
                summary_lines = []
                
                if both_mask.any():
                    diff_values = comparison_df.loc[both_mask, 'Max WSEL_Plan1'] - comparison_df.loc[both_mask, 'Max WSEL_Plan2']
                    n_plan1_only = (comparison_df['Max WSEL_Plan1'].notna() & comparison_df['Max WSEL_Plan2'].isna()).sum()
                    n_plan2_only = (comparison_df['Max WSEL_Plan1'].isna() & comparison_df['Max WSEL_Plan2'].notna()).sum()
                    n_matching = both_mask.sum()
                    
                    summary_lines.extend([
                        "",
                        "**Station Summary:**",
                        f"- Stations in Plan {plan1_name} only: {n_plan1_only}",
                        f"- Stations in Plan {plan2_name} only: {n_plan2_only}",
                        f"- Matching stations: {n_matching}",
                        "",
                        "**Statistics (for matching stations):**",
                        f"- Mean Difference: {diff_values.mean():.3f} ft",
                        f"- Max Increase (Plan1 > Plan2): {diff_values.max():.3f} ft",
                        f"- Max Decrease (Plan1 < Plan2): {diff_values.min():.3f} ft",
                        f"- Std Deviation: {diff_values.std():.3f} ft"
                    ])
                    
                    # Find location of max absolute difference
                    abs_diff = diff_values.abs()
                    if len(abs_diff) > 0:
                        max_idx = abs_diff.idxmax()
                        max_row = comparison_df.loc[max_idx]
                        summary_lines.append(f"- Max |Difference|: {abs_diff.max():.3f} ft at Station {max_row['Station']}")
                
                # Build description
                description_parts = [
                    f"Profile comparison - {plan1_name} vs {plan2_name}"
                ]
                if river_name:
                    description_parts.append(f"River: {river_name}")
                if reach_name:
                    description_parts.append(f"Reach: {reach_name}")
                
                # Return as markdown image with summary
                return [TextContent(
                    type="text",
                    text=f"![1D Profile Comparison](data:image/png;base64,{img_base64})\n\n" +
                         " - ".join(description_parts) + "\n" +
                         "\n".join(summary_lines)
                )]
                
            except Exception as e:
                return [TextContent(
                    type="text",
                    text=f"Error creating comparison plot: {str(e)}"
                )]
            
        except Exception as e:
            logger.error(f"Error plotting profile comparison: {str(e)}")
            return [TextContent(
                type="text",
                text=f"Error plotting profile comparison: {str(e)}"
            )]
    
    elif name == "get_1d_xsec_timeseries":
        try:
            project_path = arguments.get("project_path")
            plan_name = arguments["plan_name"]
            river_name = arguments["river_name"]
            reach_name = arguments["reach_name"]
            station = arguments["station"]
            
            # Get or initialize project
            ras = get_or_init_project(project_path)
            
            # Get plan HDF path
            plan_hdf_path = get_plan_hdf_path(ras, plan_name)
            if not plan_hdf_path:
                return [TextContent(
                    type="text",
                    text=f"Error: Plan '{plan_name}' not found or has no results HDF file"
                )]
            
            response_parts = [
                f"1D Cross Section Time Series Results",
                f"Plan: {plan_name}",
                f"River: {river_name}",
                f"Reach: {reach_name}",
                f"Station: {station}",
                "=" * 80
            ]
            
            try:
                # Get cross-section time series data
                xsec_data = HdfResultsXsec.get_xsec_timeseries(plan_hdf_path)
                
                # Filter for specific cross section
                # Find the cross section that matches our criteria
                matching_indices = []
                for i, xs_name in enumerate(xsec_data.cross_section.values):
                    if (xsec_data.River.values[i] == river_name and
                        xsec_data.Reach.values[i] == reach_name and
                        xsec_data.Station.values[i] == str(station)):
                        matching_indices.append(i)
                
                if not matching_indices:
                    response_parts.append(f"\nNo data found for the specified cross section.")
                    response_parts.append(f"Please verify the river, reach, and station values.")
                else:
                    # Use the first matching index
                    xs_idx = matching_indices[0]
                    
                    # Extract time series for all variables
                    time_values = pd.to_datetime(xsec_data.time.values)
                    
                    # Create DataFrame with time series results
                    results_data = {
                        'Time': time_values
                    }
                    
                    # Add each variable if it exists
                    for var_name in ['Water Surface', 'Total Velocity', 'Channel Velocity', 
                                   'Flow', 'Lateral Flow', 'Flow Area', 'Top Width']:
                        if var_name in xsec_data.data_vars:
                            results_data[var_name] = xsec_data[var_name].values[:, xs_idx]
                    
                    results_df = pd.DataFrame(results_data)
                    results_df.set_index('Time', inplace=True)
                    
                    # Add summary statistics
                    response_parts.append("\nTIME SERIES SUMMARY:")
                    for col in results_df.columns:
                        response_parts.append(f"\n{col}:")
                        response_parts.append(f"  Max: {results_df[col].max():.3f}")
                        response_parts.append(f"  Min: {results_df[col].min():.3f}")
                        response_parts.append(f"  Mean: {results_df[col].mean():.3f}")
                        
                        # Find time of max
                        max_time = results_df[col].idxmax()
                        response_parts.append(f"  Time of Max: {max_time}")
                    
                    # Show first and last few time steps
                    response_parts.append("\nFIRST 5 TIME STEPS:")
                    response_parts.append(results_df.head().to_string())
                    
                    response_parts.append("\nLAST 5 TIME STEPS:")
                    response_parts.append(results_df.tail().to_string())
                    
                    response_parts.append(f"\nTotal time steps: {len(results_df)}")
                    
            except Exception as e:
                response_parts.append(f"\nError extracting time series data: {str(e)}")
            
            return [TextContent(
                type="text",
                text="\n".join(response_parts)
            )]
            
        except Exception as e:
            logger.error(f"Error getting cross section time series: {str(e)}")
            return [TextContent(
                type="text",
                text=f"Error getting cross section time series: {str(e)}"
            )]
    
    # Commented out for now
    # elif name == "get_hdf_structure":
    #     ...
    
    else:
        return [TextContent(
            type="text",
            text=f"Error: Unknown tool: {name}"
        )]

async def main():
    """Main entry point for the MCP server."""
    async with stdio_server() as (read_stream, write_stream):
        logger.info("Starting HEC-RAS MCP Server...")
        
        await server.run(
            read_stream,
            write_stream,
            InitializationOptions(
                server_name="hecras-mcp-server",
                server_version="0.3.0",
                capabilities=server.get_capabilities(
                    notification_options=NotificationOptions(),
                    experimental_capabilities={},
                ),
            ),
        )

if __name__ == "__main__":
    asyncio.run(main())
==================================================

File: c:\GH\ras-commander-mcp\test_server.py
==================================================
#!/usr/bin/env python3
"""
Direct test of the improved MCP server functionality
"""

import asyncio
import sys
from pathlib import Path

# Add current directory to path
sys.path.insert(0, str(Path(__file__).parent))

# Import the server components
from server import handle_list_tools, handle_call_tool

async def test_server():
    """Test the server functions directly"""
    print("Testing Improved HEC-RAS MCP Server...")
    print("=" * 60)
    
    # Test listing tools
    print("\n1. Testing tool listing...")
    tools = await handle_list_tools()
    print(f"Found {len(tools)} tools:")
    for tool in tools:
        print(f"  - {tool.name}: {tool.description}")
    
    # Get the Muncie test data path
    muncie_path = Path(__file__).parent / "testdata" / "Muncie"
    muncie_path = muncie_path.resolve()
    
    # Test project summary (initializes context)
    print("\n2. Testing get_ras_projectsummary with Muncie data...")
    try:
        result = await handle_call_tool("get_ras_projectsummary", {
            "project_path": str(muncie_path),
            "include_boundaries": False
        })
        print("Result:")
        print(result[0].text[:1000] + "..." if len(result[0].text) > 1000 else result[0].text)
    except Exception as e:
        print(f"Error: {e}")
    
    # Test getting all plans (no project_path needed after initialization)
    print("\n3. Testing get_ras_planinfo (all plans)...")
    try:
        result = await handle_call_tool("get_ras_planinfo", {})
        print("Result:")
        print(result[0].text[:500] + "..." if len(result[0].text) > 500 else result[0].text)
    except Exception as e:
        print(f"Error: {e}")
    
    # Test getting specific plan
    print("\n4. Testing get_ras_planinfo (specific plan)...")
    try:
        result = await handle_call_tool("get_ras_planinfo", {
            "plan_number": "04"
        })
        print("Result:")
        print(result[0].text[:500] + "..." if len(result[0].text) > 500 else result[0].text)
    except Exception as e:
        print(f"Error: {e}")
    
    # Test infiltration data
    print("\n5. Testing get_ras_infiltration...")
    try:
        result = await handle_call_tool("get_ras_infiltration", {
            "significant_threshold": 5.0
        })
        print("Result:")
        print(result[0].text[:800] + "..." if len(result[0].text) > 800 else result[0].text)
    except Exception as e:
        print(f"Error: {e}")
    
    # Test results summary
    print("\n6. Testing get_ras_resultssummary...")
    try:
        result = await handle_call_tool("get_ras_resultssummary", {
            "plan_name": "04"  # Using plan number
        })
        print("Result:")
        print(result[0].text[:1000] + "..." if len(result[0].text) > 1000 else result[0].text)
    except Exception as e:
        print(f"Error: {e}")
    
    # Test projection info (no path needed, uses project context)
    print("\n7. Testing get_projection_info (auto-detect HDF)...")
    try:
        result = await handle_call_tool("get_projection_info", {})
        print("Result:")
        print(result[0].text)
    except Exception as e:
        print(f"Error: {e}")
    
    print("\n" + "=" * 60)
    print("Testing context persistence...")
    print("=" * 60)
    
    # Test that we can call tools without project_path after initialization
    print("\n8. Testing get_ras_projectsummary without project_path...")
    try:
        result = await handle_call_tool("get_ras_projectsummary", {
            "include_boundaries": True
        })
        print("Success! Context persisted - got project summary without specifying path")
        print(f"First 200 chars: {result[0].text[:200]}...")
    except Exception as e:
        print(f"Error: {e}")
    
    # Test new 1D profile tools
    print("\n" + "=" * 60)
    print("Testing NEW 1D Profile Tools...")
    print("=" * 60)
    
    # Test 1D profile results
    print("\n9. Testing get_1d_profile_results...")
    try:
        result = await handle_call_tool("get_1d_profile_results", {
            "plan_name": "04"
        })
        print("Result:")
        print(result[0].text[:1000] + "..." if len(result[0].text) > 1000 else result[0].text)
    except Exception as e:
        print(f"Error: {e}")
    
    # Test 1D profile comparison
    print("\n10. Testing compare_1d_profiles...")
    try:
        result = await handle_call_tool("compare_1d_profiles", {
            "plan1_name": "03",
            "plan2_name": "04"
        })
        print("Result:")
        print(result[0].text[:1500] + "..." if len(result[0].text) > 1500 else result[0].text)
    except Exception as e:
        print(f"Error: {e}")
    
    # Test new plotting tools
    print("\n" + "=" * 60)
    print("Testing NEW Plotting Tools...")
    print("=" * 60)
    
    # Test 1D profile plotting
    print("\n11. Testing plot_1d_profile...")
    try:
        result = await handle_call_tool("plot_1d_profile", {
            "plan_name": "04"
        })
        print("Result:")
        # For plotting tools, we expect base64 image data
        if "![1D Profile Plot]" in result[0].text:
            print("SUCCESS: Plot generated (base64 image data)")
            print("Description:", result[0].text.split('\n\n')[1] if '\n\n' in result[0].text else "Profile plot")
        else:
            print(result[0].text[:500] + "..." if len(result[0].text) > 500 else result[0].text)
    except Exception as e:
        print(f"Error: {e}")
    
    # Test 1D profile comparison plotting
    print("\n12. Testing plot_1d_profile_comparison...")
    try:
        result = await handle_call_tool("plot_1d_profile_comparison", {
            "plan1_name": "03",
            "plan2_name": "04"
        })
        print("Result:")
        # For plotting tools, we expect base64 image data
        if "![1D Profile Comparison]" in result[0].text:
            print("SUCCESS: Comparison plot generated (base64 image data)")
            # Extract and show summary stats if present
            lines = result[0].text.split('\n')
            for line in lines:
                if 'Summary Statistics:' in line or line.strip().startswith('- '):
                    print(line)
        else:
            print(result[0].text[:500] + "..." if len(result[0].text) > 500 else result[0].text)
    except Exception as e:
        print(f"Error: {e}")

if __name__ == "__main__":
    asyncio.run(test_server())
==================================================

File: c:\GH\ras-commander-mcp\TRADEMARKS.md
==================================================
# TRADEMARKS.md

## Project Trademark

"RAS Commander"™ is an unregistered trademark of CLB Engineering Corporation. The mark is used solely to identify this open‑source software project and related materials.

## Third‑Party Trademarks

* **HEC‑RAS**™ is a trademark of the U.S. Army Corps of Engineers (USACE) Hydrologic Engineering Center (HEC).


* This "HEC-RAS MCP" is an independent open source projects and is **not** affiliated with, endorsed by, or sponsored by USACE or HEC.

All other product names, logos, and brands mentioned in this repository are property of their respective owners and are used for identification purposes only.

## Naming & Compliance Policy

We respect the trademark rights and license terms of USACE and all other rights holders. If USACE any other rightful owner—objects to our use of "HEC-RAS" in the project name, we will promptly rename the project and update all references within **30 days** of receiving written notice.

## Contact

To raise any trademark or licensing concerns, please open an issue in this repository.
==================================================

