From a680d0c2ae8b284aa18249aa8e81b6d495399844 Mon Sep 17 00:00:00 2001 From: ramank1137 Date: Thu, 9 Apr 2026 15:12:54 +0530 Subject: [PATCH 01/19] adding local code for apis change detection/vector, croping intensity, lulc/vector, lulcXplain. lulcXslope, quifer, terrain/raster/cluster and terrain compute all, modular code sharing code in utilities/gee_utils and local_compute_helper.py --- computing/api.py | 209 ++++- .../change_detection/change_detection.py | 212 ++--- .../change_detection_local.py | 601 +++++++++++++++ .../change_detection_vector_local.py | 231 ++++++ .../cropping_intesity_local.py | 310 ++++++++ computing/local_compute_helper.py | 724 ++++++++++++++++++ computing/lulc/lulc_v3_local.py | 269 +++++++ computing/lulc/lulc_vector_local.py | 197 +++++ .../lulc_on_plain_cluster_local.py | 399 ++++++++++ .../lulc_on_slope_cluster_local.py | 392 ++++++++++ computing/misc/aquifer_vector_local.py | 419 ++++++++++ .../store_watersheds_for_tehsils.py | 394 ++++++++++ .../terrain_clusters_local.py | 181 +++++ .../terrain_compute_all_local.py | 255 ++++++ .../terrain_raster_fabdem_local.py | 166 ++++ computing/urls.py | 5 + 16 files changed, 4767 insertions(+), 197 deletions(-) create mode 100644 computing/change_detection/change_detection_local.py create mode 100644 computing/change_detection/change_detection_vector_local.py create mode 100644 computing/cropping_intensity/cropping_intesity_local.py create mode 100644 computing/local_compute_helper.py create mode 100644 computing/lulc/lulc_v3_local.py create mode 100644 computing/lulc/lulc_vector_local.py create mode 100644 computing/lulc_X_terrain/lulc_on_plain_cluster_local.py create mode 100644 computing/lulc_X_terrain/lulc_on_slope_cluster_local.py create mode 100644 computing/misc/aquifer_vector_local.py create mode 100644 computing/terrain_descriptor/store_watersheds_for_tehsils.py create mode 100644 computing/terrain_descriptor/terrain_clusters_local.py create mode 100644 computing/terrain_descriptor/terrain_compute_all_local.py create mode 100644 computing/terrain_descriptor/terrain_raster_fabdem_local.py diff --git a/computing/api.py b/computing/api.py index 365a0f7b..56e76149 100644 --- a/computing/api.py +++ b/computing/api.py @@ -9,7 +9,10 @@ from rest_framework.parsers import MultiPartParser, FormParser from computing.change_detection.change_detection_vector import ( - vectorise_change_detection, + vectorise_change_detection as vectorise_change_detection_gee_task, +) +from computing.change_detection.change_detection_vector_local import ( + vectorise_change_detection as vectorise_change_detection_local_task, ) from .utils import ( save_layer_info_to_db, @@ -17,7 +20,8 @@ ) from django.conf import settings from computing.STAC_specs.stac_collection import sanitize_text, STACConfig -from .lulc.lulc_vector import vectorise_lulc +from .lulc.lulc_vector import vectorise_lulc as vectorise_lulc_gee_task +from .lulc.lulc_vector_local import vectorise_lulc as vectorise_lulc_local_task from .lulc.river_basin_lulc.lulc_v2_river_basin import lulc_river_basin_v2 from .lulc.river_basin_lulc.lulc_v3_river_basin_using_v2 import lulc_river_basin_v3 from .lulc.tehsil_level.lulc_v2 import generate_lulc_v2_tehsil @@ -36,18 +40,50 @@ from utilities.constants import KML_PATH from .mws.mws import mws_layer from .cropping_intensity.cropping_intensity import generate_cropping_intensity +from .cropping_intensity.cropping_intesity_local import ( + generate_cropping_intensity as generate_cropping_intensity_local_task, +) from .surface_water_bodies.swb import generate_swb_layer from .drought.drought import calculate_drought -from .terrain_descriptor.terrain_clusters import generate_terrain_clusters -from .terrain_descriptor.terrain_raster_fabdem import generate_terrain_raster_clip +from .terrain_descriptor.terrain_clusters import ( + generate_terrain_clusters as generate_terrain_clusters_gee_task, +) +from .terrain_descriptor.terrain_clusters_local import ( + generate_terrain_clusters as generate_terrain_clusters_local_task, +) +from .terrain_descriptor.terrain_compute_all_local import ( + generate_terrain_compute_all as generate_terrain_compute_all_task, +) +from .terrain_descriptor.terrain_raster_fabdem import ( + generate_terrain_raster_clip as generate_terrain_raster_clip_gee_task, +) +from .terrain_descriptor.terrain_raster_fabdem_local import ( + generate_terrain_raster_clip as generate_terrain_raster_clip_local_task, +) from computing.misc.drainage_lines import clip_drainage_lines -from .lulc_X_terrain.lulc_on_slope_cluster import lulc_on_slope_cluster -from .lulc_X_terrain.lulc_on_plain_cluster import lulc_on_plain_cluster +from .lulc_X_terrain.lulc_on_slope_cluster import ( + lulc_on_slope_cluster as lulc_on_slope_cluster_gee_task, +) +from .lulc_X_terrain.lulc_on_slope_cluster_local import ( + lulc_on_slope_cluster_local as lulc_on_slope_cluster_local_task, +) +from .lulc_X_terrain.lulc_on_plain_cluster import ( + lulc_on_plain_cluster as lulc_on_plain_cluster_gee_task, +) +from .lulc_X_terrain.lulc_on_plain_cluster_local import ( + lulc_on_plain_cluster_local as lulc_on_plain_cluster_local_task, +) from .clart.clart import generate_clart_layer from .misc.admin_boundary import generate_tehsil_shape_file_data from .misc.nrega import clip_nrega_district_block -from computing.change_detection.change_detection import get_change_detection -from .lulc.lulc_v3 import clip_lulc_v3 +from computing.change_detection.change_detection import ( + get_change_detection as get_change_detection_gee_task, +) +from computing.change_detection.change_detection_local import ( + get_change_detection as get_change_detection_local_task, +) +from .lulc.lulc_v3 import clip_lulc_v3 as clip_lulc_v3_gee_task +from .lulc.lulc_v3_local import clip_lulc_v3 as clip_lulc_v3_local_task from .crop_grid.crop_grid import create_crop_grids from .tree_health.ccd import tree_health_ccd_raster from .tree_health.canopy_height import tree_health_ch_raster @@ -57,7 +93,12 @@ from .tree_health.canopy_height_vector import tree_health_ch_vector from .tree_health.ccd_vector import tree_health_ccd_vector from .plantation.site_suitability import site_suitability -from .misc.aquifer_vector import generate_aquifer_vector +from .misc.aquifer_vector import ( + generate_aquifer_vector as generate_aquifer_vector_gee_task, +) +from .misc.aquifer_vector_local import ( + generate_aquifer_vector as generate_aquifer_vector_local_task, +) from .misc.soge_vector import generate_soge_vector from .clart.fes_clart_to_geoserver import generate_fes_clart_layer from .surface_water_bodies.merge_swb_ponds import merge_swb_ponds @@ -385,13 +426,22 @@ def lulc_v3(request): start_year = request.data.get("start_year") end_year = request.data.get("end_year") gee_account_id = request.data.get("gee_account_id") - clip_lulc_v3.apply_async( + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + clip_lulc_v3_gee_task, + clip_lulc_v3_local_task, + ) + task.apply_async( args=[state, district, block, start_year, end_year, gee_account_id], queue="nrm", ) return Response( {"Success": "LULC v3 task initiated"}, status=status.HTTP_200_OK ) + except ValueError as e: + print("Invalid request in lulc_v3 api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in lulc_v3 api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -408,7 +458,13 @@ def lulc_vector(request): start_year = request.data.get("start_year") end_year = request.data.get("end_year") gee_account_id = request.data.get("gee_account_id") - vectorise_lulc.apply_async( + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + vectorise_lulc_gee_task, + vectorise_lulc_local_task, + ) + task.apply_async( args=[state, district, block, start_year, end_year, gee_account_id], queue="nrm", ) @@ -416,6 +472,9 @@ def lulc_vector(request): {"Success": "lulc_vector task initiated"}, status=status.HTTP_200_OK, ) + except ValueError as e: + print("Invalid request in lulc_vector api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in lulc_vector api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -472,7 +531,13 @@ def generate_ci_layer(request): start_year = request.data.get("start_year") end_year = request.data.get("end_year") gee_account_id = request.data.get("gee_account_id") - generate_cropping_intensity.apply_async( + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_cropping_intensity, + generate_cropping_intensity_local_task, + ) + task.apply_async( kwargs={ "state": state, "district": district, @@ -487,6 +552,9 @@ def generate_ci_layer(request): {"Success": "Cropping Intensity task initiated"}, status=status.HTTP_200_OK, ) + except ValueError as e: + print("Invalid request in generate_cropping_intensity_layer api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in generate_cropping_intensity_layer api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -562,18 +630,49 @@ def generate_terrain_descriptor(request): district = request.data.get("district") block = request.data.get("block") gee_account_id = request.data.get("gee_account_id") - generate_terrain_clusters.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_terrain_clusters_gee_task, + generate_terrain_clusters_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "generate_terrain_descriptor task initiated"}, status=status.HTTP_200_OK, ) + except ValueError as e: + print("Invalid request in generate_terrain_descriptor api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in generate_terrain_descriptor api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) +@api_view(["POST"]) +@schema(None) +def generate_terrain_compute_all(request): + print("Inside generate_terrain_compute_all") + try: + state = request.data.get("state") + district = request.data.get("district") + block = request.data.get("block") + start_year = request.data.get("start_year") + end_year = request.data.get("end_year") + gee_account_id = request.data.get("gee_account_id") + generate_terrain_compute_all_task.apply_async( + args=[state, district, block, start_year, end_year, gee_account_id], + queue="nrm", + ) + return Response( + {"Success": "generate_terrain_compute_all task initiated"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + print("Exception in generate_terrain_compute_all api :: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @api_view(["POST"]) @schema(None) def generate_terrain_raster(request): @@ -583,7 +682,13 @@ def generate_terrain_raster(request): district = request.data.get("district") block = request.data.get("block") gee_account_id = request.data.get("gee_account_id") - generate_terrain_raster_clip.apply_async( + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_terrain_raster_clip_gee_task, + generate_terrain_raster_clip_local_task, + ) + task.apply_async( kwargs={ "state": state, "district": district, @@ -597,6 +702,9 @@ def generate_terrain_raster(request): {"Success": "generate_terrain_raster task initiated"}, status=status.HTTP_200_OK, ) + except ValueError as e: + print("Invalid request in generate_terrain_raster api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in generate_terrain_raster api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -610,10 +718,16 @@ def terrain_lulc_slope_cluster(request): state = request.data.get("state") district = request.data.get("district") block = request.data.get("block") - start_year = request.data.get("start_year") - end_year = request.data.get("end_year") + start_year = int(request.data.get("start_year")) + end_year = int(request.data.get("end_year")) gee_account_id = request.data.get("gee_account_id") - lulc_on_slope_cluster.apply_async( + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + lulc_on_slope_cluster_gee_task, + lulc_on_slope_cluster_local_task, + ) + task.apply_async( args=[state, district, block, start_year, end_year, gee_account_id], queue="nrm", ) @@ -621,6 +735,9 @@ def terrain_lulc_slope_cluster(request): {"Success": "terrain_lulc_slope_cluster task initiated"}, status=status.HTTP_200_OK, ) + except ValueError as e: + print("Invalid request in terrain_lulc_slope_cluster api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in terrain_lulc_slope_cluster api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -634,10 +751,16 @@ def terrain_lulc_plain_cluster(request): state = request.data.get("state") district = request.data.get("district") block = request.data.get("block") - start_year = request.data.get("start_year") - end_year = request.data.get("end_year") + start_year = int(request.data.get("start_year")) + end_year = int(request.data.get("end_year")) gee_account_id = request.data.get("gee_account_id") - lulc_on_plain_cluster.apply_async( + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + lulc_on_plain_cluster_gee_task, + lulc_on_plain_cluster_local_task, + ) + task.apply_async( args=[state, district, block, start_year, end_year, gee_account_id], queue="nrm", ) @@ -645,6 +768,9 @@ def terrain_lulc_plain_cluster(request): {"Success": "terrain_lulc_plain_cluster task initiated"}, status=status.HTTP_200_OK, ) + except ValueError as e: + print("Invalid request in terrain_lulc_plain_cluster api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in terrain_lulc_plain_cluster api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -679,10 +805,16 @@ def change_detection(request): state = request.data.get("state").lower() district = request.data.get("district").lower() block = request.data.get("block").lower() - start_year = request.data.get("start_year") - end_year = request.data.get("end_year") + start_year = int(request.data.get("start_year")) + end_year = int(request.data.get("end_year")) gee_account_id = request.data.get("gee_account_id") - get_change_detection.apply_async( + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + get_change_detection_gee_task, + get_change_detection_local_task, + ) + task.apply_async( args=[state, district, block, start_year, end_year, gee_account_id], queue="nrm", ) @@ -690,6 +822,9 @@ def change_detection(request): {"Success": "change_detection task initiated"}, status=status.HTTP_200_OK, ) + except ValueError as e: + print("Invalid request in change_detection api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in change_detection api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -703,10 +838,16 @@ def change_detection_vector(request): state = request.data.get("state").lower() district = request.data.get("district").lower() block = request.data.get("block").lower() - start_year = request.data.get("start_year") - end_year = request.data.get("end_year") + start_year = int(request.data.get("start_year")) + end_year = int(request.data.get("end_year")) gee_account_id = request.data.get("gee_account_id") - vectorise_change_detection.apply_async( + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + vectorise_change_detection_gee_task, + vectorise_change_detection_local_task, + ) + task.apply_async( args=[state, district, block, start_year, end_year, gee_account_id], queue="nrm", ) @@ -714,6 +855,9 @@ def change_detection_vector(request): {"Success": "change_detection_vector task initiated"}, status=status.HTTP_200_OK, ) + except ValueError as e: + print("Invalid request in change_detection_vector api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in change_detection_vector api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -977,13 +1121,20 @@ def aquifer_vector(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_aquifer_vector.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_aquifer_vector_gee_task, + generate_aquifer_vector_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "aquifer vector task initiated"}, status=status.HTTP_200_OK, ) + except ValueError as e: + print("Invalid request in aquifer vector api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in aquifer vector api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/computing/change_detection/change_detection.py b/computing/change_detection/change_detection.py index 46bf27c2..6fd98148 100644 --- a/computing/change_detection/change_detection.py +++ b/computing/change_detection/change_detection.py @@ -1,5 +1,4 @@ import ee -import copy from utilities.gee_utils import ( ee_initialize, check_task_status, @@ -117,6 +116,20 @@ def get_change_detection( return layer_at_geoserver +def _compute_then_now_modes(l1_asset_remapped, lulc_projection, roi_boundary): + if len(l1_asset_remapped) < 6: + raise ValueError( + "Change detection requires at least six yearly LULC rasters to compare the first three years against the last three years." + ) + + then = ee.ImageCollection(l1_asset_remapped[:3]).mode().reproject(lulc_projection) + now = ee.ImageCollection(l1_asset_remapped[-3:]).mode().reproject(lulc_projection) + + then = then.clip(roi_boundary.geometry()) + now = now.clip(roi_boundary.geometry()) + return now, then + + def built_up(roi_boundary, l1_asset): print("built_up function is runing") @@ -133,13 +146,9 @@ def remap_values(image): l1_asset_remapped = [remap_values(asset) for asset in l1_asset] - # Create image collections - then = ee.ImageCollection(l1_asset_remapped[:3]).mode().reproject(lulc_projection) - now = ee.ImageCollection(l1_asset_remapped[3:]).mode().reproject(lulc_projection) - - # Compute mode and clip - then = then.clip(roi_boundary.geometry()) - now = now.clip(roi_boundary.geometry()) + now, then = _compute_then_now_modes( + l1_asset_remapped, lulc_projection, roi_boundary + ) # Compute transitions trans_bu_bu = then.eq(1).And(now.eq(1)) @@ -173,13 +182,9 @@ def remap_values(image): l1_asset_remapped = [remap_values(asset) for asset in l1_asset] - # Create image collections - then = ee.ImageCollection(l1_asset_remapped[:3]).mode().reproject(lulc_projection) - now = ee.ImageCollection(l1_asset_remapped[3:]).mode().reproject(lulc_projection) - - # Compute mode and clip - then = then.clip(roi_boundary.geometry()) - now = now.clip(roi_boundary.geometry()) + now, then = _compute_then_now_modes( + l1_asset_remapped, lulc_projection, roi_boundary + ) trans_f_f = then.eq(3).And(now.eq(3)) trans_f_bu = then.eq(3).And(now.eq(1)).multiply(2) @@ -198,160 +203,23 @@ def remap_values(image): return change_deg -def change_deforestation_afforestation(roi_boundary, l1_asset, lulc_projection): - print("change_deforestation is running") - # Create an initial zero image - zero_image2 = ( - ee.Image.constant(0) - .setDefaultProjection(lulc_projection) - .clip(l1_asset[0].geometry()) - ) - - # for i in range(1, 5): - for i in range(1, len(l1_asset) - 1): - before = l1_asset[i - 1] - middle = l1_asset[i] - after = l1_asset[i + 1] - - cond1 = ( - before.eq(12) - .And(after.eq(12)) - .And( - middle.eq(6) - .Or(middle.eq(8)) - .Or(middle.eq(9)) - .Or(middle.eq(10)) - .Or(middle.eq(11)) - ) - ) - cond2 = ( - before.eq(2) - .Or(before.eq(3)) - .Or(before.eq(4)) - .And(after.eq(2).Or(after.eq(3)).Or(after.eq(4))) - .And( - middle.eq(6) - .Or(middle.eq(8)) - .Or(middle.eq(9)) - .Or(middle.eq(10)) - .Or(middle.eq(11)) - ) - ) - cond3 = before.eq(6).And(after.eq(6)).And(middle.eq(12)) - cond4 = ( - before.eq(8) - .Or(before.eq(9)) - .Or(before.eq(10)) - .Or(before.eq(11)) - .And(after.eq(8).Or(after.eq(9)).Or(after.eq(10)).Or(after.eq(11))) - .And(middle.eq(12)) - ) - cond5 = ( - before.eq(8) - .Or(before.eq(9)) - .Or(before.eq(10)) - .Or(before.eq(11)) - .And(after.eq(8).Or(after.eq(9)).Or(after.eq(10)).Or(after.eq(11))) - .And(middle.eq(7)) - ) - cond6 = ( - before.eq(6) - .And(after.eq(6)) - .And(middle.eq(8).Or(middle.eq(9)).Or(middle.eq(10)).Or(middle.eq(11))) - ) - cond7 = ( - before.eq(8) - .Or(before.eq(9)) - .Or(before.eq(10)) - .Or(before.eq(11)) - .And(after.eq(8).Or(after.eq(9)).Or(after.eq(10)).Or(after.eq(11))) - .And(middle.eq(6)) - ) - cond8 = before.eq(1).And(after.eq(1)).And(middle.eq(6)) - cond9 = before.eq(6).And(after.eq(6)).And(middle.eq(1)) - cond10 = ( - before.eq(1) - .And(after.eq(1)) - .And(middle.eq(8).Or(middle.eq(9)).Or(middle.eq(10)).Or(middle.eq(11))) - ) - cond11 = ( - before.eq(7) - .And(after.eq(7)) - .And( - middle.eq(6) - .Or(middle.eq(8)) - .Or(middle.eq(9)) - .Or(middle.eq(10)) - .Or(middle.eq(11)) - ) - ) - - zero_image2 = ( - zero_image2.add(cond1) - .add(cond2) - .add(cond3) - .add(cond4) - .add(cond5) - .add(cond6) - .add(cond7) - .add(cond8) - .add(cond9) - .add(cond10) - .add(cond11) - ) - - l1_asset_copy = copy.deepcopy(l1_asset) - for i in range(1, len(l1_asset) - 1): - # for i in range(1, 5): - before = l1_asset[i - 1] - middle = l1_asset[i] - after = l1_asset[i + 1] - - cond1 = ( - before.eq(3) - .And(middle.neq(3)) - .And(after.eq(3)) - .And((zero_image2.eq(3).Or(zero_image2.eq(4)))) - ) - cond2 = ( - before.neq(3) - .And(middle.eq(3)) - .And(after.neq(3)) - .And((zero_image2.eq(3).Or(zero_image2.eq(4)))) - ) - - middle = middle.where(cond1, 3) - middle = middle.where(cond2, before) - - l1_asset_copy[i] = middle +def change_deforestation(roi_boundary, l1_asset): + lulc_projection = l1_asset[0].projection() - # Remap values function def remap_values(image): - remapped = image.remap( + return image.remap( [1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12], [1, 2, 2, 2, 3, 5, 4, 4, 4, 4, 6], 0, "predicted_label", ).setDefaultProjection(lulc_projection) - return remapped - - l1_asset_remapped = [remap_values(asset) for asset in l1_asset_copy] - - # Create image collections - then = ee.ImageCollection(l1_asset_remapped[:3]).mode().reproject(lulc_projection) - now = ee.ImageCollection(l1_asset_remapped[3:]).mode().reproject(lulc_projection) - - # Compute mode and clip - then = then.clip(roi_boundary.geometry()) - now = now.clip(roi_boundary.geometry()) - return now, then + l1_asset_remapped = [remap_values(asset) for asset in l1_asset] -def change_deforestation(roi_boundary, l1_asset): - lulc_projection = l1_asset[0].projection() - now, then = change_deforestation_afforestation( - roi_boundary, l1_asset, lulc_projection + now, then = _compute_then_now_modes( + l1_asset_remapped, lulc_projection, roi_boundary ) + trans_fo_fo = then.eq(3).And(now.eq(3)) trans_fo_bu = then.eq(3).And(now.eq(1)).multiply(2) trans_fo_fa = then.eq(3).And(now.eq(4)).multiply(3) @@ -375,9 +243,21 @@ def change_deforestation(roi_boundary, l1_asset): def change_afforestation(roi_boundary, l1_asset): lulc_projection = l1_asset[0].projection() - now, then = change_deforestation_afforestation( - roi_boundary, l1_asset, lulc_projection + + def remap_values(image): + return image.remap( + [1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12], + [1, 2, 2, 2, 3, 5, 4, 4, 4, 4, 6], + 0, + "predicted_label", + ).setDefaultProjection(lulc_projection) + + l1_asset_remapped = [remap_values(asset) for asset in l1_asset] + + now, then = _compute_then_now_modes( + l1_asset_remapped, lulc_projection, roi_boundary ) + trans_fo_fo = then.eq(3).And(now.eq(3)) trans_bu_fo = then.eq(1).And(now.eq(3)).multiply(2) trans_fa_fo = then.eq(4).And(now.eq(3)).multiply(3) @@ -414,13 +294,9 @@ def remap_values(image): l1_asset_remapped = [remap_values(asset) for asset in l1_asset] - # Create image collections - then = ee.ImageCollection(l1_asset_remapped[:3]).mode().reproject(lulc_projection) - now = ee.ImageCollection(l1_asset_remapped[3:]).mode().reproject(lulc_projection) - - # Compute mode and clip - then = then.clip(roi_boundary.geometry()) - now = now.clip(roi_boundary.geometry()) + now, then = _compute_then_now_modes( + l1_asset_remapped, lulc_projection, roi_boundary + ) trans_do_si = then.eq(6).And(now.eq(5)) trans_tr_si = then.eq(7).And(now.eq(5)).multiply(2) diff --git a/computing/change_detection/change_detection_local.py b/computing/change_detection/change_detection_local.py new file mode 100644 index 00000000..a539e703 --- /dev/null +++ b/computing/change_detection/change_detection_local.py @@ -0,0 +1,601 @@ +import os +from concurrent.futures import ThreadPoolExecutor + +import numpy as np +import rasterio +from rasterio.mask import mask +from rasterio.warp import Resampling, reproject +from shapely.geometry import mapping +from utilities.gee_utils import valid_gee_text + +from nrm_app.celery import app + +from computing.local_compute_helper import ( + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + PROJECT_ROOT, + build_output_raster_path, + get_union_geometry, + load_precomputed_roi, + push_local_raster_to_geoserver, + resolve_lulc_raster_paths, + validate_geometry, +) +from computing.utils import save_layer_info_to_db, update_layer_sync_status + + +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/change_detection/change_detection_local" +GEOSERVER_WORKSPACE = "change_detection" + +CHANGE_STAC_LAYER_NAMES = { + "Urbanization": "change_urbanization_raster", + "Degradation": "change_cropping_reduction_raster", + "Deforestation": "change_tree_cover_loss_raster", + "Afforestation": "change_tree_cover_gain_raster", + "CropIntensity": "change_cropping_intensity_raster", +} + +BUILT_UP_REMAP = { + 1: 1, + 2: 2, + 3: 2, + 4: 2, + 6: 3, + 7: 4, + 8: 3, + 9: 3, + 10: 3, + 11: 3, + 12: 4, +} + +DEGRADATION_REMAP = { + 1: 1, + 2: 2, + 3: 2, + 4: 2, + 6: 4, + 7: 5, + 8: 3, + 9: 3, + 10: 3, + 11: 3, + 12: 6, +} + +DEFORESTATION_AFFORESTATION_REMAP = { + 1: 1, + 2: 2, + 3: 2, + 4: 2, + 6: 3, + 7: 5, + 8: 4, + 9: 4, + 10: 4, + 11: 4, + 12: 6, +} + +CROP_INTENSITY_REMAP = { + 1: 1, + 2: 2, + 3: 2, + 4: 2, + 6: 3, + 7: 4, + 8: 5, + 9: 5, + 10: 6, + 11: 7, + 12: 8, +} + + +def _build_lookup_table(mapping, size=13): + lookup = np.zeros(size, dtype=np.int16) + for source_value, mapped_value in mapping.items(): + lookup[source_value] = mapped_value + return lookup + + +BUILT_UP_LOOKUP = _build_lookup_table(BUILT_UP_REMAP) +DEGRADATION_LOOKUP = _build_lookup_table(DEGRADATION_REMAP) +DEFORESTATION_AFFORESTATION_LOOKUP = _build_lookup_table( + DEFORESTATION_AFFORESTATION_REMAP +) +CROP_INTENSITY_LOOKUP = _build_lookup_table(CROP_INTENSITY_REMAP) + +CHANGE_PARAM_FUNCTIONS = { + "Urbanization": "_compute_built_up_change", + "Degradation": "_compute_degradation_change", + "Deforestation": "_compute_deforestation_change", + "Afforestation": "_compute_afforestation_change", + "CropIntensity": "_compute_crop_intensity_change", +} + +ZERO_NODATA = 0 + + +def _slug(value, fallback): + return valid_gee_text(str(value).strip().lower()) or fallback + + +def _as_lulc_int_array(array): + array = np.asarray(array) + if np.issubdtype(array.dtype, np.integer): + return array.astype(np.int16, copy=False) + + array = np.asarray(array, dtype=np.float64) + array = np.where(np.isfinite(array), array, 0) + return np.rint(array).astype(np.int16, copy=False) + + +def _remap_array(array, mapping): + source = _as_lulc_int_array(array) + if isinstance(mapping, np.ndarray): + return mapping[source] + + remapped = np.zeros(source.shape, dtype=np.int16) + for source_value, mapped_value in mapping.items(): + remapped[source == source_value] = mapped_value + return remapped + + +def _combine_transitions(shape, transitions): + result = np.zeros(shape, dtype=np.uint8) + for value, condition in transitions: + result += condition.astype(np.uint8) * np.uint8(value) + return result + + +def _base_description(district, block): + return f"change_{_slug(district, 'unknown_district')}_{_slug(block, 'unknown_block')}" + + +def _published_layer_name(district, block, param_name): + return f"{_base_description(district, block)}_{param_name}" + + +def _output_stub(district, block, param_name, start_year, end_year): + return f"{_published_layer_name(district, block, param_name)}_{start_year}_{end_year}" + + +def _select_change_detection_raster_paths(start_year, end_year): + raster_paths = resolve_lulc_raster_paths(start_year=start_year, end_year=end_year) + if len(raster_paths) < 6: + raise ValueError( + "Local change detection requires at least six yearly LULC rasters to compare the first three years against the last three years." + ) + return raster_paths[:3] + raster_paths[-3:] + + +def _build_roi_shapes_by_crs(roi_gdf, raster_paths): + roi_shapes_by_crs = {} + roi_union = get_union_geometry(roi_gdf) + if roi_union is None or roi_union.is_empty: + raise ValueError("ROI union geometry is empty for local change detection.") + + roi_shapes_by_crs[str(roi_gdf.crs)] = mapping(roi_union) + for raster_path in raster_paths: + with rasterio.open(raster_path) as src: + crs_key = str(src.crs) + if crs_key in roi_shapes_by_crs: + continue + clip_gdf = roi_gdf if src.crs is None else roi_gdf.to_crs(src.crs) + clip_union = get_union_geometry(clip_gdf) + if clip_union is None or clip_union.is_empty: + raise ValueError("ROI union geometry is empty for local change detection.") + roi_shapes_by_crs[crs_key] = mapping(clip_union) + + return roi_shapes_by_crs + + +def _load_masked_lulc_array(index, raster_path, roi_shape_by_crs): + with rasterio.open(raster_path) as src: + clipped_data, clipped_transform = mask( + src, + shapes=[roi_shape_by_crs[str(src.crs)]], + crop=True, + filled=True, + nodata=ZERO_NODATA, + ) + clipped_array = _as_lulc_int_array(clipped_data[0]) + meta = src.meta.copy() + return { + "index": index, + "raster_path": raster_path, + "array": clipped_array, + "transform": clipped_transform, + "crs": src.crs, + "meta": meta, + } + + +def _load_masked_lulc_arrays(roi_gdf, raster_paths): + roi_gdf = validate_geometry(roi_gdf) + if roi_gdf.empty: + raise ValueError("No valid ROI geometry available for local change detection.") + if roi_gdf.crs is None: + raise ValueError("ROI CRS is missing; cannot align LULC rasters.") + + roi_shapes_by_crs = _build_roi_shapes_by_crs(roi_gdf, raster_paths) + max_workers = min(len(raster_paths), max(os.cpu_count() or 1, 1)) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit( + _load_masked_lulc_array, index, raster_path, roi_shapes_by_crs + ) + for index, raster_path in enumerate(raster_paths, start=1) + ] + results = [future.result() for future in futures] + + results.sort(key=lambda item: item["index"]) + + reference = results[0] + output_meta = reference["meta"] + output_meta.update( + { + "driver": "GTiff", + "height": reference["array"].shape[0], + "width": reference["array"].shape[1], + "transform": reference["transform"], + "crs": reference["crs"], + "count": 1, + "dtype": "uint8", + "nodata": ZERO_NODATA, + "compress": "lzw", + } + ) + + arrays = [] + for result in results: + clipped_array = result["array"] + needs_alignment = ( + clipped_array.shape != (output_meta["height"], output_meta["width"]) + or result["transform"] != output_meta["transform"] + or result["crs"] != output_meta["crs"] + ) + if needs_alignment: + aligned_array = np.zeros( + (output_meta["height"], output_meta["width"]), + dtype=np.int16, + ) + reproject( + source=clipped_array, + destination=aligned_array, + source_transform=result["transform"], + source_crs=result["crs"], + destination_transform=output_meta["transform"], + destination_crs=output_meta["crs"], + src_nodata=ZERO_NODATA, + dst_nodata=ZERO_NODATA, + resampling=Resampling.nearest, + ) + clipped_array = aligned_array + + arrays.append(clipped_array.astype(np.int16, copy=False)) + print( + f"Loaded local LULC raster {result['index']}/{len(raster_paths)}: {result['raster_path']}" + ) + + return arrays, output_meta + + +def _write_change_raster(array, output_path, output_meta): + raster = np.asarray(array, dtype=np.uint8) + meta = output_meta.copy() + meta.update( + { + "driver": "GTiff", + "count": 1, + "dtype": "uint8", + "nodata": ZERO_NODATA, + "compress": "lzw", + } + ) + with rasterio.open(output_path, "w", **meta) as dst: + dst.write(raster, 1) + return str(output_path) + + +def _compute_mode_lulc_three(arrays): + first, second, third = (_as_lulc_int_array(array) for array in arrays) + mode = np.zeros(first.shape, dtype=np.int16) + + same_first = ((first == second) | (first == third)) & (first > 0) + same_second = (second == third) & (second > 0) + mode = np.where(same_first, first, mode) + mode = np.where((mode == 0) & same_second, second, mode) + + unresolved = mode == 0 + if np.any(unresolved): + sentinel = np.int16(np.iinfo(np.int16).max) + positive_stack = np.stack( + [ + np.where(first > 0, first, sentinel), + np.where(second > 0, second, sentinel), + np.where(third > 0, third, sentinel), + ], + axis=0, + ) + fallback = np.min(positive_stack, axis=0) + fallback = np.where(fallback == sentinel, 0, fallback) + mode = np.where(unresolved, fallback, mode) + + return mode + + +def _compute_then_now_modes(remapped_arrays): + if len(remapped_arrays) < 6: + raise ValueError( + "Local change detection requires at least six selected yearly LULC rasters to compare the first three years against the last three years." + ) + then = _compute_mode_lulc_three(remapped_arrays[:3]) + now = _compute_mode_lulc_three(remapped_arrays[-3:]) + return now, then + + +def _compute_built_up_change(lulc_arrays): + remapped_arrays = [_remap_array(array, BUILT_UP_LOOKUP) for array in lulc_arrays] + now, then = _compute_then_now_modes(remapped_arrays) + return _combine_transitions( + then.shape, + [ + (1, (then == 1) & (now == 1)), + (2, (then == 2) & (now == 1)), + (3, (then == 3) & (now == 1)), + (4, (then == 4) & (now == 1)), + ], + ) + + +def _compute_degradation_change(lulc_arrays): + remapped_arrays = [ + _remap_array(array, DEGRADATION_LOOKUP) for array in lulc_arrays + ] + now, then = _compute_then_now_modes(remapped_arrays) + return _combine_transitions( + then.shape, + [ + (1, (then == 3) & (now == 3)), + (2, (then == 3) & (now == 1)), + (3, (then == 3) & (now == 5)), + (4, (then == 3) & (now == 6)), + ], + ) + + +def _compute_deforestation_afforestation_modes(lulc_arrays): + remapped_arrays = [ + _remap_array(array, DEFORESTATION_AFFORESTATION_LOOKUP) + for array in lulc_arrays + ] + return _compute_then_now_modes(remapped_arrays) + + +def _build_deforestation_change(now, then): + return _combine_transitions( + then.shape, + [ + (1, (then == 3) & (now == 3)), + (2, (then == 3) & (now == 1)), + (3, (then == 3) & (now == 4)), + (4, (then == 3) & (now == 5)), + (5, (then == 3) & (now == 6)), + ], + ) + + +def _compute_deforestation_change(lulc_arrays): + now, then = _compute_deforestation_afforestation_modes(lulc_arrays) + return _build_deforestation_change(now, then) + + +def _build_afforestation_change(now, then): + return _combine_transitions( + then.shape, + [ + (1, (then == 3) & (now == 3)), + (2, (then == 1) & (now == 3)), + (3, (then == 4) & (now == 3)), + (4, (then == 5) & (now == 3)), + (5, (then == 6) & (now == 3)), + ], + ) + + +def _compute_afforestation_change(lulc_arrays): + now, then = _compute_deforestation_afforestation_modes(lulc_arrays) + return _build_afforestation_change(now, then) + + +def _compute_crop_intensity_change(lulc_arrays): + remapped_arrays = [ + _remap_array(array, CROP_INTENSITY_LOOKUP) for array in lulc_arrays + ] + now, then = _compute_then_now_modes(remapped_arrays) + return _combine_transitions( + then.shape, + [ + (1, (then == 6) & (now == 5)), + (2, (then == 7) & (now == 5)), + (3, (then == 7) & (now == 6)), + (4, (then == 5) & (now == 6)), + (5, (then == 5) & (now == 7)), + (6, (then == 6) & (now == 7)), + (7, (then == 5) & (now == 5)), + (8, (then == 6) & (now == 6)), + (9, (then == 7) & (now == 7)), + ], + ) + + +def _compute_single_change_output(param_name, lulc_arrays): + print(f"Computing local change detection raster: {param_name}") + return param_name, globals()[CHANGE_PARAM_FUNCTIONS[param_name]](lulc_arrays) + + +def _compute_forest_change_outputs(lulc_arrays): + print("Computing local change detection raster: Deforestation") + print("Computing local change detection raster: Afforestation") + now, then = _compute_deforestation_afforestation_modes(lulc_arrays) + return { + "Deforestation": _build_deforestation_change(now, then), + "Afforestation": _build_afforestation_change(now, then), + } + + +def _compute_change_outputs(lulc_arrays): + outputs = {} + cpu_workers = min(max(os.cpu_count() or 1, 1), 4) + with ThreadPoolExecutor(max_workers=cpu_workers) as executor: + futures = [ + executor.submit(_compute_single_change_output, param_name, lulc_arrays) + for param_name in ("Urbanization", "Degradation", "CropIntensity") + ] + futures.append(executor.submit(_compute_forest_change_outputs, lulc_arrays)) + + for future in futures: + result = future.result() + if isinstance(result, dict): + outputs.update(result) + else: + param_name, change_array = result + outputs[param_name] = change_array + + return outputs + + +def run_change_detection_local( + state, + district, + block, + start_year, + end_year, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + push_to_geoserver=True, + sync_layer_metadata=True, +): + state = str(state).strip().lower() + district = str(district).strip().lower() + block = str(block).strip().lower() + start_year = int(start_year) + end_year = int(end_year) + + if end_year < start_year: + raise ValueError("end_year must be greater than or equal to start_year") + + roi_gdf = load_precomputed_roi( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + lulc_raster_paths = _select_change_detection_raster_paths( + start_year=start_year, + end_year=end_year, + ) + lulc_arrays, output_meta = _load_masked_lulc_arrays(roi_gdf, lulc_raster_paths) + change_outputs = _compute_change_outputs(lulc_arrays) + + geoserver_statuses = [] + + for param_name, change_array in change_outputs.items(): + output_stub = _output_stub(district, block, param_name, start_year, end_year) + output_path = build_output_raster_path( + layer_name=output_stub, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + state=state, + district=district, + block=block, + block_fallback="unknown_block", + ) + raster_path = _write_change_raster( + array=change_array, + output_path=output_path, + output_meta=output_meta, + ) + print(f"Saved local change detection raster: {raster_path}") + + published_layer_name = _published_layer_name(district, block, param_name) + if push_to_geoserver: + upload_res, style_res = push_local_raster_to_geoserver( + file_path=raster_path, + layer_name=published_layer_name, + workspace=GEOSERVER_WORKSPACE, + style_name=param_name.lower(), + ) + print(f"GeoServer upload response for {param_name}: {upload_res}") + print(f"GeoServer style response for {param_name}: {style_res}") + geoserver_statuses.append(True) + + if sync_layer_metadata: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=published_layer_name, + asset_id=raster_path, + dataset_name="Change Detection Raster", + misc={ + "start_year": start_year, + "end_year": end_year, + }, + ) + if layer_id and push_to_geoserver: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + from computing.STAC_specs import generate_STAC_layerwise + + layer_stac_generated = generate_STAC_layerwise.generate_raster_stac( + state=state, + district=district, + block=block, + layer_name=CHANGE_STAC_LAYER_NAMES[param_name], + ) + update_layer_sync_status( + layer_id=layer_id, + is_stac_specs_generated=layer_stac_generated, + ) + + return all(geoserver_statuses) if push_to_geoserver else True + + +def _get_change_detection_local_task( + state, + district, + block, + start_year, + end_year, + gee_account_id=None, +): + _ = gee_account_id + return run_change_detection_local( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + + +@app.task(bind=True) +def get_change_detection( + self, + state, + district, + block, + start_year, + end_year, + gee_account_id=None, +): + _ = self + return _get_change_detection_local_task( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + gee_account_id=gee_account_id, + ) diff --git a/computing/change_detection/change_detection_vector_local.py b/computing/change_detection/change_detection_vector_local.py new file mode 100644 index 00000000..bbb325d3 --- /dev/null +++ b/computing/change_detection/change_detection_vector_local.py @@ -0,0 +1,231 @@ +import os + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + +from computing.local_compute_helper import ( + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + PROJECT_ROOT, + build_output_raster_path, + build_output_vector_path, + compute_categorical_raster_areas_for_watersheds, + ensure_file_exists, + load_precomputed_watersheds, + write_vector_output, +) +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) + + +CHANGE_RASTER_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/change_detection/change_detection_local" +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/change_detection/change_detection_vector_local" +GEOSERVER_WORKSPACE = "change_detection" + +CHANGE_VECTOR_CLASS_DEFINITIONS = { + "Afforestation": [ + {"value": 1, "label": "fo_fo"}, + {"value": 2, "label": "bu_fo"}, + {"value": 3, "label": "fa_fo"}, + {"value": 4, "label": "ba_fo"}, + {"value": 5, "label": "sc_fo"}, + {"value": [2, 3, 4, 5], "label": "total_aff"}, + ], + "Deforestation": [ + {"value": 1, "label": "fo_fo"}, + {"value": 2, "label": "fo_bu"}, + {"value": 3, "label": "fo_fa"}, + {"value": 4, "label": "fo_ba"}, + {"value": 5, "label": "fo_sc"}, + {"value": [2, 3, 4, 5], "label": "total_def"}, + ], + "Degradation": [ + {"value": 1, "label": "f_f"}, + {"value": 2, "label": "f_bu"}, + {"value": 3, "label": "f_ba"}, + {"value": 4, "label": "f_sc"}, + {"value": [2, 3, 4], "label": "total_deg"}, + ], + "Urbanization": [ + {"value": 1, "label": "bu_bu"}, + {"value": 2, "label": "w_bu"}, + {"value": 3, "label": "tr_bu"}, + {"value": 4, "label": "b_bu"}, + {"value": [2, 3, 4], "label": "total_urb"}, + ], + "CropIntensity": [ + {"value": 1, "label": "do_si"}, + {"value": 2, "label": "tr_si"}, + {"value": 3, "label": "tr_do"}, + {"value": 4, "label": "si_do"}, + {"value": 5, "label": "si_tr"}, + {"value": 6, "label": "do_tr"}, + {"value": 7, "label": "si_si"}, + {"value": 8, "label": "do_do"}, + {"value": 9, "label": "tr_tr"}, + {"value": [1, 2, 3, 4, 5, 6], "label": "total_change"}, + ], +} + + +def _slug(value, fallback): + return valid_gee_text(str(value).strip().lower()) or fallback + + +def _published_layer_name(district, block, param_name): + return ( + f"change_vector_{_slug(district, 'unknown_district')}_" + f"{_slug(block, 'unknown_block')}_{param_name}" + ) + + +def _output_stub(district, block, param_name, start_year, end_year): + return f"{_published_layer_name(district, block, param_name)}_{start_year}_{end_year}" + + +def _resolve_local_change_raster_path(state, district, block, param_name, start_year, end_year): + raster_path = build_output_raster_path( + layer_name=( + f"change_{_slug(district, 'unknown_district')}_" + f"{_slug(block, 'unknown_block')}_{param_name}_{int(start_year)}_{int(end_year)}" + ), + output_base_dir=CHANGE_RASTER_OUTPUT_BASE_DIR, + state=state, + district=district, + block=block, + block_fallback="unknown_block", + ) + ensure_file_exists(raster_path, f"Local change detection raster for {param_name}") + return str(raster_path) + + +def run_change_detection_vector_local( + state, + district, + block, + start_year, + end_year, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + push_to_geoserver=True, + sync_layer_metadata=True, +): + state = str(state).strip().lower() + district = str(district).strip().lower() + block = str(block).strip().lower() + start_year = int(start_year) + end_year = int(end_year) + + watersheds_gdf, watershed_source = load_precomputed_watersheds( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + print(f"Watershed boundary source: {watershed_source}") + + geoserver_statuses = [] + + for param_name, class_definitions in CHANGE_VECTOR_CLASS_DEFINITIONS.items(): + raster_path = _resolve_local_change_raster_path( + state=state, + district=district, + block=block, + param_name=param_name, + start_year=start_year, + end_year=end_year, + ) + print(f"Using local change raster for {param_name}: {raster_path}") + + result_gdf = compute_categorical_raster_areas_for_watersheds( + watersheds_gdf=watersheds_gdf, + raster_path=raster_path, + class_definitions=class_definitions, + ) + + output_stub = _output_stub(district, block, param_name, start_year, end_year) + output_path = build_output_vector_path( + layer_name=output_stub, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + block_fallback="unknown_block", + ) + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=output_stub, + ) + print(f"Saved local change detection vector: {asset_id}") + + published_layer_name = _published_layer_name(district, block, param_name) + if push_to_geoserver: + geoserver_response = push_shape_to_geoserver( + os.path.splitext(asset_id)[0], + workspace=GEOSERVER_WORKSPACE, + layer_name=published_layer_name, + file_type="gpkg", + ) + print(f"GeoServer response for {param_name}: {geoserver_response}") + if not isinstance(geoserver_response, dict) or geoserver_response.get( + "status_code" + ) not in (200, 201): + return False + geoserver_statuses.append(True) + + if sync_layer_metadata: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=published_layer_name, + asset_id=asset_id, + dataset_name="Change Detection Vector", + ) + if layer_id and push_to_geoserver: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + + return all(geoserver_statuses) if push_to_geoserver else True + + +def _vectorise_change_detection_local_task( + state, + district, + block, + start_year, + end_year, + gee_account_id=None, +): + _ = gee_account_id + return run_change_detection_vector_local( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + + +@app.task(bind=True) +def vectorise_change_detection( + self, + state, + district, + block, + start_year, + end_year, + gee_account_id=None, +): + _ = self + return _vectorise_change_detection_local_task( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + gee_account_id=gee_account_id, + ) diff --git a/computing/cropping_intensity/cropping_intesity_local.py b/computing/cropping_intensity/cropping_intesity_local.py new file mode 100644 index 00000000..fddd5f62 --- /dev/null +++ b/computing/cropping_intensity/cropping_intesity_local.py @@ -0,0 +1,310 @@ +import os + +import pandas as pd +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + +from computing.local_compute_helper import ( + LULC_BASE_DIR, + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + PROJECT_ROOT, + build_output_vector_path, + compute_categorical_raster_areas_for_watersheds, + compute_union_categorical_area_across_rasters_for_watersheds, + load_precomputed_watersheds, + resolve_lulc_raster_paths, + write_vector_output, +) +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) + + +LOCAL_OUTPUT_BASE_DIR = ( + PROJECT_ROOT / "data/cropping_intensity/cropping_intensity_local" +) +GEOSERVER_WORKSPACE = "crop_intensity" +LOCAL_ALGORITHM = "local_cropping_intensity" +LOCAL_ALGORITHM_VERSION = "local-1.0" +INITIAL_YEAR = 2017 + +SINGLE_KHARIF = 8 +SINGLE_NON_KHARIF = 9 +DOUBLE = 10 +TRIPLE = 11 + +YEARLY_CLASS_DEFINITIONS = ( + {"value": SINGLE_KHARIF, "label_prefix": "single_kharif_cropped_area_"}, + {"value": SINGLE_NON_KHARIF, "label_prefix": "single_non_kharif_cropped_area_"}, + {"value": DOUBLE, "label_prefix": "doubly_cropped_area_"}, + {"value": TRIPLE, "label_prefix": "triply_cropped_area_"}, +) + + +def _slug(value, fallback): + return valid_gee_text(str(value).strip().lower()) or fallback + + +def _layer_name(asset_suffix, zoi_ci_asset=False): + if zoi_ci_asset: + return f"{asset_suffix}_intensity_ZOI" + return f"{asset_suffix}_intensity" + + +def _resolve_asset_suffix(state, district, block, asset_suffix): + if asset_suffix: + return asset_suffix + if state and district and block: + return ( + f"{_slug(district, 'unknown_district')}_" + f"{_slug(block, 'unknown_block')}" + ) + raise ValueError("state, district, and block are required for local cropping intensity.") + + +def _coerce_year(year_value, label): + try: + return int(year_value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{label} must be a valid integer year.") from exc + + +def _compute_yearly_area_columns(result_gdf, raster_paths, start_year): + for year, raster_path in zip(range(start_year, start_year + len(raster_paths)), raster_paths): + print(f"Computing local cropping intensity inputs for {year}-{year + 1}: {raster_path}") + year_definitions = [ + { + "value": class_definition["value"], + "label": f"{class_definition['label_prefix']}{year}", + } + for class_definition in YEARLY_CLASS_DEFINITIONS + ] + year_result = compute_categorical_raster_areas_for_watersheds( + watersheds_gdf=result_gdf, + raster_path=raster_path, + class_definitions=year_definitions, + ) + for definition in year_definitions: + result_gdf[definition["label"]] = year_result[definition["label"]].astype( + float + ) + result_gdf[f"single_cropped_area_{year}"] = ( + result_gdf[f"single_kharif_cropped_area_{year}"] + + result_gdf[f"single_non_kharif_cropped_area_{year}"] + ) + return result_gdf + + +def _compute_total_croppable_area(result_gdf, denominator_raster_paths, end_year): + output_column = f"total_cropable_area_ever_hydroyear_{INITIAL_YEAR}_{end_year}" + print( + "Computing local ever-croppable denominator " + f"for {INITIAL_YEAR}-{end_year} using {len(denominator_raster_paths)} rasters" + ) + return compute_union_categorical_area_across_rasters_for_watersheds( + watersheds_gdf=result_gdf, + raster_paths=denominator_raster_paths, + class_values=[SINGLE_KHARIF, SINGLE_NON_KHARIF, DOUBLE, TRIPLE], + output_column=output_column, + ) + + +def _compute_cropping_intensity_columns(result_gdf, start_year, end_year): + denominator_column = f"total_cropable_area_ever_hydroyear_{INITIAL_YEAR}_{end_year}" + denominator = pd.to_numeric(result_gdf[denominator_column], errors="coerce").fillna(0.0) + + for year in range(start_year, end_year + 1): + single_area = pd.to_numeric( + result_gdf[f"single_cropped_area_{year}"], errors="coerce" + ).fillna(0.0) + double_area = pd.to_numeric( + result_gdf[f"doubly_cropped_area_{year}"], errors="coerce" + ).fillna(0.0) + triple_area = pd.to_numeric( + result_gdf[f"triply_cropped_area_{year}"], errors="coerce" + ).fillna(0.0) + + nonzero_denominator = denominator > 0 + intensity = pd.Series(0.0, index=result_gdf.index, dtype=float) + intensity.loc[nonzero_denominator] = ( + single_area.loc[nonzero_denominator] / denominator.loc[nonzero_denominator] + + 2.0 * double_area.loc[nonzero_denominator] / denominator.loc[nonzero_denominator] + + 3.0 * triple_area.loc[nonzero_denominator] / denominator.loc[nonzero_denominator] + ) + result_gdf[f"cropping_intensity_{year}"] = intensity.astype(float) + + return result_gdf + + +def run_cropping_intensity_local( + state, + district, + block, + start_year, + end_year, + asset_suffix=None, + zoi_ci_asset=False, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + lulc_dir=LULC_BASE_DIR, + push_to_geoserver=True, + sync_layer_metadata=True, +): + state = str(state).strip().lower() + district = str(district).strip().lower() + block = str(block).strip().lower() + start_year = _coerce_year(start_year, "start_year") + end_year = _coerce_year(end_year, "end_year") + + if start_year > end_year: + raise ValueError("start_year cannot be greater than end_year") + if start_year < INITIAL_YEAR: + raise ValueError( + f"start_year must be greater than or equal to {INITIAL_YEAR} for local cropping intensity." + ) + + asset_suffix = _resolve_asset_suffix(state, district, block, asset_suffix) + layer_name = _layer_name(asset_suffix, zoi_ci_asset=zoi_ci_asset) + + watersheds_gdf, watershed_source = load_precomputed_watersheds( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + print(f"Watershed boundary source: {watershed_source}") + + yearly_raster_paths = resolve_lulc_raster_paths( + start_year=start_year, + end_year=end_year, + lulc_dir=lulc_dir, + ) + denominator_raster_paths = resolve_lulc_raster_paths( + start_year=INITIAL_YEAR, + end_year=end_year, + lulc_dir=lulc_dir, + ) + + result_gdf = watersheds_gdf.copy() + result_gdf = _compute_yearly_area_columns( + result_gdf=result_gdf, + raster_paths=yearly_raster_paths, + start_year=start_year, + ) + result_gdf = _compute_total_croppable_area( + result_gdf=result_gdf, + denominator_raster_paths=denominator_raster_paths, + end_year=end_year, + ) + result_gdf = _compute_cropping_intensity_columns( + result_gdf=result_gdf, + start_year=start_year, + end_year=end_year, + ) + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + block_fallback="unknown_block", + ) + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local cropping intensity vector: {asset_id}") + + if push_to_geoserver: + geoserver_response = push_shape_to_geoserver( + os.path.splitext(asset_id)[0], + workspace=GEOSERVER_WORKSPACE, + layer_name=layer_name, + file_type="gpkg", + ) + print(f"GeoServer response: {geoserver_response}") + if not isinstance(geoserver_response, dict) or geoserver_response.get( + "status_code" + ) not in (200, 201): + return False + + if sync_layer_metadata: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Cropping Intensity", + misc={ + "start_year": start_year, + "end_year": end_year, + }, + algorithm=LOCAL_ALGORITHM, + algorithm_version=LOCAL_ALGORITHM_VERSION, + ) + if layer_id and push_to_geoserver: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + + return True + + +def _generate_cropping_intensity_local_task( + state=None, + district=None, + block=None, + roi_path=None, + asset_suffix=None, + asset_folder_list=None, + app_type="MWS", + start_year=None, + end_year=None, + gee_account_id=None, + zoi_ci_asset=None, +): + _ = roi_path, asset_folder_list, app_type, gee_account_id + return run_cropping_intensity_local( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + asset_suffix=asset_suffix, + zoi_ci_asset=bool(zoi_ci_asset), + push_to_geoserver=True, + sync_layer_metadata=True, + ) + + +@app.task(bind=True) +def generate_cropping_intensity( + self, + state=None, + district=None, + block=None, + roi_path=None, + asset_suffix=None, + asset_folder_list=None, + app_type="MWS", + start_year=None, + end_year=None, + gee_account_id=None, + zoi_ci_asset=None, +): + _ = self + return _generate_cropping_intensity_local_task( + state=state, + district=district, + block=block, + roi_path=roi_path, + asset_suffix=asset_suffix, + asset_folder_list=asset_folder_list, + app_type=app_type, + start_year=start_year, + end_year=end_year, + gee_account_id=gee_account_id, + zoi_ci_asset=zoi_ci_asset, + ) diff --git a/computing/local_compute_helper.py b/computing/local_compute_helper.py new file mode 100644 index 00000000..699a1e4e --- /dev/null +++ b/computing/local_compute_helper.py @@ -0,0 +1,724 @@ +import os +from contextlib import ExitStack +from pathlib import Path + +import geopandas as gpd +import numpy as np +import pandas as pd +import rasterio +from rasterio.mask import mask +from shapely.geometry import mapping +from utilities.gee_utils import valid_gee_text + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +PRECOMPUTED_TEHSIL_WATERSHED_DIR = ( + PROJECT_ROOT / "data/base_layers/tehsil_watersheds" +) +PRECOMPUTED_ROI_EXTENSIONS = (".gpkg", ".geojson") +AEZ_VECTOR_PATH = ( + PROJECT_ROOT / "data/base_layers/AEZs/Agro_Ecological_Regions.shp" +) +LULC_BASE_DIR = PROJECT_ROOT / "data/base_layers/lulc" +TERRAIN_RASTER_PATH = ( + PROJECT_ROOT / "data/base_layers/terrain_raster_fabdam_pan_india.tif" +) +VALID_COMPUTE_TYPES = {"gee", "local"} +MIN_WATERSHED_AREA_HA = 400.0 +LULC_CLASSES = np.arange(1, 13, dtype=np.int16) + +TERRAIN_CLUSTER_CENTROIDS = np.array( + [ + [0.36255426, 0.21039965, 0.12161905, 0.17393119, 0.13149585], + [0.09171062, 0.84299211, 0.035222, 0.02172654, 0.00834873], + [0.08497599, 0.01051893, 0.23763531, 0.37992855, 0.28694122], + [0.22301813, 0.5611825, 0.08511123, 0.07314189, 0.05754624], + ], + dtype=np.float64, +) + +PLAIN_CLASSES = {5} +VALLEY_CLASSES = {1, 2, 4, 9} +HILL_SLOPES_CLASSES = {8} +RIDGE_CLASSES = {3, 7, 10, 11} +SLOPY_CLASSES = {6} + + +def _slug(value, fallback): + return valid_gee_text(str(value).strip().lower()) or fallback + + +def ensure_file_exists(path, label): + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"{label} not found: {path}") + + +def validate_geometry(gdf): + gdf = gdf[gdf.geometry.notna()].copy() + if gdf.empty: + return gdf + invalid = ~gdf.is_valid + if invalid.any(): + gdf.loc[invalid, "geometry"] = gdf.loc[invalid, "geometry"].buffer(0) + return gdf[~gdf.geometry.is_empty].copy() + + +def read_validated_vector_file(path, empty_message): + gdf = validate_geometry(gpd.read_file(path)) + if gdf.empty: + raise ValueError(empty_message) + return gdf + + +def resolve_precomputed_vector_file( + state, + district, + block, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + extensions=PRECOMPUTED_ROI_EXTENSIONS, + missing_file_label="Precomputed vector file", +): + roi_dir = Path(precomputed_roi_dir or PRECOMPUTED_TEHSIL_WATERSHED_DIR) + state_slug = _slug(state, "unknown_state") + district_slug = _slug(district, "unknown_district") + block_slug = _slug(block, "unknown_tehsil") + + expected_paths = [ + roi_dir / state_slug / district_slug / f"{block_slug}{ext}" + for ext in extensions + ] + for path in expected_paths: + if path.exists(): + return path + + raise FileNotFoundError( + f"{missing_file_label} not found. " + f"state={state}, district={district}, block={block}. " + f"Expected one of: {[str(path) for path in expected_paths]}" + ) + + +def load_precomputed_watersheds( + state, + district, + block, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, +): + watershed_path = resolve_precomputed_vector_file( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + missing_file_label="Precomputed watershed boundary file", + ) + watersheds_gdf = read_validated_vector_file( + watershed_path, + f"Precomputed watershed file has no valid geometries: {watershed_path}", + ) + print(f"Loaded watershed boundaries: {watershed_path}") + return watersheds_gdf, str(watershed_path) + + +def load_precomputed_roi( + state, + district, + block, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, +): + roi_path = resolve_precomputed_vector_file( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + missing_file_label="Precomputed tehsil watershed file", + ) + roi_gdf = read_validated_vector_file( + roi_path, + f"Precomputed ROI file has no valid geometries: {roi_path}", + ) + print(f"Loaded precomputed ROI file: {roi_path}") + return roi_gdf + + +def _build_output_dir( + output_base_dir, + state=None, + district=None, + block=None, + custom_subdir="custom", + block_fallback="unknown_tehsil", +): + output_base_dir = Path(output_base_dir) + if state and district and block: + output_dir = ( + output_base_dir + / _slug(state, "unknown_state") + / _slug(district, "unknown_district") + / _slug(block, block_fallback) + ) + else: + output_dir = output_base_dir / custom_subdir + output_dir.mkdir(parents=True, exist_ok=True) + return output_dir + + +def build_output_vector_path( + layer_name, + state, + district, + block, + output_base_dir, + block_fallback="unknown_block", +): + output_dir = _build_output_dir( + output_base_dir=output_base_dir, + state=state, + district=district, + block=block, + block_fallback=block_fallback, + ) + return output_dir / f"{layer_name}.gpkg" + + +def build_output_raster_path( + layer_name, + output_base_dir, + state=None, + district=None, + block=None, + custom_subdir="custom", + block_fallback="unknown_tehsil", +): + output_dir = _build_output_dir( + output_base_dir=output_base_dir, + state=state, + district=district, + block=block, + custom_subdir=custom_subdir, + block_fallback=block_fallback, + ) + return output_dir / f"{layer_name}.tif" + + +def write_vector_output(gdf, output_path, layer_name): + gdf.to_file(output_path, driver="GPKG", layer=layer_name) + return str(output_path) + + +def clip_raster_with_roi(roi_gdf, raster_path, output_path, raster_label="Raster"): + ensure_file_exists(raster_path, raster_label) + + with rasterio.open(raster_path) as src: + clip_gdf = roi_gdf + if src.crs and clip_gdf.crs and clip_gdf.crs != src.crs: + clip_gdf = clip_gdf.to_crs(src.crs) + + clip_gdf = validate_geometry(clip_gdf) + shapes = [ + mapping(geom) + for geom in clip_gdf.geometry + if geom is not None and not geom.is_empty + ] + if not shapes: + raise ValueError("No valid ROI geometry available for raster clipping.") + + clipped_data, clipped_transform = mask(src, shapes=shapes, crop=True) + clipped_meta = src.meta.copy() + clipped_meta.update( + { + "driver": "GTiff", + "height": clipped_data.shape[1], + "width": clipped_data.shape[2], + "transform": clipped_transform, + "compress": "lzw", + } + ) + + with rasterio.open(output_path, "w", **clipped_meta) as dst: + dst.write(clipped_data) + + return str(output_path) + + +def push_local_raster_to_geoserver(file_path, layer_name, workspace, style_name=None): + from utilities.geoserver_utils import Geoserver + + geo = Geoserver() + geo.delete_raster_store(workspace=workspace, store=layer_name) + upload_response = geo.create_coveragestore( + path=file_path, + workspace=workspace, + layer_name=layer_name, + ) + style_response = None + if style_name: + style_response = geo.publish_style( + layer_name=layer_name, + style_name=style_name, + workspace=workspace, + ) + return upload_response, style_response + + + +def compute_pixel_area_grid(transform, height, width, crs): + if crs is None: + raise ValueError("Raster CRS is missing; cannot compute pixel areas.") + + if getattr(crs, "is_geographic", False): + lon_width_radians = np.deg2rad(abs(transform.a)) + row_indices = np.arange(height, dtype=np.float64) + lat_top = transform.f + (row_indices * transform.e) + lat_bottom = lat_top + transform.e + earth_radius_m = 6378137.0 + row_areas = (earth_radius_m**2) * lon_width_radians * np.abs( + np.sin(np.deg2rad(lat_top)) - np.sin(np.deg2rad(lat_bottom)) + ) + return np.broadcast_to(row_areas[:, None], (height, width)) + + pixel_area = abs(transform.a * transform.e) + return np.full((height, width), pixel_area, dtype=np.float64) + + + +def compute_categorical_raster_areas_for_watersheds( + watersheds_gdf, + raster_path, + class_definitions, +): + ensure_file_exists(raster_path, "Categorical raster") + + with rasterio.open(raster_path) as src: + working_gdf = watersheds_gdf.copy() + if working_gdf.crs is None: + raise ValueError( + "Watershed CRS is missing; cannot align with categorical raster." + ) + if src.crs and working_gdf.crs != src.crs: + working_gdf = working_gdf.to_crs(src.crs) + + nodata = src.nodata + computed_rows = [] + empty_result = { + class_definition["label"]: 0.0 + for class_definition in class_definitions + } + + total = len(working_gdf) + for index, row in enumerate(working_gdf.itertuples(index=False), start=1): + geom = row.geometry + if geom is None or geom.is_empty: + computed_rows.append(empty_result.copy()) + continue + + try: + clipped, clipped_transform = mask( + src, + [mapping(geom)], + crop=True, + filled=False, + ) + except ValueError: + computed_rows.append(empty_result.copy()) + continue + + data = clipped[0] + if data.size == 0: + computed_rows.append(empty_result.copy()) + continue + + values = np.asarray(data, dtype=np.float64) + values = np.where(np.isfinite(values), values, 0) + values = np.rint(values).astype(np.int16, copy=False) + valid_mask = ~np.ma.getmaskarray(data) + if nodata is not None and not np.isnan(nodata): + valid_mask &= values != int(round(nodata)) + + pixel_area_ha = ( + compute_pixel_area_grid( + transform=clipped_transform, + height=values.shape[0], + width=values.shape[1], + crs=src.crs, + ) + * 0.0001 + ) + + row_result = {} + for class_definition in class_definitions: + raw_values = class_definition.get("values", class_definition.get("value")) + if isinstance(raw_values, (list, tuple, set, np.ndarray)): + class_values = list(raw_values) + else: + class_values = [raw_values] + class_mask = valid_mask & np.isin(values, class_values) + row_result[class_definition["label"]] = float( + pixel_area_ha[class_mask].sum() + ) + + computed_rows.append(row_result) + + if index % 200 == 0 or index == total: + print( + f"Computed categorical raster areas for {index}/{total} watersheds" + ) + + result = watersheds_gdf.copy() + computed_df = pd.DataFrame(computed_rows) + for column in computed_df.columns: + result[column] = computed_df[column].values + return result + + +def compute_union_categorical_area_across_rasters_for_watersheds( + watersheds_gdf, + raster_paths, + class_values, + output_column, +): + if not raster_paths: + raise ValueError("At least one raster path is required for union area computation.") + + for raster_path in raster_paths: + ensure_file_exists(raster_path, "Categorical raster") + + if isinstance(class_values, (list, tuple, set, np.ndarray)): + class_values = list(class_values) + else: + class_values = [class_values] + + with ExitStack() as stack: + sources = [stack.enter_context(rasterio.open(path)) for path in raster_paths] + aligned_gdfs = [] + for src in sources: + aligned = watersheds_gdf.copy() + if aligned.crs is None: + raise ValueError( + "Watershed CRS is missing; cannot align with categorical raster." + ) + if src.crs and aligned.crs != src.crs: + aligned = aligned.to_crs(src.crs) + aligned_gdfs.append(aligned) + + computed_rows = [] + total = len(watersheds_gdf) + + for index in range(total): + union_mask = None + union_pixel_area_ha = None + + for src, aligned_gdf in zip(sources, aligned_gdfs): + geom = aligned_gdf.iloc[index].geometry + if geom is None or geom.is_empty: + continue + + try: + clipped, clipped_transform = mask( + src, + [mapping(geom)], + crop=True, + filled=False, + ) + except ValueError: + continue + + data = clipped[0] + if data.size == 0: + continue + + values = np.asarray(data, dtype=np.float64) + values = np.where(np.isfinite(values), values, 0) + values = np.rint(values).astype(np.int16, copy=False) + + valid_mask = ~np.ma.getmaskarray(data) + nodata = src.nodata + if nodata is not None and not np.isnan(nodata): + valid_mask &= values != int(round(nodata)) + + class_mask = valid_mask & np.isin(values, class_values) + if union_mask is None: + union_mask = class_mask + union_pixel_area_ha = ( + compute_pixel_area_grid( + transform=clipped_transform, + height=values.shape[0], + width=values.shape[1], + crs=src.crs, + ) + * 0.0001 + ) + else: + if union_mask.shape != class_mask.shape: + raise ValueError( + "Local categorical rasters do not align for union area computation." + ) + union_mask |= class_mask + + if union_mask is None or union_pixel_area_ha is None: + computed_rows.append({output_column: 0.0}) + else: + computed_rows.append( + {output_column: float(union_pixel_area_ha[union_mask].sum())} + ) + + if (index + 1) % 200 == 0 or (index + 1) == total: + print( + f"Computed union categorical raster areas for {index + 1}/{total} watersheds" + ) + + result = watersheds_gdf.copy() + computed_df = pd.DataFrame(computed_rows) + result[output_column] = computed_df[output_column].values + return result + + +def get_union_geometry(gdf): + if hasattr(gdf.geometry, "union_all"): + return gdf.geometry.union_all() + return gdf.geometry.unary_union + + +def resolve_clipped_terrain_raster_path( + state, + district, + block, + clipped_raster_dir, +): + layer_stub = f"{_slug(district, 'unknown_district')}_{_slug(block, 'unknown_tehsil')}_terrain_raster" + raster_path = ( + Path(clipped_raster_dir) + / _slug(state, "unknown_state") + / _slug(district, "unknown_district") + / _slug(block, "unknown_tehsil") + / f"{layer_stub}.tif" + ) + ensure_file_exists(raster_path, "Clipped terrain raster") + return str(raster_path) + + +def _compute_cluster_id( + slopy_prop, + plain_prop, + ridge_prop, + valley_prop, + hill_slopes_prop, +): + feature_vector = np.array( + [slopy_prop, plain_prop, ridge_prop, valley_prop, hill_slopes_prop], + dtype=np.float64, + ) + distances = np.sum((TERRAIN_CLUSTER_CENTROIDS - feature_vector) ** 2, axis=1) + return int(np.argmin(distances)) + + +def _fraction(values, valid_mask, class_values): + total = int(valid_mask.sum()) + if total == 0: + return 0.0 + class_mask = np.isin(values, list(class_values)) + return float((class_mask & valid_mask).sum()) / float(total) + + +def compute_terrain_properties_for_watersheds(watersheds_gdf, raster_path): + with rasterio.open(raster_path) as src: + working_gdf = watersheds_gdf.copy() + if working_gdf.crs is None: + raise ValueError( + "Watershed CRS is missing; cannot align with raster CRS." + ) + if src.crs and working_gdf.crs != src.crs: + working_gdf = working_gdf.to_crs(src.crs) + + nodata = src.nodata + computed_rows = [] + + total = len(working_gdf) + for index, row in enumerate(working_gdf.itertuples(index=False), start=1): + geom = row.geometry + if geom is None or geom.is_empty: + computed_rows.append( + { + "plain_area": 0.0, + "valley_area": 0.0, + "hill_slopes_area": 0.0, + "ridge_area": 0.0, + "slopy_area": 0.0, + "terrainClusters": -1, + } + ) + continue + + try: + clipped, _ = mask(src, [mapping(geom)], crop=True, filled=True) + except ValueError: + computed_rows.append( + { + "plain_area": 0.0, + "valley_area": 0.0, + "hill_slopes_area": 0.0, + "ridge_area": 0.0, + "slopy_area": 0.0, + "terrainClusters": -1, + } + ) + continue + + values = clipped[0] + if values.size == 0: + computed_rows.append( + { + "plain_area": 0.0, + "valley_area": 0.0, + "hill_slopes_area": 0.0, + "ridge_area": 0.0, + "slopy_area": 0.0, + "terrainClusters": -1, + } + ) + continue + + values = np.rint(values).astype(np.int16, copy=False) + valid_mask = np.ones(values.shape, dtype=bool) + if nodata is not None and not np.isnan(nodata): + valid_mask &= values != int(round(nodata)) + valid_mask &= values != 0 + + plain_prop = _fraction(values, valid_mask, PLAIN_CLASSES) + valley_prop = _fraction(values, valid_mask, VALLEY_CLASSES) + hill_slopes_prop = _fraction( + values, valid_mask, HILL_SLOPES_CLASSES + ) + ridge_prop = _fraction(values, valid_mask, RIDGE_CLASSES) + slopy_prop = _fraction(values, valid_mask, SLOPY_CLASSES) + + if valid_mask.sum() == 0: + cluster_id = -1 + else: + cluster_id = _compute_cluster_id( + slopy_prop=slopy_prop, + plain_prop=plain_prop, + ridge_prop=ridge_prop, + valley_prop=valley_prop, + hill_slopes_prop=hill_slopes_prop, + ) + + computed_rows.append( + { + "plain_area": plain_prop * 100.0, + "valley_area": valley_prop * 100.0, + "hill_slopes_area": hill_slopes_prop * 100.0, + "ridge_area": ridge_prop * 100.0, + "slopy_area": slopy_prop * 100.0, + "terrainClusters": cluster_id, + } + ) + + if index % 200 == 0 or index == total: + print( + f"Computed terrain properties for {index}/{total} watersheds" + ) + + result = watersheds_gdf.copy() + computed_df = pd.DataFrame(computed_rows) + for column in computed_df.columns: + result[column] = computed_df[column].values + return result + + +def resolve_lulc_raster_paths( + start_year, + end_year, + lulc_dir=LULC_BASE_DIR, +): + raster_paths = [] + for year in range(int(start_year), int(end_year) + 1): + raster_path = Path(lulc_dir) / f"lulc_v3_{year}_{year + 1}.tif" + ensure_file_exists(raster_path, f"LULC raster for {year}-{year + 1}") + raster_paths.append(str(raster_path)) + return raster_paths + + +def get_watershed_areas_in_hectares(watersheds_gdf): + if "area_in_ha" in watersheds_gdf.columns: + area_in_ha = pd.to_numeric( + watersheds_gdf["area_in_ha"], errors="coerce" + ) + if area_in_ha.notna().any(): + return area_in_ha + projected = watersheds_gdf.to_crs("EPSG:6933") + return projected.geometry.area / 10000.0 + + +def filter_large_watersheds( + watersheds_gdf, + min_watershed_area_ha=MIN_WATERSHED_AREA_HA, +): + area_in_ha = get_watershed_areas_in_hectares(watersheds_gdf) + filtered = watersheds_gdf.loc[area_in_ha > min_watershed_area_ha].copy() + filtered["area"] = area_in_ha.loc[filtered.index].astype(float) * 10000.0 + return filtered.reset_index(drop=True) + + +def resolve_aez_code( + watersheds_gdf, + aez_vector_path=AEZ_VECTOR_PATH, +): + ensure_file_exists(aez_vector_path, "AEZ vector") + + aez_gdf = read_validated_vector_file( + aez_vector_path, + f"AEZ vector has no valid geometries: {aez_vector_path}", + ) + + study_area = watersheds_gdf[["geometry"]].copy() + if study_area.crs is None: + raise ValueError("Watershed CRS is missing; cannot resolve AEZ.") + if aez_gdf.crs and study_area.crs != aez_gdf.crs: + study_area = study_area.to_crs(aez_gdf.crs) + + study_union = get_union_geometry(study_area) + intersecting_aez = aez_gdf.loc[aez_gdf.intersects(study_union)].copy() + if intersecting_aez.empty: + raise ValueError("No AEZ polygon intersects the watershed study area.") + + intersecting_aez["overlap_area"] = intersecting_aez.geometry.intersection( + study_union + ).area + return int( + intersecting_aez.sort_values("overlap_area", ascending=False).iloc[0][ + "ae_regcode" + ] + ) + + +def compute_mode_lulc_array(reprojected_arrays, lulc_classes=LULC_CLASSES): + stack = np.rint(np.stack(reprojected_arrays, axis=0)).astype( + np.int16, + copy=False, + ) + valid_mask = np.isfinite(stack) & (stack > 0) + counts = np.zeros( + (len(lulc_classes), stack.shape[1], stack.shape[2]), + dtype=np.uint16, + ) + + for index, lulc_class in enumerate(lulc_classes): + counts[index] = np.sum((stack == lulc_class) & valid_mask, axis=0) + + mode_index = np.argmax(counts, axis=0) + mode_values = lulc_classes[mode_index] + max_counts = np.max(counts, axis=0) + mode_values[max_counts == 0] = 0 + return mode_values + + +def get_compute_mode(request, default="gee"): + compute = str(request.data.get("compute") or default).strip().lower() + if compute not in VALID_COMPUTE_TYPES: + raise ValueError("compute must be either 'gee' or 'local'") + return compute + + +def select_compute_task(compute, gee_task, local_task): + return gee_task if compute == "gee" else local_task diff --git a/computing/lulc/lulc_v3_local.py b/computing/lulc/lulc_v3_local.py new file mode 100644 index 00000000..5fc5a0de --- /dev/null +++ b/computing/lulc/lulc_v3_local.py @@ -0,0 +1,269 @@ +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + +from computing.local_compute_helper import ( + LULC_BASE_DIR, + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + PROJECT_ROOT, + build_output_raster_path, + clip_raster_with_roi, + load_precomputed_roi, + push_local_raster_to_geoserver, + read_validated_vector_file, + resolve_lulc_raster_paths, +) +from computing.models import Dataset +from computing.utils import save_layer_info_to_db, update_layer_sync_status + + +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/lulc/lulc_v3_local" +GEOSERVER_WORKSPACE = "LULC_v3" +GEOSERVER_STYLE = "lulc_level_3_style" +LOCAL_ALGORITHM = "local_lulc_v3_clip" +LOCAL_ALGORITHM_VERSION = "local-1.0" + + +def _slug(value, fallback): + if value is None: + return fallback + text = str(value).strip().lower() + if not text: + return fallback + return valid_gee_text(text) or fallback + + +def _resolve_roi(state, district, block, roi_path, precomputed_roi_dir): + if state and district and block: + return load_precomputed_roi( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + + if not roi_path: + raise ValueError( + "For non state/district/block runs, `roi_path` must be provided." + ) + + return read_validated_vector_file( + roi_path, + f"ROI file has no valid geometries: {roi_path}", + ) + + +def _resolve_filename_prefix(district, block, asset_suffix): + if district and block: + return ( + f"{_slug(district, 'unknown_district')}_" + f"{_slug(block, 'unknown_block')}" + ) + if asset_suffix is None or not str(asset_suffix).strip(): + raise ValueError( + "For non state/district/block runs, `asset_suffix` must be provided." + ) + return _slug(asset_suffix, "custom") + + +def _build_output_stub(filename_prefix, start_year): + return ( + f"{filename_prefix}_{start_year}-07-01_" + f"{start_year + 1}-06-30_LULCmap_10m" + ) + + +def _build_layer_name(start_year, end_year, filename_prefix): + return f"LULC_{start_year}_{end_year}_{filename_prefix}_level_3" + + +def _resolve_dataset_name(): + return "LULC_v3" if Dataset.objects.filter(name="LULC_v3").exists() else "LULC_level_3" + + +def _sync_lulc_stac(layer_id, state, district, block, start_year): + from computing.STAC_specs import generate_STAC_layerwise + + layer_stac_generated = generate_STAC_layerwise.generate_raster_stac( + state=state, + district=district, + block=block, + layer_name="land_use_land_cover_raster", + start_year=str(start_year), + ) + update_layer_sync_status( + layer_id=layer_id, + is_stac_specs_generated=layer_stac_generated, + ) + + +def run_lulc_v3_local( + state=None, + district=None, + block=None, + start_year=None, + end_year=None, + roi_path=None, + asset_suffix=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + lulc_dir=LULC_BASE_DIR, + push_to_geoserver=True, + sync_layer_metadata=True, +): + state = str(state).strip().lower() if state else None + district = str(district).strip().lower() if district else None + block = str(block).strip().lower() if block else None + start_year = int(start_year) + end_year = int(end_year) + + if end_year < start_year: + raise ValueError("end_year must be greater than or equal to start_year") + + roi_gdf = _resolve_roi( + state=state, + district=district, + block=block, + roi_path=roi_path, + precomputed_roi_dir=precomputed_roi_dir, + ) + filename_prefix = _resolve_filename_prefix(district, block, asset_suffix) + raster_paths = resolve_lulc_raster_paths( + start_year=start_year, + end_year=end_year, + lulc_dir=lulc_dir, + ) + + layer_at_geoserver = True + is_admin_run = bool(state and district and block) + + for current_year, raster_path in zip(range(start_year, end_year + 1), raster_paths): + year_start = str(current_year)[2:] + year_end = str(current_year + 1)[2:] + output_stub = _build_output_stub(filename_prefix, current_year) + output_path = build_output_raster_path( + layer_name=output_stub, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + state=state, + district=district, + block=block, + custom_subdir=_slug(asset_suffix, "custom"), + block_fallback="unknown_block", + ) + clipped_raster_path = clip_raster_with_roi( + roi_gdf=roi_gdf, + raster_path=raster_path, + output_path=output_path, + raster_label=f"LULC raster for {current_year}-{current_year + 1}", + ) + print(f"Saved local LULC raster: {clipped_raster_path}") + + layer_name = _build_layer_name( + start_year=year_start, + end_year=year_end, + filename_prefix=filename_prefix, + ) + print(f"Prepared local LULC layer {GEOSERVER_WORKSPACE}: {layer_name}") + + layer_id = None + if sync_layer_metadata and is_admin_run: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=clipped_raster_path, + dataset_name=_resolve_dataset_name(), + misc={ + "start_year": start_year, + "end_year": end_year, + }, + algorithm=LOCAL_ALGORITHM, + algorithm_version=LOCAL_ALGORITHM_VERSION, + ) + + if not push_to_geoserver: + continue + + try: + upload_res, style_res = push_local_raster_to_geoserver( + file_path=clipped_raster_path, + layer_name=layer_name, + workspace=GEOSERVER_WORKSPACE, + style_name=GEOSERVER_STYLE, + ) + print(f"GeoServer upload response for {layer_name}: {upload_res}") + print(f"GeoServer style response for {layer_name}: {style_res}") + except Exception as error: + print(f"Failed to sync local LULC raster {layer_name}: {error}") + layer_at_geoserver = False + continue + + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + _sync_lulc_stac( + layer_id=layer_id, + state=state, + district=district, + block=block, + start_year=current_year, + ) + + return layer_at_geoserver if push_to_geoserver else True + + +def _clip_lulc_v3_local_task( + state=None, + district=None, + block=None, + start_year=None, + end_year=None, + gee_account_id=None, + roi_path=None, + asset_folder=None, + asset_suffix=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + app_type="MWS", +): + _ = gee_account_id, asset_folder, app_type + return run_lulc_v3_local( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + roi_path=roi_path, + asset_suffix=asset_suffix, + precomputed_roi_dir=precomputed_roi_dir, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + + +@app.task(bind=True) +def clip_lulc_v3( + self, + state=None, + district=None, + block=None, + start_year=None, + end_year=None, + gee_account_id=None, + roi_path=None, + asset_folder=None, + asset_suffix=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + app_type="MWS", +): + _ = self + return _clip_lulc_v3_local_task( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + gee_account_id=gee_account_id, + roi_path=roi_path, + asset_folder=asset_folder, + asset_suffix=asset_suffix, + precomputed_roi_dir=precomputed_roi_dir, + app_type=app_type, + ) diff --git a/computing/lulc/lulc_vector_local.py b/computing/lulc/lulc_vector_local.py new file mode 100644 index 00000000..837df1eb --- /dev/null +++ b/computing/lulc/lulc_vector_local.py @@ -0,0 +1,197 @@ +import os + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + +from computing.local_compute_helper import ( + LULC_BASE_DIR, + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + PROJECT_ROOT, + build_output_vector_path, + compute_categorical_raster_areas_for_watersheds, + load_precomputed_watersheds, + resolve_lulc_raster_paths, + write_vector_output, +) +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) + + +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/lulc/lulc_vector_local" +GEOSERVER_WORKSPACE = "lulc_vector" +LOCAL_ALGORITHM = "local_lulc_vector" +LOCAL_ALGORITHM_VERSION = "local-1.0" + +LULC_VECTOR_CLASS_DEFINITIONS = ( + {"value": 1, "label": "built-up_area_"}, + {"value": 2, "label": "k_water_area_"}, + {"value": 3, "label": "kr_water_area_"}, + {"value": 4, "label": "krz_water_area_"}, + {"value": 5, "label": "cropland_area_"}, + {"value": 6, "label": "tree_forest_area_"}, + {"value": 7, "label": "barrenlands_area_"}, + {"value": 8, "label": "single_kharif_cropped_area_"}, + {"value": 9, "label": "single_non_kharif_cropped_area_"}, + {"value": 10, "label": "doubly_cropped_area_"}, + {"value": 11, "label": "triply_cropped_area_"}, + {"value": 12, "label": "shrub_scrub_area_"}, +) + + +def _slug(value, fallback): + text = str(value).strip().lower() + return valid_gee_text(text) or fallback + + +def _layer_name(district, block): + return f"lulc_vector_{_slug(district, 'unknown_district')}_{_slug(block, 'unknown_block')}" + + +def _year_class_definitions(year): + return [ + { + "value": class_definition["value"], + "label": f"{class_definition['label']}{year}", + } + for class_definition in LULC_VECTOR_CLASS_DEFINITIONS + ] + + +def run_lulc_vector_local( + state, + district, + block, + start_year, + end_year, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + lulc_dir=LULC_BASE_DIR, + push_to_geoserver=True, + sync_layer_metadata=True, +): + state = str(state).strip().lower() + district = str(district).strip().lower() + block = str(block).strip().lower() + start_year = int(start_year) + end_year = int(end_year) + + if end_year < start_year: + raise ValueError("end_year must be greater than or equal to start_year") + + watersheds_gdf, watershed_source = load_precomputed_watersheds( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + print(f"Watershed boundary source: {watershed_source}") + + layer_name = _layer_name(district, block) + result_gdf = watersheds_gdf.copy() + raster_paths = resolve_lulc_raster_paths( + start_year=start_year, + end_year=end_year, + lulc_dir=lulc_dir, + ) + + for year, raster_path in zip(range(start_year, end_year + 1), raster_paths): + print(f"Computing local LULC vector properties for {year}-{year + 1}: {raster_path}") + year_result = compute_categorical_raster_areas_for_watersheds( + watersheds_gdf=result_gdf, + raster_path=raster_path, + class_definitions=_year_class_definitions(year), + ) + for column in year_result.columns: + if column not in result_gdf.columns: + result_gdf[column] = year_result[column] + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + block_fallback="unknown_block", + ) + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local LULC vector: {asset_id}") + + if push_to_geoserver: + geoserver_response = push_shape_to_geoserver( + os.path.splitext(asset_id)[0], + workspace=GEOSERVER_WORKSPACE, + layer_name=layer_name, + file_type="gpkg", + ) + print(f"GeoServer response: {geoserver_response}") + if not isinstance(geoserver_response, dict) or geoserver_response.get( + "status_code" + ) not in (200, 201): + return False + + if sync_layer_metadata: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="LULC", + misc={ + "start_year": start_year, + "end_year": end_year, + }, + algorithm=LOCAL_ALGORITHM, + algorithm_version=LOCAL_ALGORITHM_VERSION, + ) + if layer_id and push_to_geoserver: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + + return True + + +def _vectorise_lulc_local_task( + state, + district, + block, + start_year, + end_year, + gee_account_id=None, +): + _ = gee_account_id + return run_lulc_vector_local( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + + +@app.task(bind=True) +def vectorise_lulc( + self, + state, + district, + block, + start_year, + end_year, + gee_account_id=None, +): + _ = self + return _vectorise_lulc_local_task( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + gee_account_id=gee_account_id, + ) diff --git a/computing/lulc_X_terrain/lulc_on_plain_cluster_local.py b/computing/lulc_X_terrain/lulc_on_plain_cluster_local.py new file mode 100644 index 00000000..5427d096 --- /dev/null +++ b/computing/lulc_X_terrain/lulc_on_plain_cluster_local.py @@ -0,0 +1,399 @@ +import os +from contextlib import ExitStack + +import numpy as np +import pandas as pd +import rasterio +from rasterio.mask import mask +from rasterio.warp import Resampling, reproject +from utilities.gee_utils import valid_gee_text + +from nrm_app.celery import app + +from computing.local_compute_helper import ( + AEZ_VECTOR_PATH, + LULC_BASE_DIR, + MIN_WATERSHED_AREA_HA, + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + PROJECT_ROOT, + TERRAIN_RASTER_PATH, + build_output_vector_path as _build_output_vector_path, + compute_mode_lulc_array as _compute_mode_lulc_array, + compute_terrain_properties_for_watersheds, + ensure_file_exists as _ensure_file_exists, + filter_large_watersheds as _filter_large_watersheds, + load_precomputed_watersheds as _load_precomputed_watersheds, + resolve_aez_code as _resolve_aez_code, + resolve_lulc_raster_paths as _resolve_lulc_raster_paths, + validate_geometry as _validate_geometry, + write_vector_output as _write_output_vector, +) +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) + +from .utils import aez_lulcXterrain_cluster_centroids + + +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/lulc_X_terrain/lulc_plain_clusters_local" +GEOSERVER_WORKSPACE = "terrain_lulc" + +PLAIN_LULC_FIELD_MAPPING = { + "barren": 7, + "double_crop": 10, + "shrubs_scrubs": 12, + "sing_crop": 8, + "sing_non_kharif_crop": 9, + "forest": 6, + "triple_crop": 11, +} + + +def _compute_plain_lulc_properties_for_feature(geometry, terrain_src, lulc_sources): + try: + terrain_data, terrain_transform = mask( + terrain_src, + [geometry.__geo_interface__], + crop=True, + filled=True, + ) + except ValueError: + return None + + terrain_values = np.rint(terrain_data[0]).astype(np.int16, copy=False) + if terrain_values.size == 0: + return None + + terrain_valid_mask = np.ones(terrain_values.shape, dtype=bool) + if terrain_src.nodata is not None: + terrain_valid_mask &= terrain_values != int(round(terrain_src.nodata)) + terrain_valid_mask &= terrain_values > 0 + + if not terrain_valid_mask.any(): + return None + + reprojected_lulc = [] + for lulc_src in lulc_sources: + destination = np.zeros(terrain_values.shape, dtype=np.float32) + reproject( + source=rasterio.band(lulc_src, 1), + destination=destination, + src_transform=lulc_src.transform, + src_crs=lulc_src.crs, + src_nodata=lulc_src.nodata, + dst_transform=terrain_transform, + dst_crs=terrain_src.crs, + dst_nodata=0, + resampling=Resampling.nearest, + ) + reprojected_lulc.append(destination) + + lulc_mode = _compute_mode_lulc_array(reprojected_lulc) + plains_mask = terrain_valid_mask & (terrain_values == 5) + slopy_mask = terrain_valid_mask & (terrain_values == 6) + plain_plus_slope_pixels = int((plains_mask | slopy_mask).sum()) + + if plain_plus_slope_pixels == 0: + return { + "LxP_cluster": -1, + "clust_name": "No plain or slope pixels", + "barren": 0.0, + "double_crop": 0.0, + "shrubs_scrubs": 0.0, + "sing_crop": 0.0, + "sing_non_kharif_crop": 0.0, + "forest": 0.0, + "triple_crop": 0.0, + } + + proportions = {} + for field_name, lulc_class in PLAIN_LULC_FIELD_MAPPING.items(): + class_pixels = int((plains_mask & (lulc_mode == lulc_class)).sum()) + proportions[field_name] = class_pixels / float(plain_plus_slope_pixels) + + return proportions + + +def _assign_plain_clusters( + plain_watersheds_gdf, + terrain_raster_path, + plain_centroids, + lulc_raster_paths, +): + centroid_vectors = np.array( + [ + plain_centroids[str(index)]["cluster_vector"] + for index in range(len(plain_centroids)) + ], + dtype=np.float64, + ) + + computed_rows = [] + with rasterio.open(terrain_raster_path) as terrain_src, ExitStack() as stack: + lulc_sources = [ + stack.enter_context(rasterio.open(path)) + for path in lulc_raster_paths + ] + total = len(plain_watersheds_gdf) + + for index, row in enumerate( + plain_watersheds_gdf.itertuples(index=False), + start=1, + ): + properties = _compute_plain_lulc_properties_for_feature( + geometry=row.geometry, + terrain_src=terrain_src, + lulc_sources=lulc_sources, + ) + + if properties is None: + computed_rows.append( + { + "LxP_cluster": -1, + "clust_name": "No valid terrain pixels", + "barren": 0.0, + "double_crop": 0.0, + "shrubs_scrubs": 0.0, + "sing_crop": 0.0, + "sing_non_kharif_crop": 0.0, + "forest": 0.0, + "triple_crop": 0.0, + } + ) + elif properties.get("LxP_cluster") == -1: + computed_rows.append(properties) + else: + feature_vector = np.array( + [ + properties["barren"], + properties["double_crop"], + properties["shrubs_scrubs"], + properties["sing_crop"], + properties["sing_non_kharif_crop"], + properties["forest"], + properties["triple_crop"], + ], + dtype=np.float64, + ) + distances = np.sum( + (centroid_vectors - feature_vector) ** 2, + axis=1, + ) + cluster_index = int(np.argmin(distances)) + + computed_rows.append( + { + "LxP_cluster": cluster_index, + "clust_name": plain_centroids[str(cluster_index)][ + "cluster_name" + ], + "barren": properties["barren"] * 100.0, + "double_crop": properties["double_crop"] * 100.0, + "shrubs_scrubs": properties["shrubs_scrubs"] * 100.0, + "sing_crop": properties["sing_crop"] * 100.0, + "sing_non_kharif_crop": ( + properties["sing_non_kharif_crop"] * 100.0 + ), + "forest": properties["forest"] * 100.0, + "triple_crop": properties["triple_crop"] * 100.0, + } + ) + + if index % 200 == 0 or index == total: + print( + f"Computed plain LULC clusters for {index}/{total} watersheds" + ) + + result = plain_watersheds_gdf.copy() + computed_df = pd.DataFrame(computed_rows) + for column in computed_df.columns: + result[column] = computed_df[column].values + return result + + +def run_lulc_on_plain_cluster_local( + state, + district, + block, + start_year, + end_year, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + aez_vector_path=AEZ_VECTOR_PATH, + lulc_dir=LULC_BASE_DIR, + terrain_raster_path=TERRAIN_RASTER_PATH, + push_to_geoserver=True, + sync_layer_metadata=True, +): + state = str(state).strip() + district = str(district).strip() + block = str(block).strip() + start_year = int(start_year) + end_year = int(end_year) + + if start_year > end_year: + raise ValueError("start_year cannot be greater than end_year") + + _ensure_file_exists(terrain_raster_path, "Terrain raster") + lulc_raster_paths = _resolve_lulc_raster_paths( + start_year=start_year, + end_year=end_year, + lulc_dir=lulc_dir, + ) + + watersheds_gdf, watershed_source = _load_precomputed_watersheds( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + watersheds_gdf = _validate_geometry(watersheds_gdf) + if watersheds_gdf.empty: + raise ValueError("No valid watershed geometries found for local processing.") + + aez_code = _resolve_aez_code( + watersheds_gdf, + aez_vector_path=aez_vector_path, + ) + plain_centroids = aez_lulcXterrain_cluster_centroids[f"aez{aez_code}"]["plains"] + + filtered_watersheds = _filter_large_watersheds(watersheds_gdf) + if filtered_watersheds.empty: + raise ValueError( + f"No watersheds larger than {MIN_WATERSHED_AREA_HA} ha found for {state}/{district}/{block}." + ) + + terrain_classified = compute_terrain_properties_for_watersheds( + watersheds_gdf=filtered_watersheds, + raster_path=str(terrain_raster_path), + ) + terrain_classified["terrain_cluster"] = terrain_classified[ + "terrainClusters" + ].astype(int) + plain_watersheds = terrain_classified.loc[ + terrain_classified["terrain_cluster"] != 2 + ].copy() + if plain_watersheds.empty: + raise ValueError( + f"No plain-cluster watersheds found for {state}/{district}/{block}." + ) + + temp_columns = [ + "terrainClusters", + "plain_area", + "valley_area", + "hill_slopes_area", + "ridge_area", + "slopy_area", + ] + plain_watersheds.drop( + columns=[ + column + for column in temp_columns + if column in plain_watersheds.columns + ], + inplace=True, + ) + + result_gdf = _assign_plain_clusters( + plain_watersheds_gdf=plain_watersheds, + terrain_raster_path=str(terrain_raster_path), + plain_centroids=plain_centroids, + lulc_raster_paths=lulc_raster_paths, + ) + + layer_name = ( + f"{valid_gee_text(str(district).strip().lower())}_" + f"{valid_gee_text(str(block).strip().lower())}_lulc_plain" + ) + output_path = _build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + block_fallback="unknown_block", + ) + asset_id = _write_output_vector( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local plain-cluster vector: {asset_id}") + print(f"Watershed boundary source: {watershed_source}") + print(f"Resolved AEZ code: {aez_code}") + + geoserver_response = None + if push_to_geoserver: + geoserver_response = push_shape_to_geoserver( + os.path.splitext(asset_id)[0], + workspace=GEOSERVER_WORKSPACE, + layer_name=layer_name, + file_type="gpkg", + ) + print(f"GeoServer response: {geoserver_response}") + + if not isinstance(geoserver_response, dict) or geoserver_response.get( + "status_code" + ) not in (200, 201): + return False + + if sync_layer_metadata: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Terrain LULC", + misc={"start_year": start_year, "end_year": end_year}, + ) + if layer_id and push_to_geoserver: + update_layer_sync_status( + layer_id=layer_id, + sync_to_geoserver=True, + ) + + return True + + +def _generate_lulc_on_plain_cluster_local_task( + state, + district, + block, + start_year, + end_year, + gee_account_id=None, +): + _ = gee_account_id + return run_lulc_on_plain_cluster_local( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + + +@app.task(bind=True) +def lulc_on_plain_cluster_local( + self, + state, + district, + block, + start_year, + end_year, + gee_account_id=None, +): + _ = self + return _generate_lulc_on_plain_cluster_local_task( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + gee_account_id=gee_account_id, + ) diff --git a/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py b/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py new file mode 100644 index 00000000..737fa926 --- /dev/null +++ b/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py @@ -0,0 +1,392 @@ +import os + +import numpy as np +import pandas as pd +import rasterio +from rasterio.mask import mask +from rasterio.warp import Resampling, reproject +from utilities.gee_utils import valid_gee_text + +from nrm_app.celery import app + +from computing.local_compute_helper import ( + AEZ_VECTOR_PATH, + LULC_BASE_DIR, + MIN_WATERSHED_AREA_HA, + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + PROJECT_ROOT, + TERRAIN_RASTER_PATH, + build_output_vector_path as _build_output_vector_path, + compute_mode_lulc_array as _compute_mode_lulc_array, + compute_terrain_properties_for_watersheds, + ensure_file_exists as _ensure_file_exists, + filter_large_watersheds as _filter_large_watersheds, + load_precomputed_watersheds as _load_precomputed_watersheds, + resolve_aez_code as _resolve_aez_code, + resolve_lulc_raster_paths as _resolve_lulc_raster_paths, + validate_geometry as _validate_geometry, + write_vector_output as _write_output_vector, +) +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) + +from .utils import aez_lulcXterrain_cluster_centroids + + +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/lulc_X_terrain/lulc_slope_clusters_local" +GEOSERVER_WORKSPACE = "terrain_lulc" +SLOPE_LULC_FIELD_MAPPING = { + "barren": 7, + "double": 10, + "shrub_scrub": 12, + "sing_kharif": 8, + "sing_non_kharif": 9, + "forests": 6, + "triple": 11, +} + + +def _compute_slope_lulc_properties_for_feature(geometry, terrain_src, lulc_sources): + try: + terrain_data, terrain_transform = mask( + terrain_src, + [geometry.__geo_interface__], + crop=True, + filled=True, + ) + except ValueError: + return None + + terrain_values = np.rint(terrain_data[0]).astype(np.int16, copy=False) + if terrain_values.size == 0: + return None + + terrain_valid_mask = np.ones(terrain_values.shape, dtype=bool) + if terrain_src.nodata is not None: + terrain_valid_mask &= terrain_values != int(round(terrain_src.nodata)) + terrain_valid_mask &= terrain_values > 0 + + if not terrain_valid_mask.any(): + return None + + reprojected_lulc = [] + for lulc_src in lulc_sources: + destination = np.zeros(terrain_values.shape, dtype=np.float32) + reproject( + source=rasterio.band(lulc_src, 1), + destination=destination, + src_transform=lulc_src.transform, + src_crs=lulc_src.crs, + src_nodata=lulc_src.nodata, + dst_transform=terrain_transform, + dst_crs=terrain_src.crs, + dst_nodata=0, + resampling=Resampling.nearest, + ) + reprojected_lulc.append(destination) + + lulc_mode = _compute_mode_lulc_array(reprojected_lulc) + plains_mask = terrain_valid_mask & (terrain_values == 5) + slopy_mask = terrain_valid_mask & (terrain_values == 6) + plain_plus_slope_pixels = int((plains_mask | slopy_mask).sum()) + + if plain_plus_slope_pixels == 0: + return { + "LxS_cluster": -1, + "clust_name": "No plain or slope pixels", + "barren": 0.0, + "double": 0.0, + "shrub_scrub": 0.0, + "sing_kharif": 0.0, + "sing_non_kharif": 0.0, + "forests": 0.0, + "triple": 0.0, + } + + proportions = {} + for field_name, lulc_class in SLOPE_LULC_FIELD_MAPPING.items(): + class_pixels = int((slopy_mask & (lulc_mode == lulc_class)).sum()) + proportions[field_name] = class_pixels / float(plain_plus_slope_pixels) + + return proportions + + +def _assign_slope_clusters( + slope_watersheds_gdf, + terrain_raster_path, + slope_centroids, + lulc_raster_paths, +): + centroid_vectors = np.array( + [ + slope_centroids[str(index)]["cluster_vector"] + for index in range(len(slope_centroids)) + ], + dtype=np.float64, + ) + + computed_rows = [] + with rasterio.open(terrain_raster_path) as terrain_src: + lulc_sources = [rasterio.open(path) for path in lulc_raster_paths] + try: + total = len(slope_watersheds_gdf) + for index, row in enumerate( + slope_watersheds_gdf.itertuples(index=False), + start=1, + ): + properties = _compute_slope_lulc_properties_for_feature( + geometry=row.geometry, + terrain_src=terrain_src, + lulc_sources=lulc_sources, + ) + + if properties is None: + computed_rows.append( + { + "LxS_cluster": -1, + "clust_name": "No valid terrain pixels", + "barren": 0.0, + "double": 0.0, + "shrub_scrub": 0.0, + "sing_kharif": 0.0, + "sing_non_kharif": 0.0, + "forests": 0.0, + "triple": 0.0, + } + ) + elif properties.get("LxS_cluster") == -1: + computed_rows.append(properties) + else: + feature_vector = np.array( + [ + properties["barren"], + properties["shrub_scrub"], + properties["forests"], + ], + dtype=np.float64, + ) + distances = np.sum( + (centroid_vectors - feature_vector) ** 2, + axis=1, + ) + cluster_index = int(np.argmin(distances)) + + computed_rows.append( + { + "LxS_cluster": cluster_index, + "clust_name": slope_centroids[str(cluster_index)][ + "cluster_name" + ], + "barren": properties["barren"] * 100.0, + "double": properties["double"] * 100.0, + "shrub_scrub": properties["shrub_scrub"] * 100.0, + "sing_kharif": properties["sing_kharif"] * 100.0, + "sing_non_kharif": ( + properties["sing_non_kharif"] * 100.0 + ), + "forests": properties["forests"] * 100.0, + "triple": properties["triple"] * 100.0, + } + ) + + if index % 200 == 0 or index == total: + print( + f"Computed slope LULC clusters for {index}/{total} watersheds" + ) + finally: + for lulc_src in lulc_sources: + lulc_src.close() + + result = slope_watersheds_gdf.copy() + computed_df = pd.DataFrame(computed_rows) + for column in computed_df.columns: + result[column] = computed_df[column].values + return result + + +def run_lulc_on_slope_cluster_local( + state, + district, + block, + start_year, + end_year, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + aez_vector_path=AEZ_VECTOR_PATH, + lulc_dir=LULC_BASE_DIR, + terrain_raster_path=TERRAIN_RASTER_PATH, + push_to_geoserver=True, + sync_layer_metadata=True, +): + state = str(state).strip() + district = str(district).strip() + block = str(block).strip() + start_year = int(start_year) + end_year = int(end_year) + + if start_year > end_year: + raise ValueError("start_year cannot be greater than end_year") + + _ensure_file_exists(terrain_raster_path, "Terrain raster") + lulc_raster_paths = _resolve_lulc_raster_paths( + start_year=start_year, + end_year=end_year, + lulc_dir=lulc_dir, + ) + + watersheds_gdf, watershed_source = _load_precomputed_watersheds( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + watersheds_gdf = _validate_geometry(watersheds_gdf) + if watersheds_gdf.empty: + raise ValueError("No valid watershed geometries found for local processing.") + + aez_code = _resolve_aez_code( + watersheds_gdf, + aez_vector_path=aez_vector_path, + ) + slope_centroids = aez_lulcXterrain_cluster_centroids[f"aez{aez_code}"]["slopes"] + + filtered_watersheds = _filter_large_watersheds(watersheds_gdf) + if filtered_watersheds.empty: + raise ValueError( + f"No watersheds larger than {MIN_WATERSHED_AREA_HA} ha found for {state}/{district}/{block}." + ) + + terrain_classified = compute_terrain_properties_for_watersheds( + watersheds_gdf=filtered_watersheds, + raster_path=str(terrain_raster_path), + ) + terrain_classified["terrain_cluster"] = terrain_classified[ + "terrainClusters" + ].astype(int) + slope_watersheds = terrain_classified.loc[ + terrain_classified["terrain_cluster"].isin([0, 3]) + ].copy() + if slope_watersheds.empty: + raise ValueError( + f"No slope-cluster watersheds found for {state}/{district}/{block}." + ) + + temp_columns = [ + "terrainClusters", + "plain_area", + "valley_area", + "hill_slopes_area", + "ridge_area", + "slopy_area", + ] + slope_watersheds.drop( + columns=[ + column + for column in temp_columns + if column in slope_watersheds.columns + ], + inplace=True, + ) + + result_gdf = _assign_slope_clusters( + slope_watersheds_gdf=slope_watersheds, + terrain_raster_path=str(terrain_raster_path), + slope_centroids=slope_centroids, + lulc_raster_paths=lulc_raster_paths, + ) + + layer_name = ( + f"{valid_gee_text(str(district).strip().lower())}_" + f"{valid_gee_text(str(block).strip().lower())}_lulc_slope" + ) + output_path = _build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + block_fallback="unknown_block", + ) + asset_id = _write_output_vector( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local slope-cluster vector: {asset_id}") + print(f"Watershed boundary source: {watershed_source}") + print(f"Resolved AEZ code: {aez_code}") + + if push_to_geoserver: + geoserver_response = push_shape_to_geoserver( + str(output_path.with_suffix("")), + workspace=GEOSERVER_WORKSPACE, + layer_name=layer_name, + file_type="gpkg", + ) + print(f"GeoServer response: {geoserver_response}") + + if not isinstance(geoserver_response, dict) or geoserver_response.get( + "status_code" + ) not in (200, 201): + return False + + if sync_layer_metadata: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Terrain LULC", + misc={"start_year": start_year, "end_year": end_year}, + ) + if layer_id and push_to_geoserver: + update_layer_sync_status( + layer_id=layer_id, + sync_to_geoserver=True, + ) + + return True + + +def _generate_lulc_on_slope_cluster_local_task( + state, + district, + block, + start_year, + end_year, + gee_account_id=None, +): + _ = gee_account_id + return run_lulc_on_slope_cluster_local( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + + +@app.task(bind=True) +def lulc_on_slope_cluster_local( + self, + state, + district, + block, + start_year, + end_year, + gee_account_id=None, +): + _ = self + return _generate_lulc_on_slope_cluster_local_task( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + gee_account_id=gee_account_id, + ) diff --git a/computing/misc/aquifer_vector_local.py b/computing/misc/aquifer_vector_local.py new file mode 100644 index 00000000..abf7e55b --- /dev/null +++ b/computing/misc/aquifer_vector_local.py @@ -0,0 +1,419 @@ +import os + +import pandas as pd +from utilities.gee_utils import valid_gee_text + +from nrm_app.celery import app + +from computing.local_compute_helper import ( + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + PROJECT_ROOT, + build_output_vector_path, + get_watershed_areas_in_hectares, + load_precomputed_watersheds, + read_validated_vector_file, + validate_geometry, + write_vector_output, +) +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) + + +AQUIFER_VECTOR_PATH = PROJECT_ROOT / "data/base_layers/Aquifer_vector.geojson" +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/misc/aquifer_vector_local" +GEOSERVER_WORKSPACE = "aquifer" + +YIELD_VALUE_MAP = { + "": None, + "-": None, + "Upto 2%": 0.02, + "1-2%": 0.02, + "Upto 1.5%": 0.015, + "Upto 3%": 0.03, + "Upto 2.5%": 0.025, + "6 - 8%": 0.08, + "1-1.5%": 0.015, + "2-3%": 0.03, + "Upto 4%": 0.04, + "Upto 5%": 0.05, + "Upto -3.5%": 0.035, + "Upto 3 %": 0.03, + "Upto 9%": 0.09, + "1-2.5": 0.025, + "Upto 1.2%": 0.012, + "Upto 5-2%": 0.05, + "Upto 1%": 0.01, + "Up to 1.5%": 0.015, + "Upto 8%": 0.08, + "Upto 6%": 0.06, + "0.08": 0.08, + "8 - 16%": 0.16, + "Not Explored": None, + "8 - 15%": 0.15, + "6 - 10%": 0.10, + "6 - 15%": 0.15, + "8 - 20%": 0.20, + "8 - 10%": 0.10, + "6 - 12%": 0.12, + "6 - 16%": 0.16, + "8 - 12%": 0.12, + "8 - 18%": 0.18, + "Upto 3.5%": 0.035, + "Upto 15%": 0.15, + "1.5-2%": 0.02, +} + +PRINCIPAL_AQUIFERS = [ + "Laterite", + "Basalt", + "Sandstone", + "Shale", + "Limestone", + "Granite", + "Schist", + "Quartzite", + "Charnockite", + "Khondalite", + "Banded Gneissic Complex", + "Gneiss", + "Intrusive", + "Alluvium", + "None", +] + +PRINCIPAL_AQUIFER_PERCENT_COLUMNS = [ + f"principle_aq_{aquifer_name}_percent" + for aquifer_name in PRINCIPAL_AQUIFERS +] + + +def _safe_string(value): + if pd.isna(value): + return "" + return str(value).strip() + + +def _safe_int(value): + if pd.isna(value): + return None + return int(float(value)) + + +def _normalize_principal_name(value): + principal_name = _safe_string(value) + return principal_name or "None" + + +def _map_yield_value(value): + return YIELD_VALUE_MAP.get(_safe_string(value)) + + +def _build_empty_aquifer_properties(watershed_row, area_in_ha): + properties = { + "uid": watershed_row.get("uid"), + "id": watershed_row.get("id", watershed_row.get("uid")), + "area_in_ha": float(area_in_ha) if pd.notna(area_in_ha) else 0.0, + "total_weighted_yield": 0.0, + "%_area_aquifer": 0.0, + "aquifer_count": 0, + "aquifer_class": "No Data", + "Age": "", + "Lithology_": None, + "Major_Aq_1": "", + "Major_Aqui": "", + "Principal_": "", + "Recommende": None, + "yeild__": "", + "zone_m": "", + "y_value": None, + } + properties.update( + { + column_name: 0.0 + for column_name in PRINCIPAL_AQUIFER_PERCENT_COLUMNS + } + ) + return properties + + +def _build_aquifer_properties(watershed_row, area_in_ha, intersections_df): + properties = _build_empty_aquifer_properties(watershed_row, area_in_ha) + dominant_aquifer = intersections_df.sort_values( + "intersection_area_m2", + ascending=False, + ).iloc[0] + pct_by_aquifer = intersections_df.groupby("principal_name")[ + "percent_area_aquifer" + ].sum() + + for aquifer_name in PRINCIPAL_AQUIFERS: + properties[f"principle_aq_{aquifer_name}_percent"] = float( + pct_by_aquifer.get(aquifer_name, 0.0) + ) + + principal_value = _safe_string(dominant_aquifer["Principal_"]) + properties.update( + { + "total_weighted_yield": float( + intersections_df["weighted_contribution"].sum() + ), + "%_area_aquifer": float(dominant_aquifer["percent_area_aquifer"]), + "aquifer_count": int(len(intersections_df)), + "aquifer_class": ( + "Alluvium" + if principal_value == "Alluvium" + else "Hard-Rock" + ), + "Age": _safe_string(dominant_aquifer["Age"]), + "Lithology_": _safe_int(dominant_aquifer["Lithology_"]), + "Major_Aq_1": _safe_string(dominant_aquifer["Major_Aq_1"]), + "Major_Aqui": _safe_string(dominant_aquifer["Major_Aqui"]), + "Principal_": principal_value, + "Recommende": _safe_int(dominant_aquifer["Recommende"]), + "yeild__": _safe_string(dominant_aquifer["yeild__"]), + "zone_m": _safe_string(dominant_aquifer["zone_m"]), + "y_value": ( + float(dominant_aquifer["y_value"]) + if pd.notna(dominant_aquifer["y_value"]) + else None + ), + } + ) + return properties + + +def _compute_aquifer_properties_for_watersheds(watersheds_gdf, aquifers_gdf): + watersheds_gdf = validate_geometry(watersheds_gdf) + if watersheds_gdf.empty: + raise ValueError("No valid watershed geometries found for local processing.") + if watersheds_gdf.crs is None: + raise ValueError("Watershed CRS is missing; cannot compute aquifer overlaps.") + + aquifers_gdf = validate_geometry(aquifers_gdf) + if aquifers_gdf.empty: + raise ValueError("Aquifer source file has no valid geometries.") + if aquifers_gdf.crs is None: + raise ValueError("Aquifer source CRS is missing; cannot compute overlaps.") + + watersheds_result = watersheds_gdf.copy() + watersheds_result["area_in_ha"] = get_watershed_areas_in_hectares( + watersheds_result + ).astype(float) + + aquifers_with_yield = aquifers_gdf.copy() + aquifers_with_yield["y_value"] = aquifers_with_yield["yeild__"].apply( + _map_yield_value + ) + aquifers_with_yield = aquifers_with_yield.loc[ + aquifers_with_yield["y_value"].notna() + ].copy() + if aquifers_with_yield.empty: + raise ValueError("Aquifer source has no records with valid yield values.") + + watersheds_projected = watersheds_result.to_crs("EPSG:6933") + aquifers_projected = aquifers_with_yield.to_crs("EPSG:6933") + + computed_rows = [] + total = len(watersheds_projected) + + for index, watershed_idx in enumerate(watersheds_projected.index, start=1): + watershed_geometry = watersheds_projected.at[watershed_idx, "geometry"] + watershed_row = watersheds_result.loc[watershed_idx] + area_in_ha = watersheds_result.at[watershed_idx, "area_in_ha"] + + if watershed_geometry is None or watershed_geometry.is_empty: + computed_rows.append( + _build_empty_aquifer_properties(watershed_row, area_in_ha) + ) + continue + + watershed_area_m2 = float(watershed_geometry.area) + if watershed_area_m2 <= 0: + computed_rows.append( + _build_empty_aquifer_properties(watershed_row, area_in_ha) + ) + continue + + intersecting_aquifers = aquifers_projected.loc[ + aquifers_projected.intersects(watershed_geometry) + ] + + intersections = [] + for _, aquifer_row in intersecting_aquifers.iterrows(): + intersection_geometry = watershed_geometry.intersection( + aquifer_row.geometry + ) + if intersection_geometry.is_empty: + continue + + intersection_area_m2 = float(intersection_geometry.area) + if intersection_area_m2 <= 0: + continue + + fraction = intersection_area_m2 / watershed_area_m2 + intersections.append( + { + "intersection_area_m2": intersection_area_m2, + "percent_area_aquifer": fraction * 100.0, + "weighted_contribution": fraction * float(aquifer_row["y_value"]), + "principal_name": _normalize_principal_name( + aquifer_row["Principal_"] + ), + "Age": aquifer_row["Age"], + "Lithology_": aquifer_row["Lithology_"], + "Major_Aq_1": aquifer_row["Major_Aq_1"], + "Major_Aqui": aquifer_row["Major_Aqui"], + "Principal_": aquifer_row["Principal_"], + "Recommende": aquifer_row["Recommende"], + "yeild__": aquifer_row["yeild__"], + "zone_m": aquifer_row["zone_m"], + "y_value": aquifer_row["y_value"], + } + ) + + if intersections: + intersections_df = pd.DataFrame(intersections) + computed_rows.append( + _build_aquifer_properties( + watershed_row, + area_in_ha, + intersections_df, + ) + ) + else: + computed_rows.append( + _build_empty_aquifer_properties(watershed_row, area_in_ha) + ) + + if index % 200 == 0 or index == total: + print(f"Computed aquifer properties for {index}/{total} watersheds") + + computed_df = pd.DataFrame(computed_rows) + for column in computed_df.columns: + watersheds_result[column] = computed_df[column].values + return watersheds_result + + +def run_aquifer_vector_local( + state, + district, + block, + aquifer_vector_path=AQUIFER_VECTOR_PATH, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + push_to_geoserver=True, + sync_layer_metadata=True, +): + state = str(state).strip().lower() + district = str(district).strip().lower() + block = str(block).strip().lower() + + watersheds_gdf, watershed_source = load_precomputed_watersheds( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + aquifers_gdf = read_validated_vector_file( + aquifer_vector_path, + f"Aquifer source file has no valid geometries: {aquifer_vector_path}", + ) + + result_gdf = _compute_aquifer_properties_for_watersheds( + watersheds_gdf=watersheds_gdf, + aquifers_gdf=aquifers_gdf, + ) + + layer_name = ( + f"aquifer_vector_{valid_gee_text(district.lower())}_" + f"{valid_gee_text(block.lower())}" + ) + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + block_fallback="unknown_block", + ) + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local aquifer vector: {asset_id}") + print(f"Watershed boundary source: {watershed_source}") + + if push_to_geoserver: + geoserver_response = push_shape_to_geoserver( + os.path.splitext(asset_id)[0], + workspace=GEOSERVER_WORKSPACE, + layer_name=layer_name, + file_type="gpkg", + ) + print(f"GeoServer response: {geoserver_response}") + if not isinstance(geoserver_response, dict) or geoserver_response.get( + "status_code" + ) not in (200, 201): + return False + + if sync_layer_metadata: + from computing.STAC_specs import generate_STAC_layerwise + + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Aquifer", + ) + if layer_id and push_to_geoserver: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + layer_stac_generated = generate_STAC_layerwise.generate_vector_stac( + state=state, + district=district, + block=block, + layer_name="aquifer_vector", + ) + update_layer_sync_status( + layer_id=layer_id, + is_stac_specs_generated=layer_stac_generated, + ) + + return True + + +def _generate_aquifer_vector_local_task( + state, + district, + block, + gee_account_id=None, +): + _ = gee_account_id + return run_aquifer_vector_local( + state=state, + district=district, + block=block, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + + +@app.task(bind=True) +def generate_aquifer_vector( + self, + state, + district, + block, + gee_account_id=None, +): + _ = self + return _generate_aquifer_vector_local_task( + state=state, + district=district, + block=block, + gee_account_id=gee_account_id, + ) diff --git a/computing/terrain_descriptor/store_watersheds_for_tehsils.py b/computing/terrain_descriptor/store_watersheds_for_tehsils.py new file mode 100644 index 00000000..5937f5df --- /dev/null +++ b/computing/terrain_descriptor/store_watersheds_for_tehsils.py @@ -0,0 +1,394 @@ +import argparse +import csv +import os +import re +import time + +import geopandas as gpd + + +DEFAULT_MICROWATERSHED_PATH = ( + "data/base_layers/Microwatershed_v2_with_details.geojson" +) +DEFAULT_TEHSIL_PATH = "data/base_layers/SOI_tehsil.geojson" +DEFAULT_OUTPUT_DIR = "data/base_layers/tehsil_watersheds" + +STATE_COLUMN_CANDIDATES = ["STATE", "state", "state_name", "State"] +DISTRICT_COLUMN_CANDIDATES = ["District", "district", "district_name", "DISTRICT"] +TEHSIL_COLUMN_CANDIDATES = ["TEHSIL", "tehsil", "tehsil_name", "block", "block_name"] +MWS_UID_COLUMN_CANDIDATES = ["uid", "UID", "Uid"] +MWS_OPTIONAL_COLUMNS = ["area_in_ha", "bacode", "sbcode", "wsconc"] + +OUTPUT_FORMATS = { + "geojson": ("GeoJSON", ".geojson"), + "gpkg": ("GPKG", ".gpkg"), +} + + +def valid_gee_text(description): + description = re.sub(r"[^a-zA-Z0-9 ,:;_-]", "", description) + return description.replace(" ", "_") + + +def _normalize_text(value): + return valid_gee_text(str(value).strip().lower()) + +def _find_matching_column(columns, candidates): + columns_list = list(columns) + lower_lookup = {col.lower(): col for col in columns_list} + for candidate in candidates: + if candidate.lower() in lower_lookup: + return lower_lookup[candidate.lower()] + + compact_lookup = { + re.sub(r"[^a-z0-9]", "", col.lower()): col for col in columns_list + } + for candidate in candidates: + compact = re.sub(r"[^a-z0-9]", "", candidate.lower()) + if compact in compact_lookup: + return compact_lookup[compact] + return None + + +def _validate_geometry(gdf, fix_invalid=False): + gdf = gdf[gdf.geometry.notna()].copy() + if gdf.empty: + return gdf + + gdf = gdf[~gdf.geometry.is_empty].copy() + if gdf.empty: + return gdf + + if fix_invalid: + invalid = ~gdf.is_valid + if invalid.any(): + gdf.loc[invalid, "geometry"] = gdf.loc[invalid, "geometry"].buffer(0) + gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty].copy() + return gdf + + +def _prepare_tehsil_boundaries(tehsil_path, state=None, district=None, tehsil=None): + tehsil_gdf = gpd.read_file(tehsil_path) + + state_col = "STATE" if "STATE" in tehsil_gdf.columns else None + district_col = "District" if "District" in tehsil_gdf.columns else None + tehsil_col = "TEHSIL" if "TEHSIL" in tehsil_gdf.columns else None + + if state_col is None: + state_col = _find_matching_column(tehsil_gdf.columns, STATE_COLUMN_CANDIDATES) + if district_col is None: + district_col = _find_matching_column(tehsil_gdf.columns, DISTRICT_COLUMN_CANDIDATES) + if tehsil_col is None: + tehsil_col = _find_matching_column(tehsil_gdf.columns, TEHSIL_COLUMN_CANDIDATES) + + if not all([state_col, district_col, tehsil_col]): + raise ValueError( + "Could not identify STATE/District/TEHSIL columns in tehsil file. " + f"Available columns: {list(tehsil_gdf.columns)}" + ) + + if state: + tehsil_gdf = tehsil_gdf[ + tehsil_gdf[state_col].fillna("").map(_normalize_text) + == _normalize_text(state) + ] + if district: + tehsil_gdf = tehsil_gdf[ + tehsil_gdf[district_col].fillna("").map(_normalize_text) + == _normalize_text(district) + ] + if tehsil: + tehsil_gdf = tehsil_gdf[ + tehsil_gdf[tehsil_col].fillna("").map(_normalize_text) + == _normalize_text(tehsil) + ] + + tehsil_gdf = _validate_geometry(tehsil_gdf, fix_invalid=True) + if tehsil_gdf.empty: + raise ValueError("No matching tehsil records found after applying filters.") + + if tehsil_gdf.crs is None: + tehsil_gdf = tehsil_gdf.set_crs("EPSG:4326") + + tehsil_gdf["__state_norm"] = tehsil_gdf[state_col].map(_normalize_text) + tehsil_gdf["__district_norm"] = tehsil_gdf[district_col].map(_normalize_text) + tehsil_gdf["__tehsil_norm"] = tehsil_gdf[tehsil_col].map(_normalize_text) + tehsil_gdf["__key"] = ( + tehsil_gdf["__state_norm"] + + "||" + + tehsil_gdf["__district_norm"] + + "||" + + tehsil_gdf["__tehsil_norm"] + ) + + meta = ( + tehsil_gdf.groupby("__key", as_index=False) + .agg( + { + state_col: "first", + district_col: "first", + tehsil_col: "first", + } + ) + .rename( + columns={ + state_col: "STATE", + district_col: "District", + tehsil_col: "TEHSIL", + } + ) + ) + + dissolved = tehsil_gdf[["__key", "geometry"]].dissolve(by="__key").reset_index() + dissolved = dissolved.merge(meta, on="__key", how="left") + dissolved = _validate_geometry(dissolved, fix_invalid=True) + + return dissolved + + +def _load_microwatersheds(microwatershed_path, fix_invalid=False): + microwatershed_gdf = gpd.read_file(microwatershed_path) + if microwatershed_gdf.empty: + raise ValueError("Microwatershed file is empty.") + + uid_col = _find_matching_column( + microwatershed_gdf.columns, MWS_UID_COLUMN_CANDIDATES + ) + if not uid_col: + raise ValueError( + "Could not identify microwatershed UID column. " + f"Available columns: {list(microwatershed_gdf.columns)}" + ) + + keep_columns = ["geometry", uid_col] + for col in MWS_OPTIONAL_COLUMNS: + if col in microwatershed_gdf.columns: + keep_columns.append(col) + + microwatershed_gdf = microwatershed_gdf[keep_columns].copy() + microwatershed_gdf = microwatershed_gdf.rename(columns={uid_col: "uid"}) + microwatershed_gdf = _validate_geometry(microwatershed_gdf, fix_invalid=fix_invalid) + + if microwatershed_gdf.crs is None: + microwatershed_gdf = microwatershed_gdf.set_crs("EPSG:4326") + + if microwatershed_gdf.empty: + raise ValueError("No valid microwatershed geometry found.") + + return microwatershed_gdf + + +def _save_subset(subset_gdf, output_path, driver): + if driver == "GPKG": + subset_gdf.to_file(output_path, driver=driver, layer="watersheds") + else: + subset_gdf.to_file(output_path, driver=driver) + + +def generate_tehsil_watershed_copies( + microwatershed_path=DEFAULT_MICROWATERSHED_PATH, + tehsil_path=DEFAULT_TEHSIL_PATH, + output_dir=DEFAULT_OUTPUT_DIR, + output_format="gpkg", + overwrite=False, + include_empty=False, + fix_invalid_mws=False, + state=None, + district=None, + tehsil=None, +): + if output_format not in OUTPUT_FORMATS: + raise ValueError( + f"Unsupported format: {output_format}. Supported: {sorted(OUTPUT_FORMATS)}" + ) + + driver, extension = OUTPUT_FORMATS[output_format] + + if not os.path.exists(microwatershed_path): + raise FileNotFoundError(f"Microwatershed file not found: {microwatershed_path}") + if not os.path.exists(tehsil_path): + raise FileNotFoundError(f"Tehsil file not found: {tehsil_path}") + + os.makedirs(output_dir, exist_ok=True) + start_time = time.time() + + print("Loading tehsil boundaries...") + tehsil_boundaries = _prepare_tehsil_boundaries( + tehsil_path=tehsil_path, + state=state, + district=district, + tehsil=tehsil, + ) + print(f"Prepared {len(tehsil_boundaries)} unique tehsil boundaries.") + + print("Loading microwatersheds (this can take time once)...") + microwatersheds = _load_microwatersheds( + microwatershed_path=microwatershed_path, + fix_invalid=fix_invalid_mws, + ) + print(f"Loaded {len(microwatersheds)} microwatershed features.") + + if tehsil_boundaries.crs != microwatersheds.crs: + tehsil_boundaries = tehsil_boundaries.to_crs(microwatersheds.crs) + + print("Building microwatershed spatial index...") + mws_sindex = microwatersheds.sindex + + manifest_rows = [] + total = len(tehsil_boundaries) + written_count = 0 + skipped_count = 0 + + for idx, row in enumerate(tehsil_boundaries.itertuples(index=False), start=1): + state_name = row.STATE + district_name = row.District + tehsil_name = row.TEHSIL + tehsil_geom = row.geometry + + try: + candidate_ids = mws_sindex.query(tehsil_geom, predicate="intersects") + except TypeError: + candidate_ids = list(mws_sindex.intersection(tehsil_geom.bounds)) + + if len(candidate_ids) == 0: + intersection_gdf = microwatersheds.iloc[0:0].copy() + else: + candidates = microwatersheds.iloc[list(candidate_ids)] + intersection_gdf = candidates[candidates.intersects(tehsil_geom)].copy() + + feature_count = len(intersection_gdf) + state_dir = valid_gee_text(str(state_name).strip().lower()) or "unknown_state" + district_dir = ( + valid_gee_text(str(district_name).strip().lower()) or "unknown_district" + ) + tehsil_file = ( + valid_gee_text(str(tehsil_name).strip().lower()) + or f"unknown_tehsil_{idx:05d}" + ) + + output_subdir = os.path.join(output_dir, state_dir, district_dir) + os.makedirs(output_subdir, exist_ok=True) + out_path = os.path.join(output_subdir, f"{tehsil_file}{extension}") + + row_status = "empty" + if feature_count > 0 or include_empty: + if feature_count > 0: + intersection_gdf["STATE"] = state_name + intersection_gdf["District"] = district_name + intersection_gdf["TEHSIL"] = tehsil_name + + if overwrite or not os.path.exists(out_path): + _save_subset(intersection_gdf, out_path, driver) + row_status = "written" + written_count += 1 + else: + row_status = "exists" + skipped_count += 1 + else: + skipped_count += 1 + + manifest_rows.append( + { + "state": state_name, + "district": district_name, + "tehsil": tehsil_name, + "feature_count": feature_count, + "status": row_status, + "output_path": out_path if (feature_count > 0 or include_empty) else "", + } + ) + + if idx % 100 == 0 or idx == total: + print(f"Processed {idx}/{total} tehsils...") + + manifest_path = os.path.join(output_dir, "tehsil_watershed_manifest.csv") + with open(manifest_path, "w", newline="", encoding="utf-8") as csvfile: + writer = csv.DictWriter( + csvfile, + fieldnames=[ + "state", + "district", + "tehsil", + "feature_count", + "status", + "output_path", + ], + ) + writer.writeheader() + writer.writerows(manifest_rows) + + elapsed = time.time() - start_time + print("Done.") + print(f"Output directory: {output_dir}") + print(f"Manifest: {manifest_path}") + print(f"Tehsils processed: {total}") + print(f"Files written: {written_count}") + print(f"Skipped: {skipped_count}") + print(f"Elapsed time: {elapsed:.2f} seconds") + + return manifest_path + + +def _build_parser(): + parser = argparse.ArgumentParser( + description=( + "Precompute and store microwatersheds for each tehsil based on spatial " + "intersection with tehsil boundaries." + ) + ) + parser.add_argument( + "--microwatershed-path", + default=DEFAULT_MICROWATERSHED_PATH, + help="Path to Microwatershed GeoJSON", + ) + parser.add_argument( + "--tehsil-path", + default=DEFAULT_TEHSIL_PATH, + help="Path to tehsil boundary GeoJSON", + ) + parser.add_argument( + "--output-dir", + default=DEFAULT_OUTPUT_DIR, + help="Directory to save per-tehsil watershed files", + ) + parser.add_argument( + "--format", + choices=sorted(OUTPUT_FORMATS.keys()), + default="gpkg", + help="Output file format", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Overwrite existing output files", + ) + parser.add_argument( + "--include-empty", + action="store_true", + help="Also write empty files for tehsils with no intersecting microwatersheds", + ) + parser.add_argument( + "--fix-invalid-mws", + action="store_true", + help="Fix invalid microwatershed geometries (slower, use only if needed)", + ) + parser.add_argument("--state", help="Optional state filter") + parser.add_argument("--district", help="Optional district filter") + parser.add_argument("--tehsil", help="Optional tehsil filter") + return parser + + +if __name__ == "__main__": + args = _build_parser().parse_args() + generate_tehsil_watershed_copies( + microwatershed_path=args.microwatershed_path, + tehsil_path=args.tehsil_path, + output_dir=args.output_dir, + output_format=args.format, + overwrite=args.overwrite, + include_empty=args.include_empty, + fix_invalid_mws=args.fix_invalid_mws, + state=args.state, + district=args.district, + tehsil=args.tehsil, + ) diff --git a/computing/terrain_descriptor/terrain_clusters_local.py b/computing/terrain_descriptor/terrain_clusters_local.py new file mode 100644 index 00000000..bc788982 --- /dev/null +++ b/computing/terrain_descriptor/terrain_clusters_local.py @@ -0,0 +1,181 @@ +import os +import shutil +import tempfile +import zipfile + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + +from computing.local_compute_helper import ( + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + build_output_vector_path, + compute_terrain_properties_for_watersheds, + load_precomputed_watersheds, + resolve_clipped_terrain_raster_path, + write_vector_output, +) + + +CLIPPED_TERRAIN_RASTER_DIR = "data/terrain/fabdem_local" +LOCAL_CLUSTER_OUTPUT_DIR = "data/terrain/terrain_clusters_local" +GEOSERVER_WORKSPACE = "terrain" + + +def _push_vector_to_geoserver(gdf, layer_name): + from utilities.geoserver_utils import Geoserver + + temp_dir = tempfile.mkdtemp(prefix=f"{layer_name}_") + gpkg_path = os.path.join(temp_dir, f"{layer_name}.gpkg") + zip_path = os.path.join(temp_dir, f"{layer_name}.zip") + + try: + gdf.to_file(gpkg_path, driver="GPKG", layer=layer_name) + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: + zipf.write(gpkg_path, arcname=os.path.basename(gpkg_path)) + + geo = Geoserver() + geo.delete_vector_store(workspace=GEOSERVER_WORKSPACE, store=layer_name) + response = geo.create_shp_datastore( + path=zip_path, + store_name=layer_name, + workspace=GEOSERVER_WORKSPACE, + file_extension="gpkg", + ) + return response + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +def run_terrain_clusters_local( + state, + district, + block, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + clipped_raster_dir=CLIPPED_TERRAIN_RASTER_DIR, + push_to_geoserver=True, + sync_layer_metadata=False, +): + state = str(state).strip() + district = str(district).strip() + block = str(block).strip() + + layer_name = ( + f"{valid_gee_text(str(district).strip().lower())}_" + f"{valid_gee_text(str(block).strip().lower())}_cluster" + ) + + watersheds_gdf, watershed_source = load_precomputed_watersheds( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + clipped_raster_path = resolve_clipped_terrain_raster_path( + state=state, + district=district, + block=block, + clipped_raster_dir=clipped_raster_dir, + ) + print(f"Using clipped terrain raster: {clipped_raster_path}") + + terrain_clusters_gdf = compute_terrain_properties_for_watersheds( + watersheds_gdf=watersheds_gdf, + raster_path=clipped_raster_path, + ) + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_CLUSTER_OUTPUT_DIR, + block_fallback="unknown_tehsil", + ) + local_vector_path = write_vector_output( + gdf=terrain_clusters_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local terrain cluster vector: {local_vector_path}") + + if push_to_geoserver: + try: + geoserver_response = _push_vector_to_geoserver( + gdf=terrain_clusters_gdf, + layer_name=layer_name, + ) + print(f"GeoServer response: {geoserver_response}") + except Exception as error: + print(f"Failed to sync terrain clusters vector to GeoServer: {error}") + return False + + if sync_layer_metadata: + from computing.STAC_specs import generate_STAC_layerwise + from computing.utils import save_layer_info_to_db, update_layer_sync_status + + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=local_vector_path, + dataset_name="Terrain Vector", + algorithm="FABDEM", + algorithm_version="2.0", + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + layer_stac_generated = generate_STAC_layerwise.generate_vector_stac( + state=state, + district=district, + block=block, + layer_name="terrain_vector", + ) + update_layer_sync_status( + layer_id=layer_id, + is_stac_specs_generated=layer_stac_generated, + ) + + print(f"Completed terrain cluster computation for {state}/{district}/{block}") + print(f"Watershed boundary source: {watershed_source}") + return True + + +def _generate_terrain_clusters_task( + state, + district, + block, + gee_account_id=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + clipped_raster_dir=CLIPPED_TERRAIN_RASTER_DIR, +): + _ = gee_account_id + return run_terrain_clusters_local( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + clipped_raster_dir=clipped_raster_dir, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + + +@app.task(bind=True) +def generate_terrain_clusters( + self, + state, + district, + block, + gee_account_id=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + clipped_raster_dir=CLIPPED_TERRAIN_RASTER_DIR, +): + _ = self + return _generate_terrain_clusters_task( + state=state, + district=district, + block=block, + gee_account_id=gee_account_id, + precomputed_roi_dir=precomputed_roi_dir, + clipped_raster_dir=clipped_raster_dir, + ) diff --git a/computing/terrain_descriptor/terrain_compute_all_local.py b/computing/terrain_descriptor/terrain_compute_all_local.py new file mode 100644 index 00000000..27e98847 --- /dev/null +++ b/computing/terrain_descriptor/terrain_compute_all_local.py @@ -0,0 +1,255 @@ +from pathlib import Path + +from nrm_app.celery import app + +from computing.local_compute_helper import PRECOMPUTED_TEHSIL_WATERSHED_DIR +from computing.lulc_X_terrain.lulc_on_plain_cluster_local import ( + run_lulc_on_plain_cluster_local, +) +from computing.lulc_X_terrain.lulc_on_slope_cluster_local import ( + run_lulc_on_slope_cluster_local, +) +from computing.terrain_descriptor.terrain_clusters_local import ( + run_terrain_clusters_local, +) +from computing.terrain_descriptor.terrain_raster_fabdem_local import ( + run_terrain_raster_fabdem_local, +) +from utilities.gee_utils import valid_gee_text + + +def _run_step(step_name, step_func, **kwargs): + print(f"Starting step: {step_name}") + step_result = step_func(**kwargs) + print(f"Completed step: {step_name} -> {step_result}") + if step_result is False: + raise RuntimeError(f"{step_name} failed") + return step_result + + +def _is_missing_block(block): + if block is None: + return True + return str(block).strip().lower() in {"", "null", "none"} + + +def _resolve_blocks_for_district( + state, + district, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, +): + district_dir = ( + Path(precomputed_roi_dir) + / (valid_gee_text(str(state).strip().lower()) or "unknown_state") + / (valid_gee_text(str(district).strip().lower()) or "unknown_district") + ) + if not district_dir.exists(): + raise FileNotFoundError( + f"District watershed directory not found: {district_dir}" + ) + + asset_block_slugs = sorted( + { + path.stem + for path in district_dir.iterdir() + if path.is_file() and path.suffix.lower() in {".gpkg", ".geojson"} + } + ) + if not asset_block_slugs: + raise FileNotFoundError( + f"No watershed files found for district: {state}/{district}" + ) + + canonical_block_names = {} + try: + from geoadmin.models import DistrictSOI, StateSOI, TehsilSOI + + state_obj = StateSOI.objects.get(state_name__iexact=state) + district_obj = DistrictSOI.objects.get( + district_name__iexact=district, + state=state_obj, + ) + tehsil_names = TehsilSOI.objects.filter(district=district_obj).values_list( + "tehsil_name", + flat=True, + ) + canonical_block_names = { + ( + valid_gee_text(str(tehsil_name).strip().lower()) + or "unknown_block" + ): tehsil_name + for tehsil_name in tehsil_names + } + except Exception as error: + print( + "Unable to resolve canonical block names from DB. " + f"Falling back to asset slugs. Error: {error}" + ) + + return [ + canonical_block_names.get(block_slug, block_slug) + for block_slug in asset_block_slugs + ] + + +def _run_terrain_compute_for_block( + state, + district, + block, + start_year, + end_year, +): + state = str(state).strip() + district = str(district).strip() + block = str(block).strip() + start_year = int(start_year) + end_year = int(end_year) + + results = {} + + results["terrain_raster"] = _run_step( + "terrain_raster", + run_terrain_raster_fabdem_local, + state=state, + district=district, + block=block, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + results["terrain_vector"] = _run_step( + "terrain_vector", + run_terrain_clusters_local, + state=state, + district=district, + block=block, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + results["terrain_lulc_slope"] = _run_step( + "terrain_lulc_slope", + run_lulc_on_slope_cluster_local, + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + results["terrain_lulc_plain"] = _run_step( + "terrain_lulc_plain", + run_lulc_on_plain_cluster_local, + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + + print( + f"Completed local terrain compute-all flow for {state}/{district}/{block}: {results}" + ) + return results + + +def run_terrain_compute_all_local( + state, + district, + block, + start_year, + end_year, +): + state = str(state).strip() + district = str(district).strip() + start_year = int(start_year) + end_year = int(end_year) + + if _is_missing_block(block): + block_names = _resolve_blocks_for_district( + state=state, + district=district, + ) + district_results = {} + success_count = 0 + + for block_name in block_names: + try: + district_results[block_name] = _run_terrain_compute_for_block( + state=state, + district=district, + block=block_name, + start_year=start_year, + end_year=end_year, + ) + success_count += 1 + except Exception as error: + print( + f"Failed district-wide terrain compute-all for block {block_name}: {error}" + ) + district_results[block_name] = {"error": str(error)} + + summary = { + "scope": "district", + "state": state, + "district": district, + "total_blocks": len(block_names), + "successful_blocks": success_count, + "failed_blocks": len(block_names) - success_count, + "blocks": district_results, + } + print(f"Completed district-wide terrain compute-all flow: {summary}") + + if success_count == 0: + raise RuntimeError( + f"Terrain compute-all failed for every block in {state}/{district}" + ) + return summary + + return _run_terrain_compute_for_block( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + ) + + +def _generate_terrain_compute_all_local_task( + state, + district, + block, + start_year, + end_year, + gee_account_id=None, +): + _ = gee_account_id + return run_terrain_compute_all_local( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + ) + + +@app.task(bind=True) +def generate_terrain_compute_all( + self, + state, + district, + block, + start_year, + end_year, + gee_account_id=None, +): + _ = self + return _generate_terrain_compute_all_local_task( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + gee_account_id=gee_account_id, + ) diff --git a/computing/terrain_descriptor/terrain_raster_fabdem_local.py b/computing/terrain_descriptor/terrain_raster_fabdem_local.py new file mode 100644 index 00000000..2d4d253e --- /dev/null +++ b/computing/terrain_descriptor/terrain_raster_fabdem_local.py @@ -0,0 +1,166 @@ +from utilities.gee_utils import valid_gee_text + +from nrm_app.celery import app + +from computing.local_compute_helper import ( + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + TERRAIN_RASTER_PATH, + build_output_raster_path, + clip_raster_with_roi, + load_precomputed_roi, + push_local_raster_to_geoserver, + read_validated_vector_file, +) + + +LOCAL_OUTPUT_BASE_DIR = "data/terrain/fabdem_local" +GEOSERVER_STYLE = "terrain_raster" +GEOSERVER_WORKSPACE = "terrain" + + +def run_terrain_raster_fabdem_local( + state=None, + district=None, + block=None, + asset_suffix=None, + roi=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=False, +): + if state and district and block: + layer_name = ( + f"{valid_gee_text(str(district).strip().lower())}_" + f"{valid_gee_text(str(block).strip().lower())}_terrain_raster" + ) + roi_gdf = load_precomputed_roi( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + else: + if not roi or not asset_suffix: + raise ValueError( + "For non state/district/block runs, both `roi` and `asset_suffix` are required." + ) + layer_name = f"{asset_suffix}_terrain_raster".lower() + roi_gdf = read_validated_vector_file( + roi, + f"ROI file has no valid geometries: {roi}", + ) + + output_raster_path = build_output_raster_path( + layer_name=layer_name, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + state=state, + district=district, + block=block, + ) + clipped_raster_path = clip_raster_with_roi( + roi_gdf=roi_gdf, + raster_path=TERRAIN_RASTER_PATH, + output_path=output_raster_path, + ) + + print(f"Local clipped FABDEM raster written to: {clipped_raster_path}") + + if push_to_geoserver: + try: + upload_res, style_res = push_local_raster_to_geoserver( + file_path=clipped_raster_path, + layer_name=layer_name, + workspace=GEOSERVER_WORKSPACE, + style_name=GEOSERVER_STYLE, + ) + print(f"GeoServer upload response: {upload_res}") + print(f"GeoServer style response: {style_res}") + except Exception as error: + print(f"Failed to sync local FABDEM raster to GeoServer: {error}") + return False + + if sync_layer_metadata and state and district and block: + from computing.STAC_specs import generate_STAC_layerwise + from computing.utils import save_layer_info_to_db, update_layer_sync_status + + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=clipped_raster_path, + dataset_name="Terrain Raster", + algorithm="FABDEM", + algorithm_version="2.0", + ) + + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("sync to geoserver flag is updated") + + layer_stac_generated = generate_STAC_layerwise.generate_raster_stac( + state=state, + district=district, + block=block, + layer_name="terrain_raster", + ) + update_layer_sync_status( + layer_id=layer_id, + is_stac_specs_generated=layer_stac_generated, + ) + + return True + + +def _generate_terrain_raster_clip_task( + state=None, + district=None, + block=None, + gee_account_id=None, + asset_suffix=None, + asset_folder=None, + proj_id=None, + roi=None, + precomputed_roi_dir=None, + app_type="MWS", +): + _ = gee_account_id, asset_folder, proj_id, app_type + return run_terrain_raster_fabdem_local( + state=state, + district=district, + block=block, + asset_suffix=asset_suffix, + roi=roi, + precomputed_roi_dir=precomputed_roi_dir, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + + +@app.task(bind=True) +def generate_terrain_raster_clip( + self, + state=None, + district=None, + block=None, + gee_account_id=None, + asset_suffix=None, + asset_folder=None, + proj_id=None, + roi=None, + precomputed_roi_dir=None, + app_type="MWS", +): + _ = self + return _generate_terrain_raster_clip_task( + state=state, + district=district, + block=block, + gee_account_id=gee_account_id, + asset_suffix=asset_suffix, + asset_folder=asset_folder, + proj_id=proj_id, + roi=roi, + precomputed_roi_dir=precomputed_roi_dir, + app_type=app_type, + ) diff --git a/computing/urls.py b/computing/urls.py index 19e8bd3f..d28f8311 100644 --- a/computing/urls.py +++ b/computing/urls.py @@ -40,6 +40,11 @@ api.generate_terrain_descriptor, name="generate_terrain_descriptor", ), + path( + "generate_terrain_compute_all/", + api.generate_terrain_compute_all, + name="generate_terrain_compute_all", + ), path( "generate_terrain_raster/", api.generate_terrain_raster, From 7990c54ec7bb9c3ba8a47c8b21b507e1a16f70eb Mon Sep 17 00:00:00 2001 From: Ankit K Date: Wed, 15 Apr 2026 02:20:10 +0530 Subject: [PATCH 02/19] bump --- .installation_state/collectstatic.done | 1 + .installation_state/conda_env.done | 1 + .installation_state/env_file.done | 1 + .installation_state/miniconda.done | 1 + .installation_state/postgres.done | 1 + .installation_state/rabbitmq.done | 1 + .installation_state/unzip_install.done | 1 + 7 files changed, 7 insertions(+) create mode 100644 .installation_state/collectstatic.done create mode 100644 .installation_state/conda_env.done create mode 100644 .installation_state/env_file.done create mode 100644 .installation_state/miniconda.done create mode 100644 .installation_state/postgres.done create mode 100644 .installation_state/rabbitmq.done create mode 100644 .installation_state/unzip_install.done diff --git a/.installation_state/collectstatic.done b/.installation_state/collectstatic.done new file mode 100644 index 00000000..2812016d --- /dev/null +++ b/.installation_state/collectstatic.done @@ -0,0 +1 @@ +2026-04-13T19:43:46Z diff --git a/.installation_state/conda_env.done b/.installation_state/conda_env.done new file mode 100644 index 00000000..60785b45 --- /dev/null +++ b/.installation_state/conda_env.done @@ -0,0 +1 @@ +2026-04-13T19:43:39Z diff --git a/.installation_state/env_file.done b/.installation_state/env_file.done new file mode 100644 index 00000000..278c3835 --- /dev/null +++ b/.installation_state/env_file.done @@ -0,0 +1 @@ +2026-04-13T19:43:40Z diff --git a/.installation_state/miniconda.done b/.installation_state/miniconda.done new file mode 100644 index 00000000..e0463ce4 --- /dev/null +++ b/.installation_state/miniconda.done @@ -0,0 +1 @@ +2026-04-13T19:40:19Z diff --git a/.installation_state/postgres.done b/.installation_state/postgres.done new file mode 100644 index 00000000..c4573478 --- /dev/null +++ b/.installation_state/postgres.done @@ -0,0 +1 @@ +2026-04-13T19:40:33Z diff --git a/.installation_state/rabbitmq.done b/.installation_state/rabbitmq.done new file mode 100644 index 00000000..d868a03c --- /dev/null +++ b/.installation_state/rabbitmq.done @@ -0,0 +1 @@ +2026-04-13T19:40:34Z diff --git a/.installation_state/unzip_install.done b/.installation_state/unzip_install.done new file mode 100644 index 00000000..8fe01fb6 --- /dev/null +++ b/.installation_state/unzip_install.done @@ -0,0 +1 @@ +2026-04-13T19:40:06Z From 2002ad5c9dc982f1260c8433bc6f75a956b90620 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Thu, 16 Apr 2026 00:33:29 +0530 Subject: [PATCH 03/19] added methods to update db and generate change detection from local machine --- computing/api.py | 6 ++ computing/apps.py | 5 + computing/base_layer_setup.py | 171 ++++++++++++++++++++++++++++++ computing/local_compute_helper.py | 34 +++++- computing/utils.py | 49 +++++++++ nrm_app/settings.py | 7 ++ 6 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 computing/base_layer_setup.py diff --git a/computing/api.py b/computing/api.py index 56e76149..a7f1b266 100644 --- a/computing/api.py +++ b/computing/api.py @@ -34,6 +34,12 @@ from .utils import ( Geoserver, kml_to_shp, + save_layer_info_to_db, + update_layer_sync_status, +) +from .local_compute_helper import ( + get_compute_mode as _get_compute_mode, + select_compute_task as _select_compute_task, ) from utilities.gee_utils import download_gee_layer, check_gee_task_status from django.core.files.storage import FileSystemStorage diff --git a/computing/apps.py b/computing/apps.py index d1f2ec46..de88f666 100644 --- a/computing/apps.py +++ b/computing/apps.py @@ -4,3 +4,8 @@ class ComputingConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "computing" + + def ready(self): + from computing.base_layer_setup import setup_base_layers + + setup_base_layers() diff --git a/computing/base_layer_setup.py b/computing/base_layer_setup.py new file mode 100644 index 00000000..6f560f7c --- /dev/null +++ b/computing/base_layer_setup.py @@ -0,0 +1,171 @@ +import logging +import subprocess +from pathlib import Path + +import requests +from django.conf import settings + +from utilities.constants import GEOSERVER_BASE + +logger = logging.getLogger(__name__) + +_PROJECT_ROOT = Path(settings.BASE_DIR) + +SOI_TEHSIL_PATH = _PROJECT_ROOT / "data/admin-boundary/input/soi_tehsil.geojson" +ADMIN_BOUNDARY_INPUT_DIR = _PROJECT_ROOT / "data/admin-boundary/input" +ADMIN_BOUNDARY_OUTPUT_DIR = _PROJECT_ROOT / "data/admin-boundary/output" +LULC_DIR = _PROJECT_ROOT / "data/base_layers/lulc" +VILLAGE_BOUNDARIES_DIR = _PROJECT_ROOT / "data/base_layers/village_boundaries" +TEHSIL_WATERSHEDS_DIR = _PROJECT_ROOT / "data/base_layers/tehsil_watersheds" + +_GDRIVE_ADMIN_BOUNDARY_FILE_ID = "1VqIhB6HrKFDkDnlk1vedcEHhh5fk4f1d" + +# Ordered oldest → newest; each file is ~7-8 GB. +_LULC_GDRIVE_FILES = [ + ("lulc_v3_2017_2018.tif", "1VidwEQqkwtoHqqdUqdwURWyiGd-OteaJ"), + ("lulc_v3_2018_2019.tif", "1ZeLMAiBfolMrfEJkFnOlvb8OjSqC9vHP"), + ("lulc_v3_2019_2020.tif", "1gx5VwJCHI-WUDJIwWv48OvbybBe9y0PR"), + ("lulc_v3_2020_2021.tif", "1xbOt3-t1Ws5olq2Q88Tk32KnUVNKUXqe"), + ("lulc_v3_2021_2022.tif", "1m8ZnUBbTp-fcH_JcRTUEceRaa8WewQmz"), + ("lulc_v3_2022_2023.tif", "1_S0VESClg7s-DloAqxrfU8mLHhNSBfp7"), + ("lulc_v3_2023_2024.tif", "1JVfl67ARRv7TPV5lyLnjoSfWDiXtvXjY"), + ("lulc_v3_2024_2025.tif", "1CPV03S47s0asEJqdAozbNOT1lkgr0YkG"), +] + +_SOI_WFS_PARAMS = { + "service": "WFS", + "version": "1.0.0", + "request": "GetFeature", + "typeName": "pan_india_asset:SOI_tehsil_pan_india_dataset", + "outputFormat": "application/json", +} + + +def _is_dir_populated(path: Path) -> bool: + return path.is_dir() and any(path.iterdir()) + + +def ensure_soi_tehsil(): + """ + Downloads the SOI tehsil GeoJSON from GeoServer if not already present. + This is a lightweight bootstrap; the full admin-boundary archive includes + more data but takes much longer to acquire. + """ + if SOI_TEHSIL_PATH.exists(): + logger.info("SOI tehsil layer already exists at %s, skipping.", SOI_TEHSIL_PATH) + return + + SOI_TEHSIL_PATH.parent.mkdir(parents=True, exist_ok=True) + + wfs_url = f"{GEOSERVER_BASE}pan_india_asset/ows" + logger.info("Downloading SOI tehsil layer from GeoServer...") + try: + response = requests.get( + wfs_url, params=_SOI_WFS_PARAMS, timeout=600, stream=True + ) + response.raise_for_status() + except requests.RequestException as e: + logger.error("Failed to download SOI tehsil layer: %s", e) + return + + with open(SOI_TEHSIL_PATH, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + logger.info("SOI tehsil layer saved to %s", SOI_TEHSIL_PATH) + + +def ensure_admin_boundary_data(): + """ + Downloads and extracts the full admin-boundary archive (~8 GB) from Google Drive. + Skipped if the input directory is already populated. + Requires `gdown` and `7z` to be available on PATH. + """ + if _is_dir_populated(ADMIN_BOUNDARY_INPUT_DIR): + logger.info("Admin boundary data already exists, skipping.") + return + + ADMIN_BOUNDARY_INPUT_DIR.mkdir(parents=True, exist_ok=True) + ADMIN_BOUNDARY_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + archive_path = _PROJECT_ROOT / "dataset.7z" + logger.info("Downloading admin boundary data (~8 GB) from Google Drive...") + try: + subprocess.run( + ["gdown", _GDRIVE_ADMIN_BOUNDARY_FILE_ID, "-O", str(archive_path)], + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + logger.error("Failed to download admin boundary archive: %s", e) + return + + logger.info("Extracting admin boundary data...") + try: + subprocess.run( + ["7z", "x", str(archive_path), f"-o{_PROJECT_ROOT / 'data/admin-boundary'}"], + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + logger.error("Failed to extract admin boundary archive: %s", e) + return + finally: + if archive_path.exists(): + archive_path.unlink() + + logger.info("Admin boundary data ready at %s", ADMIN_BOUNDARY_INPUT_DIR) + + +def ensure_lulc_rasters(): + """ + Downloads any missing LULC v3 yearly rasters from Google Drive. + Files already present on disk are skipped — no re-download. + Requires `gdown` on PATH. Each file is ~7-8 GB. + """ + LULC_DIR.mkdir(parents=True, exist_ok=True) + + missing = [ + (filename, file_id) + for filename, file_id in _LULC_GDRIVE_FILES + if not (LULC_DIR / filename).exists() + ] + + if not missing: + logger.info("All LULC rasters already present at %s, skipping.", LULC_DIR) + return + + logger.info( + "%d LULC raster(s) missing, downloading: %s", + len(missing), + [f for f, _ in missing], + ) + + for filename, file_id in missing: + dest = LULC_DIR / filename + logger.info("Downloading %s (~7-8 GB)...", filename) + try: + subprocess.run( + ["gdown", file_id, "-O", str(dest)], + check=True, + ) + logger.info("Saved %s", dest) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + logger.error("Failed to download %s: %s", filename, e) + if dest.exists(): + dest.unlink() + raise + + +def ensure_village_boundaries_dir(): + """ + Ensures the village boundaries directory exists. + TODO: add download logic once the source is determined. + """ + VILLAGE_BOUNDARIES_DIR.mkdir(parents=True, exist_ok=True) + TEHSIL_WATERSHEDS_DIR.mkdir(parents=True, exist_ok=True) + + +def setup_base_layers(): + ensure_soi_tehsil() + ensure_admin_boundary_data() + ensure_lulc_rasters() + ensure_village_boundaries_dir() diff --git a/computing/local_compute_helper.py b/computing/local_compute_helper.py index 699a1e4e..0785f3a1 100644 --- a/computing/local_compute_helper.py +++ b/computing/local_compute_helper.py @@ -241,10 +241,9 @@ def clip_raster_with_roi(roi_gdf, raster_path, output_path, raster_label="Raster return str(output_path) -def push_local_raster_to_geoserver(file_path, layer_name, workspace, style_name=None): +def _push_raster_to_geoserver_instance(geo, file_path, layer_name, workspace, style_name): from utilities.geoserver_utils import Geoserver - geo = Geoserver() geo.delete_raster_store(workspace=workspace, store=layer_name) upload_response = geo.create_coveragestore( path=file_path, @@ -261,6 +260,35 @@ def push_local_raster_to_geoserver(file_path, layer_name, workspace, style_name= return upload_response, style_response +def push_local_raster_to_geoserver(file_path, layer_name, workspace, style_name=None): + from django.conf import settings + from utilities.geoserver_utils import Geoserver + + local_geo = Geoserver() + upload_response, style_response = _push_raster_to_geoserver_instance( + local_geo, file_path, layer_name, workspace, style_name + ) + + prod_url = getattr(settings, "PROD_GEOSERVER_URL", "") + if prod_url: + try: + prod_geo = Geoserver( + service_url=prod_url, + username=settings.PROD_GEOSERVER_USERNAME, + password=settings.PROD_GEOSERVER_PASSWORD, + ) + _push_raster_to_geoserver_instance( + prod_geo, file_path, layer_name, workspace, style_name + ) + except Exception as e: + import logging + logging.getLogger(__name__).error( + "Failed to push raster %s to prod GeoServer: %s", layer_name, e + ) + + return upload_response, style_response + + def compute_pixel_area_grid(transform, height, width, crs): if crs is None: @@ -713,7 +741,7 @@ def compute_mode_lulc_array(reprojected_arrays, lulc_classes=LULC_CLASSES): return mode_values -def get_compute_mode(request, default="gee"): +def get_compute_mode(request, default="local"): compute = str(request.data.get("compute") or default).strip().lower() if compute not in VALID_COMPUTE_TYPES: raise ValueError("compute must be either 'gee' or 'local'") diff --git a/computing/utils.py b/computing/utils.py index 2590f3b9..31ac64e1 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -1,8 +1,12 @@ +import logging import os import geopandas as gpd import fiona import copy +import requests + +logger = logging.getLogger(__name__) from computing.models import Layer, Dataset from geoadmin.models import ( @@ -607,6 +611,35 @@ def merge_with_ndvi(pair): sync_project_fc_to_geoserver(merged_final, proj_obj.name, layer_name, "waterrej") +def _sync_layer_to_prod_db(payload: dict): + from django.conf import settings + + prod_url = getattr(settings, "PROD_BACKEND_URL", "") + api_key = getattr(settings, "PROD_BACKEND_API_KEY", "") + if not prod_url: + return + + endpoint = prod_url.rstrip("/") + "/api/v1/computing/sync_layer_remote/" + try: + response = requests.post( + endpoint, + json=payload, + headers={"X-Api-Key": api_key}, + timeout=30, + ) + if response.status_code not in (200, 201): + logger.warning( + "Prod DB sync returned %s for layer %s: %s", + response.status_code, + payload.get("layer_name"), + response.text, + ) + else: + logger.info("Layer %s synced to prod DB.", payload.get("layer_name")) + except requests.RequestException as e: + logger.error("Failed to sync layer %s to prod DB: %s", payload.get("layer_name"), e) + + def save_layer_info_to_db( state, district, @@ -709,6 +742,22 @@ def save_layer_info_to_db( ) print(f"Saved layer info (id={layer_obj.id}, version={layer_obj.layer_version})") + + _sync_layer_to_prod_db({ + "state": state, + "district": district, + "block": block, + "layer_name": layer_name, + "asset_id": asset_id, + "dataset_name": dataset_name, + "sync_to_geoserver": sync_to_geoserver, + "layer_version": layer_version, + "algorithm": algorithm, + "algorithm_version": algorithm_version, + "misc": misc, + "is_override": is_override, + }) + return layer_obj.id diff --git a/nrm_app/settings.py b/nrm_app/settings.py index d06a3d21..abf03bc4 100755 --- a/nrm_app/settings.py +++ b/nrm_app/settings.py @@ -378,6 +378,13 @@ def resolve_env_path(name, default="", *, trailing_sep=False): PROD_BACKEND_URL = env("PROD_BACKEND_URL", default="") PROD_BACKEND_API_KEY = env("PROD_BACKEND_API_KEY", default="") +PROD_GEOSERVER_URL = env("PROD_GEOSERVER_URL", default="") +PROD_GEOSERVER_USERNAME = env("PROD_GEOSERVER_USERNAME", default="") +PROD_GEOSERVER_PASSWORD = env("PROD_GEOSERVER_PASSWORD", default="") + +PROD_BACKEND_URL = env("PROD_BACKEND_URL", default="") +PROD_BACKEND_API_KEY = env("PROD_BACKEND_API_KEY", default="") + CE_BUCKET_URL = env("CE_BUCKET_URL") EARTH_DATA_USER = env("EARTH_DATA_USER") From 7ce55475605c0860ae1a86adfbf9569617f96b5f Mon Sep 17 00:00:00 2001 From: Ankit K Date: Thu, 16 Apr 2026 14:22:56 +0530 Subject: [PATCH 04/19] api for pushing db update --- computing/api.py | 29 ++++++++++++++ computing/utils.py | 99 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 103 insertions(+), 25 deletions(-) diff --git a/computing/api.py b/computing/api.py index a7f1b266..c0efcd7f 100644 --- a/computing/api.py +++ b/computing/api.py @@ -1952,6 +1952,35 @@ def update_layer_sync_remote(request): @authentication_classes([]) @permission_classes([AllowAny]) @schema(None) +def update_layer_sync_remote(request): + """ + Called by a local compute instance to update sync/STAC flags on a layer + record in this (prod) backend. + """ + from django.conf import settings + + api_key = getattr(settings, "PROD_BACKEND_API_KEY", "") + if api_key and request.headers.get("X-Api-Key") != api_key: + return Response({"error": "Unauthorized"}, status=status.HTTP_401_UNAUTHORIZED) + + try: + d = request.data + layer_id = d.get("layer_id") + if layer_id is None: + return Response({"error": "layer_id is required"}, status=status.HTTP_400_BAD_REQUEST) + + result = update_layer_sync_status( + layer_id=layer_id, + sync_to_geoserver=d.get("sync_to_geoserver"), + is_stac_specs_generated=d.get("is_stac_specs_generated"), + ) + return Response({"layer_id": result}, status=status.HTTP_200_OK) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@schema(None) def sync_layer_remote(request): """ Called by a local compute instance to persist a layer record on this (prod) backend. diff --git a/computing/utils.py b/computing/utils.py index 31ac64e1..6c4c1c0b 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -41,6 +41,8 @@ from shapely.geometry import shape from shapely.validation import explain_validity import zipfile +from django.conf import settings + from datetime import datetime, timedelta from django.conf import settings import logging @@ -611,20 +613,25 @@ def merge_with_ndvi(pair): sync_project_fc_to_geoserver(merged_final, proj_obj.name, layer_name, "waterrej") -def _sync_layer_to_prod_db(payload: dict): - from django.conf import settings +def _get_prod_backend_url(): + return getattr(settings, "PROD_BACKEND_URL", "").rstrip("/") + + +def _get_prod_api_key(): + return getattr(settings, "PROD_BACKEND_API_KEY", "") + - prod_url = getattr(settings, "PROD_BACKEND_URL", "") - api_key = getattr(settings, "PROD_BACKEND_API_KEY", "") +def _sync_layer_to_prod_db(payload: dict): + prod_url = _get_prod_backend_url() if not prod_url: - return + return None - endpoint = prod_url.rstrip("/") + "/api/v1/computing/sync_layer_remote/" + endpoint = prod_url + "/api/v1/computing/sync_layer_remote/" try: response = requests.post( endpoint, json=payload, - headers={"X-Api-Key": api_key}, + headers={"X-Api-Key": _get_prod_api_key()}, timeout=30, ) if response.status_code not in (200, 201): @@ -634,10 +641,44 @@ def _sync_layer_to_prod_db(payload: dict): payload.get("layer_name"), response.text, ) - else: - logger.info("Layer %s synced to prod DB.", payload.get("layer_name")) + return None + layer_id = response.json().get("layer_id") + logger.info("Layer %s synced to prod DB (id=%s).", payload.get("layer_name"), layer_id) + return layer_id except requests.RequestException as e: logger.error("Failed to sync layer %s to prod DB: %s", payload.get("layer_name"), e) + return None + + +def _update_layer_sync_remote(layer_id, sync_to_geoserver=None, is_stac_specs_generated=None): + prod_url = _get_prod_backend_url() + if not prod_url or layer_id is None: + return + + endpoint = prod_url + "/api/v1/computing/update_layer_sync_remote/" + payload = { + "layer_id": layer_id, + "sync_to_geoserver": sync_to_geoserver, + "is_stac_specs_generated": is_stac_specs_generated, + } + try: + response = requests.post( + endpoint, + json=payload, + headers={"X-Api-Key": _get_prod_api_key()}, + timeout=30, + ) + if response.status_code not in (200, 201): + logger.warning( + "Prod layer sync status update returned %s for layer %s: %s", + response.status_code, + layer_id, + response.text, + ) + else: + logger.info("Layer sync status updated on prod DB for id=%s.", layer_id) + except requests.RequestException as e: + logger.error("Failed to update layer sync status on prod DB for id=%s: %s", layer_id, e) def save_layer_info_to_db( @@ -654,6 +695,22 @@ def save_layer_info_to_db( misc=None, is_override=False, ): + if _get_prod_backend_url(): + return _sync_layer_to_prod_db({ + "state": state, + "district": district, + "block": block, + "layer_name": layer_name, + "asset_id": asset_id, + "dataset_name": dataset_name, + "sync_to_geoserver": sync_to_geoserver, + "layer_version": layer_version, + "algorithm": algorithm, + "algorithm_version": algorithm_version, + "misc": misc, + "is_override": is_override, + }) + print("inside the save_layer_info_to_db function") dataset = Dataset.objects.get(name=dataset_name) @@ -742,28 +799,20 @@ def save_layer_info_to_db( ) print(f"Saved layer info (id={layer_obj.id}, version={layer_obj.layer_version})") - - _sync_layer_to_prod_db({ - "state": state, - "district": district, - "block": block, - "layer_name": layer_name, - "asset_id": asset_id, - "dataset_name": dataset_name, - "sync_to_geoserver": sync_to_geoserver, - "layer_version": layer_version, - "algorithm": algorithm, - "algorithm_version": algorithm_version, - "misc": misc, - "is_override": is_override, - }) - return layer_obj.id def update_layer_sync_status( layer_id, sync_to_geoserver=None, is_stac_specs_generated=None ): + if _get_prod_backend_url(): + _update_layer_sync_remote( + layer_id, + sync_to_geoserver=sync_to_geoserver, + is_stac_specs_generated=is_stac_specs_generated, + ) + return layer_id + try: layer_obj = Layer.objects.filter(id=layer_id) if sync_to_geoserver is not None: From ab5e0e293ba4b7dcad54387ccbe665f4d7527f28 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Thu, 16 Apr 2026 14:25:32 +0530 Subject: [PATCH 05/19] api for pushing db update --- computing/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/computing/api.py b/computing/api.py index c0efcd7f..18f47d0f 100644 --- a/computing/api.py +++ b/computing/api.py @@ -125,6 +125,8 @@ from .mws.mws_centroid import generate_mws_centroid_data from .misc.facilities_proximity import generate_facilities_proximity_task from .STAC_specs.stac_collection import _make_celery_task as _make_stac_task +from django.conf import settings + @api_security_check(allowed_methods="POST") @@ -1957,7 +1959,6 @@ def update_layer_sync_remote(request): Called by a local compute instance to update sync/STAC flags on a layer record in this (prod) backend. """ - from django.conf import settings api_key = getattr(settings, "PROD_BACKEND_API_KEY", "") if api_key and request.headers.get("X-Api-Key") != api_key: From e8714f3d711627ca75775076c355ba6aeb03b7e4 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Fri, 24 Apr 2026 00:13:59 +0530 Subject: [PATCH 06/19] end to end flow for change detection -- push to geoserver -- push to prod db -- fallback to local db --- computing/apps.py | 3 +- computing/base_layer_setup.py | 82 ++++++++++++- .../change_detection_vector_local.py | 8 +- computing/local_compute_helper.py | 114 +++++++++++++++++- .../store_watersheds_for_tehsils.py | 2 +- computing/utils.py | 11 +- dpr/api.py | 3 +- utilities/geoserver_utils.py | 72 +++++++++-- 8 files changed, 268 insertions(+), 27 deletions(-) diff --git a/computing/apps.py b/computing/apps.py index de88f666..a73777ee 100644 --- a/computing/apps.py +++ b/computing/apps.py @@ -1,4 +1,5 @@ from django.apps import AppConfig +from computing.base_layer_setup import setup_base_layers class ComputingConfig(AppConfig): @@ -6,6 +7,4 @@ class ComputingConfig(AppConfig): name = "computing" def ready(self): - from computing.base_layer_setup import setup_base_layers - setup_base_layers() diff --git a/computing/base_layer_setup.py b/computing/base_layer_setup.py index 6f560f7c..415cd1d3 100644 --- a/computing/base_layer_setup.py +++ b/computing/base_layer_setup.py @@ -20,6 +20,10 @@ _GDRIVE_ADMIN_BOUNDARY_FILE_ID = "1VqIhB6HrKFDkDnlk1vedcEHhh5fk4f1d" +MICROWATERSHED_PATH = _PROJECT_ROOT / "data/base_layers/Microwatershed_v2_with_details.geojson" +# TODO: fill in the Google Drive file ID for Microwatershed_v2_with_details.geojson +_GDRIVE_MICROWATERSHED_FILE_ID = "" + # Ordered oldest → newest; each file is ~7-8 GB. _LULC_GDRIVE_FILES = [ ("lulc_v3_2017_2018.tif", "1VidwEQqkwtoHqqdUqdwURWyiGd-OteaJ"), @@ -155,17 +159,91 @@ def ensure_lulc_rasters(): raise +def ensure_microwatershed(): + """ + Downloads the pan-India microwatershed GeoJSON from Google Drive if not already present. + Requires `gdown` on PATH. + Fill in _GDRIVE_MICROWATERSHED_FILE_ID above once the Drive link is available. + """ + if MICROWATERSHED_PATH.exists(): + logger.info("Microwatershed file already exists at %s, skipping.", MICROWATERSHED_PATH) + return + + if not _GDRIVE_MICROWATERSHED_FILE_ID: + logger.warning( + "Microwatershed file not found at %s and no Google Drive file ID is configured. " + "Set _GDRIVE_MICROWATERSHED_FILE_ID in base_layer_setup.py or place the file manually.", + MICROWATERSHED_PATH, + ) + return + + MICROWATERSHED_PATH.parent.mkdir(parents=True, exist_ok=True) + logger.info("Downloading Microwatershed_v2_with_details.geojson from Google Drive...") + try: + subprocess.run( + ["gdown", _GDRIVE_MICROWATERSHED_FILE_ID, "-O", str(MICROWATERSHED_PATH)], + check=True, + ) + logger.info("Saved microwatershed file to %s", MICROWATERSHED_PATH) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + logger.error("Failed to download microwatershed file: %s", e) + if MICROWATERSHED_PATH.exists(): + MICROWATERSHED_PATH.unlink() + raise + + +def ensure_tehsil_watersheds(): + """ + Generates per-tehsil watershed .gpkg files by spatially intersecting the + microwatershed dataset against SOI tehsil boundaries. + Skipped entirely if the output directory is already populated. + Both source files (SOI tehsil + microwatershed) must exist first. + """ + if _is_dir_populated(TEHSIL_WATERSHEDS_DIR): + logger.info("Tehsil watershed files already present at %s, skipping.", TEHSIL_WATERSHEDS_DIR) + return + + if not SOI_TEHSIL_PATH.exists(): + logger.warning( + "Cannot generate tehsil watersheds: SOI tehsil file missing at %s.", SOI_TEHSIL_PATH + ) + return + + if not MICROWATERSHED_PATH.exists(): + logger.warning( + "Cannot generate tehsil watersheds: microwatershed file missing at %s.", MICROWATERSHED_PATH + ) + return + + TEHSIL_WATERSHEDS_DIR.mkdir(parents=True, exist_ok=True) + logger.info("Generating tehsil watershed files (this may take a while)...") + + from computing.terrain_descriptor.store_watersheds_for_tehsils import ( + generate_tehsil_watershed_copies, + ) + + generate_tehsil_watershed_copies( + microwatershed_path=str(MICROWATERSHED_PATH), + tehsil_path=str(SOI_TEHSIL_PATH), + output_dir=str(TEHSIL_WATERSHEDS_DIR), + output_format="gpkg", + overwrite=False, + ) + logger.info("Tehsil watershed files ready at %s", TEHSIL_WATERSHEDS_DIR) + + def ensure_village_boundaries_dir(): """ Ensures the village boundaries directory exists. TODO: add download logic once the source is determined. """ VILLAGE_BOUNDARIES_DIR.mkdir(parents=True, exist_ok=True) - TEHSIL_WATERSHEDS_DIR.mkdir(parents=True, exist_ok=True) def setup_base_layers(): ensure_soi_tehsil() ensure_admin_boundary_data() - ensure_lulc_rasters() + # ensure_lulc_rasters() + # ensure_microwatershed() + # ensure_tehsil_watersheds() ensure_village_boundaries_dir() diff --git a/computing/change_detection/change_detection_vector_local.py b/computing/change_detection/change_detection_vector_local.py index bbb325d3..c457a352 100644 --- a/computing/change_detection/change_detection_vector_local.py +++ b/computing/change_detection/change_detection_vector_local.py @@ -11,10 +11,10 @@ compute_categorical_raster_areas_for_watersheds, ensure_file_exists, load_precomputed_watersheds, + push_local_vector_to_geoserver, write_vector_output, ) from computing.utils import ( - push_shape_to_geoserver, save_layer_info_to_db, update_layer_sync_status, ) @@ -162,10 +162,10 @@ def run_change_detection_vector_local( published_layer_name = _published_layer_name(district, block, param_name) if push_to_geoserver: - geoserver_response = push_shape_to_geoserver( - os.path.splitext(asset_id)[0], - workspace=GEOSERVER_WORKSPACE, + geoserver_response = push_local_vector_to_geoserver( + path=os.path.splitext(asset_id)[0], layer_name=published_layer_name, + workspace=GEOSERVER_WORKSPACE, file_type="gpkg", ) print(f"GeoServer response for {param_name}: {geoserver_response}") diff --git a/computing/local_compute_helper.py b/computing/local_compute_helper.py index 0785f3a1..53519013 100644 --- a/computing/local_compute_helper.py +++ b/computing/local_compute_helper.py @@ -241,15 +241,37 @@ def clip_raster_with_roi(roi_gdf, raster_path, output_path, raster_label="Raster return str(output_path) +RASTER_DECLARED_SRS = "EPSG:4326" + + def _push_raster_to_geoserver_instance(geo, file_path, layer_name, workspace, style_name): - from utilities.geoserver_utils import Geoserver + import logging + _log = logging.getLogger(__name__) + + geo.ensure_workspace(workspace) geo.delete_raster_store(workspace=workspace, store=layer_name) upload_response = geo.create_coveragestore( path=file_path, workspace=workspace, layer_name=layer_name, ) + try: + geo.configure_coverage( + workspace=workspace, + store=layer_name, + coverage=layer_name, + srs=RASTER_DECLARED_SRS, + projection_policy="REPROJECT_TO_DECLARED", + enabled=True, + ) + except Exception as e: + _log.warning( + "Failed to force SRS on coverage %s:%s: %s", + workspace, + layer_name, + e, + ) style_response = None if style_name: style_response = geo.publish_style( @@ -260,14 +282,81 @@ def _push_raster_to_geoserver_instance(geo, file_path, layer_name, workspace, st return upload_response, style_response +def _verify_raster_layer(geo, workspace, layer_name): + """Return True iff a layer (not just the store) is registered on the server.""" + from utilities.geoserver_utils import GeoserverException + + try: + geo.get_layer(layer_name=layer_name, workspace=workspace) + return True + except GeoserverException: + return False + except Exception: + return False + + +def _push_vector_to_geoserver_instance(geo, path, layer_name, workspace, file_type="gpkg"): + from computing.utils import convert_to_zip + + geo.ensure_workspace(workspace) + try: + geo.delete_vector_store(workspace=workspace, store=layer_name) + except Exception: + pass + + zip_path = convert_to_zip(path, file_type) + return geo.create_shp_datastore( + path=zip_path, + store_name=layer_name, + workspace=workspace, + file_extension=file_type, + ) + + +def push_local_vector_to_geoserver(path, layer_name, workspace, file_type="gpkg"): + import logging + from django.conf import settings + from utilities.geoserver_utils import Geoserver + + _log = logging.getLogger(__name__) + + local_geo = Geoserver() + response = _push_vector_to_geoserver_instance(local_geo, path, layer_name, workspace, file_type) + _log.info("Pushed vector %s to local GeoServer.", layer_name) + + prod_url = getattr(settings, "PROD_GEOSERVER_URL", "") + if prod_url: + try: + prod_geo = Geoserver( + service_url=prod_url, + username=settings.PROD_GEOSERVER_USERNAME, + password=settings.PROD_GEOSERVER_PASSWORD, + ) + _push_vector_to_geoserver_instance(prod_geo, path, layer_name, workspace, file_type) + _log.info("Pushed vector %s to prod GeoServer (%s).", layer_name, prod_url) + except Exception as e: + _log.error("Failed to push vector %s to prod GeoServer: %s", layer_name, e) + + return response + + def push_local_raster_to_geoserver(file_path, layer_name, workspace, style_name=None): + import logging from django.conf import settings from utilities.geoserver_utils import Geoserver + _log = logging.getLogger(__name__) + local_geo = Geoserver() upload_response, style_response = _push_raster_to_geoserver_instance( local_geo, file_path, layer_name, workspace, style_name ) + local_layer_ok = _verify_raster_layer(local_geo, workspace, layer_name) + _log.info( + "Pushed raster %s to local GeoServer (layer_exists=%s).", + layer_name, + local_layer_ok, + ) prod_url = getattr(settings, "PROD_GEOSERVER_URL", "") if prod_url: @@ -277,14 +366,27 @@ def push_local_raster_to_geoserver(file_path, layer_name, workspace, style_name= username=settings.PROD_GEOSERVER_USERNAME, password=settings.PROD_GEOSERVER_PASSWORD, ) - _push_raster_to_geoserver_instance( + prod_upload, prod_style = _push_raster_to_geoserver_instance( prod_geo, file_path, layer_name, workspace, style_name ) - except Exception as e: - import logging - logging.getLogger(__name__).error( - "Failed to push raster %s to prod GeoServer: %s", layer_name, e + prod_layer_ok = _verify_raster_layer(prod_geo, workspace, layer_name) + _log.info( + "Pushed raster %s to prod GeoServer (%s) " + "(layer_exists=%s, upload=%s, style=%s).", + layer_name, + prod_url, + prod_layer_ok, + prod_upload, + prod_style, ) + if not prod_layer_ok: + _log.error( + "Prod raster push for %s created store but NO layer. " + "Coverage auto-config likely failed on prod GeoServer.", + layer_name, + ) + except Exception as e: + _log.error("Failed to push raster %s to prod GeoServer: %s", layer_name, e) return upload_response, style_response diff --git a/computing/terrain_descriptor/store_watersheds_for_tehsils.py b/computing/terrain_descriptor/store_watersheds_for_tehsils.py index 5937f5df..530a8ef8 100644 --- a/computing/terrain_descriptor/store_watersheds_for_tehsils.py +++ b/computing/terrain_descriptor/store_watersheds_for_tehsils.py @@ -10,7 +10,7 @@ DEFAULT_MICROWATERSHED_PATH = ( "data/base_layers/Microwatershed_v2_with_details.geojson" ) -DEFAULT_TEHSIL_PATH = "data/base_layers/SOI_tehsil.geojson" +DEFAULT_TEHSIL_PATH = "data/admin-boundary/input/soi_tehsil.geojson" DEFAULT_OUTPUT_DIR = "data/base_layers/tehsil_watersheds" STATE_COLUMN_CANDIDATES = ["STATE", "state", "state_name", "State"] diff --git a/computing/utils.py b/computing/utils.py index 6c4c1c0b..aa594383 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -626,7 +626,7 @@ def _sync_layer_to_prod_db(payload: dict): if not prod_url: return None - endpoint = prod_url + "/api/v1/computing/sync_layer_remote/" + endpoint = prod_url + "/api/v1/sync_layer_remote/" try: response = requests.post( endpoint, @@ -655,7 +655,7 @@ def _update_layer_sync_remote(layer_id, sync_to_geoserver=None, is_stac_specs_ge if not prod_url or layer_id is None: return - endpoint = prod_url + "/api/v1/computing/update_layer_sync_remote/" + endpoint = prod_url + "/api/v1/update_layer_sync_remote/" payload = { "layer_id": layer_id, "sync_to_geoserver": sync_to_geoserver, @@ -696,7 +696,7 @@ def save_layer_info_to_db( is_override=False, ): if _get_prod_backend_url(): - return _sync_layer_to_prod_db({ + layer_id = _sync_layer_to_prod_db({ "state": state, "district": district, "block": block, @@ -710,6 +710,11 @@ def save_layer_info_to_db( "misc": misc, "is_override": is_override, }) + if layer_id is not None: + return layer_id + logger.warning( + "Prod DB sync failed for layer %s — falling back to local DB write.", layer_name + ) print("inside the save_layer_info_to_db function") diff --git a/dpr/api.py b/dpr/api.py index 1c116ea2..307b9b5d 100644 --- a/dpr/api.py +++ b/dpr/api.py @@ -449,9 +449,8 @@ def download_mws_report(request): @api_view(["GET"]) -@auth_free @schema(None) -@api_security_check(auth_type="Auth_free") +@auth_free def generate_tehsil_report(request): try: # ? district, block, mwsId diff --git a/utilities/geoserver_utils.py b/utilities/geoserver_utils.py index c55289a1..53dbe592 100644 --- a/utilities/geoserver_utils.py +++ b/utilities/geoserver_utils.py @@ -1,8 +1,11 @@ # inbuilt libraries import io +import logging import os from typing import List, Optional, Set +logger = logging.getLogger(__name__) + # third-party libraries import requests from xmltodict import parse, unparse @@ -246,6 +249,22 @@ def create_workspace(self, workspace: str): else: raise GeoserverException(r.status_code, r.content) + def ensure_workspace(self, workspace: str): + """ + Creates the workspace if it does not already exist. + Never raises — a failed workspace creation is non-fatal; the upload + attempt that follows will surface the real error if the workspace is + truly missing. + """ + try: + url = "{}/rest/workspaces/{}.json".format(self.service_url, workspace) + r = self._requests("get", url) + if r.status_code == 200: + return + self.create_workspace(workspace) + except Exception as e: + logger.warning("ensure_workspace(%s) failed (proceeding anyway): %s", workspace, e) + def delete_workspace(self, workspace: str): """ @@ -320,7 +339,7 @@ def delete_vector_store(self, workspace, store): - store: Name of the vector datastore to delete """ print("inside delete_vector_store") - url = f"{GEOSERVER_URL}/rest/workspaces/{workspace}/datastores/{store}?recurse=true" + url = f"{self.service_url}/rest/workspaces/{workspace}/datastores/{store}?recurse=true" resp = self._requests(method="delete", url=url) if resp.status_code in [200, 202]: print(f"Vector store '{store}' deleted successfully.") @@ -416,14 +435,13 @@ def create_coveragestore( headers = {"content-type": content_type, "Accept": "application/json"} - r = None with open(path, "rb") as f: r = self._requests(method="put", url=url, data=f, headers=headers) - if r.status_code == 201: - return r.json() - else: - raise GeoserverException(r.status_code, r.content) + if r.status_code in (200, 201): + return r.json() + else: + raise GeoserverException(r.status_code, r.content) def upload_raster( self, @@ -513,6 +531,46 @@ def publish_time_dimension_to_coveragestore( else: raise GeoserverException(r.status_code, r.content) + def configure_coverage( + self, + workspace: str, + store: str, + coverage: Optional[str] = None, + srs: str = "EPSG:4326", + projection_policy: str = "FORCE_DECLARED", + enabled: bool = True, + ): + """ + Force a coverage's declared SRS, projection policy, and enabled state. + + Use this right after ``create_coveragestore`` when the source raster + may have missing/unresolvable CRS metadata (e.g. broken PROJ on the + writer side). ``FORCE_DECLARED`` tells GeoServer to treat the declared + SRS as authoritative instead of relying on the file's native CRS. + """ + coverage_name = coverage or store + url = "{0}/rest/workspaces/{1}/coveragestores/{2}/coverages/{3}".format( + self.service_url, workspace, store, coverage_name + ) + body = ( + "" + f"{coverage_name}" + f"{coverage_name}" + f"{str(enabled).lower()}" + f"{srs}" + f"{projection_policy}" + "" + ) + r = self._requests( + method="put", + url=url, + data=body, + headers={"content-type": "application/xml"}, + ) + if r.status_code in (200, 201): + return r.status_code + raise GeoserverException(r.status_code, r.content) + # delete coveragestore(raster) def delete_raster_store(self, workspace, store): """ @@ -523,7 +581,7 @@ def delete_raster_store(self, workspace, store): - store: Name of the vector datastore to delete """ print("inside delete_raster_store") - url = f"{GEOSERVER_URL}/rest/workspaces/{workspace}/coveragestores/{store}?recurse=true" + url = f"{self.service_url}/rest/workspaces/{workspace}/coveragestores/{store}?recurse=true" resp = self._requests(method="delete", url=url) if resp.status_code in [200, 202]: print(f"Raster store '{store}' deleted successfully.") From 021a948d17311f0bc710dca88a8f62f36f3a6e34 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Fri, 24 Apr 2026 15:34:13 +0530 Subject: [PATCH 07/19] changed the change detection vector name --- computing/change_detection/change_detection_vector_local.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/computing/change_detection/change_detection_vector_local.py b/computing/change_detection/change_detection_vector_local.py index c457a352..6d9b36be 100644 --- a/computing/change_detection/change_detection_vector_local.py +++ b/computing/change_detection/change_detection_vector_local.py @@ -144,6 +144,7 @@ def run_change_detection_vector_local( class_definitions=class_definitions, ) + published_layer_name = _published_layer_name(district, block, param_name) output_stub = _output_stub(district, block, param_name, start_year, end_year) output_path = build_output_vector_path( layer_name=output_stub, @@ -156,11 +157,9 @@ def run_change_detection_vector_local( asset_id = write_vector_output( gdf=result_gdf, output_path=output_path, - layer_name=output_stub, + layer_name=published_layer_name, ) print(f"Saved local change detection vector: {asset_id}") - - published_layer_name = _published_layer_name(district, block, param_name) if push_to_geoserver: geoserver_response = push_local_vector_to_geoserver( path=os.path.splitext(asset_id)[0], From 8dfd025784323c9486ec02c0fa0e132aa745a790 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Mon, 4 May 2026 18:22:37 +0530 Subject: [PATCH 08/19] acquifer --- computing/utils.py | 41 ++--------------------------------------- 1 file changed, 2 insertions(+), 39 deletions(-) diff --git a/computing/utils.py b/computing/utils.py index aa594383..e1191492 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -807,43 +807,6 @@ def save_layer_info_to_db( return layer_obj.id -def update_layer_sync_status( - layer_id, sync_to_geoserver=None, is_stac_specs_generated=None -): - if _get_prod_backend_url(): - _update_layer_sync_remote( - layer_id, - sync_to_geoserver=sync_to_geoserver, - is_stac_specs_generated=is_stac_specs_generated, - ) - return layer_id - - try: - layer_obj = Layer.objects.filter(id=layer_id) - if sync_to_geoserver is not None: - updated_count = layer_obj.update(is_sync_to_geoserver=sync_to_geoserver) - - if updated_count > 0: - print( - f"Updated sync status to {sync_to_geoserver} for layer ID: {layer_id}" - ) - return layer_id - - if is_stac_specs_generated is not None: - updated_count = layer_obj.update( - is_stac_specs_generated=is_stac_specs_generated - ) - - if updated_count > 0: - print( - f"Updated sync status to {is_stac_specs_generated} for layer ID: {layer_id}" - ) - return layer_id - - except Exception as e: - print(f"Error updating layer sync status: {e}") - - def get_existing_end_year(dataset_name, layer_name): """fetch objects from db on the basis of dataset name and layer_name""" dataset = Dataset.objects.get(name=dataset_name) @@ -1138,7 +1101,7 @@ def _sync_layer_to_prod_db(payload: dict): if not prod_url: return None - endpoint = prod_url + "/api/v1/computing/sync_layer_remote/" + endpoint = prod_url + "/api/v1/sync_layer_remote/" try: response = requests.post( endpoint, @@ -1167,7 +1130,7 @@ def _update_layer_sync_remote(layer_id, sync_to_geoserver=None, is_stac_specs_ge if not prod_url or layer_id is None: return - endpoint = prod_url + "/api/v1/computing/update_layer_sync_remote/" + endpoint = prod_url + "/api/v1/update_layer_sync_remote/" payload = { "layer_id": layer_id, "sync_to_geoserver": sync_to_geoserver, From 4c3f4c0ff1e41f939f70c95d558e056cf04093ac Mon Sep 17 00:00:00 2001 From: Ankit K Date: Mon, 11 May 2026 13:32:26 +0530 Subject: [PATCH 09/19] change detection itr completed on geoserver and DB --- .../change_detection_vector_local.py | 1 + computing/local_compute_helper.py | 25 +- computing/utils.py | 203 +-------------- utilities/active_loc_layer_generation.py | 110 +++++++++ utilities/active_location_for_layer_gen.json | 232 ++++++++++++++++++ utilities/geoserver_utils.py | 35 +++ 6 files changed, 408 insertions(+), 198 deletions(-) create mode 100644 utilities/active_loc_layer_generation.py create mode 100644 utilities/active_location_for_layer_gen.json diff --git a/computing/change_detection/change_detection_vector_local.py b/computing/change_detection/change_detection_vector_local.py index 6d9b36be..6dce5fd8 100644 --- a/computing/change_detection/change_detection_vector_local.py +++ b/computing/change_detection/change_detection_vector_local.py @@ -182,6 +182,7 @@ def run_change_detection_vector_local( layer_name=published_layer_name, asset_id=asset_id, dataset_name="Change Detection Vector", + misc={"is_computed_locally": True}, ) if layer_id and push_to_geoserver: update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) diff --git a/computing/local_compute_helper.py b/computing/local_compute_helper.py index 53519013..d6d0d1d6 100644 --- a/computing/local_compute_helper.py +++ b/computing/local_compute_helper.py @@ -10,6 +10,9 @@ from shapely.geometry import mapping from utilities.gee_utils import valid_gee_text +from computing.utils import convert_to_zip +import logging + PROJECT_ROOT = Path(__file__).resolve().parents[1] PRECOMPUTED_TEHSIL_WATERSHED_DIR = ( @@ -242,10 +245,10 @@ def clip_raster_with_roi(roi_gdf, raster_path, output_path, raster_label="Raster RASTER_DECLARED_SRS = "EPSG:4326" +VECTOR_DECLARED_SRS = "EPSG:4326" def _push_raster_to_geoserver_instance(geo, file_path, layer_name, workspace, style_name): - import logging _log = logging.getLogger(__name__) @@ -296,7 +299,7 @@ def _verify_raster_layer(geo, workspace, layer_name): def _push_vector_to_geoserver_instance(geo, path, layer_name, workspace, file_type="gpkg"): - from computing.utils import convert_to_zip + _log = logging.getLogger(__name__) geo.ensure_workspace(workspace) try: @@ -305,12 +308,28 @@ def _push_vector_to_geoserver_instance(geo, path, layer_name, workspace, file_ty pass zip_path = convert_to_zip(path, file_type) - return geo.create_shp_datastore( + response = geo.create_shp_datastore( path=zip_path, store_name=layer_name, workspace=workspace, file_extension=file_type, ) + try: + geo.configure_featuretype( + workspace=workspace, + store=layer_name, + featuretype=layer_name, + srs=VECTOR_DECLARED_SRS, + projection_policy="FORCE_DECLARED", + ) + except Exception as e: + _log.warning( + "Failed to force SRS on featuretype %s:%s: %s", + workspace, + layer_name, + e, + ) + return response def push_local_vector_to_geoserver(path, layer_name, workspace, file_type="gpkg"): diff --git a/computing/utils.py b/computing/utils.py index e1191492..c4038942 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -681,132 +681,6 @@ def _update_layer_sync_remote(layer_id, sync_to_geoserver=None, is_stac_specs_ge logger.error("Failed to update layer sync status on prod DB for id=%s: %s", layer_id, e) -def save_layer_info_to_db( - state, - district, - block, - layer_name, - asset_id, - dataset_name, - sync_to_geoserver=False, - layer_version="1.0", - algorithm=None, - algorithm_version="1.0", - misc=None, - is_override=False, -): - if _get_prod_backend_url(): - layer_id = _sync_layer_to_prod_db({ - "state": state, - "district": district, - "block": block, - "layer_name": layer_name, - "asset_id": asset_id, - "dataset_name": dataset_name, - "sync_to_geoserver": sync_to_geoserver, - "layer_version": layer_version, - "algorithm": algorithm, - "algorithm_version": algorithm_version, - "misc": misc, - "is_override": is_override, - }) - if layer_id is not None: - return layer_id - logger.warning( - "Prod DB sync failed for layer %s — falling back to local DB write.", layer_name - ) - - print("inside the save_layer_info_to_db function") - - dataset = Dataset.objects.get(name=dataset_name) - - try: - state_obj = StateSOI.objects.get(state_name__iexact=state) - district_obj = DistrictSOI.objects.get( - district_name__iexact=district, state=state_obj - ) - block_obj = TehsilSOI.objects.get( - tehsil_name__iexact=block, district=district_obj - ) - except Exception as e: - print("Error fetching in state district block:", e) - return - - is_public = is_asset_public(asset_id) - - # Check if there’s an existing layer - existing_layer = ( - Layer.objects.filter( - dataset=dataset, - layer_name=layer_name, - state=state_obj, - district=district_obj, - block=block_obj, - ) - .order_by("-layer_version") - .first() - ) - - if existing_layer: - if existing_layer.algorithm_version != algorithm_version: - # Algorithm version changed --> create new record with incremented layer_version - new_layer_version = str(float(existing_layer.layer_version) + 1) - print( - f"Algorithm version changed. Creating new layer version: {new_layer_version}" - ) - layer_obj = Layer.objects.create( - dataset=dataset, - layer_name=layer_name, - state=state_obj, - district=district_obj, - block=block_obj, - layer_version=new_layer_version, - algorithm=algorithm, - algorithm_version=algorithm_version, - is_sync_to_geoserver=sync_to_geoserver, - is_public_gee_asset=is_public, - is_override=is_override, - misc=misc, - gee_asset_path=asset_id, - ) - else: - # Algorithm version is same --> update existing layer - print("Algorithm version same. Updating existing layer.") - for field, value in { - "algorithm": algorithm, - "algorithm_version": algorithm_version, - "is_sync_to_geoserver": sync_to_geoserver, - "is_public_gee_asset": is_public, - "is_override": is_override, - "misc": misc, - "gee_asset_path": asset_id, - }.items(): - setattr(existing_layer, field, value) - existing_layer.save() - layer_obj = existing_layer - else: - # No existing record --> create a new one - print("No existing layer found. Creating new one.") - layer_obj = Layer.objects.create( - dataset=dataset, - layer_name=layer_name, - state=state_obj, - district=district_obj, - block=block_obj, - layer_version=layer_version, - algorithm=algorithm, - algorithm_version=algorithm_version, - is_sync_to_geoserver=sync_to_geoserver, - is_public_gee_asset=is_public, - is_override=is_override, - misc=misc, - gee_asset_path=asset_id, - ) - - print(f"Saved layer info (id={layer_obj.id}, version={layer_obj.layer_version})") - return layer_obj.id - - def get_existing_end_year(dataset_name, layer_name): """fetch objects from db on the basis of dataset name and layer_name""" dataset = Dataset.objects.get(name=dataset_name) @@ -1088,73 +962,6 @@ def compute_for_feature(feature): -def _get_prod_backend_url(): - return getattr(settings, "PROD_BACKEND_URL", "").rstrip("/") - - -def _get_prod_api_key(): - return getattr(settings, "PROD_BACKEND_API_KEY", "") - - -def _sync_layer_to_prod_db(payload: dict): - prod_url = _get_prod_backend_url() - if not prod_url: - return None - - endpoint = prod_url + "/api/v1/sync_layer_remote/" - try: - response = requests.post( - endpoint, - json=payload, - headers={"X-Api-Key": _get_prod_api_key()}, - timeout=30, - ) - if response.status_code not in (200, 201): - logger.warning( - "Prod DB sync returned %s for layer %s: %s", - response.status_code, - payload.get("layer_name"), - response.text, - ) - return None - layer_id = response.json().get("layer_id") - logger.info("Layer %s synced to prod DB (id=%s).", payload.get("layer_name"), layer_id) - return layer_id - except requests.RequestException as e: - logger.error("Failed to sync layer %s to prod DB: %s", payload.get("layer_name"), e) - return None - - -def _update_layer_sync_remote(layer_id, sync_to_geoserver=None, is_stac_specs_generated=None): - prod_url = _get_prod_backend_url() - if not prod_url or layer_id is None: - return - - endpoint = prod_url + "/api/v1/update_layer_sync_remote/" - payload = { - "layer_id": layer_id, - "sync_to_geoserver": sync_to_geoserver, - "is_stac_specs_generated": is_stac_specs_generated, - } - try: - response = requests.post( - endpoint, - json=payload, - headers={"X-Api-Key": _get_prod_api_key()}, - timeout=30, - ) - if response.status_code not in (200, 201): - logger.warning( - "Prod layer sync status update returned %s for layer %s: %s", - response.status_code, - layer_id, - response.text, - ) - else: - logger.info("Layer sync status updated on prod DB for id=%s.", layer_id) - except requests.RequestException as e: - logger.error("Failed to update layer sync status on prod DB for id=%s: %s", layer_id, e) - def save_layer_info_to_db( state, @@ -1171,7 +978,7 @@ def save_layer_info_to_db( is_override=False, ): if _get_prod_backend_url(): - return _sync_layer_to_prod_db({ + layer_id = _sync_layer_to_prod_db({ "state": state, "district": district, "block": block, @@ -1185,6 +992,11 @@ def save_layer_info_to_db( "misc": misc, "is_override": is_override, }) + if layer_id is not None: + return layer_id + logger.warning( + "Prod DB sync failed for layer %s — falling back to local DB write.", layer_name + ) print("inside the save_layer_info_to_db function") @@ -1242,13 +1054,14 @@ def save_layer_info_to_db( else: # Algorithm version is same --> update existing layer print("Algorithm version same. Updating existing layer.") + merged_misc = {**(existing_layer.misc or {}), **(misc or {})} for field, value in { "algorithm": algorithm, "algorithm_version": algorithm_version, "is_sync_to_geoserver": sync_to_geoserver, "is_public_gee_asset": is_public, "is_override": is_override, - "misc": misc, + "misc": merged_misc, "gee_asset_path": asset_id, }.items(): setattr(existing_layer, field, value) diff --git a/utilities/active_loc_layer_generation.py b/utilities/active_loc_layer_generation.py new file mode 100644 index 00000000..7eaf1144 --- /dev/null +++ b/utilities/active_loc_layer_generation.py @@ -0,0 +1,110 @@ +import requests +import os +import json + +# Load the locations data from the JSON file +with open("/home/cfpt-jedi/developer/repos/core-stack-backend/utilities/active_location_for_layer_gen.json", "r") as file: + locations = json.load(file) + +# Base URL for API request + + +# Function to make the GET request and save the file +def download_excel(state, district, block): + # Constructing the URL with query parameters + base_url = "http://127.0.0.1:8000/api/v1/add_new_layer_data_to_excel/" + params = { + "state": state, + "district": district, + "block": block, + "workspace": "nrega_assets", + } + + print(f"Generation Excel for {state} {district} {block}") + + try: + # Make GET request to the API + response = requests.get(base_url, params=params) + + # Check if the request was successful + if response.status_code == 200: + # Ensure directory exists + # os.makedirs(os.path.dirname(save_path), exist_ok=True) + + # Saving the file (Excel file) + # with open(save_path, 'wb') as file: + # file.write(response.content) + print("File saved to:") + else: + print( + f"Failed to fetch data for {state} - {district} - {block}. Status code: {response.status_code}" + ) + except Exception as e: + print(f"An error occurred: {e}") + + +def restoration_vector(state, district, block): + # Updated base URL + base_url = "http://localhost:8000/api/v1/generate_fabdem_raster/" + + headers = { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzg0NTQwNTQ2LCJpYXQiOjE3NzY3NjQ1NDYsImp0aSI6IjIxYmVmZTMwYWE2MzQ4ZDM4YmE5M2EyNGQ3MjdhYzgyIiwidXNlcl9pZCI6NH0.FHqq8dRaI39L5zIDKotjYIlwnnc8JWyi9B1vDLWBhZQ", + "Content-Type": "application/json", + } + + # base_url = "https://geoserver.core-stack.org/api/v1/change_detection/" + # base_url = "https://geoserver.core-stack.org/api/v1/generate_distance_nearest_DL/" + # base_url = "https://geoserver.core-stack.org/api/v1/generate_ci_layer/" + # base_url = "https://geoserver.core-stack.org/api/v1/generate_facilities_proximity/" + + # headers = { + # "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzc5NzE0OTU5LCJpYXQiOjE3NzE5Mzg5NTksImp0aSI6IjA5OGU0MzRjYWE0ZTQ2ZDVhYmE3MTc1YjljMDAwODQ3IiwidXNlcl9pZCI6NH0.BjE41D07gARbRXCdKOcVxJjpsVMZDxVYg7oaN4ABxCg", + # "Content-Type": "application/json", + # } + + # Request bodyS + body = { + "state": state, + "district": district, + "block": block, + "start_year": 2017, + "end_year": 2024, + "compute": "local" + } + + print(f"Generating Layer for {state}, {district}, {block}") + + try: + # Make POST request to the API with headers and JSON body + response = requests.post(base_url, headers=headers, json=body) + + # Check if the request was successful + if response.status_code == 200: + print(f"Response: {response.text}") + return True + else: + print( + f"Failed to fetch data for {state} - {district} - {block}. Status code: {response.status_code}" + ) + print(f"Response: {response.text}") + return False + except Exception as e: + print(f"An error occurred: {e}") + return False + + +# Loop through the JSON data to fetch state, district, and block and download files +for entry in locations: + # state = entry["state"] + # district = entry["district"] + # block = entry["block"] + state = entry["state"] + district = entry["district"] + block = entry["block"] + + # Define the save path (e.g., saving as a .xlsx file in a folder based on district) + # save_path = os.path.join("downloaded_files", state, district, f"{block}.xlsx") + + # Download and save the Excel file + #download_excel(state, district, block) + restoration_vector(state, district, block) diff --git a/utilities/active_location_for_layer_gen.json b/utilities/active_location_for_layer_gen.json new file mode 100644 index 00000000..54dcc84d --- /dev/null +++ b/utilities/active_location_for_layer_gen.json @@ -0,0 +1,232 @@ +[ +{ + "state": "Telangana", + "district": "Vikarabad", + "block": "Bomraspet" + }, + { + "state": "Telangana", + "district": "Vikarabad", + "block": "Doma" + }, + { + "state": "Telangana", + "district": "Vikarabad", + "block": "Doulatabad" + }, + { + "state": "Uttar Pradesh", + "district": "Bahraich", + "block": "Bahraich" + }, + { + "state": "Uttar Pradesh", + "district": "Bahraich", + "block": "Nanpara" + }, + { + "state": "Uttar Pradesh", + "district": "Ballia", + "block": "Bairia" + }, + { + "state": "Uttar Pradesh", + "district": "Ballia", + "block": "Ballia" + }, + { + "state": "Uttar Pradesh", + "district": "Ballia", + "block": "Bansdih" + }, + { + "state": "Uttar Pradesh", + "district": "Ballia", + "block": "Belthara Road" + }, + { + "state": "Uttar Pradesh", + "district": "Ballia", + "block": "Rasra" + }, + { + "state": "Uttar Pradesh", + "district": "Ballia", + "block": "Sikandarpur" + }, + { + "state": "Uttar Pradesh", + "district": "Balrampur", + "block": "Balrampur" + }, + { + "state": "Uttar Pradesh", + "district": "Balrampur", + "block": "Tulsipur" + }, + { + "state": "Uttar Pradesh", + "district": "Bara banki", + "block": "Fatehpur" + }, + { + "state": "Uttar Pradesh", + "district": "Basti", + "block": "Basti" + }, + { + "state": "Uttar Pradesh", + "district": "Basti", + "block": "Bhanpur" + }, + { + "state": "Uttar Pradesh", + "district": "Basti", + "block": "Harraiya" + }, + { + "state": "Uttar Pradesh", + "district": "Basti", + "block": "Rudhauli" + }, + { + "state": "Uttar Pradesh", + "district": "Bhadohi", + "block": "Aurai" + }, + { + "state": "Uttar Pradesh", + "district": "Bhadohi", + "block": "Gyanpur" + }, + { + "state": "Uttar Pradesh", + "district": "Chitrakoot", + "block": "Karwi" + }, + { + "state": "Uttar Pradesh", + "district": "Chitrakoot", + "block": "Mau" + }, + { + "state": "Uttar Pradesh", + "district": "Deoria", + "block": "Deoria" + }, + { + "state": "Uttar Pradesh", + "district": "Fatehpur", + "block": "Binki" + }, + { + "state": "Uttar Pradesh", + "district": "Fatehpur", + "block": "Fatehpur" + }, + { + "state": "Uttar Pradesh", + "district": "Fatehpur", + "block": "Khaga" + }, + { + "state": "Uttar Pradesh", + "district": "Hamirpur", + "block": "Maudaha" + }, + { + "state": "Uttar Pradesh", + "district": "Jaunpur", + "block": "Badlapur" + }, + { + "state": "Uttar Pradesh", + "district": "Jhansi", + "block": "Jhansi" + }, + { + "state": "Uttar Pradesh", + "district": "Kaushambi", + "block": "Chail" + }, + { + "state": "Uttar Pradesh", + "district": "Lucknow", + "block": "Lucknow" + }, + { + "state": "Uttar Pradesh", + "district": "Mau", + "block": "Ghosi" + }, + { + "state": "Uttar Pradesh", + "district": "Mau", + "block": "Madhuban" + }, + { + "state": "Uttar Pradesh", + "district": "Mau", + "block": "Mau" + }, + { + "state": "Uttar Pradesh", + "district": "Mau", + "block": "Muhammadabad" + }, + { + "state": "Uttar Pradesh", + "district": "Mirzapur", + "block": "Marihan" + }, + { + "state": "Uttar Pradesh", + "district": "Mirzapur", + "block": "Mirzapur" + }, + { + "state": "Uttar Pradesh", + "district": "Shrawasti", + "block": "Bhinga" + }, + { + "state": "West Bengal", + "district": "Bankura", + "block": "Bankura" + }, + { + "state": "West Bengal", + "district": "Darjiling", + "block": "Karsiyang" + }, + { + "state": "West Bengal", + "district": "Darjiling", + "block": "Siliguri" + }, + { + "state": "West Bengal", + "district": "North Twenty-Four Parganas", + "block": "Basirhat" + }, + { + "state": "West Bengal", + "district": "Purba Medinipur", + "block": "Tamluk" + }, + { + "state": "West Bengal", + "district": "Puruliya", + "block": "Raghunathpur" + }, + { + "state": "West Bengal", + "district": "South Twenty-Four Parganas", + "block": "Canning" + }, + { + "state": "West Bengal", + "district": "South Twenty-Four Parganas", + "block": "Kakdwip" + } +] diff --git a/utilities/geoserver_utils.py b/utilities/geoserver_utils.py index 53dbe592..78b4599a 100644 --- a/utilities/geoserver_utils.py +++ b/utilities/geoserver_utils.py @@ -571,6 +571,41 @@ def configure_coverage( return r.status_code raise GeoserverException(r.status_code, r.content) + def configure_featuretype( + self, + workspace: str, + store: str, + featuretype: Optional[str] = None, + srs: str = "EPSG:4326", + projection_policy: str = "FORCE_DECLARED", + ): + """ + Force a featuretype's declared SRS and projection policy. + + Use after publishing a vector store when the source file may have + missing or mismatched CRS metadata. ``FORCE_DECLARED`` treats the + declared SRS as authoritative without reprojecting coordinates. + """ + ft_name = featuretype or store + url = "{0}/rest/workspaces/{1}/datastores/{2}/featuretypes/{3}".format( + self.service_url, workspace, store, ft_name + ) + body = ( + "" + f"{srs}" + f"{projection_policy}" + "" + ) + r = self._requests( + method="put", + url=url, + data=body, + headers={"content-type": "application/xml"}, + ) + if r.status_code in (200, 201): + return r.status_code + raise GeoserverException(r.status_code, r.content) + # delete coveragestore(raster) def delete_raster_store(self, workspace, store): """ From dd93b91d5cfbcef98d05b82fdfd5e005aed539ea Mon Sep 17 00:00:00 2001 From: Ankit K Date: Tue, 12 May 2026 23:45:23 +0530 Subject: [PATCH 10/19] added config file and used it across local generation --- computing/base_layer_setup.py | 48 +++----- .../change_detection_local.py | 5 +- .../change_detection_vector_local.py | 9 +- computing/config.yaml | 111 ++++++++++++++++++ computing/config_loader.py | 96 +++++++++++++++ computing/local_compute_helper.py | 17 ++- computing/lulc/lulc_vector_local.py | 5 +- .../lulc_on_slope_cluster_local.py | 3 +- computing/misc/aquifer_vector_local.py | 9 +- 9 files changed, 241 insertions(+), 62 deletions(-) create mode 100644 computing/config.yaml create mode 100644 computing/config_loader.py diff --git a/computing/base_layer_setup.py b/computing/base_layer_setup.py index 415cd1d3..081460ec 100644 --- a/computing/base_layer_setup.py +++ b/computing/base_layer_setup.py @@ -1,40 +1,24 @@ import logging import subprocess -from pathlib import Path import requests -from django.conf import settings - from utilities.constants import GEOSERVER_BASE -logger = logging.getLogger(__name__) +from computing.config_loader import ( + ADMIN_BOUNDARY_INPUT_DIR, + ADMIN_BOUNDARY_OUTPUT_DIR, + GDRIVE_ADMIN_BOUNDARY_FILE_ID as _GDRIVE_ADMIN_BOUNDARY_FILE_ID, + GDRIVE_MICROWATERSHED_FILE_ID as _GDRIVE_MICROWATERSHED_FILE_ID, + LULC_BASE_DIR as LULC_DIR, + LULC_GDRIVE_FILES as _LULC_GDRIVE_FILES, + MICROWATERSHED_PATH, + PRECOMPUTED_TEHSIL_WATERSHED_DIR as TEHSIL_WATERSHEDS_DIR, + PROJECT_ROOT, + SOI_TEHSIL_PATH, + VILLAGE_BOUNDARIES_DIR, +) -_PROJECT_ROOT = Path(settings.BASE_DIR) - -SOI_TEHSIL_PATH = _PROJECT_ROOT / "data/admin-boundary/input/soi_tehsil.geojson" -ADMIN_BOUNDARY_INPUT_DIR = _PROJECT_ROOT / "data/admin-boundary/input" -ADMIN_BOUNDARY_OUTPUT_DIR = _PROJECT_ROOT / "data/admin-boundary/output" -LULC_DIR = _PROJECT_ROOT / "data/base_layers/lulc" -VILLAGE_BOUNDARIES_DIR = _PROJECT_ROOT / "data/base_layers/village_boundaries" -TEHSIL_WATERSHEDS_DIR = _PROJECT_ROOT / "data/base_layers/tehsil_watersheds" - -_GDRIVE_ADMIN_BOUNDARY_FILE_ID = "1VqIhB6HrKFDkDnlk1vedcEHhh5fk4f1d" - -MICROWATERSHED_PATH = _PROJECT_ROOT / "data/base_layers/Microwatershed_v2_with_details.geojson" -# TODO: fill in the Google Drive file ID for Microwatershed_v2_with_details.geojson -_GDRIVE_MICROWATERSHED_FILE_ID = "" - -# Ordered oldest → newest; each file is ~7-8 GB. -_LULC_GDRIVE_FILES = [ - ("lulc_v3_2017_2018.tif", "1VidwEQqkwtoHqqdUqdwURWyiGd-OteaJ"), - ("lulc_v3_2018_2019.tif", "1ZeLMAiBfolMrfEJkFnOlvb8OjSqC9vHP"), - ("lulc_v3_2019_2020.tif", "1gx5VwJCHI-WUDJIwWv48OvbybBe9y0PR"), - ("lulc_v3_2020_2021.tif", "1xbOt3-t1Ws5olq2Q88Tk32KnUVNKUXqe"), - ("lulc_v3_2021_2022.tif", "1m8ZnUBbTp-fcH_JcRTUEceRaa8WewQmz"), - ("lulc_v3_2022_2023.tif", "1_S0VESClg7s-DloAqxrfU8mLHhNSBfp7"), - ("lulc_v3_2023_2024.tif", "1JVfl67ARRv7TPV5lyLnjoSfWDiXtvXjY"), - ("lulc_v3_2024_2025.tif", "1CPV03S47s0asEJqdAozbNOT1lkgr0YkG"), -] +logger = logging.getLogger(__name__) _SOI_WFS_PARAMS = { "service": "WFS", @@ -92,7 +76,7 @@ def ensure_admin_boundary_data(): ADMIN_BOUNDARY_INPUT_DIR.mkdir(parents=True, exist_ok=True) ADMIN_BOUNDARY_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - archive_path = _PROJECT_ROOT / "dataset.7z" + archive_path = PROJECT_ROOT / "dataset.7z" logger.info("Downloading admin boundary data (~8 GB) from Google Drive...") try: subprocess.run( @@ -106,7 +90,7 @@ def ensure_admin_boundary_data(): logger.info("Extracting admin boundary data...") try: subprocess.run( - ["7z", "x", str(archive_path), f"-o{_PROJECT_ROOT / 'data/admin-boundary'}"], + ["7z", "x", str(archive_path), f"-o{PROJECT_ROOT / 'data/admin-boundary'}"], check=True, ) except (subprocess.CalledProcessError, FileNotFoundError) as e: diff --git a/computing/change_detection/change_detection_local.py b/computing/change_detection/change_detection_local.py index a539e703..43d3cb87 100644 --- a/computing/change_detection/change_detection_local.py +++ b/computing/change_detection/change_detection_local.py @@ -10,9 +10,9 @@ from nrm_app.celery import app +from computing.config_loader import CHANGE_DETECTION_RASTER_OUTPUT_DIR as LOCAL_OUTPUT_BASE_DIR from computing.local_compute_helper import ( PRECOMPUTED_TEHSIL_WATERSHED_DIR, - PROJECT_ROOT, build_output_raster_path, get_union_geometry, load_precomputed_roi, @@ -21,9 +21,6 @@ validate_geometry, ) from computing.utils import save_layer_info_to_db, update_layer_sync_status - - -LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/change_detection/change_detection_local" GEOSERVER_WORKSPACE = "change_detection" CHANGE_STAC_LAYER_NAMES = { diff --git a/computing/change_detection/change_detection_vector_local.py b/computing/change_detection/change_detection_vector_local.py index 6dce5fd8..1cacab3a 100644 --- a/computing/change_detection/change_detection_vector_local.py +++ b/computing/change_detection/change_detection_vector_local.py @@ -3,9 +3,12 @@ from nrm_app.celery import app from utilities.gee_utils import valid_gee_text +from computing.config_loader import ( + CHANGE_DETECTION_RASTER_OUTPUT_DIR as CHANGE_RASTER_OUTPUT_BASE_DIR, + CHANGE_DETECTION_VECTOR_OUTPUT_DIR as LOCAL_OUTPUT_BASE_DIR, +) from computing.local_compute_helper import ( PRECOMPUTED_TEHSIL_WATERSHED_DIR, - PROJECT_ROOT, build_output_raster_path, build_output_vector_path, compute_categorical_raster_areas_for_watersheds, @@ -18,10 +21,6 @@ save_layer_info_to_db, update_layer_sync_status, ) - - -CHANGE_RASTER_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/change_detection/change_detection_local" -LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/change_detection/change_detection_vector_local" GEOSERVER_WORKSPACE = "change_detection" CHANGE_VECTOR_CLASS_DEFINITIONS = { diff --git a/computing/config.yaml b/computing/config.yaml new file mode 100644 index 00000000..c267cc1b --- /dev/null +++ b/computing/config.yaml @@ -0,0 +1,111 @@ +# Local compute dependency manifest. + +base_layers: + inputs: + - path: data/base_layers/lulc/lulc_v3_2017_2018.tif + source: google_drive + gdrive_id: 1VidwEQqkwtoHqqdUqdwURWyiGd-OteaJ + - path: data/base_layers/lulc/lulc_v3_2018_2019.tif + source: google_drive + gdrive_id: 1ZeLMAiBfolMrfEJkFnOlvb8OjSqC9vHP + - path: data/base_layers/lulc/lulc_v3_2019_2020.tif + source: google_drive + gdrive_id: 1gx5VwJCHI-WUDJIwWv48OvbybBe9y0PR + - path: data/base_layers/lulc/lulc_v3_2020_2021.tif + source: google_drive + gdrive_id: 1xbOt3-t1Ws5olq2Q88Tk32KnUVNKUXqe + - path: data/base_layers/lulc/lulc_v3_2021_2022.tif + source: google_drive + gdrive_id: 1m8ZnUBbTp-fcH_JcRTUEceRaa8WewQmz + - path: data/base_layers/lulc/lulc_v3_2022_2023.tif + source: google_drive + gdrive_id: 1_S0VESClg7s-DloAqxrfU8mLHhNSBfp7 + - path: data/base_layers/lulc/lulc_v3_2023_2024.tif + source: google_drive + gdrive_id: 1JVfl67ARRv7TPV5lyLnjoSfWDiXtvXjY + - path: data/base_layers/lulc/lulc_v3_2024_2025.tif + source: google_drive + gdrive_id: 1CPV03S47s0asEJqdAozbNOT1lkgr0YkG + + - path: data/base_layers/terrain_raster_fabdam_pan_india.tif + source: manual + note: Pan-India terrain raster used for slope/terrain classification + + - path: data/base_layers/AEZs/Agro_Ecological_Regions.shp + source: manual + note: Agro-Ecological Zone boundaries for AEZ-based cluster lookup + + - path: data/base_layers/Microwatershed_v2_with_details.geojson + source: google_drive + gdrive_id: 1gwaCrCXq1t3fWyfqcQMw1e7DTlzxoCb3 + + - path: data/base_layers/Aquifer_vector.geojson + source: manual + note: Pan-India aquifer vector layer + + - path: data/admin-boundary/input/ + source: google_drive + gdrive_id: 1VqIhB6HrKFDkDnlk1vedcEHhh5fk4f1d + note: Full admin-boundary archive (~8 GB, 7z); extracted in place + + - path: data/admin-boundary/input/soi_tehsil.geojson + source: geoserver_wfs + typename: pan_india_asset:SOI_tehsil_pan_india_dataset + note: Lightweight bootstrap; also included in the full admin-boundary archive + + - path: data/base_layers/tehsil_watersheds/ + source: derived + derived_from: + - data/base_layers/Microwatershed_v2_with_details.geojson + - data/admin-boundary/input/soi_tehsil.geojson + note: Per-tehsil watershed .gpkg files; generated by store_watersheds_for_tehsils + + - path: data/base_layers/village_boundaries/ + source: manual + note: Village boundary files; download source TBD + +local_compute_outputs: + change_detection: + # change_detection_local.py + - path: data/change_detection/change_detection_local/{state}/{district}/{block}/ + pattern: "change_{district}_{block}_{param_name}_{start_year}_{end_year}.tif" + params: + [ + Afforestation, + Deforestation, + Degradation, + Urbanization, + CropIntensity, + ] + geoserver_workspace: change_detection + + # change_detection_vector_local.py + - path: data/change_detection/change_detection_vector_local/{state}/{district}/{block}/ + pattern: "change_vector_{district}_{block}_{param_name}_{start_year}_{end_year}.gpkg" + params: + [ + Afforestation, + Deforestation, + Degradation, + Urbanization, + CropIntensity, + ] + geoserver_workspace: change_detection + + lulc: + # lulc_vector_local.py + - path: data/lulc/lulc_vector_local/{state}/{district}/{block}/ + pattern: "lulc_vector_{district}_{block}.gpkg" + geoserver_workspace: lulc_vector + + lulc_x_terrain: + # lulc_on_slope_cluster_local.py + - path: data/lulc_X_terrain/lulc_slope_clusters_local/{state}/{district}/{block}/ + pattern: "{district}_{block}_lulc_slope.gpkg" + geoserver_workspace: terrain_lulc + + misc: + # aquifer_vector_local.py + - path: data/misc/aquifer_vector_local/{state}/{district}/{block}/ + pattern: "aquifer_vector_{district}_{block}.gpkg" + geoserver_workspace: aquifer diff --git a/computing/config_loader.py b/computing/config_loader.py new file mode 100644 index 00000000..6cee97bb --- /dev/null +++ b/computing/config_loader.py @@ -0,0 +1,96 @@ +from pathlib import Path + +import yaml + +_CONFIG_PATH = Path(__file__).resolve().parent / "config.yaml" +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _load(): + with open(_CONFIG_PATH) as f: + return yaml.safe_load(f) + + +_cfg = _load() + + +def _abs(rel_path: str) -> Path: + base = rel_path.split("{")[0].rstrip("/") + return PROJECT_ROOT / base + + +def _find_input(path_suffix: str) -> dict: + for item in _cfg["base_layers"]["inputs"]: + if item["path"] == path_suffix: + return item + raise KeyError(f"No base layer input found in config.yaml for path: {path_suffix}") + + +def _output_entry(module: str, index: int = 0) -> dict: + return _cfg["local_compute_outputs"][module][index] + + +# --------------------------------------------------------------------------- +# Input paths +# --------------------------------------------------------------------------- + +LULC_BASE_DIR: Path = _abs( + next( + item["path"] + for item in _cfg["base_layers"]["inputs"] + if item["path"].startswith("data/base_layers/lulc/") + ) +).parent + +TERRAIN_RASTER_PATH: Path = PROJECT_ROOT / _find_input( + "data/base_layers/terrain_raster_fabdam_pan_india.tif" +)["path"] + +AEZ_VECTOR_PATH: Path = PROJECT_ROOT / _find_input( + "data/base_layers/AEZs/Agro_Ecological_Regions.shp" +)["path"] + +PRECOMPUTED_TEHSIL_WATERSHED_DIR: Path = _abs( + _find_input("data/base_layers/tehsil_watersheds/")["path"] +) + +MICROWATERSHED_PATH: Path = PROJECT_ROOT / _find_input( + "data/base_layers/Microwatershed_v2_with_details.geojson" +)["path"] + +AQUIFER_VECTOR_PATH: Path = PROJECT_ROOT / _find_input( + "data/base_layers/Aquifer_vector.geojson" +)["path"] + +SOI_TEHSIL_PATH: Path = PROJECT_ROOT / _find_input( + "data/admin-boundary/input/soi_tehsil.geojson" +)["path"] + +ADMIN_BOUNDARY_INPUT_DIR: Path = PROJECT_ROOT / "data/admin-boundary/input" +ADMIN_BOUNDARY_OUTPUT_DIR: Path = PROJECT_ROOT / "data/admin-boundary/output" +VILLAGE_BOUNDARIES_DIR: Path = PROJECT_ROOT / "data/base_layers/village_boundaries" + +# --------------------------------------------------------------------------- +# Google Drive IDs +# --------------------------------------------------------------------------- + +GDRIVE_ADMIN_BOUNDARY_FILE_ID: str = _find_input("data/admin-boundary/input/")["gdrive_id"] +GDRIVE_MICROWATERSHED_FILE_ID: str = _find_input( + "data/base_layers/Microwatershed_v2_with_details.geojson" +)["gdrive_id"] + +LULC_GDRIVE_FILES: list[tuple[str, str]] = [ + (Path(item["path"]).name, item["gdrive_id"]) + for item in _cfg["base_layers"]["inputs"] + if item["path"].startswith("data/base_layers/lulc/") and item.get("source") == "google_drive" +] + +# --------------------------------------------------------------------------- +# Output base directories +# --------------------------------------------------------------------------- + +CHANGE_DETECTION_RASTER_OUTPUT_DIR: Path = _abs(_output_entry("change_detection", 0)["path"]) +CHANGE_DETECTION_VECTOR_OUTPUT_DIR: Path = _abs(_output_entry("change_detection", 1)["path"]) +LULC_VECTOR_OUTPUT_DIR: Path = _abs(_output_entry("lulc", 0)["path"]) +LULC_SLOPE_CLUSTER_OUTPUT_DIR: Path = _abs(_output_entry("lulc_x_terrain", 0)["path"]) +AQUIFER_VECTOR_OUTPUT_DIR: Path = _abs(_output_entry("misc", 0)["path"]) diff --git a/computing/local_compute_helper.py b/computing/local_compute_helper.py index d6d0d1d6..254a32ea 100644 --- a/computing/local_compute_helper.py +++ b/computing/local_compute_helper.py @@ -14,18 +14,15 @@ import logging -PROJECT_ROOT = Path(__file__).resolve().parents[1] -PRECOMPUTED_TEHSIL_WATERSHED_DIR = ( - PROJECT_ROOT / "data/base_layers/tehsil_watersheds" +from computing.config_loader import ( + AEZ_VECTOR_PATH, + LULC_BASE_DIR, + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + PROJECT_ROOT, + TERRAIN_RASTER_PATH, ) + PRECOMPUTED_ROI_EXTENSIONS = (".gpkg", ".geojson") -AEZ_VECTOR_PATH = ( - PROJECT_ROOT / "data/base_layers/AEZs/Agro_Ecological_Regions.shp" -) -LULC_BASE_DIR = PROJECT_ROOT / "data/base_layers/lulc" -TERRAIN_RASTER_PATH = ( - PROJECT_ROOT / "data/base_layers/terrain_raster_fabdam_pan_india.tif" -) VALID_COMPUTE_TYPES = {"gee", "local"} MIN_WATERSHED_AREA_HA = 400.0 LULC_CLASSES = np.arange(1, 13, dtype=np.int16) diff --git a/computing/lulc/lulc_vector_local.py b/computing/lulc/lulc_vector_local.py index 837df1eb..c6aff009 100644 --- a/computing/lulc/lulc_vector_local.py +++ b/computing/lulc/lulc_vector_local.py @@ -3,10 +3,10 @@ from nrm_app.celery import app from utilities.gee_utils import valid_gee_text +from computing.config_loader import LULC_VECTOR_OUTPUT_DIR as LOCAL_OUTPUT_BASE_DIR from computing.local_compute_helper import ( LULC_BASE_DIR, PRECOMPUTED_TEHSIL_WATERSHED_DIR, - PROJECT_ROOT, build_output_vector_path, compute_categorical_raster_areas_for_watersheds, load_precomputed_watersheds, @@ -18,9 +18,6 @@ save_layer_info_to_db, update_layer_sync_status, ) - - -LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/lulc/lulc_vector_local" GEOSERVER_WORKSPACE = "lulc_vector" LOCAL_ALGORITHM = "local_lulc_vector" LOCAL_ALGORITHM_VERSION = "local-1.0" diff --git a/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py b/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py index 737fa926..078bdbea 100644 --- a/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py +++ b/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py @@ -9,12 +9,12 @@ from nrm_app.celery import app +from computing.config_loader import LULC_SLOPE_CLUSTER_OUTPUT_DIR as LOCAL_OUTPUT_BASE_DIR from computing.local_compute_helper import ( AEZ_VECTOR_PATH, LULC_BASE_DIR, MIN_WATERSHED_AREA_HA, PRECOMPUTED_TEHSIL_WATERSHED_DIR, - PROJECT_ROOT, TERRAIN_RASTER_PATH, build_output_vector_path as _build_output_vector_path, compute_mode_lulc_array as _compute_mode_lulc_array, @@ -36,7 +36,6 @@ from .utils import aez_lulcXterrain_cluster_centroids -LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/lulc_X_terrain/lulc_slope_clusters_local" GEOSERVER_WORKSPACE = "terrain_lulc" SLOPE_LULC_FIELD_MAPPING = { "barren": 7, diff --git a/computing/misc/aquifer_vector_local.py b/computing/misc/aquifer_vector_local.py index abf7e55b..8e6d5350 100644 --- a/computing/misc/aquifer_vector_local.py +++ b/computing/misc/aquifer_vector_local.py @@ -5,9 +5,12 @@ from nrm_app.celery import app +from computing.config_loader import ( + AQUIFER_VECTOR_PATH, + AQUIFER_VECTOR_OUTPUT_DIR as LOCAL_OUTPUT_BASE_DIR, +) from computing.local_compute_helper import ( PRECOMPUTED_TEHSIL_WATERSHED_DIR, - PROJECT_ROOT, build_output_vector_path, get_watershed_areas_in_hectares, load_precomputed_watersheds, @@ -20,10 +23,6 @@ save_layer_info_to_db, update_layer_sync_status, ) - - -AQUIFER_VECTOR_PATH = PROJECT_ROOT / "data/base_layers/Aquifer_vector.geojson" -LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/misc/aquifer_vector_local" GEOSERVER_WORKSPACE = "aquifer" YIELD_VALUE_MAP = { From 9f599d1076655e06db8b4aa0f84498f1446b484f Mon Sep 17 00:00:00 2001 From: Ankit K Date: Wed, 13 May 2026 01:46:27 +0530 Subject: [PATCH 11/19] LULC v3 local --- computing/change_detection/change_detection_local.py | 1 + computing/change_detection/change_detection_vector_local.py | 2 +- computing/config.yaml | 5 +++++ computing/config_loader.py | 1 + computing/lulc/lulc_v3_local.py | 6 ++---- computing/lulc/lulc_vector_local.py | 1 + computing/lulc_X_terrain/lulc_on_slope_cluster_local.py | 2 +- computing/misc/aquifer_vector_local.py | 1 + 8 files changed, 13 insertions(+), 6 deletions(-) diff --git a/computing/change_detection/change_detection_local.py b/computing/change_detection/change_detection_local.py index 43d3cb87..7ad4dbba 100644 --- a/computing/change_detection/change_detection_local.py +++ b/computing/change_detection/change_detection_local.py @@ -537,6 +537,7 @@ def run_change_detection_local( misc={ "start_year": start_year, "end_year": end_year, + "is_generated_locally": True, }, ) if layer_id and push_to_geoserver: diff --git a/computing/change_detection/change_detection_vector_local.py b/computing/change_detection/change_detection_vector_local.py index 1cacab3a..669b8c11 100644 --- a/computing/change_detection/change_detection_vector_local.py +++ b/computing/change_detection/change_detection_vector_local.py @@ -181,7 +181,7 @@ def run_change_detection_vector_local( layer_name=published_layer_name, asset_id=asset_id, dataset_name="Change Detection Vector", - misc={"is_computed_locally": True}, + misc={"is_generated_locally": True}, ) if layer_id and push_to_geoserver: update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) diff --git a/computing/config.yaml b/computing/config.yaml index c267cc1b..dadce03e 100644 --- a/computing/config.yaml +++ b/computing/config.yaml @@ -98,6 +98,11 @@ local_compute_outputs: pattern: "lulc_vector_{district}_{block}.gpkg" geoserver_workspace: lulc_vector + # lulc_v3_local.py + - path: data/lulc/lulc_v3_local/{state}/{district}/{block}/ + pattern: "{district}_{block}_{start_year}-07-01_{end_year}-06-30_LULCmap_10m.tif" + geoserver_workspace: LULC_v3 + lulc_x_terrain: # lulc_on_slope_cluster_local.py - path: data/lulc_X_terrain/lulc_slope_clusters_local/{state}/{district}/{block}/ diff --git a/computing/config_loader.py b/computing/config_loader.py index 6cee97bb..2b3eadce 100644 --- a/computing/config_loader.py +++ b/computing/config_loader.py @@ -92,5 +92,6 @@ def _output_entry(module: str, index: int = 0) -> dict: CHANGE_DETECTION_RASTER_OUTPUT_DIR: Path = _abs(_output_entry("change_detection", 0)["path"]) CHANGE_DETECTION_VECTOR_OUTPUT_DIR: Path = _abs(_output_entry("change_detection", 1)["path"]) LULC_VECTOR_OUTPUT_DIR: Path = _abs(_output_entry("lulc", 0)["path"]) +LULC_V3_OUTPUT_DIR: Path = _abs(_output_entry("lulc", 1)["path"]) LULC_SLOPE_CLUSTER_OUTPUT_DIR: Path = _abs(_output_entry("lulc_x_terrain", 0)["path"]) AQUIFER_VECTOR_OUTPUT_DIR: Path = _abs(_output_entry("misc", 0)["path"]) diff --git a/computing/lulc/lulc_v3_local.py b/computing/lulc/lulc_v3_local.py index 5fc5a0de..13e273d4 100644 --- a/computing/lulc/lulc_v3_local.py +++ b/computing/lulc/lulc_v3_local.py @@ -1,10 +1,10 @@ from nrm_app.celery import app from utilities.gee_utils import valid_gee_text +from computing.config_loader import LULC_V3_OUTPUT_DIR as LOCAL_OUTPUT_BASE_DIR from computing.local_compute_helper import ( LULC_BASE_DIR, PRECOMPUTED_TEHSIL_WATERSHED_DIR, - PROJECT_ROOT, build_output_raster_path, clip_raster_with_roi, load_precomputed_roi, @@ -14,9 +14,6 @@ ) from computing.models import Dataset from computing.utils import save_layer_info_to_db, update_layer_sync_status - - -LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/lulc/lulc_v3_local" GEOSERVER_WORKSPACE = "LULC_v3" GEOSERVER_STYLE = "lulc_level_3_style" LOCAL_ALGORITHM = "local_lulc_v3_clip" @@ -175,6 +172,7 @@ def run_lulc_v3_local( misc={ "start_year": start_year, "end_year": end_year, + "is_generated_locally": True, }, algorithm=LOCAL_ALGORITHM, algorithm_version=LOCAL_ALGORITHM_VERSION, diff --git a/computing/lulc/lulc_vector_local.py b/computing/lulc/lulc_vector_local.py index c6aff009..e9c7628c 100644 --- a/computing/lulc/lulc_vector_local.py +++ b/computing/lulc/lulc_vector_local.py @@ -143,6 +143,7 @@ def run_lulc_vector_local( misc={ "start_year": start_year, "end_year": end_year, + "is_generated_locally": True, }, algorithm=LOCAL_ALGORITHM, algorithm_version=LOCAL_ALGORITHM_VERSION, diff --git a/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py b/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py index 078bdbea..96d9ecc1 100644 --- a/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py +++ b/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py @@ -339,7 +339,7 @@ def run_lulc_on_slope_cluster_local( layer_name=layer_name, asset_id=asset_id, dataset_name="Terrain LULC", - misc={"start_year": start_year, "end_year": end_year}, + misc={"start_year": start_year, "end_year": end_year, "is_generated_locally": True}, ) if layer_id and push_to_geoserver: update_layer_sync_status( diff --git a/computing/misc/aquifer_vector_local.py b/computing/misc/aquifer_vector_local.py index 8e6d5350..c67a4281 100644 --- a/computing/misc/aquifer_vector_local.py +++ b/computing/misc/aquifer_vector_local.py @@ -368,6 +368,7 @@ def run_aquifer_vector_local( layer_name=layer_name, asset_id=asset_id, dataset_name="Aquifer", + misc={"is_generated_locally": True}, ) if layer_id and push_to_geoserver: update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) From b061698e37c5671a150f1ef0b97a04bca8da0ee4 Mon Sep 17 00:00:00 2001 From: aman verma Date: Mon, 18 May 2026 06:41:32 +0000 Subject: [PATCH 12/19] soil health local --- computing/soil_health/soil_health.py | 146 ++++++++++++++++++++ computing/soil_health/soil_health_helper.py | 79 +++++++++++ 2 files changed, 225 insertions(+) create mode 100644 computing/soil_health/soil_health.py create mode 100644 computing/soil_health/soil_health_helper.py diff --git a/computing/soil_health/soil_health.py b/computing/soil_health/soil_health.py new file mode 100644 index 00000000..c11cb120 --- /dev/null +++ b/computing/soil_health/soil_health.py @@ -0,0 +1,146 @@ +import os +import geopandas as gpd + +from computing.soil_health.soil_health_helper import nutrient_stats_for_geometries +from utilities.gee_utils import valid_gee_text +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_vector_path, + clip_raster_with_roi, + build_output_raster_path, + read_validated_vector_file, + validate_geometry, + write_vector_output, + push_local_vector_to_geoserver, + push_local_raster_to_geoserver, +) + +ROI_PATH = str(PROJECT_ROOT / "data/yalburga_mws.json") +LOCAL_OUTPUT_BASE_DIR = "data/soil_health" +GEOSERVER_STYLE = "" +GEOSERVER_WORKSPACE = "soil_health" +NUTRIENTS = ["N", "K", "P", "OC"] +NUTRIENT_PERCENTILES = (5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95) + + +def clip_soil_health_raster( + state=None, + district=None, + block=None, + asset_suffix=None, + roi=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=False, +): + + asset_suffix, roi_gdf = get_roi(asset_suffix, block, district, roi, state) + layer_name = f"{asset_suffix}_soil_health_raster" + geoserver_statuses = [] + for nutrient in NUTRIENTS: + SOIL_MAP_PATH = str( + PROJECT_ROOT + / f"data/soil_health/AEZ_3_{nutrient}_20260311.tif" # TODO Replace with pan India local path + ) + output_raster_path = build_output_raster_path( + layer_name=f"{layer_name}_{nutrient}", + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + state=state, + district=district, + block=block, + ) + + clip_raster_with_roi( + roi_gdf, SOIL_MAP_PATH, output_raster_path, raster_label="Raster" + ) + + if push_to_geoserver: + upload_res, style_res = push_local_raster_to_geoserver( + file_path=output_raster_path, + layer_name=f"{layer_name}_{nutrient}", + workspace=GEOSERVER_WORKSPACE, + ) + print(f"GeoServer upload response for {nutrient}: {upload_res}") + geoserver_statuses.append(True) + + return all(geoserver_statuses) if push_to_geoserver else True # TODO Add Stac specs + + +def get_roi(asset_suffix, block, district, roi, state): + if state and district and block: + asset_suffix = f"{valid_gee_text(str(district).lower())}_{valid_gee_text(str(block).lower())}" + # roi_gdf = load_precomputed_roi( + # state=state, + # district=district, + # block=block, + # precomputed_roi_dir=precomputed_roi_dir, + # ) + roi_gdf = gpd.read_file(ROI_PATH) + else: + if not roi or not asset_suffix: + raise ValueError( + "For non state/district/block runs, both `roi` and `asset_suffix` are required." + ) + + roi_gdf = read_validated_vector_file( + roi, + f"ROI file has no valid geometries: {roi}", + ) + roi_gdf = validate_geometry(roi_gdf) + return asset_suffix, roi_gdf + + +def vectorize_soil_health( + state=None, + district=None, + block=None, + asset_suffix=None, + roi=None, + percentiles=NUTRIENT_PERCENTILES, + push_to_geoserver=True, +): + + asset_suffix, roi_gdf = get_roi(asset_suffix, block, district, roi, state) + layer_name = f"{asset_suffix}_soil_health" + + for nutrient in NUTRIENTS: + # This produces one output feature per ROI geometry with Nitrogen summary columns. + raster_path = build_output_raster_path( + layer_name=f"{layer_name}_raster_{nutrient}", + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + state=state, + district=district, + block=block, + ) + + result_gdf = nutrient_stats_for_geometries( + roi_gdf=roi_gdf, + raster_path=raster_path, + percentiles=tuple(percentiles), + nutrient=nutrient, + ) + + output_path = build_output_vector_path( + layer_name=f"{layer_name}_vector_{nutrient}", + state=state, + district=district, + block=block, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + ) + write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved soil health vector: {output_path}") + + if push_to_geoserver: + geoserver_response = push_local_vector_to_geoserver( + path=os.path.splitext(output_path)[0], + layer_name=layer_name, + workspace=GEOSERVER_WORKSPACE, + file_type="gpkg", + ) + print(f"GeoServer response: {geoserver_response}") + + # TODO Add Stac specs diff --git a/computing/soil_health/soil_health_helper.py b/computing/soil_health/soil_health_helper.py new file mode 100644 index 00000000..8d96acb5 --- /dev/null +++ b/computing/soil_health/soil_health_helper.py @@ -0,0 +1,79 @@ +import numpy as np +import rasterio +from rasterio.mask import mask +from shapely.geometry import mapping + +from computing.local_compute_helper import ensure_file_exists + + +def nutrient_stats_for_geometries(roi_gdf, raster_path, percentiles, nutrient): + ensure_file_exists(raster_path, "Clipped soil health raster") + + with rasterio.open(raster_path) as src: + # Raster masking expects geometries in the raster CRS, so reproject a + # working copy and preserve the original ROI CRS for the output vector. + working_gdf = roi_gdf.copy() + if working_gdf.crs is None: + raise ValueError( + "ROI CRS is missing; cannot align with soil health raster." + ) + if src.crs and working_gdf.crs != src.crs: + working_gdf = working_gdf.to_crs(src.crs) + + nodata = src.nodata + rows = [] + total = len(working_gdf) + for index, row in enumerate(working_gdf.itertuples(index=False), start=1): + geom = row.geometry + if geom is None or geom.is_empty: + values = np.array([], dtype=np.float64) + else: + # Clip the raster to one ROI/watershed polygon. `filled=False` + # keeps rasterio's mask, which lets us drop pixels outside the geometry. + try: + clipped, _ = mask( + src, + [mapping(geom)], + crop=True, + filled=False, + ) + except ValueError: + clipped = None + + if clipped is None or clipped.size == 0: + values = np.array([], dtype=np.float64) + else: + data = clipped[0] + valid_mask = ~np.ma.getmaskarray(data) + values = np.asarray(data, dtype=np.float64) + + # Ignore pixels outside the polygon, nodata pixels, and any + # non-finite values before calculating nutrient statistics. + valid_mask &= np.isfinite(values) + if nodata is not None and not np.isnan(nodata): + valid_mask &= values != float(nodata) + values = values[valid_mask] + + stats = {f"{nutrient}_count": int(values.size)} + if values.size == 0: + # Use NULLs in the vector table when a polygon has no valid raster pixels. + stats[f"{nutrient}_mean"] = None + for percentile in percentiles: + stats[f"{nutrient}_p{percentile:02d}"] = None + else: + stats[f"{nutrient}_mean"] = float(np.mean(values)) + percentile_values = np.percentile(values, percentiles) + print(f"Percentile values: {percentile_values}") + for percentile, value in zip(percentiles, percentile_values): + print(f"Percentile: {percentile}, Value: {value}") + stats[f"{nutrient}_p{percentile:02d}"] = float(value) + rows.append(stats) + + if index % 200 == 0 or index == total: + print(f"Computed soil health stats for {index}/{total} geometries") + + result = roi_gdf.copy() + # Attach the computed columns back to the original ROI geometries for output. + for column in rows[0] if rows else []: + result[column] = [row[column] for row in rows] + return result From b1b06c082659ea7a5ed82dff27475a8b9527ffb3 Mon Sep 17 00:00:00 2001 From: aman verma Date: Tue, 19 May 2026 11:41:09 +0000 Subject: [PATCH 13/19] spei download input datasets --- computing/base_layer_setup.py | 20 +- .../drought/spei/download_chirps_local.py | 395 ++++++++++++++++++ .../drought/spei/export_ppet_single_state.py | 170 ++++++++ 3 files changed, 580 insertions(+), 5 deletions(-) create mode 100644 computing/drought/spei/download_chirps_local.py create mode 100644 computing/drought/spei/export_ppet_single_state.py diff --git a/computing/base_layer_setup.py b/computing/base_layer_setup.py index 081460ec..fffbedf0 100644 --- a/computing/base_layer_setup.py +++ b/computing/base_layer_setup.py @@ -2,6 +2,7 @@ import subprocess import requests +from pathlib import Path from utilities.constants import GEOSERVER_BASE from computing.config_loader import ( @@ -150,7 +151,9 @@ def ensure_microwatershed(): Fill in _GDRIVE_MICROWATERSHED_FILE_ID above once the Drive link is available. """ if MICROWATERSHED_PATH.exists(): - logger.info("Microwatershed file already exists at %s, skipping.", MICROWATERSHED_PATH) + logger.info( + "Microwatershed file already exists at %s, skipping.", MICROWATERSHED_PATH + ) return if not _GDRIVE_MICROWATERSHED_FILE_ID: @@ -162,7 +165,9 @@ def ensure_microwatershed(): return MICROWATERSHED_PATH.parent.mkdir(parents=True, exist_ok=True) - logger.info("Downloading Microwatershed_v2_with_details.geojson from Google Drive...") + logger.info( + "Downloading Microwatershed_v2_with_details.geojson from Google Drive..." + ) try: subprocess.run( ["gdown", _GDRIVE_MICROWATERSHED_FILE_ID, "-O", str(MICROWATERSHED_PATH)], @@ -184,18 +189,23 @@ def ensure_tehsil_watersheds(): Both source files (SOI tehsil + microwatershed) must exist first. """ if _is_dir_populated(TEHSIL_WATERSHEDS_DIR): - logger.info("Tehsil watershed files already present at %s, skipping.", TEHSIL_WATERSHEDS_DIR) + logger.info( + "Tehsil watershed files already present at %s, skipping.", + TEHSIL_WATERSHEDS_DIR, + ) return if not SOI_TEHSIL_PATH.exists(): logger.warning( - "Cannot generate tehsil watersheds: SOI tehsil file missing at %s.", SOI_TEHSIL_PATH + "Cannot generate tehsil watersheds: SOI tehsil file missing at %s.", + SOI_TEHSIL_PATH, ) return if not MICROWATERSHED_PATH.exists(): logger.warning( - "Cannot generate tehsil watersheds: microwatershed file missing at %s.", MICROWATERSHED_PATH + "Cannot generate tehsil watersheds: microwatershed file missing at %s.", + MICROWATERSHED_PATH, ) return diff --git a/computing/drought/spei/download_chirps_local.py b/computing/drought/spei/download_chirps_local.py new file mode 100644 index 00000000..e2bbbb3e --- /dev/null +++ b/computing/drought/spei/download_chirps_local.py @@ -0,0 +1,395 @@ +""" +Download drought inputs locally from Google Earth Engine. + +This script downloads pan-India or single-state GeoTIFFs locally for: + - UCSB-CHG/CHIRPS/DAILY precipitation + - MODIS/061/MOD16A2GF PET + +It downloads one small GeoTIFF per time step for each dataset. + +Examples: + main(aoi="india", datasets=["both"]) + main(aoi="india", datasets=["chirps", "modis_pet"], start_date="2004-01-01", end_date="2023-12-31") + main(aoi="state", state="Madhya Pradesh", datasets=["chirps"]) +""" + +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +import ee +import requests + +from utilities.gee_utils import ee_initialize + +CHIRPS_COLLECTION = "UCSB-CHG/CHIRPS/DAILY" +MODIS_PET_COLLECTION = "MODIS/061/MOD16A2GF" +DEFAULT_PROJECT = "ee-corestackdev" +DATASET_CHOICES = ("chirps", "modis_pet", "both") + + +def initialize_earth_engine(project: str) -> None: + # Uses the repo's shared Earth Engine initialization helper. + # If auth/project errors happen, debug utilities.gee_utils.ee_initialize first. + ee_initialize() + print("Earth Engine initialized.") + + +def get_aoi(aoi_type: str, state_name: str) -> ee.FeatureCollection: + # GAUL level1 contains Indian state boundaries. For pan-India, keep all + # Indian state features and use their combined geometry downstream. + # admin = ee.FeatureCollection("FAO/GAUL/2015/level1").filter( + # ee.Filter.eq("ADM0_NAME", "India") + # ) + admin = ee.FeatureCollection( + "projects/ext-datasets/assets/datasets/State_pan_india" + ) + + if aoi_type == "india": + admin = admin.filter(ee.Filter.neq("Name", "Andaman & Nicobar")).filter( + ee.Filter.neq("Name", "Lakshadweep") + ) + return admin.union() + return admin.filter(ee.Filter.eq("Name", state_name)) + + +def date_range( + start_date: ee.Date, end_date_exclusive: ee.Date, unit: str +) -> list[ee.Date]: + # Builds monthly/daily date anchors. end_date_exclusive is intentionally + # advanced by one day in build_dataset_image so user input is inclusive. + count = end_date_exclusive.difference(start_date, unit).round().getInfo() + return [start_date.advance(offset, unit) for offset in range(count)] + + +def collection_dates( + collection: ee.ImageCollection, start_date: ee.Date, end_date_exclusive: ee.Date +) -> list[ee.Date]: + # Native cadence should use the source image timestamps. This is important + # for MODIS PET, which is 8-day rather than daily. + millis = ( + collection.filterDate(start_date, end_date_exclusive) + .aggregate_array("system:time_start") + .getInfo() + ) + return [ee.Date(value) for value in millis] + + +def expand_datasets(selected: list[str]) -> list[str]: + print("Inside expand_datasets") + if isinstance(selected, str): + selected = [selected] + if "both" in selected: + return ["chirps", "modis_pet"] + return selected + + +def make_chirps_image( + chirps: ee.ImageCollection, + aoi: ee.FeatureCollection, + start_date: ee.Date, + frequency: str, +) -> tuple[ee.Image, str]: + # CHIRPS is daily precipitation. Monthly mode sums daily precipitation + # into one monthly image before local download. + if frequency in ("daily", "native"): + end_date = start_date.advance(1, "day") + label = start_date.format("YYYYMMdd").getInfo() + image = chirps.filterDate(start_date, end_date).first() + else: + end_date = start_date.advance(1, "month") + label = start_date.format("YYYYMM").getInfo() + image = chirps.filterDate(start_date, end_date).sum() + + image = ( + ee.Image(image) + .select("precipitation") + .rename("precipitation") + .clip(aoi) + .toFloat() + ) + return image, label + + +def make_modis_pet_image( + modis_pet: ee.ImageCollection, + aoi: ee.FeatureCollection, + start_date: ee.Date, + frequency: str, + target_projection: ee.Projection | None = None, +) -> tuple[ee.Image, str]: + # MOD16A2GF PET is not daily. The PET band has a 0.1 scale factor, applied + # here so downloaded rasters contain real PET values. + if frequency == "daily": + raise ValueError("MODIS PET is not daily. Use --frequency monthly or native.") + + if frequency == "native": + end_date = start_date.advance(8, "day") + label = start_date.format("YYYYMMdd").getInfo() + image = modis_pet.filterDate(start_date, end_date).first() + else: + end_date = start_date.advance(1, "month") + label = start_date.format("YYYYMM").getInfo() + image = modis_pet.filterDate(start_date, end_date).sum() + image = image.setDefaultProjection(modis_pet.first().projection()) + + image = ee.Image(image).multiply(0.1).rename("PET") + + if target_projection is not None: + # Match the original GEE P-PET script: + # PET.reduceResolution(mean).reproject(crs=P.projection()) + image = image.reduceResolution( + reducer=ee.Reducer.mean(), maxPixels=65536 + ).reproject(crs=target_projection) + + image = image.clip(aoi).toFloat() + return image, label + + +def download_image( + image: ee.Image, + aoi: ee.FeatureCollection, + output_path: Path, + scale: int, + crs: str | None = "EPSG:4326", + retries: int = 3, +) -> None: + # Earth Engine's direct download endpoint has a request-size limit, so this + # function downloads only one time step at a time. + params = { + "scale": scale, + "region": aoi.geometry(), + "format": "GEO_TIFF", + } + if crs: + params["crs"] = crs + + for attempt in range(1, retries + 1): + try: + # Generate a signed EE URL, then stream the GeoTIFF bytes to disk. + url = image.getDownloadURL(params) + with requests.get(url, stream=True, timeout=300) as response: + response.raise_for_status() + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("wb") as handle: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if chunk: + handle.write(chunk) + return + except Exception: + # Remove partial files so retries/reruns do not treat corrupt files + # as valid completed downloads. + if output_path.exists(): + output_path.unlink() + if attempt == retries: + raise + time.sleep(2 * attempt) + + +def validate_inputs(aoi: str, datasets: list[str] | str, frequency: str) -> None: + # Keep validation near main() so notebook calls fail early and clearly. + print("Inside validate_inputs") + if aoi not in ("india", "state"): + raise ValueError("aoi must be 'india' or 'state'.") + if frequency not in ("daily", "monthly", "native"): + raise ValueError("frequency must be 'daily', 'monthly', or 'native'.") + + selected = [datasets] if isinstance(datasets, str) else datasets + invalid_datasets = set(selected) - set(DATASET_CHOICES) + if invalid_datasets: + raise ValueError(f"Invalid dataset(s): {sorted(invalid_datasets)}") + + +def dataset_label(dataset: str) -> str: + # Output labels are used in file names and final raster band descriptions. + if dataset == "chirps": + return "CHIRPS" + if dataset == "modis_pet": + return "MODIS_PET" + raise ValueError(f"Unknown dataset: {dataset}") + + +def build_dataset_image( + aoi: str, + state: str, + dataset: str, + start_date: str, + end_date: str, + frequency: str, +) -> tuple[ee.ImageCollection, ee.FeatureCollection, str, list[tuple[ee.Date, str]]]: + # Prepares the collection, AOI, and date labels for exactly one dataset. + # CHIRPS and MODIS PET stay separate from this point through final output. + start_ee_date = ee.Date(start_date) + end_date_exclusive = ee.Date(end_date).advance(1, "day") + + region = get_aoi(aoi, state) + chirps = ee.ImageCollection(CHIRPS_COLLECTION).select("precipitation") + modis_pet = ee.ImageCollection(MODIS_PET_COLLECTION).select("PET") + + aoi_label = "India" if aoi == "india" else state + + if dataset == "chirps": + unit = "day" if frequency in ("daily", "native") else "month" + dates = date_range(start_ee_date, end_date_exclusive, unit) + collection = chirps + name = dataset_label(dataset) + else: + if frequency == "daily": + raise ValueError( + "MODIS PET is not daily. Use --frequency monthly or native." + ) + if frequency == "native": + dates = collection_dates(modis_pet, start_ee_date, end_date_exclusive) + else: + dates = date_range(start_ee_date, end_date_exclusive, "month") + collection = modis_pet + name = dataset_label(dataset) + + labeled_dates = [] + for date in dates: + # These labels become the downloaded GeoTIFF file names. + date_format = "YYYYMMdd" if frequency in ("daily", "native") else "YYYYMM" + label = date.format(date_format).getInfo() + labeled_dates.append((date, label)) + + print( + f"Preparing {len(labeled_dates)} {frequency} {name} image(s) " + f"for {aoi_label}" + ) + return collection, region, name, labeled_dates + + +def download_dataset_images( + aoi: str, + state: str, + dataset: str, + start_date: str, + end_date: str, + frequency: str, + output_dir: str, + scale: int, + crs: str | None, + sleep: float, + overwrite: bool, + max_workers: int, +) -> None: + # End-to-end local workflow for one dataset: + # download one small GeoTIFF per time step and keep those files on disk. + aoi_label = "India" if aoi == "india" else state + safe_aoi = aoi_label.replace(" ", "_") + name = dataset_label(dataset) + dataset_output_dir = Path(output_dir) / safe_aoi / frequency / dataset + + collection, region, _, labeled_dates = build_dataset_image( + aoi=aoi, + state=state, + dataset=dataset, + start_date=start_date, + end_date=end_date, + frequency=frequency, + ) + + download_jobs = [] + target_projection = None + if dataset == "modis_pet" and frequency == "monthly": + # Use CHIRPS monthly precipitation projection as the output grid for PET. + # This keeps local PET files aligned with CHIRPS and reproduces the + # original GEE reduceResolution(mean)->reproject(P.projection()) step. + first_date = labeled_dates[0][0] + first_chirps, _ = make_chirps_image( + ee.ImageCollection(CHIRPS_COLLECTION).select("precipitation"), + region, + first_date, + frequency, + ) + target_projection = first_chirps.projection() + + for index, (current_date, label) in enumerate(labeled_dates, start=1): + # Keeping one time step per request avoids the EE direct-download size + # error seen when trying to download a large multiband image directly. + band_name = f"{name}_{label}" + single_path = dataset_output_dir / f"{band_name}.tif" + + if single_path.exists() and not overwrite: + print(f"[{index}/{len(labeled_dates)}] exists: {single_path.name}") + else: + download_jobs.append((index, current_date, band_name, single_path)) + + if max_workers <= 1: + for index, current_date, band_name, single_path in download_jobs: + if dataset == "chirps": + image, _ = make_chirps_image(collection, region, current_date, frequency) + else: + image, _ = make_modis_pet_image( + collection, region, current_date, frequency, target_projection + ) + print(f"[{index}/{len(labeled_dates)}] downloading: {single_path.name}") + download_image(image.rename(band_name), region, single_path, scale, crs) + time.sleep(sleep) + else: + # Parallel downloads are the main speed-up. Keep max_workers modest + # because Earth Engine may throttle too many simultaneous URL requests. + def download_one(job: tuple[int, ee.Date, str, Path]) -> tuple[int, str]: + index, current_date, band_name, single_path = job + if dataset == "chirps": + image, _ = make_chirps_image(collection, region, current_date, frequency) + else: + image, _ = make_modis_pet_image( + collection, region, current_date, frequency, target_projection + ) + download_image(image.rename(band_name), region, single_path, scale, crs) + if sleep: + time.sleep(sleep) + return index, single_path.name + + print(f"Downloading {len(download_jobs)} file(s) with {max_workers} workers") + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [executor.submit(download_one, job) for job in download_jobs] + for future in as_completed(futures): + index, file_name = future.result() + print(f"[{index}/{len(labeled_dates)}] downloaded: {file_name}") + + print(f"Downloaded {len(labeled_dates)} {name} image(s) to {dataset_output_dir}") + + +def main( + aoi: str = "india", + datasets: list[str] | str | None = None, + start_date: str = "2004-01-01", + end_date: str = "2023-12-31", + frequency: str = "monthly", + state: str = "Madhya Pradesh", + project: str = DEFAULT_PROJECT, + output_dir: str = "data/drought_inputs", + sleep: float = 0.2, + max_workers: int = 4, + overwrite: bool = False, +) -> None: + # Public entry point for notebooks/scripts. datasets=["both"] downloads + # separate CHIRPS and MODIS PET time-step GeoTIFFs. + selected_datasets = datasets or ["both"] + validate_inputs(aoi, selected_datasets, frequency) + initialize_earth_engine(project) + + expanded_datasets = expand_datasets(selected_datasets) + for dataset in expanded_datasets: + scale = 5500 + crs = "EPSG:4326" + download_dataset_images( + aoi=aoi, + state=state, + dataset=dataset, + start_date=start_date, + end_date=end_date, + frequency=frequency, + output_dir=output_dir, + scale=scale, + crs=crs, + sleep=sleep, + overwrite=overwrite, + max_workers=max_workers, + ) + + print("Done.") diff --git a/computing/drought/spei/export_ppet_single_state.py b/computing/drought/spei/export_ppet_single_state.py new file mode 100644 index 00000000..c3c7f52a --- /dev/null +++ b/computing/drought/spei/export_ppet_single_state.py @@ -0,0 +1,170 @@ +# ============================================================================= +# SPEI Pipeline - Step 1 (Local P-PET) +# +# Uses GeoTIFFs downloaded by download_chirps_local.py instead of reading: +# ee.ImageCollection("UCSB-CHG/CHIRPS/DAILY") +# ee.ImageCollection("MODIS/061/MOD16A2GF").select("PET") +# +# Output: one local multiband GeoTIFF with one P-PET band per month. +# Band names follow the original script: y{year}_m{month}, e.g. y2015_m06. +# ============================================================================= + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import rasterio +from rasterio.enums import Resampling +from rasterio.warp import reproject + + +# --- CONFIG --- +state_name = "Madhya_Pradesh" +start_year = 2004 +end_year = 2023 + +input_root = Path("data/drought_inputs") +output_dir = Path("data/drought_inputs") / state_name / "monthly" / "ppet" +output_path = output_dir / f"P_PET_{state_name}_monthly_multiband.tif" +OUTPUT_NODATA = -9999.0 + + +def find_monthly_file(dataset_dir: Path, prefix: str, year: int, month: int) -> Path: + """Find a downloaded monthly GeoTIFF, allowing nested folders from old runs.""" + label = f"{year}{month:02d}" + matches = sorted(dataset_dir.rglob(f"{prefix}_{label}.tif")) + if not matches: + raise FileNotFoundError( + f"Missing {prefix}_{label}.tif under {dataset_dir}. " + "Run download_chirps_local.py first." + ) + return matches[0] + + +def read_masked_band(dataset: rasterio.DatasetReader) -> np.ma.MaskedArray: + """Read band 1 and mask nodata plus any nan/inf values.""" + data = dataset.read(1, masked=True).astype("float32") + data = np.ma.masked_invalid(data) + return np.ma.masked_where(np.abs(data) > 1.0e20, data) + + +def reproject_modis_to_chirps_grid( + modis_path: Path, + chirps_dataset: rasterio.DatasetReader, + log_metadata: bool = False, +) -> np.ma.MaskedArray: + """Reproject/resample MODIS PET onto the CHIRPS projection and pixel grid.""" + with rasterio.open(modis_path) as modis_src: + if log_metadata: + print( + " MODIS -> CHIRPS grid:", + f"modis_crs={modis_src.crs}", + f"chirps_crs={chirps_dataset.crs}", + f"modis_shape=({modis_src.height}, {modis_src.width})", + f"chirps_shape=({chirps_dataset.height}, {chirps_dataset.width})", + ) + + if ( + modis_src.crs == chirps_dataset.crs + and modis_src.transform == chirps_dataset.transform + and modis_src.width == chirps_dataset.width + and modis_src.height == chirps_dataset.height + ): + return read_masked_band(modis_src) + + pet_source = read_masked_band(modis_src) + pet_on_chirps_grid = np.full( + (chirps_dataset.height, chirps_dataset.width), + OUTPUT_NODATA, + dtype="float32", + ) + + # MODIS PET is finer (~500m) than CHIRPS (~5500m), so average is the + # right downsampling behavior for a monthly total/continuous variable. + reproject( + source=pet_source.filled(OUTPUT_NODATA), + destination=pet_on_chirps_grid, + src_transform=modis_src.transform, + src_crs=modis_src.crs, + dst_transform=chirps_dataset.transform, + dst_crs=chirps_dataset.crs, + dst_width=chirps_dataset.width, + dst_height=chirps_dataset.height, + src_nodata=OUTPUT_NODATA, + dst_nodata=OUTPUT_NODATA, + init_dest_nodata=True, + resampling=Resampling.average, + ) + return np.ma.masked_where( + (pet_on_chirps_grid == OUTPUT_NODATA) | ~np.isfinite(pet_on_chirps_grid), + pet_on_chirps_grid, + ) + + +def main( + state: str = state_name, + start: int = start_year, + end: int = end_year, + data_root: Path | str = input_root, + output: Path | str | None = output_path, +) -> Path: + data_root = Path(data_root) + chirps_dir = data_root / state / "monthly" / "chirps" + modis_dir = data_root / state / "monthly" / "modis_pet" + + output_file = Path(output) if output else data_root / state / "ppet" / ( + f"P_PET_{state}_monthly_multiband.tif" + ) + output_file.parent.mkdir(parents=True, exist_ok=True) + + months = [(year, month) for year in range(start, end + 1) for month in range(1, 13)] + first_chirps = find_monthly_file(chirps_dir, "CHIRPS", *months[0]) + + with rasterio.open(first_chirps) as template: + profile = template.profile.copy() + profile.update( + count=len(months), + dtype="float32", + nodata=OUTPUT_NODATA, + compress="lzw", + BIGTIFF="IF_SAFER", + ) + + with rasterio.open(output_file, "w", **profile) as dst: + for band_index, (year, month) in enumerate(months, start=1): + chirps_path = find_monthly_file(chirps_dir, "CHIRPS", year, month) + modis_path = find_monthly_file(modis_dir, "MODIS_PET", year, month) + + with rasterio.open(chirps_path) as chirps_src: + precipitation = read_masked_band(chirps_src) + + # MODIS PET downloaded by download_chirps_local.py already has + # the 0.1 scale factor applied, so do not multiply again here. + pet = reproject_modis_to_chirps_grid( + modis_path, chirps_src, log_metadata=(band_index == 1) + ) + + precipitation_data = precipitation.filled(np.nan).astype("float32") + pet_data = pet.filled(np.nan).astype("float32") + + valid_mask = np.isfinite(precipitation_data) & np.isfinite(pet_data) + ppet_data = np.full( + precipitation_data.shape, OUTPUT_NODATA, dtype="float32" + ) + with np.errstate(invalid="ignore", over="ignore"): + ppet_data[valid_mask] = ( + precipitation_data[valid_mask] - pet_data[valid_mask] + ) + + ppet = np.ma.masked_where( + (ppet_data == OUTPUT_NODATA) | ~np.isfinite(ppet_data), ppet_data + ) + band_name = f"y{year}_m{month:02d}" + dst.write(ppet.filled(OUTPUT_NODATA).astype("float32"), band_index) + dst.set_band_description(band_index, band_name) + invalid_count = int(np.ma.count_masked(ppet)) + print(f"Prepared: {band_name} nodata_pixels={invalid_count}") + + print(f"\nWrote {len(months)} P-PET band(s): {output_file}") + return output_file From 3648904d4946f15b76d855fb3e5d81d59904820b Mon Sep 17 00:00:00 2001 From: aman verma Date: Wed, 20 May 2026 16:22:12 +0000 Subject: [PATCH 14/19] spei refactoring --- computing/{drought/spei => spei/drought}/download_chirps_local.py | 0 .../{drought/spei => spei/drought}/export_ppet_single_state.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename computing/{drought/spei => spei/drought}/download_chirps_local.py (100%) rename computing/{drought/spei => spei/drought}/export_ppet_single_state.py (100%) diff --git a/computing/drought/spei/download_chirps_local.py b/computing/spei/drought/download_chirps_local.py similarity index 100% rename from computing/drought/spei/download_chirps_local.py rename to computing/spei/drought/download_chirps_local.py diff --git a/computing/drought/spei/export_ppet_single_state.py b/computing/spei/drought/export_ppet_single_state.py similarity index 100% rename from computing/drought/spei/export_ppet_single_state.py rename to computing/spei/drought/export_ppet_single_state.py From ba9d7e961899ab510e8a06977ac3dd0f28fb667b Mon Sep 17 00:00:00 2001 From: aman verma Date: Wed, 20 May 2026 16:22:57 +0000 Subject: [PATCH 15/19] drought spei R script --- computing/spei/__init__.py | 0 computing/spei/drought/__init__.py | 0 computing/spei/drought/drought_spei.R | 341 ++++++++++++++++++++++++++ computing/spei/drought/spei_runner.py | 37 +++ 4 files changed, 378 insertions(+) create mode 100644 computing/spei/__init__.py create mode 100644 computing/spei/drought/__init__.py create mode 100644 computing/spei/drought/drought_spei.R create mode 100644 computing/spei/drought/spei_runner.py diff --git a/computing/spei/__init__.py b/computing/spei/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/computing/spei/drought/__init__.py b/computing/spei/drought/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/computing/spei/drought/drought_spei.R b/computing/spei/drought/drought_spei.R new file mode 100644 index 00000000..61d0c15c --- /dev/null +++ b/computing/spei/drought/drought_spei.R @@ -0,0 +1,341 @@ +# ============================================================================= +# SPEI Pipeline — Step 2 (Single State) +# Read multiband P-PET GeoTIFF, compute SPEI-1/3/12 pixel-wise, +# write 3 multiband output GeoTIFFs with named bands. +# ============================================================================= + +# ============================================================================= +# INSTALL (Run once if needed) +# ============================================================================= +# install.packages(c("SPEI", "raster"), repos="https://cloud.r-project.org") + +# ============================================================================= +# LIBRARIES +# ============================================================================= +library(SPEI) +library(raster) + +# ============================================================================= +# MAIN FUNCTION +# ============================================================================= +run_spei_pipeline <- function( + state_safe, + input_file, + output_dir +) { + + paste("Starting R script") + + # ------------------------------------------------------------------------- + # Create output directory + # ------------------------------------------------------------------------- + if (!dir.exists(output_dir)) { + dir.create(output_dir, recursive = TRUE) + } + + # ------------------------------------------------------------------------- + # Resume check + # ------------------------------------------------------------------------- + out_check <- file.path( + output_dir, + paste0("SPEI12_", state_safe, ".tif") + ) + + if (file.exists(out_check)) { + stop( + paste( + "Already processed:", + state_safe, + "— delete output files to rerun." + ) + ) + } + + # ========================================================================= + # SPEI FUNCTION + # + # Input: + # x = 240-length vector of monthly P-PET values + # + # Output: + # 340-length vector: + # [1:240] SPEI-1 + # [241:320] SPEI-3 seasonal months only + # [321:340] SPEI-12 annual only + # ========================================================================= + spei_function <- function(x, ...) { + + tryCatch({ + + if (all(is.na(x))) { + return(rep(NA, 340)) + } + + pixel_ts <- ts( + x, + start = c(2004, 1), + frequency = 12 + ) + + spei1_all <- as.vector( + spei( + pixel_ts, + 1, + distribution = "log-Logistic", + na.rm = TRUE + )$fitted + ) + + spei3_all <- as.vector( + spei( + pixel_ts, + 3, + distribution = "log-Logistic", + na.rm = TRUE + )$fitted + ) + + spei12_all <- as.vector( + spei( + pixel_ts, + 12, + distribution = "log-Logistic", + na.rm = TRUE + )$fitted + ) + + if (length(spei1_all) != 240) { + stop("Incorrect output length.") + } + + # ----------------------------------------------------------------- + # SPEI-3: keep only Mar, Jun, Sep, Dec + # ----------------------------------------------------------------- + seasonal_idx <- which( + ((seq_along(spei3_all) - 1) %% 12 + 1) %in% + c(3, 6, 9, 12) + ) + + spei3_sel <- spei3_all[seasonal_idx] + + # ----------------------------------------------------------------- + # SPEI-12: keep only December + # ----------------------------------------------------------------- + annual_idx <- seq(12, 240, by = 12) + + spei12_sel <- spei12_all[annual_idx] + + return( + c( + spei1_all, + spei3_sel, + spei12_sel + ) + ) + + }, error = function(e) { + + return(rep(NA, 340)) + + }) + } + + # ========================================================================= + # LOAD INPUT + # ========================================================================= + cat(paste("Loading:", input_file, "\n")) + + p_pet_brick <- brick(input_file) + + cat( + paste( + "Loaded", + nlayers(p_pet_brick), + "bands (expected 240)\n" + ) + ) + + # ========================================================================= + # COMPUTE BLOCK BY BLOCK + # ========================================================================= + cat("Running SPEI computation...\n") + + temp_file <- file.path( + output_dir, + paste0(state_safe, "_temp.tif") + ) + + result_brick <- brick( + p_pet_brick, + nl = 340 + ) + + result_brick <- writeStart( + result_brick, + filename = temp_file, + overwrite = TRUE + ) + + bs <- blockSize(p_pet_brick) + + for (i in 1:bs$n) { + + v <- getValues( + p_pet_brick, + row = bs$row[i], + nrows = bs$nrows[i] + ) + + res <- t( + apply(v, 1, spei_function) + ) + + writeValues( + result_brick, + res, + bs$row[i] + ) + + cat( + paste( + "Chunk", + i, + "/", + bs$n, + "\n" + ) + ) + } + + result_brick <- writeStop(result_brick) + + cat("Computation complete.\n") + + # ========================================================================= + # GENERATE BAND NAMES + # ========================================================================= + spei1_names <- paste0( + "y", + rep(2004:2023, each = 12), + "_m", + sprintf("%02d", rep(1:12, 20)) + ) + + spei3_names <- paste0( + "y", + rep(2004:2023, each = 4), + "_m", + sprintf("%02d", rep(c(3, 6, 9, 12), 20)) + ) + + spei12_names <- paste0( + "y", + 2004:2023 + ) + + # ========================================================================= + # SPLIT OUTPUTS + # ========================================================================= + cat("Saving output files...\n") + + all_b <- brick(temp_file) + + spei1_brick <- all_b[[1:240]] + spei3_brick <- all_b[[241:320]] + spei12_brick <- all_b[[321:340]] + + names(spei1_brick) <- spei1_names + names(spei3_brick) <- spei3_names + names(spei12_brick) <- spei12_names + + # ========================================================================= + # WRITE OUTPUTS + # ========================================================================= + writeRaster( + spei1_brick, + file.path( + output_dir, + paste0("SPEI1_", state_safe, ".tif") + ), + format = "GTiff", + overwrite = TRUE, + NAflag = -9999 + ) + + writeRaster( + spei3_brick, + file.path( + output_dir, + paste0("SPEI3_", state_safe, ".tif") + ), + format = "GTiff", + overwrite = TRUE, + NAflag = -9999 + ) + + writeRaster( + spei12_brick, + file.path( + output_dir, + paste0("SPEI12_", state_safe, ".tif") + ), + format = "GTiff", + overwrite = TRUE, + NAflag = -9999 + ) + + # ========================================================================= + # CLEANUP + # ========================================================================= + file.remove(temp_file) + + # ========================================================================= + # DONE + # ========================================================================= + cat( + paste0( + "\n✅ Done. Output files saved to: ", + output_dir, + "\n" + ) + ) + + cat( + paste0( + " SPEI1_", + state_safe, + ".tif — ", + nlayers(spei1_brick), + " bands\n" + ) + ) + + cat( + paste0( + " SPEI3_", + state_safe, + ".tif — ", + nlayers(spei3_brick), + " bands\n" + ) + ) + + cat( + paste0( + " SPEI12_", + state_safe, + ".tif — ", + nlayers(spei12_brick), + " bands\n" + ) + ) +} + +# ============================================================================= +# FUNCTION CALL +# ============================================================================= +run_spei_pipeline( + state_safe = "Madhya_Pradesh", + input_file = "data/drought_inputs/Madhya_Pradesh/monthly/ppet/P_PET_Madhya_Pradesh_monthly_multiband.tif", + output_dir = "data/drought_inputs/Madhya_Pradesh/monthly/ppet" +) \ No newline at end of file diff --git a/computing/spei/drought/spei_runner.py b/computing/spei/drought/spei_runner.py new file mode 100644 index 00000000..0f528c4c --- /dev/null +++ b/computing/spei/drought/spei_runner.py @@ -0,0 +1,37 @@ +from pathlib import Path +import subprocess + + +BASE_DIR = Path(__file__).resolve().parent.parent + +R_SCRIPT = BASE_DIR / "drought" / "drought_spei.R" + + +def run_spei_pipeline(state_safe=None, input_file=None, output_dir=None): + + command = [ + "Rscript", + str(R_SCRIPT), + state_safe, + input_file, + output_dir, + ] + + print("COMMAND:", command) + + result = subprocess.run( + command, + capture_output=True, + text=True, + ) + + print("RETURN CODE:", result.returncode) + print("STDOUT:\n", result.stdout) + print("STDERR:\n", result.stderr) + + if result.returncode != 0: + raise Exception( + f" R Script Failed COMMAND:{command} RETURN CODE:{result.returncode} STDOUT:{result.stdout} STDERR:{result.stderr}" + ) + + return result.stdout From c40c96470d50c351d59471829fc13d6515e5e15f Mon Sep 17 00:00:00 2001 From: ramank1137 Date: Mon, 1 Jun 2026 15:16:38 +0530 Subject: [PATCH 16/19] adding code for runnoff computing to local --- .gitignore | 4 + computing/api.py | 43 ++ computing/config.yaml | 4 +- computing/config_loader.py | 2 +- computing/hydrology_gpu/__init__.py | 1 + .../hydrology_gpu/algorithms/__init__.py | 49 ++ computing/hydrology_gpu/algorithms/runoff.py | 552 ++++++++++++++++++ .../algorithms/tiled_timeseries.py | 260 +++++++++ .../hydrology_gpu/algorithms/timeseries.py | 119 ++++ computing/hydrology_gpu/config/__init__.py | 38 ++ computing/hydrology_gpu/config/config.toml | 26 + computing/hydrology_gpu/downloads/__init__.py | 147 +++++ computing/hydrology_gpu/downloads/dem.py | 95 +++ computing/hydrology_gpu/downloads/lulc.py | 32 + computing/hydrology_gpu/downloads/rainfall.py | 416 +++++++++++++ computing/hydrology_gpu/downloads/soil.py | 18 + computing/hydrology_gpu/lulc_mapping.py | 120 ++++ computing/hydrology_gpu/runoff.py | 299 ++++++++++ computing/hydrology_gpu/utils.py | 399 +++++++++++++ computing/hydrology_gpu/watershed_boundary.py | 447 ++++++++++++++ computing/mws/runoff_gpu.py | 284 +++++++++ computing/tasks.py | 3 + computing/urls.py | 1 + installation/environment.yml | 17 +- 24 files changed, 3369 insertions(+), 7 deletions(-) create mode 100644 computing/hydrology_gpu/__init__.py create mode 100644 computing/hydrology_gpu/algorithms/__init__.py create mode 100644 computing/hydrology_gpu/algorithms/runoff.py create mode 100644 computing/hydrology_gpu/algorithms/tiled_timeseries.py create mode 100644 computing/hydrology_gpu/algorithms/timeseries.py create mode 100644 computing/hydrology_gpu/config/__init__.py create mode 100644 computing/hydrology_gpu/config/config.toml create mode 100644 computing/hydrology_gpu/downloads/__init__.py create mode 100644 computing/hydrology_gpu/downloads/dem.py create mode 100644 computing/hydrology_gpu/downloads/lulc.py create mode 100644 computing/hydrology_gpu/downloads/rainfall.py create mode 100644 computing/hydrology_gpu/downloads/soil.py create mode 100644 computing/hydrology_gpu/lulc_mapping.py create mode 100644 computing/hydrology_gpu/runoff.py create mode 100644 computing/hydrology_gpu/utils.py create mode 100644 computing/hydrology_gpu/watershed_boundary.py create mode 100644 computing/mws/runoff_gpu.py create mode 100644 computing/tasks.py diff --git a/.gitignore b/.gitignore index 65411952..c28ebc76 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ venv/ .DS_Store .cursor/ +#IDE +.codex +.vscode/* + # Django *.log local_settings.py diff --git a/computing/api.py b/computing/api.py index 18f47d0f..22d9faca 100644 --- a/computing/api.py +++ b/computing/api.py @@ -31,6 +31,7 @@ from .misc.restoration_opportunity import generate_restoration_opportunity from .misc.stream_order import generate_stream_order from .mws.generate_hydrology import generate_hydrology +from .mws.runoff_gpu import generate_runoff_gpu as generate_runoff_gpu_task from .utils import ( Geoserver, kml_to_shp, @@ -333,6 +334,48 @@ def generate_annual_hydrology(request): return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) +@api_view(["POST"]) +@schema(None) +def generate_runoff_gpu(request): + print("Inside generate_runoff_gpu") + try: + compute = _get_compute_mode(request) + if compute != "local": + raise ValueError("runoff_gpu currently supports compute='local' only") + + tehsil = request.data.get("tehsil") or request.data.get("block") + pan_india = request.data.get( + "pan_india", + request.data.get("pan-india", request.data.get("panIndia", False)), + ) + task = generate_runoff_gpu_task.apply_async( + kwargs={ + "state": request.data.get("state"), + "district": request.data.get("district"), + "tehsil": tehsil, + "pan_india": pan_india, + "start_date": request.data.get("start_date"), + "end_date": request.data.get("end_date"), + "start_year": request.data.get("start_year"), + "end_year": request.data.get("end_year"), + }, + queue="nrm", + ) + return Response( + { + "Success": "runoff_gpu task initiated", + "task_id": task.id, + }, + status=status.HTTP_200_OK, + ) + except ValueError as e: + print("Invalid request in generate_runoff_gpu api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + print("Exception in generate_runoff_gpu api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @api_view(["POST"]) @schema(None) def lulc_for_tehsil(request): diff --git a/computing/config.yaml b/computing/config.yaml index dadce03e..46109991 100644 --- a/computing/config.yaml +++ b/computing/config.yaml @@ -27,9 +27,9 @@ base_layers: source: google_drive gdrive_id: 1CPV03S47s0asEJqdAozbNOT1lkgr0YkG - - path: data/base_layers/terrain_raster_fabdam_pan_india.tif + - path: data/base_layers/slope/slope_india_30m_merged.tif source: manual - note: Pan-India terrain raster used for slope/terrain classification + note: Pan-India slope raster used for slope/terrain classification - path: data/base_layers/AEZs/Agro_Ecological_Regions.shp source: manual diff --git a/computing/config_loader.py b/computing/config_loader.py index 2b3eadce..d1c8ec10 100644 --- a/computing/config_loader.py +++ b/computing/config_loader.py @@ -43,7 +43,7 @@ def _output_entry(module: str, index: int = 0) -> dict: ).parent TERRAIN_RASTER_PATH: Path = PROJECT_ROOT / _find_input( - "data/base_layers/terrain_raster_fabdam_pan_india.tif" + "data/base_layers/slope/slope_india_30m_merged.tif" )["path"] AEZ_VECTOR_PATH: Path = PROJECT_ROOT / _find_input( diff --git a/computing/hydrology_gpu/__init__.py b/computing/hydrology_gpu/__init__.py new file mode 100644 index 00000000..2f6da047 --- /dev/null +++ b/computing/hydrology_gpu/__init__.py @@ -0,0 +1 @@ +"""Local GPU runoff pipeline package.""" diff --git a/computing/hydrology_gpu/algorithms/__init__.py b/computing/hydrology_gpu/algorithms/__init__.py new file mode 100644 index 00000000..38ae2d5b --- /dev/null +++ b/computing/hydrology_gpu/algorithms/__init__.py @@ -0,0 +1,49 @@ +from typing import Dict +from time import perf_counter +from .. import config as cfg +from .. import utils + +from ..utils import GeoTIFFHandler + + +def format_elapsed(seconds): + if seconds < 60: + return f"{seconds:.2f}s" + minutes, seconds = divmod(seconds, 60) + if minutes < 60: + return f"{int(minutes)}m {seconds:.2f}s" + hours, minutes = divmod(minutes, 60) + return f"{int(hours)}h {int(minutes)}m {seconds:.2f}s" + + +class GenericAlgorithm: + def __init__(self) -> None: + tif_handler = utils.tif_handler + self.tif_handler = tif_handler + self.logger = tif_handler.logger + + def load_inputs(self): + pass + + def main(self): + pass + + def save_outputs(self): + pass + + def run_timed(self, name, fn): + start_time = perf_counter() + self.logger.info("Starting %s", name) + try: + result = fn() + except Exception: + self.logger.exception("Failed %s after %s", name, format_elapsed(perf_counter() - start_time)) + raise + else: + self.logger.info("Finished %s in %s", name, format_elapsed(perf_counter() - start_time)) + return result + + def run(self): + self.run_timed("loading inputs", self.load_inputs) + self.run_timed("main algorithm", self.main) + self.run_timed("saving outputs", self.save_outputs) diff --git a/computing/hydrology_gpu/algorithms/runoff.py b/computing/hydrology_gpu/algorithms/runoff.py new file mode 100644 index 00000000..8b3784bd --- /dev/null +++ b/computing/hydrology_gpu/algorithms/runoff.py @@ -0,0 +1,552 @@ +import pathlib +import warnings +import os +from natsort import natsorted +from tqdm import tqdm +from . import GenericAlgorithm, GeoTIFFHandler +import cupy as cp +import numpy as np +from ..downloads import rainfall +from .. import config as cfg +from ..lulc_mapping import lulc_cache_key_for_timestamp, map_lulc_to_dynamic_world + +class Runoff(GenericAlgorithm): + """ + This one saves outputs in main function. It is part of a generator. It is consumed by timeseries.py + """ + def load_inputs(self): + self.rainfall_iter = rainfall.Load_from_database() + + def load_sr_inputs(self): + soil = cp.asarray(self.tif_handler.load_with_padding(cfg.SOIL_PATH)) + raw_lulc = cp.asarray(self.tif_handler.load_with_padding(cfg.LULC_PATH)) + slope = cp.asarray(self.tif_handler.load_with_padding(cfg.DEMFILE_PATH)) + return soil, raw_lulc, slope + + def sr_cache_key(self, timestamp): + return lulc_cache_key_for_timestamp( + getattr(cfg, "LULC_SOURCE", "dynamicworld"), + timestamp, + ) + + def compute_sr_for_timestamp(self, soil, raw_lulc, slope, timestamp): + source = getattr(cfg, "LULC_SOURCE", "dynamicworld") + sr_key = self.sr_cache_key(timestamp) + self.logger.info("Preparing SR for LULC source=%s key=%s", source, sr_key) + mapped_lulc = map_lulc_to_dynamic_world(raw_lulc, source, timestamp) + try: + return self.compute_sr_and_CNs(soil, mapped_lulc, slope) + finally: + if mapped_lulc is not raw_lulc: + del mapped_lulc + + def main(self): + # pathlib.Path(cfg.RUNOFFS_FOLDER).mkdir(parents=True, exist_ok=True) + + images = [] + P5_sum = None + previous_Runoff = None + soil, raw_lulc, slope = self.load_sr_inputs() + current_sr_key = None + sr1 = sr2 = sr3 = None + + for index, file in enumerate(self.rainfall_iter.main()): + # self.logger.info(f"Processing file {index}") + img = self.tif_handler.load_with_padding_inner(file['crs'], file['data'], file['bounds']) + + np.nan_to_num(img, copy=False) + + images.append(img) + + if index == 4: + P5_sum = cp.sum(cp.stack([cp.asarray(i) for i in images[:5]]), axis=0) + # self.logger.info(f"4. Initial sum creation") + elif index >= 5: + new_img = cp.asarray(images[-1]) + # assert check_physical_range(new_img, "new_img", min_val=0.0) + old_img = cp.asarray(images[0]) + # assert check_physical_range(old_img, "old_img", min_val=0.0) + P5_sum = P5_sum - old_img + new_img + old_img = cp.asarray(images[-3]) + # assert check_physical_range(old_img, "old_img (for P_sum)", min_val=0.0) + # self.logger.info(f"4. Updated sums") + del old_img, new_img + old_img = images.pop(0) + del old_img + # self.logger.info(f"5. Pop and delete oldest image") + + if index >= 4: + # if previous_Runoff is not None: + # P_sum += previous_Runoff + # P5_sum += previous_Runoff + # self.logger.info("6. Add previous runoff") + + P_sum = cp.asarray(images[-1]) + sr_key = self.sr_cache_key(file['timestamp']) + if sr_key != current_sr_key: + if sr1 is not None: + del sr1, sr2, sr3 + sr1, sr2, sr3 = self.compute_sr_for_timestamp( + soil, + raw_lulc, + slope, + file['timestamp'], + ) + current_sr_key = sr_key + self.logger.info("Loaded SR for LULC key %s", sr_key) + + # check_numerical_stability(P_sum, "P_sum") + # check_numerical_stability(P5_sum, "P5_sum") + # assert check_physical_range(P_sum, "P_sum", min_val=0.0) + # assert check_physical_range(P5_sum, "P5_sum", min_val=0.0) + + m1_cp = LegacyCodes.compute_M(sr1, P5_sum) + m2_cp = LegacyCodes.compute_M(sr2, P5_sum) + m3_cp = LegacyCodes.compute_M(sr3, P5_sum) + # self.logger.info("7. Compute M1,M2,M3") + + # cp.cuda.set_allocator(cp.cuda.MemoryPool().malloc) + # with cp.cuda.memory_hooks.DebugPrintHook(): + R = LegacyCodes.calculate_runoff_cupy(P_sum, P5_sum, m1_cp, m2_cp, m3_cp, sr1, sr2, sr3) + del m1_cp, m2_cp, m3_cp + # self.logger.info("8. Calculate runoff") + + # previous_Runoff = LegacyCodes.transfer_flow(destination_index, R) + + # self.test_raster(previous_Runoff, index, file) + + # positive_inf_check = cp.isposinf(cp.asarray(previous_Runoff)) + # negative_inf_check = cp.isneginf(cp.asarray(previous_Runoff)) + # nan_check= cp.isnan(cp.asarray(previous_Runoff)) + # if cp.sum(positive_inf_check) > 0 or cp.sum(negative_inf_check) > 0: + # logger.warning(f"Infinity values found in the transferred runoff data at index {index} and rainfall file {file}. They will be replaced with NaN.") + # elif cp.sum(nan_check) > 0: + # logger.warning(f"NaN values found in the transferred runoff data at index {index} and rainfall file {file}. They will be preserved as NaN.") + + # print("test") + + # self.logger.info("9. Transfer flow") + + # self.tif_handler.save_tiff(cp.asnumpy(R), os.path.join(cfg.RUNOFFS_FOLDER, f'runoff_simulation_{index}.tif')) + # self.logger.info("10. write runoff simulation result") + # runoff_rasters.append(R.get()) + self.tif_handler.save_geozarr_time(R.get(), file['timestamp'], cfg.RUNOFFS_FOLDER, "runoff") + yield (img, R, file['timestamp']) + + else: + yield (img, None, file['timestamp']) + + if sr1 is not None: + del sr1, sr2, sr3 + del soil, raw_lulc, slope + self.logger.info("Done runoff sim") + # return runoff_rasters + + def compute_sr_and_CNs(self, soil, lulc, slope): + """ + Returns sr1, sr2 and sr3 cupy arrays. + """ + + # check_numerical_stability(soil, "soil") + # check_numerical_stability(lulc, "lulc") + # check_numerical_stability(slope, "slope") + # check_physical_range(soil, "soil", min_val=0, max_val=4) + # check_physical_range(lulc, "lulc", min_val=0, max_val=7) + # check_physical_range(slope, "slope", min_val=0.0) + + self.logger.info("Calculating SR ...") + + CN2 = LegacyCodes.compute_cn2(soil, lulc) + CN1 = LegacyCodes.compute_cn1(CN2) + CN3 = LegacyCodes.compute_cn3(CN2) + + # check_numerical_stability(CN2, "CN2") + # check_numerical_stability(CN1, "CN1") + # check_numerical_stability(CN3, "CN3") + + p1 = LegacyCodes.compute_part1(CN3, CN2) + p2 = LegacyCodes.compute_part2(slope) + + CN2a = LegacyCodes.compute_CN2a(p1, p2, CN2) + CN1a = LegacyCodes.compute_CN1a(CN2a) + CN3a = LegacyCodes.compute_CN3a(CN2a) + + # check_numerical_stability(CN2a, "CN2a") + # check_numerical_stability(CN1a, "CN1a") + # check_numerical_stability(CN3a, "CN3a") + # assert check_physical_range(CN2a, "CN2a", min_val=0.0, max_val=100.0) + # assert check_physical_range(CN1a, "CN1a", min_val=0.0, max_val=100.0) + # assert check_physical_range(CN3a, "CN3a", min_val=0.0, max_val=100.0) + + sr1 = LegacyCodes.compute_sr(CN1a) + sr2 = LegacyCodes.compute_sr(CN2a) + sr3 = LegacyCodes.compute_sr(CN3a) + + # check_numerical_stability(sr1, "sr1") + # check_numerical_stability(sr2, "sr2") + # check_numerical_stability(sr3, "sr3") + # assert check_physical_range(sr1, "sr1", min_val=0.0) + # assert check_physical_range(sr2, "sr2", min_val=0.0) + # assert check_physical_range(sr3, "sr3", min_val=0.0) + + self.logger.info("Just completed calculating SR") + + return sr1, sr2, sr3 + +def static_all_methods(cls): + for name, attr in cls.__dict__.items(): + if callable(attr): + setattr(cls, name, staticmethod(attr)) + return cls + +@static_all_methods +class LegacyCodes: + + def compute_cn2(soil: cp.ndarray, lulc: cp.ndarray) -> cp.ndarray: + """ + Compute CN2 values from soil and lulc matrices using CuPy. + + Parameters: + soil (cp.ndarray): Soil type matrix (values 0–4). + lulc (cp.ndarray): LULC class matrix (values 0–7). + + Returns: + cp.ndarray: CN2 values. + """ + # Define lookup table [soil_type][lulc_class] + LUT = cp.array([ + [ 0, 0, 0, 0, 0, 0, 0, 0], # soil 0 → CN2 = 0 (as fallback) + [ 0, 30, 39, 0, 64, 39, 82, 49], # soil 1 + [ 0, 55, 61, 0, 75, 61, 88, 69], # soil 2 + [ 0, 70, 74, 0, 82, 74, 91, 79], # soil 3 + [ 0, 77, 80, 0, 85, 80, 93, 84], # soil 4 + ], dtype=cp.int32) + + # Ensure valid bounds before indexing + soil = cp.clip(soil.astype(cp.int32), 0, 4) + lulc = cp.clip(lulc.astype(cp.int32), 0, 7) + + # Apply lookup + CN2 = LUT[soil, lulc] + + del soil, lulc, LUT + + return CN2 + + @staticmethod # this should happen for other funcs also, idk why isn't happening + def compute_cn1(CN2: cp.ndarray) -> cp.ndarray: + """ + Compute CN1 from CN2 using the formula: CN1 = -75 * CN2 / (CN2 - 175) + + Parameters: + CN2 (cp.ndarray): Curve Number 2 matrix (usually int or float) + + Returns: + cp.ndarray: CN1 values as float32 + """ + CN2 = CN2.astype(cp.float32) + denom = CN2 - 175 + # Prevent division by zero + denom = cp.where(denom == 0, cp.finfo(cp.float32).eps, denom) + CN1 = (-75 * CN2) / denom + + del denom + + return CN1 + + def compute_cn3(CN2: cp.ndarray) -> cp.ndarray: + """ + Compute CN3 from CN2 using the formula: + CN3 = CN2 * (e ** (0.00673 * (100 - CN2))) + + Parameters: + CN2 (cp.ndarray): CuPy array of Curve Number 2 values. + + Returns: + cp.ndarray: CuPy array of CN3 values. + """ + CN2 = CN2.astype(cp.float32) + exponent = 0.00673 * (100.0 - CN2) + CN3 = CN2 * cp.exp(exponent) + + del exponent, CN2 + + return CN3 + + def compute_part1(CN3: cp.ndarray, CN2: cp.ndarray) -> cp.ndarray: + CN3 = CN3.astype(cp.float32) + CN2 = CN2.astype(cp.float32) + p1 = (CN3 - CN2) / 3.0 + + del CN3, CN2 + + return p1 + + def compute_part2(slope: cp.ndarray) -> cp.ndarray: + slope = slope.astype(cp.float32) + p2 = 1.0 - 2.0 * cp.exp(-13.86 * slope) + + del slope + + return p2 + + def compute_CN2a(part1: cp.ndarray, part2: cp.ndarray, CN2: cp.ndarray) -> cp.ndarray: + # Ensure type consistency + part1 = part1.astype(cp.float32) + part2 = part2.astype(cp.float32) + CN2 = CN2.astype(cp.float32) + + CN2a = part1 * part2 + CN2 + + del part1, part2, CN2 + + return CN2a + + def compute_CN1a(CN2a: cp.ndarray) -> cp.ndarray: + CN2a = CN2a.astype(cp.float32) + CN1a = 4.2 * CN2a / (10 - 0.058 * CN2a) + + del CN2a + + return CN1a + + def compute_CN3a(CN2a: cp.ndarray) -> cp.ndarray: + CN2a = CN2a.astype(cp.float32) + CN3a = 23 * CN2a / (10 + 0.13 * CN2a) + + del CN2a + + return CN3a + + def compute_sr(CN: cp.ndarray) -> cp.ndarray: + CN = CN.astype(cp.float32) + + # Mask where CN is invalid (e.g., 0 or very small) + mask = CN <= 10 + CN = cp.where(mask, 100.0, CN) # default value or np.nan + + # Clip CN to range 30–100 + CN = cp.clip(CN, 30.0, 100.0) + + sr = (25400.0 / CN) - 254.0 + + # # Optional: mask output back where CN was invalid + # sr = cp.where(mask, -254.0, sr) + + del CN, mask + + return sr + + + + + def compute_M(sr, p): + """ + Compute M2 using CuPy, ensuring that if either sr or p is NaN, the result is NaN. + """ + nan_mask = cp.isnan(sr) | cp.isnan(p) + sqrt_term = cp.sqrt(cp.maximum(sr**2 + 4 * p * sr, 0.0)) + M2 = 0.5 * (-sr + sqrt_term) + M2[nan_mask] = cp.nan # Preserve NaN values + return M2 + + def compute_M_alt(sr, p): + # Ensure float type, potentially float64 for precision + sr = sr.astype(cp.float64) + p = p.astype(cp.float64) + + # Preserve input NaNs + nan_mask_input = cp.isnan(sr) | cp.isnan(p) + + # Calculate term inside sqrt + term = sr**2 + 4 * p * sr + + # Allow sqrt to produce NaN for negative inputs (and suppress warning) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=RuntimeWarning) # Ignore sqrt domain error warning + sqrt_term = cp.sqrt(term) # This will be NaN where term < 0 + + M_result = 0.5 * (-sr + sqrt_term) + + # Ensure input NaNs propagate, and sqrt NaNs are kept + M_result[nan_mask_input | cp.isnan(sqrt_term)] = cp.nan + + del sr, p, nan_mask_input, term, sqrt_term + + return M_result + + def sum_tif_images(images, start, end): + """ + Sum multiple TIFF images using CuPy on the GPU. + """ + return cp.sum(images[start:end], axis=0) + + def calculate_p_and_p5(file_paths): + """ + Load images, convert to CuPy, and compute total precipitation sums. + """ + images = [cp.asarray(load_tif_image(fp), dtype=cp.float32) for fp in file_paths] + total_sum = cp.sum(images, axis=0) + mid_sum = cp.sum(images[-2:], axis=0) if len(images) >= 2 else total_sum + return total_sum, mid_sum + + def calculate_runoff(P, P5, M1, M2, M3, sr1, sr2, sr3): + """ + Compute runoff using CuPy. + """ + nan_mask = cp.isnan(P) | cp.isnan(P5) | cp.isnan(M1) | cp.isnan(M2) | cp.isnan(M3) | cp.isnan(sr1) | cp.isnan(sr2) | cp.isnan(sr3) + runoff = cp.zeros_like(sr1) + mask1 = (~nan_mask) & (P >= 0.2 * sr1) & (P5 >= 0) & (P5 <= 35) + mask2 = (~nan_mask) & (P >= 0.2 * sr2) & (P5 > 35) & (P5 <= 52.5) + mask3 = (~nan_mask) & (P >= 0.2 * sr3) & (P5 > 52.5) + + runoff[mask1] = ((P[mask1] - 0.2 * sr1[mask1]) * (P[mask1] - 0.2 * sr1[mask1] + M1[mask1])) / (P[mask1] + 0.2 * sr1[mask1] + sr1[mask1] + M1[mask1]) + runoff[mask2] = ((P[mask2] - 0.2 * sr2[mask2]) * (P[mask2] - 0.2 * sr2[mask2] + M2[mask2])) / (P[mask2] + 0.2 * sr2[mask2] + sr2[mask2] + M2[mask2]) + runoff[mask3] = ((P[mask3] - 0.2 * sr3[mask3]) * (P[mask3] - 0.2 * sr3[mask3] + M3[mask3])) / (P[mask3] + 0.2 * sr3[mask3] + sr3[mask3] + M3[mask3]) + + runoff[nan_mask] = cp.nan # Restore NaNs + return runoff + + @staticmethod + def calculate_runoff_cupy(P, P5, m1, m2, m3, sr1, sr2, sr3): + """ + Calculates runoff using a CuPy implementation mirroring a GEE expression. + + Follows the logic: + Q = f(P, P5, sr, m) based on AMC I, II, III where P5 determines AMC. + Uses derived m1, m2, m3 and potential max retention sr1, sr2, sr3. + Ensures runoff >= 0 and handles NaN inputs (NaN in any input -> NaN output). + + Args: + P (cp.ndarray): Precipitation matrix. + P5 (cp.ndarray): 5-day antecedent precipitation matrix. + m1 (cp.ndarray): Derived moisture parameter for AMC I. + m2 (cp.ndarray): Derived moisture parameter for AMC II. + m3 (cp.ndarray): Derived moisture parameter for AMC III. + sr1 (cp.ndarray): Potential maximum retention for AMC I (S derived from CN1). + sr2 (cp.ndarray): Potential maximum retention for AMC II (S derived from CN2). + sr3 (cp.ndarray): Potential maximum retention for AMC III (S derived from CN3). + + Returns: + cp.ndarray: Calculated runoff matrix, with NaN where any input was NaN. + """ + # Optional: Check if inputs are indeed CuPy arrays (if function might receive others) + # P = cp.asarray(P) # etc. for all inputs + + # --- 0. Input Validation (Optional but Recommended) --- + if not P.shape == P5.shape == m1.shape == m2.shape == m3.shape == \ + sr1.shape == sr2.shape == sr3.shape: + raise ValueError("All input CuPy arrays must have the same shape.") + + # --- 1. Handle NaN Inputs: Create combined mask --- + # If any input pixel is NaN, the output for that pixel will be NaN. + + # --- 2. Calculate Intermediate Terms (Initial Abstraction) --- + Ia1 = 0.2 * sr1 + Ia2 = 0.2 * sr2 + Ia3 = 0.2 * sr3 + + # --- 3. Calculate Potential Runoff Values (Q1, Q2, Q3) --- + # Suppress potential division-by-zero or invalid value warnings as we handle them + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=RuntimeWarning) + + # # Calculate Denominators for the runoff formula + den = P + Ia1 + sr1 + m1 + num = (P - Ia1) * (P - Ia1 + m1) + Q1 = cp.where(den != 0, num / den, 0.0).astype(cp.float64) + + den = P + Ia2 + sr2 + m2 + num = (P - Ia2) * (P - Ia2 + m2) + Q2 = cp.where(den != 0, num / den, 0.0).astype(cp.float64) + + den = P + Ia3 + sr3 + m3 + num = (P - Ia3) * (P - Ia3 + m3) + Q3 = cp.where(den != 0, num / den, 0.0).astype(cp.float64) + + del den, num + + # --- 4. Define Conditions using Boolean Masks --- + # Basic precipitation conditions: P must be >= Initial Abstraction (Ia) + condP = (P >= Ia1) + condAMC = (P5 >= 0) & (P5 <= 35) + condQ_pos = (Q1 >= 0) + cond1_full = condP & condAMC & condQ_pos + + condP = (P >= Ia2) + condAMC = (P5 >= 0) & (P5 > 35) # Corresponds to the second check in GEE ternary + condQ_pos = (Q2 >= 0) + cond2_full = condP & condAMC & condQ_pos + + condP = (P >= Ia3) + condAMC = (P5 >= 0) & (P5 > 52.5) # Corresponds to the third check in GEE ternary + condQ_pos = (Q3 >= 0) + cond3_full = condP & condAMC & condQ_pos + + del condP, condAMC, condQ_pos + del Ia1, Ia2, Ia3 + + # Antecedent Moisture Conditions based on P5 thresholds from GEE expression + # Note: P5>=0 check is included in the GEE expression, so we replicate it. + + # Runoff non-negativity conditions (Q must be >= 0) from GEE expression + + # Combine all conditions for each case + # These directly represent the full condition before the '?' in the GEE expression + + # --- 5. Apply Conditions using Nested cp.where (Mirrors GEE Ternary Logic) --- + # This structure directly implements: cond1 ? Q1 : (cond2 ? Q2 : (cond3 ? Q3 : 0)) + final_runoff = cp.where(cond1_full, Q1, # If Cond1 is true, use Q1 + cp.where(cond2_full, Q2, # Else, if Cond2 is true, use Q2 + cp.where(cond3_full, Q3, # Else, if Cond3 is true, use Q3 + 0.0))) # Else (all conditions false), use 0.0 + + del cond1_full, cond2_full, cond3_full + + # --- 6. Apply NaN Mask --- + # Ensure any pixel that had NaN in any input results in NaN output + # Note: cp.where might already propagate NaNs correctly in many cases, + # but applying the mask explicitly guarantees it. + + nan_mask = cp.isnan(P) | cp.isnan(P5) | cp.isnan(m1) | cp.isnan(m2) | cp.isnan(m3) | \ + cp.isnan(sr1) | cp.isnan(sr2) | cp.isnan(sr3) + + final_runoff[nan_mask] = cp.nan + + return final_runoff + + + def runoff_total_volume(runoff): + nan_mask = cp.isnan(runoff) + runoff[~nan_mask] = runoff[~nan_mask] * 900 + return runoff + + # Define the kernel + transfer_kernel = cp.RawKernel(r''' + extern "C" __global__ + void transfer_flow(const int* F, const float* V, float* V_out, int size) { + int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i >= size) return; + + int dest = F[i]; // Get destination index + if (dest >= 0 && dest < size) { + atomicAdd(&V_out[dest], V[i]); // Transfer value to destination + } + } + ''', 'transfer_flow') + + # Function to execute the kernel + def transfer_flow(F, V): + F_cp = cp.asarray(F, dtype=cp.int32) + V_cp = cp.asarray(V, dtype=cp.float32) + V_out = cp.zeros_like(V_cp) # Initialize output matrix + + size = F_cp.size + threads_per_block = 256 + blocks_per_grid = (size + threads_per_block - 1) // threads_per_block + + + # Launch the kernel + LegacyCodes.transfer_kernel((blocks_per_grid,), (threads_per_block,), (F_cp, V_cp, V_out, size)) + + return V_out diff --git a/computing/hydrology_gpu/algorithms/tiled_timeseries.py b/computing/hydrology_gpu/algorithms/tiled_timeseries.py new file mode 100644 index 00000000..8041c3b5 --- /dev/null +++ b/computing/hydrology_gpu/algorithms/tiled_timeseries.py @@ -0,0 +1,260 @@ +import csv +import gc +import json +import shutil +from collections import defaultdict +from datetime import datetime +from pathlib import Path + +import cupy as cp +import numpy as np +from shapely.geometry import shape +from tqdm import tqdm + +from .. import config as cfg +from ..downloads import rainfall +from . import GenericAlgorithm +from .runoff import Runoff, LegacyCodes + + +def per_watershed_sum_count(mws, raster): + watershed = mws.ravel().astype(cp.int32) + values = cp.asarray(raster, dtype=cp.float32).ravel() + + mask = watershed > 0 + if int(cp.count_nonzero(mask).get()) == 0: + return [], [], [] + + watershed = watershed[mask] + values = values[mask] + finite = cp.isfinite(values) + if int(cp.count_nonzero(finite).get()) == 0: + return [], [], [] + + watershed = watershed[finite] + values = values[finite] + + sums = cp.bincount(watershed, weights=values) + counts = cp.bincount(watershed) + ids = cp.nonzero(counts)[0] + + return ids.get(), sums[ids].get(), counts[ids].get() + + +class TiledTimeSeries(GenericAlgorithm): + def __init__( + self, + tile_size: int, + series_1="Rainfall", + series_2="Runoff", + *oth_args, + **kwargs, + ) -> None: + super().__init__(*oth_args, **kwargs) + self.tile_size = tile_size + self.series_1 = series_1 + self.series_2 = series_2 + + def load_inputs(self): + with open(cfg.MICROWATERSHEDS_PATH, "r") as f: + self.mws_geojson = json.load(f) + + self.shape_records = [] + for fallback_id, feature in enumerate(self.mws_geojson["features"], start=1): + properties = feature.setdefault("properties", {}) + try: + feature_id = int(properties["id"]) + except (KeyError, TypeError, ValueError): + feature_id = fallback_id + properties["id"] = feature_id + properties.pop("timeseries", None) + geometry = shape(feature["geometry"]) + self.shape_records.append((geometry, feature_id, geometry.bounds)) + + def tile_shapes(self, tile_bounds): + left, bottom, right, top = tile_bounds + return [ + (geometry, feature_id) + for geometry, feature_id, bounds in self.shape_records + if bounds[0] < right and bounds[2] > left and bounds[1] < top and bounds[3] > bottom + ] + + @staticmethod + def _empty_series_entry(): + return [0.0, 0] + + def _series_path(self, watershed_id): + return self.series_dir / f"{watershed_id // 1000:04d}" / f"{watershed_id}.csv" + + def write_tile_series(self, tile_index, data1, data2): + watershed_ids = sorted(set(data1) | set(data2)) + if not watershed_ids: + return + for watershed_id in watershed_ids: + path = self._series_path(watershed_id) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", newline="") as f: + writer = csv.writer(f) + timestamps = set(data1[watershed_id]) | set(data2[watershed_id]) + for timestamp in sorted(timestamps): + rainfall_sum, rainfall_count = data1[watershed_id].get(timestamp, (0.0, 0)) + runoff_sum, runoff_count = data2[watershed_id].get(timestamp, (0.0, 0)) + writer.writerow((timestamp, rainfall_sum, rainfall_count, runoff_sum, runoff_count)) + self.logger.info("Stored tile %s series for %s watershed(s) in %s", tile_index, len(watershed_ids), self.series_dir) + + def add_to_series(self, running_data, watershed_raster, raster, name): + raw_date = name + dt = datetime.strptime(raw_date, "%Y%m%d_%H").isoformat() + ids, sums, counts = per_watershed_sum_count(watershed_raster, raster) + for watershed_id, value_sum, value_count in zip(ids, sums, counts): + entry = running_data[int(watershed_id)][dt] + entry[0] += float(value_sum) + entry[1] += int(value_count) + + def process_tile(self, tile_handler, tile_index, tile_count): + shapes = self.tile_shapes(tile_handler.bounds) + if not shapes: + self.logger.info("Skipping tile %s/%s with no intersecting watershed geometries", tile_index, tile_count) + return + + watershed_raster = tile_handler.rasterize_by_id(shapes) + if not np.any(watershed_raster > 0): + self.logger.info("Skipping tile %s/%s with no watershed pixels", tile_index, tile_count) + return + + self.logger.info( + "Processing tile %s/%s: size=%sx%s bounds=%s", + tile_index, + tile_count, + tile_handler.width, + tile_handler.height, + tile_handler.bounds, + ) + + watershed_cp = cp.asarray(watershed_raster) + tile_series_data1 = defaultdict(lambda: defaultdict(self._empty_series_entry)) + tile_series_data2 = defaultdict(lambda: defaultdict(self._empty_series_entry)) + runoff_algo = Runoff() + runoff_algo.tif_handler = tile_handler + + soil, raw_lulc, slope = runoff_algo.load_sr_inputs() + current_sr_key = None + sr1 = sr2 = sr3 = None + + images = [] + p5_sum = None + rainfall_iter = rainfall.LoadTile_from_database(tile_handler) + + for index, file in enumerate(rainfall_iter.main()): + img = file["data"] + np.nan_to_num(img, copy=False) + self.add_to_series(tile_series_data1, watershed_cp, img, file["timestamp"]) + + images.append(img) + if index == 4: + p5_sum = cp.zeros_like(cp.asarray(images[0], dtype=cp.float32)) + for previous_img in images[:5]: + p5_sum = p5_sum + cp.asarray(previous_img, dtype=cp.float32) + elif index >= 5: + new_img = cp.asarray(images[-1], dtype=cp.float32) + old_img = cp.asarray(images[0], dtype=cp.float32) + p5_sum = p5_sum - old_img + new_img + images.pop(0) + del new_img, old_img + + if index >= 4: + p_sum = cp.asarray(images[-1], dtype=cp.float32) + sr_key = runoff_algo.sr_cache_key(file["timestamp"]) + if sr_key != current_sr_key: + if sr1 is not None: + del sr1, sr2, sr3 + sr1, sr2, sr3 = runoff_algo.compute_sr_for_timestamp( + soil, + raw_lulc, + slope, + file["timestamp"], + ) + current_sr_key = sr_key + self.logger.info("Loaded SR for LULC key %s", sr_key) + m1_cp = LegacyCodes.compute_M(sr1, p5_sum) + m2_cp = LegacyCodes.compute_M(sr2, p5_sum) + m3_cp = LegacyCodes.compute_M(sr3, p5_sum) + runoff = LegacyCodes.calculate_runoff_cupy(p_sum, p5_sum, m1_cp, m2_cp, m3_cp, sr1, sr2, sr3) + self.add_to_series(tile_series_data2, watershed_cp, runoff, file["timestamp"]) + del p_sum, m1_cp, m2_cp, m3_cp, runoff + + self.write_tile_series(tile_index, tile_series_data1, tile_series_data2) + if sr1 is not None: + del sr1, sr2, sr3 + del watershed_cp, soil, raw_lulc, slope, rainfall_iter, images, p5_sum, tile_series_data1, tile_series_data2 + cp.get_default_memory_pool().free_all_blocks() + gc.collect() + + def main(self): + output_path = Path(cfg.TIMESERIES_VECTOR) + self.series_dir = output_path.parent / f"{output_path.stem}_tile_series" + shutil.rmtree(self.series_dir, ignore_errors=True) + self.series_dir.mkdir(parents=True, exist_ok=True) + + windows = list(self.tif_handler.iter_windows(self.tile_size)) + self.logger.info("Processing %s tile(s) with tile_size=%s", len(windows), self.tile_size) + for tile_index, window in enumerate(tqdm(windows, desc="Processing spatial tiles"), start=1): + tile_handler = self.tif_handler.for_window(window) + self.process_tile(tile_handler, tile_index, len(windows)) + + def save_outputs(self): + def make_output(watershed_id): + path = self._series_path(watershed_id) + if not path.exists(): + return {} + data = defaultdict(lambda: [0.0, 0, 0.0, 0]) + with path.open(newline="") as f: + for timestamp, rainfall_sum, rainfall_count, runoff_sum, runoff_count in csv.reader(f): + entry = data[timestamp] + entry[0] += float(rainfall_sum) + entry[1] += int(rainfall_count) + entry[2] += float(runoff_sum) + entry[3] += int(runoff_count) + + ret = {} + for timestamp in sorted(data): + values = {} + rainfall_sum, rainfall_count, runoff_sum, runoff_count = data[timestamp] + if rainfall_count: + values[self.series_1] = rainfall_sum / rainfall_count + if runoff_count: + values[self.series_2] = runoff_sum / runoff_count + if values: + ret[timestamp] = values + return ret + + output_path = Path(cfg.TIMESERIES_VECTOR) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w") as f: + f.write("{") + first_key = True + for key, value in self.mws_geojson.items(): + if key == "features": + continue + if not first_key: + f.write(",") + json.dump(key, f) + f.write(":") + json.dump(value, f, separators=(",", ":")) + first_key = False + + if not first_key: + f.write(",") + f.write('"features":[') + first_feature = True + for watershed in tqdm(self.mws_geojson["features"], desc="Writing tiled timeseries"): + watershed_id = int(watershed["properties"]["id"]) + watershed["properties"]["timeseries"] = make_output(watershed_id) + if not first_feature: + f.write(",") + json.dump(watershed, f, separators=(",", ":")) + watershed["properties"].pop("timeseries", None) + first_feature = False + f.write("]}") + + self.logger.info("Saved file to %s", cfg.TIMESERIES_VECTOR) diff --git a/computing/hydrology_gpu/algorithms/timeseries.py b/computing/hydrology_gpu/algorithms/timeseries.py new file mode 100644 index 00000000..a9a56ef2 --- /dev/null +++ b/computing/hydrology_gpu/algorithms/timeseries.py @@ -0,0 +1,119 @@ +import json +import os +import pathlib +from datetime import datetime +from pprint import pprint +from shapely.geometry import shape +from tqdm import tqdm +import cProfile +import pstats +from .runoff import Runoff +from . import GenericAlgorithm, GeoTIFFHandler +from typing import Type, Dict, Tuple, DefaultDict +from collections import defaultdict, OrderedDict +import cupy as cp +from .. import config as cfg + +# Not generic enough yet... +class TimeSeries(GenericAlgorithm): + def __init__(self, + algo: Type[GenericAlgorithm] = Runoff, + series_1="Rainfall", + series_2="Runoff", + *oth_args, **kwargs + ) -> None: + super().__init__(*oth_args, **kwargs) + self.algo = algo() + self.series_1 = series_1 + self.series_2 = series_2 + + def load_inputs(self): + self.algo.load_inputs() + # self.mws = self.tif_handler.rasterize_by_id(cfg.MICROWATERSHEDS_PATH) + # self.mws_geojson = mws.Clip(self.args, self.logger, cfg).main() + with open(cfg.MICROWATERSHEDS_PATH, 'r') as f: + self.mws_geojson = json.load(f) + + shapes = [] + for fallback_id, feature in enumerate(self.mws_geojson['features'], start=1): + properties = feature.setdefault('properties', {}) + try: + feature_id = int(properties['id']) + except (KeyError, TypeError, ValueError): + feature_id = fallback_id + properties['id'] = feature_id + shapes.append((shape(feature['geometry']), feature_id)) + self.mws = self.tif_handler.rasterize_by_id(shapes) + + def main(self): + mws_cp = cp.asarray(self.mws) + mws_series_data1 = defaultdict(list) + mws_series_data2 = defaultdict(list) + + def add_to_series(running_data, raster, name): + avg, ids = per_watershed_avg(mws_cp, raster) + raw_date = name + dt = datetime.strptime(raw_date, "%Y%m%d_%H") + for val, mws_id in zip(avg, ids): + running_data[int(mws_id)].append((val, dt.isoformat())) + + # profiler = cProfile.Profile() + # profiler.enable() + for s1, s2, name in self.algo.main(): + add_to_series(mws_series_data1, s1, name) + if s2 is not None: + add_to_series(mws_series_data2, s2, name) + + # profiler.disable() + # + # stats = pstats.Stats(profiler) + # stats.dump_stats("loop_profile.prof") + # + # self.logger.info("Profiling data saved to loop_profile.prof") + + self.mws_series_data1 = mws_series_data1 + self.mws_series_data2 = mws_series_data2 + + def save_outputs(self): + def make_output(id): + ret = dict() # apparently, dict is an OrderdDict + for (val, name) in self.mws_series_data1[id]: + ret[name] = {self.series_1: val} + for (val, name) in self.mws_series_data2[id]: + if name not in ret: + ret[name] = {self.series_2: val} + else: + ret[name][self.series_2] = val + return ret + + mws_data = self.mws_geojson + + for mws in tqdm(mws_data['features']): + id = int(mws['properties']['id']) + mws['properties']["timeseries"] = make_output(id) + + with open(cfg.TIMESERIES_VECTOR, 'w+') as f: + json.dump(mws_data, f) + + self.logger.info(f"Saved file to {cfg.TIMESERIES_VECTOR}") + + + +def per_watershed_avg(mws, raster): + runoff_raster = cp.asarray(raster) + watershed_raster = mws + + ws_flat = watershed_raster.ravel() + rf_flat = runoff_raster.ravel() + + unique_ws, inv = cp.unique(ws_flat, return_inverse=True) + + per_ws_sum = cp.bincount(inv, weights=rf_flat, minlength=unique_ws.size) + per_ws_count = cp.bincount(inv, minlength=unique_ws.size) + + per_ws_mean = per_ws_sum / per_ws_count + + # result = per_ws_mean[inv].reshape(watershed_raster.shape) + + return (per_ws_mean.get(), unique_ws.get()) + # return result diff --git a/computing/hydrology_gpu/config/__init__.py b/computing/hydrology_gpu/config/__init__.py new file mode 100644 index 00000000..13180a08 --- /dev/null +++ b/computing/hydrology_gpu/config/__init__.py @@ -0,0 +1,38 @@ +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: + tomllib = None + + +def _loads_config(text): + if tomllib is not None: + return tomllib.loads(text) + + values = {} + for raw_line in text.splitlines(): + line = raw_line.split("#", 1)[0].strip() + if not line: + continue + key, raw_value = line.split("=", 1) + key = key.strip() + raw_value = raw_value.strip() + if raw_value.startswith(("'", '"')) and raw_value.endswith(("'", '"')): + values[key] = raw_value[1:-1] + else: + try: + values[key] = int(raw_value) + except ValueError: + values[key] = raw_value + return values + + +_config_path = Path(__file__).with_name("config.toml") +_values = _loads_config(_config_path.read_text()) + +globals().update(_values) + +# The current runner uses the same vector file as both the region boundary and +# the microwatershed feature collection unless the CLI overrides it. +MICROWATERSHEDS_PATH = BOUNDARY_GEOJSON_PATH diff --git a/computing/hydrology_gpu/config/config.toml b/computing/hydrology_gpu/config/config.toml new file mode 100644 index 00000000..e105009b --- /dev/null +++ b/computing/hydrology_gpu/config/config.toml @@ -0,0 +1,26 @@ +# configuration values for the project + +GEE_PROJECT_NAME = 'raman-461708' # Google Earth Engine project name + +GEE_SCALE = 30 # in meters + +# Path to the GeoJSON file defining the region of interest +# Currently, this can be provided with command line arguments. +BOUNDARY_GEOJSON_PATH = './tifs/delhi.geojson' + +# The following tifs will be downloaded by scripts. +DEMFILE_PATH = './tifs/dem.tif' +SOIL_PATH = './data/base_layers/soil/hysogs_india_250m_4326.tif' +LULC_PATH = './data/base_layers/lulc/lulc_v3_2024_2025.tif' +LULC_SOURCE = 'indiasatv3' +INDIASATV3_LULC_PATH = './data/base_layers/lulc/lulc_v3_2024_2025.tif' + +# Folder where rainfall data from GEE is downloaded to +RAINFALL_FOLDER = './tifs/rainfall3/' +RUNOFFS_FOLDER = './tifs/runoffs3/' + +# The output file. +TIMESERIES_VECTOR = './tifs/timeseries.geojson' + +ARG_START_DATE = '2017-07-01' +ARG_END_DATE = '2025-06-18' diff --git a/computing/hydrology_gpu/downloads/__init__.py b/computing/hydrology_gpu/downloads/__init__.py new file mode 100644 index 00000000..ce915dda --- /dev/null +++ b/computing/hydrology_gpu/downloads/__init__.py @@ -0,0 +1,147 @@ +import os +import shutil +from pathlib import Path +from logging import Logger +from argparse import ArgumentParser +from dataclasses import dataclass +import ee +import geedim +import geopandas as gpd +import requests + +from .. import config as cfg +from pydrive2.auth import GoogleAuth +from pydrive2.drive import GoogleDrive + +from .. import utils +from ..utils import GeoTIFFHandler + +ee.Initialize(project=cfg.GEE_PROJECT_NAME) + +class GenericDownloader: + # Singleton pattern + # _instance = None + # def __new__(cls, *args, **kwargs): + # if cls._instance is None: + # cls._instance = super().__new__(cls, *args, **kwargs) + # return cls._instance + + @dataclass + class InitializationData: + gauth: GoogleAuth = None + drive: GoogleDrive = None + _init_structs = None + + def __init__(self): + self.logger = utils.tif_handler.logger + self.tif_loader = utils.tif_handler + + if GenericDownloader._init_structs is None: + GenericDownloader._init_structs = GenericDownloader.InitializationData() + + self.gauth = GenericDownloader._init_structs.gauth + self.drive = GenericDownloader._init_structs.drive + + def download_gdrive_file(self, file_id, path): + if GenericDownloader._init_structs.gauth is None: + settings_path = Path(__file__).resolve().parents[1] / "pydrive_settings.yaml" + GenericDownloader._init_structs.gauth = GoogleAuth(settings_file=str(settings_path)) + GenericDownloader._init_structs.drive = GoogleDrive(GenericDownloader._init_structs.gauth) + self.gauth = GenericDownloader._init_structs.gauth + self.drive = GenericDownloader._init_structs.drive + + file_obj = self.drive.CreateFile({'id': file_id}) + # Fetching title first to name the local file + file_obj.FetchMetadata() + self.logger.info(f"Downloading {file_obj['title']}...") + file_obj.GetContentFile(path + file_obj['title']) + return f"Finished {file_obj['title']}" + + + @staticmethod + def empty_folder(folder): + shutil.rmtree(folder) + os.mkdir(folder) + + def load_region(self): + # Load the file - GeoPandas handles FeatureCollection vs Feature automatically + gdf = gpd.read_file(cfg.BOUNDARY_GEOJSON_PATH) + + # Generalize to a single geometry (Unions everything if there are multiple features) + # Helpful if the input itself is a vector of different mws. + combined_geom = gdf.unary_union + + # Convert to Earth Engine Geometry + # __geo_interface__ is a standard way to get GeoJSON-like dicts from objects + geojson_struct = combined_geom.__geo_interface__ + region = ee.Geometry(geojson_struct) + return region + + @staticmethod + def save_from_gee(collection, region, tif_file_path): + logger = utils.tif_handler.logger if utils.tif_handler else None + os.makedirs(os.path.dirname(tif_file_path) or ".", exist_ok=True) + + try: + # 1. Attempt the fast direct download + if logger: + logger.info(f"Requesting Earth Engine download URL for {tif_file_path}") + + url = collection.getDownloadURL({ + 'format': 'GEO_TIFF', + 'scale': cfg.GEE_SCALE, + 'region': region + }) + + if logger: + logger.info(f"Downloading {tif_file_path}") + + response = requests.get(url, timeout=(30, 900)) + + # If Earth Engine says "Too Large", the status_code will not be 200 + if response.status_code == 200: + with open(tif_file_path, 'wb') as f: + f.write(response.content) + + if logger: + logger.info(f"Downloaded {tif_file_path} with `getDownloadURL`") + else: + print(f"Downloaded {tif_file_path} with `getDownloadURL`") + else: + raise ValueError(f"Image too large for direct URL (Status {response.status_code})") + + except Exception as e: + if logger: + logger.warning( + "Direct Earth Engine download failed for %s: %s. Falling back to tiled download.", + tif_file_path, + e, + ) + else: + print(f"Direct Earth Engine download failed for {tif_file_path}: {e}") + print("Falling back to tiled download.") + + # getDownloadURL uses a single Earth Engine thumbnail request, which + # fails for district-scale rasters over the 50 MB limit. geedim + # splits the same image into smaller computePixels requests and + # stitches them into one local GeoTIFF. + prepared_image = collection.gd.prepareForExport( + scale=cfg.GEE_SCALE, + region=region, + resampling="near", + ) + prepared_image.gd.toGeoTIFF( + tif_file_path, + overwrite=True, + max_tile_size=4, + max_requests=2, + max_cpus=1, + ) + + if logger: + logger.info(f"Downloaded {tif_file_path} with tiled Earth Engine download") + else: + print(f"Downloaded {tif_file_path} with tiled Earth Engine download") + + def main(self): + pass diff --git a/computing/hydrology_gpu/downloads/dem.py b/computing/hydrology_gpu/downloads/dem.py new file mode 100644 index 00000000..69373056 --- /dev/null +++ b/computing/hydrology_gpu/downloads/dem.py @@ -0,0 +1,95 @@ +""" +Currently, this calculates Slope (gradient) on GEE Servers. +DEM is not downloaded. +""" +from pathlib import Path + +import geopandas as gpd +import rasterio +from rasterio.mask import mask +import xarray +from .. import config as cfg +from . import GenericDownloader, ee +import geemap + + +def clip_local_raster(source_path, boundary_path, output_path=None, logger=None, fill_value=0): + source_path = Path(source_path) + boundary_path = Path(boundary_path) + output_path = Path(output_path or cfg.DEMFILE_PATH) + + if logger: + logger.info("Clipping local terrain raster %s to %s", source_path, output_path) + logger.warning( + "Reading local terrain raster with GTIFF_IGNORE_READ_ERRORS=YES; unreadable source tiles may be filled by GDAL" + ) + + gdf = gpd.read_file(boundary_path) + if gdf.empty: + raise ValueError(f"No geometries found in boundary file: {boundary_path}") + + output_path.parent.mkdir(parents=True, exist_ok=True) + + with rasterio.Env(GTIFF_IGNORE_READ_ERRORS="YES"): + with rasterio.open(source_path) as src: + if gdf.crs is None: + gdf = gdf.set_crs(src.crs) + elif gdf.crs != src.crs: + gdf = gdf.to_crs(src.crs) + + data, transform = mask( + src, + gdf.geometry, + crop=True, + filled=True, + nodata=fill_value, + ) + + profile = src.profile.copy() + profile.update( + driver="GTiff", + height=data.shape[1], + width=data.shape[2], + transform=transform, + nodata=fill_value, + tiled=True, + compress="ZSTD", + ZSTD_LEVEL=1, + NUM_THREADS=10, + ) + + with rasterio.open(output_path, "w", **profile) as dst: + dst.write(data) + + if logger: + logger.info("Saved clipped local terrain raster to %s", output_path) + + return output_path + + +class Downloader(GenericDownloader): + def __init__(self): + """ + Override parent class's init. As GeoTiff handler needs one tif file for CRS reference + We download DEM as this reference + """ + pass + + def main(self): + dataset = ee.Image('USGS/SRTMGL1_003') + elevation = dataset.select('elevation') + + region = self.load_region() + + # 1. Calculate slope in degrees + slope_deg = ee.Terrain.slope(elevation) + + # 2. Convert to Gradient: tan(slope * pi / 180) + slope_gradient = slope_deg.multiply(3.141592).divide(180).tan() + + elevation_clip = slope_gradient.clipToBoundsAndScale( + geometry=region, + scale=cfg.GEE_SCALE + ) + + self.save_from_gee(elevation_clip, region, cfg.DEMFILE_PATH) diff --git a/computing/hydrology_gpu/downloads/lulc.py b/computing/hydrology_gpu/downloads/lulc.py new file mode 100644 index 00000000..ef643e98 --- /dev/null +++ b/computing/hydrology_gpu/downloads/lulc.py @@ -0,0 +1,32 @@ +from argparse import ArgumentParser +import geemap +import xarray +from .. import config as cfg +from . import GenericDownloader, ee +import json +import requests +from .rainfall import DownloaderBase as RainfallDownloader + +class Downloader(GenericDownloader): + """ + Currently, the LULC is mode of lulc's from start date to end date. Static + """ + def main(self): + region = self.load_region() + + end_date = ee.Date(cfg.ARG_END_DATE) + start_date = ee.Date(cfg.ARG_START_DATE) + + dw_col = (ee.ImageCollection('GOOGLE/DYNAMICWORLD/V1') + .filterDate(start_date, end_date) + .filterBounds(region) + .select('label')) + + dw_image = dw_col.reduce(ee.Reducer.mode()).rename('lulc') + + dw_clip = dw_image.clipToBoundsAndScale( + geometry=region, + scale=cfg.GEE_SCALE + ) + + self.save_from_gee(dw_clip, region, cfg.LULC_PATH) diff --git a/computing/hydrology_gpu/downloads/rainfall.py b/computing/hydrology_gpu/downloads/rainfall.py new file mode 100644 index 00000000..8a709a08 --- /dev/null +++ b/computing/hydrology_gpu/downloads/rainfall.py @@ -0,0 +1,416 @@ +import gc +import json +import os +import pathlib +import shutil +import threading +import time +from argparse import ArgumentParser +from collections import deque +from concurrent.futures import ThreadPoolExecutor +from threading import Lock +from .. import config as cfg +import xarray as xr +import requests +import concurrent.futures + +import geopandas as gpd +import zarr +from rasterio.enums import Resampling +from shapely.geometry import box +from rasterio.transform import Affine, from_origin +from rasterio.warp import reproject +from tqdm import tqdm +# import geedim as gd +import cupy as cp +import numpy as np +import pandas as pd +import cucim.skimage.transform as cimg + +from . import GenericDownloader, ee, Logger + +class DownloaderBase(GenericDownloader): + def __init__(self): + + super().__init__() + self.zarr_path = os.path.join(cfg.RAINFALL_FOLDER, "rainfall_archive.zarr") + pathlib.Path(self.zarr_path).parent.mkdir(parents=True, exist_ok=True) + + def load_region_gdf(self): + gdf = gpd.read_file(cfg.BOUNDARY_GEOJSON_PATH) + if gdf.empty: + raise ValueError(f"No geometries found in {cfg.BOUNDARY_GEOJSON_PATH}") + + if gdf.crs is None: + gdf = gdf.set_crs("EPSG:4326") + elif gdf.crs.to_epsg() != 4326: + gdf = gdf.to_crs("EPSG:4326") + return gdf + + def load_local_region_geometry(self): + gdf = self.load_region_gdf() + if hasattr(gdf.geometry, "union_all"): + return gdf.geometry.union_all() + return gdf.unary_union + + def load_buffered_bounds_geometry(self, buffer_m=12000): + """ + Build a small EE rectangle from local bounds instead of sending the full + boundary polygon to Earth Engine. Large pan-India polygons can exceed + EE's 10 MB request payload limit. + """ + gdf = self.load_region_gdf() + metric_gdf = gdf.to_crs("EPSG:3857") + minx, miny, maxx, maxy = metric_gdf.geometry.buffer(buffer_m).total_bounds + bounds_geom = gpd.GeoSeries([box(minx, miny, maxx, maxy)], crs="EPSG:3857").to_crs("EPSG:4326") + west, south, east, north = [float(value) for value in bounds_geom.total_bounds] + self.logger.info("Using local buffered boundary bounds: [%s, %s, %s, %s]", west, south, east, north) + return ee.Geometry.Rectangle([west, south, east, north], proj="EPSG:4326", geodesic=False) + + def init_zarr(self, dates, dummy_da): + """ + Initializes a Zarr store with the full time extent to allow parallel region writes. + """ + native_y, native_x = dummy_da.y.size, dummy_da.x.size + + # Create the skeleton Dataset + # We use empty/zeros but with compute=False, so no data is actually written yet + ds_skeleton = xr.Dataset( + {"precipitation": (["time", "y", "x"], + np.zeros((len(dates), native_y, native_x), dtype='float32'))}, + coords={ + "time": dates, + "y": dummy_da.y.values, + "x": dummy_da.x.values + } + ) + + # Metadata/CRS (Important for GeoZarr) + ds_skeleton.rio.write_crs(dummy_da.rio.crs, inplace=True) + ds_skeleton.rio.write_transform(dummy_da.rio.transform(), inplace=True) + + # Encoding: chunking by 1 day is standard for daily time-series + encoding = { + "precipitation": {"chunks": (1, native_y, native_x)}, + "time": { + "units": "hours since 2017-07-01 00:00:00", + "calendar": "proleptic_gregorian", + "dtype": "int64" # Ensuring integer storage for hours + } + } + + # Write ONLY metadata + ds_skeleton.to_zarr(self.zarr_path, mode='w', encoding=encoding, compute=False, zarr_format=2) + self.logger.info(f"Initialized Zarr skeleton at {self.zarr_path} with {len(dates)} slots.") + + + def save_geozarr(self, flat_data, timestamp, dummy_da, t): + """ + Saves data as a 3D (Time, Y, X) chunked array with full spatial coordinates. + """ + + # 1. Reshape flat data to native grid dimensions + # Using the shape from self.dummy_da (e.g., [Lat, Lon]) + native_y, native_x = dummy_da.y.size, dummy_da.x.size + raster_data = flat_data.reshape(native_y, native_x) + + # 2. Create the DataArray with proper spatial coords + da = xr.DataArray( + raster_data[np.newaxis, ...], # Shape: (1, Y, X) + dims=("time", "y", "x"), + coords={ + "time": [pd.to_datetime(timestamp, format='%Y%m%d_%H')], + "y": dummy_da.y.values, + "x": dummy_da.x.values + }, + name="precipitation" + ) + + # 3. Add CRS and metadata + da.rio.write_crs(dummy_da.rio.crs, inplace=True) + da.rio.write_transform(dummy_da.rio.transform(), inplace=True) + + ds = da.to_dataset().drop_vars(["y", "x", "spatial_ref"]) + + ds.to_zarr(self.zarr_path, region={"time": slice(t, t + 1)}) + + def load_geozarr(self): + """ + Loads the Zarr archive as a standard Xarray Dataset. + """ + + if not os.path.exists(self.zarr_path): + self.logger.error(f"Zarr not found at {self.zarr_path}") + return None + + # chunks={} opens it lazily using Dask + ds = xr.open_zarr(self.zarr_path, consolidated=True, chunks={}) + return ds + +class Download_to_database(DownloaderBase): + def main(self): + self.ingest_rainfall_to_zarr() + + def ingest_rainfall_to_zarr(self): + """ + Part 1: Purely fetches data, sums it on GPU, and saves to GeoZarr. + """ + self.logger.info("Starting rainfall ingestion to GeoZarr") + self.logger.info("Loading boundary bounds") + buffered_region = self.load_buffered_bounds_geometry() + + self.logger.info( + "Preparing GSMaP rainfall collection for [%s, %s)", + cfg.ARG_START_DATE, + cfg.ARG_END_DATE, + ) + rainfall_collection = ( + ee.ImageCollection('JAXA/GPM_L3/GSMaP/v6/operational') + .filterDate(cfg.ARG_START_DATE, cfg.ARG_END_DATE) + .select('hourlyPrecipRate') + ) + + # rainfall_collection = ( + # ee.ImageCollection("NASA/GPM_L3/IMERG_DAILY_V06") + # .filterDate(cfg.ARG_START_DATE, cfg.ARG_END_DATE) + # .select('total_accum') + # ) + # + # # 1. Define the time range + # start_date = ee.Date(cfg.ARG_START_DATE) + # end_date = ee.Date(cfg.ARG_END_DATE) + # + # # 2. Calculate the number of days between start and end + # n_days = end_date.difference(start_date, 'days') + # + # def sum_daily(day_offset): + # # Calculate the start and end of each 24-hour window + # start = start_date.advance(ee.Number(day_offset), 'days') + # end = start.advance(1, 'days') + # + # # Filter the collection for this specific day and sum + # daily_sum = (rainfall_collection + # .filterDate(start, end) + # .sum()) # Sums the 'hourlyPrecipRate' + # + # # Return the image with its date metadata (important for further filtering) + # return daily_sum.set({ + # 'system:time_start': start.millis(), + # 'date_string': start.format('YYYY-MM-DD') + # }) + # + # # 3. Create a sequence of days and map the function + # daily_collection = ee.ImageCollection( + # ee.List.sequence(0, n_days.subtract(1)).map(sum_daily) + # ) + + first_img = rainfall_collection.first() + self.logger.info("Fetching GSMaP native projection") + native_proj = first_img.projection() + + self.logger.info("Opening Earth Engine dataset through xarray/xee; this can take a few minutes for pan-India yearly ranges") + ds = xr.open_dataset( + rainfall_collection, + engine='ee', + projection=native_proj, + geometry=buffered_region, + fast_time_slicing=True, + ) + self.logger.info("Earth Engine dataset opened; preparing rainfall coordinates") + + da = ds['hourlyPrecipRate'].rename({'lat': 'y', 'lon': 'x'}).transpose("time", "y", "x") + total_pixels = da.y.size * da.x.size + dummy_da = da.isel(time=0) + + N = len(da.time) + K = 24 # Hours per day + self.logger.info( + "Rainfall grid has %s hourly slices and %sx%s native pixels", + N, + da.y.size, + da.x.size, + ) + + dates = pd.date_range(start=cfg.ARG_START_DATE, end=cfg.ARG_END_DATE, freq='D', inclusive='left') + self.init_zarr(dates, dummy_da) + + ASK_BUFF = 50 + WORKERS = 20 + + def process_slice(t, ticket_num): + da_slice = da.isel(time=slice(t, min(t + K, N))) + + # Extract raw data and sum on GPU + raw_data = da_slice.values + gpu_sum = cp.zeros(total_pixels, dtype=cp.float32) + + for i in range(raw_data.shape[0]): + gpu_sum += cp.asarray(raw_data[i]).ravel() + + timestamp = da_slice.time[0].dt.strftime('%Y%m%d_%H').item() + + # Save the flat sum to the database + # Assuming self.save_geozarr handles time-indexing inside the Zarr + self.save_geozarr(gpu_sum.get(), timestamp, dummy_da, ticket_num) + + with ( + concurrent.futures.ThreadPoolExecutor(max_workers=WORKERS) as executor, + tqdm(range(N), desc="Downloading Rainfall") as pbar + ): + futures = [] + + while pbar.n < pbar.total: + if len(futures) == 0: + gc.collect() + t = pbar.n + assert t % K == 0 + for i in range(ASK_BUFF): + current_hour = t+i*K + day_index = current_hour // K + if current_hour >= N: + break + futures.append(executor.submit( + process_slice, + current_hour, + day_index + )) + futures.pop().result() + pbar.update(K) + + zarr.consolidate_metadata(self.zarr_path) + self.logger.info("Ingestion complete.") + + +def transform_from_center_coords(x_values, y_values): + if len(x_values) < 2 or len(y_values) < 2: + raise ValueError("Rainfall archive must have at least two x and y coordinates") + + dx = float(np.median(np.diff(x_values))) + dy = float(np.median(np.diff(y_values))) + return Affine.translation(float(x_values[0]) - dx / 2, float(y_values[0]) - dy / 2) * Affine.scale(dx, dy) + + +def build_reference_index_map(source_shape, original_size, src_transform, src_crs, tif_handler): + source_indices = np.arange(original_size, dtype=np.int32).reshape(source_shape) + mapped_indices = np.full( + (tif_handler.height, tif_handler.width), + original_size, + dtype=np.int32, + ) + + reproject( + source=source_indices, + destination=mapped_indices, + src_transform=src_transform, + src_crs=src_crs, + dst_transform=tif_handler.transform, + dst_crs=tif_handler.crs, + dst_nodata=original_size, + resampling=Resampling.nearest, + ) + return mapped_indices + + +class Load_from_database(DownloaderBase): + + def __init__(self): + super().__init__() + self.tif_handler = self.tif_loader + self.ds = self.load_geozarr() + if self.ds is None: + raise FileNotFoundError(f"Rainfall archive not found at {self.zarr_path}") + + self.src_transform = transform_from_center_coords( + self.ds.x.values, + self.ds.y.values, + ) + self.src_crs = self.ds.rio.crs or "EPSG:4326" + self.source_shape = (self.ds.sizes["y"], self.ds.sizes["x"]) + self.original_size = self.source_shape[0] * self.source_shape[1] + self.GPU_LUT = cp.asarray(build_reference_index_map( + self.source_shape, + self.original_size, + self.src_transform, + self.src_crs, + self.tif_handler, + )) + + self.STATIC_METADATA = { + "bounds": self.tif_handler.bounds, + "transform": self.tif_handler.transform, + "crs": self.tif_handler.crs, + "shape": (self.tif_handler.height, self.tif_handler.width), + "original_size": self.original_size, + } + + def main(self): + self.logger.info("Starting rainfall load from GeoZarr onto reference grid") + + yield from self.stream_reprojected_rainfall() + + def stream_reprojected_rainfall(self): + for i in tqdm(range(len(self.ds.time))): + # Extract 2D slice and flatten for the GPU LUT + hourly_slice = self.ds.precipitation.isel(time=i) + flat_cpu = hourly_slice.values.ravel() + + # Move to GPU + gpu_src = cp.asarray(flat_cpu) + + # Append sink pixel for nodata and apply LUT + projected_buffer = cp.concatenate([gpu_src, cp.array([0], dtype=gpu_src.dtype)]) + gpu_final = projected_buffer[self.GPU_LUT] + + yield { + "timestamp": hourly_slice.time.dt.strftime('%Y%m%d_%H').item(), + "data": gpu_final.get(), + "bounds": self.STATIC_METADATA["bounds"], + "transform": self.STATIC_METADATA["transform"], + "crs": self.STATIC_METADATA["crs"] + } + + +class LoadTile_from_database(DownloaderBase): + def __init__(self, tif_handler): + super().__init__() + self.tif_handler = tif_handler + self.ds = self.load_geozarr() + if self.ds is None: + raise FileNotFoundError(f"Rainfall archive not found at {self.zarr_path}") + + self.src_transform = transform_from_center_coords( + self.ds.x.values, + self.ds.y.values, + ) + self.src_crs = self.ds.rio.crs or "EPSG:4326" + self.source_shape = (self.ds.sizes["y"], self.ds.sizes["x"]) + self.original_size = self.source_shape[0] * self.source_shape[1] + self.GPU_LUT = cp.asarray(build_reference_index_map( + self.source_shape, + self.original_size, + self.src_transform, + self.src_crs, + self.tif_handler, + )) + + def main(self): + self.logger.info("Starting tiled rainfall load from GeoZarr") + yield from self.stream_reprojected_rainfall() + + def stream_reprojected_rainfall(self): + for i in tqdm(range(len(self.ds.time)), desc="Projecting rainfall tile"): + hourly_slice = self.ds.precipitation.isel(time=i) + flat_cpu = hourly_slice.values.ravel() + gpu_src = cp.asarray(flat_cpu) + projected_buffer = cp.concatenate([gpu_src, cp.array([0], dtype=gpu_src.dtype)]) + gpu_final = projected_buffer[self.GPU_LUT] + data = gpu_final.get() + del gpu_src, projected_buffer, gpu_final + + yield { + "timestamp": hourly_slice.time.dt.strftime('%Y%m%d_%H').item(), + "data": data, + "bounds": self.tif_handler.bounds, + "transform": self.tif_handler.transform, + "crs": self.tif_handler.crs, + } diff --git a/computing/hydrology_gpu/downloads/soil.py b/computing/hydrology_gpu/downloads/soil.py new file mode 100644 index 00000000..d2b8bf60 --- /dev/null +++ b/computing/hydrology_gpu/downloads/soil.py @@ -0,0 +1,18 @@ +from argparse import ArgumentParser +from .. import config as cfg +import xarray +from . import GenericDownloader, ee +import geemap + +class Downloader(GenericDownloader): + def main(self): + region = self.load_region() + + hsg_image = ee.Image('projects/ee-dharmisha-siddharth/assets/HYSOGs250m') + + hsg_clip = hsg_image.clipToBoundsAndScale( + geometry=region, + scale=cfg.GEE_SCALE + ) + + self.save_from_gee(hsg_clip, region, cfg.SOIL_PATH) diff --git a/computing/hydrology_gpu/lulc_mapping.py b/computing/hydrology_gpu/lulc_mapping.py new file mode 100644 index 00000000..ace57b89 --- /dev/null +++ b/computing/hydrology_gpu/lulc_mapping.py @@ -0,0 +1,120 @@ +from datetime import datetime + +import cupy as cp + + +LULC_SOURCE_DYNAMICWORLD = "dynamicworld" +LULC_SOURCE_INDIASATV3 = "indiasatv3" +LULC_SOURCES = (LULC_SOURCE_DYNAMICWORLD, LULC_SOURCE_INDIASATV3) + +SEASON_KHARIF = "kharif" +SEASON_RABI = "rabi" +SEASON_ZAID = "zaid" +SEASON_STATIC = "static" + +DW_WATER = 0 +DW_TREES = 1 +DW_CROPS = 4 +DW_SHRUB_AND_SCRUB = 5 +DW_BUILT = 6 +DW_BARE = 7 + +INDIASAT_BACKGROUND = 0 +INDIASAT_BUILT_UP = 1 +INDIASAT_WATER_KHARIF = 2 +INDIASAT_WATER_KHARIF_RABI = 3 +INDIASAT_WATER_ALL_SEASONS = 4 +INDIASAT_TREE_FORESTS = 6 +INDIASAT_BARRENLANDS = 7 +INDIASAT_SINGLE_CROPPING = 8 +INDIASAT_SINGLE_NON_KHARIF_CROPPING = 9 +INDIASAT_DOUBLE_CROPPING = 10 +INDIASAT_TRIPLE_CROPPING = 11 +INDIASAT_SHRUB_SCRUB = 12 + + +def normalize_lulc_source(source: str) -> str: + normalized = str(source or LULC_SOURCE_DYNAMICWORLD).strip().lower() + if normalized not in LULC_SOURCES: + raise ValueError( + f"Unsupported LULC source {source!r}; expected one of {', '.join(LULC_SOURCES)}" + ) + return normalized + + +def month_from_timestamp(timestamp) -> int: + if hasattr(timestamp, "month"): + return int(timestamp.month) + + text = str(timestamp) + for fmt in ("%Y%m%d_%H", "%Y%m%d"): + try: + return datetime.strptime(text, fmt).month + except ValueError: + pass + + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).month + except ValueError as exc: + raise ValueError(f"Cannot parse rainfall timestamp {timestamp!r}") from exc + + +def season_from_month(month: int) -> str: + if month in (7, 8, 9, 10): + return SEASON_KHARIF + if month in (11, 12, 1, 2): + return SEASON_RABI + if month in (3, 4, 5, 6): + return SEASON_ZAID + raise ValueError(f"Invalid month {month!r}") + + +def lulc_cache_key_for_timestamp(source: str, timestamp) -> str: + source = normalize_lulc_source(source) + if source == LULC_SOURCE_DYNAMICWORLD: + return SEASON_STATIC + return season_from_month(month_from_timestamp(timestamp)) + + +def map_lulc_to_dynamic_world(raw_lulc: cp.ndarray, source: str, timestamp) -> cp.ndarray: + source = normalize_lulc_source(source) + if source == LULC_SOURCE_DYNAMICWORLD: + return raw_lulc + + season = season_from_month(month_from_timestamp(timestamp)) + return map_indiasatv3_to_dynamic_world(raw_lulc, season) + + +def map_indiasatv3_to_dynamic_world(raw_lulc: cp.ndarray, season: str) -> cp.ndarray: + mapped = cp.full(raw_lulc.shape, DW_WATER, dtype=cp.uint8) + + mapped = cp.where(raw_lulc == INDIASAT_BUILT_UP, DW_BUILT, mapped) + mapped = cp.where(raw_lulc == INDIASAT_TREE_FORESTS, DW_TREES, mapped) + mapped = cp.where(raw_lulc == INDIASAT_BARRENLANDS, DW_BARE, mapped) + mapped = cp.where(raw_lulc == INDIASAT_SHRUB_SCRUB, DW_SHRUB_AND_SCRUB, mapped) + + water = ( + (raw_lulc == INDIASAT_WATER_KHARIF) + | (raw_lulc == INDIASAT_WATER_KHARIF_RABI) + | (raw_lulc == INDIASAT_WATER_ALL_SEASONS) + ) + mapped = cp.where(water, DW_WATER, mapped) + + mapped = cp.where( + raw_lulc == INDIASAT_SINGLE_CROPPING, + DW_CROPS if season == SEASON_KHARIF else DW_SHRUB_AND_SCRUB, + mapped, + ) + mapped = cp.where( + raw_lulc == INDIASAT_SINGLE_NON_KHARIF_CROPPING, + DW_CROPS if season == SEASON_RABI else DW_SHRUB_AND_SCRUB, + mapped, + ) + mapped = cp.where( + raw_lulc == INDIASAT_DOUBLE_CROPPING, + DW_CROPS if season in (SEASON_KHARIF, SEASON_RABI) else DW_SHRUB_AND_SCRUB, + mapped, + ) + mapped = cp.where(raw_lulc == INDIASAT_TRIPLE_CROPPING, DW_CROPS, mapped) + + return mapped.astype(cp.uint8, copy=False) diff --git a/computing/hydrology_gpu/runoff.py b/computing/hydrology_gpu/runoff.py new file mode 100644 index 00000000..73356532 --- /dev/null +++ b/computing/hydrology_gpu/runoff.py @@ -0,0 +1,299 @@ +import shutil +from argparse import ArgumentParser +from contextlib import contextmanager +from pathlib import Path +from time import perf_counter +import ee +from .downloads import dem, lulc, soil +from .algorithms import tiled_timeseries, timeseries +from .downloads import rainfall +from . import config as cfg +from .lulc_mapping import LULC_SOURCE_INDIASATV3, LULC_SOURCES +from .utils import GeoTIFFHandler, make_logger +from . import utils +from .watershed_boundary import ( + DEFAULT_WATERSHED_ROOT, + PAN_INDIA_SLUG, + download_boundary_path, + materialize_district_boundary, + materialize_pan_india_boundary, + materialize_state_boundary, + materialize_tehsil_boundary, + slugify, +) + +parser = ArgumentParser() +logger = make_logger("runoff_only_with_rainfall.log") +PAN_INDIA_DEFAULT_TILE_SIZE = 11264 +STATE_DEFAULT_TILE_SIZE = 4096 + + +def format_elapsed(seconds): + if seconds < 60: + return f"{seconds:.2f}s" + minutes, seconds = divmod(seconds, 60) + if minutes < 60: + return f"{int(minutes)}m {seconds:.2f}s" + hours, minutes = divmod(minutes, 60) + return f"{int(hours)}h {int(minutes)}m {seconds:.2f}s" + + +@contextmanager +def timed_stage(name): + start_time = perf_counter() + logger.info("Starting %s", name) + try: + yield + except Exception: + logger.exception("Failed %s after %s", name, format_elapsed(perf_counter() - start_time)) + raise + else: + logger.info("Finished %s in %s", name, format_elapsed(perf_counter() - start_time)) + + +def selected_output_folder(root, args): + if getattr(args, "pan_india", False): + return str(Path(root) / PAN_INDIA_SLUG) + + path = Path(root) / slugify(args.state) + if args.district: + path = path / slugify(args.district) + if args.tehsil: + path = path / slugify(args.tehsil) + return str(path) + + +def validate_local_raster(path_value, option_name): + if not path_value: + return + + path = Path(path_value) + if not path.exists(): + parser.error(f"{option_name} path does not exist: {path}") + + if path.is_dir(): + has_tif = any( + child.is_file() and child.suffix.lower() in {".tif", ".tiff"} + for child in path.rglob("*") + ) + if not has_tif: + parser.error(f"{option_name} directory has no GeoTIFF files: {path}") + + +def resolve_boundary(args): + selectors = [args.pan_india, args.state, args.district, args.tehsil] + if not any(selectors): + return + if args.pan_india and any([args.state, args.district, args.tehsil]): + parser.error("--pan-india cannot be combined with --state, --district, or --tehsil") + if args.pan_india: + microwatersheds_path, source_paths, feature_count = materialize_pan_india_boundary( + watershed_root=args.watershed_root, + output_path=args.watershed_boundary_output, + overwrite=not args.reuse_watershed_boundary, + ) + boundary_path = download_boundary_path(microwatersheds_path) + args.boundary = str(boundary_path) + args.microwatersheds = str(microwatersheds_path) + if args.rainfall_folder is None: + args.rainfall_folder = selected_output_folder("./tifs/rainfall_pan_india", args) + if args.runoffs_folder is None: + args.runoffs_folder = selected_output_folder("./tifs/runoffs_pan_india", args) + logger.info( + "Resolved pan-India watershed boundary: sources=%s download_boundary=%s microwatersheds=%s features=%s", + len(source_paths), + boundary_path, + microwatersheds_path, + feature_count, + ) + return + + if not args.state: + parser.error("--state is required for watershed boundary lookup") + if args.tehsil and not args.district: + parser.error("--district is required when --tehsil is provided") + + if not args.district: + microwatersheds_path, source_paths, feature_count = materialize_state_boundary( + state=args.state, + watershed_root=args.watershed_root, + output_path=args.watershed_boundary_output, + overwrite=not args.reuse_watershed_boundary, + ) + boundary_path = download_boundary_path(microwatersheds_path) + args.boundary = str(boundary_path) + args.microwatersheds = str(microwatersheds_path) + if args.rainfall_folder is None: + args.rainfall_folder = selected_output_folder("./tifs/rainfall_state", args) + if args.runoffs_folder is None: + args.runoffs_folder = selected_output_folder("./tifs/runoffs_state", args) + logger.info( + "Resolved state watershed boundary: state=%s sources=%s download_boundary=%s microwatersheds=%s features=%s", + args.state, + len(source_paths), + boundary_path, + microwatersheds_path, + feature_count, + ) + return + + if not args.tehsil: + microwatersheds_path, source_paths, feature_count = materialize_district_boundary( + state=args.state, + district=args.district, + watershed_root=args.watershed_root, + output_path=args.watershed_boundary_output, + overwrite=not args.reuse_watershed_boundary, + ) + boundary_path = download_boundary_path(microwatersheds_path) + args.boundary = str(boundary_path) + args.microwatersheds = str(microwatersheds_path) + if args.rainfall_folder is None: + args.rainfall_folder = selected_output_folder("./tifs/rainfall_district", args) + if args.runoffs_folder is None: + args.runoffs_folder = selected_output_folder("./tifs/runoffs_district", args) + logger.info( + "Resolved district watershed boundary: state=%s district=%s sources=%s download_boundary=%s microwatersheds=%s features=%s", + args.state, + args.district, + len(source_paths), + boundary_path, + microwatersheds_path, + feature_count, + ) + return + + boundary_path, source_path, feature_count = materialize_tehsil_boundary( + state=args.state, + district=args.district, + tehsil=args.tehsil, + watershed_root=args.watershed_root, + output_path=args.watershed_boundary_output, + overwrite=not args.reuse_watershed_boundary, + ) + args.boundary = str(boundary_path) + args.microwatersheds = str(boundary_path) + if args.rainfall_folder is None: + args.rainfall_folder = selected_output_folder("./tifs/rainfall_tehsil", args) + if args.runoffs_folder is None: + args.runoffs_folder = selected_output_folder("./tifs/runoffs_tehsil", args) + logger.info( + "Resolved watershed boundary: state=%s district=%s tehsil=%s source=%s output=%s features=%s", + args.state, + args.district, + args.tehsil, + source_path, + boundary_path, + feature_count, + ) + +def modify_cfg(args): + cfg.BOUNDARY_GEOJSON_PATH = args.boundary + cfg.MICROWATERSHEDS_PATH = getattr(args, "microwatersheds", args.boundary) + cfg.LULC_SOURCE = args.lulc_source + if args.local_lulc is not None: + cfg.LULC_PATH = args.local_lulc + if args.local_soil is not None: + cfg.SOIL_PATH = args.local_soil + if args.rainfall_folder is not None: + cfg.RAINFALL_FOLDER = args.rainfall_folder + if args.runoffs_folder is not None: + cfg.RUNOFFS_FOLDER = args.runoffs_folder + if args.t: + path_obj = Path(cfg.MICROWATERSHEDS_PATH) + new_path = path_obj.with_name(f"{path_obj.stem}_timeseries{path_obj.suffix}") + cfg.TIMESERIES_VECTOR = new_path + cfg.ARG_START_DATE = args.start + cfg.ARG_END_DATE = args.end + cfg.TILE_SIZE = args.tile_size + +def prereq(args): + downloaders = [] + if args.local_lulc: + logger.info("Using local LULC from %s; skipping downloads.lulc.Downloader", args.local_lulc) + else: + downloaders.append(lulc.Downloader) + + if args.local_soil: + logger.info("Using local soil from %s; skipping downloads.soil.Downloader", args.local_soil) + else: + downloaders.append(soil.Downloader) + + downloaders.append(rainfall.Download_to_database) + + for downloader in downloaders: + stage_name = f"prerequisite downloader: {downloader.__module__}.{downloader.__name__}" + with timed_stage(stage_name): + downloader().main() + +if __name__=="__main__": + overall_start = perf_counter() + logger.info("Starting up") + + try: + parser.add_argument('-p', "--pre-req", action='store_true', help="also do pre-req stuff") + parser.add_argument('-b', '--boundary', help="use another boundary file", default=cfg.BOUNDARY_GEOJSON_PATH) + parser.add_argument('-t', help="Dump timeseries next to boundary file", action='store_true') + parser.add_argument('--start', help="in YYYY-MM-DD format (inclusive)", default=cfg.ARG_START_DATE) + parser.add_argument('--end', help="in YYYY-MM-DD format (exclusive)", default=cfg.ARG_END_DATE) + parser.add_argument('--rainfall-folder', help=f"folder containing rainfall_archive.zarr (default: {cfg.RAINFALL_FOLDER})") + parser.add_argument('--runoffs-folder', help=f"folder where runoff GeoZarr output will be written (default: {cfg.RUNOFFS_FOLDER})") + parser.add_argument('--pan-india', action='store_true', help="run all written watershed boundaries in the manifest as one pan-India job") + parser.add_argument('--state', help="state name for state/district/tehsil watershed lookup; omit --district to run the whole state") + parser.add_argument('--district', help="district name for district/tehsil watershed lookup") + parser.add_argument('--tehsil', help="optional tehsil name; omit to run the whole district") + parser.add_argument('--watershed-root', default=str(DEFAULT_WATERSHED_ROOT), help="root folder containing tehsil watershed GeoPackages") + parser.add_argument('--watershed-boundary-output', help="optional GeoJSON path for the resolved watershed boundary") + parser.add_argument('--reuse-watershed-boundary', action='store_true', help="reuse an existing resolved watershed GeoJSON instead of rebuilding it") + parser.add_argument('--local-dem', help="clip this local terrain/slope raster to the selected boundary instead of downloading DEM/slope from GEE") + parser.add_argument('--local-lulc', help="read LULC from this local GeoTIFF file or folder of GeoTIFF tiles instead of downloading LULC from GEE") + parser.add_argument('--lulc-source', choices=LULC_SOURCES, default=getattr(cfg, "LULC_SOURCE", "dynamicworld"), help="LULC class scheme for --local-lulc or downloaded LULC") + parser.add_argument('--local-soil', help="read soil/HSG from this local GeoTIFF file or folder of GeoTIFF tiles instead of downloading soil from GEE") + parser.add_argument('--tile-size', type=int, default=None, help=f"process runoff/timeseries in square pixel tiles; default is {PAN_INDIA_DEFAULT_TILE_SIZE} for pan-India, {STATE_DEFAULT_TILE_SIZE} for whole-state runs, and disabled otherwise; pass 0 to disable") + + args = parser.parse_args() + if args.lulc_source == LULC_SOURCE_INDIASATV3 and args.local_lulc is None: + args.local_lulc = getattr(cfg, "INDIASATV3_LULC_PATH", "./tifs/lulc_v3_2024_2025.tif") + validate_local_raster(args.local_lulc, "--local-lulc") + validate_local_raster(args.local_soil, "--local-soil") + resolve_boundary(args) + if args.tile_size is None: + if args.pan_india: + args.tile_size = PAN_INDIA_DEFAULT_TILE_SIZE + elif args.state and not args.district: + args.tile_size = STATE_DEFAULT_TILE_SIZE + else: + args.tile_size = 0 + if args.tile_size < 0: + parser.error("--tile-size cannot be negative") + modify_cfg(args) + + if args.pre_req: + shutil.rmtree(cfg.RAINFALL_FOLDER, ignore_errors=True) + if args.local_dem: + with timed_stage(f"local DEM/slope clip from {args.local_dem}"): + dem.clip_local_raster(args.local_dem, cfg.BOUNDARY_GEOJSON_PATH, cfg.DEMFILE_PATH, logger) + else: + with timed_stage("prerequisite downloader: downloads.dem.Downloader"): + dem.Downloader().main() + with timed_stage(f"loading DEM reference grid from {cfg.DEMFILE_PATH}"): + utils.tif_handler = GeoTIFFHandler(cfg.DEMFILE_PATH, logger) + with timed_stage("remaining prerequisite downloads"): + prereq(args) + else: + with timed_stage(f"loading DEM reference grid from {cfg.DEMFILE_PATH}"): + utils.tif_handler = GeoTIFFHandler(cfg.DEMFILE_PATH, logger) + + shutil.rmtree(cfg.RUNOFFS_FOLDER, ignore_errors=True) + with timed_stage("runoff/timeseries processing"): + if args.tile_size: + logger.info( + "Using tiled runoff/timeseries processing with tile_size=%s; runoff GeoZarr rasters are skipped in tiled mode", + args.tile_size, + ) + tiled_timeseries.TiledTimeSeries(args.tile_size).run() + else: + timeseries.TimeSeries().run() + logger.info("Done") + finally: + logger.info("Overall runtime: %s", format_elapsed(perf_counter() - overall_start)) diff --git a/computing/hydrology_gpu/utils.py b/computing/hydrology_gpu/utils.py new file mode 100644 index 00000000..eccdc59b --- /dev/null +++ b/computing/hydrology_gpu/utils.py @@ -0,0 +1,399 @@ +import json +from pathlib import Path +from shapely.geometry import shape +import rasterio +from rasterio.coords import BoundingBox +from rasterio import features +from rasterio.windows import Window, bounds as window_bounds, from_bounds, transform as window_transform +import os +import logging +import numpy.typing as nptypes +import cupy as cp +import numpy as np +from tqdm import tqdm +from typing import Any, List +import xarray as xr +import rioxarray +from rasterio.enums import Resampling +from rasterio.vrt import WarpedVRT + +def load_tif_image(file_path) -> nptypes.NDArray[Any]: + """Load a .tif image efficiently and return a NumPy array (float32).""" + with rasterio.open(file_path) as src: + image = src.read(1) # Read only the first band + print(f"Loaded Raster - Shape: {image.shape}, Dtype: {image.dtype}") + return image # Kept as NumPy array for easier slicing + +def make_logger(file_name, gpu_mem_usage=False, level=logging.INFO): + os.makedirs("logs", exist_ok=True) + + # Create logger + logger = logging.getLogger("mylog") + # without level, nothing gets print. I guess level is set to WARN etc + logger.setLevel(level) + + # File handler + fh = logging.FileHandler("logs/" + file_name) + fh.setLevel(level) + + # Console handler + ch = logging.StreamHandler() + ch.setLevel(level) + + # Formatter + formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + fh.setFormatter(formatter) + ch.setFormatter(formatter) + + # Add handlers + logger.addHandler(fh) + logger.addHandler(ch) + + if gpu_mem_usage: + class GPUMemUsageAdapter(logging.LoggerAdapter): + def __init__(self, logger, extra) -> None: + super().__init__(logger, extra) + # pynvml.nvmlInit() + # Get handle for the first GPU (device 0) + # Loop pynvml.nvmlDeviceGetCount() for multi-GPU setups + # self.GPU_HANDLE = pynvml.nvmlDeviceGetHandleByIndex(0) + self.mempool = cp.get_default_memory_pool() + # logger.info(f"pynvml initialized {self.GPU_HANDLE}") + + def process(self, msg, kwargs): + # mem_info = pynvml.nvmlDeviceGetMemoryInfo(self.GPU_HANDLE) + used_bytes = self.mempool.used_bytes() + # cp.cuda.runtime.memGetInfo() returns (free, total) bytes + free_device, total_device = cp.cuda.runtime.memGetInfo() + gpu_mem_str = f" | CuPy Used: {used_bytes / (1024**2):.0f}/{total_device / (1024**2):.0f} MiB" + return msg + gpu_mem_str, kwargs + + return GPUMemUsageAdapter(logger, {}) + + return logger + + +class GeoTIFFHandler: + """ + Saves tiff file with correct crs and transforms. + """ + + def __init__(self, tiff_path: str, logger: logging.Logger): + """ + Initialize by loading an existing GeoTIFF file and storing its properties. + """ + with rasterio.open(tiff_path) as src: + self.crs = src.crs + self.transform = src.transform + self.width = src.width + self.height = src.height + self.dtype = src.dtypes[0] # Get the data type of the first band + self.count = src.count # Number of bands + self.window = self._window_from_bounds + self.bounds = src.bounds + + # Read original data (optional) + # self.original_data = src.read(1) + + self.logger = logger + logger.info(f"Loaded TIFF: {tiff_path}") + logger.info(f"CRS: {self.crs}, Transform: {self.transform}, Size: {self.width}x{self.height}, Type: {self.dtype}, Window: {self.window}") + + def _window_from_bounds(self, left, bottom, right, top): + return from_bounds(left, bottom, right, top, transform=self.transform) + + def iter_windows(self, tile_size: int): + if tile_size <= 0: + raise ValueError("tile_size must be positive") + + for row_off in range(0, self.height, tile_size): + height = min(tile_size, self.height - row_off) + for col_off in range(0, self.width, tile_size): + width = min(tile_size, self.width - col_off) + yield Window(col_off, row_off, width, height) + + def for_window(self, window): + window = self._clipped_window(window, self.width, self.height) + if window is None: + raise ValueError("Window does not overlap the parent raster") + + child = self.__class__.__new__(self.__class__) + child.crs = self.crs + child.transform = window_transform(window, self.transform) + child.width = int(window.width) + child.height = int(window.height) + child.dtype = self.dtype + child.count = self.count + child.window = child._window_from_bounds + child.bounds = BoundingBox(*window_bounds(window, self.transform)) + child.logger = self.logger + return child + + @staticmethod + def _raster_paths(src_path): + path = Path(src_path) + if path.is_dir(): + paths = sorted( + child for child in path.rglob("*") + if child.is_file() and child.suffix.lower() in {".tif", ".tiff"} + ) + if not paths: + raise FileNotFoundError(f"No GeoTIFF files found under {path}") + return paths + return [path] + + @staticmethod + def _rounded_window(window): + col_off = int(np.floor(window.col_off)) + row_off = int(np.floor(window.row_off)) + col_stop = int(np.ceil(window.col_off + window.width)) + row_stop = int(np.ceil(window.row_off + window.height)) + + if col_stop <= col_off or row_stop <= row_off: + return None + + return Window(col_off, row_off, col_stop - col_off, row_stop - row_off) + + @staticmethod + def _clipped_window(window, width, height): + window = GeoTIFFHandler._rounded_window(window) + if window is None: + return None + + col_off = max(0, int(window.col_off)) + row_off = max(0, int(window.row_off)) + col_stop = min(width, int(window.col_off + window.width)) + row_stop = min(height, int(window.row_off + window.height)) + + if col_stop <= col_off or row_stop <= row_off: + return None + + return Window(col_off, row_off, col_stop - col_off, row_stop - row_off) + + def _load_raster_path_into(self, src_path, padded, fill_value): + with rasterio.open(src_path) as src: + if src.crs != self.crs: + raise ValueError(f"CRS mismatch for {src_path}; reproject first.") + + left = max(src.bounds.left, self.bounds.left) + right = min(src.bounds.right, self.bounds.right) + bottom = max(src.bounds.bottom, self.bounds.bottom) + top = min(src.bounds.top, self.bounds.top) + if left >= right or bottom >= top: + return False + + with WarpedVRT( + src, + crs=self.crs, + transform=self.transform, + width=self.width, + height=self.height, + resampling=Resampling.nearest, + ) as vrt: + data = vrt.read(1, masked=True) + + mask = np.ma.getmaskarray(data) + valid = ~mask + if not np.any(valid): + return True + + padded[valid] = data.filled(fill_value)[valid] + return True + + def save_tiff(self, new_data, output_path: str): + """ + Save a new TIFF file using the stored properties but with new data. + + :param new_data: 2D NumPy array containing new raster data. + :param output_path: Path to save the new TIFF file. + :param compression: Compression type for the TIFF file (default: "LZW"). + """ + if new_data.shape != (self.height, self.width): + raise ValueError(f"Data shape {new_data.shape} does not match the stored shape ({self.height}, {self.width})") + + # output_dir = os.path.dirname(output_path) + # if output_dir: + # os.makedirs(output_dir, exist_ok=True) + + gdal_options = { + 'tiled': True, + 'compress': 'ZSTD', # <--- Use Zstandard + # 'ZSTD_LEVEL': 6, # <--- Set to lowest level (1=fastest, 22=best) + 'ZSTD_LEVEL': 1, # <--- Set to lowest level (1=fastest, 22=best) + 'NUM_THREADS': 10 # <--- Essential for speed. we have 12 cores. + } + + with rasterio.open( + output_path, + "w", + driver="GTiff", + height=self.height, + width=self.width, + count=self.count, + dtype=new_data.dtype, + crs=self.crs, + transform=self.transform, + # compress=compression + **gdal_options + ) as dst: + dst.write(new_data, 1) # Write new data to band 1 + + self.logger.info(f"Saved new TIFF to {output_path}") + + def save_geozarr(self, new_data: np.ndarray, output_path: str, name:str="data"): + if new_data.shape != (self.height, self.width): + raise ValueError(f"Data shape {new_data.shape} does not match shape ({self.height}, {self.width})") + + # 1. Generate coordinates from transform (Top-Left + half-pixel offset) + x_coords = self.transform.c + (np.arange(self.width) + 0.5) * self.transform.a + y_coords = self.transform.f + (np.arange(self.height) + 0.5) * self.transform.e + + # 2. Wrap in Xarray + da = xr.DataArray( + new_data, + dims=("y", "x"), + coords={ + "y": y_coords, + "x": x_coords + }, + name=name + ) + + # 3. Attach CRS (Essential for GIS) + da.rio.write_crs(self.crs, inplace=True) + + # 4. Save to Zarr (Creates a directory at output_path) + da.to_dataset().to_zarr(output_path, mode="w", zarr_format=2) + + def save_geozarr_time(self, new_data: np.ndarray, time, output_path: str, name:str="data"): + if new_data.shape != (self.height, self.width): + raise ValueError(f"Data shape {new_data.shape} does not match shape ({self.height}, {self.width})") + + # 1. Generate coordinates from transform (Top-Left + half-pixel offset) + x_coords = self.transform.c + (np.arange(self.width) + 0.5) * self.transform.a + y_coords = self.transform.f + (np.arange(self.height) + 0.5) * self.transform.e + + # 2. Wrap in Xarray + da = xr.DataArray( + [new_data], + dims=("time", "y", "x"), + coords={ + "time": [time], + "y": y_coords, + "x": x_coords + }, + name=name + ) + + # 3. Attach CRS (Essential for GIS) + da.rio.write_crs(self.crs, inplace=True) + + # 4. Save to Zarr (Creates a directory at output_path) + if not os.path.exists(output_path): + da.to_dataset().to_zarr(output_path, mode="w", zarr_format=2) + else: + da.to_dataset().to_zarr(output_path, mode="a", zarr_format=2, append_dim="time") + + def save_multiband_tiff(self, output_path:str, data_arrays: list[nptypes.NDArray[Any]], compression="LZW"): + """ + Save multiple 2D NumPy arrays as bands in a single GeoTIFF. + data_arrays: list or tuple of 2D numpy arrays with identical shape. + """ + count = len(data_arrays) + dtype = data_arrays[0].dtype + + with rasterio.open( + output_path, + "w", + driver="GTiff", + height=self.height, + width=self.width, + count=count, + dtype=dtype, + crs=self.crs, + transform=self.transform, + compress=compression + ) as dst: + for i, arr in tqdm(enumerate(data_arrays, start=1)): + dst.write(arr, i) + + self.logger.info("Done writing to " + output_path) + + def load_with_padding_inner(self, crs, data: np.ndarray, src_bounds, fill_value=0): + if crs != self.crs: + raise ValueError("CRS mismatch") + + # Use your existing window logic with the bounds from the dict + ref_window = self.window(*src_bounds) + row_off, col_off = int(ref_window.row_off), int(ref_window.col_off) + + padded = np.full((self.height, self.width), fill_value, dtype=data.dtype) + + height, width = data.shape + + dest_row0 = max(0, row_off) + dest_col0 = max(0, col_off) + dest_row1 = min(self.height, dest_row0 + height) + dest_col1 = min(self.width, dest_col0 + width) + + # Paste source data if overlapping + src_row0 = max(0, -row_off) + src_col0 = max(0, -col_off) + src_row1 = src_row0 + (dest_row1 - dest_row0) + src_col1 = src_col0 + (dest_col1 - dest_col0) + + padded[dest_row0:dest_row1, dest_col0:dest_col1] = data[src_row0:src_row1, src_col0:src_col1] + + return padded + + def load_with_padding(self, src_path, fill_value=0): + paths = self._raster_paths(src_path) + + with rasterio.open(paths[0]) as first: + padded = np.full((self.height, self.width), fill_value, dtype=first.dtypes[0]) + + loaded = 0 + for path in paths: + if self._load_raster_path_into(path, padded, fill_value): + loaded += 1 + + if loaded == 0: + raise ValueError(f"No raster data from {src_path} overlaps the reference grid") + + self.logger.info("Loaded %s overlapping raster file(s) from %s", loaded, src_path) + return padded + + + def rasterize_by_id(self, shapes, fill_value=0): + """ + Rasterizes GeoJSON features using their 'id' property as the pixel value. + Not generic enough, i think. + """ + # with open(geojson_path, 'r') as f: + # geojson_data = json.load(f) + + # 1. Extract (geometry, value) pairs + # This creates a list like: [(geom1, 817103), (geom2, 818258), ...] + + # shapes = [ + # (shape(feature['geometry']), feature['properties']['id']) + # for feature in geojson_data['features'] + # ] + + # 2. Rasterize + # Note: Use a dtype large enough for your IDs (e.g., int32 or float32) + mask = rasterio.features.rasterize( + shapes=shapes, + out_shape=(self.height, self.width), + transform=self.transform, + fill=fill_value, + dtype='int32' + ) + + return mask + + +tif_handler: GeoTIFFHandler = None diff --git a/computing/hydrology_gpu/watershed_boundary.py b/computing/hydrology_gpu/watershed_boundary.py new file mode 100644 index 00000000..5ad89f16 --- /dev/null +++ b/computing/hydrology_gpu/watershed_boundary.py @@ -0,0 +1,447 @@ +import csv +import re +from pathlib import Path + +import geopandas as gpd +import pandas as pd +from shapely.geometry import GeometryCollection, MultiPolygon, Polygon + + +DEFAULT_WATERSHED_ROOT = Path("/media/disk3/raman/code/core-stack-backend/data/base_layers/tehsil_watersheds") +DEFAULT_BOUNDARY_OUTPUT_ROOT = Path("./tifs/tehsil_watersheds") +DEFAULT_PAN_INDIA_DOWNLOAD_BOUNDARY = Path("./data/base_layers/PanIndia_Boundaries/india_state_outer_no_islands.geojson") +PAN_INDIA_SLUG = "pan_india" + + +def normalize_name(value: str) -> str: + text = str(value).strip().lower().replace("&", " and ") + text = re.sub(r"[^a-z0-9]+", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +def slugify(value: str) -> str: + text = str(value).strip().lower() + text = re.sub(r"[^a-z0-9]+", "_", text) + return re.sub(r"_+", "_", text).strip("_") + + +def default_output_path(state: str, district: str, tehsil: str) -> Path: + return ( + DEFAULT_BOUNDARY_OUTPUT_ROOT + / slugify(state) + / slugify(district) + / f"{slugify(tehsil)}.geojson" + ) + + +def default_district_output_path(state: str, district: str) -> Path: + return ( + DEFAULT_BOUNDARY_OUTPUT_ROOT + / slugify(state) + / f"{slugify(district)}.geojson" + ) + + +def default_state_output_path(state: str) -> Path: + return ( + DEFAULT_BOUNDARY_OUTPUT_ROOT + / slugify(state) + / f"{slugify(state)}.geojson" + ) + + +def default_pan_india_output_path() -> Path: + return DEFAULT_BOUNDARY_OUTPUT_ROOT / PAN_INDIA_SLUG / f"{PAN_INDIA_SLUG}.geojson" + + +def download_boundary_path(boundary_path: str | Path) -> Path: + path = Path(boundary_path) + return path.with_name(f"{path.stem}_download_boundary{path.suffix}") + + +def manifest_path(root: Path) -> Path: + return root / "tehsil_watershed_manifest.csv" + + +def load_manifest(root: Path) -> list[dict]: + path = manifest_path(root) + if not path.exists(): + raise FileNotFoundError(f"Watershed manifest not found: {path}") + + with path.open(newline="") as f: + return list(csv.DictReader(f)) + + +def manifest_relative_output(root: Path, output_path: str) -> Path | None: + if not output_path: + return None + + raw_path = Path(output_path) + if raw_path.is_absolute() and raw_path.exists(): + return raw_path + + parts = raw_path.parts + if "tehsil_watersheds" in parts: + idx = parts.index("tehsil_watersheds") + candidate = root.joinpath(*parts[idx + 1 :]) + if candidate.exists(): + return candidate + + candidate = root / raw_path + if candidate.exists(): + return candidate + + return None + + +def fallback_gpkg_path(root: Path, state: str, district: str, tehsil: str) -> Path: + return root / slugify(state) / slugify(district) / f"{slugify(tehsil)}.gpkg" + + +def find_tehsil_watershed(root: Path, state: str, district: str, tehsil: str) -> tuple[Path, dict]: + wanted_state = normalize_name(state) + wanted_district = normalize_name(district) + wanted_tehsil = normalize_name(tehsil) + + for row in load_manifest(root): + if normalize_name(row.get("state", "")) != wanted_state: + continue + if normalize_name(row.get("district", "")) != wanted_district: + continue + if normalize_name(row.get("tehsil", "")) != wanted_tehsil: + continue + + if row.get("status") != "written": + raise ValueError(f"Watershed boundary is not available for {state}/{district}/{tehsil}: {row}") + + path = manifest_relative_output(root, row.get("output_path", "")) + if path is None: + path = fallback_gpkg_path(root, state, district, tehsil) + if not path.exists(): + raise FileNotFoundError(f"Manifest matched, but watershed file does not exist: {path}") + return path, row + + path = fallback_gpkg_path(root, state, district, tehsil) + if path.exists(): + return path, {"state": state, "district": district, "tehsil": tehsil, "status": "written"} + + raise FileNotFoundError( + "Could not find tehsil watershed for " + f"state={state!r}, district={district!r}, tehsil={tehsil!r} under {root}" + ) + + +def find_district_watersheds(root: Path, state: str, district: str) -> list[tuple[Path, dict]]: + wanted_state = normalize_name(state) + wanted_district = normalize_name(district) + matches = [] + + for row in load_manifest(root): + if normalize_name(row.get("state", "")) != wanted_state: + continue + if normalize_name(row.get("district", "")) != wanted_district: + continue + if row.get("status") != "written": + continue + + path = manifest_relative_output(root, row.get("output_path", "")) + if path is None: + path = fallback_gpkg_path(root, row.get("state", state), row.get("district", district), row.get("tehsil", "")) + if path.exists(): + matches.append((path, row)) + + if not matches: + raise FileNotFoundError( + f"Could not find written district watersheds for state={state!r}, district={district!r} under {root}" + ) + + return matches + + +def find_state_watersheds(root: Path, state: str) -> list[tuple[Path, dict]]: + wanted_state = normalize_name(state) + matches = [] + + for row in load_manifest(root): + if normalize_name(row.get("state", "")) != wanted_state: + continue + if row.get("status") != "written": + continue + + path = manifest_relative_output(root, row.get("output_path", "")) + if path is None: + path = fallback_gpkg_path( + root, + row.get("state", state), + row.get("district", ""), + row.get("tehsil", ""), + ) + if path.exists(): + matches.append((path, row)) + + if not matches: + raise FileNotFoundError(f"Could not find written state watersheds for state={state!r} under {root}") + + return matches + + +def find_pan_india_watersheds(root: Path) -> list[tuple[Path, dict]]: + matches = [] + + for row in load_manifest(root): + if row.get("status") != "written": + continue + + path = manifest_relative_output(root, row.get("output_path", "")) + if path is None: + path = fallback_gpkg_path( + root, + row.get("state", ""), + row.get("district", ""), + row.get("tehsil", ""), + ) + if path.exists(): + matches.append((path, row)) + + if not matches: + raise FileNotFoundError(f"Could not find written pan-India watersheds under {root}") + + return matches + + +def prepare_boundary_gdf(gdf, state: str, district: str, tehsil: str | None, source_path: Path): + if gdf.empty: + raise ValueError(f"Watershed file has no features: {source_path}") + + if gdf.crs is None: + gdf = gdf.set_crs("EPSG:4326") + elif gdf.crs.to_epsg() != 4326: + gdf = gdf.to_crs("EPSG:4326") + + gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty].copy() + if gdf.empty: + raise ValueError(f"Watershed file has no valid geometries: {source_path}") + + if "id" in gdf.columns: + gdf = gdf.rename(columns={"id": "source_id"}) + if "uid" in gdf.columns and "watershed_uid" not in gdf.columns: + gdf["watershed_uid"] = gdf["uid"].astype(str) + + gdf["selected_state"] = state + gdf["selected_district"] = district + if tehsil is not None: + gdf["selected_tehsil"] = tehsil + elif "TEHSIL" in gdf.columns: + gdf["selected_tehsil"] = gdf["TEHSIL"].astype(str) + gdf["source_gpkg"] = str(source_path) + return gdf + + +def polygon_parts(geometry): + if isinstance(geometry, Polygon): + return [geometry] + if isinstance(geometry, MultiPolygon): + return list(geometry.geoms) + if isinstance(geometry, GeometryCollection): + parts = [] + for part in geometry.geoms: + parts.extend(polygon_parts(part)) + return parts + return [] + + +def strip_inner_rings(geometry): + polygons = [] + for polygon in polygon_parts(geometry): + if not polygon.is_empty: + polygons.append(Polygon(polygon.exterior)) + + if not polygons: + raise ValueError("Could not build an outer boundary from the watershed geometries") + if len(polygons) == 1: + return polygons[0] + return MultiPolygon(polygons) + + +def write_download_boundary(gdf, destination: str | Path, state: str, district: str | None = None) -> Path: + destination = Path(destination) + union_geometry = gdf.geometry.unary_union + outer_geometry = strip_inner_rings(union_geometry) + properties = { + "id": 1, + "selected_state": state, + "boundary_role": "download_outer_boundary", + } + if district is not None: + properties["selected_district"] = district + + outer_gdf = gpd.GeoDataFrame( + [properties], + geometry=[outer_geometry], + crs=gdf.crs, + ) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(outer_gdf.to_json()) + return destination + + +def materialize_tehsil_boundary( + state: str, + district: str, + tehsil: str, + watershed_root: str | Path = DEFAULT_WATERSHED_ROOT, + output_path: str | Path | None = None, + overwrite: bool = True, +) -> tuple[Path, Path, int]: + root = Path(watershed_root) + source_path, _ = find_tehsil_watershed(root, state, district, tehsil) + destination = Path(output_path) if output_path else default_output_path(state, district, tehsil) + + if destination.exists() and not overwrite: + gdf_existing = gpd.read_file(destination) + return destination, source_path, len(gdf_existing) + + gdf = prepare_boundary_gdf(gpd.read_file(source_path), state, district, tehsil, source_path) + gdf["id"] = range(1, len(gdf) + 1) + + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(gdf.to_json()) + + return destination, source_path, len(gdf) + + +def materialize_district_boundary( + state: str, + district: str, + watershed_root: str | Path = DEFAULT_WATERSHED_ROOT, + output_path: str | Path | None = None, + overwrite: bool = True, +) -> tuple[Path, list[Path], int]: + root = Path(watershed_root) + matches = find_district_watersheds(root, state, district) + destination = Path(output_path) if output_path else default_district_output_path(state, district) + + if destination.exists() and not overwrite: + gdf_existing = gpd.read_file(destination) + return destination, [path for path, _ in matches], len(gdf_existing) + + frames = [] + source_paths = [] + for source_path, row in matches: + gdf = prepare_boundary_gdf( + gpd.read_file(source_path), + state=state, + district=district, + tehsil=row.get("tehsil") or None, + source_path=source_path, + ) + frames.append(gdf) + source_paths.append(source_path) + + if not frames: + raise ValueError(f"No non-empty watershed files found for {state}/{district}") + + combined = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs="EPSG:4326") + combined["id"] = range(1, len(combined) + 1) + + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(combined.to_json()) + write_download_boundary(combined, download_boundary_path(destination), state, district) + + return destination, source_paths, len(combined) + + +def materialize_state_boundary( + state: str, + watershed_root: str | Path = DEFAULT_WATERSHED_ROOT, + output_path: str | Path | None = None, + overwrite: bool = True, +) -> tuple[Path, list[Path], int]: + root = Path(watershed_root) + matches = find_state_watersheds(root, state) + destination = Path(output_path) if output_path else default_state_output_path(state) + + if destination.exists() and not overwrite: + gdf_existing = gpd.read_file(destination) + return destination, [path for path, _ in matches], len(gdf_existing) + + frames = [] + source_paths = [] + for source_path, row in matches: + district = row.get("district") or source_path.parent.name + tehsil = row.get("tehsil") or None + gdf = prepare_boundary_gdf( + gpd.read_file(source_path), + state=state, + district=district, + tehsil=tehsil, + source_path=source_path, + ) + frames.append(gdf) + source_paths.append(source_path) + + if not frames: + raise ValueError(f"No non-empty watershed files found for {state}") + + combined = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs="EPSG:4326") + combined["id"] = range(1, len(combined) + 1) + + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(combined.to_json()) + write_download_boundary(combined, download_boundary_path(destination), state) + + return destination, source_paths, len(combined) + + +def materialize_pan_india_boundary( + watershed_root: str | Path = DEFAULT_WATERSHED_ROOT, + output_path: str | Path | None = None, + overwrite: bool = True, + download_boundary_source: str | Path = DEFAULT_PAN_INDIA_DOWNLOAD_BOUNDARY, +) -> tuple[Path, list[Path], int]: + root = Path(watershed_root) + matches = find_pan_india_watersheds(root) + destination = Path(output_path) if output_path else default_pan_india_output_path() + + if destination.exists() and not overwrite: + download_destination = download_boundary_path(destination) + download_boundary_source = Path(download_boundary_source) + if not download_destination.exists() and download_boundary_source.exists(): + download_destination.parent.mkdir(parents=True, exist_ok=True) + download_destination.write_text(download_boundary_source.read_text()) + gdf_existing = gpd.read_file(destination) + return destination, [path for path, _ in matches], len(gdf_existing) + + frames = [] + source_paths = [] + for source_path, row in matches: + state = row.get("state") or source_path.parent.parent.name + district = row.get("district") or source_path.parent.name + tehsil = row.get("tehsil") or None + gdf = prepare_boundary_gdf( + gpd.read_file(source_path), + state=state, + district=district, + tehsil=tehsil, + source_path=source_path, + ) + frames.append(gdf) + source_paths.append(source_path) + + if not frames: + raise ValueError("No non-empty watershed files found for pan-India") + + combined = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs="EPSG:4326") + combined["id"] = range(1, len(combined) + 1) + + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(combined.to_json()) + + download_destination = download_boundary_path(destination) + download_boundary_source = Path(download_boundary_source) + if download_boundary_source.exists(): + download_destination.write_text(download_boundary_source.read_text()) + else: + write_download_boundary(combined, download_destination, PAN_INDIA_SLUG) + + return destination, source_paths, len(combined) diff --git a/computing/mws/runoff_gpu.py b/computing/mws/runoff_gpu.py new file mode 100644 index 00000000..33de8c4e --- /dev/null +++ b/computing/mws/runoff_gpu.py @@ -0,0 +1,284 @@ +import os +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace + +from nrm_app.celery import app + +from computing.config_loader import PROJECT_ROOT, PRECOMPUTED_TEHSIL_WATERSHED_DIR +from utilities.gee_utils import valid_gee_text + + +DATA_ROOT = PROJECT_ROOT / "data" +HYDROLOGY_OUTPUT_ROOT = DATA_ROOT / "hydrology_gpu" +LULC_BASE_DIR = DATA_ROOT / "base_layers" / "lulc" +DEFAULT_LOCAL_DEM_PATH = DATA_ROOT / "base_layers" / "slope" / "slope_india_30m_merged.tif" +DEFAULT_LOCAL_SOIL_PATH = DATA_ROOT / "base_layers" / "soil" / "hysogs_india_250m_4326.tif" +PAN_INDIA_DEFAULT_TILE_SIZE = 11264 +STATE_DEFAULT_TILE_SIZE = 4096 + + +def _is_blank(value): + return value is None or str(value).strip().lower() in {"", "none", "null"} + + +def _parse_bool(value): + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in {"1", "true", "yes", "y"} + + +def _parse_date(value, field_name): + if _is_blank(value): + raise ValueError(f"{field_name} is required") + try: + return datetime.strptime(str(value), "%Y-%m-%d").date() + except ValueError as exc: + raise ValueError(f"{field_name} must be in YYYY-MM-DD format") from exc + + +def _slug(value, fallback): + return valid_gee_text(str(value).strip().lower()) or fallback + + +def _resolve_dates(start_date, end_date, start_year=None, end_year=None): + if not _is_blank(start_date) and not _is_blank(end_date): + start = _parse_date(start_date, "start_date") + end = _parse_date(end_date, "end_date") + if end <= start: + raise ValueError("end_date must be after start_date") + return start.isoformat(), end.isoformat(), start.year, end.year if end.year > start.year else start.year + 1 + + if _is_blank(start_year) or _is_blank(end_year): + raise ValueError("Provide start_date and end_date, or start_year and end_year") + + start_year = int(start_year) + end_year = int(end_year) + if end_year < start_year: + raise ValueError("end_year must be greater than or equal to start_year") + + annual_end_year = end_year + 1 + return f"{start_year}-07-01", f"{annual_end_year}-07-01", start_year, annual_end_year + + +def _resolve_lulc_path(lulc_start_year, lulc_end_year): + expected_name = f"lulc_v3_{lulc_start_year}_{lulc_end_year}.tif" + lulc_path = LULC_BASE_DIR / expected_name + if not lulc_path.exists(): + raise FileNotFoundError(f"LULC raster not found for requested annual period: {lulc_path}") + return lulc_path + + +def _validate_scope(pan_india, state, district, tehsil): + if pan_india: + if any(not _is_blank(value) for value in (state, district, tehsil)): + raise ValueError("pan_india=true cannot be combined with state, district, or tehsil") + return "pan_india", None, None, None + + if _is_blank(state): + raise ValueError("state is required unless pan_india=true") + if not _is_blank(tehsil) and _is_blank(district): + raise ValueError("district is required when tehsil is provided") + + state = str(state).strip() + district = None if _is_blank(district) else str(district).strip() + tehsil = None if _is_blank(tehsil) else str(tehsil).strip() + + if tehsil: + return "tehsil", state, district, tehsil + if district: + return "district", state, district, None + return "state", state, None, None + + +def _scope_slug(scope, state, district, tehsil): + parts = [scope] + for value, fallback in ((state, "state"), (district, "district"), (tehsil, "tehsil")): + if not _is_blank(value): + parts.append(_slug(value, fallback)) + return "/".join(parts) + + +def _ensure_default_inputs_exist(): + for label, path in ( + ("Default local DEM/terrain raster", DEFAULT_LOCAL_DEM_PATH), + ("Default local soil raster", DEFAULT_LOCAL_SOIL_PATH), + ): + if not path.exists(): + raise FileNotFoundError(f"{label} not found: {path}") + + +def _build_runner_args( + *, + state, + district, + tehsil, + pan_india, + start_date, + end_date, + local_lulc_path, + annual_key, +): + scope, state, district, tehsil = _validate_scope(pan_india, state, district, tehsil) + slug_path = _scope_slug(scope, state, district, tehsil) + output_root = HYDROLOGY_OUTPUT_ROOT / slug_path / annual_key + boundary_output = output_root / "boundaries" / f"{slug_path.replace('/', '_')}.geojson" + + return SimpleNamespace( + pre_req=True, + boundary=None, + t=True, + start=start_date, + end=end_date, + rainfall_folder=str(output_root / "rainfall"), + runoffs_folder=str(output_root / "runoffs"), + demfile_path=str(output_root / "dem.tif"), + pan_india=pan_india, + state=state, + district=district, + tehsil=tehsil, + watershed_root=str(PRECOMPUTED_TEHSIL_WATERSHED_DIR), + watershed_boundary_output=str(boundary_output), + reuse_watershed_boundary=True, + local_dem=str(DEFAULT_LOCAL_DEM_PATH), + local_lulc=str(local_lulc_path), + lulc_source="indiasatv3", + local_soil=str(DEFAULT_LOCAL_SOIL_PATH), + tile_size=None, + ) + + +@contextmanager +def _working_directory(path): + previous = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def run_runoff_gpu_local( + *, + state=None, + district=None, + tehsil=None, + pan_india=False, + start_date=None, + end_date=None, + start_year=None, + end_year=None, +): + _ensure_default_inputs_exist() + pan_india = _parse_bool(pan_india) + start_date, end_date, lulc_start_year, lulc_end_year = _resolve_dates( + start_date=start_date, + end_date=end_date, + start_year=start_year, + end_year=end_year, + ) + local_lulc_path = _resolve_lulc_path(lulc_start_year, lulc_end_year) + annual_key = f"{lulc_start_year}_{lulc_end_year}" + args = _build_runner_args( + state=state, + district=district, + tehsil=tehsil, + pan_india=pan_india, + start_date=start_date, + end_date=end_date, + local_lulc_path=local_lulc_path, + annual_key=annual_key, + ) + + with _working_directory(PROJECT_ROOT): + from computing.hydrology_gpu import runoff as hydro_runoff + from computing.hydrology_gpu.downloads import dem + + hydro_runoff.validate_local_raster(args.local_lulc, "--local-lulc") + hydro_runoff.validate_local_raster(args.local_soil, "--local-soil") + hydro_runoff.resolve_boundary(args) + if args.tile_size is None: + if args.pan_india: + args.tile_size = PAN_INDIA_DEFAULT_TILE_SIZE + elif args.state and not args.district: + args.tile_size = STATE_DEFAULT_TILE_SIZE + else: + args.tile_size = 0 + hydro_runoff.modify_cfg(args) + hydro_runoff.cfg.DEMFILE_PATH = args.local_dem if args.pan_india else args.demfile_path + + hydro_runoff.shutil.rmtree(hydro_runoff.cfg.RAINFALL_FOLDER, ignore_errors=True) + if args.pan_india: + hydro_runoff.logger.info( + "Using existing pan-India DEM/slope raster directly: %s", + hydro_runoff.cfg.DEMFILE_PATH, + ) + else: + with hydro_runoff.timed_stage(f"local DEM/slope clip from {args.local_dem}"): + dem.clip_local_raster( + args.local_dem, + hydro_runoff.cfg.BOUNDARY_GEOJSON_PATH, + hydro_runoff.cfg.DEMFILE_PATH, + hydro_runoff.logger, + ) + + with hydro_runoff.timed_stage(f"loading DEM reference grid from {hydro_runoff.cfg.DEMFILE_PATH}"): + hydro_runoff.utils.tif_handler = hydro_runoff.GeoTIFFHandler( + hydro_runoff.cfg.DEMFILE_PATH, + hydro_runoff.logger, + ) + + with hydro_runoff.timed_stage("remaining prerequisite downloads"): + hydro_runoff.prereq(args) + + hydro_runoff.shutil.rmtree(hydro_runoff.cfg.RUNOFFS_FOLDER, ignore_errors=True) + with hydro_runoff.timed_stage("runoff/timeseries processing"): + if args.tile_size: + hydro_runoff.tiled_timeseries.TiledTimeSeries(args.tile_size).run() + else: + hydro_runoff.timeseries.TimeSeries().run() + + return { + "scope": "pan_india" if args.pan_india else ("tehsil" if args.tehsil else ("district" if args.district else "state")), + "state": args.state, + "district": args.district, + "tehsil": args.tehsil, + "start_date": start_date, + "end_date": end_date, + "annual_key": annual_key, + "lulc_path": str(local_lulc_path), + "rainfall_folder": args.rainfall_folder, + "runoffs_folder": args.runoffs_folder, + "boundary": args.boundary, + "microwatersheds": args.microwatersheds, + "timeseries_vector": str(hydro_runoff.cfg.TIMESERIES_VECTOR), + "tile_size": args.tile_size, + } + + +@app.task(bind=True) +def generate_runoff_gpu( + self, + state=None, + district=None, + tehsil=None, + pan_india=False, + start_date=None, + end_date=None, + start_year=None, + end_year=None, +): + _ = self + return run_runoff_gpu_local( + state=state, + district=district, + tehsil=tehsil, + pan_india=pan_india, + start_date=start_date, + end_date=end_date, + start_year=start_year, + end_year=end_year, + ) diff --git a/computing/tasks.py b/computing/tasks.py new file mode 100644 index 00000000..2f2299d9 --- /dev/null +++ b/computing/tasks.py @@ -0,0 +1,3 @@ +from computing.mws.runoff_gpu import generate_runoff_gpu + +__all__ = ["generate_runoff_gpu"] diff --git a/computing/urls.py b/computing/urls.py index d28f8311..b6becf3e 100644 --- a/computing/urls.py +++ b/computing/urls.py @@ -20,6 +20,7 @@ name="hydrology_fortnightly", ), path("hydrology_annual/", api.generate_annual_hydrology, name="hydrology_annual"), + path("runoff_gpu/", api.generate_runoff_gpu, name="runoff_gpu"), path("lulc_for_tehsil/", api.lulc_for_tehsil, name="lulc_for_tehsil"), path("lulc_v2_river_basin/", api.lulc_v2_river_basin, name="lulc_v2_river_basin"), path("lulc_v3_river_basin/", api.lulc_v3_river_basin, name="lulc_v3_river_basin"), diff --git a/installation/environment.yml b/installation/environment.yml index a566ba42..cbc60142 100644 --- a/installation/environment.yml +++ b/installation/environment.yml @@ -9,6 +9,7 @@ dependencies: - pip - setuptools=80 - wheel + - docker-compose=1.29.2 - djangorestframework=3.15.2 @@ -24,6 +25,7 @@ dependencies: - seaborn=0.13.0 - contourpy=1.2.0 + - rasterio - gdal=3.6.4 - geopandas=0.14.1 - fiona=1.9.5 @@ -48,7 +50,7 @@ dependencies: - django-timezone-field==7.2.1 - djangorestframework-simplejwt==5.5.0 - djangorestframework-api-key==3.1.0 - - earthengine-api==0.1.389 + - earthengine-api==1.6.12 - geoserver-rest==2.5.3 - geojson==3.1.0 - google-api-core==2.17.0 @@ -61,7 +63,6 @@ dependencies: - google-resumable-media==2.7.0 - googleapis-common-protos==1.62.0 - docker==5.0.3 - - docker-compose==1.29.2 - python-docx==1.1.0 - pymongo==3.11.0 - xmltodict==0.13.0 @@ -69,7 +70,6 @@ dependencies: - emoji - boto3 - speechrecognition==3.14.3 - - rasterio - pystac - tqdm - geemap @@ -79,4 +79,13 @@ dependencies: - pymannkendall - selenium - webdriver_manager - - celery \ No newline at end of file + - celery + - cupy-cuda12x[ctk]>=13.6.0 + - cucim-cu12>=26.2.0 + - geedim==1.9.1 + - natsort>=8.4.0 + - pydrive2>=1.21.3 + - rioxarray==0.19.0 + - xee==0.0.24 + - zarr==2.18.3 + - xarray From 3a4c4cbc60422f8792ab65e296d7d1cf6da696f7 Mon Sep 17 00:00:00 2001 From: ramank1137 Date: Fri, 5 Jun 2026 16:15:24 +0530 Subject: [PATCH 17/19] adding corrections to the runoff computation code. And adding code for downloading ET to be used in hydroogy --- computing/api.py | 40 ++ computing/hydrology_gpu/algorithms/runoff.py | 57 +-- .../algorithms/tiled_timeseries.py | 34 +- computing/hydrology_gpu/et_download.py | 411 ++++++++++++++++++ computing/hydrology_gpu/lulc_mapping.py | 10 +- computing/mws/et_download.py | 101 +++++ computing/tasks.py | 3 +- computing/urls.py | 1 + 8 files changed, 609 insertions(+), 48 deletions(-) create mode 100644 computing/hydrology_gpu/et_download.py create mode 100644 computing/mws/et_download.py diff --git a/computing/api.py b/computing/api.py index 22d9faca..c40e89b0 100644 --- a/computing/api.py +++ b/computing/api.py @@ -31,6 +31,7 @@ from .misc.restoration_opportunity import generate_restoration_opportunity from .misc.stream_order import generate_stream_order from .mws.generate_hydrology import generate_hydrology +from .mws.et_download import et_download as et_download_task from .mws.runoff_gpu import generate_runoff_gpu as generate_runoff_gpu_task from .utils import ( Geoserver, @@ -376,6 +377,45 @@ def generate_runoff_gpu(request): return Response({"Exception": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) +@api_view(["POST"]) +@schema(None) +def et_download(request): + print("Inside et_download") + try: + compute = _get_compute_mode(request) + if compute != "local": + raise ValueError("et_download currently supports compute='local' only") + + pan_india = request.data.get( + "pan_india", + request.data.get("pan-india", request.data.get("panIndia", False)), + ) + task = et_download_task.apply_async( + kwargs={ + "pan_india": pan_india, + "start_date": request.data.get("start_date"), + "end_date": request.data.get("end_date"), + "start_year": request.data.get("start_year"), + "end_year": request.data.get("end_year"), + "overwrite": request.data.get("overwrite", False), + }, + queue="nrm", + ) + return Response( + { + "Success": "et_download task initiated", + "task_id": task.id, + }, + status=status.HTTP_200_OK, + ) + except ValueError as e: + print("Invalid request in et_download api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + print("Exception in et_download api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @api_view(["POST"]) @schema(None) def lulc_for_tehsil(request): diff --git a/computing/hydrology_gpu/algorithms/runoff.py b/computing/hydrology_gpu/algorithms/runoff.py index 8b3784bd..d304b207 100644 --- a/computing/hydrology_gpu/algorithms/runoff.py +++ b/computing/hydrology_gpu/algorithms/runoff.py @@ -8,7 +8,13 @@ import numpy as np from ..downloads import rainfall from .. import config as cfg -from ..lulc_mapping import lulc_cache_key_for_timestamp, map_lulc_to_dynamic_world +from ..lulc_mapping import ( + lulc_cache_key_for_timestamp, + map_lulc_to_dynamic_world, + nodata_lulc_value_for_source, +) + +ANTECEDENT_DAYS = 5 class Runoff(GenericAlgorithm): """ @@ -19,7 +25,13 @@ def load_inputs(self): def load_sr_inputs(self): soil = cp.asarray(self.tif_handler.load_with_padding(cfg.SOIL_PATH)) - raw_lulc = cp.asarray(self.tif_handler.load_with_padding(cfg.LULC_PATH)) + source = getattr(cfg, "LULC_SOURCE", "dynamicworld") + raw_lulc = cp.asarray( + self.tif_handler.load_with_padding( + cfg.LULC_PATH, + fill_value=nodata_lulc_value_for_source(source), + ) + ) slope = cp.asarray(self.tif_handler.load_with_padding(cfg.DEMFILE_PATH)) return soil, raw_lulc, slope @@ -43,7 +55,7 @@ def compute_sr_for_timestamp(self, soil, raw_lulc, slope, timestamp): def main(self): # pathlib.Path(cfg.RUNOFFS_FOLDER).mkdir(parents=True, exist_ok=True) - images = [] + antecedent_images = [] P5_sum = None previous_Runoff = None soil, raw_lulc, slope = self.load_sr_inputs() @@ -56,32 +68,17 @@ def main(self): np.nan_to_num(img, copy=False) - images.append(img) - - if index == 4: - P5_sum = cp.sum(cp.stack([cp.asarray(i) for i in images[:5]]), axis=0) - # self.logger.info(f"4. Initial sum creation") - elif index >= 5: - new_img = cp.asarray(images[-1]) - # assert check_physical_range(new_img, "new_img", min_val=0.0) - old_img = cp.asarray(images[0]) - # assert check_physical_range(old_img, "old_img", min_val=0.0) - P5_sum = P5_sum - old_img + new_img - old_img = cp.asarray(images[-3]) - # assert check_physical_range(old_img, "old_img (for P_sum)", min_val=0.0) - # self.logger.info(f"4. Updated sums") - del old_img, new_img - old_img = images.pop(0) - del old_img - # self.logger.info(f"5. Pop and delete oldest image") - - if index >= 4: + if len(antecedent_images) == ANTECEDENT_DAYS: + if P5_sum is None: + P5_sum = cp.sum(cp.stack([cp.asarray(i) for i in antecedent_images]), axis=0) + # self.logger.info("4. Initial antecedent sum creation") + # if previous_Runoff is not None: # P_sum += previous_Runoff # P5_sum += previous_Runoff # self.logger.info("6. Add previous runoff") - P_sum = cp.asarray(images[-1]) + P_sum = cp.asarray(img) sr_key = self.sr_cache_key(file['timestamp']) if sr_key != current_sr_key: if sr1 is not None: @@ -131,14 +128,19 @@ def main(self): # self.logger.info("10. write runoff simulation result") # runoff_rasters.append(R.get()) self.tif_handler.save_geozarr_time(R.get(), file['timestamp'], cfg.RUNOFFS_FOLDER, "runoff") + old_img = cp.asarray(antecedent_images.pop(0)) + P5_sum = P5_sum - old_img + P_sum + antecedent_images.append(img) + del old_img, P_sum yield (img, R, file['timestamp']) else: + antecedent_images.append(img) yield (img, None, file['timestamp']) if sr1 is not None: del sr1, sr2, sr3 - del soil, raw_lulc, slope + del soil, raw_lulc, slope, antecedent_images, P5_sum self.logger.info("Done runoff sim") # return runoff_rasters @@ -474,7 +476,7 @@ def calculate_runoff_cupy(P, P5, m1, m2, m3, sr1, sr2, sr3): cond1_full = condP & condAMC & condQ_pos condP = (P >= Ia2) - condAMC = (P5 >= 0) & (P5 > 35) # Corresponds to the second check in GEE ternary + condAMC = (P5 > 35) & (P5 <= 52.5) condQ_pos = (Q2 >= 0) cond2_full = condP & condAMC & condQ_pos @@ -486,8 +488,7 @@ def calculate_runoff_cupy(P, P5, m1, m2, m3, sr1, sr2, sr3): del condP, condAMC, condQ_pos del Ia1, Ia2, Ia3 - # Antecedent Moisture Conditions based on P5 thresholds from GEE expression - # Note: P5>=0 check is included in the GEE expression, so we replicate it. + # Antecedent Moisture Conditions based on documented P5 thresholds. # Runoff non-negativity conditions (Q must be >= 0) from GEE expression diff --git a/computing/hydrology_gpu/algorithms/tiled_timeseries.py b/computing/hydrology_gpu/algorithms/tiled_timeseries.py index 8041c3b5..55c6959f 100644 --- a/computing/hydrology_gpu/algorithms/tiled_timeseries.py +++ b/computing/hydrology_gpu/algorithms/tiled_timeseries.py @@ -14,7 +14,7 @@ from .. import config as cfg from ..downloads import rainfall from . import GenericAlgorithm -from .runoff import Runoff, LegacyCodes +from .runoff import ANTECEDENT_DAYS, Runoff, LegacyCodes def per_watershed_sum_count(mws, raster): @@ -141,7 +141,7 @@ def process_tile(self, tile_handler, tile_index, tile_count): current_sr_key = None sr1 = sr2 = sr3 = None - images = [] + antecedent_images = [] p5_sum = None rainfall_iter = rainfall.LoadTile_from_database(tile_handler) @@ -150,20 +150,13 @@ def process_tile(self, tile_handler, tile_index, tile_count): np.nan_to_num(img, copy=False) self.add_to_series(tile_series_data1, watershed_cp, img, file["timestamp"]) - images.append(img) - if index == 4: - p5_sum = cp.zeros_like(cp.asarray(images[0], dtype=cp.float32)) - for previous_img in images[:5]: - p5_sum = p5_sum + cp.asarray(previous_img, dtype=cp.float32) - elif index >= 5: - new_img = cp.asarray(images[-1], dtype=cp.float32) - old_img = cp.asarray(images[0], dtype=cp.float32) - p5_sum = p5_sum - old_img + new_img - images.pop(0) - del new_img, old_img - - if index >= 4: - p_sum = cp.asarray(images[-1], dtype=cp.float32) + if len(antecedent_images) == ANTECEDENT_DAYS: + if p5_sum is None: + p5_sum = cp.zeros_like(cp.asarray(antecedent_images[0], dtype=cp.float32)) + for previous_img in antecedent_images: + p5_sum = p5_sum + cp.asarray(previous_img, dtype=cp.float32) + + p_sum = cp.asarray(img, dtype=cp.float32) sr_key = runoff_algo.sr_cache_key(file["timestamp"]) if sr_key != current_sr_key: if sr1 is not None: @@ -181,12 +174,17 @@ def process_tile(self, tile_handler, tile_index, tile_count): m3_cp = LegacyCodes.compute_M(sr3, p5_sum) runoff = LegacyCodes.calculate_runoff_cupy(p_sum, p5_sum, m1_cp, m2_cp, m3_cp, sr1, sr2, sr3) self.add_to_series(tile_series_data2, watershed_cp, runoff, file["timestamp"]) - del p_sum, m1_cp, m2_cp, m3_cp, runoff + old_img = cp.asarray(antecedent_images.pop(0), dtype=cp.float32) + p5_sum = p5_sum - old_img + p_sum + antecedent_images.append(img) + del p_sum, m1_cp, m2_cp, m3_cp, runoff, old_img + else: + antecedent_images.append(img) self.write_tile_series(tile_index, tile_series_data1, tile_series_data2) if sr1 is not None: del sr1, sr2, sr3 - del watershed_cp, soil, raw_lulc, slope, rainfall_iter, images, p5_sum, tile_series_data1, tile_series_data2 + del watershed_cp, soil, raw_lulc, slope, rainfall_iter, antecedent_images, p5_sum, tile_series_data1, tile_series_data2 cp.get_default_memory_pool().free_all_blocks() gc.collect() diff --git a/computing/hydrology_gpu/et_download.py b/computing/hydrology_gpu/et_download.py new file mode 100644 index 00000000..2bb45419 --- /dev/null +++ b/computing/hydrology_gpu/et_download.py @@ -0,0 +1,411 @@ +import datetime as dt +import json +import logging +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Iterable +from urllib.parse import urlencode + +import requests +from django.conf import settings + + +GESDISC_OTF_URL = "https://hydro1.gesdisc.eosdis.nasa.gov/daac-bin/OTF/HTTP_services.cgi" +EVAP_VARIABLE = "Evap_tavg" +FLDAS_FORMAT = "Y29nLw" + +FLDAS_CA_DAILY_SHORTNAME = "FLDAS_NOAHMP001_G_CA_D" +FLDAS_CA_DAILY_PRODUCT = "FLDAS_NOAHMP001_G_CA_D.001" +FLDAS_CA_DAILY_BBOX = "21,65.566,37.932,99.844" + +FLDAS_GLOBAL_MONTHLY_SHORTNAME = "FLDAS_NOAH01_C_GL_M" +FLDAS_GLOBAL_MONTHLY_PRODUCT = "FLDAS_NOAH01_C_GL_M.001" +PAN_INDIA_BBOX = "6,68,38,98" +DEFAULT_MAX_WORKERS = 3 + + +@dataclass(frozen=True) +class SourceManifest: + source: str + shortname: str + product: str + variable: str + bbox: str + temporal_resolution: str + output_folder: str + file_count: int + downloaded_count: int + skipped_count: int + failed_count: int + + +def _coerce_date(value, field_name): + if isinstance(value, dt.date): + return value + if value is None or str(value).strip().lower() in {"", "none", "null"}: + raise ValueError(f"{field_name} is required") + try: + return dt.datetime.strptime(str(value), "%Y-%m-%d").date() + except ValueError as exc: + raise ValueError(f"{field_name} must be in YYYY-MM-DD format") from exc + + +def _iter_days(start_date: dt.date, end_date: dt.date) -> Iterable[dt.date]: + current = start_date + while current < end_date: + yield current + current += dt.timedelta(days=1) + + +def _iter_month_starts(start_date: dt.date, end_date: dt.date) -> Iterable[dt.date]: + current = start_date.replace(day=1) + while current < end_date: + yield current + if current.month == 12: + current = current.replace(year=current.year + 1, month=1) + else: + current = current.replace(month=current.month + 1) + + +def _download_url(params): + return f"{GESDISC_OTF_URL}?{urlencode(params)}" + + +def _daily_ca_params(day: dt.date, bbox: str): + stamp = day.strftime("%Y%m%d") + return { + "FILENAME": ( + f"/data/FLDAS/{FLDAS_CA_DAILY_PRODUCT}/" + f"{day.year}/{day.month:02d}/" + f"{FLDAS_CA_DAILY_SHORTNAME}.A{stamp}.001.nc" + ), + "SERVICE": "L34RS_LDAS", + "BBOX": bbox, + "FORMAT": FLDAS_FORMAT, + "VERSION": "1.02", + "SHORTNAME": FLDAS_CA_DAILY_SHORTNAME, + "LABEL": f"{FLDAS_CA_DAILY_SHORTNAME}.A{stamp}.001.nc.SUB.tif", + "DATASET_VERSION": "001", + "VARIABLES": EVAP_VARIABLE, + } + + +def _monthly_global_params(month_start: dt.date, bbox: str): + stamp = month_start.strftime("%Y%m") + return { + "FILENAME": ( + f"/data/FLDAS/{FLDAS_GLOBAL_MONTHLY_PRODUCT}/" + f"{month_start.year}/" + f"{FLDAS_GLOBAL_MONTHLY_SHORTNAME}.A{stamp}.001.nc" + ), + "VARIABLES": EVAP_VARIABLE, + "FORMAT": FLDAS_FORMAT, + "LABEL": f"{FLDAS_GLOBAL_MONTHLY_SHORTNAME}.A{stamp}.001.nc.SUB.tif", + "SERVICE": "L34RS_LDAS", + "DATASET_VERSION": "001", + "VERSION": "1.02", + "SHORTNAME": FLDAS_GLOBAL_MONTHLY_SHORTNAME, + "BBOX": bbox, + } + + +def _gesdisc_auth(): + username = getattr(settings, "USERNAME_GESDISC", None) + password = getattr(settings, "PASSWORD_GESDISC", None) + if not username or not password: + raise ValueError("USERNAME_GESDISC and PASSWORD_GESDISC are required") + return username, password + + +def _raise_if_html_response(response, first_chunk: bytes, url: str): + content_type = response.headers.get("Content-Type", "").lower() + stripped = first_chunk.lstrip().lower() + if "text/html" not in content_type and not stripped.startswith((b" 0 and not overwrite: + logger.info("Skipping existing ET raster: %s", output_path) + return "skipped" + + output_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = output_path.with_suffix(f"{output_path.suffix}.tmp") + logger.info("Downloading ET raster: %s", output_path) + + try: + with session.get(url, stream=True, timeout=(30, 300)) as response: + if response.status_code != 200: + body = response.content[:500].decode("utf-8", errors="replace") + raise RuntimeError( + f"GES DISC download failed with HTTP {response.status_code}. " + f"URL={url}. Response starts with: {body}" + ) + + wrote_any = False + with tmp_path.open("wb") as handle: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if not chunk: + continue + if not wrote_any: + _raise_if_html_response(response, chunk, url) + wrote_any = True + handle.write(chunk) + + if not wrote_any or tmp_path.stat().st_size == 0: + raise RuntimeError(f"GES DISC returned an empty raster for URL={url}") + + tmp_path.replace(output_path) + return "downloaded" + except Exception: + tmp_path.unlink(missing_ok=True) + raise + + +def _write_json(path: Path, payload): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + + +def _download_record(record, auth, overwrite, logger, max_attempts, retry_delay_seconds): + session = requests.Session() + session.auth = auth + try: + for attempt in range(1, max_attempts + 1): + record["attempts"] = attempt + try: + status = _download_file( + session=session, + url=record["url"], + output_path=Path(record["path"]), + overwrite=overwrite, + logger=logger, + ) + record["status"] = status + record.pop("error", None) + return status + except Exception as exc: + record["error"] = str(exc) + logger.warning( + "ET raster download failed for %s on attempt %s/%s: %s", + record["path"], + attempt, + max_attempts, + exc, + ) + if attempt < max_attempts: + time.sleep(retry_delay_seconds * attempt) + + record["status"] = "failed" + logger.error( + "Skipping ET raster after %s failed attempts: %s", + max_attempts, + record["path"], + ) + return "failed" + finally: + session.close() + + +def _download_records(auth, records, overwrite, logger, max_attempts, retry_delay_seconds, max_workers): + downloaded_count = 0 + skipped_count = 0 + failed_count = 0 + if not records: + return downloaded_count, skipped_count, failed_count + + worker_count = min(max_workers, len(records)) + logger.info("Downloading %s ET rasters with %s workers", len(records), worker_count) + with ThreadPoolExecutor(max_workers=worker_count) as executor: + futures = [ + executor.submit( + _download_record, + record, + auth, + overwrite, + logger, + max_attempts, + retry_delay_seconds, + ) + for record in records + ] + for future in as_completed(futures): + status = future.result() + if status == "downloaded": + downloaded_count += 1 + elif status == "skipped": + skipped_count += 1 + elif status == "failed": + failed_count += 1 + + return downloaded_count, skipped_count, failed_count + + +def download_pan_india_et_assets( + output_root, + start_date, + end_date, + *, + overwrite=False, + fldas_ca_daily_bbox=FLDAS_CA_DAILY_BBOX, + fldas_global_monthly_bbox=PAN_INDIA_BBOX, + max_attempts=5, + retry_delay_seconds=5, + max_workers=DEFAULT_MAX_WORKERS, + logger=None, +): + """ + Download source ET rasters used by the GEE hydrology flow. + + The Central Asia FLDAS daily product is stored for the northern/high-resolution + branch. The global monthly FLDAS product is stored for the pan-India fallback + branch currently named GLDAS in utilities/constants.py. + """ + logger = logger or logging.getLogger(__name__) + start_date = _coerce_date(start_date, "start_date") + end_date = _coerce_date(end_date, "end_date") + if end_date <= start_date: + raise ValueError("end_date must be after start_date") + if max_attempts < 1: + raise ValueError("max_attempts must be at least 1") + if max_workers < 1: + raise ValueError("max_workers must be at least 1") + + output_root = Path(output_root) + et_root = output_root / "et" + daily_root = et_root / "fldas_ca_daily" / "daily" + monthly_root = et_root / "fldas_global_monthly" / "monthly" + + daily_records = [] + for day in _iter_days(start_date, end_date): + params = _daily_ca_params(day, fldas_ca_daily_bbox) + daily_records.append( + { + "date": day.isoformat(), + "path": str(daily_root / f"{day:%Y%m%d}.tif"), + "url": _download_url(params), + } + ) + + monthly_records = [] + for month_start in _iter_month_starts(start_date, end_date): + params = _monthly_global_params(month_start, fldas_global_monthly_bbox) + monthly_records.append( + { + "month": month_start.strftime("%Y-%m"), + "path": str(monthly_root / f"{month_start:%Y%m}.tif"), + "url": _download_url(params), + } + ) + + auth = _gesdisc_auth() + + logger.info( + "Downloading local ET assets for [%s, %s): %s daily CA rasters, %s global monthly rasters", + start_date, + end_date, + len(daily_records), + len(monthly_records), + ) + daily_downloaded, daily_skipped, daily_failed = _download_records( + auth, + daily_records, + overwrite, + logger, + max_attempts, + retry_delay_seconds, + max_workers, + ) + monthly_downloaded, monthly_skipped, monthly_failed = _download_records( + auth, + monthly_records, + overwrite, + logger, + max_attempts, + retry_delay_seconds, + max_workers, + ) + + sources = [ + SourceManifest( + source="fldas_ca_daily", + shortname=FLDAS_CA_DAILY_SHORTNAME, + product=FLDAS_CA_DAILY_PRODUCT, + variable=EVAP_VARIABLE, + bbox=fldas_ca_daily_bbox, + temporal_resolution="daily", + output_folder=str(daily_root), + file_count=len(daily_records), + downloaded_count=daily_downloaded, + skipped_count=daily_skipped, + failed_count=daily_failed, + ), + SourceManifest( + source="fldas_global_monthly", + shortname=FLDAS_GLOBAL_MONTHLY_SHORTNAME, + product=FLDAS_GLOBAL_MONTHLY_PRODUCT, + variable=EVAP_VARIABLE, + bbox=fldas_global_monthly_bbox, + temporal_resolution="monthly", + output_folder=str(monthly_root), + file_count=len(monthly_records), + downloaded_count=monthly_downloaded, + skipped_count=monthly_skipped, + failed_count=monthly_failed, + ), + ] + + manifest = { + "created_at": dt.datetime.now(dt.timezone.utc).isoformat(), + "output_root": str(output_root), + "et_root": str(et_root), + "date_range": { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "end_date_is_exclusive": True, + }, + "purpose": ( + "Local source rasters for replacing GEE evapotranspiration inputs in " + "computing/mws/generate_hydrology.py." + ), + "retry_policy": { + "max_attempts": max_attempts, + "retry_delay_seconds": retry_delay_seconds, + "failed_records_are_skipped": True, + }, + "download_policy": { + "max_workers": max_workers, + }, + "sources": [asdict(source) for source in sources], + "records": { + "fldas_ca_daily": daily_records, + "fldas_global_monthly": monthly_records, + }, + } + _write_json(et_root / "manifest.json", manifest) + _write_json( + et_root / "fldas_ca_daily" / "metadata.json", + { + "source": asdict(sources[0]), + "records": daily_records, + }, + ) + _write_json( + et_root / "fldas_global_monthly" / "metadata.json", + { + "source": asdict(sources[1]), + "records": monthly_records, + }, + ) + + return manifest diff --git a/computing/hydrology_gpu/lulc_mapping.py b/computing/hydrology_gpu/lulc_mapping.py index ace57b89..bda70140 100644 --- a/computing/hydrology_gpu/lulc_mapping.py +++ b/computing/hydrology_gpu/lulc_mapping.py @@ -76,6 +76,13 @@ def lulc_cache_key_for_timestamp(source: str, timestamp) -> str: return season_from_month(month_from_timestamp(timestamp)) +def nodata_lulc_value_for_source(source: str) -> int: + source = normalize_lulc_source(source) + if source == LULC_SOURCE_DYNAMICWORLD: + return DW_SHRUB_AND_SCRUB + return INDIASAT_BACKGROUND + + def map_lulc_to_dynamic_world(raw_lulc: cp.ndarray, source: str, timestamp) -> cp.ndarray: source = normalize_lulc_source(source) if source == LULC_SOURCE_DYNAMICWORLD: @@ -86,7 +93,8 @@ def map_lulc_to_dynamic_world(raw_lulc: cp.ndarray, source: str, timestamp) -> c def map_indiasatv3_to_dynamic_world(raw_lulc: cp.ndarray, season: str) -> cp.ndarray: - mapped = cp.full(raw_lulc.shape, DW_WATER, dtype=cp.uint8) + # Background/unknown classes stay shrub/scrub instead of becoming water. + mapped = cp.full(raw_lulc.shape, DW_SHRUB_AND_SCRUB, dtype=cp.uint8) mapped = cp.where(raw_lulc == INDIASAT_BUILT_UP, DW_BUILT, mapped) mapped = cp.where(raw_lulc == INDIASAT_TREE_FORESTS, DW_TREES, mapped) diff --git a/computing/mws/et_download.py b/computing/mws/et_download.py new file mode 100644 index 00000000..3b5e97e3 --- /dev/null +++ b/computing/mws/et_download.py @@ -0,0 +1,101 @@ +import logging + +from nrm_app.celery import app + +from computing.config_loader import PROJECT_ROOT +from computing.hydrology_gpu.et_download import download_pan_india_et_assets + +from .runoff_gpu import HYDROLOGY_OUTPUT_ROOT, _parse_bool, _resolve_dates + + +def _make_logger(): + log_dir = PROJECT_ROOT / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + + logger = logging.getLogger("hydrology_gpu.et_download") + logger.setLevel(logging.INFO) + logger.propagate = False + if logger.handlers: + return logger + + formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + file_handler = logging.FileHandler(log_dir / "et_download.log") + file_handler.setLevel(logging.INFO) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + stream_handler = logging.StreamHandler() + stream_handler.setLevel(logging.INFO) + stream_handler.setFormatter(formatter) + logger.addHandler(stream_handler) + + return logger + + +def run_et_download_local( + *, + pan_india=False, + start_date=None, + end_date=None, + start_year=None, + end_year=None, + overwrite=False, +): + pan_india = _parse_bool(pan_india) + if not pan_india: + raise ValueError("et_download currently supports pan_india=true only") + + start_date, end_date, annual_start_year, annual_end_year = _resolve_dates( + start_date=start_date, + end_date=end_date, + start_year=start_year, + end_year=end_year, + ) + annual_key = f"{annual_start_year}_{annual_end_year}" + output_root = HYDROLOGY_OUTPUT_ROOT / "pan_india" / annual_key + output_root.mkdir(parents=True, exist_ok=True) + + logger = _make_logger() + manifest = download_pan_india_et_assets( + output_root=output_root, + start_date=start_date, + end_date=end_date, + overwrite=_parse_bool(overwrite), + logger=logger, + ) + + return { + "scope": "pan_india", + "start_date": start_date, + "end_date": end_date, + "annual_key": annual_key, + "output_root": str(output_root), + "et_root": manifest["et_root"], + "sources": manifest["sources"], + "manifest": str(output_root / "et" / "manifest.json"), + } + + +@app.task(bind=True) +def et_download( + self, + pan_india=False, + start_date=None, + end_date=None, + start_year=None, + end_year=None, + overwrite=False, +): + _ = self + return run_et_download_local( + pan_india=pan_india, + start_date=start_date, + end_date=end_date, + start_year=start_year, + end_year=end_year, + overwrite=overwrite, + ) diff --git a/computing/tasks.py b/computing/tasks.py index 2f2299d9..98809f5e 100644 --- a/computing/tasks.py +++ b/computing/tasks.py @@ -1,3 +1,4 @@ +from computing.mws.et_download import et_download from computing.mws.runoff_gpu import generate_runoff_gpu -__all__ = ["generate_runoff_gpu"] +__all__ = ["et_download", "generate_runoff_gpu"] diff --git a/computing/urls.py b/computing/urls.py index b6becf3e..cf8828da 100644 --- a/computing/urls.py +++ b/computing/urls.py @@ -21,6 +21,7 @@ ), path("hydrology_annual/", api.generate_annual_hydrology, name="hydrology_annual"), path("runoff_gpu/", api.generate_runoff_gpu, name="runoff_gpu"), + path("et_download/", api.et_download, name="et_download"), path("lulc_for_tehsil/", api.lulc_for_tehsil, name="lulc_for_tehsil"), path("lulc_v2_river_basin/", api.lulc_v2_river_basin, name="lulc_v2_river_basin"), path("lulc_v3_river_basin/", api.lulc_v3_river_basin, name="lulc_v3_river_basin"), From aef2d0927578f830175130f2dad3f9a21f9753ca Mon Sep 17 00:00:00 2001 From: ramank1137 Date: Fri, 5 Jun 2026 16:53:18 +0530 Subject: [PATCH 18/19] removing legacy code --- computing/config.yaml | 4 + computing/config_loader.py | 4 + computing/hydrology_gpu/config/__init__.py | 79 +++++----- computing/hydrology_gpu/config/config.toml | 26 ---- computing/hydrology_gpu/downloads/__init__.py | 23 ++- computing/hydrology_gpu/downloads/lulc.py | 1 - computing/hydrology_gpu/downloads/rainfall.py | 1 - computing/hydrology_gpu/downloads/soil.py | 1 - computing/hydrology_gpu/runoff.py | 135 +++--------------- computing/hydrology_gpu/watershed_boundary.py | 7 +- computing/mws/runoff_gpu.py | 13 +- 11 files changed, 104 insertions(+), 190 deletions(-) delete mode 100644 computing/hydrology_gpu/config/config.toml diff --git a/computing/config.yaml b/computing/config.yaml index 46109991..6b0896ad 100644 --- a/computing/config.yaml +++ b/computing/config.yaml @@ -31,6 +31,10 @@ base_layers: source: manual note: Pan-India slope raster used for slope/terrain classification + - path: data/base_layers/soil/hysogs_india_250m_4326.tif + source: manual + note: Pan-India hydrologic soil group raster for runoff calculation + - path: data/base_layers/AEZs/Agro_Ecological_Regions.shp source: manual note: Agro-Ecological Zone boundaries for AEZ-based cluster lookup diff --git a/computing/config_loader.py b/computing/config_loader.py index d1c8ec10..3489bede 100644 --- a/computing/config_loader.py +++ b/computing/config_loader.py @@ -46,6 +46,10 @@ def _output_entry(module: str, index: int = 0) -> dict: "data/base_layers/slope/slope_india_30m_merged.tif" )["path"] +SOIL_RASTER_PATH: Path = PROJECT_ROOT / _find_input( + "data/base_layers/soil/hysogs_india_250m_4326.tif" +)["path"] + AEZ_VECTOR_PATH: Path = PROJECT_ROOT / _find_input( "data/base_layers/AEZs/Agro_Ecological_Regions.shp" )["path"] diff --git a/computing/hydrology_gpu/config/__init__.py b/computing/hydrology_gpu/config/__init__.py index 13180a08..4fa6a67d 100644 --- a/computing/hydrology_gpu/config/__init__.py +++ b/computing/hydrology_gpu/config/__init__.py @@ -1,38 +1,49 @@ -from pathlib import Path +import os try: - import tomllib + from django.conf import settings except ModuleNotFoundError: - tomllib = None - - -def _loads_config(text): - if tomllib is not None: - return tomllib.loads(text) - - values = {} - for raw_line in text.splitlines(): - line = raw_line.split("#", 1)[0].strip() - if not line: - continue - key, raw_value = line.split("=", 1) - key = key.strip() - raw_value = raw_value.strip() - if raw_value.startswith(("'", '"')) and raw_value.endswith(("'", '"')): - values[key] = raw_value[1:-1] - else: - try: - values[key] = int(raw_value) - except ValueError: - values[key] = raw_value - return values - - -_config_path = Path(__file__).with_name("config.toml") -_values = _loads_config(_config_path.read_text()) - -globals().update(_values) - -# The current runner uses the same vector file as both the region boundary and -# the microwatershed feature collection unless the CLI overrides it. + settings = None + +from computing.config_loader import ( + LULC_BASE_DIR, + PROJECT_ROOT, + SOIL_RASTER_PATH, + TERRAIN_RASTER_PATH, +) + + +def _env_or_setting(name, default=""): + value = None + if settings is not None: + try: + value = getattr(settings, name, None) + except Exception: + value = None + if value in (None, ""): + value = os.environ.get(name, default) + return str(value or "").strip() + + +DATA_ROOT = PROJECT_ROOT / "data" + +# Optional. Do not commit a real project id; use env/settings when needed. +GEE_PROJECT_NAME = _env_or_setting("GEE_PROJECT_NAME") +GEE_SCALE = 30 + +# API/Celery wrappers set these per request before running hydrology. +BOUNDARY_GEOJSON_PATH = "" MICROWATERSHEDS_PATH = BOUNDARY_GEOJSON_PATH +DEMFILE_PATH = "" +SOIL_PATH = str(SOIL_RASTER_PATH) +LULC_PATH = str(LULC_BASE_DIR / "lulc_v3_2024_2025.tif") +LULC_SOURCE = "indiasatv3" +INDIASATV3_LULC_PATH = LULC_PATH + +RAINFALL_FOLDER = "" +RUNOFFS_FOLDER = "" +TIMESERIES_VECTOR = "" + +ARG_START_DATE = "2017-07-01" +ARG_END_DATE = "2025-06-18" +TILE_SIZE = None diff --git a/computing/hydrology_gpu/config/config.toml b/computing/hydrology_gpu/config/config.toml deleted file mode 100644 index e105009b..00000000 --- a/computing/hydrology_gpu/config/config.toml +++ /dev/null @@ -1,26 +0,0 @@ -# configuration values for the project - -GEE_PROJECT_NAME = 'raman-461708' # Google Earth Engine project name - -GEE_SCALE = 30 # in meters - -# Path to the GeoJSON file defining the region of interest -# Currently, this can be provided with command line arguments. -BOUNDARY_GEOJSON_PATH = './tifs/delhi.geojson' - -# The following tifs will be downloaded by scripts. -DEMFILE_PATH = './tifs/dem.tif' -SOIL_PATH = './data/base_layers/soil/hysogs_india_250m_4326.tif' -LULC_PATH = './data/base_layers/lulc/lulc_v3_2024_2025.tif' -LULC_SOURCE = 'indiasatv3' -INDIASATV3_LULC_PATH = './data/base_layers/lulc/lulc_v3_2024_2025.tif' - -# Folder where rainfall data from GEE is downloaded to -RAINFALL_FOLDER = './tifs/rainfall3/' -RUNOFFS_FOLDER = './tifs/runoffs3/' - -# The output file. -TIMESERIES_VECTOR = './tifs/timeseries.geojson' - -ARG_START_DATE = '2017-07-01' -ARG_END_DATE = '2025-06-18' diff --git a/computing/hydrology_gpu/downloads/__init__.py b/computing/hydrology_gpu/downloads/__init__.py index ce915dda..0336b2b7 100644 --- a/computing/hydrology_gpu/downloads/__init__.py +++ b/computing/hydrology_gpu/downloads/__init__.py @@ -2,7 +2,6 @@ import shutil from pathlib import Path from logging import Logger -from argparse import ArgumentParser from dataclasses import dataclass import ee import geedim @@ -16,7 +15,27 @@ from .. import utils from ..utils import GeoTIFFHandler -ee.Initialize(project=cfg.GEE_PROJECT_NAME) + +def _initialize_earth_engine(): + project = getattr(cfg, "GEE_PROJECT_NAME", "") + try: + if project: + ee.Initialize(project=project) + else: + ee.Initialize() + return + except Exception: + pass + + try: + from utilities.gee_utils import ee_initialize_safe + + ee_initialize_safe() + except Exception as exc: + print(f"Skipping Earth Engine initialization: {exc}") + + +_initialize_earth_engine() class GenericDownloader: # Singleton pattern diff --git a/computing/hydrology_gpu/downloads/lulc.py b/computing/hydrology_gpu/downloads/lulc.py index ef643e98..54ffce88 100644 --- a/computing/hydrology_gpu/downloads/lulc.py +++ b/computing/hydrology_gpu/downloads/lulc.py @@ -1,4 +1,3 @@ -from argparse import ArgumentParser import geemap import xarray from .. import config as cfg diff --git a/computing/hydrology_gpu/downloads/rainfall.py b/computing/hydrology_gpu/downloads/rainfall.py index 8a709a08..622dd9f7 100644 --- a/computing/hydrology_gpu/downloads/rainfall.py +++ b/computing/hydrology_gpu/downloads/rainfall.py @@ -5,7 +5,6 @@ import shutil import threading import time -from argparse import ArgumentParser from collections import deque from concurrent.futures import ThreadPoolExecutor from threading import Lock diff --git a/computing/hydrology_gpu/downloads/soil.py b/computing/hydrology_gpu/downloads/soil.py index d2b8bf60..57b448b5 100644 --- a/computing/hydrology_gpu/downloads/soil.py +++ b/computing/hydrology_gpu/downloads/soil.py @@ -1,4 +1,3 @@ -from argparse import ArgumentParser from .. import config as cfg import xarray from . import GenericDownloader, ee diff --git a/computing/hydrology_gpu/runoff.py b/computing/hydrology_gpu/runoff.py index 73356532..f7f25266 100644 --- a/computing/hydrology_gpu/runoff.py +++ b/computing/hydrology_gpu/runoff.py @@ -1,28 +1,22 @@ import shutil -from argparse import ArgumentParser from contextlib import contextmanager from pathlib import Path from time import perf_counter -import ee -from .downloads import dem, lulc, soil +from .downloads import lulc, soil from .algorithms import tiled_timeseries, timeseries from .downloads import rainfall from . import config as cfg -from .lulc_mapping import LULC_SOURCE_INDIASATV3, LULC_SOURCES from .utils import GeoTIFFHandler, make_logger from . import utils from .watershed_boundary import ( DEFAULT_WATERSHED_ROOT, - PAN_INDIA_SLUG, download_boundary_path, materialize_district_boundary, materialize_pan_india_boundary, materialize_state_boundary, materialize_tehsil_boundary, - slugify, ) -parser = ArgumentParser() logger = make_logger("runoff_only_with_rainfall.log") PAN_INDIA_DEFAULT_TILE_SIZE = 11264 STATE_DEFAULT_TILE_SIZE = 4096 @@ -51,25 +45,13 @@ def timed_stage(name): logger.info("Finished %s in %s", name, format_elapsed(perf_counter() - start_time)) -def selected_output_folder(root, args): - if getattr(args, "pan_india", False): - return str(Path(root) / PAN_INDIA_SLUG) - - path = Path(root) / slugify(args.state) - if args.district: - path = path / slugify(args.district) - if args.tehsil: - path = path / slugify(args.tehsil) - return str(path) - - def validate_local_raster(path_value, option_name): if not path_value: return path = Path(path_value) if not path.exists(): - parser.error(f"{option_name} path does not exist: {path}") + raise ValueError(f"{option_name} path does not exist: {path}") if path.is_dir(): has_tif = any( @@ -77,7 +59,7 @@ def validate_local_raster(path_value, option_name): for child in path.rglob("*") ) if not has_tif: - parser.error(f"{option_name} directory has no GeoTIFF files: {path}") + raise ValueError(f"{option_name} directory has no GeoTIFF files: {path}") def resolve_boundary(args): @@ -85,7 +67,7 @@ def resolve_boundary(args): if not any(selectors): return if args.pan_india and any([args.state, args.district, args.tehsil]): - parser.error("--pan-india cannot be combined with --state, --district, or --tehsil") + raise ValueError("--pan-india cannot be combined with --state, --district, or --tehsil") if args.pan_india: microwatersheds_path, source_paths, feature_count = materialize_pan_india_boundary( watershed_root=args.watershed_root, @@ -95,10 +77,6 @@ def resolve_boundary(args): boundary_path = download_boundary_path(microwatersheds_path) args.boundary = str(boundary_path) args.microwatersheds = str(microwatersheds_path) - if args.rainfall_folder is None: - args.rainfall_folder = selected_output_folder("./tifs/rainfall_pan_india", args) - if args.runoffs_folder is None: - args.runoffs_folder = selected_output_folder("./tifs/runoffs_pan_india", args) logger.info( "Resolved pan-India watershed boundary: sources=%s download_boundary=%s microwatersheds=%s features=%s", len(source_paths), @@ -109,9 +87,9 @@ def resolve_boundary(args): return if not args.state: - parser.error("--state is required for watershed boundary lookup") + raise ValueError("--state is required for watershed boundary lookup") if args.tehsil and not args.district: - parser.error("--district is required when --tehsil is provided") + raise ValueError("--district is required when --tehsil is provided") if not args.district: microwatersheds_path, source_paths, feature_count = materialize_state_boundary( @@ -123,10 +101,6 @@ def resolve_boundary(args): boundary_path = download_boundary_path(microwatersheds_path) args.boundary = str(boundary_path) args.microwatersheds = str(microwatersheds_path) - if args.rainfall_folder is None: - args.rainfall_folder = selected_output_folder("./tifs/rainfall_state", args) - if args.runoffs_folder is None: - args.runoffs_folder = selected_output_folder("./tifs/runoffs_state", args) logger.info( "Resolved state watershed boundary: state=%s sources=%s download_boundary=%s microwatersheds=%s features=%s", args.state, @@ -148,10 +122,6 @@ def resolve_boundary(args): boundary_path = download_boundary_path(microwatersheds_path) args.boundary = str(boundary_path) args.microwatersheds = str(microwatersheds_path) - if args.rainfall_folder is None: - args.rainfall_folder = selected_output_folder("./tifs/rainfall_district", args) - if args.runoffs_folder is None: - args.runoffs_folder = selected_output_folder("./tifs/runoffs_district", args) logger.info( "Resolved district watershed boundary: state=%s district=%s sources=%s download_boundary=%s microwatersheds=%s features=%s", args.state, @@ -173,10 +143,6 @@ def resolve_boundary(args): ) args.boundary = str(boundary_path) args.microwatersheds = str(boundary_path) - if args.rainfall_folder is None: - args.rainfall_folder = selected_output_folder("./tifs/rainfall_tehsil", args) - if args.runoffs_folder is None: - args.runoffs_folder = selected_output_folder("./tifs/runoffs_tehsil", args) logger.info( "Resolved watershed boundary: state=%s district=%s tehsil=%s source=%s output=%s features=%s", args.state, @@ -187,18 +153,23 @@ def resolve_boundary(args): feature_count, ) +def _required_arg(args, name): + value = getattr(args, name, None) + if value is None: + raise ValueError(f"{name} is required") + return value + + def modify_cfg(args): - cfg.BOUNDARY_GEOJSON_PATH = args.boundary - cfg.MICROWATERSHEDS_PATH = getattr(args, "microwatersheds", args.boundary) + cfg.BOUNDARY_GEOJSON_PATH = _required_arg(args, "boundary") + cfg.MICROWATERSHEDS_PATH = _required_arg(args, "microwatersheds") cfg.LULC_SOURCE = args.lulc_source if args.local_lulc is not None: cfg.LULC_PATH = args.local_lulc if args.local_soil is not None: cfg.SOIL_PATH = args.local_soil - if args.rainfall_folder is not None: - cfg.RAINFALL_FOLDER = args.rainfall_folder - if args.runoffs_folder is not None: - cfg.RUNOFFS_FOLDER = args.runoffs_folder + cfg.RAINFALL_FOLDER = _required_arg(args, "rainfall_folder") + cfg.RUNOFFS_FOLDER = _required_arg(args, "runoffs_folder") if args.t: path_obj = Path(cfg.MICROWATERSHEDS_PATH) new_path = path_obj.with_name(f"{path_obj.stem}_timeseries{path_obj.suffix}") @@ -225,75 +196,3 @@ def prereq(args): stage_name = f"prerequisite downloader: {downloader.__module__}.{downloader.__name__}" with timed_stage(stage_name): downloader().main() - -if __name__=="__main__": - overall_start = perf_counter() - logger.info("Starting up") - - try: - parser.add_argument('-p', "--pre-req", action='store_true', help="also do pre-req stuff") - parser.add_argument('-b', '--boundary', help="use another boundary file", default=cfg.BOUNDARY_GEOJSON_PATH) - parser.add_argument('-t', help="Dump timeseries next to boundary file", action='store_true') - parser.add_argument('--start', help="in YYYY-MM-DD format (inclusive)", default=cfg.ARG_START_DATE) - parser.add_argument('--end', help="in YYYY-MM-DD format (exclusive)", default=cfg.ARG_END_DATE) - parser.add_argument('--rainfall-folder', help=f"folder containing rainfall_archive.zarr (default: {cfg.RAINFALL_FOLDER})") - parser.add_argument('--runoffs-folder', help=f"folder where runoff GeoZarr output will be written (default: {cfg.RUNOFFS_FOLDER})") - parser.add_argument('--pan-india', action='store_true', help="run all written watershed boundaries in the manifest as one pan-India job") - parser.add_argument('--state', help="state name for state/district/tehsil watershed lookup; omit --district to run the whole state") - parser.add_argument('--district', help="district name for district/tehsil watershed lookup") - parser.add_argument('--tehsil', help="optional tehsil name; omit to run the whole district") - parser.add_argument('--watershed-root', default=str(DEFAULT_WATERSHED_ROOT), help="root folder containing tehsil watershed GeoPackages") - parser.add_argument('--watershed-boundary-output', help="optional GeoJSON path for the resolved watershed boundary") - parser.add_argument('--reuse-watershed-boundary', action='store_true', help="reuse an existing resolved watershed GeoJSON instead of rebuilding it") - parser.add_argument('--local-dem', help="clip this local terrain/slope raster to the selected boundary instead of downloading DEM/slope from GEE") - parser.add_argument('--local-lulc', help="read LULC from this local GeoTIFF file or folder of GeoTIFF tiles instead of downloading LULC from GEE") - parser.add_argument('--lulc-source', choices=LULC_SOURCES, default=getattr(cfg, "LULC_SOURCE", "dynamicworld"), help="LULC class scheme for --local-lulc or downloaded LULC") - parser.add_argument('--local-soil', help="read soil/HSG from this local GeoTIFF file or folder of GeoTIFF tiles instead of downloading soil from GEE") - parser.add_argument('--tile-size', type=int, default=None, help=f"process runoff/timeseries in square pixel tiles; default is {PAN_INDIA_DEFAULT_TILE_SIZE} for pan-India, {STATE_DEFAULT_TILE_SIZE} for whole-state runs, and disabled otherwise; pass 0 to disable") - - args = parser.parse_args() - if args.lulc_source == LULC_SOURCE_INDIASATV3 and args.local_lulc is None: - args.local_lulc = getattr(cfg, "INDIASATV3_LULC_PATH", "./tifs/lulc_v3_2024_2025.tif") - validate_local_raster(args.local_lulc, "--local-lulc") - validate_local_raster(args.local_soil, "--local-soil") - resolve_boundary(args) - if args.tile_size is None: - if args.pan_india: - args.tile_size = PAN_INDIA_DEFAULT_TILE_SIZE - elif args.state and not args.district: - args.tile_size = STATE_DEFAULT_TILE_SIZE - else: - args.tile_size = 0 - if args.tile_size < 0: - parser.error("--tile-size cannot be negative") - modify_cfg(args) - - if args.pre_req: - shutil.rmtree(cfg.RAINFALL_FOLDER, ignore_errors=True) - if args.local_dem: - with timed_stage(f"local DEM/slope clip from {args.local_dem}"): - dem.clip_local_raster(args.local_dem, cfg.BOUNDARY_GEOJSON_PATH, cfg.DEMFILE_PATH, logger) - else: - with timed_stage("prerequisite downloader: downloads.dem.Downloader"): - dem.Downloader().main() - with timed_stage(f"loading DEM reference grid from {cfg.DEMFILE_PATH}"): - utils.tif_handler = GeoTIFFHandler(cfg.DEMFILE_PATH, logger) - with timed_stage("remaining prerequisite downloads"): - prereq(args) - else: - with timed_stage(f"loading DEM reference grid from {cfg.DEMFILE_PATH}"): - utils.tif_handler = GeoTIFFHandler(cfg.DEMFILE_PATH, logger) - - shutil.rmtree(cfg.RUNOFFS_FOLDER, ignore_errors=True) - with timed_stage("runoff/timeseries processing"): - if args.tile_size: - logger.info( - "Using tiled runoff/timeseries processing with tile_size=%s; runoff GeoZarr rasters are skipped in tiled mode", - args.tile_size, - ) - tiled_timeseries.TiledTimeSeries(args.tile_size).run() - else: - timeseries.TimeSeries().run() - logger.info("Done") - finally: - logger.info("Overall runtime: %s", format_elapsed(perf_counter() - overall_start)) diff --git a/computing/hydrology_gpu/watershed_boundary.py b/computing/hydrology_gpu/watershed_boundary.py index 5ad89f16..4e99f817 100644 --- a/computing/hydrology_gpu/watershed_boundary.py +++ b/computing/hydrology_gpu/watershed_boundary.py @@ -6,10 +6,11 @@ import pandas as pd from shapely.geometry import GeometryCollection, MultiPolygon, Polygon +from computing.config_loader import PRECOMPUTED_TEHSIL_WATERSHED_DIR, PROJECT_ROOT -DEFAULT_WATERSHED_ROOT = Path("/media/disk3/raman/code/core-stack-backend/data/base_layers/tehsil_watersheds") -DEFAULT_BOUNDARY_OUTPUT_ROOT = Path("./tifs/tehsil_watersheds") -DEFAULT_PAN_INDIA_DOWNLOAD_BOUNDARY = Path("./data/base_layers/PanIndia_Boundaries/india_state_outer_no_islands.geojson") +DEFAULT_WATERSHED_ROOT = PRECOMPUTED_TEHSIL_WATERSHED_DIR +DEFAULT_BOUNDARY_OUTPUT_ROOT = PROJECT_ROOT / "data" / "hydrology_gpu" / "boundaries" +DEFAULT_PAN_INDIA_DOWNLOAD_BOUNDARY = PROJECT_ROOT / "data" / "base_layers" / "PanIndia_Boundaries" / "india_state_outer_no_islands.geojson" PAN_INDIA_SLUG = "pan_india" diff --git a/computing/mws/runoff_gpu.py b/computing/mws/runoff_gpu.py index 33de8c4e..4415f5ac 100644 --- a/computing/mws/runoff_gpu.py +++ b/computing/mws/runoff_gpu.py @@ -6,15 +6,20 @@ from nrm_app.celery import app -from computing.config_loader import PROJECT_ROOT, PRECOMPUTED_TEHSIL_WATERSHED_DIR +from computing.config_loader import ( + LULC_BASE_DIR, + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + PROJECT_ROOT, + SOIL_RASTER_PATH, + TERRAIN_RASTER_PATH, +) from utilities.gee_utils import valid_gee_text DATA_ROOT = PROJECT_ROOT / "data" HYDROLOGY_OUTPUT_ROOT = DATA_ROOT / "hydrology_gpu" -LULC_BASE_DIR = DATA_ROOT / "base_layers" / "lulc" -DEFAULT_LOCAL_DEM_PATH = DATA_ROOT / "base_layers" / "slope" / "slope_india_30m_merged.tif" -DEFAULT_LOCAL_SOIL_PATH = DATA_ROOT / "base_layers" / "soil" / "hysogs_india_250m_4326.tif" +DEFAULT_LOCAL_DEM_PATH = TERRAIN_RASTER_PATH +DEFAULT_LOCAL_SOIL_PATH = SOIL_RASTER_PATH PAN_INDIA_DEFAULT_TILE_SIZE = 11264 STATE_DEFAULT_TILE_SIZE = 4096 From b4c2907b028db8edb22337aa45ae61e216734301 Mon Sep 17 00:00:00 2001 From: ramank1137 Date: Wed, 12 Aug 2026 18:33:04 +0530 Subject: [PATCH 19/19] adding code for hydrology for local compute along with other apis --- computing/api.py | 257 ++- computing/config.yaml | 6 + computing/config_loader.py | 1 + computing/hydrology_gpu/et_download.py | 309 ++- computing/hydrology_gpu/runoff.py | 10 +- computing/misc/aquifer_vector_local.py | 48 +- computing/mws/et_download.py | 15 +- computing/mws/generate_hydrology_local.py | 2457 +++++++++++++++++++++ computing/mws/runoff_gpu.py | 25 +- computing/tasks.py | 11 +- computing/urls.py | 10 + 11 files changed, 3065 insertions(+), 84 deletions(-) create mode 100644 computing/mws/generate_hydrology_local.py diff --git a/computing/api.py b/computing/api.py index c40e89b0..1a9d58e2 100644 --- a/computing/api.py +++ b/computing/api.py @@ -30,7 +30,13 @@ from .misc.ndvi_time_series import ndvi_timeseries from .misc.restoration_opportunity import generate_restoration_opportunity from .misc.stream_order import generate_stream_order -from .mws.generate_hydrology import generate_hydrology +from .mws.generate_hydrology import ( + generate_hydrology as generate_hydrology_gee_task, +) +from .mws.generate_hydrology_local import ( + generate_hydrology_base_layer as generate_hydrology_base_layer_task, + generate_hydrology as generate_hydrology_local_task, +) from .mws.et_download import et_download as et_download_task from .mws.runoff_gpu import generate_runoff_gpu as generate_runoff_gpu_task from .utils import ( @@ -130,6 +136,26 @@ from django.conf import settings +def _get_pan_india_flag(request): + value = request.data.get( + "pan_india", + request.data.get( + "pan-india", + request.data.get("panIndia", False), + ), + ) + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "y"} + + +def _has_any_payload_field(request, fields): + return any(field in request.data for field in fields) + + +PAN_INDIA_PAYLOAD_FIELDS = ("pan_india", "pan-india", "panIndia") +PAN_INDIA_LOCATION_FIELDS = ("state", "district", "block", "tehsil", "year") + @api_security_check(allowed_methods="POST") @schema(None) @@ -278,30 +304,16 @@ def generate_mws_layer(request): def generate_fortnightly_hydrology(request): print("Inside generate_fortnightly_hydrology") try: - state = request.data.get("state") - district = request.data.get("district") - block = request.data.get("block") - start_year = int(request.data.get("start_year")) - end_year = int(request.data.get("end_year")) - gee_account_id = request.data.get("gee_account_id") - generate_hydrology.apply_async( - kwargs={ - "state": state, - "district": district, - "block": block, - "start_year": start_year, - "end_year": end_year, - "gee_account_id": gee_account_id, - "is_annual": False, - }, - queue="nrm", - ) - return Response( - {"Success": "Successfully initiated"}, status=status.HTTP_200_OK - ) + return _generate_tehsil_hydrology(request, is_annual=False) + except ValueError as e: + print("Invalid request in generate_fortnightly_hydrology api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in generate_fortnightly_hydrology api :: ", e) - return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response( + {"Exception": str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) @api_view(["POST"]) @@ -309,30 +321,186 @@ def generate_fortnightly_hydrology(request): def generate_annual_hydrology(request): print("Inside generate_annual_hydrology") try: - state = request.data.get("state") - district = request.data.get("district") - block = request.data.get("block") - start_year = int(request.data.get("start_year")) - end_year = int(request.data.get("end_year")) - gee_account_id = request.data.get("gee_account_id") - generate_hydrology.apply_async( + return _generate_tehsil_hydrology(request, is_annual=True) + except ValueError as e: + print("Invalid request in generate_annual_hydrology api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + print("Exception in generate_annual_hydrology api :: ", e) + return Response( + {"Exception": str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +def _generate_tehsil_hydrology(request, is_annual): + compute = _get_compute_mode(request) + if _has_any_payload_field(request, PAN_INDIA_PAYLOAD_FIELDS): + raise ValueError( + "Do not pass pan_india to the tehsil hydrology API. " + "Use /api/v1/pan-india/hydrology_fortnightly/ or " + "/api/v1/pan-india/hydrology_annual/ for Pan-India generation." + ) + + state = request.data.get("state") + district = request.data.get("district") + block = request.data.get("block") + if not all([state, district, block]): + raise ValueError("state, district, and block are required") + + if request.data.get("start_year") is None or request.data.get("end_year") is None: + raise ValueError("start_year and end_year are required") + start_year = int(request.data.get("start_year")) + end_year = int(request.data.get("end_year")) + if end_year < start_year: + raise ValueError("end_year must be greater than or equal to start_year") + + if compute == "gee": + task = generate_hydrology_gee_task.apply_async( kwargs={ "state": state, "district": district, "block": block, "start_year": start_year, "end_year": end_year, - "is_annual": True, - "gee_account_id": gee_account_id, + "gee_account_id": request.data.get("gee_account_id"), + "is_annual": is_annual, }, queue="nrm", ) + source = "gee" + success = "hydrology GEE task initiated" + else: + if start_year != 2017: + raise ValueError( + "Local hydrology clipping must start from start_year=2017 because " + "the fortnightly cadence and cumulative G are anchored at 2017-07-01" + ) + task = generate_hydrology_local_task.apply_async( + kwargs={ + "state": state, + "district": district, + "block": block, + "start_year": start_year, + "end_year": end_year, + "is_annual": is_annual, + "pan_india": False, + "overwrite": request.data.get("overwrite", False), + }, + queue="nrm", + ) + source = "base_layer_clip" + success = "hydrology clipping task initiated" + + return Response( + { + "Success": success, + "task_id": task.id, + "compute": compute, + "scope": "tehsil", + "source": source, + "start_year": start_year, + "end_year": end_year, + "is_annual": bool(is_annual), + }, + status=status.HTTP_200_OK, + ) + + +def _generate_pan_india_hydrology_base_layer(request, is_annual): + compute = _get_compute_mode(request) + if compute != "local": + raise ValueError( + "Pan-India hydrology generation supports compute='local' only" + ) + if _has_any_payload_field(request, PAN_INDIA_PAYLOAD_FIELDS): + raise ValueError( + "Do not pass pan_india to the Pan-India hydrology API; " + "the /api/v1/pan-india/ route already defines the scope." + ) + forbidden_fields = [ + field for field in PAN_INDIA_LOCATION_FIELDS if field in request.data + ] + if forbidden_fields: + raise ValueError( + "Pan-India hydrology API accepts only compute, start_year, " + "end_year, and overwrite; do not pass " + f"{', '.join(forbidden_fields)}" + ) + + if request.data.get("start_year") is None or request.data.get("end_year") is None: + raise ValueError("start_year and end_year are required") + start_year = int(request.data.get("start_year")) + end_year = int(request.data.get("end_year")) + if end_year <= start_year: + raise ValueError("end_year must be greater than start_year") + if end_year != start_year + 1: + raise ValueError( + "Hydrology base-layer generation supports one hydrological year " + "at a time; use end_year=start_year+1" + ) + task = generate_hydrology_base_layer_task.apply_async( + kwargs={ + "start_year": start_year, + "end_year": end_year, + "is_annual": is_annual, + "overwrite": request.data.get("overwrite", False), + }, + queue="nrm", + ) + return Response( + { + "Success": "Pan-India hydrology task initiated", + "task_id": task.id, + "compute": compute, + "scope": "pan_india", + "start_year": start_year, + "end_year": end_year, + "year_key": f"{start_year}_{end_year}", + "is_annual": bool(is_annual), + }, + status=status.HTTP_200_OK, + ) + + +@api_view(["POST"]) +@schema(None) +def generate_pan_india_fortnightly_hydrology(request): + print("Inside generate_pan_india_fortnightly_hydrology") + try: + return _generate_pan_india_hydrology_base_layer(request, is_annual=False) + except ValueError as e: + print( + "Invalid request in generate_pan_india_fortnightly_hydrology api :: ", + e, + ) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + print( + "Exception in generate_pan_india_fortnightly_hydrology api :: ", + e, + ) return Response( - {"Success": "Successfully initiated"}, status=status.HTTP_200_OK + {"Exception": str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + + +@api_view(["POST"]) +@schema(None) +def generate_pan_india_annual_hydrology(request): + print("Inside generate_pan_india_annual_hydrology") + try: + return _generate_pan_india_hydrology_base_layer(request, is_annual=True) + except ValueError as e: + print("Invalid request in generate_pan_india_annual_hydrology api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: - print("Exception in generate_annual_hydrology api :: ", e) - return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + print("Exception in generate_pan_india_annual_hydrology api :: ", e) + return Response( + {"Exception": str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) @api_view(["POST"]) @@ -345,10 +513,7 @@ def generate_runoff_gpu(request): raise ValueError("runoff_gpu currently supports compute='local' only") tehsil = request.data.get("tehsil") or request.data.get("block") - pan_india = request.data.get( - "pan_india", - request.data.get("pan-india", request.data.get("panIndia", False)), - ) + pan_india = _get_pan_india_flag(request) task = generate_runoff_gpu_task.apply_async( kwargs={ "state": request.data.get("state"), @@ -374,7 +539,9 @@ def generate_runoff_gpu(request): return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in generate_runoff_gpu api :: ", e) - return Response({"Exception": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response( + {"Exception": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) @api_view(["POST"]) @@ -386,10 +553,7 @@ def et_download(request): if compute != "local": raise ValueError("et_download currently supports compute='local' only") - pan_india = request.data.get( - "pan_india", - request.data.get("pan-india", request.data.get("panIndia", False)), - ) + pan_india = _get_pan_india_flag(request) task = et_download_task.apply_async( kwargs={ "pan_india": pan_india, @@ -398,6 +562,7 @@ def et_download(request): "start_year": request.data.get("start_year"), "end_year": request.data.get("end_year"), "overwrite": request.data.get("overwrite", False), + "patch_fill": request.data.get("patch_fill", True), }, queue="nrm", ) @@ -413,7 +578,9 @@ def et_download(request): return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in et_download api :: ", e) - return Response({"Exception": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response( + {"Exception": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) @api_view(["POST"]) diff --git a/computing/config.yaml b/computing/config.yaml index 6b0896ad..0b5ee911 100644 --- a/computing/config.yaml +++ b/computing/config.yaml @@ -118,3 +118,9 @@ local_compute_outputs: - path: data/misc/aquifer_vector_local/{state}/{district}/{block}/ pattern: "aquifer_vector_{district}_{block}.gpkg" geoserver_workspace: aquifer + + hydrology: + # generate_hydrology_local.py + - path: data/hydrology/hydrology_local/{state}/{district}/{block}/ + pattern: "deltaG_{period}_{district}_{block}.gpkg" + geoserver_workspace: mws_layers diff --git a/computing/config_loader.py b/computing/config_loader.py index 3489bede..3462ed05 100644 --- a/computing/config_loader.py +++ b/computing/config_loader.py @@ -99,3 +99,4 @@ def _output_entry(module: str, index: int = 0) -> dict: LULC_V3_OUTPUT_DIR: Path = _abs(_output_entry("lulc", 1)["path"]) LULC_SLOPE_CLUSTER_OUTPUT_DIR: Path = _abs(_output_entry("lulc_x_terrain", 0)["path"]) AQUIFER_VECTOR_OUTPUT_DIR: Path = _abs(_output_entry("misc", 0)["path"]) +HYDROLOGY_LOCAL_OUTPUT_DIR: Path = _abs(_output_entry("hydrology", 0)["path"]) diff --git a/computing/hydrology_gpu/et_download.py b/computing/hydrology_gpu/et_download.py index 2bb45419..deb5045c 100644 --- a/computing/hydrology_gpu/et_download.py +++ b/computing/hydrology_gpu/et_download.py @@ -10,16 +10,23 @@ import requests from django.conf import settings +import numpy as np +import rasterio +from rasterio.fill import fillnodata GESDISC_OTF_URL = "https://hydro1.gesdisc.eosdis.nasa.gov/daac-bin/OTF/HTTP_services.cgi" EVAP_VARIABLE = "Evap_tavg" FLDAS_FORMAT = "Y29nLw" +FLDAS_CA_DAILY_SOURCE = "fldas_ca_daily" +FLDAS_CA_DAILY_PATCH_FILLED_SOURCE = "fldas_ca_daily_patch_filled" FLDAS_CA_DAILY_SHORTNAME = "FLDAS_NOAHMP001_G_CA_D" FLDAS_CA_DAILY_PRODUCT = "FLDAS_NOAHMP001_G_CA_D.001" FLDAS_CA_DAILY_BBOX = "21,65.566,37.932,99.844" +FLDAS_GLOBAL_MONTHLY_SOURCE = "fldas_global_monthly" +FLDAS_GLOBAL_MONTHLY_PATCH_FILLED_SOURCE = "fldas_global_monthly_patch_filled" FLDAS_GLOBAL_MONTHLY_SHORTNAME = "FLDAS_NOAH01_C_GL_M" FLDAS_GLOBAL_MONTHLY_PRODUCT = "FLDAS_NOAH01_C_GL_M.001" PAN_INDIA_BBOX = "6,68,38,98" @@ -39,6 +46,12 @@ class SourceManifest: downloaded_count: int skipped_count: int failed_count: int + patch_filled_source: str | None = None + patch_filled_folder: str | None = None + patch_filled_count: int = 0 + patch_fill_skipped_count: int = 0 + patch_fill_failed_count: int = 0 + patch_fill_invalid_pixels: int = 0 def _coerce_date(value, field_name): @@ -251,17 +264,213 @@ def _download_records(auth, records, overwrite, logger, max_attempts, retry_dela return downloaded_count, skipped_count, failed_count +def _invalid_pixel_mask(values: np.ndarray, nodata) -> np.ndarray: + invalid = ~np.isfinite(values) + if nodata is not None: + invalid |= values == nodata + invalid |= values < 0 + return invalid + + +def _fill_raster_band( + values: np.ndarray, + nodata, + *, + max_search_distance: float, + smoothing_iterations: int, +): + invalid = _invalid_pixel_mask(values, nodata) + invalid_count = int(invalid.sum()) + if invalid_count == 0: + return values, invalid_count + + valid = ~invalid + if not valid.any(): + raise ValueError("raster band has no valid pixels to fill from") + + working = values.astype("float32", copy=True) + working[invalid] = 0.0 + mask = valid.astype("uint8") + filled = fillnodata( + working, + mask=mask, + max_search_distance=max_search_distance, + smoothing_iterations=smoothing_iterations, + ) + return filled, invalid_count + + +def _patch_fill_raster( + input_path: Path, + output_path: Path, + *, + overwrite: bool, + max_search_distance: float | None, + smoothing_iterations: int, +): + if output_path.exists() and output_path.stat().st_size > 0 and not overwrite: + return "skipped", 0, None + + output_path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = output_path.with_name( + f"{output_path.stem}.{int(time.time() * 1000)}.tmp{output_path.suffix}" + ) + try: + with rasterio.open(input_path) as src: + profile = src.profile.copy() + nodata = src.nodata + data = src.read() + search_distance = ( + float(max(src.width, src.height)) + if max_search_distance is None + else max_search_distance + ) + + filled = np.empty((data.shape[0], data.shape[1], data.shape[2]), dtype="float32") + invalid_pixels = 0 + for band_index in range(data.shape[0]): + filled_band, band_invalid = _fill_raster_band( + data[band_index].astype("float32", copy=False), + nodata, + max_search_distance=search_distance, + smoothing_iterations=smoothing_iterations, + ) + filled[band_index] = filled_band + invalid_pixels += band_invalid + + profile.update( + dtype="float32", + count=data.shape[0], + compress=profile.get("compress") or "deflate", + tiled=profile.get("tiled", True), + ) + with rasterio.open(temporary_path, "w", **profile) as dst: + dst.write(filled) + temporary_path.replace(output_path) + return "patch_filled", invalid_pixels, search_distance + except Exception: + temporary_path.unlink(missing_ok=True) + raise + + +def _patch_fill_record( + record, + output_root, + overwrite, + max_search_distance, + smoothing_iterations, +): + input_path = Path(record["path"]) + output_path = Path(output_root) / input_path.name + patch_record = { + key: value for key, value in record.items() if key in {"date", "month", "url"} + } + patch_record.update( + { + "source_path": str(input_path), + "path": str(output_path), + } + ) + + if not input_path.exists(): + patch_record["status"] = "failed" + patch_record["error"] = f"source raster not found: {input_path}" + return "failed", patch_record + + try: + status, invalid_pixels, search_distance = _patch_fill_raster( + input_path, + output_path, + overwrite=overwrite, + max_search_distance=max_search_distance, + smoothing_iterations=smoothing_iterations, + ) + patch_record["status"] = status + patch_record["invalid_pixels_filled"] = invalid_pixels + patch_record["max_search_distance"] = search_distance + patch_record.pop("error", None) + return status, patch_record + except Exception as exc: + patch_record["status"] = "failed" + patch_record["error"] = str(exc) + return "failed", patch_record + + +def _patch_fill_records( + source_name, + records, + output_root, + *, + overwrite, + logger, + max_workers, + max_search_distance=None, + smoothing_iterations=0, +): + patched_count = 0 + skipped_count = 0 + failed_count = 0 + invalid_pixels = 0 + patch_records = [] + if not records: + return patch_records, patched_count, skipped_count, failed_count, invalid_pixels + + output_root = Path(output_root) + output_root.mkdir(parents=True, exist_ok=True) + worker_count = min(max_workers, len(records)) + logger.info( + "Patch-filling %s ET rasters into %s with %s workers", + source_name, + output_root, + worker_count, + ) + with ThreadPoolExecutor(max_workers=worker_count) as executor: + futures = [ + executor.submit( + _patch_fill_record, + record, + output_root, + overwrite, + max_search_distance, + smoothing_iterations, + ) + for record in records + ] + for future in as_completed(futures): + status, patch_record = future.result() + patch_records.append(patch_record) + if status == "patch_filled": + patched_count += 1 + invalid_pixels += int(patch_record.get("invalid_pixels_filled") or 0) + elif status == "skipped": + skipped_count += 1 + elif status == "failed": + failed_count += 1 + logger.error( + "Patch-fill failed for %s: %s", + patch_record.get("source_path"), + patch_record.get("error"), + ) + + patch_records.sort(key=lambda item: item.get("date") or item.get("month") or "") + return patch_records, patched_count, skipped_count, failed_count, invalid_pixels + + def download_pan_india_et_assets( output_root, start_date, end_date, *, + et_root=None, overwrite=False, fldas_ca_daily_bbox=FLDAS_CA_DAILY_BBOX, fldas_global_monthly_bbox=PAN_INDIA_BBOX, max_attempts=5, retry_delay_seconds=5, max_workers=DEFAULT_MAX_WORKERS, + patch_fill=True, + patch_fill_max_search_distance=None, + patch_fill_smoothing_iterations=0, logger=None, ): """ @@ -280,11 +489,20 @@ def download_pan_india_et_assets( raise ValueError("max_attempts must be at least 1") if max_workers < 1: raise ValueError("max_workers must be at least 1") + if patch_fill_smoothing_iterations < 0: + raise ValueError("patch_fill_smoothing_iterations must be >= 0") + if ( + patch_fill_max_search_distance is not None + and patch_fill_max_search_distance < 0 + ): + raise ValueError("patch_fill_max_search_distance must be >= 0") output_root = Path(output_root) - et_root = output_root / "et" - daily_root = et_root / "fldas_ca_daily" / "daily" - monthly_root = et_root / "fldas_global_monthly" / "monthly" + et_root = Path(et_root) if et_root is not None else output_root / "et" + daily_root = et_root / FLDAS_CA_DAILY_SOURCE / "daily" + monthly_root = et_root / FLDAS_GLOBAL_MONTHLY_SOURCE / "monthly" + daily_patch_filled_root = et_root / FLDAS_CA_DAILY_PATCH_FILLED_SOURCE + monthly_patch_filled_root = et_root / FLDAS_GLOBAL_MONTHLY_PATCH_FILLED_SOURCE daily_records = [] for day in _iter_days(start_date, end_date): @@ -336,9 +554,48 @@ def download_pan_india_et_assets( max_workers, ) + daily_patch_records = [] + monthly_patch_records = [] + daily_patch_count = daily_patch_skipped = daily_patch_failed = 0 + monthly_patch_count = monthly_patch_skipped = monthly_patch_failed = 0 + daily_patch_invalid_pixels = monthly_patch_invalid_pixels = 0 + if patch_fill: + ( + daily_patch_records, + daily_patch_count, + daily_patch_skipped, + daily_patch_failed, + daily_patch_invalid_pixels, + ) = _patch_fill_records( + FLDAS_CA_DAILY_SOURCE, + daily_records, + daily_patch_filled_root, + overwrite=overwrite, + logger=logger, + max_workers=max_workers, + max_search_distance=patch_fill_max_search_distance, + smoothing_iterations=patch_fill_smoothing_iterations, + ) + ( + monthly_patch_records, + monthly_patch_count, + monthly_patch_skipped, + monthly_patch_failed, + monthly_patch_invalid_pixels, + ) = _patch_fill_records( + FLDAS_GLOBAL_MONTHLY_SOURCE, + monthly_records, + monthly_patch_filled_root, + overwrite=overwrite, + logger=logger, + max_workers=max_workers, + max_search_distance=patch_fill_max_search_distance, + smoothing_iterations=patch_fill_smoothing_iterations, + ) + sources = [ SourceManifest( - source="fldas_ca_daily", + source=FLDAS_CA_DAILY_SOURCE, shortname=FLDAS_CA_DAILY_SHORTNAME, product=FLDAS_CA_DAILY_PRODUCT, variable=EVAP_VARIABLE, @@ -349,9 +606,19 @@ def download_pan_india_et_assets( downloaded_count=daily_downloaded, skipped_count=daily_skipped, failed_count=daily_failed, + patch_filled_source=( + FLDAS_CA_DAILY_PATCH_FILLED_SOURCE if patch_fill else None + ), + patch_filled_folder=( + str(daily_patch_filled_root) if patch_fill else None + ), + patch_filled_count=daily_patch_count, + patch_fill_skipped_count=daily_patch_skipped, + patch_fill_failed_count=daily_patch_failed, + patch_fill_invalid_pixels=daily_patch_invalid_pixels, ), SourceManifest( - source="fldas_global_monthly", + source=FLDAS_GLOBAL_MONTHLY_SOURCE, shortname=FLDAS_GLOBAL_MONTHLY_SHORTNAME, product=FLDAS_GLOBAL_MONTHLY_PRODUCT, variable=EVAP_VARIABLE, @@ -362,6 +629,16 @@ def download_pan_india_et_assets( downloaded_count=monthly_downloaded, skipped_count=monthly_skipped, failed_count=monthly_failed, + patch_filled_source=( + FLDAS_GLOBAL_MONTHLY_PATCH_FILLED_SOURCE if patch_fill else None + ), + patch_filled_folder=( + str(monthly_patch_filled_root) if patch_fill else None + ), + patch_filled_count=monthly_patch_count, + patch_fill_skipped_count=monthly_patch_skipped, + patch_fill_failed_count=monthly_patch_failed, + patch_fill_invalid_pixels=monthly_patch_invalid_pixels, ), ] @@ -386,26 +663,38 @@ def download_pan_india_et_assets( "download_policy": { "max_workers": max_workers, }, + "patch_fill_policy": { + "enabled": bool(patch_fill), + "method": "rasterio.fill.fillnodata", + "invalid_pixels": ["nodata", "nan", "inf", "negative"], + "max_search_distance": ( + "max(width, height) per raster" + if patch_fill_max_search_distance is None + else patch_fill_max_search_distance + ), + "smoothing_iterations": patch_fill_smoothing_iterations, + }, "sources": [asdict(source) for source in sources], "records": { - "fldas_ca_daily": daily_records, - "fldas_global_monthly": monthly_records, + FLDAS_CA_DAILY_SOURCE: daily_records, + FLDAS_GLOBAL_MONTHLY_SOURCE: monthly_records, + FLDAS_CA_DAILY_PATCH_FILLED_SOURCE: daily_patch_records, + FLDAS_GLOBAL_MONTHLY_PATCH_FILLED_SOURCE: monthly_patch_records, }, } _write_json(et_root / "manifest.json", manifest) _write_json( - et_root / "fldas_ca_daily" / "metadata.json", + et_root / FLDAS_CA_DAILY_SOURCE / "metadata.json", { "source": asdict(sources[0]), "records": daily_records, }, ) _write_json( - et_root / "fldas_global_monthly" / "metadata.json", + et_root / FLDAS_GLOBAL_MONTHLY_SOURCE / "metadata.json", { "source": asdict(sources[1]), "records": monthly_records, }, ) - return manifest diff --git a/computing/hydrology_gpu/runoff.py b/computing/hydrology_gpu/runoff.py index f7f25266..4c7eced2 100644 --- a/computing/hydrology_gpu/runoff.py +++ b/computing/hydrology_gpu/runoff.py @@ -171,9 +171,13 @@ def modify_cfg(args): cfg.RAINFALL_FOLDER = _required_arg(args, "rainfall_folder") cfg.RUNOFFS_FOLDER = _required_arg(args, "runoffs_folder") if args.t: - path_obj = Path(cfg.MICROWATERSHEDS_PATH) - new_path = path_obj.with_name(f"{path_obj.stem}_timeseries{path_obj.suffix}") - cfg.TIMESERIES_VECTOR = new_path + configured_timeseries = getattr(args, "timeseries_vector", None) + if configured_timeseries: + cfg.TIMESERIES_VECTOR = Path(configured_timeseries) + else: + path_obj = Path(cfg.MICROWATERSHEDS_PATH) + new_path = path_obj.with_name(f"{path_obj.stem}_timeseries{path_obj.suffix}") + cfg.TIMESERIES_VECTOR = new_path cfg.ARG_START_DATE = args.start cfg.ARG_END_DATE = args.end cfg.TILE_SIZE = args.tile_size diff --git a/computing/misc/aquifer_vector_local.py b/computing/misc/aquifer_vector_local.py index c67a4281..47f49492 100644 --- a/computing/misc/aquifer_vector_local.py +++ b/computing/misc/aquifer_vector_local.py @@ -184,24 +184,13 @@ def _build_aquifer_properties(watershed_row, area_in_ha, intersections_df): return properties -def _compute_aquifer_properties_for_watersheds(watersheds_gdf, aquifers_gdf): - watersheds_gdf = validate_geometry(watersheds_gdf) - if watersheds_gdf.empty: - raise ValueError("No valid watershed geometries found for local processing.") - if watersheds_gdf.crs is None: - raise ValueError("Watershed CRS is missing; cannot compute aquifer overlaps.") - +def _prepare_aquifers_for_intersection(aquifers_gdf): aquifers_gdf = validate_geometry(aquifers_gdf) if aquifers_gdf.empty: raise ValueError("Aquifer source file has no valid geometries.") if aquifers_gdf.crs is None: raise ValueError("Aquifer source CRS is missing; cannot compute overlaps.") - watersheds_result = watersheds_gdf.copy() - watersheds_result["area_in_ha"] = get_watershed_areas_in_hectares( - watersheds_result - ).astype(float) - aquifers_with_yield = aquifers_gdf.copy() aquifers_with_yield["y_value"] = aquifers_with_yield["yeild__"].apply( _map_yield_value @@ -212,8 +201,31 @@ def _compute_aquifer_properties_for_watersheds(watersheds_gdf, aquifers_gdf): if aquifers_with_yield.empty: raise ValueError("Aquifer source has no records with valid yield values.") + return aquifers_with_yield.to_crs("EPSG:6933") + + +def _compute_aquifer_properties_for_watersheds( + watersheds_gdf, + aquifers_gdf=None, + aquifers_projected=None, +): + watersheds_gdf = validate_geometry(watersheds_gdf) + if watersheds_gdf.empty: + raise ValueError("No valid watershed geometries found for local processing.") + if watersheds_gdf.crs is None: + raise ValueError("Watershed CRS is missing; cannot compute aquifer overlaps.") + + if aquifers_projected is None: + if aquifers_gdf is None: + raise ValueError("Aquifer source is required for local processing.") + aquifers_projected = _prepare_aquifers_for_intersection(aquifers_gdf) + + watersheds_result = watersheds_gdf.copy() + watersheds_result["area_in_ha"] = get_watershed_areas_in_hectares( + watersheds_result + ).astype(float) watersheds_projected = watersheds_result.to_crs("EPSG:6933") - aquifers_projected = aquifers_with_yield.to_crs("EPSG:6933") + aquifer_spatial_index = aquifers_projected.sindex computed_rows = [] total = len(watersheds_projected) @@ -236,9 +248,13 @@ def _compute_aquifer_properties_for_watersheds(watersheds_gdf, aquifers_gdf): ) continue - intersecting_aquifers = aquifers_projected.loc[ - aquifers_projected.intersects(watershed_geometry) - ] + candidate_positions = sorted( + aquifer_spatial_index.query( + watershed_geometry, + predicate="intersects", + ) + ) + intersecting_aquifers = aquifers_projected.iloc[candidate_positions] intersections = [] for _, aquifer_row in intersecting_aquifers.iterrows(): diff --git a/computing/mws/et_download.py b/computing/mws/et_download.py index 3b5e97e3..4ff99576 100644 --- a/computing/mws/et_download.py +++ b/computing/mws/et_download.py @@ -5,7 +5,10 @@ from computing.config_loader import PROJECT_ROOT from computing.hydrology_gpu.et_download import download_pan_india_et_assets -from .runoff_gpu import HYDROLOGY_OUTPUT_ROOT, _parse_bool, _resolve_dates +from .runoff_gpu import _parse_bool, _resolve_dates + + +PAN_INDIA_ET_OUTPUT_ROOT = PROJECT_ROOT / "data" / "base_layers" / "hydrology" / "et" def _make_logger(): @@ -44,6 +47,7 @@ def run_et_download_local( start_year=None, end_year=None, overwrite=False, + patch_fill=True, ): pan_india = _parse_bool(pan_india) if not pan_india: @@ -56,15 +60,17 @@ def run_et_download_local( end_year=end_year, ) annual_key = f"{annual_start_year}_{annual_end_year}" - output_root = HYDROLOGY_OUTPUT_ROOT / "pan_india" / annual_key + output_root = PAN_INDIA_ET_OUTPUT_ROOT / annual_key output_root.mkdir(parents=True, exist_ok=True) logger = _make_logger() manifest = download_pan_india_et_assets( output_root=output_root, + et_root=output_root, start_date=start_date, end_date=end_date, overwrite=_parse_bool(overwrite), + patch_fill=_parse_bool(patch_fill), logger=logger, ) @@ -76,7 +82,8 @@ def run_et_download_local( "output_root": str(output_root), "et_root": manifest["et_root"], "sources": manifest["sources"], - "manifest": str(output_root / "et" / "manifest.json"), + "patch_fill_policy": manifest.get("patch_fill_policy"), + "manifest": str(output_root / "manifest.json"), } @@ -89,6 +96,7 @@ def et_download( start_year=None, end_year=None, overwrite=False, + patch_fill=True, ): _ = self return run_et_download_local( @@ -98,4 +106,5 @@ def et_download( start_year=start_year, end_year=end_year, overwrite=overwrite, + patch_fill=patch_fill, ) diff --git a/computing/mws/generate_hydrology_local.py b/computing/mws/generate_hydrology_local.py new file mode 100644 index 00000000..038e562d --- /dev/null +++ b/computing/mws/generate_hydrology_local.py @@ -0,0 +1,2457 @@ +import csv +import datetime as dt +import json +import math +import os +from collections import Counter, defaultdict +from pathlib import Path + +import geopandas as gpd +import numpy as np +import pandas as pd +import rasterio +from nrm_app.celery import app +from rasterio.features import geometry_mask +from rasterio.windows import Window, from_bounds +from shapely.geometry import box, mapping + +from computing.config_loader import ( + AQUIFER_VECTOR_PATH, + HYDROLOGY_LOCAL_OUTPUT_DIR, + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + PROJECT_ROOT, +) +from computing.hydrology_gpu.watershed_boundary import ( + find_pan_india_watersheds, + find_tehsil_watershed, +) +from computing.hydrology_gpu.et_download import ( + FLDAS_CA_DAILY_PATCH_FILLED_SOURCE, + FLDAS_CA_DAILY_SOURCE, + FLDAS_GLOBAL_MONTHLY_PATCH_FILLED_SOURCE, + FLDAS_GLOBAL_MONTHLY_SOURCE, +) +from computing.local_compute_helper import ( + build_output_vector_path, + load_precomputed_watersheds, + push_local_vector_to_geoserver, + read_validated_vector_file, + write_vector_output, +) +from computing.misc.aquifer_vector_local import ( + _compute_aquifer_properties_for_watersheds, + _prepare_aquifers_for_intersection, +) +from computing.mws.runoff_gpu import ( + HYDROLOGY_OUTPUT_ROOT, + PAN_INDIA_RUNOFF_OUTPUT_ROOT, + PAN_INDIA_RUNOFF_TIMESERIES_DIR_NAME, +) +from computing.mws.et_download import PAN_INDIA_ET_OUTPUT_ROOT +from computing.utils import ( + save_layer_info_to_db, + update_layer_sync_status, +) +from utilities.gee_utils import valid_gee_text + + +GEOSERVER_WORKSPACE = "mws_layers" +LOCAL_ALGORITHM = "local_hydrology" +LOCAL_ALGORITHM_VERSION = "local-1.0" +SECONDS_PER_DAY = 86400.0 +CACHE_UID_COLUMN = "uid" +CACHE_ET_SOURCE_COLUMN = "et_source" +CACHE_ET_SOURCE_SIGNATURE_COLUMN = "et_source_signature" +CACHE_ET_ERROR_COLUMN = "et_error" +SOURCE_YEARS_COLUMN = "source_years" +FORTNIGHT_ANCHOR_DATE = dt.date(2017, 7, 1) +HYDROLOGY_BASE_LAYER_ROOT = PROJECT_ROOT / "data" / "base_layers" / "hydrology" + + +def _parse_bool(value): + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in {"1", "true", "yes", "y"} + + +def _normalize_location(value, field_name): + if value is None or not str(value).strip(): + raise ValueError(f"{field_name} is required") + return str(value).strip().lower() + + +def _year_key(year): + return f"{year}_{year + 1}" + + +def _source_year_for_date(day): + boundary = dt.date(day.year, 7, 1) + return day.year if day >= boundary else day.year - 1 + + +def _source_years_for_period(start_date, end_date): + years = set() + current = start_date + while current < end_date: + years.add(_source_year_for_date(current)) + current += dt.timedelta(days=1) + return sorted(years) + + +def _source_years_for_periods(periods): + years = set() + for start_date, end_date, _ in periods: + years.update(_source_years_for_period(start_date, end_date)) + return sorted(years) + + +def _cache_root(output_base_dir): + return Path(output_base_dir) / "cache" + + +def _et_cache_root(year): + return Path(PAN_INDIA_ET_OUTPUT_ROOT) / _year_key(year) / "cache" + + +def _aquifer_cache_path(output_base_dir): + return ( + Path(HYDROLOGY_BASE_LAYER_ROOT) + / "aquifer" + / "cache" + / "aquifer_by_uid.parquet" + ) + + +def _et_cache_path(output_base_dir, year, is_annual): + period = "annual" if is_annual else "fortnight" + return _et_cache_root(year) / f"{period}.parquet" + + +def _et_aggregate_root(output_base_dir, year, is_annual): + period = "annual" if is_annual else "fortnight" + return _et_cache_root(year) / "rasters" / period + + +def _base_layer_period_name(is_annual): + return "annual" if is_annual else "fortnightly" + + +def _base_layer_name(year, is_annual): + return f"hydrology_{_base_layer_period_name(is_annual)}_{_year_key(year)}" + + +def _base_layer_path(base_layer_root, year, is_annual): + return ( + Path(base_layer_root) + / _base_layer_period_name(is_annual) + / f"{_base_layer_name(year, is_annual)}.gpkg" + ) + + +def _write_parquet_atomic(frame, path): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = path.with_name(f"{path.name}.{os.getpid()}.tmp") + frame.to_parquet(temporary_path, index=False) + temporary_path.replace(path) + + +def _layer_name(district, block, is_annual): + suffix = "_".join( + [ + valid_gee_text(district.lower()), + valid_gee_text(block.lower()), + ] + ) + prefix = "deltaG_well_depth_" if is_annual else "deltaG_fortnight_" + return prefix + suffix + + +def _build_periods(year, is_annual): + start = dt.date(year, 7, 1) + end = dt.date(year + 1, 7, 1) + if is_annual: + return [(start, end, _year_key(year))] + if year < FORTNIGHT_ANCHOR_DATE.year: + raise ValueError( + "Fortnightly hydrology starts from the 2017 agricultural year" + ) + + periods = [] + current = FORTNIGHT_ANCHOR_DATE + for previous_year in range(FORTNIGHT_ANCHOR_DATE.year, year): + previous_end = dt.date(previous_year + 1, 7, 1) + while current + dt.timedelta(days=14) <= previous_end: + current += dt.timedelta(days=14) + + while current + dt.timedelta(days=14) <= end: + period_end = current + dt.timedelta(days=14) + periods.append((current, period_end, current.isoformat())) + current = period_end + return periods + + +def _resolve_base_layer_year_bounds(year=None, start_year=None, end_year=None): + if year is not None and (start_year is not None or end_year is not None): + raise ValueError("Provide start_year/end_year or year, not both") + + if year is not None: + start_year = int(year) + end_year = start_year + 1 + else: + if start_year is None or end_year is None: + raise ValueError("start_year and end_year are required") + start_year = int(start_year) + end_year = int(end_year) + + if start_year < FORTNIGHT_ANCHOR_DATE.year: + raise ValueError("Hydrology base layers can only be generated from 2017") + if end_year <= start_year: + raise ValueError("end_year must be greater than start_year") + if end_year != start_year + 1: + raise ValueError( + "Hydrology base-layer generation supports one hydrological year " + "at a time; use end_year=start_year+1" + ) + return start_year, end_year + + +def _resolve_year_inputs( + year, + hydrology_output_root=HYDROLOGY_OUTPUT_ROOT, +): + year_key = _year_key(year) + runoff_roots = [ + Path(PAN_INDIA_RUNOFF_OUTPUT_ROOT), + Path(hydrology_output_root) / "pan_india", + ] + timeseries_dir = None + for runoff_root in runoff_roots: + candidate = ( + runoff_root + / year_key + / PAN_INDIA_RUNOFF_TIMESERIES_DIR_NAME + / "pan_india_timeseries_tile_series" + ) + if candidate.exists(): + timeseries_dir = candidate + break + + et_roots = [ + Path(PAN_INDIA_ET_OUTPUT_ROOT) / year_key, + Path(hydrology_output_root) / "pan_india" / year_key / "et", + ] + et_root = next((path for path in et_roots if path.exists()), None) + + if timeseries_dir is None: + available_years = sorted( + { + path.name + for runoff_root in runoff_roots + for path in runoff_root.glob("*_*") + if ( + path + / PAN_INDIA_RUNOFF_TIMESERIES_DIR_NAME + / "pan_india_timeseries_tile_series" + ).is_dir() + } + ) + raise FileNotFoundError( + "Pan-India rainfall/runoff timeseries not found for " + f"{year_key}: " + f"{PAN_INDIA_RUNOFF_OUTPUT_ROOT / year_key / PAN_INDIA_RUNOFF_TIMESERIES_DIR_NAME / 'pan_india_timeseries_tile_series'}. " + f"Available Pan-India runoff years: {available_years or 'none'}." + ) + if et_root is None: + available_et_years = sorted( + path.name + for path in Path(PAN_INDIA_ET_OUTPUT_ROOT).glob("*_*") + if path.is_dir() + ) + raise FileNotFoundError( + "Pan-India ET folder not found for " + f"{year_key}: {PAN_INDIA_ET_OUTPUT_ROOT / year_key}. " + f"Available Pan-India ET years: {available_et_years or 'none'}. " + "Run et_download for this hydrological year first." + ) + return timeseries_dir, et_root + + +def _resolve_source_year_inputs( + *, + output_year, + periods, + hydrology_output_root=HYDROLOGY_OUTPUT_ROOT, +): + source_inputs = {} + for source_year in _source_years_for_periods(periods): + try: + source_inputs[source_year] = _resolve_year_inputs( + year=source_year, + hydrology_output_root=hydrology_output_root, + ) + except FileNotFoundError as error: + raise FileNotFoundError( + f"Hydrology output year {_year_key(output_year)} requires " + f"source year {_year_key(source_year)} because fortnight " + f"windows are anchored at {FORTNIGHT_ANCHOR_DATE.isoformat()}." + ) from error + return source_inputs + + +def _pan_india_watershed_offset( + state, + district, + block, + watershed_root=PRECOMPUTED_TEHSIL_WATERSHED_DIR, +): + root = Path(watershed_root) + target_path, target_row = find_tehsil_watershed( + root, + state, + district, + block, + ) + target_path = target_path.resolve() + offset = 0 + + matches = find_pan_india_watersheds(root) + for match_index, (source_path, row) in enumerate(matches): + feature_count = int(row.get("feature_count") or 0) + if source_path.resolve() == target_path: + return ( + matches, + match_index, + offset, + feature_count, + str(target_path), + ) + offset += feature_count + + raise ValueError( + "The requested watershed file is not part of the Pan-India runoff " + f"boundary manifest: {target_path}. Manifest row: {target_row}" + ) + + +def _pan_india_series_path(series_dir, watershed_id): + return Path(series_dir) / f"{watershed_id // 1000:04d}" / f"{watershed_id}.csv" + + +def _pan_india_series_paths_exist(series_dirs, watershed_id): + return all( + _pan_india_series_path(series_dir, watershed_id).exists() + for series_dir in series_dirs + ) + + +def _read_pan_india_series(series_dir, watershed_id): + path = _pan_india_series_path(series_dir, watershed_id) + if not path.exists(): + return {}, path + + data = defaultdict(lambda: [0.0, 0, 0.0, 0]) + with path.open(newline="") as source: + for row in csv.reader(source): + if len(row) != 5: + raise ValueError( + f"Invalid Pan-India rainfall/runoff row in {path}: {row}" + ) + timestamp, rainfall_sum, rainfall_count, runoff_sum, runoff_count = row + values = data[timestamp] + values[0] += float(rainfall_sum) + values[1] += int(rainfall_count) + values[2] += float(runoff_sum) + values[3] += int(runoff_count) + + timeseries = {} + for timestamp in sorted(data): + rainfall_sum, rainfall_count, runoff_sum, runoff_count = data[timestamp] + values = {} + if rainfall_count: + values["Rainfall"] = rainfall_sum / rainfall_count + if runoff_count: + values["Runoff"] = runoff_sum / runoff_count + if values: + timeseries[timestamp] = values + return timeseries, path + + +def _read_pan_india_series_from_dirs(series_dirs, watershed_id): + combined = {} + missing_paths = [] + for series_dir in series_dirs: + timeseries, path = _read_pan_india_series(series_dir, watershed_id) + if path.exists(): + combined.update(timeseries) + else: + missing_paths.append(path) + return combined, missing_paths + + +def _resolve_duplicate_pan_india_ids( + *, + matches, + target_match_index, + target_offset, + target_count, + unresolved_uids, + series_dirs, +): + resolved = {} + remaining = set(unresolved_uids) + source_offset = target_offset + target_count + + for source_path, row in matches[target_match_index + 1 :]: + feature_count = int(row.get("feature_count") or 0) + uid_frame = gpd.read_file( + source_path, + columns=["uid"], + ignore_geometry=True, + ) + for position, uid in enumerate(uid_frame["uid"].astype(str)): + if uid not in remaining: + continue + watershed_id = source_offset + position + 1 + if _pan_india_series_paths_exist(series_dirs, watershed_id): + resolved[uid] = watershed_id + remaining.remove(uid) + if not remaining: + break + source_offset += feature_count + + return resolved + + +def _attach_pan_india_timeseries( + watersheds_gdf, + *, + state, + district, + block, + series_dirs, + watershed_root=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + watershed_ids=None, + uid_to_watershed_id=None, +): + if "uid" not in watersheds_gdf.columns: + raise ValueError("Precomputed watershed vector must contain uid") + + series_dirs = [Path(series_dir) for series_dir in series_dirs] + if not series_dirs: + raise ValueError( + "At least one Pan-India rainfall/runoff series folder is required" + ) + + year_gdf = watersheds_gdf.copy() + year_gdf["uid"] = year_gdf["uid"].astype(str) + if year_gdf["uid"].duplicated().any(): + raise ValueError("Duplicate uid values found in precomputed watersheds") + + if uid_to_watershed_id is not None: + watershed_source = "Pan-India watershed manifest" + watershed_ids = [uid_to_watershed_id.get(uid) for uid in year_gdf["uid"]] + else: + ( + matches, + target_match_index, + offset, + expected_count, + watershed_source, + ) = _pan_india_watershed_offset( + state, + district, + block, + watershed_root=watershed_root, + ) + if len(year_gdf) != expected_count: + raise ValueError( + "Precomputed watershed count differs from the Pan-India runoff " + f"manifest for {watershed_source}: vector={len(year_gdf)}, " + f"manifest={expected_count}" + ) + + if watershed_ids is None: + watershed_ids = [offset + position + 1 for position in range(len(year_gdf))] + unresolved_positions = [ + position + for position, watershed_id in enumerate(watershed_ids) + if not _pan_india_series_paths_exist(series_dirs, watershed_id) + ] + if unresolved_positions: + unresolved_uids = { + year_gdf.iloc[position]["uid"] for position in unresolved_positions + } + duplicate_ids = _resolve_duplicate_pan_india_ids( + matches=matches, + target_match_index=target_match_index, + target_offset=offset, + target_count=expected_count, + unresolved_uids=unresolved_uids, + series_dirs=series_dirs, + ) + for position in unresolved_positions: + uid = year_gdf.iloc[position]["uid"] + if uid in duplicate_ids: + watershed_ids[position] = duplicate_ids[uid] + elif len(watershed_ids) != len(year_gdf): + raise ValueError( + "Pan-India watershed ID count differs from precomputed " + f"watersheds: ids={len(watershed_ids)}, " + f"watersheds={len(year_gdf)}" + ) + + timeseries_values = [] + missing_paths = [] + for uid, watershed_id in zip(year_gdf["uid"], watershed_ids): + if watershed_id is None: + timeseries_values.append({}) + missing_paths.append(f"uid={uid} has no Pan-India runoff series") + continue + timeseries, missing_for_uid = _read_pan_india_series_from_dirs( + series_dirs, + watershed_id, + ) + timeseries_values.append(timeseries) + missing_paths.extend(str(path) for path in missing_for_uid) + + year_gdf["timeseries"] = timeseries_values + return ( + year_gdf, + watershed_source, + missing_paths, + watershed_ids, + ) + + +def _available_pan_india_series_ids(series_dirs): + available_ids = None + for series_dir in series_dirs: + current_ids = { + int(path.stem) + for path in Path(series_dir).glob("*/*.csv") + if path.stem.isdigit() + } + available_ids = ( + current_ids if available_ids is None else available_ids & current_ids + ) + return available_ids or set() + + +def _build_pan_india_uid_index( + series_dirs, + watershed_root=PRECOMPUTED_TEHSIL_WATERSHED_DIR, +): + available_ids = _available_pan_india_series_ids(series_dirs) + if not available_ids: + raise FileNotFoundError( + "No common Pan-India rainfall/runoff CSV files were found for " + f"the requested years: {[str(path) for path in series_dirs]}" + ) + + uid_to_watershed_id = {} + offset = 0 + matches = find_pan_india_watersheds(Path(watershed_root)) + for source_path, row in matches: + feature_count = int(row.get("feature_count") or 0) + uid_frame = gpd.read_file( + source_path, + columns=["uid"], + ignore_geometry=True, + ) + if len(uid_frame) != feature_count: + raise ValueError( + "Watershed count differs from the Pan-India manifest for " + f"{source_path}: vector={len(uid_frame)}, " + f"manifest={feature_count}" + ) + for position, uid in enumerate(uid_frame["uid"].astype(str)): + watershed_id = offset + position + 1 + if watershed_id in available_ids: + uid_to_watershed_id[uid] = watershed_id + offset += feature_count + + print( + "Built Pan-India rainfall/runoff index for " + f"{len(uid_to_watershed_id)} unique watersheds" + ) + return matches, uid_to_watershed_id + + +def _read_complete_uid_cache(path, required_uids, value_columns): + path = Path(path) + if not path.exists(): + return None + + frame = pd.read_parquet(path) + required_columns = {CACHE_UID_COLUMN, *value_columns} + missing_columns = required_columns - set(frame.columns) + if missing_columns: + print( + f"Ignoring incomplete cache {path}; missing columns: " + f"{sorted(missing_columns)}" + ) + return None + + frame[CACHE_UID_COLUMN] = frame[CACHE_UID_COLUMN].astype(str) + if frame[CACHE_UID_COLUMN].duplicated().any(): + print(f"Ignoring cache with duplicate UIDs: {path}") + return None + + missing_uids = set(required_uids) - set(frame[CACHE_UID_COLUMN]) + if missing_uids: + print( + f"Ignoring incomplete cache {path}; " + f"missing {len(missing_uids)} UIDs" + ) + return None + return frame + + +def _iter_unique_watershed_partitions( + matches, + *, + allowed_uids=None, + area_limit=None, +): + seen_uids = set() + selected_matches = matches if area_limit is None else matches[: int(area_limit)] + + for source_path, row in selected_matches: + area_gdf = read_validated_vector_file( + source_path, + f"Watershed partition has no valid geometries: {source_path}", + ) + if CACHE_UID_COLUMN not in area_gdf.columns: + raise ValueError(f"Watershed partition must contain uid: {source_path}") + + area_gdf[CACHE_UID_COLUMN] = area_gdf[CACHE_UID_COLUMN].astype(str) + keep = ~area_gdf[CACHE_UID_COLUMN].isin(seen_uids) + if allowed_uids is not None: + keep &= area_gdf[CACHE_UID_COLUMN].isin(allowed_uids) + area_gdf = area_gdf.loc[keep].copy() + seen_uids.update(area_gdf[CACHE_UID_COLUMN]) + if not area_gdf.empty: + yield source_path, row, area_gdf + + +def _ensure_pan_india_aquifer_cache( + *, + matches, + required_uids, + output_base_dir, + aquifer_vector_path, + area_limit=None, +): + cache_path = _aquifer_cache_path(output_base_dir) + value_column = "weighted_avg_yeild" + cached = _read_complete_uid_cache( + cache_path, + required_uids, + [value_column], + ) + if cached is not None: + print(f"Using Pan-India aquifer cache: {cache_path}") + return cached.set_index(CACHE_UID_COLUMN), cache_path + + aquifers_gdf = read_validated_vector_file( + aquifer_vector_path, + f"Aquifer source file has no valid geometries: " f"{aquifer_vector_path}", + ) + aquifers_projected = _prepare_aquifers_for_intersection(aquifers_gdf) + records = [] + processed = 0 + for source_path, _, watersheds_gdf in _iter_unique_watershed_partitions( + matches, + allowed_uids=set(required_uids), + area_limit=area_limit, + ): + result = _compute_aquifer_properties_for_watersheds( + watersheds_gdf=watersheds_gdf[[CACHE_UID_COLUMN, "geometry"]].copy(), + aquifers_projected=aquifers_projected, + ) + records.append( + pd.DataFrame( + { + CACHE_UID_COLUMN: result[CACHE_UID_COLUMN].astype(str), + value_column: result["total_weighted_yield"].astype(float), + } + ) + ) + processed += len(result) + print( + f"Cached aquifer yield for {processed} watersheds " + f"(latest partition: {source_path})" + ) + + if not records: + raise ValueError("No watershed records were available for aquifer caching") + + cache_frame = pd.concat(records, ignore_index=True) + cache_frame = cache_frame.drop_duplicates( + CACHE_UID_COLUMN, + keep="last", + ).sort_values(CACHE_UID_COLUMN) + if area_limit is None: + missing_uids = set(required_uids) - set(cache_frame[CACHE_UID_COLUMN]) + if missing_uids: + raise ValueError( + "Aquifer cache generation missed " + f"{len(missing_uids)} required watershed UIDs" + ) + _write_parquet_atomic(cache_frame, cache_path) + print( + f"Saved Pan-India aquifer cache with {len(cache_frame)} records: " + f"{cache_path}" + ) + return cache_frame.set_index(CACHE_UID_COLUMN), cache_path + + +def _decode_timeseries(value, uid): + if isinstance(value, dict): + return value + if value is None or (isinstance(value, float) and np.isnan(value)): + raise ValueError(f"Missing rainfall/runoff timeseries for uid={uid}") + try: + return json.loads(value) + except (TypeError, json.JSONDecodeError) as error: + raise ValueError( + f"Invalid rainfall/runoff timeseries JSON for uid={uid}" + ) from error + + +def _parse_timestamp(value): + return dt.datetime.fromisoformat(str(value).replace("Z", "+00:00")).date() + + +def _aggregate_rainfall_runoff(timeseries, periods): + totals = {key: {"Precipitation": 0.0, "RunOff": 0.0} for _, _, key in periods} + for timestamp, values in timeseries.items(): + if not isinstance(values, dict): + continue + day = _parse_timestamp(timestamp) + for period_start, period_end, key in periods: + if period_start <= day < period_end: + totals[key]["Precipitation"] += float( + values.get("Rainfall", values.get("rainfall", 0.0)) or 0.0 + ) + totals[key]["RunOff"] += float( + values.get("Runoff", values.get("runoff", 0.0)) or 0.0 + ) + break + return totals + + +class _RasterZonalGrid: + def __init__(self, watersheds_gdf, reference_path): + with rasterio.open(reference_path) as src: + if src.crs is None: + raise ValueError(f"Raster CRS is missing: {reference_path}") + working_gdf = watersheds_gdf.to_crs(src.crs) + minx, miny, maxx, maxy = working_gdf.total_bounds + requested = from_bounds(minx, miny, maxx, maxy, src.transform) + col_start = max(0, math.floor(requested.col_off)) + row_start = max(0, math.floor(requested.row_off)) + col_stop = min( + src.width, + math.ceil(requested.col_off + requested.width), + ) + row_stop = min( + src.height, + math.ceil(requested.row_off + requested.height), + ) + self.window = Window( + col_start, + row_start, + col_stop - col_start, + row_stop - row_start, + ) + if self.window.width <= 0 or self.window.height <= 0: + raise ValueError( + f"Watersheds do not overlap ET raster: {reference_path}" + ) + self.transform = src.window_transform(self.window) + self.shape = (int(self.window.height), int(self.window.width)) + self.crs = src.crs + self.reference_transform = src.transform + self.reference_width = src.width + self.reference_height = src.height + self.geometries = list(working_gdf.geometry) + self._geometry_masks = None + + def read_flux(self, raster_path, negative_as_nodata): + with rasterio.open(raster_path) as src: + if ( + src.crs != self.crs + or src.width != self.reference_width + or src.height != self.reference_height + or not src.transform.almost_equals(self.reference_transform) + ): + raise ValueError( + f"ET raster grid does not match reference raster: {raster_path}" + ) + + data = src.read(1, window=self.window, masked=True) + values = np.asarray(data.filled(np.nan), dtype=np.float64) + valid = ~np.ma.getmaskarray(data) & np.isfinite(values) + if src.nodata is not None: + valid &= values != src.nodata + if negative_as_nodata: + valid &= values >= 0 + else: + values = np.where(values > 0, values, 0.0) + return values, valid + + def means(self, values, valid): + if self._geometry_masks is None: + self._geometry_masks = [] + for geometry in self.geometries: + center_mask = geometry_mask( + [mapping(geometry)], + out_shape=self.shape, + transform=self.transform, + invert=True, + all_touched=False, + ) + touched_mask = geometry_mask( + [mapping(geometry)], + out_shape=self.shape, + transform=self.transform, + invert=True, + all_touched=True, + ) + self._geometry_masks.append((center_mask, touched_mask)) + + means = [] + for center_mask, touched_mask in self._geometry_masks: + selected = center_mask & valid + if not selected.any(): + selected = touched_mask & valid + means.append(float(values[selected].mean()) if selected.any() else np.nan) + return np.asarray(means, dtype=np.float64) + + +def _aggregate_flux_rasters( + watersheds_gdf, + weighted_rasters, + *, + negative_as_nodata, +): + if not weighted_rasters: + return np.zeros(len(watersheds_gdf), dtype=np.float64) + + grid = _RasterZonalGrid(watersheds_gdf, weighted_rasters[0][0]) + total = np.zeros(grid.shape, dtype=np.float64) + has_value = np.zeros(grid.shape, dtype=bool) + + for raster_path, day_count in weighted_rasters: + values, valid = grid.read_flux( + raster_path, + negative_as_nodata=negative_as_nodata, + ) + total[valid] += values[valid] * SECONDS_PER_DAY * float(day_count) + has_value |= valid + + return grid.means(total, has_value) + + +def _folder_has_tifs(path): + path = Path(path) + return path.is_dir() and next(path.glob("*.tif"), None) is not None + + +def _preferred_et_source_root(et_root, patch_filled_source, raw_source, raw_subfolder): + et_root = Path(et_root) + patch_filled_root = et_root / patch_filled_source + if _folder_has_tifs(patch_filled_root): + return patch_filled_root + return et_root / raw_source / raw_subfolder + + +def _et_source_name_from_root(root): + root = Path(root) + if root.name in { + FLDAS_CA_DAILY_PATCH_FILLED_SOURCE, + FLDAS_GLOBAL_MONTHLY_PATCH_FILLED_SOURCE, + }: + return root.name + if root.name in {"daily", "monthly"}: + return root.parent.name + return root.name + + +def _et_source_names_from_roots(roots_by_year): + return sorted( + { + _et_source_name_from_root(root) + for root in roots_by_year.values() + } + ) + + +def _aggregate_source_folder(source_names): + source_names = sorted(set(source_names)) + if len(source_names) == 1: + return source_names[0] + return "__".join(source_names) + + +def _cache_et_sources(frame): + sources = set() + if CACHE_ET_SOURCE_COLUMN not in frame.columns: + return sources + for value in frame[CACHE_ET_SOURCE_COLUMN].dropna().astype(str): + sources.update( + source.strip() + for source in value.split(",") + if source.strip() + ) + return sources + + +def _et_source_signature(et_roots_by_year): + daily_roots = _daily_roots_by_year(et_roots_by_year) + monthly_roots = _monthly_roots_by_year(et_roots_by_year) + return "|".join( + ( + f"{_year_key(source_year)}:" + f"daily={_et_source_name_from_root(daily_roots[source_year])};" + f"monthly={_et_source_name_from_root(monthly_roots[source_year])}" + ) + for source_year in sorted(et_roots_by_year) + ) + + +def _daily_roots_by_year(et_roots_by_year): + return { + source_year: _preferred_et_source_root( + et_root, + FLDAS_CA_DAILY_PATCH_FILLED_SOURCE, + FLDAS_CA_DAILY_SOURCE, + "daily", + ) + for source_year, et_root in et_roots_by_year.items() + } + + +def _monthly_roots_by_year(et_roots_by_year): + return { + source_year: _preferred_et_source_root( + et_root, + FLDAS_GLOBAL_MONTHLY_PATCH_FILLED_SOURCE, + FLDAS_GLOBAL_MONTHLY_SOURCE, + "monthly", + ) + for source_year, et_root in et_roots_by_year.items() + } + + +def _source_root_for_date(roots_by_year, day, source_name): + source_year = _source_year_for_date(day) + root = roots_by_year.get(source_year) + if root is None: + raise FileNotFoundError( + f"{source_name} folder for source year {_year_key(source_year)} " + f"is required for {day.isoformat()}" + ) + return root + + +def _month_path(monthly_roots_by_year, day): + monthly_root = _source_root_for_date( + monthly_roots_by_year, + day, + "Monthly global ET", + ) + path = monthly_root / f"{day:%Y%m}.tif" + if not path.exists(): + raise FileNotFoundError(f"Monthly global ET raster not found: {path}") + return path + + +def _monthly_weights( + monthly_roots_by_year, + start_date, + end_date, + included_dates=None, +): + if included_dates is None: + dates = [] + current = start_date + while current < end_date: + dates.append(current) + current += dt.timedelta(days=1) + else: + dates = list(included_dates) + + month_counts = Counter(day.replace(day=1) for day in dates) + return [ + (_month_path(monthly_roots_by_year, month), day_count) + for month, day_count in sorted(month_counts.items()) + ] + + +def _daily_rasters(daily_roots_by_year, start_date, end_date): + available = [] + missing = [] + current = start_date + while current < end_date: + daily_root = _source_root_for_date( + daily_roots_by_year, + current, + "Daily CA ET", + ) + path = daily_root / f"{current:%Y%m%d}.tif" + if path.exists(): + available.append((path, 1)) + else: + missing.append(current) + current += dt.timedelta(days=1) + return available, missing + + +def _uses_daily_et(watersheds_gdf, daily_roots_by_year): + reference_path = None + for daily_root in daily_roots_by_year.values(): + reference_path = next(iter(sorted(daily_root.glob("*.tif"))), None) + if reference_path is not None: + break + if reference_path is None: + return False + + with rasterio.open(reference_path) as src: + raster_bounds = box(*src.bounds) + watersheds = watersheds_gdf.to_crs(src.crs) + return raster_bounds.covers(watersheds.geometry.union_all()) + + +def _calculate_period_et(watersheds_gdf, periods, et_roots_by_year): + daily_roots = _daily_roots_by_year(et_roots_by_year) + monthly_roots = _monthly_roots_by_year(et_roots_by_year) + daily_source = ",".join(_et_source_names_from_roots(daily_roots)) + monthly_source = ",".join(_et_source_names_from_roots(monthly_roots)) + use_daily = _uses_daily_et(watersheds_gdf, daily_roots) + result = {} + + for period_start, period_end, key in periods: + if use_daily: + daily_rasters, missing_dates = _daily_rasters( + daily_roots, + period_start, + period_end, + ) + values = _aggregate_flux_rasters( + watersheds_gdf, + daily_rasters, + negative_as_nodata=True, + ) + if missing_dates: + fallback = _aggregate_flux_rasters( + watersheds_gdf, + _monthly_weights( + monthly_roots, + period_start, + period_end, + included_dates=missing_dates, + ), + negative_as_nodata=False, + ) + values = values + fallback + else: + values = _aggregate_flux_rasters( + watersheds_gdf, + _monthly_weights(monthly_roots, period_start, period_end), + negative_as_nodata=False, + ) + + if np.isnan(values).any(): + missing_count = int(np.isnan(values).sum()) + raise ValueError( + f"ET could not be calculated for {missing_count} watersheds " + f"during period {key}" + ) + result[key] = values + + return result, daily_source if use_daily else monthly_source + + +def _write_integrated_flux_raster( + weighted_rasters, + *, + negative_as_nodata, + output_path, +): + output_path = Path(output_path) + if output_path.exists(): + return output_path + if not weighted_rasters: + return None + + output_path.parent.mkdir(parents=True, exist_ok=True) + reference_path = weighted_rasters[0][0] + with rasterio.open(reference_path) as reference: + profile = reference.profile.copy() + shape = (reference.height, reference.width) + reference_crs = reference.crs + reference_transform = reference.transform + + total = np.zeros(shape, dtype=np.float64) + has_value = np.zeros(shape, dtype=bool) + for raster_path, day_count in weighted_rasters: + with rasterio.open(raster_path) as src: + if ( + src.crs != reference_crs + or src.width != shape[1] + or src.height != shape[0] + or not src.transform.almost_equals(reference_transform) + ): + raise ValueError( + "ET raster grid does not match aggregate reference: " + f"{raster_path}" + ) + data = src.read(1, masked=True) + values = np.asarray(data.filled(np.nan), dtype=np.float64) + valid = ~np.ma.getmaskarray(data) & np.isfinite(values) + if src.nodata is not None: + valid &= values != src.nodata + if negative_as_nodata: + valid &= values >= 0 + else: + values = np.where(values > 0, values, 0.0) + total[valid] += values[valid] * SECONDS_PER_DAY * float(day_count) + has_value |= valid + + nodata = -9999.0 + output = np.where(has_value, total, nodata).astype(np.float32) + profile.update( + count=1, + dtype="float32", + nodata=nodata, + compress="deflate", + predictor=3, + ) + temporary_path = output_path.with_name( + f"{output_path.stem}.{os.getpid()}.tmp{output_path.suffix}" + ) + with rasterio.open(temporary_path, "w", **profile) as dst: + dst.write(output, 1) + temporary_path.replace(output_path) + return output_path + + +def _ensure_period_et_aggregate_rasters( + *, + periods, + et_roots_by_year, + aggregate_root, +): + daily_roots = _daily_roots_by_year(et_roots_by_year) + monthly_roots = _monthly_roots_by_year(et_roots_by_year) + daily_source_names = _et_source_names_from_roots(daily_roots) + monthly_source_names = _et_source_names_from_roots(monthly_roots) + daily_source = ",".join(daily_source_names) + monthly_source = ",".join(monthly_source_names) + daily_aggregate_folder = _aggregate_source_folder(daily_source_names) + monthly_aggregate_folder = _aggregate_source_folder(monthly_source_names) + aggregate_root = Path(aggregate_root) + result = {} + + for period_start, period_end, key in periods: + file_key = key.replace("-", "") + daily_rasters, missing_dates = _daily_rasters( + daily_roots, + period_start, + period_end, + ) + daily_path = _write_integrated_flux_raster( + daily_rasters, + negative_as_nodata=True, + output_path=aggregate_root / daily_aggregate_folder / f"{file_key}.tif", + ) + global_path = _write_integrated_flux_raster( + _monthly_weights( + monthly_roots, + period_start, + period_end, + ), + negative_as_nodata=False, + output_path=aggregate_root / monthly_aggregate_folder / f"{file_key}.tif", + ) + missing_path = None + if missing_dates: + missing_path = _write_integrated_flux_raster( + _monthly_weights( + monthly_roots, + period_start, + period_end, + included_dates=missing_dates, + ), + negative_as_nodata=False, + output_path=aggregate_root + / f"{monthly_aggregate_folder}_missing_daily" + / f"{file_key}.tif", + ) + + result[key] = { + "daily": daily_path, + "global": global_path, + "missing_daily": missing_path, + "daily_source": daily_source, + "global_source": monthly_source, + } + return result + + +def _integrated_raster_means(watersheds_gdf, raster_path, grid=None): + grid = grid or _RasterZonalGrid(watersheds_gdf, raster_path) + values, valid = grid.read_flux( + raster_path, + negative_as_nodata=True, + ) + return grid.means(values, valid) + + +def _raster_covers_watersheds(watersheds_gdf, raster_path): + if raster_path is None: + return False + with rasterio.open(raster_path) as src: + raster_bounds = box(*src.bounds) + watersheds = watersheds_gdf.to_crs(src.crs) + return raster_bounds.covers(watersheds.geometry.union_all()) + + +def _calculate_period_et_from_aggregates( + watersheds_gdf, + periods, + aggregate_rasters, +): + daily_reference = next( + ( + aggregate_rasters[key]["daily"] + for _, _, key in periods + if aggregate_rasters[key]["daily"] is not None + ), + None, + ) + use_daily = _raster_covers_watersheds( + watersheds_gdf, + daily_reference, + ) + first_key = periods[0][2] + primary_reference = ( + daily_reference if use_daily else aggregate_rasters[first_key]["global"] + ) + primary_grid = _RasterZonalGrid(watersheds_gdf, primary_reference) + missing_daily_grid = None + result = {} + + for _, _, key in periods: + paths = aggregate_rasters[key] + if use_daily: + if paths["daily"] is None: + values = np.zeros(len(watersheds_gdf), dtype=np.float64) + else: + values = _integrated_raster_means( + watersheds_gdf, + paths["daily"], + grid=primary_grid, + ) + if paths["missing_daily"] is not None: + if missing_daily_grid is None: + missing_daily_grid = _RasterZonalGrid( + watersheds_gdf, + paths["missing_daily"], + ) + values += _integrated_raster_means( + watersheds_gdf, + paths["missing_daily"], + grid=missing_daily_grid, + ) + else: + values = _integrated_raster_means( + watersheds_gdf, + paths["global"], + grid=primary_grid, + ) + + if np.isnan(values).any(): + missing_count = int(np.isnan(values).sum()) + raise ValueError( + f"Cached ET could not be calculated for {missing_count} " + f"watersheds during period {key}" + ) + result[key] = values + + source = ( + aggregate_rasters[first_key]["daily_source"] + if use_daily + else aggregate_rasters[first_key]["global_source"] + ) + return result, source + + +def _ensure_pan_india_et_cache( + *, + matches, + required_uids, + year, + is_annual, + et_roots_by_year, + output_base_dir, + area_limit=None, +): + periods = _build_periods(year, is_annual) + period_columns = [key for _, _, key in periods] + source_year_keys = ",".join( + _year_key(source_year) for source_year in sorted(et_roots_by_year) + ) + cache_path = _et_cache_path(output_base_dir, year, is_annual) + source_signature = _et_source_signature(et_roots_by_year) + expected_cache_sources = set( + _et_source_names_from_roots(_daily_roots_by_year(et_roots_by_year)) + ) | set(_et_source_names_from_roots(_monthly_roots_by_year(et_roots_by_year))) + cached = _read_complete_uid_cache( + cache_path, + required_uids, + [ + *period_columns, + CACHE_ET_SOURCE_COLUMN, + CACHE_ET_SOURCE_SIGNATURE_COLUMN, + SOURCE_YEARS_COLUMN, + ], + ) + if cached is not None: + cached_sources = _cache_et_sources(cached) + cached_signatures = set( + cached[CACHE_ET_SOURCE_SIGNATURE_COLUMN].dropna().astype(str) + ) + if cached_signatures != {source_signature}: + print( + f"Ignoring ET cache with stale source signature: {cache_path}; " + f"cache={sorted(cached_signatures)}, " + f"current={source_signature}" + ) + elif cached_sources and not cached_sources.issubset(expected_cache_sources): + print( + f"Ignoring ET cache with stale source rasters: {cache_path}; " + f"cache={sorted(cached_sources)}, " + f"current={sorted(expected_cache_sources)}" + ) + else: + print(f"Using Pan-India ET cache: {cache_path}") + return cached.set_index(CACHE_UID_COLUMN), cache_path + + aggregate_rasters = _ensure_period_et_aggregate_rasters( + periods=periods, + et_roots_by_year=et_roots_by_year, + aggregate_root=_et_aggregate_root( + output_base_dir, + year, + is_annual, + ), + ) + records = [] + processed = 0 + for source_path, _, watersheds_gdf in _iter_unique_watershed_partitions( + matches, + allowed_uids=set(required_uids), + area_limit=area_limit, + ): + record = pd.DataFrame( + { + CACHE_UID_COLUMN: watersheds_gdf[CACHE_UID_COLUMN].astype(str), + SOURCE_YEARS_COLUMN: source_year_keys, + CACHE_ET_SOURCE_SIGNATURE_COLUMN: source_signature, + } + ) + try: + et_by_period, et_source = _calculate_period_et_from_aggregates( + watersheds_gdf, + periods, + aggregate_rasters, + ) + record[CACHE_ET_SOURCE_COLUMN] = et_source + record[CACHE_ET_ERROR_COLUMN] = None + for key in period_columns: + record[key] = et_by_period[key] + except Exception as error: + record[CACHE_ET_SOURCE_COLUMN] = None + record[CACHE_ET_ERROR_COLUMN] = str(error) + for key in period_columns: + record[key] = np.nan + print( + f"ET cache unavailable for {len(record)} watersheds in " + f"{source_path}: {error}" + ) + records.append(record) + processed += len(record) + print( + f"Cached {len(period_columns)} ET period(s) for " + f"{processed} watersheds (latest partition: {source_path})" + ) + + if not records: + raise ValueError("No watershed records were available for ET caching") + + cache_frame = pd.concat(records, ignore_index=True) + cache_frame = cache_frame.drop_duplicates( + CACHE_UID_COLUMN, + keep="last", + ).sort_values(CACHE_UID_COLUMN) + if area_limit is None: + missing_uids = set(required_uids) - set(cache_frame[CACHE_UID_COLUMN]) + if missing_uids: + raise ValueError( + "ET cache generation missed " + f"{len(missing_uids)} required watershed UIDs" + ) + _write_parquet_atomic(cache_frame, cache_path) + print(f"Saved Pan-India ET cache with {len(cache_frame)} records: " f"{cache_path}") + return cache_frame.set_index(CACHE_UID_COLUMN), cache_path + + +def _add_annual_well_depth( + result_gdf, + annual_columns, + aquifer_vector_path, + aquifers_gdf=None, + aquifer_cache=None, +): + if aquifer_cache is not None: + uid_values = result_gdf[CACHE_UID_COLUMN].astype(str) + missing_uids = set(uid_values) - set(aquifer_cache.index) + if missing_uids: + raise ValueError( + "Aquifer cache is missing " f"{len(missing_uids)} watershed UIDs" + ) + result_gdf["weighted_avg_yeild"] = aquifer_cache.reindex(uid_values)[ + "weighted_avg_yeild" + ].to_numpy(dtype=float) + else: + if aquifers_gdf is None: + aquifers_gdf = read_validated_vector_file( + aquifer_vector_path, + f"Aquifer source file has no valid geometries: " + f"{aquifer_vector_path}", + ) + aquifer_result = _compute_aquifer_properties_for_watersheds( + watersheds_gdf=result_gdf[["uid", "geometry"]].copy(), + aquifers_gdf=aquifers_gdf, + ) + result_gdf["weighted_avg_yeild"] = aquifer_result[ + "total_weighted_yield" + ].to_numpy() + + for index, row in result_gdf.iterrows(): + weighted_yield = row["weighted_avg_yeild"] + for column in annual_columns: + values = json.loads(row[column]) + values["WellDepth"] = ( + values["DeltaG"] / (float(weighted_yield) * 1000.0) + if pd.notna(weighted_yield) and float(weighted_yield) > 0 + else None + ) + result_gdf.at[index, column] = json.dumps( + values, + separators=(",", ":"), + ) + + return _add_annual_net_columns(result_gdf, annual_columns) + + +def _add_annual_net_columns(result_gdf, annual_columns): + for start_index in range(len(annual_columns) - 4): + window = annual_columns[start_index : start_index + 5] + start_year = window[0].split("_")[0] + end_year = window[-1].split("_")[1][-2:] + net_column = f"Net{start_year}_{end_year}" + + def net_value(row): + well_depths = [ + json.loads(row[column]).get("WellDepth") for column in window + ] + if any(value is None for value in well_depths): + return None + return sum(float(value) for value in well_depths) + + result_gdf[net_column] = result_gdf.apply(net_value, axis=1) + + return result_gdf + + +def _run_generate_hydrology_area_local( + *, + state, + district, + block, + start_year, + end_year, + is_annual=False, + gee_account_id=None, + hydrology_output_root=HYDROLOGY_OUTPUT_ROOT, + output_base_dir=HYDROLOGY_LOCAL_OUTPUT_DIR, + aquifer_vector_path=AQUIFER_VECTOR_PATH, + push_to_geoserver=True, + sync_layer_metadata=True, + watersheds_gdf=None, + watershed_source=None, + uid_to_watershed_id=None, + aquifers_gdf=None, + aquifer_cache=None, + et_cache_by_year=None, + et_cache_paths_by_year=None, + layer_name_override=None, + write_output=True, +): + _ = gee_account_id + state = _normalize_location(state, "state") + district = _normalize_location(district, "district") + block = _normalize_location(block, "block") + start_year = int(start_year) + end_year = int(end_year) + if end_year < start_year: + raise ValueError("end_year must be greater than or equal to start_year") + + result_gdf = None + uid_to_index = None + cumulative_g = {} + period_columns = [] + et_sources = set() + input_paths = [] + pan_india_watershed_ids = None + if watersheds_gdf is None: + watersheds_gdf, watershed_source = load_precomputed_watersheds( + state=state, + district=district, + block=block, + ) + else: + watersheds_gdf = watersheds_gdf.copy() + watershed_source = watershed_source or "provided watershed partition" + + for year in range(start_year, end_year + 1): + periods = _build_periods(year, is_annual) + source_inputs = _resolve_source_year_inputs( + output_year=year, + periods=periods, + hydrology_output_root=hydrology_output_root, + ) + source_years = sorted(source_inputs) + series_dirs = [ + source_inputs[source_year][0] for source_year in source_years + ] + et_roots_by_year = { + source_year: source_inputs[source_year][1] + for source_year in source_years + } + ( + year_gdf, + runoff_watershed_source, + missing_series_paths, + pan_india_watershed_ids, + ) = _attach_pan_india_timeseries( + watersheds_gdf, + state=state, + district=district, + block=block, + series_dirs=series_dirs, + watershed_ids=pan_india_watershed_ids, + uid_to_watershed_id=uid_to_watershed_id, + ) + if missing_series_paths: + raise FileNotFoundError( + "Pan-India rainfall/runoff remains unavailable for " + f"{len(missing_series_paths)} of {len(year_gdf)} watersheds " + f"in {_year_key(year)} after checking duplicate watershed " + f"IDs. First missing files: {missing_series_paths[:3]}" + ) + + if result_gdf is None: + result_gdf = year_gdf.drop(columns=["timeseries"]).copy() + uid_to_index = {uid: index for index, uid in result_gdf["uid"].items()} + cumulative_g = {uid: 0.0 for uid in uid_to_index} + elif set(year_gdf["uid"]) != set(uid_to_index): + raise ValueError( + "Watershed uid set differs between yearly inputs: " + f"{[str(path) for path in series_dirs]}" + ) + + if et_cache_by_year is not None and year in et_cache_by_year: + et_cache = et_cache_by_year[year] + uid_values = year_gdf[CACHE_UID_COLUMN].astype(str) + missing_uids = set(uid_values) - set(et_cache.index) + if missing_uids: + raise ValueError( + f"ET cache for {_year_key(year)} is missing " + f"{len(missing_uids)} watershed UIDs" + ) + selected_et = et_cache.reindex(uid_values) + invalid_et = selected_et[ + [key for _, _, key in periods] + ].isna().any(axis=1) + if invalid_et.any(): + errors = [] + if CACHE_ET_ERROR_COLUMN in selected_et.columns: + errors = ( + selected_et.loc[invalid_et, CACHE_ET_ERROR_COLUMN] + .dropna() + .astype(str) + .unique() + .tolist() + ) + error_suffix = f" First cache errors: {errors[:3]}" if errors else "" + raise ValueError( + f"ET cache for {_year_key(year)} has no usable value for " + f"{int(invalid_et.sum())} watershed UIDs.{error_suffix}" + ) + et_by_period = { + key: selected_et[key].to_numpy(dtype=float) for _, _, key in periods + } + selected_sources = set( + selected_et[CACHE_ET_SOURCE_COLUMN].dropna().astype(str) + ) + et_sources.update(selected_sources) + et_source = ",".join(sorted(selected_sources)) + else: + et_by_period, et_source = _calculate_period_et( + year_gdf, + periods, + et_roots_by_year, + ) + et_sources.add(et_source) + input_paths.append( + { + "year": _year_key(year), + "source_years": [ + _year_key(source_year) for source_year in source_years + ], + "rainfall_runoff": [str(path) for path in series_dirs], + "rainfall_runoff_watersheds": runoff_watershed_source, + "missing_rainfall_runoff_series": len(missing_series_paths), + "et": [ + str(et_roots_by_year[source_year]) + for source_year in source_years + ], + "et_source": et_source, + "et_cache": ( + str(et_cache_paths_by_year[year]) + if et_cache_paths_by_year and year in et_cache_paths_by_year + else None + ), + } + ) + + for source_index, row in enumerate(year_gdf.itertuples(index=False)): + uid = str(row.uid) + target_index = uid_to_index[uid] + timeseries = _decode_timeseries(row.timeseries, uid) + water_balance = _aggregate_rainfall_runoff(timeseries, periods) + + for _, _, key in periods: + values = water_balance[key] + values["ET"] = float(et_by_period[key][source_index]) + values["DeltaG"] = ( + values["Precipitation"] - values["RunOff"] - values["ET"] + ) + cumulative_g[uid] += values["DeltaG"] + values["G"] = cumulative_g[uid] + result_gdf.at[target_index, key] = json.dumps( + values, + separators=(",", ":"), + ) + + period_columns.extend(key for _, _, key in periods) + + if is_annual: + result_gdf = _add_annual_well_depth( + result_gdf, + annual_columns=period_columns, + aquifer_vector_path=aquifer_vector_path, + aquifers_gdf=aquifers_gdf, + aquifer_cache=aquifer_cache, + ) + + if not write_output: + return { + "gdf": result_gdf, + "is_annual": bool(is_annual), + "start_year": start_year, + "end_year": end_year, + "period_columns": period_columns, + "period_count": len(period_columns), + "watershed_count": len(result_gdf), + "et_sources": sorted(et_sources), + "inputs": input_paths, + } + + layer_name = layer_name_override or _layer_name( + district, + block, + is_annual, + ) + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=output_base_dir, + block_fallback="unknown_block", + ) + if output_path.exists(): + output_path.unlink() + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local hydrology vector: {asset_id}") + + geoserver_synced = False + if push_to_geoserver: + response = push_local_vector_to_geoserver( + path=os.path.splitext(asset_id)[0], + layer_name=layer_name, + workspace=GEOSERVER_WORKSPACE, + file_type="gpkg", + ) + print(f"GeoServer response: {response}") + geoserver_synced = isinstance(response, dict) and response.get( + "status_code" + ) in (200, 201, 202) + + layer_id = None + if sync_layer_metadata: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Hydrology", + algorithm=LOCAL_ALGORITHM, + algorithm_version=LOCAL_ALGORITHM_VERSION, + misc={ + "start_date": f"{start_year}-07-01", + "end_date": f"{end_year + 1}-06-30", + "is_annual": bool(is_annual), + "is_generated_locally": True, + "et_sources": sorted(et_sources), + "inputs": input_paths, + }, + ) + if layer_id and geoserver_synced: + update_layer_sync_status( + layer_id=layer_id, + sync_to_geoserver=True, + ) + + return { + "output": asset_id, + "layer_name": layer_name, + "is_annual": bool(is_annual), + "start_year": start_year, + "end_year": end_year, + "period_count": len(period_columns), + "watershed_count": len(result_gdf), + "et_sources": sorted(et_sources), + "geoserver_synced": geoserver_synced, + "layer_id": layer_id, + } + + +def _pan_india_run_root(output_base_dir, start_year, end_year, is_annual): + period = "annual" if is_annual else "fortnight" + return Path(output_base_dir) / "pan_india" / f"{start_year}_{end_year + 1}" / period + + +def _pan_india_area_output_path( + *, + output_base_dir, + state, + district, + block, + is_annual, +): + layer_name = _layer_name( + district, + block, + is_annual, + ) + return build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=output_base_dir, + block_fallback="unknown_block", + ) + + +def _run_generate_hydrology_pan_india_local( + *, + start_year, + end_year, + is_annual, + hydrology_output_root, + output_base_dir, + aquifer_vector_path, + push_to_geoserver, + sync_layer_metadata, + overwrite=False, + area_limit=None, +): + series_dirs = [] + et_roots_by_output_year = {} + for year in range(start_year, end_year + 1): + periods = _build_periods(year, is_annual) + source_inputs = _resolve_source_year_inputs( + output_year=year, + periods=periods, + hydrology_output_root=hydrology_output_root, + ) + source_years = sorted(source_inputs) + series_dirs.extend( + source_inputs[source_year][0] for source_year in source_years + ) + et_roots_by_output_year[year] = { + source_year: source_inputs[source_year][1] + for source_year in source_years + } + series_dirs = list(dict.fromkeys(series_dirs)) + + matches, uid_to_watershed_id = _build_pan_india_uid_index( + series_dirs=series_dirs, + ) + required_uids = set(uid_to_watershed_id) + aquifer_cache = None + aquifer_cache_path = None + if is_annual: + aquifer_cache, aquifer_cache_path = _ensure_pan_india_aquifer_cache( + matches=matches, + required_uids=required_uids, + output_base_dir=output_base_dir, + aquifer_vector_path=aquifer_vector_path, + area_limit=area_limit, + ) + + et_cache_by_year = {} + et_cache_paths_by_year = {} + for year in range(start_year, end_year + 1): + et_cache, et_cache_path = _ensure_pan_india_et_cache( + matches=matches, + required_uids=required_uids, + year=year, + is_annual=is_annual, + et_roots_by_year=et_roots_by_output_year[year], + output_base_dir=output_base_dir, + area_limit=area_limit, + ) + et_cache_by_year[year] = et_cache + et_cache_paths_by_year[year] = et_cache_path + + run_root = _pan_india_run_root( + output_base_dir, + start_year, + end_year, + is_annual, + ) + layers_root = run_root / "layers" + manifest_path = run_root / "manifest.csv" + manifest_path.parent.mkdir(parents=True, exist_ok=True) + + fieldnames = [ + "state", + "district", + "block", + "source", + "watershed_count", + "duplicate_count", + "status", + "output", + "layer_name", + "geoserver_synced", + "layer_id", + "error", + ] + seen_uids = set() + written_count = 0 + skipped_count = 0 + failed_count = 0 + watershed_count = 0 + + selected_matches = matches if area_limit is None else matches[: int(area_limit)] + with manifest_path.open("w", newline="") as manifest_file: + writer = csv.DictWriter(manifest_file, fieldnames=fieldnames) + writer.writeheader() + + for source_path, row in selected_matches: + state = str(row.get("state") or "").strip().lower() + district = str(row.get("district") or "").strip().lower() + block = str(row.get("tehsil") or "").strip().lower() + manifest_row = { + "state": state, + "district": district, + "block": block, + "source": str(source_path), + "watershed_count": 0, + "duplicate_count": 0, + "status": "failed", + "output": "", + "layer_name": "", + "geoserver_synced": False, + "layer_id": "", + "error": "", + } + + try: + area_gdf = read_validated_vector_file( + source_path, + f"Watershed partition has no valid geometries: {source_path}", + ) + if "uid" not in area_gdf.columns: + raise ValueError( + f"Watershed partition must contain uid: {source_path}" + ) + area_gdf["uid"] = area_gdf["uid"].astype(str) + duplicate_mask = area_gdf["uid"].isin(seen_uids) + duplicate_count = int(duplicate_mask.sum()) + manifest_row["duplicate_count"] = duplicate_count + manifest_row["watershed_count"] = len(area_gdf) + seen_uids.update(area_gdf["uid"]) + + output_path = _pan_india_area_output_path( + output_base_dir=layers_root, + state=state, + district=district, + block=block, + is_annual=is_annual, + ) + if ( + output_path.exists() + and not overwrite + and not push_to_geoserver + and not sync_layer_metadata + ): + manifest_row["status"] = "skipped_existing" + manifest_row["output"] = str(output_path) + watershed_count += len(area_gdf) + skipped_count += 1 + writer.writerow(manifest_row) + manifest_file.flush() + continue + + layer_name = _layer_name( + district, + block, + is_annual, + ) + result = _run_generate_hydrology_area_local( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + is_annual=is_annual, + hydrology_output_root=hydrology_output_root, + output_base_dir=layers_root, + aquifer_vector_path=aquifer_vector_path, + push_to_geoserver=push_to_geoserver, + sync_layer_metadata=sync_layer_metadata, + watersheds_gdf=area_gdf, + watershed_source=str(source_path), + uid_to_watershed_id=uid_to_watershed_id, + aquifer_cache=aquifer_cache, + et_cache_by_year=et_cache_by_year, + et_cache_paths_by_year=et_cache_paths_by_year, + layer_name_override=layer_name, + ) + if push_to_geoserver and not result["geoserver_synced"]: + raise RuntimeError( + f"GeoServer upload did not succeed for {layer_name}" + ) + if sync_layer_metadata and not result["layer_id"]: + raise RuntimeError( + f"Layer metadata sync did not succeed for {layer_name}" + ) + manifest_row["status"] = "written" + manifest_row["output"] = result["output"] + manifest_row["layer_name"] = result["layer_name"] + manifest_row["geoserver_synced"] = result["geoserver_synced"] + manifest_row["layer_id"] = result["layer_id"] or "" + watershed_count += len(area_gdf) + written_count += 1 + except Exception as error: + manifest_row["error"] = str(error) + failed_count += 1 + + writer.writerow(manifest_row) + manifest_file.flush() + + return { + "scope": "pan_india", + "manifest": str(manifest_path), + "output_root": str(layers_root), + "is_annual": bool(is_annual), + "start_year": start_year, + "end_year": end_year, + "partition_count": len(selected_matches), + "written_count": written_count, + "skipped_count": skipped_count, + "failed_count": failed_count, + "watershed_count": watershed_count, + "runoff_index_size": len(uid_to_watershed_id), + "aquifer_cache": (str(aquifer_cache_path) if aquifer_cache_path else None), + "et_caches": { + _year_key(year): str(path) for year, path in et_cache_paths_by_year.items() + }, + } + + +def _run_generate_hydrology_base_layer_local( + *, + year=None, + start_year=None, + end_year=None, + is_annual, + hydrology_output_root=HYDROLOGY_OUTPUT_ROOT, + output_base_dir=HYDROLOGY_BASE_LAYER_ROOT, + cache_base_dir=HYDROLOGY_LOCAL_OUTPUT_DIR, + aquifer_vector_path=AQUIFER_VECTOR_PATH, + overwrite=False, + area_limit=None, +): + year, hydrology_end_year = _resolve_base_layer_year_bounds( + year=year, + start_year=start_year, + end_year=end_year, + ) + + overwrite = _parse_bool(overwrite) + layer_name = _base_layer_name(year, is_annual) + output_path = _base_layer_path(output_base_dir, year, is_annual) + manifest_path = output_path.with_name(f"{output_path.stem}_manifest.csv") + if output_path.exists() and not overwrite: + return { + "scope": "pan_india_base_layer", + "status": "skipped_existing", + "output": str(output_path), + "manifest": str(manifest_path) if manifest_path.exists() else None, + "layer_name": layer_name, + "start_year": year, + "end_year": hydrology_end_year, + "year_key": _year_key(year), + "is_annual": bool(is_annual), + } + + periods = _build_periods(year, is_annual) + source_inputs = _resolve_source_year_inputs( + output_year=year, + periods=periods, + hydrology_output_root=hydrology_output_root, + ) + source_years = sorted(source_inputs) + series_dirs = [source_inputs[source_year][0] for source_year in source_years] + et_roots_by_year = { + source_year: source_inputs[source_year][1] + for source_year in source_years + } + matches, uid_to_watershed_id = _build_pan_india_uid_index( + series_dirs=series_dirs, + ) + required_uids = set(uid_to_watershed_id) + aquifer_cache = None + aquifer_cache_path = None + if is_annual: + aquifer_cache, aquifer_cache_path = _ensure_pan_india_aquifer_cache( + matches=matches, + required_uids=required_uids, + output_base_dir=cache_base_dir, + aquifer_vector_path=aquifer_vector_path, + area_limit=area_limit, + ) + + et_cache, et_cache_path = _ensure_pan_india_et_cache( + matches=matches, + required_uids=required_uids, + year=year, + is_annual=is_annual, + et_roots_by_year=et_roots_by_year, + output_base_dir=cache_base_dir, + area_limit=area_limit, + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + fieldnames = [ + "state", + "district", + "block", + "source", + "watershed_count", + "status", + "error", + ] + records = [] + written_count = 0 + failed_count = 0 + watershed_count = 0 + selected_matches = matches if area_limit is None else matches[: int(area_limit)] + + with manifest_path.open("w", newline="") as manifest_file: + writer = csv.DictWriter(manifest_file, fieldnames=fieldnames) + writer.writeheader() + + for source_path, row, area_gdf in _iter_unique_watershed_partitions( + selected_matches, + allowed_uids=required_uids, + ): + state = str(row.get("state") or "unknown_state").strip().lower() + district = str(row.get("district") or "unknown_district").strip().lower() + block = str(row.get("tehsil") or Path(source_path).stem).strip().lower() + manifest_row = { + "state": state, + "district": district, + "block": block, + "source": str(source_path), + "watershed_count": len(area_gdf), + "status": "failed", + "error": "", + } + try: + result = _run_generate_hydrology_area_local( + state=state, + district=district, + block=block, + start_year=year, + end_year=year, + is_annual=is_annual, + hydrology_output_root=hydrology_output_root, + output_base_dir=output_base_dir, + aquifer_vector_path=aquifer_vector_path, + push_to_geoserver=False, + sync_layer_metadata=False, + watersheds_gdf=area_gdf, + watershed_source=str(source_path), + uid_to_watershed_id=uid_to_watershed_id, + aquifer_cache=aquifer_cache, + et_cache_by_year={year: et_cache}, + et_cache_paths_by_year={year: et_cache_path}, + write_output=False, + ) + records.append(result["gdf"]) + watershed_count += len(result["gdf"]) + written_count += 1 + manifest_row["status"] = "written" + except Exception as error: + failed_count += 1 + manifest_row["error"] = str(error) + + writer.writerow(manifest_row) + manifest_file.flush() + + if not records: + raise RuntimeError( + "Hydrology base layer generation produced no watershed records. " + f"See manifest: {manifest_path}" + ) + + combined = pd.concat(records, ignore_index=True) + result_gdf = gpd.GeoDataFrame( + combined, + geometry="geometry", + crs=records[0].crs, + ) + if output_path.exists(): + output_path.unlink() + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local hydrology base layer: {asset_id}") + + return { + "scope": "pan_india_base_layer", + "status": "written_with_failures" if failed_count else "written", + "output": asset_id, + "manifest": str(manifest_path), + "layer_name": layer_name, + "start_year": year, + "end_year": hydrology_end_year, + "year_key": _year_key(year), + "is_annual": bool(is_annual), + "period_count": len(_build_periods(year, is_annual)), + "watershed_count": watershed_count, + "partition_count": len(selected_matches), + "written_count": written_count, + "failed_count": failed_count, + "runoff_index_size": len(uid_to_watershed_id), + "aquifer_cache": str(aquifer_cache_path) if aquifer_cache_path else None, + "et_cache": str(et_cache_path), + } + + +def _run_clip_hydrology_area_from_base_layers( + *, + state, + district, + block, + start_year, + end_year, + is_annual, + output_base_dir=HYDROLOGY_LOCAL_OUTPUT_DIR, + base_layer_root=HYDROLOGY_BASE_LAYER_ROOT, + push_to_geoserver=True, + sync_layer_metadata=True, +): + state = _normalize_location(state, "state") + district = _normalize_location(district, "district") + block = _normalize_location(block, "block") + start_year = int(start_year) + end_year = int(end_year) + if start_year != FORTNIGHT_ANCHOR_DATE.year: + raise ValueError( + "Local hydrology clipping requires start_year=2017 because the " + "fortnightly cadence is anchored at 2017-07-01" + ) + if end_year < start_year: + raise ValueError("end_year must be greater than or equal to start_year") + + watersheds_gdf, watershed_source = load_precomputed_watersheds( + state=state, + district=district, + block=block, + ) + if CACHE_UID_COLUMN not in watersheds_gdf.columns: + raise ValueError("Precomputed watershed vector must contain uid") + + result_gdf = watersheds_gdf.copy() + result_gdf[CACHE_UID_COLUMN] = result_gdf[CACHE_UID_COLUMN].astype(str) + if result_gdf[CACHE_UID_COLUMN].duplicated().any(): + raise ValueError("Duplicate uid values found in precomputed watersheds") + + uid_values = result_gdf[CACHE_UID_COLUMN].astype(str) + period_columns = [] + input_paths = [] + + for year in range(start_year, end_year + 1): + base_path = _base_layer_path(base_layer_root, year, is_annual) + if not base_path.exists(): + raise FileNotFoundError( + f"Hydrology base layer not found: {base_path}. " + "Generate it first using the /api/v1/pan-india/ hydrology API." + ) + + periods = _build_periods(year, is_annual) + year_columns = [key for _, _, key in periods] + base_frame = gpd.read_file(base_path, ignore_geometry=True) + if CACHE_UID_COLUMN not in base_frame.columns: + raise ValueError(f"Hydrology base layer must contain uid: {base_path}") + base_frame[CACHE_UID_COLUMN] = base_frame[CACHE_UID_COLUMN].astype(str) + base_frame = base_frame.drop_duplicates(CACHE_UID_COLUMN, keep="last") + base_frame = base_frame.set_index(CACHE_UID_COLUMN) + + copy_columns = list(year_columns) + if ( + is_annual + and "weighted_avg_yeild" in base_frame.columns + and "weighted_avg_yeild" not in result_gdf.columns + ): + copy_columns.append("weighted_avg_yeild") + missing_columns = sorted(set(copy_columns) - set(base_frame.columns)) + if missing_columns: + raise ValueError( + f"Hydrology base layer {base_path} is missing columns: " + f"{missing_columns}" + ) + + missing_uids = sorted(set(uid_values) - set(base_frame.index)) + if missing_uids: + raise FileNotFoundError( + f"Hydrology base layer {base_path} is missing " + f"{len(missing_uids)} watershed UIDs for this tehsil. " + f"First missing UIDs: {missing_uids[:5]}" + ) + + matched = base_frame.reindex(uid_values) + for column in copy_columns: + result_gdf[column] = matched[column].to_numpy() + + period_columns.extend(year_columns) + input_paths.append( + { + "year": _year_key(year), + "path": str(base_path), + "period_columns": year_columns, + } + ) + + if is_annual: + result_gdf = _add_annual_net_columns(result_gdf, period_columns) + + layer_name = _layer_name( + district, + block, + is_annual, + ) + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=output_base_dir, + block_fallback="unknown_block", + ) + if output_path.exists(): + output_path.unlink() + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved clipped local hydrology vector: {asset_id}") + + geoserver_synced = False + if push_to_geoserver: + response = push_local_vector_to_geoserver( + path=os.path.splitext(asset_id)[0], + layer_name=layer_name, + workspace=GEOSERVER_WORKSPACE, + file_type="gpkg", + ) + print(f"GeoServer response: {response}") + geoserver_synced = isinstance(response, dict) and response.get( + "status_code" + ) in (200, 201, 202) + + layer_id = None + if sync_layer_metadata: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Hydrology", + algorithm=LOCAL_ALGORITHM, + algorithm_version=LOCAL_ALGORITHM_VERSION, + misc={ + "start_date": f"{start_year}-07-01", + "end_date": f"{end_year + 1}-06-30", + "is_annual": bool(is_annual), + "is_generated_locally": True, + "source": "base_layer_clip", + "watershed_source": watershed_source, + "inputs": input_paths, + }, + ) + if layer_id and geoserver_synced: + update_layer_sync_status( + layer_id=layer_id, + sync_to_geoserver=True, + ) + + return { + "output": asset_id, + "layer_name": layer_name, + "is_annual": bool(is_annual), + "start_year": start_year, + "end_year": end_year, + "period_count": len(period_columns), + "watershed_count": len(result_gdf), + "source": "base_layer_clip", + "geoserver_synced": geoserver_synced, + "layer_id": layer_id, + } + + +def run_generate_hydrology_local( + *, + state=None, + district=None, + block=None, + pan_india=False, + start_year, + end_year, + is_annual=False, + gee_account_id=None, + hydrology_output_root=HYDROLOGY_OUTPUT_ROOT, + output_base_dir=HYDROLOGY_LOCAL_OUTPUT_DIR, + aquifer_vector_path=AQUIFER_VECTOR_PATH, + push_to_geoserver=True, + sync_layer_metadata=True, + overwrite=False, +): + pan_india = _parse_bool(pan_india) + start_year = int(start_year) + end_year = int(end_year) + if end_year < start_year: + raise ValueError("end_year must be greater than or equal to start_year") + + if pan_india: + raise ValueError( + "pan_india=true is not supported on the tehsil hydrology API. " + "Use the /api/v1/pan-india/ hydrology API to generate " + "Pan-India outputs." + ) + + return _run_clip_hydrology_area_from_base_layers( + state=state, + district=district, + block=block, + start_year=start_year, + end_year=end_year, + is_annual=is_annual, + output_base_dir=output_base_dir, + push_to_geoserver=push_to_geoserver, + sync_layer_metadata=sync_layer_metadata, + ) + + +@app.task(bind=True) +def generate_hydrology( + self, + state=None, + district=None, + block=None, + pan_india=False, + start_year=None, + end_year=None, + is_annual=False, + gee_account_id=None, + overwrite=False, +): + _ = self + return run_generate_hydrology_local( + state=state, + district=district, + block=block, + pan_india=pan_india, + start_year=start_year, + end_year=end_year, + is_annual=is_annual, + gee_account_id=gee_account_id, + push_to_geoserver=True, + sync_layer_metadata=True, + overwrite=overwrite, + ) + + +@app.task(bind=True) +def generate_hydrology_base_layer( + self, + year=None, + start_year=None, + end_year=None, + is_annual=False, + overwrite=False, +): + _ = self + return _run_generate_hydrology_base_layer_local( + year=year, + start_year=start_year, + end_year=end_year, + is_annual=is_annual, + overwrite=overwrite, + ) diff --git a/computing/mws/runoff_gpu.py b/computing/mws/runoff_gpu.py index 4415f5ac..ec2e58a2 100644 --- a/computing/mws/runoff_gpu.py +++ b/computing/mws/runoff_gpu.py @@ -18,6 +18,9 @@ DATA_ROOT = PROJECT_ROOT / "data" HYDROLOGY_OUTPUT_ROOT = DATA_ROOT / "hydrology_gpu" +PAN_INDIA_RUNOFF_OUTPUT_ROOT = DATA_ROOT / "base_layers" / "hydrology" / "runoff" +PAN_INDIA_RUNOFF_COMMONS_ROOT = PAN_INDIA_RUNOFF_OUTPUT_ROOT / "commons" +PAN_INDIA_RUNOFF_TIMESERIES_DIR_NAME = "runoff_timeseries" DEFAULT_LOCAL_DEM_PATH = TERRAIN_RASTER_PATH DEFAULT_LOCAL_SOIL_PATH = SOIL_RASTER_PATH PAN_INDIA_DEFAULT_TILE_SIZE = 11264 @@ -62,11 +65,10 @@ def _resolve_dates(start_date, end_date, start_year=None, end_year=None): start_year = int(start_year) end_year = int(end_year) - if end_year < start_year: - raise ValueError("end_year must be greater than or equal to start_year") + if end_year <= start_year: + raise ValueError("end_year must be greater than start_year") - annual_end_year = end_year + 1 - return f"{start_year}-07-01", f"{annual_end_year}-07-01", start_year, annual_end_year + return f"{start_year}-07-01", f"{end_year}-07-01", start_year, end_year def _resolve_lulc_path(lulc_start_year, lulc_end_year): @@ -129,8 +131,18 @@ def _build_runner_args( ): scope, state, district, tehsil = _validate_scope(pan_india, state, district, tehsil) slug_path = _scope_slug(scope, state, district, tehsil) - output_root = HYDROLOGY_OUTPUT_ROOT / slug_path / annual_key - boundary_output = output_root / "boundaries" / f"{slug_path.replace('/', '_')}.geojson" + if scope == "pan_india": + output_root = PAN_INDIA_RUNOFF_OUTPUT_ROOT / annual_key + boundary_output = PAN_INDIA_RUNOFF_COMMONS_ROOT / "pan_india.geojson" + timeseries_output = ( + output_root + / PAN_INDIA_RUNOFF_TIMESERIES_DIR_NAME + / "pan_india_timeseries.geojson" + ) + else: + output_root = HYDROLOGY_OUTPUT_ROOT / slug_path / annual_key + boundary_output = output_root / "boundaries" / f"{slug_path.replace('/', '_')}.geojson" + timeseries_output = None return SimpleNamespace( pre_req=True, @@ -147,6 +159,7 @@ def _build_runner_args( tehsil=tehsil, watershed_root=str(PRECOMPUTED_TEHSIL_WATERSHED_DIR), watershed_boundary_output=str(boundary_output), + timeseries_vector=str(timeseries_output) if timeseries_output else None, reuse_watershed_boundary=True, local_dem=str(DEFAULT_LOCAL_DEM_PATH), local_lulc=str(local_lulc_path), diff --git a/computing/tasks.py b/computing/tasks.py index 98809f5e..eb0b1000 100644 --- a/computing/tasks.py +++ b/computing/tasks.py @@ -1,4 +1,13 @@ from computing.mws.et_download import et_download +from computing.mws.generate_hydrology_local import ( + generate_hydrology, + generate_hydrology_base_layer, +) from computing.mws.runoff_gpu import generate_runoff_gpu -__all__ = ["et_download", "generate_runoff_gpu"] +__all__ = [ + "et_download", + "generate_hydrology", + "generate_hydrology_base_layer", + "generate_runoff_gpu", +] diff --git a/computing/urls.py b/computing/urls.py index cf8828da..706f554e 100644 --- a/computing/urls.py +++ b/computing/urls.py @@ -20,6 +20,16 @@ name="hydrology_fortnightly", ), path("hydrology_annual/", api.generate_annual_hydrology, name="hydrology_annual"), + path( + "pan-india/hydrology_fortnightly/", + api.generate_pan_india_fortnightly_hydrology, + name="pan_india_hydrology_fortnightly", + ), + path( + "pan-india/hydrology_annual/", + api.generate_pan_india_annual_hydrology, + name="pan_india_hydrology_annual", + ), path("runoff_gpu/", api.generate_runoff_gpu, name="runoff_gpu"), path("et_download/", api.et_download, name="et_download"), path("lulc_for_tehsil/", api.lulc_for_tehsil, name="lulc_for_tehsil"),