diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..d653aef --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +# Include all JSON files in the resources directory and its subdirectories +recursive-include src/pyromb/resources *.json diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..3e30280 --- /dev/null +++ b/build.bat @@ -0,0 +1,61 @@ +@echo off +setlocal + +REM Verify Python and Pip paths +echo Using Python from: +where python +echo Using pip from: +where pip + +REM Check setuptools version +python -m pip show setuptools + +REM Upgrade setuptools and wheel +echo Upgrading setuptools and wheel... +python -m pip install --upgrade setuptools wheel + +REM Define the directory where the package will be stored +set "PACKAGE_DIR=%~dp0dist" +echo Package directory: %PACKAGE_DIR% + +REM Navigate to the directory containing the setup.py script +cd /d "%~dp0" + +REM Clean previous builds +if exist "%PACKAGE_DIR%" ( + echo Cleaning previous builds... + rmdir /s /q "%PACKAGE_DIR%" +) + +REM Build the source distribution and wheel using setuptools +echo Building the package using setuptools... +python setup.py sdist bdist_wheel + +REM Check if the build was successful +if %ERRORLEVEL% neq 0 ( + echo Build failed. Please check the setup.py and pyproject.toml for errors. + endlocal + pause + goto :EOF +) + +REM Create the dist directory if it doesn't exist +if not exist "%PACKAGE_DIR%" mkdir "%PACKAGE_DIR%" + +REM Move the generated .tar.gz and .whl files to the desired folder +echo Moving built packages to %PACKAGE_DIR%... +move /Y "dist\*.tar.gz" "%PACKAGE_DIR%" >nul +move /Y "dist\*.whl" "%PACKAGE_DIR%" >nul + +REM Check if the move was successful +if %ERRORLEVEL% neq 0 ( + echo Failed to move the package to the destination folder. + endlocal + pause + goto :EOF +) + +echo Package created and moved to %PACKAGE_DIR% successfully. +endlocal +pause +goto :EOF diff --git a/installer_osgeo4w.bat b/installer_osgeo4w.bat new file mode 100644 index 0000000..1afab18 --- /dev/null +++ b/installer_osgeo4w.bat @@ -0,0 +1,41 @@ +@echo off +setlocal + +REM THIS DIDN'T SEEM TO WORK, LIKELY USER ERROR. + +REM Activate OSGeo4W environment +call "C:\OSGEO4W\bin\o4w_env.bat" + +REM Define the directory where the built package is stored +set "PACKAGE_DIR=%~dp0dist" +echo Package directory: %PACKAGE_DIR% + +REM Find the latest version of the package +for /f "delims=" %%i in ('dir /b /o-n "%PACKAGE_DIR%\pyromb-*.whl"') do ( + set "LATEST_PACKAGE=%%i" + goto found +) + +:found +if "%LATEST_PACKAGE%"=="" ( + echo No package found in the directory. + pause + endlocal + goto :EOF +) + +echo Installing or updating "%LATEST_PACKAGE%" + +REM Install or update the package using pip from the OSGeo4W environment +pip install --upgrade "%PACKAGE_DIR%\%LATEST_PACKAGE%" + +REM Check if the installation was successful +if %ERRORLEVEL% equ 0 ( + echo Installation completed successfully. +) else ( + echo Installation failed. Please check the path and try again. +) + +endlocal +pause +goto :EOF diff --git a/pyproject.toml b/pyproject.toml index bd67230..74012e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,26 +1,25 @@ [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/pyromb"] - -[tool.hatch.build] -only-packages = true -exclude = [ - ".conda", -] +requires = ["setuptools>=61.0.0", "wheel"] +build-backend = "setuptools.build_meta" [project] name = "pyromb" -version = "0.2.0" +version = "0.2.2" authors = [ - { name="Tom Norman", email="tom@normcosystems.com" } + { name = "Tom Norman", email = "tom@normcosystems.com" } ] description = "Runoff Model Builder (Pyromb) is a package used for building RORB and WBNM control files from catchment diagrams built from ESRI shapefiles. Its primary use is in the QGIS plugin Runoff Model: RORB and Runoff Model: WBNM" readme = "README.md" requires-python = ">=3.9" +dependencies = [ + "shapely", + "pyshp", + "matplotlib", + "numpy", + # Add any additional dependencies here +] + classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", @@ -29,4 +28,4 @@ classifiers = [ [project.urls] "Homepage" = "https://github.com/norman-tom/pyromb" -"Bug Tracker" = "https://github.com/norman-tom/pyromb/issues" \ No newline at end of file +"Bug Tracker" = "https://github.com/norman-tom/pyromb/issues" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b362768 --- /dev/null +++ b/setup.py @@ -0,0 +1,29 @@ +from setuptools import setup, find_packages +import os + +# Read the long description from README.md +current_dir = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(current_dir, "README.md"), "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="pyromb", + version="0.2.1", + packages=find_packages( + where="src", exclude=["*.tests", "*.tests.*", "tests.*", "tests"] + ), + package_dir={"": "src"}, + author="Tom Norman", + author_email="tom@normcosystems.com", + description="Runoff Model Builder (Pyromb) is a package used for building RORB and WBNM control files from catchment diagrams built from ESRI shapefiles. Its primary use is in the QGIS plugin Runoff Model: RORB and Runoff Model: WBNM", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/norman-tom/pyromb", + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + python_requires=">=3.12", + include_package_data=True, # Ensures inclusion of files specified in MANIFEST.in +) diff --git a/src/app.py b/src/app.py index db1ecb9..a172286 100644 --- a/src/app.py +++ b/src/app.py @@ -1,62 +1,196 @@ +# app.py import os import pyromb from plot_catchment import plot_catchment import shapefile as sf +from shapely.geometry import shape as shapely_shape +import logging +import json +from typing import Any +from pyromb.core.geometry.shapefile_validation import ( + validate_shapefile_fields, + validate_shapefile_geometries, + validate_confluences_out_field, +) + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") DIR = os.path.dirname(__file__) -REACH_PATH = os.path.join(DIR, '../data', 'reaches.shp') -BASIN_PATH = os.path.join(DIR, '../data', 'basins.shp') -CENTROID_PATH = os.path.join(DIR, '../data', 'centroids.shp') -CONFUL_PATH = os.path.join(DIR, '../data', 'confluences.shp') +REACH_PATH = os.path.join(DIR, "../data", "reaches.shp") +BASIN_PATH = os.path.join(DIR, "../data", "basins.shp") +CENTROID_PATH = os.path.join(DIR, "../data", "centroids.shp") +CONFUL_PATH = os.path.join(DIR, "../data", "confluences.shp") + +# Load expected fields from JSON file +with open(os.path.join(DIR, r"pyromb\resources", r"expected_fields.json"), "r") as f: + EXPECTED_FIELDS_JSON = json.load(f) + +# Convert JSON to the required dictionary format +EXPECTED_FIELDS = { + key: [(field["name"], field["type"]) for field in fields] + for key, fields in EXPECTED_FIELDS_JSON.items() +} + class SFVectorLayer(sf.Reader, pyromb.VectorLayer): """ Wrap the shapefile.Reader() with the necessary interface - to work with the builder. + to work with the builder. """ + def __init__(self, path) -> None: super().__init__(path) + # Extract field names, skipping the first DeletionFlag field + self.field_names = [field[0] for field in self.fields[1:]] + # Precompute Shapely geometries for all shapes + self.shapely_geometries = [ + shapely_shape(self.shape(i).__geo_interface__) for i in range(len(self)) + ] def geometry(self, i) -> list: return self.shape(i).points - + + def shapely_geometry(self, i): + """ + Return the Shapely geometry for the ith shape. + """ + return self.shapely_geometries[i] + def record(self, i) -> dict: - return super().record(i) - + """ + Return a dictionary mapping field names to their corresponding values. + """ + rec = super().record(i) + return dict(zip(self.field_names, rec)) + def __len__(self) -> int: return super().__len__() -def main(): - ### Config ### - plot = False # Set True of you want the catchment to be plotted - model = pyromb.RORB() # Select your hydrology model, either pyromb.RORB() or pyromb.WBNM() + +def main( + reach_path: str | None = None, + basin_path: str | None = None, + centroid_path: str | None = None, + confluence_path: str | None = None, + output_name: str | None = None, + plot: bool = False, + model: Any | None = None, +) -> None: + """ + Main function to build and process catchment data. + + Parameters + ---------- + reach_path : str + Path to the reaches shapefile. + basin_path : str + Path to the basins shapefile. + centroid_path : str + Path to the centroids shapefile. + confluence_path : str + Path to the confluences shapefile. + output_name : str + Name of the output file. + plot : bool + Whether to plot the catchment. + model : pyromb.Model + The hydrology model to use. + """ + # Set default paths if not provided + reach_path = reach_path or REACH_PATH + basin_path = basin_path or BASIN_PATH + centroid_path = centroid_path or CENTROID_PATH + confluence_path = confluence_path or CONFUL_PATH + model = model or pyromb.RORB() + if isinstance(model, pyromb.RORB): + output_name = output_name or os.path.join(DIR, "../vector.catg") + else: + output_name = output_name or os.path.join(DIR, "../runfile.wbnm") + model = model or pyromb.RORB() ### Build Catchment Objects ### - # Vector layers - reach_vector = SFVectorLayer(REACH_PATH) - basin_vector = SFVectorLayer(BASIN_PATH) - centroid_vector = SFVectorLayer(CENTROID_PATH) - confluence_vector = SFVectorLayer(CONFUL_PATH) - # Create the builder. + # Vector layers + reach_vector = SFVectorLayer(reach_path) + basin_vector = SFVectorLayer(basin_path) + centroid_vector = SFVectorLayer(centroid_path) + confluence_vector = SFVectorLayer(confluence_path) + + # Validate shapefile fields + validation_reaches = validate_shapefile_fields( + reach_vector, "Reaches", EXPECTED_FIELDS["reaches"] + ) + validation_basins = validate_shapefile_fields( + basin_vector, "Basins", EXPECTED_FIELDS["basins"] + ) + validation_centroids = validate_shapefile_fields( + centroid_vector, "Centroids", EXPECTED_FIELDS["centroids"] + ) + validation_confluences = validate_shapefile_fields( + confluence_vector, "Confluences", EXPECTED_FIELDS["confluences"] + ) + + validate_confluences_out = validate_confluences_out_field( + confluence_vector, "Confluences" + ) + + # Validate shapefile geometries + validation_geometries_reaches = validate_shapefile_geometries( + reach_vector, "Reaches" + ) + validation_geometries_basins = validate_shapefile_geometries(basin_vector, "Basins") + validation_geometries_centroids = validate_shapefile_geometries( + centroid_vector, "Centroids" + ) + validation_geometries_confluences = validate_shapefile_geometries( + confluence_vector, "Confluences" + ) + + # Decide whether to proceed based on validation + # Decide whether to proceed based on validation + if not all( + [ + validation_reaches, + validation_basins, + validation_centroids, + validation_confluences, + validate_confluences_out, + validation_geometries_reaches, + validation_geometries_basins, + validation_geometries_centroids, + validation_geometries_confluences, + ] + ): + logging.warning( + "One or more shapefiles failed validation. Proceeding with caution." + ) + else: + print("Shapefiles passed initial validation check.") + + # Create the builder. builder = pyromb.Builder() # Build each element as per the vector layer. tr = builder.reach(reach_vector) tc = builder.confluence(confluence_vector) tb = builder.basin(centroid_vector, basin_vector) - - ### Create the catchment ### + + ### Create the catchment ### catchment = pyromb.Catchment(tc, tb, tr) connected = catchment.connect() # Create the traveller and pass the catchment. traveller = pyromb.Traveller(catchment) - + ### Write ### # Control vector to file with a call to the Traveller's getVector method - with open(os.path.join(DIR, '../vector.catg' if isinstance(model, pyromb.RORB) else '../runfile.wbn'), 'w') as f: + output_path = output_name + with open(output_path, "w") as f: f.write(traveller.getVector(model)) - + print(f"Output written to {output_name}") + ### Plot the catchment ###. - if plot: plot_catchment(connected, tr, tc, tb) + if plot: + plot_catchment(connected, tr, tc, tb) + -if (__name__ == "__main__"): - main() \ No newline at end of file +if __name__ == "__main__": + main() diff --git a/src/app_testing.py b/src/app_testing.py new file mode 100644 index 0000000..dbeac23 --- /dev/null +++ b/src/app_testing.py @@ -0,0 +1,67 @@ +# app_testing.py +import os +import pyromb +from plot_catchment import plot_catchment +import shapefile as sf +from shapely.geometry import shape as shapely_shape +import logging + +from app import ( + main, + SFVectorLayer, +) # Import main function and SFVectorLayer from app.py + +# Configure logging (optional: configure in app.py instead) +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + +# Define testing paths +TEST_DIR = r"Q:/qgis/" +TEST_REACH_PATH = os.path.join(TEST_DIR, "BC_reaches.shp") +TEST_BASIN_PATH = os.path.join(TEST_DIR, "BC_basins.shp") +TEST_CENTROID_PATH = os.path.join(TEST_DIR, "BC_centroids.shp") +TEST_CONFUL_PATH = os.path.join(TEST_DIR, "BC_confluences.shp") + +TEST_OUTPUT_PATH = r"Q:\qgis" +TEST_OUTPUT_NAME = r"testing_mod_python2.catg" +TEST_OUT = os.path.join(TEST_OUTPUT_PATH, TEST_OUTPUT_NAME) + + +def print_shapefile_fields(shp, name): + fields = shp.fields[1:] # skip DeletionFlag + field_names = [field[0] for field in fields] + print(f"{name} fields: {field_names}") + + +def test_main(): + ### Config ### + plot = False # Set to True if you want the catchment to be plotted + model = pyromb.RORB() + # Select your hydrology model, either pyromb.RORB() or pyromb.WBNM() + + ### Build Catchment Objects ### + # Vector layers with test paths + reach_vector = SFVectorLayer(TEST_REACH_PATH) + basin_vector = SFVectorLayer(TEST_BASIN_PATH) + centroid_vector = SFVectorLayer(TEST_CENTROID_PATH) + confluence_vector = SFVectorLayer(TEST_CONFUL_PATH) + + # Print field names (optional, for debugging) + print_shapefile_fields(reach_vector, "Reach") + print_shapefile_fields(basin_vector, "Basin") + print_shapefile_fields(centroid_vector, "Centroid") + print_shapefile_fields(confluence_vector, "Confluence") + + ### Call the main function with test paths and parameters ### + main( + reach_path=TEST_REACH_PATH, + basin_path=TEST_BASIN_PATH, + centroid_path=TEST_CENTROID_PATH, + confluence_path=TEST_CONFUL_PATH, + output_name=TEST_OUT, + plot=plot, + model=model, + ) + + +if __name__ == "__main__": + test_main() diff --git a/src/pyromb/core/attributes/reach.py b/src/pyromb/core/attributes/reach.py index 6e4ae98..65582d0 100644 --- a/src/pyromb/core/attributes/reach.py +++ b/src/pyromb/core/attributes/reach.py @@ -1,12 +1,14 @@ from ..geometry.line import Line from enum import Enum + class ReachType(Enum): NATURAL = 1 UNLINED = 2 LINED = 3 DROWNED = 4 + class Reach(Line): """A Reach object represents a reach as defined in hydrological models. @@ -20,45 +22,50 @@ class Reach(Line): The slope of the reach in m/m """ - def __init__(self, name: str = "", - vector: list = [], - type: ReachType = ReachType.NATURAL, - slope: float = 0.0): + def __init__( + self, + name: str = "", + vector: list = [], + type: ReachType = ReachType.NATURAL, + slope: float = 0.0, + ): super().__init__(vector) self._name: str = name self._type: ReachType = type self._slope: float = slope self._idx: int = 0 - + def __str__(self) -> str: - return "Name: {}\nLength: {}\nType: {}\nSlope: {}".format(self._name, round(self.length(), 3), self._type, self._slope) - + return "Name: {}\nLength: {}\nType: {}\nSlope: {}".format( + self._name, round(self.length(), 3), self._type, self._slope + ) + @property def name(self) -> str: return self._name - + @name.setter def name(self, name: str) -> None: self._name = name - + @property def type(self) -> ReachType: return self._type - + @type.setter def type(self, type: ReachType) -> None: self._type = type @property def slope(self) -> float: - return self._slope - + return self._slope + @slope.setter def slope(self, slope: float) -> None: self._slope = slope def getPoint(self, direction: str): - """ Returns either the upstream or downstream 'ds' point of the reach. + """Returns either the upstream or downstream 'ds' point of the reach. Parameters ---------- @@ -77,9 +84,18 @@ def getPoint(self, direction: str): If direction is not either 'us' or 'ds' """ - if direction == 'us': + if direction == "us": return self._vector[self._idx] - elif direction == 'ds': + elif direction == "ds": return self._vector[self._end - self._idx] else: - raise KeyError("Node direction not properly defines: \n") \ No newline at end of file + raise KeyError("Node direction not properly defines: \n") + + @property + def id(self) -> str: + """Alias for the 'name' attribute.""" + return self._name + + @id.setter + def id(self, value: str) -> None: + self._name = value diff --git a/src/pyromb/core/geometry/shapefile_validation.py b/src/pyromb/core/geometry/shapefile_validation.py new file mode 100644 index 0000000..f14b70b --- /dev/null +++ b/src/pyromb/core/geometry/shapefile_validation.py @@ -0,0 +1,148 @@ +from matplotlib.ft2font import SFNT +from shapely.geometry import shape +from shapely.validation import explain_validity +import shapefile as sf +import logging + + +def validate_shapefile_geometries(shp: sf.Reader, shapefile_name: str) -> bool: + """ + Validate the geometries of a shapefile. + + Parameters + ---------- + shp : shapefile.Reader + The shapefile reader object. + shapefile_name : str + The name of the shapefile (for logging purposes). + + Returns + ------- + bool + True if all geometries are valid, False otherwise. + """ + validation_passed = True + for idx, shp_rec in enumerate(shp.shapes()): + geom = shape(shp_rec.__geo_interface__) + if not geom.is_valid: + validity_reason = explain_validity(geom) + logging.error( + f"Invalid geometry in {shapefile_name} at Shape ID {idx}: {validity_reason}" + ) + validation_passed = False + + if validation_passed: + logging.info(f"All geometries in {shapefile_name} are valid.") + + return validation_passed + + +import logging +import shapefile as sf +from typing import List, Tuple + + +def validate_shapefile_fields( + shp: sf.Reader, shapefile_name: str, expected_fields: List[Tuple[str, str]] +) -> bool: + """ + Validate the fields of a shapefile against expected field names and types. + Additionally, ensure that required fields contain valid data (not None or empty). + + Args: + shp (sf.Reader): Shapefile reader object. + shapefile_name (str): Name of the shapefile for logging purposes. + expected_fields (List[Tuple[str, str]]): List of tuples containing expected field names and their types. + + Returns: + bool: True if all expected fields are present with correct types and contain valid data, False otherwise. + """ + TYPE_MAPPING = { + "C": "Character", + "N": "Numeric", + "F": "Float", + "L": "Logical", + "D": "Date", + "G": "General", + "M": "Memo", + } + + actual_fields = shp.fields[1:] # Skip DeletionFlag field + actual_field_names = [field[0] for field in actual_fields] + actual_field_types = [field[1] for field in actual_fields] + + logging.info(f"\nValidating fields for {shapefile_name}:") + for name, type_code in zip(actual_field_names, actual_field_types): + type_desc = TYPE_MAPPING.get(type_code, "Unknown") + logging.info(f" Field Name: {name}, Type: {type_code} ({type_desc})") + + validation_passed = True + + # Field Name and Type Validation + for exp_field, exp_type in expected_fields: + if exp_field not in actual_field_names: + logging.error(f"Missing expected field '{exp_field}' in {shapefile_name}.") + validation_passed = False + else: + idx = actual_field_names.index(exp_field) + act_type = actual_field_types[idx] + if act_type != exp_type: + type_desc = TYPE_MAPPING.get(act_type, "Unknown") + logging.error( + f"Type mismatch for field '{exp_field}' in {shapefile_name}: " + f"Expected '{exp_type}' ({TYPE_MAPPING.get(exp_type, 'Unknown')}), " + f"Got '{act_type}' ({type_desc})" + ) + validation_passed = False + + # Data Validation: Check for None or Empty Values in Required Fields + if validation_passed: + logging.info(f"Validating data integrity for fields in {shapefile_name}...") + for record_num, record in enumerate(shp.records(), start=1): + for exp_field, _ in expected_fields: + value = record[exp_field] + if value is None or (isinstance(value, str) and not value.strip()): + logging.error( + f"Empty or None value found in field '{exp_field}' " + f"for record {record_num} in {shapefile_name}." + ) + validation_passed = False + if validation_passed: + logging.info(f"All required fields contain valid data in {shapefile_name}.") + + return validation_passed + + +def validate_confluences_out_field(shp: sf.Reader, shapefile_name: str) -> bool: + """ + Validate that the 'out' field in the Confluences shapefile has exactly one '1' and the rest '0'. + + Args: + shp (sf.Reader): Shapefile reader object. + shapefile_name (str): Name of the shapefile for logging purposes. + + Returns: + bool: True if the validation passes, False otherwise. + """ + out_values = [record["out"] for record in shp.records()] + + count_ones = out_values.count(1) + count_zeros = out_values.count(0) + total_records = len(out_values) + + if count_ones != 1: + logging.error( + f"The 'out' field in {shapefile_name} should have exactly one '1'. Found {count_ones}." + ) + return False + + if count_zeros != (total_records - 1): + logging.error( + f"The 'out' field in {shapefile_name} should have {total_records - 1} '0's. Found {count_zeros}." + ) + return False + + logging.info( + f"'out' field validation passed for {shapefile_name}: 1 '1' and {count_zeros} '0's." + ) + return True diff --git a/src/pyromb/core/gis/builder.py b/src/pyromb/core/gis/builder.py index 5d2e9cb..d7595ea 100644 --- a/src/pyromb/core/gis/builder.py +++ b/src/pyromb/core/gis/builder.py @@ -1,23 +1,26 @@ +# builder.py from ..attributes.basin import Basin from ..attributes.confluence import Confluence from ..attributes.reach import Reach from ..attributes.reach import ReachType -from ..geometry.line import pointVector -from ..geometry.point import Point from ..gis.vector_layer import VectorLayer -from ...math import geometry +import logging -class Builder(): +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + +class Builder: """ Build the entities of the catchment. - The Builder is responsible for creating the entities (geometry, attributes) that - the catchment will be built from. Building must take place before the - catchment is connected and traversed. + The Builder is responsible for creating the entities (geometry, attributes) that + the catchment will be built from. Building must take place before the + catchment is connected and traversed. - The objects returned from the Builder are to be passed to the Catcment. + The objects returned from the Builder are to be passed to the Catchment. """ - + def reach(self, reach: VectorLayer) -> list: """Build the reach objects. @@ -29,14 +32,14 @@ def reach(self, reach: VectorLayer) -> list: Returns ------- list - A list of the reache objects. + A list of the reach objects. """ reaches = [] for i in range(len(reach)): s = reach.geometry(i) r = reach.record(i) - reaches.append(Reach(r['id'], s, ReachType(r['t']), r['s'])) + reaches.append(Reach(r["id"], s, ReachType(r["t"]), r["s"])) return reaches def basin(self, centroid: VectorLayer, basin: VectorLayer) -> list: @@ -55,23 +58,43 @@ def basin(self, centroid: VectorLayer, basin: VectorLayer) -> list: A list of the basin objects. """ basins = [] + # Precompute Shapely polygons for all basins + basin_geometries = [basin.shapely_geometry(j) for j in range(len(basin))] + for i in range(len(centroid)): - min = 0 - d = 999 - s = centroid.geometry(i) + centroid_geom = centroid.shapely_geometry(i) + centroid_point = centroid_geom.centroid # Shapely Point object + matching_basins = [] + + # Find all basins that contain the centroid point + for j, basin_geom in enumerate(basin_geometries): + if basin_geom.contains(centroid_point): + matching_basins.append(j) + + if not matching_basins: + logging.warning( + f"Centroid ID {centroid.record(i)['id']} at ({centroid_point.x}, {centroid_point.y}) " + f"is not contained within any basin polygon." + ) + continue # Skip this centroid or handle as needed + + if len(matching_basins) > 1: + logging.error( + f"Centroid ID {centroid.record(i)['id']} at ({centroid_point.x}, {centroid_point.y}) " + f"is contained within multiple basins: {matching_basins}. " + f"Associating with the first matching basin." + ) + + # Associate with the first matching basin + associated_basin_idx = matching_basins[0] + associated_basin_geom = basin_geometries[associated_basin_idx] + # Area in the units of the shapefile's projection + a = associated_basin_geom.area r = centroid.record(i) - p = s[0] - for j in range(len(basin)): - b = basin.geometry(j) - v = b - c = geometry.polygon_centroid(pointVector(v)) - l = geometry.length([Point(p[0], p[1]), c]) - if l < d: - d = l - min = j - a = geometry.polygon_area(pointVector(basin.geometry(min))) - fi = r['fi'] - basins.append(Basin(r['id'], p[0], p[1], (a / 1E6), fi)) + fi = r["fi"] + p = centroid_geom.centroid.coords[0] + basins.append(Basin(r["id"], p[0], p[1], (a / 1e6), fi)) + return basins def confluence(self, confluence: VectorLayer) -> list: @@ -80,7 +103,7 @@ def confluence(self, confluence: VectorLayer) -> list: Parameters ---------- confluence : VectorLayer - The vector layer the confluences are on. + The vector layer the confluences are on. Returns ------- @@ -92,5 +115,5 @@ def confluence(self, confluence: VectorLayer) -> list: s = confluence.geometry(i) p = s[0] r = confluence.record(i) - confluences.append(Confluence(r['id'], p[0], p[1], bool(r['out']))) - return confluences \ No newline at end of file + confluences.append(Confluence(r["id"], p[0], p[1], bool(r["out"]))) + return confluences diff --git a/src/pyromb/resources/expected_fields.json b/src/pyromb/resources/expected_fields.json new file mode 100644 index 0000000..f5d4e20 --- /dev/null +++ b/src/pyromb/resources/expected_fields.json @@ -0,0 +1,37 @@ +{ + "reaches": [ + { + "name": "t", + "type": "N" + }, + { + "name": "s", + "type": "N" + }, + { + "name": "id", + "type": "C" + } + ], + "basins": [], + "centroids": [ + { + "name": "id", + "type": "C" + }, + { + "name": "fi", + "type": "N" + } + ], + "confluences": [ + { + "name": "id", + "type": "C" + }, + { + "name": "out", + "type": "N" + } + ] +} \ No newline at end of file