From 460e9b3299abb6d8fc68f68e0ba79697d3d60493 Mon Sep 17 00:00:00 2001 From: ramank1137 Date: Thu, 9 Apr 2026 15:12:54 +0530 Subject: [PATCH 001/120] 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 f30348f0..104cd6ea 100644 --- a/computing/api.py +++ b/computing/api.py @@ -15,7 +15,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, @@ -23,7 +26,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 @@ -42,18 +46,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 @@ -63,7 +99,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 @@ -399,13 +440,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) @@ -422,7 +472,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", ) @@ -430,6 +486,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) @@ -486,7 +545,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, @@ -501,6 +566,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) @@ -576,18 +644,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): @@ -597,7 +696,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, @@ -611,6 +716,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) @@ -624,10 +732,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", ) @@ -635,6 +749,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) @@ -648,10 +765,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", ) @@ -659,6 +782,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) @@ -693,10 +819,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", ) @@ -704,6 +836,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) @@ -717,10 +852,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", ) @@ -728,6 +869,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) @@ -991,13 +1135,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 274087a6..c272e8d0 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, @@ -116,6 +115,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") @@ -132,13 +145,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)) @@ -172,13 +181,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) @@ -197,160 +202,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) @@ -374,9 +242,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) @@ -413,13 +293,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 d450e737..e7eb2c9c 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 705e43399c43700912ac9302ce2ba7af3e2cc891 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Wed, 15 Apr 2026 02:20:10 +0530 Subject: [PATCH 002/120] 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 41308ea597535b8dfa751aca1d47a1f622ed7660 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Thu, 16 Apr 2026 00:33:29 +0530 Subject: [PATCH 003/120] added methods to update db and generate change detection from local machine --- computing/api.py | 6 + computing/apps.py | 3 + computing/base_layer_setup.py | 171 +++ computing/local_compute_helper.py | 34 +- computing/utils.py | 2287 +++++++++++++++-------------- nrm_app/settings.py | 7 + 6 files changed, 1384 insertions(+), 1124 deletions(-) create mode 100644 computing/base_layer_setup.py diff --git a/computing/api.py b/computing/api.py index 104cd6ea..40a95b17 100644 --- a/computing/api.py +++ b/computing/api.py @@ -40,6 +40,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 91f698d3..48774962 100644 --- a/computing/apps.py +++ b/computing/apps.py @@ -8,3 +8,6 @@ class ComputingConfig(AppConfig): def ready(self): import computing.tasks # noqa: F401 (task autodiscovery) import computing.signals # noqa: F401 (post_save -> auto STAC trigger) + 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 da6b632b..f914a72c 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -1,1121 +1,1166 @@ -import copy -import json -import logging -import os -import shutil -import zipfile -from datetime import datetime, timedelta - -import ee -import fiona -import geopandas as gpd -import requests -from django.conf import settings -from shapely.geometry import shape -from shapely.validation import explain_validity - -from computing.models import Dataset, Layer -from geoadmin.models import ( - DistrictSOI, - State_Disritct_Block_Properties, - StateSOI, - TehsilSOI, -) -from projects.models import Project -from utilities.constants import ( - ADMIN_BOUNDARY_OUTPUT_DIR, - GEE_ASSET_PATH, - GEE_HELPER_PATH, - GEE_PATHS, - SHAPEFILE_DIR, -) -from utilities.gee_utils import ( - check_task_status, - ee_initialize, - get_gee_asset_path, - get_gee_dir_path, - get_geojson_from_gcs, - is_asset_public, - is_gee_asset_exists, - sync_vector_to_gcs, - valid_gee_text, -) -from utilities.geoserver_utils import Geoserver -from django.core.mail import EmailMessage, get_connection -import time - -logger = logging.getLogger(__name__) - - -def generate_shape_files(path): - gdf = gpd.read_file(path + ".json") - if os.path.exists(path): - # Only replace the target shapefile directory. Removing the parent - # state/workspace directory here corrupts sibling outputs on reruns. - shutil.rmtree(path) - - os.makedirs(os.path.dirname(path), exist_ok=True) - gdf.to_file( - path, - driver="ESRI Shapefile", - ) - return path - - -def convert_to_zip(dir_name, file_type): - if file_type == "gpkg": - with zipfile.ZipFile(dir_name + ".zip", "w", zipfile.ZIP_DEFLATED) as zipf: - zipf.write(dir_name + ".gpkg", arcname=os.path.basename(dir_name + ".gpkg")) - return dir_name + ".zip" - else: - return shutil.make_archive(dir_name, "zip", dir_name + "/") - - -def push_shape_to_geoserver( - path, store_name=None, workspace=None, layer_name=None, file_type="shp" -): - geo = Geoserver() - - print(f"layer_name: {layer_name}") - if layer_name: - try: - print(f"Attempting to delete store: {layer_name}") - geo.delete_vector_store(workspace=workspace, store=layer_name) - print(f"Successfully deleted store: {layer_name}") - except Exception as e: - print(f"Store does not exist or error deleting: {str(e)}") - - zip_path = convert_to_zip(path, file_type) - print(f"Zip path: {zip_path}") - print(f"Store name: {store_name}") - print(f"Workspace: {workspace}") - - response = geo.create_shp_datastore( - path=zip_path, - store_name=store_name, - workspace=workspace, - file_extension=file_type, - ) - print(f"Response: {response}") - return response - - -def kml_to_geojson(state_name, district_name, block_name, kml_path): - fiona.drvsupport.supported_drivers["kml"] = ( - "rw" # enable KML support which is disabled by default - ) - fiona.drvsupport.supported_drivers["KML"] = ( - "rw" # enable KML support which is disabled by default - ) - gdf = gpd.read_file(kml_path) - geometry_types = gdf.geometry.geometry.type.unique() - state_dir = os.path.join(ADMIN_BOUNDARY_OUTPUT_DIR, state_name) - - for gtype in geometry_types: - df = gdf.loc[gdf.geometry.geometry.type == gtype] - path = os.path.join(state_dir, f"{district_name}_{block_name}_{gtype}") - df.to_file(path + ".json", driver="GeoJSON") - generate_shape_files(path) - push_shape_to_geoserver(path, workspace="test_workspace") - - -def convert_kml_to_shapefile(kml_path, output_dir, shapefile_name): - if not os.path.exists(output_dir + "/" + shapefile_name): - os.makedirs(output_dir + "/" + shapefile_name) - - shapefile_path = os.path.join( - output_dir + "/" + shapefile_name, shapefile_name + ".shp" - ) - print("path path", shapefile_path) - cmd = f"ogr2ogr -f 'ESRI Shapefile' {shapefile_path} {kml_path}" # output.shp input.kml - os.system(command=cmd) - - return output_dir + "/" + shapefile_name - - -def kml_to_shp(state_name, district_name, block_name, kml_path): - shapefile_name = f"{district_name}_{block_name}" - shapefile_layer_path = convert_kml_to_shapefile( - kml_path, SHAPEFILE_DIR, shapefile_name - ) - - push_shape_to_geoserver(shapefile_layer_path, workspace="customkml") - - # os.remove(kml_path) - # shutil.rmtree(shapefile_layer_path) - os.remove(shapefile_layer_path + ".zip") - - -def sync_layer_to_geoserver(state_name, fc, layer_name, workspace): - state_dir = os.path.join("data/fc_to_shape", state_name) - if not os.path.exists(state_dir): - os.mkdir(state_dir) - path = os.path.join(state_dir, f"{layer_name}") - # Write the feature collection into json file - with open(path + ".json", "w") as f: - try: - f.write(f"{json.dumps(fc)}") - except Exception as e: - print(e) - - path = generate_shape_files(path) - return push_shape_to_geoserver(path, workspace=workspace, layer_name=layer_name) - - -def sync_fc_to_geoserver(fc, shp_folder, layer_name, workspace, style_name=None): - try: - geojson_fc = fc.getInfo() - except Exception as e: - print("Exception in getInfo()", e) - task_id = sync_vector_to_gcs(fc, layer_name, "GeoJSON") - check_task_status([task_id]) - - geojson_fc = get_geojson_from_gcs(layer_name) - geo = Geoserver() - if len(geojson_fc["features"]) > 0: - state_dir = os.path.join("data/fc_to_shape", shp_folder) - if not os.path.exists(state_dir): - os.mkdir(state_dir) - path = os.path.join(state_dir, f"{layer_name}") - - # Convert to GeoDataFrame - gdf = gpd.GeoDataFrame.from_features(geojson_fc["features"]) - - # Set CRS (Earth Engine uses EPSG:4326 by default) - gdf.crs = "EPSG:4326" - - gdf = fix_invalid_geometry_in_gdf(gdf) - - # Save as GeoPackage - gdf.to_file(path + ".gpkg", driver="GPKG") - res = push_shape_to_geoserver(path, workspace=workspace, file_type="gpkg") - if style_name: - style_res = geo.publish_style( - layer_name=layer_name, style_name=style_name, workspace=workspace - ) - print("Style response:", style_res) - return res - else: - return "No features in FeatureCollection" - - -def sync_project_fc_to_geoserver(fc, project_name, layer_name, workspace): - print("inside") - print(layer_name) - try: - geojson_fc = fc.getInfo() - except Exception as e: - print("Exception in getInfo()", e) - task_id = sync_vector_to_gcs(fc, layer_name, "GeoJSON") - check_task_status([task_id]) - - geojson_fc = get_geojson_from_gcs(layer_name) - print(len(geojson_fc["features"])) - if len(geojson_fc["features"]) > 0: - state_dir = os.path.join("data/fc_to_shape", project_name) - if not os.path.exists(state_dir): - os.mkdir(state_dir) - path = os.path.join(state_dir, f"{layer_name}") - - # Convert to GeoDataFrame - gdf = gpd.GeoDataFrame.from_features(geojson_fc["features"]) - - # Set CRS (Earth Engine uses EPSG:4326 by default) - gdf.crs = "EPSG:4326" - - gdf = fix_invalid_geometry_in_gdf(gdf) - - # Save as GeoPackage - gdf.to_file(path + ".gpkg", driver="GPKG") - print("pushed to geoserver") - return push_shape_to_geoserver( - path, workspace=workspace, layer_name=layer_name, file_type="gpkg" - ) - else: - print("no features found") - return - - -def to_camelcase(text): - words = text.split() - camelcase = words[0].lower() - for word in words[1:]: - camelcase += word.capitalize() - return camelcase - - -def create_chunk(aoi, description, chunk_size): - size = aoi.size().getInfo() - parts = size // chunk_size - # task_ids = [] - rois = [] - descs = [] - for part in range(parts + 1): - start = part * chunk_size - end = start + chunk_size - block_name_for_parts = description + "_" + str(start) + "-" + str(end) - roi = ee.FeatureCollection(aoi.toList(aoi.size()).slice(start, end)) - if roi.size().getInfo() > 0: - descs.append(block_name_for_parts) - rois.append(roi) - - return rois, descs - - -def merge_chunks( - aoi, - folder_list, - description, - chunk_size, - chunk_asset_path=GEE_HELPER_PATH, - merge_asset_path=GEE_ASSET_PATH, - merge_asset_id=None, -): - print("Merge Chunk task initiated") - ee_initialize() - size = aoi.size().getInfo() - parts = size // chunk_size - assets = [] - for part in range(parts + 1): - start = part * chunk_size - end = start + chunk_size - block_name_for_parts = description + "_" + str(start) + "-" + str(end) - src_asset_id = ( - get_gee_dir_path(folder_list, chunk_asset_path) + block_name_for_parts - ) - if is_gee_asset_exists(src_asset_id): - assets.append(ee.FeatureCollection(src_asset_id)) - - asset = ee.FeatureCollection(assets).flatten() - - asset_id = merge_asset_id or ( - get_gee_dir_path(folder_list, merge_asset_path) + description - ) - try: - # Export an ee.FeatureCollection as an Earth Engine asset. - task = ee.batch.Export.table.toAsset( - **{ - "collection": asset, - "description": description, - "assetId": asset_id, - } - ) - - task.start() - print("Successfully started the merge chunk", task.status()) - return task.status()["id"] - except Exception as e: - print(f"Error occurred in running merge task: {e}") - return None - - -def fix_invalid_geometry_in_gdf(gdf): - invalid = gdf[~gdf.is_valid] - if not invalid.empty: - print("Invalid geometries found:") - for idx, geom in invalid.geometry.items(): - print(f"Index {idx}: {explain_validity(geom)}") - gdf.loc[idx, "geometry"] = gdf.loc[idx, "geometry"].buffer(0) - - return gdf - - -def get_season_key(date): - """Return season key like 'rabi_2017-2018' based on Indian cropping seasons.""" - month = date.month - year = date.year - next_year = year + 1 - - if month in [1, 2]: - return f"rabi_{year - 1}-{year}" # Jan–Feb → Rabi of previous year - elif month in [11, 12]: - return f"rabi_{year}-{next_year}" # Nov–Dec → Rabi starting this year - elif month in [3, 4, 5, 6]: - return f"zaid_{year}-{next_year}" - elif month in [7, 8, 9, 10]: - return f"kharif_{year}-{next_year}" - else: - return None - - -def get_agri_year_key(season_key): - """Convert a season key to agricultural year key (e.g., rabi_2017-2018 → 2017-2018).""" - season, years = season_key.split("_") - start_year, end_year = map(int, years.split("-")) - - if season in ["kharif", "rabi"]: - return f"{start_year}-{end_year}" - elif season == "zaid": - return f"{start_year - 1}-{start_year}" # Zaid 2018-2019 → Agri year 2017-2018 - else: - return None - - -def calculate_precipitation_season( - geojson_filepath, draught_asset_id, start_year=2017, end_year=2024 -): - - # Load the GeoJSON file - with open(geojson_filepath, "r") as f: - feature_collection = json.load(f) - - features_ee = [] - - for feature in feature_collection["features"]: - original_props = feature["properties"] - new_props = {} - - # Copy UID - if "uid" in original_props: - new_props["uid"] = original_props["uid"] - - agri_year_totals = {} - - # Parse precipitation date keys - for key, val in original_props.items(): - try: - date = datetime.strptime(key, "%Y-%m-%d") - season_key = get_season_key(date) - if not season_key: - continue - - agri_key = get_agri_year_key(season_key) - if not agri_key: - continue - - agri_start = int(agri_key.split("-")[0]) - if not (start_year <= agri_start <= end_year): - continue - - season = season_key.split("_")[0] # kharif, rabi etc - full_key = f"{season}_{agri_key}" - - agri_year_totals[full_key] = agri_year_totals.get(full_key, 0) + float( - val - ) - - except Exception: - continue - - # Add all seasonal totals to new_props - for agri_key, total in agri_year_totals.items(): - new_props[f"precipitation_{agri_key}"] = total - - # Create EE Feature - geom_ee = ee.Geometry(feature["geometry"]) - feature_ee = ee.Feature(geom_ee, new_props) - features_ee.append(feature_ee) - - # Left side FC - mws_fc = ee.FeatureCollection(features_ee) - - return mws_fc - - -def generate_geojson_with_ci_and_ndvi(zoi_asset, ci_asset, ndvi_asset, proj_id): - # Load project - proj_obj = Project.objects.get(pk=proj_id) - - # Build CI and NDVI asset paths - asset_path_ci = ( - get_gee_dir_path( - [proj_obj.name], asset_path=GEE_PATHS["WATER_REJ"]["GEE_ASSET_PATH"] - ) - + ci_asset - ) - - asset_path_ndvi = ( - get_gee_dir_path( - [proj_obj.name], asset_path=GEE_PATHS["WATER_REJ"]["GEE_ASSET_PATH"] - ) - + ndvi_asset - ) - - # Load FeatureCollections - zoi = ee.FeatureCollection(zoi_asset) - ci = ee.FeatureCollection(asset_path_ci) - ndvi = ee.FeatureCollection(asset_path_ndvi) - - # ------------------------- - # STEP 1: Join ZOI with Cropping Intensity - # ------------------------- - join = ee.Join.inner() - filter = ee.Filter.intersects(leftField=".geo", rightField=".geo") - zoi_ci_joined = join.apply(zoi, ci, filter) - - def merge_zoi_ci(pair): - zoi_feat = ee.Feature(pair.get("primary")) - ci_feat = ee.Feature(pair.get("secondary")) - merged_props = zoi_feat.toDictionary().combine(ci_feat.toDictionary(), True) - return ee.Feature(zoi_feat.geometry(), merged_props) - - zoi_with_ci = ee.FeatureCollection(zoi_ci_joined.map(merge_zoi_ci)) - - # ------------------------- - # STEP 2: Join ZOI+CI with NDVI - # ------------------------- - zoi_ndvi_joined = join.apply(zoi_with_ci, ndvi, filter) - - def merge_zoi_ci_ndvi(pair): - ci_feat = ee.Feature(pair.get("primary")) - ndvi_feat = ee.Feature(pair.get("secondary")) - merged_props = ci_feat.toDictionary().combine(ndvi_feat.toDictionary(), True) - return ee.Feature(ci_feat.geometry(), merged_props) - - final_merged = ee.FeatureCollection(zoi_ndvi_joined.map(merge_zoi_ci_ndvi)) - - # ------------------------- - # STEP 3: Export or Push to GeoServer - # ------------------------- - layer_name = f"WaterRejapp_zoi_{proj_obj.name}_{proj_obj.id}" - sync_project_fc_to_geoserver(final_merged, proj_obj.name, layer_name, "waterrej") - - -def get_directory_size(path): - total_size = 0 - for dirpath, dirnames, filenames in os.walk(path): - for filename in filenames: - file_path = os.path.join(dirpath, filename) - if os.path.isfile(file_path): - total_size += os.path.getsize(file_path) - return total_size - - -def generate_geojson_with_ci_ndvi_ndmi( - zoi_asset, ci_asset, ndvi_asset, ndmi_asset, proj_id -): - - # Load project - proj_obj = Project.objects.get(pk=proj_id) - - zoi = ee.FeatureCollection(zoi_asset) - print("Number of features zoi:", zoi.size().getInfo()) - - ci = ee.FeatureCollection(ci_asset) - print("Number of features zoi:", ci.size().getInfo()) - ndvi = ee.FeatureCollection(ndmi_asset) - print("Number of features zoi:", ndvi.size().getInfo()) - ndmi = ee.FeatureCollection(ndmi_asset) - print("Number of features zoi:", ndmi.size().getInfo()) - - # ------------------------- - # STEP 1: Join ZOI with CI - # ------------------------- - join = ee.Join.inner() - filter = ee.Filter.intersects(leftField=".geo", rightField=".geo") - zoi_ci_joined = join.apply(zoi, ci, filter) - - def merge_zoi_ci(pair): - zoi_feat = ee.Feature(pair.get("primary")) - ci_feat = ee.Feature(pair.get("secondary")) - merged_props = zoi_feat.toDictionary().combine(ci_feat.toDictionary(), True) - return ee.Feature(zoi_feat.geometry(), merged_props) # ✅ keep ZOI geom - - zoi_with_ci = ee.FeatureCollection(zoi_ci_joined.map(merge_zoi_ci)) - - # ------------------------- - # STEP 2: Join with NDVI - # ------------------------- - zoi_ndvi_joined = join.apply(zoi_with_ci, ndvi, filter) - - def merge_zoi_ci_ndvi(pair): - prev_feat = ee.Feature(pair.get("primary")) - ndvi_feat = ee.Feature(pair.get("secondary")) - merged_props = prev_feat.toDictionary().combine(ndvi_feat.toDictionary(), True) - return ee.Feature(prev_feat.geometry(), merged_props) # ✅ still ZOI geom - - zoi_ci_ndvi = ee.FeatureCollection(zoi_ndvi_joined.map(merge_zoi_ci_ndvi)) - - # ------------------------- - # STEP 3: Join with NDMI - # ------------------------- - zoi_ndmi_joined = join.apply(zoi_ci_ndvi, ndmi, filter) - - def merge_zoi_ci_ndvi_ndmi(pair): - prev_feat = ee.Feature(pair.get("primary")) - ndmi_feat = ee.Feature(pair.get("secondary")) - merged_props = prev_feat.toDictionary().combine(ndmi_feat.toDictionary(), True) - return ee.Feature(prev_feat.geometry(), merged_props) # ✅ keep ZOI geom - - final_merged = ee.FeatureCollection(zoi_ndmi_joined.map(merge_zoi_ci_ndvi_ndmi)) - - # ------------------------- - # STEP 4: Export or Push to GeoServer - # ------------------------- - layer_name = f"WaterRejapp_zoi_{proj_obj.name}_{proj_obj.id}" - print(layer_name) - sync_project_fc_to_geoserver(final_merged, proj_obj.name, layer_name, "waterrej") - - -def generate_geojson_with_ci_ndvi(zoi_asset, ci_asset, ndvi_asset, proj_id): - # Load project - proj_obj = Project.objects.get(pk=proj_id) - - # Initialize Earth Engine - ee_initialize(4) - - # Load FeatureCollections - zoi = ee.FeatureCollection(zoi_asset) - ci = ee.FeatureCollection(ci_asset) - ndvi = ee.FeatureCollection(ndvi_asset) - - print("ZOI:", zoi.size().getInfo()) - print("CI:", ci.size().getInfo()) - print("NDVI:", ndvi.size().getInfo()) - - # Common join logic on UID - join = ee.Join.inner() - uid_filter = ee.Filter.equals(leftField="UID", rightField="UID") - - # --- Join ZOI + CI --- - zoi_ci_joined = join.apply(zoi, ci, uid_filter) - - def merge_zoi_ci(pair): - zoi_feat = ee.Feature(pair.get("primary")) - ci_feat = ee.Feature(pair.get("secondary")) - merged_props = zoi_feat.toDictionary().combine(ci_feat.toDictionary(), True) - # Keep ZOI geometry only - return ee.Feature(zoi_feat.geometry(), merged_props) - - zoi_with_ci = ee.FeatureCollection(zoi_ci_joined.map(merge_zoi_ci)) - - # --- Join with NDVI --- - zoi_ndvi_joined = join.apply(zoi_with_ci, ndvi, uid_filter) - - def merge_with_ndvi(pair): - base_feat = ee.Feature(pair.get("primary")) - ndvi_feat = ee.Feature(pair.get("secondary")) - merged_props = base_feat.toDictionary().combine(ndvi_feat.toDictionary(), True) - # Always retain ZOI geometry - return ee.Feature(base_feat.geometry(), merged_props) - - merged_final = ee.FeatureCollection(zoi_ndvi_joined.map(merge_with_ndvi)) - - # --- Ensure ZOI geometry retained in all features --- - merged_final = merged_final.map( - lambda f: ee.Feature( - f.setGeometry( - ee.Feature( - zoi.filter(ee.Filter.eq("UID", f.get("UID"))).first() - ).geometry() - ) - ) - ) - - layer_name = f"WaterRejapp_zoi_{proj_obj.name}_{proj_obj.id}" - print(layer_name) - - sync_project_fc_to_geoserver(merged_final, proj_obj.name, layer_name, "waterrej") - - -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, -): - 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) - layer_obj = Layer.objects.get(dataset=dataset, layer_name=layer_name) - existing_end_date = layer_obj.misc["end_year"] - print("existing_end_date", existing_end_date) - return existing_end_date - - -def get_layer_object(state, district, block, layer_name, dataset_name): - 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) - layer_obj = ( - Layer.objects.filter( - state=state_obj, - district=district_obj, - block=block_obj, - layer_name=layer_name, - dataset__name=dataset_name, - ) - .order_by("-layer_version") - .first() - ) - return layer_obj - - -def update_dashboard_geojson( - state=None, - district=None, - block=None, - layer_name=None, - workspace_name=None, - proj_id=None, -): - if state and block and block: - print(f"🔄 Updating GeoJSON for {state}, {district}, {block}") - - # Get related objects - state_obj = StateSOI.objects.get(state_name=state) - district_obj = DistrictSOI.objects.get(district_name=district) - tehsil_obj = TehsilSOI.objects.get(tehsil_name=block) # fixed typo - - # Get or create main record - obj, created = State_Disritct_Block_Properties.objects.get_or_create( - state=state_obj, district=district_obj, tehsil=tehsil_obj - ) - else: - obj = Project.objects.get(pk=proj_id) - - # Map suffix to json_key - suffix_to_key = { - "wb": "wb_geojson", - "zoi": "zoi_geojson", - "mws": "mws_geojson", - } - - # Detect which key this layer corresponds to - json_key = None - for suffix, key in suffix_to_key.items(): - if layer_name == f"{state}_{district}_{block}_{suffix}": - json_key = key - break - - if not json_key: - print(f"⚠️ Layer name {layer_name} did not match any known type.") - return - - # Construct GeoServer URL - waterrej_url = ( - f"https://geoserver.core-stack.org:8443/geoserver/waterrej/ows?" - f"service=WFS&version=1.0.0&request=GetFeature&typeName={workspace_name}:{layer_name}" - f"&outputFormat=application%2Fjson" - ) - - # Load existing dashboard_geojson or create new - if proj_id: - misc = obj.dashboard_geojson or {} - else: - misc = obj.geojson_path or {} - - # Ensure waterrej section exists - if "waterrej" not in misc: - misc["waterrej"] = {} - - # Update or add this specific json_key - misc["waterrej"][json_key] = waterrej_url - - # Save the updated JSON field - obj.dashboard_geojson = misc - obj.save() - - print(f"✅ Added/Updated {json_key} for {state}, {district}, {block}") - - -def clean_geometry(geom): - """ - Clean geometry: - - Dissolve multipolygon → single polygon - - Remove holes automatically - - Fix invalid topology - - Buffer tiny polygons - """ - - # 1. Dissolve multi-polygons and remove holes - geom = geom.dissolve(maxError=1) - - # 2. Fix invalid rings by simplifying slightly (NEVER buffer(0)) - geom = geom.simplify(1) - - # 3. Buffer polygons smaller than 1 pixel (< 900 m²) - area = geom.area() - geom = ee.Algorithms.If( - area.lt(900), - geom.buffer(15), - geom, # ensure raster pixel center is captured - ) - - return ee.Geometry(geom) - - -def safe_reduce_max(image, geom, scale=30): - geom = clean_geometry(geom) - - val = ( - image.unmask(0) - .reduceRegion( - reducer=ee.Reducer.max(), - geometry=geom, - scale=scale, - maxPixels=1e13, - tileScale=4, - bestEffort=True, - ) - .get("b1") - ) - - return ee.Number(ee.Algorithms.If(val, val, 0)) - - -# ------------------------------------------------------ -# SAFE REDUCE MAX FUNCTION -# ------------------------------------------------------ -def safe_reduce_max(image, geom, scale=30): - geom = clean_geometry(geom) - - result = ( - image.unmask(0) - .reduceRegion( - reducer=ee.Reducer.max(), - geometry=geom, - scale=scale, - maxPixels=1e13, - tileScale=4, - bestEffort=True, - ) - .get("b1") - ) - - # Convert null → 0 - return ee.Number(ee.Algorithms.If(result, result, 0)) - - -# ------------------------------------------------------ -# MAIN FUNCTION TO PROCESS SWB LAYER -# ------------------------------------------------------ -def generate_swb_layer_with_max_so_catchment( - roi=None, - app_type="MWS", - asset_suffix=None, - asset_folder=None, - gee_account_id=None, -): - ee_initialize(gee_account_id) - - # Build asset paths - base_path = get_gee_dir_path( - asset_folder, asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"] - ) - - so_asset = f"{base_path}stream_order_{asset_suffix}_raster" - ca_asset = f"{base_path}catchment_area_{asset_suffix}_raster" - - # Load rasters - stream_order_band = ee.Image(so_asset).select("b1") - catchment_band = ee.Image(ca_asset).select("b1") - - # Processing per waterbody - def compute_for_feature(feature): - geom = feature.geometry() - - max_so = safe_reduce_max(stream_order_band, geom, scale=30) - max_ca = safe_reduce_max(catchment_band, geom, scale=30) - - return feature.set( - { - "max_stream_order": max_so, - "max_catchment_area": max_ca, - } - ) - - # Map over the feature collection - return roi.map(compute_for_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 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).first() - if layer_obj is None: - return None - - update_fields = [] - if sync_to_geoserver is not None: - layer_obj.is_sync_to_geoserver = sync_to_geoserver - update_fields.append("is_sync_to_geoserver") - if is_stac_specs_generated is not None: - layer_obj.is_stac_specs_generated = is_stac_specs_generated - update_fields.append("is_stac_specs_generated") - - # `save(update_fields=...)` fires the post_save signal so the STAC - # auto-trigger handler in `computing.signals` can pick up the flip. - if update_fields: - layer_obj.save(update_fields=update_fields) - print( - f"Updated {update_fields} for layer ID: {layer_id} " - f"(sync={sync_to_geoserver}, stac={is_stac_specs_generated})" - ) - return layer_id - - except Exception as e: - print(f"Error updating layer sync status: {e}") - - -# send missing layer to recipient email -def send_missing_layers_report(result: dict, recipients: list = None) -> bool: - if recipients is None: - recipients = getattr(settings, "MISSING_LAYER_RECIPIENTS", []) - - if isinstance(recipients, str): - recipients = [recipients] - - if not recipients: - logger.error("No recipients configured for missing layers report.") - return False - - summary = [] - total_missing = 0 - - for layer, data in result.items(): - count = len(data.get("missing_layers", [])) - total_missing += count - summary.append(f"{layer}: {count}") - body = ( - "Missing Layers Report\n\n" - f"Total Missing: {total_missing}\n\n" - + "\n".join(summary) - + "\n\nDetailed report attached." - ) - - attachment_content = json.dumps(result, indent=4) - max_retries = 3 - - for attempt in range(max_retries): - connection = None - try: - connection = get_connection(timeout=120) - connection.open() - email = EmailMessage( - subject="Missing Layers Report", - body=body, - from_email=settings.EMAIL_HOST_USER, - to=recipients, - connection=connection, - ) - email.attach( - "missing_layers.json", - attachment_content, - "application/json", - ) - email.send() - logger.info(f"Missing layers report sent to {recipients}") - logger.info( - f"Attachment size: " - f"{len(attachment_content.encode('utf-8')) / 1024:.2f} KB" - ) - return True - except Exception as e: - logger.exception(f"Attempt {attempt + 1}/{max_retries} failed: {e}") - if attempt < max_retries - 1: - wait_time = 5 * (attempt + 1) - logger.info(f"Retrying after {wait_time} seconds...") - time.sleep(wait_time) - else: - logger.error("All attempts to send email failed.") - return False - finally: - if connection: - try: - connection.close() - except Exception: - pass - - -def _is_cache_valid(cache: dict, workspace: str) -> bool: - if workspace not in cache: - return False - age = time.time() - cache[workspace]["cached_at"] - if age > 3600: - logger.info(f"Cache expired for {workspace} (age: {int(age)}s)") - return False - return True - - -def _set_cache(cache: dict, workspace: str, data: set): - cache[workspace] = { - "data": data, - "cached_at": time.time(), - } +import copy +import json +import logging +import os +import shutil +import zipfile +from datetime import datetime, timedelta + +import ee +import fiona +import geopandas as gpd +import requests +from django.conf import settings +from shapely.geometry import shape +from shapely.validation import explain_validity + +from computing.models import Dataset, Layer +from geoadmin.models import ( + DistrictSOI, + State_Disritct_Block_Properties, + StateSOI, + TehsilSOI, +) +from projects.models import Project +from utilities.constants import ( + ADMIN_BOUNDARY_OUTPUT_DIR, + GEE_ASSET_PATH, + GEE_HELPER_PATH, + GEE_PATHS, + SHAPEFILE_DIR, +) +from utilities.gee_utils import ( + check_task_status, + ee_initialize, + get_gee_asset_path, + get_gee_dir_path, + get_geojson_from_gcs, + is_asset_public, + is_gee_asset_exists, + sync_vector_to_gcs, + valid_gee_text, +) +from utilities.geoserver_utils import Geoserver +from django.core.mail import EmailMessage, get_connection +import time + +logger = logging.getLogger(__name__) + + +def generate_shape_files(path): + gdf = gpd.read_file(path + ".json") + if os.path.exists(path): + # Only replace the target shapefile directory. Removing the parent + # state/workspace directory here corrupts sibling outputs on reruns. + shutil.rmtree(path) + + os.makedirs(os.path.dirname(path), exist_ok=True) + gdf.to_file( + path, + driver="ESRI Shapefile", + ) + return path + + +def convert_to_zip(dir_name, file_type): + if file_type == "gpkg": + with zipfile.ZipFile(dir_name + ".zip", "w", zipfile.ZIP_DEFLATED) as zipf: + zipf.write(dir_name + ".gpkg", arcname=os.path.basename(dir_name + ".gpkg")) + return dir_name + ".zip" + else: + return shutil.make_archive(dir_name, "zip", dir_name + "/") + + +def push_shape_to_geoserver( + path, store_name=None, workspace=None, layer_name=None, file_type="shp" +): + geo = Geoserver() + + print(f"layer_name: {layer_name}") + if layer_name: + try: + print(f"Attempting to delete store: {layer_name}") + geo.delete_vector_store(workspace=workspace, store=layer_name) + print(f"Successfully deleted store: {layer_name}") + except Exception as e: + print(f"Store does not exist or error deleting: {str(e)}") + + zip_path = convert_to_zip(path, file_type) + print(f"Zip path: {zip_path}") + print(f"Store name: {store_name}") + print(f"Workspace: {workspace}") + + response = geo.create_shp_datastore( + path=zip_path, + store_name=store_name, + workspace=workspace, + file_extension=file_type, + ) + print(f"Response: {response}") + return response + + +def kml_to_geojson(state_name, district_name, block_name, kml_path): + fiona.drvsupport.supported_drivers["kml"] = ( + "rw" # enable KML support which is disabled by default + ) + fiona.drvsupport.supported_drivers["KML"] = ( + "rw" # enable KML support which is disabled by default + ) + gdf = gpd.read_file(kml_path) + geometry_types = gdf.geometry.geometry.type.unique() + state_dir = os.path.join(ADMIN_BOUNDARY_OUTPUT_DIR, state_name) + + for gtype in geometry_types: + df = gdf.loc[gdf.geometry.geometry.type == gtype] + path = os.path.join(state_dir, f"{district_name}_{block_name}_{gtype}") + df.to_file(path + ".json", driver="GeoJSON") + generate_shape_files(path) + push_shape_to_geoserver(path, workspace="test_workspace") + + +def convert_kml_to_shapefile(kml_path, output_dir, shapefile_name): + if not os.path.exists(output_dir + "/" + shapefile_name): + os.makedirs(output_dir + "/" + shapefile_name) + + shapefile_path = os.path.join( + output_dir + "/" + shapefile_name, shapefile_name + ".shp" + ) + print("path path", shapefile_path) + cmd = f"ogr2ogr -f 'ESRI Shapefile' {shapefile_path} {kml_path}" # output.shp input.kml + os.system(command=cmd) + + return output_dir + "/" + shapefile_name + + +def kml_to_shp(state_name, district_name, block_name, kml_path): + shapefile_name = f"{district_name}_{block_name}" + shapefile_layer_path = convert_kml_to_shapefile( + kml_path, SHAPEFILE_DIR, shapefile_name + ) + + push_shape_to_geoserver(shapefile_layer_path, workspace="customkml") + + # os.remove(kml_path) + # shutil.rmtree(shapefile_layer_path) + os.remove(shapefile_layer_path + ".zip") + + +def sync_layer_to_geoserver(state_name, fc, layer_name, workspace): + state_dir = os.path.join("data/fc_to_shape", state_name) + if not os.path.exists(state_dir): + os.mkdir(state_dir) + path = os.path.join(state_dir, f"{layer_name}") + # Write the feature collection into json file + with open(path + ".json", "w") as f: + try: + f.write(f"{json.dumps(fc)}") + except Exception as e: + print(e) + + path = generate_shape_files(path) + return push_shape_to_geoserver(path, workspace=workspace, layer_name=layer_name) + + +def sync_fc_to_geoserver(fc, shp_folder, layer_name, workspace, style_name=None): + try: + geojson_fc = fc.getInfo() + except Exception as e: + print("Exception in getInfo()", e) + task_id = sync_vector_to_gcs(fc, layer_name, "GeoJSON") + check_task_status([task_id]) + + geojson_fc = get_geojson_from_gcs(layer_name) + geo = Geoserver() + if len(geojson_fc["features"]) > 0: + state_dir = os.path.join("data/fc_to_shape", shp_folder) + if not os.path.exists(state_dir): + os.mkdir(state_dir) + path = os.path.join(state_dir, f"{layer_name}") + + # Convert to GeoDataFrame + gdf = gpd.GeoDataFrame.from_features(geojson_fc["features"]) + + # Set CRS (Earth Engine uses EPSG:4326 by default) + gdf.crs = "EPSG:4326" + + gdf = fix_invalid_geometry_in_gdf(gdf) + + # Save as GeoPackage + gdf.to_file(path + ".gpkg", driver="GPKG") + res = push_shape_to_geoserver(path, workspace=workspace, file_type="gpkg") + if style_name: + style_res = geo.publish_style( + layer_name=layer_name, style_name=style_name, workspace=workspace + ) + print("Style response:", style_res) + return res + else: + return "No features in FeatureCollection" + + +def sync_project_fc_to_geoserver(fc, project_name, layer_name, workspace): + print("inside") + print(layer_name) + try: + geojson_fc = fc.getInfo() + except Exception as e: + print("Exception in getInfo()", e) + task_id = sync_vector_to_gcs(fc, layer_name, "GeoJSON") + check_task_status([task_id]) + + geojson_fc = get_geojson_from_gcs(layer_name) + print(len(geojson_fc["features"])) + if len(geojson_fc["features"]) > 0: + state_dir = os.path.join("data/fc_to_shape", project_name) + if not os.path.exists(state_dir): + os.mkdir(state_dir) + path = os.path.join(state_dir, f"{layer_name}") + + # Convert to GeoDataFrame + gdf = gpd.GeoDataFrame.from_features(geojson_fc["features"]) + + # Set CRS (Earth Engine uses EPSG:4326 by default) + gdf.crs = "EPSG:4326" + + gdf = fix_invalid_geometry_in_gdf(gdf) + + # Save as GeoPackage + gdf.to_file(path + ".gpkg", driver="GPKG") + print("pushed to geoserver") + return push_shape_to_geoserver( + path, workspace=workspace, layer_name=layer_name, file_type="gpkg" + ) + else: + print("no features found") + return + + +def to_camelcase(text): + words = text.split() + camelcase = words[0].lower() + for word in words[1:]: + camelcase += word.capitalize() + return camelcase + + +def create_chunk(aoi, description, chunk_size): + size = aoi.size().getInfo() + parts = size // chunk_size + # task_ids = [] + rois = [] + descs = [] + for part in range(parts + 1): + start = part * chunk_size + end = start + chunk_size + block_name_for_parts = description + "_" + str(start) + "-" + str(end) + roi = ee.FeatureCollection(aoi.toList(aoi.size()).slice(start, end)) + if roi.size().getInfo() > 0: + descs.append(block_name_for_parts) + rois.append(roi) + + return rois, descs + + +def merge_chunks( + aoi, + folder_list, + description, + chunk_size, + chunk_asset_path=GEE_HELPER_PATH, + merge_asset_path=GEE_ASSET_PATH, + merge_asset_id=None, +): + print("Merge Chunk task initiated") + ee_initialize() + size = aoi.size().getInfo() + parts = size // chunk_size + assets = [] + for part in range(parts + 1): + start = part * chunk_size + end = start + chunk_size + block_name_for_parts = description + "_" + str(start) + "-" + str(end) + src_asset_id = ( + get_gee_dir_path(folder_list, chunk_asset_path) + block_name_for_parts + ) + if is_gee_asset_exists(src_asset_id): + assets.append(ee.FeatureCollection(src_asset_id)) + + asset = ee.FeatureCollection(assets).flatten() + + asset_id = merge_asset_id or ( + get_gee_dir_path(folder_list, merge_asset_path) + description + ) + try: + # Export an ee.FeatureCollection as an Earth Engine asset. + task = ee.batch.Export.table.toAsset( + **{ + "collection": asset, + "description": description, + "assetId": asset_id, + } + ) + + task.start() + print("Successfully started the merge chunk", task.status()) + return task.status()["id"] + except Exception as e: + print(f"Error occurred in running merge task: {e}") + return None + + +def fix_invalid_geometry_in_gdf(gdf): + invalid = gdf[~gdf.is_valid] + if not invalid.empty: + print("Invalid geometries found:") + for idx, geom in invalid.geometry.items(): + print(f"Index {idx}: {explain_validity(geom)}") + gdf.loc[idx, "geometry"] = gdf.loc[idx, "geometry"].buffer(0) + + return gdf + + +def get_season_key(date): + """Return season key like 'rabi_2017-2018' based on Indian cropping seasons.""" + month = date.month + year = date.year + next_year = year + 1 + + if month in [1, 2]: + return f"rabi_{year - 1}-{year}" # Jan–Feb → Rabi of previous year + elif month in [11, 12]: + return f"rabi_{year}-{next_year}" # Nov–Dec → Rabi starting this year + elif month in [3, 4, 5, 6]: + return f"zaid_{year}-{next_year}" + elif month in [7, 8, 9, 10]: + return f"kharif_{year}-{next_year}" + else: + return None + + +def get_agri_year_key(season_key): + """Convert a season key to agricultural year key (e.g., rabi_2017-2018 → 2017-2018).""" + season, years = season_key.split("_") + start_year, end_year = map(int, years.split("-")) + + if season in ["kharif", "rabi"]: + return f"{start_year}-{end_year}" + elif season == "zaid": + return f"{start_year - 1}-{start_year}" # Zaid 2018-2019 → Agri year 2017-2018 + else: + return None + + +def calculate_precipitation_season( + geojson_filepath, draught_asset_id, start_year=2017, end_year=2024 +): + + # Load the GeoJSON file + with open(geojson_filepath, "r") as f: + feature_collection = json.load(f) + + features_ee = [] + + for feature in feature_collection["features"]: + original_props = feature["properties"] + new_props = {} + + # Copy UID + if "uid" in original_props: + new_props["uid"] = original_props["uid"] + + agri_year_totals = {} + + # Parse precipitation date keys + for key, val in original_props.items(): + try: + date = datetime.strptime(key, "%Y-%m-%d") + season_key = get_season_key(date) + if not season_key: + continue + + agri_key = get_agri_year_key(season_key) + if not agri_key: + continue + + agri_start = int(agri_key.split("-")[0]) + if not (start_year <= agri_start <= end_year): + continue + + season = season_key.split("_")[0] # kharif, rabi etc + full_key = f"{season}_{agri_key}" + + agri_year_totals[full_key] = agri_year_totals.get(full_key, 0) + float( + val + ) + + except Exception: + continue + + # Add all seasonal totals to new_props + for agri_key, total in agri_year_totals.items(): + new_props[f"precipitation_{agri_key}"] = total + + # Create EE Feature + geom_ee = ee.Geometry(feature["geometry"]) + feature_ee = ee.Feature(geom_ee, new_props) + features_ee.append(feature_ee) + + # Left side FC + mws_fc = ee.FeatureCollection(features_ee) + + return mws_fc + + +def generate_geojson_with_ci_and_ndvi(zoi_asset, ci_asset, ndvi_asset, proj_id): + # Load project + proj_obj = Project.objects.get(pk=proj_id) + + # Build CI and NDVI asset paths + asset_path_ci = ( + get_gee_dir_path( + [proj_obj.name], asset_path=GEE_PATHS["WATER_REJ"]["GEE_ASSET_PATH"] + ) + + ci_asset + ) + + asset_path_ndvi = ( + get_gee_dir_path( + [proj_obj.name], asset_path=GEE_PATHS["WATER_REJ"]["GEE_ASSET_PATH"] + ) + + ndvi_asset + ) + + # Load FeatureCollections + zoi = ee.FeatureCollection(zoi_asset) + ci = ee.FeatureCollection(asset_path_ci) + ndvi = ee.FeatureCollection(asset_path_ndvi) + + # ------------------------- + # STEP 1: Join ZOI with Cropping Intensity + # ------------------------- + join = ee.Join.inner() + filter = ee.Filter.intersects(leftField=".geo", rightField=".geo") + zoi_ci_joined = join.apply(zoi, ci, filter) + + def merge_zoi_ci(pair): + zoi_feat = ee.Feature(pair.get("primary")) + ci_feat = ee.Feature(pair.get("secondary")) + merged_props = zoi_feat.toDictionary().combine(ci_feat.toDictionary(), True) + return ee.Feature(zoi_feat.geometry(), merged_props) + + zoi_with_ci = ee.FeatureCollection(zoi_ci_joined.map(merge_zoi_ci)) + + # ------------------------- + # STEP 2: Join ZOI+CI with NDVI + # ------------------------- + zoi_ndvi_joined = join.apply(zoi_with_ci, ndvi, filter) + + def merge_zoi_ci_ndvi(pair): + ci_feat = ee.Feature(pair.get("primary")) + ndvi_feat = ee.Feature(pair.get("secondary")) + merged_props = ci_feat.toDictionary().combine(ndvi_feat.toDictionary(), True) + return ee.Feature(ci_feat.geometry(), merged_props) + + final_merged = ee.FeatureCollection(zoi_ndvi_joined.map(merge_zoi_ci_ndvi)) + + # ------------------------- + # STEP 3: Export or Push to GeoServer + # ------------------------- + layer_name = f"WaterRejapp_zoi_{proj_obj.name}_{proj_obj.id}" + sync_project_fc_to_geoserver(final_merged, proj_obj.name, layer_name, "waterrej") + + +def get_directory_size(path): + total_size = 0 + for dirpath, dirnames, filenames in os.walk(path): + for filename in filenames: + file_path = os.path.join(dirpath, filename) + if os.path.isfile(file_path): + total_size += os.path.getsize(file_path) + return total_size + + +def generate_geojson_with_ci_ndvi_ndmi( + zoi_asset, ci_asset, ndvi_asset, ndmi_asset, proj_id +): + + # Load project + proj_obj = Project.objects.get(pk=proj_id) + + zoi = ee.FeatureCollection(zoi_asset) + print("Number of features zoi:", zoi.size().getInfo()) + + ci = ee.FeatureCollection(ci_asset) + print("Number of features zoi:", ci.size().getInfo()) + ndvi = ee.FeatureCollection(ndmi_asset) + print("Number of features zoi:", ndvi.size().getInfo()) + ndmi = ee.FeatureCollection(ndmi_asset) + print("Number of features zoi:", ndmi.size().getInfo()) + + # ------------------------- + # STEP 1: Join ZOI with CI + # ------------------------- + join = ee.Join.inner() + filter = ee.Filter.intersects(leftField=".geo", rightField=".geo") + zoi_ci_joined = join.apply(zoi, ci, filter) + + def merge_zoi_ci(pair): + zoi_feat = ee.Feature(pair.get("primary")) + ci_feat = ee.Feature(pair.get("secondary")) + merged_props = zoi_feat.toDictionary().combine(ci_feat.toDictionary(), True) + return ee.Feature(zoi_feat.geometry(), merged_props) # ✅ keep ZOI geom + + zoi_with_ci = ee.FeatureCollection(zoi_ci_joined.map(merge_zoi_ci)) + + # ------------------------- + # STEP 2: Join with NDVI + # ------------------------- + zoi_ndvi_joined = join.apply(zoi_with_ci, ndvi, filter) + + def merge_zoi_ci_ndvi(pair): + prev_feat = ee.Feature(pair.get("primary")) + ndvi_feat = ee.Feature(pair.get("secondary")) + merged_props = prev_feat.toDictionary().combine(ndvi_feat.toDictionary(), True) + return ee.Feature(prev_feat.geometry(), merged_props) # ✅ still ZOI geom + + zoi_ci_ndvi = ee.FeatureCollection(zoi_ndvi_joined.map(merge_zoi_ci_ndvi)) + + # ------------------------- + # STEP 3: Join with NDMI + # ------------------------- + zoi_ndmi_joined = join.apply(zoi_ci_ndvi, ndmi, filter) + + def merge_zoi_ci_ndvi_ndmi(pair): + prev_feat = ee.Feature(pair.get("primary")) + ndmi_feat = ee.Feature(pair.get("secondary")) + merged_props = prev_feat.toDictionary().combine(ndmi_feat.toDictionary(), True) + return ee.Feature(prev_feat.geometry(), merged_props) # ✅ keep ZOI geom + + final_merged = ee.FeatureCollection(zoi_ndmi_joined.map(merge_zoi_ci_ndvi_ndmi)) + + # ------------------------- + # STEP 4: Export or Push to GeoServer + # ------------------------- + layer_name = f"WaterRejapp_zoi_{proj_obj.name}_{proj_obj.id}" + print(layer_name) + sync_project_fc_to_geoserver(final_merged, proj_obj.name, layer_name, "waterrej") + + +def generate_geojson_with_ci_ndvi(zoi_asset, ci_asset, ndvi_asset, proj_id): + # Load project + proj_obj = Project.objects.get(pk=proj_id) + + # Initialize Earth Engine + ee_initialize(4) + + # Load FeatureCollections + zoi = ee.FeatureCollection(zoi_asset) + ci = ee.FeatureCollection(ci_asset) + ndvi = ee.FeatureCollection(ndvi_asset) + + print("ZOI:", zoi.size().getInfo()) + print("CI:", ci.size().getInfo()) + print("NDVI:", ndvi.size().getInfo()) + + # Common join logic on UID + join = ee.Join.inner() + uid_filter = ee.Filter.equals(leftField="UID", rightField="UID") + + # --- Join ZOI + CI --- + zoi_ci_joined = join.apply(zoi, ci, uid_filter) + + def merge_zoi_ci(pair): + zoi_feat = ee.Feature(pair.get("primary")) + ci_feat = ee.Feature(pair.get("secondary")) + merged_props = zoi_feat.toDictionary().combine(ci_feat.toDictionary(), True) + # Keep ZOI geometry only + return ee.Feature(zoi_feat.geometry(), merged_props) + + zoi_with_ci = ee.FeatureCollection(zoi_ci_joined.map(merge_zoi_ci)) + + # --- Join with NDVI --- + zoi_ndvi_joined = join.apply(zoi_with_ci, ndvi, uid_filter) + + def merge_with_ndvi(pair): + base_feat = ee.Feature(pair.get("primary")) + ndvi_feat = ee.Feature(pair.get("secondary")) + merged_props = base_feat.toDictionary().combine(ndvi_feat.toDictionary(), True) + # Always retain ZOI geometry + return ee.Feature(base_feat.geometry(), merged_props) + + merged_final = ee.FeatureCollection(zoi_ndvi_joined.map(merge_with_ndvi)) + + # --- Ensure ZOI geometry retained in all features --- + merged_final = merged_final.map( + lambda f: ee.Feature( + f.setGeometry( + ee.Feature( + zoi.filter(ee.Filter.eq("UID", f.get("UID"))).first() + ).geometry() + ) + ) + ) + + layer_name = f"WaterRejapp_zoi_{proj_obj.name}_{proj_obj.id}" + print(layer_name) + + 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, + 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, +): + 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})") + + _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 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) + layer_obj = Layer.objects.get(dataset=dataset, layer_name=layer_name) + existing_end_date = layer_obj.misc["end_year"] + print("existing_end_date", existing_end_date) + return existing_end_date + + +def get_layer_object(state, district, block, layer_name, dataset_name): + 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) + layer_obj = ( + Layer.objects.filter( + state=state_obj, + district=district_obj, + block=block_obj, + layer_name=layer_name, + dataset__name=dataset_name, + ) + .order_by("-layer_version") + .first() + ) + return layer_obj + + +def update_dashboard_geojson( + state=None, + district=None, + block=None, + layer_name=None, + workspace_name=None, + proj_id=None, +): + if state and block and block: + print(f"🔄 Updating GeoJSON for {state}, {district}, {block}") + + # Get related objects + state_obj = StateSOI.objects.get(state_name=state) + district_obj = DistrictSOI.objects.get(district_name=district) + tehsil_obj = TehsilSOI.objects.get(tehsil_name=block) # fixed typo + + # Get or create main record + obj, created = State_Disritct_Block_Properties.objects.get_or_create( + state=state_obj, district=district_obj, tehsil=tehsil_obj + ) + else: + obj = Project.objects.get(pk=proj_id) + + # Map suffix to json_key + suffix_to_key = { + "wb": "wb_geojson", + "zoi": "zoi_geojson", + "mws": "mws_geojson", + } + + # Detect which key this layer corresponds to + json_key = None + for suffix, key in suffix_to_key.items(): + if layer_name == f"{state}_{district}_{block}_{suffix}": + json_key = key + break + + if not json_key: + print(f"⚠️ Layer name {layer_name} did not match any known type.") + return + + # Construct GeoServer URL + waterrej_url = ( + f"https://geoserver.core-stack.org:8443/geoserver/waterrej/ows?" + f"service=WFS&version=1.0.0&request=GetFeature&typeName={workspace_name}:{layer_name}" + f"&outputFormat=application%2Fjson" + ) + + # Load existing dashboard_geojson or create new + if proj_id: + misc = obj.dashboard_geojson or {} + else: + misc = obj.geojson_path or {} + + # Ensure waterrej section exists + if "waterrej" not in misc: + misc["waterrej"] = {} + + # Update or add this specific json_key + misc["waterrej"][json_key] = waterrej_url + + # Save the updated JSON field + obj.dashboard_geojson = misc + obj.save() + + print(f"✅ Added/Updated {json_key} for {state}, {district}, {block}") + + +def clean_geometry(geom): + """ + Clean geometry: + - Dissolve multipolygon → single polygon + - Remove holes automatically + - Fix invalid topology + - Buffer tiny polygons + """ + + # 1. Dissolve multi-polygons and remove holes + geom = geom.dissolve(maxError=1) + + # 2. Fix invalid rings by simplifying slightly (NEVER buffer(0)) + geom = geom.simplify(1) + + # 3. Buffer polygons smaller than 1 pixel (< 900 m²) + area = geom.area() + geom = ee.Algorithms.If( + area.lt(900), + geom.buffer(15), + geom, # ensure raster pixel center is captured + ) + + return ee.Geometry(geom) + + +def safe_reduce_max(image, geom, scale=30): + geom = clean_geometry(geom) + + val = ( + image.unmask(0) + .reduceRegion( + reducer=ee.Reducer.max(), + geometry=geom, + scale=scale, + maxPixels=1e13, + tileScale=4, + bestEffort=True, + ) + .get("b1") + ) + + return ee.Number(ee.Algorithms.If(val, val, 0)) + + +# ------------------------------------------------------ +# SAFE REDUCE MAX FUNCTION +# ------------------------------------------------------ +def safe_reduce_max(image, geom, scale=30): + geom = clean_geometry(geom) + + result = ( + image.unmask(0) + .reduceRegion( + reducer=ee.Reducer.max(), + geometry=geom, + scale=scale, + maxPixels=1e13, + tileScale=4, + bestEffort=True, + ) + .get("b1") + ) + + # Convert null → 0 + return ee.Number(ee.Algorithms.If(result, result, 0)) + + +# ------------------------------------------------------ +# MAIN FUNCTION TO PROCESS SWB LAYER +# ------------------------------------------------------ +def generate_swb_layer_with_max_so_catchment( + roi=None, + app_type="MWS", + asset_suffix=None, + asset_folder=None, + gee_account_id=None, +): + ee_initialize(gee_account_id) + + # Build asset paths + base_path = get_gee_dir_path( + asset_folder, asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"] + ) + + so_asset = f"{base_path}stream_order_{asset_suffix}_raster" + ca_asset = f"{base_path}catchment_area_{asset_suffix}_raster" + + # Load rasters + stream_order_band = ee.Image(so_asset).select("b1") + catchment_band = ee.Image(ca_asset).select("b1") + + # Processing per waterbody + def compute_for_feature(feature): + geom = feature.geometry() + + max_so = safe_reduce_max(stream_order_band, geom, scale=30) + max_ca = safe_reduce_max(catchment_band, geom, scale=30) + + return feature.set( + { + "max_stream_order": max_so, + "max_catchment_area": max_ca, + } + ) + + # Map over the feature collection + return roi.map(compute_for_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 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).first() + if layer_obj is None: + return None + + update_fields = [] + if sync_to_geoserver is not None: + layer_obj.is_sync_to_geoserver = sync_to_geoserver + update_fields.append("is_sync_to_geoserver") + if is_stac_specs_generated is not None: + layer_obj.is_stac_specs_generated = is_stac_specs_generated + update_fields.append("is_stac_specs_generated") + + # `save(update_fields=...)` fires the post_save signal so the STAC + # auto-trigger handler in `computing.signals` can pick up the flip. + if update_fields: + layer_obj.save(update_fields=update_fields) + print( + f"Updated {update_fields} for layer ID: {layer_id} " + f"(sync={sync_to_geoserver}, stac={is_stac_specs_generated})" + ) + return layer_id + + except Exception as e: + print(f"Error updating layer sync status: {e}") + + +# send missing layer to recipient email +def send_missing_layers_report(result: dict, recipients: list = None) -> bool: + if recipients is None: + recipients = getattr(settings, "MISSING_LAYER_RECIPIENTS", []) + + if isinstance(recipients, str): + recipients = [recipients] + + if not recipients: + logger.error("No recipients configured for missing layers report.") + return False + + summary = [] + total_missing = 0 + + for layer, data in result.items(): + count = len(data.get("missing_layers", [])) + total_missing += count + summary.append(f"{layer}: {count}") + body = ( + "Missing Layers Report\n\n" + f"Total Missing: {total_missing}\n\n" + + "\n".join(summary) + + "\n\nDetailed report attached." + ) + + attachment_content = json.dumps(result, indent=4) + max_retries = 3 + + for attempt in range(max_retries): + connection = None + try: + connection = get_connection(timeout=120) + connection.open() + email = EmailMessage( + subject="Missing Layers Report", + body=body, + from_email=settings.EMAIL_HOST_USER, + to=recipients, + connection=connection, + ) + email.attach( + "missing_layers.json", + attachment_content, + "application/json", + ) + email.send() + logger.info(f"Missing layers report sent to {recipients}") + logger.info( + f"Attachment size: " + f"{len(attachment_content.encode('utf-8')) / 1024:.2f} KB" + ) + return True + except Exception as e: + logger.exception(f"Attempt {attempt + 1}/{max_retries} failed: {e}") + if attempt < max_retries - 1: + wait_time = 5 * (attempt + 1) + logger.info(f"Retrying after {wait_time} seconds...") + time.sleep(wait_time) + else: + logger.error("All attempts to send email failed.") + return False + finally: + if connection: + try: + connection.close() + except Exception: + pass + + +def _is_cache_valid(cache: dict, workspace: str) -> bool: + if workspace not in cache: + return False + age = time.time() - cache[workspace]["cached_at"] + if age > 3600: + logger.info(f"Cache expired for {workspace} (age: {int(age)}s)") + return False + return True + + +def _set_cache(cache: dict, workspace: str, data: set): + cache[workspace] = { + "data": data, + "cached_at": time.time(), + } diff --git a/nrm_app/settings.py b/nrm_app/settings.py index 796ea402..6a353c40 100755 --- a/nrm_app/settings.py +++ b/nrm_app/settings.py @@ -359,6 +359,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 38cfe0ee08d96265cccde782a1cd04ce9a723c7f Mon Sep 17 00:00:00 2001 From: Ankit K Date: Thu, 16 Apr 2026 14:22:56 +0530 Subject: [PATCH 004/120] api for pushing db update --- computing/api.py | 29 +++++++++++++++ computing/utils.py | 91 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 96 insertions(+), 24 deletions(-) diff --git a/computing/api.py b/computing/api.py index 40a95b17..3d05a200 100644 --- a/computing/api.py +++ b/computing/api.py @@ -1994,6 +1994,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 f914a72c..12867a68 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -4,6 +4,8 @@ import os import shutil import zipfile +from django.conf import settings + from datetime import datetime, timedelta import ee @@ -608,20 +610,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): @@ -631,10 +638,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( @@ -651,6 +692,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) @@ -739,23 +796,9 @@ def save_layer_info_to_db( ) print(f"Saved layer info (id={layer_obj.id}, version={layer_obj.layer_version})") + return layer_obj.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, - }) - return layer_obj.id def get_existing_end_year(dataset_name, layer_name): From 34e14cd60d3100c753c08974393117762cd62fd8 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Thu, 16 Apr 2026 14:25:32 +0530 Subject: [PATCH 005/120] api for pushing db update --- computing/api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/computing/api.py b/computing/api.py index 3d05a200..33a8b90e 100644 --- a/computing/api.py +++ b/computing/api.py @@ -1999,7 +1999,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 901cc0627c16223569db3f406ea4c4e00396cf07 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Fri, 24 Apr 2026 00:13:59 +0530 Subject: [PATCH 006/120] 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 48774962..93831787 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): @@ -8,6 +9,4 @@ class ComputingConfig(AppConfig): def ready(self): import computing.tasks # noqa: F401 (task autodiscovery) import computing.signals # noqa: F401 (post_save -> auto STAC trigger) - 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 12867a68..289738e1 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -623,7 +623,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, @@ -652,7 +652,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, @@ -693,7 +693,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, @@ -707,6 +707,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 242b4c6a..3e3da645 100644 --- a/dpr/api.py +++ b/dpr/api.py @@ -482,9 +482,8 @@ def download_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 8f399b58e1794a3f162b044b4eb481770cc20fa2 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Fri, 24 Apr 2026 15:34:13 +0530 Subject: [PATCH 007/120] 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 e314e0a0a8da213331c6023c1d44c79d498d8f64 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Mon, 11 May 2026 13:32:26 +0530 Subject: [PATCH 008/120] change detection itr completed on geoserver and DB --- .../change_detection_vector_local.py | 1 + computing/local_compute_helper.py | 25 +- computing/utils.py | 300 +++++++----------- utilities/active_loc_layer_generation.py | 110 +++++++ utilities/active_location_for_layer_gen.json | 232 ++++++++++++++ utilities/geoserver_utils.py | 35 ++ 6 files changed, 513 insertions(+), 190 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 289738e1..943a0154 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -678,133 +678,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""" @@ -1013,81 +886,134 @@ def compute_for_feature(feature): return roi.map(compute_for_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 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") -def _sync_layer_to_prod_db(payload: dict): - prod_url = _get_prod_backend_url() - if not prod_url: - return None + dataset = Dataset.objects.get(name=dataset_name) - 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 + state_obj = StateSOI.objects.get(state_name__iexact=state) + district_obj = DistrictSOI.objects.get( + district_name__iexact=district, state=state_obj ) - return layer_id - except requests.RequestException as e: - logger.error( - "Failed to sync layer %s to prod DB: %s", payload.get("layer_name"), e + block_obj = TehsilSOI.objects.get( + tehsil_name__iexact=block, district=district_obj ) - 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: + except Exception as e: + print("Error fetching in state district block:", e) 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, + 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, ) - 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, + .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: - 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 + # 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": merged_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 update_layer_sync_status( layer_id, sync_to_geoserver=None, is_stac_specs_generated=None 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 4a3ea70d5915a56e6f811ebf6fc91937927ed9ad Mon Sep 17 00:00:00 2001 From: Ankit K Date: Tue, 12 May 2026 23:45:23 +0530 Subject: [PATCH 009/120] 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 9009a5a9258be7dddadefe3719af0432f6f525c8 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Wed, 13 May 2026 01:46:27 +0530 Subject: [PATCH 010/120] 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 d7c0489e6180e9edf086894a1fa6814810d5518c Mon Sep 17 00:00:00 2001 From: Ankit K Date: Wed, 13 May 2026 02:24:57 +0530 Subject: [PATCH 011/120] LULC vector --- computing/lulc/lulc_vector_local.py | 41 +++++++++++++++++------------ 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/computing/lulc/lulc_vector_local.py b/computing/lulc/lulc_vector_local.py index e9c7628c..1962c6fe 100644 --- a/computing/lulc/lulc_vector_local.py +++ b/computing/lulc/lulc_vector_local.py @@ -1,3 +1,4 @@ +import logging import os from nrm_app.celery import app @@ -18,6 +19,9 @@ save_layer_info_to_db, update_layer_sync_status, ) + +logger = logging.getLogger(__name__) + GEOSERVER_WORKSPACE = "lulc_vector" LOCAL_ALGORITHM = "local_lulc_vector" LOCAL_ALGORITHM_VERSION = "local-1.0" @@ -83,7 +87,7 @@ def run_lulc_vector_local( block=block, precomputed_roi_dir=precomputed_roi_dir, ) - print(f"Watershed boundary source: {watershed_source}") + logger.info("Watershed boundary source: %s", watershed_source) layer_name = _layer_name(district, block) result_gdf = watersheds_gdf.copy() @@ -94,7 +98,7 @@ def run_lulc_vector_local( ) 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}") + logger.info("Computing LULC vector properties for %d-%d: %s", year, year + 1, raster_path) year_result = compute_categorical_raster_areas_for_watersheds( watersheds_gdf=result_gdf, raster_path=raster_path, @@ -117,21 +121,9 @@ def run_lulc_vector_local( 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 + logger.info("Saved local LULC vector: %s", asset_id) + layer_id = None if sync_layer_metadata: layer_id = save_layer_info_to_db( state=state, @@ -148,7 +140,22 @@ def run_lulc_vector_local( algorithm=LOCAL_ALGORITHM, algorithm_version=LOCAL_ALGORITHM_VERSION, ) - if layer_id and push_to_geoserver: + logger.info("Saved layer metadata to DB: layer_id=%s", layer_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", + ) + logger.info("GeoServer response for %s: %s", layer_name, geoserver_response) + if not isinstance(geoserver_response, dict) or geoserver_response.get( + "status_code" + ) not in (200, 201): + logger.error("GeoServer upload failed for layer %s", layer_name) + return False + if layer_id: update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) return True From a797ae0c1a7de7cf79ad2b593d1673dad74a0005 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Wed, 13 May 2026 17:23:54 +0530 Subject: [PATCH 012/120] base config --- computing/base_layer_setup.py | 1 + computing/config.yaml | 5 ++ computing/config_loader.py | 1 + computing/lulc/lulc_vector_local.py | 38 ++++++++------ .../lulc_on_plain_cluster_local.py | 51 +++++++++++-------- .../lulc_on_slope_cluster_local.py | 47 ++++++++++------- computing/utils.py | 8 ++- 7 files changed, 93 insertions(+), 58 deletions(-) diff --git a/computing/base_layer_setup.py b/computing/base_layer_setup.py index 081460ec..500ada4e 100644 --- a/computing/base_layer_setup.py +++ b/computing/base_layer_setup.py @@ -1,5 +1,6 @@ import logging import subprocess +from pathlib import Path import requests from utilities.constants import GEOSERVER_BASE diff --git a/computing/config.yaml b/computing/config.yaml index dadce03e..fcd7b88b 100644 --- a/computing/config.yaml +++ b/computing/config.yaml @@ -109,6 +109,11 @@ local_compute_outputs: pattern: "{district}_{block}_lulc_slope.gpkg" geoserver_workspace: terrain_lulc + # lulc_on_plain_cluster_local.py + - path: data/lulc_X_terrain/lulc_plain_clusters_local/{state}/{district}/{block}/ + pattern: "{district}_{block}_lulc_plain.gpkg" + geoserver_workspace: terrain_lulc + misc: # aquifer_vector_local.py - path: data/misc/aquifer_vector_local/{state}/{district}/{block}/ diff --git a/computing/config_loader.py b/computing/config_loader.py index 2b3eadce..96362f0c 100644 --- a/computing/config_loader.py +++ b/computing/config_loader.py @@ -94,4 +94,5 @@ def _output_entry(module: str, index: int = 0) -> dict: 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"]) +LULC_PLAIN_CLUSTER_OUTPUT_DIR: Path = _abs(_output_entry("lulc_x_terrain", 1)["path"]) AQUIFER_VECTOR_OUTPUT_DIR: Path = _abs(_output_entry("misc", 0)["path"]) diff --git a/computing/lulc/lulc_vector_local.py b/computing/lulc/lulc_vector_local.py index 1962c6fe..702c83b6 100644 --- a/computing/lulc/lulc_vector_local.py +++ b/computing/lulc/lulc_vector_local.py @@ -123,6 +123,25 @@ def run_lulc_vector_local( ) logger.info("Saved local LULC vector: %s", asset_id) + geoserver_ok = False + 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", + ) + geoserver_ok = ( + isinstance(geoserver_response, dict) + and geoserver_response.get("status_code") in (200, 201) + ) + if geoserver_ok: + logger.info("GeoServer upload succeeded for layer %s", layer_name) + else: + logger.error( + "GeoServer upload failed for layer %s: %s", layer_name, geoserver_response + ) + layer_id = None if sync_layer_metadata: layer_id = save_layer_info_to_db( @@ -136,29 +155,16 @@ def run_lulc_vector_local( "start_year": start_year, "end_year": end_year, "is_generated_locally": True, + "geoserver_available": geoserver_ok, }, algorithm=LOCAL_ALGORITHM, algorithm_version=LOCAL_ALGORITHM_VERSION, ) logger.info("Saved layer metadata to DB: layer_id=%s", layer_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", - ) - logger.info("GeoServer response for %s: %s", layer_name, geoserver_response) - if not isinstance(geoserver_response, dict) or geoserver_response.get( - "status_code" - ) not in (200, 201): - logger.error("GeoServer upload failed for layer %s", layer_name) - return False - if layer_id: + if layer_id and geoserver_ok: update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) - return True + return geoserver_ok if push_to_geoserver else True def _vectorise_lulc_local_task( diff --git a/computing/lulc_X_terrain/lulc_on_plain_cluster_local.py b/computing/lulc_X_terrain/lulc_on_plain_cluster_local.py index 5427d096..5ae5dd91 100644 --- a/computing/lulc_X_terrain/lulc_on_plain_cluster_local.py +++ b/computing/lulc_X_terrain/lulc_on_plain_cluster_local.py @@ -1,3 +1,4 @@ +import logging import os from contextlib import ExitStack @@ -10,12 +11,12 @@ from nrm_app.celery import app +from computing.config_loader import LULC_PLAIN_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,8 +37,8 @@ from .utils import aez_lulcXterrain_cluster_centroids +logger = logging.getLogger(__name__) -LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/lulc_X_terrain/lulc_plain_clusters_local" GEOSERVER_WORKSPACE = "terrain_lulc" PLAIN_LULC_FIELD_MAPPING = { @@ -202,9 +203,7 @@ def _assign_plain_clusters( ) if index % 200 == 0 or index == total: - print( - f"Computed plain LULC clusters for {index}/{total} watersheds" - ) + logger.info("Computed plain LULC clusters for %d/%d watersheds", index, total) result = plain_watersheds_gdf.copy() computed_df = pd.DataFrame(computed_rows) @@ -320,11 +319,11 @@ def run_lulc_on_plain_cluster_local( 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}") + logger.info("Saved local plain-cluster vector: %s", asset_id) + logger.info("Watershed boundary source: %s", watershed_source) + logger.info("Resolved AEZ code: %s", aez_code) - geoserver_response = None + geoserver_ok = False if push_to_geoserver: geoserver_response = push_shape_to_geoserver( os.path.splitext(asset_id)[0], @@ -332,13 +331,18 @@ def run_lulc_on_plain_cluster_local( 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 + geoserver_ok = ( + isinstance(geoserver_response, dict) + and geoserver_response.get("status_code") in (200, 201) + ) + if geoserver_ok: + logger.info("GeoServer upload succeeded for layer %s", layer_name) + else: + logger.error( + "GeoServer upload failed for layer %s: %s", layer_name, geoserver_response + ) + layer_id = None if sync_layer_metadata: layer_id = save_layer_info_to_db( state=state, @@ -347,15 +351,18 @@ def run_lulc_on_plain_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, + "geoserver_available": geoserver_ok, + }, ) - if layer_id and push_to_geoserver: - update_layer_sync_status( - layer_id=layer_id, - sync_to_geoserver=True, - ) + logger.info("Saved layer metadata to DB: layer_id=%s", layer_id) + if layer_id and geoserver_ok: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) - return True + return geoserver_ok if push_to_geoserver else True def _generate_lulc_on_plain_cluster_local_task( 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 96d9ecc1..35db91e4 100644 --- a/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py +++ b/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py @@ -1,3 +1,4 @@ +import logging import os import numpy as np @@ -35,6 +36,7 @@ from .utils import aez_lulcXterrain_cluster_centroids +logger = logging.getLogger(__name__) GEOSERVER_WORKSPACE = "terrain_lulc" SLOPE_LULC_FIELD_MAPPING = { @@ -192,9 +194,7 @@ def _assign_slope_clusters( ) if index % 200 == 0 or index == total: - print( - f"Computed slope LULC clusters for {index}/{total} watersheds" - ) + logger.info("Computed slope LULC clusters for %d/%d watersheds", index, total) finally: for lulc_src in lulc_sources: lulc_src.close() @@ -313,10 +313,11 @@ def run_lulc_on_slope_cluster_local( 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}") + logger.info("Saved local slope-cluster vector: %s", asset_id) + logger.info("Watershed boundary source: %s", watershed_source) + logger.info("Resolved AEZ code: %s", aez_code) + geoserver_ok = False if push_to_geoserver: geoserver_response = push_shape_to_geoserver( str(output_path.with_suffix("")), @@ -324,13 +325,18 @@ def run_lulc_on_slope_cluster_local( 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 + geoserver_ok = ( + isinstance(geoserver_response, dict) + and geoserver_response.get("status_code") in (200, 201) + ) + if geoserver_ok: + logger.info("GeoServer upload succeeded for layer %s", layer_name) + else: + logger.error( + "GeoServer upload failed for layer %s: %s", layer_name, geoserver_response + ) + layer_id = None if sync_layer_metadata: layer_id = save_layer_info_to_db( state=state, @@ -339,15 +345,18 @@ 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, "is_generated_locally": True}, + misc={ + "start_year": start_year, + "end_year": end_year, + "is_generated_locally": True, + "geoserver_available": geoserver_ok, + }, ) - if layer_id and push_to_geoserver: - update_layer_sync_status( - layer_id=layer_id, - sync_to_geoserver=True, - ) + logger.info("Saved layer metadata to DB: layer_id=%s", layer_id) + if layer_id and geoserver_ok: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) - return True + return geoserver_ok if push_to_geoserver else True def _generate_lulc_on_slope_cluster_local_task( diff --git a/computing/utils.py b/computing/utils.py index 943a0154..f102b82c 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -979,7 +979,13 @@ 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 {})} + _deprecated_misc_keys = {"is_computed_locally"} + base_misc = { + k: v + for k, v in (existing_layer.misc or {}).items() + if k not in _deprecated_misc_keys + } + merged_misc = {**base_misc, **(misc or {})} for field, value in { "algorithm": algorithm, "algorithm_version": algorithm_version, From ca4fb243b2396dc332c5fd067022fcd48d33f524 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Fri, 5 Jun 2026 11:54:39 +0530 Subject: [PATCH 013/120] swb local clipping --- computing/api.py | 43 +++- computing/apps.py | 2 - computing/base_layer_setup.py | 95 ++++++-- computing/config.yaml | 10 + computing/config_loader.py | 7 + computing/local_compute_helper.py | 47 +++- computing/lulc/lulc_v3_local.py | 22 +- computing/lulc/lulc_vector_local.py | 8 +- computing/misc/admin_boundary.py | 3 + .../surface_water_bodies/clip_swb_local.py | 53 +++++ computing/surface_water_bodies/swb_local.py | 220 ++++++++++++++++++ .../store_watersheds_for_tehsils.py | 53 ++++- computing/utils.py | 55 +++-- nrm_app/settings.py | 19 +- nrm_app/wsgi.py | 19 +- 15 files changed, 569 insertions(+), 87 deletions(-) create mode 100644 computing/surface_water_bodies/clip_swb_local.py create mode 100644 computing/surface_water_bodies/swb_local.py diff --git a/computing/api.py b/computing/api.py index 33a8b90e..5c1ad484 100644 --- a/computing/api.py +++ b/computing/api.py @@ -55,7 +55,8 @@ 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 .surface_water_bodies.swb import generate_swb_layer as generate_swb_gee_task +from .surface_water_bodies.swb_local import generate_swb_layer as generate_swb_local_task from .drought.drought import calculate_drought from .terrain_descriptor.terrain_clusters import ( generate_terrain_clusters as generate_terrain_clusters_gee_task, @@ -588,23 +589,41 @@ def generate_swb(request): state = request.data.get("state") district = request.data.get("district") block = request.data.get("block") + roi = request.data.get("roi") + roi_path = request.data.get("roi_path") + asset_suffix = request.data.get("asset_suffix") start_year = request.data.get("start_year") end_year = request.data.get("end_year") gee_account_id = request.data.get("gee_account_id") - generate_swb_layer.apply_async( - kwargs={ - "state": state, - "district": district, - "block": block, - "start_year": start_year, - "end_year": end_year, - "gee_account_id": gee_account_id, - }, - queue="nrm", - ) + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_swb_gee_task, + generate_swb_local_task, + ) + task_kwargs = { + "state": state, + "district": district, + "block": block, + "start_year": start_year, + "end_year": end_year, + "gee_account_id": gee_account_id, + } + if compute == "local": + task_kwargs.update( + { + "roi": roi, + "roi_path": roi_path, + "asset_suffix": asset_suffix, + } + ) + task.apply_async(kwargs=task_kwargs, queue="nrm") return Response( {"Success": "Generate swb task initiated"}, status=status.HTTP_200_OK ) + except ValueError as e: + print("Invalid request in generate_swf api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in generate_swf api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/computing/apps.py b/computing/apps.py index 93831787..91f698d3 100644 --- a/computing/apps.py +++ b/computing/apps.py @@ -1,5 +1,4 @@ from django.apps import AppConfig -from computing.base_layer_setup import setup_base_layers class ComputingConfig(AppConfig): @@ -9,4 +8,3 @@ class ComputingConfig(AppConfig): def ready(self): import computing.tasks # noqa: F401 (task autodiscovery) import computing.signals # noqa: F401 (post_save -> auto STAC trigger) - setup_base_layers() diff --git a/computing/base_layer_setup.py b/computing/base_layer_setup.py index 500ada4e..98ddedee 100644 --- a/computing/base_layer_setup.py +++ b/computing/base_layer_setup.py @@ -1,23 +1,37 @@ import logging import subprocess +from functools import wraps from pathlib import Path import requests -from utilities.constants import GEOSERVER_BASE from computing.config_loader import ( ADMIN_BOUNDARY_INPUT_DIR, ADMIN_BOUNDARY_OUTPUT_DIR, + MICROWATERSHED_PATH, + PROJECT_ROOT, + SOI_TEHSIL_PATH, + VILLAGE_BOUNDARIES_DIR, +) +from computing.config_loader import ( GDRIVE_ADMIN_BOUNDARY_FILE_ID as _GDRIVE_ADMIN_BOUNDARY_FILE_ID, +) +from computing.config_loader import ( GDRIVE_MICROWATERSHED_FILE_ID as _GDRIVE_MICROWATERSHED_FILE_ID, +) +from computing.config_loader import ( LULC_BASE_DIR as LULC_DIR, +) +from computing.config_loader import ( LULC_GDRIVE_FILES as _LULC_GDRIVE_FILES, - MICROWATERSHED_PATH, +) +from computing.config_loader import ( PRECOMPUTED_TEHSIL_WATERSHED_DIR as TEHSIL_WATERSHEDS_DIR, - PROJECT_ROOT, - SOI_TEHSIL_PATH, - VILLAGE_BOUNDARIES_DIR, ) +from computing.terrain_descriptor.store_watersheds_for_tehsils import ( + generate_tehsil_watershed_copies, +) +from utilities.constants import GEOSERVER_BASE logger = logging.getLogger(__name__) @@ -151,7 +165,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: @@ -163,7 +179,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)], @@ -185,34 +203,36 @@ 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 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, + clip_to_tehsil=False, ) logger.info("Tehsil watershed files ready at %s", TEHSIL_WATERSHEDS_DIR) @@ -225,10 +245,43 @@ def ensure_village_boundaries_dir(): VILLAGE_BOUNDARIES_DIR.mkdir(parents=True, exist_ok=True) -def setup_base_layers(): - ensure_soi_tehsil() - ensure_admin_boundary_data() - # ensure_lulc_rasters() - # ensure_microwatershed() - # ensure_tehsil_watersheds() - ensure_village_boundaries_dir() +_BASE_LAYER_ENSURERS = { + "soi_tehsil": ensure_soi_tehsil, + "admin_boundary": ensure_admin_boundary_data, + "lulc_rasters": ensure_lulc_rasters, + "microwatershed": ensure_microwatershed, + "tehsil_watersheds": ensure_tehsil_watersheds, + "village_boundaries": ensure_village_boundaries_dir, +} + +DEFAULT_BASE_LAYERS = ( + "soi_tehsil", + "admin_boundary", + "village_boundaries", +) + + +def setup_base_layers(*layers): + selected_layers = layers or DEFAULT_BASE_LAYERS + for layer in selected_layers: + try: + ensure_layer = _BASE_LAYER_ENSURERS[layer] + except KeyError as exc: + available_layers = ", ".join(sorted(_BASE_LAYER_ENSURERS)) + raise ValueError( + f"Unknown base layer '{layer}'. Available layers: {available_layers}" + ) from exc + + ensure_layer() + + +def with_base_layers(*layers): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + setup_base_layers(*layers) + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/computing/config.yaml b/computing/config.yaml index fcd7b88b..633d3814 100644 --- a/computing/config.yaml +++ b/computing/config.yaml @@ -43,6 +43,10 @@ base_layers: source: manual note: Pan-India aquifer vector layer + - path: data/base_layers/pan_india_waterbodies.geojson + source: manual + note: Pan-India surface water bodies vector layer used for local clipping + - path: data/admin-boundary/input/ source: google_drive gdrive_id: 1VqIhB6HrKFDkDnlk1vedcEHhh5fk4f1d @@ -119,3 +123,9 @@ local_compute_outputs: - path: data/misc/aquifer_vector_local/{state}/{district}/{block}/ pattern: "aquifer_vector_{district}_{block}.gpkg" geoserver_workspace: aquifer + + surface_water_bodies: + # swb_local.py + - path: data/surface_water_bodies/swb_local/{state}/{district}/{block}/ + pattern: "surface_waterbodies_{district}_{block}.gpkg" + geoserver_workspace: swb diff --git a/computing/config_loader.py b/computing/config_loader.py index 96362f0c..fc7fb5e1 100644 --- a/computing/config_loader.py +++ b/computing/config_loader.py @@ -62,6 +62,10 @@ def _output_entry(module: str, index: int = 0) -> dict: "data/base_layers/Aquifer_vector.geojson" )["path"] +SWB_VECTOR_PATH: Path = PROJECT_ROOT / _find_input( + "data/base_layers/pan_india_waterbodies.geojson" +)["path"] + SOI_TEHSIL_PATH: Path = PROJECT_ROOT / _find_input( "data/admin-boundary/input/soi_tehsil.geojson" )["path"] @@ -96,3 +100,6 @@ def _output_entry(module: str, index: int = 0) -> dict: LULC_SLOPE_CLUSTER_OUTPUT_DIR: Path = _abs(_output_entry("lulc_x_terrain", 0)["path"]) LULC_PLAIN_CLUSTER_OUTPUT_DIR: Path = _abs(_output_entry("lulc_x_terrain", 1)["path"]) AQUIFER_VECTOR_OUTPUT_DIR: Path = _abs(_output_entry("misc", 0)["path"]) +SWB_VECTOR_OUTPUT_DIR: Path = _abs( + _output_entry("surface_water_bodies", 0)["path"] +) diff --git a/computing/local_compute_helper.py b/computing/local_compute_helper.py index 254a32ea..07c0bf3b 100644 --- a/computing/local_compute_helper.py +++ b/computing/local_compute_helper.py @@ -169,6 +169,7 @@ def build_output_vector_path( district, block, output_base_dir, + custom_subdir="custom", block_fallback="unknown_block", ): output_dir = _build_output_dir( @@ -176,6 +177,7 @@ def build_output_vector_path( state=state, district=district, block=block, + custom_subdir=custom_subdir, block_fallback=block_fallback, ) return output_dir / f"{layer_name}.gpkg" @@ -337,10 +339,20 @@ def push_local_vector_to_geoserver(path, layer_name, workspace, file_type="gpkg" _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) + local_response = _push_vector_to_geoserver_instance( + local_geo, path, layer_name, workspace, file_type + ) + local_ok = isinstance(local_response, dict) and local_response.get("status_code") in ( + 200, + 201, + ) + _log.info( + "Pushed vector %s to local GeoServer (ok=%s).", layer_name, local_ok + ) prod_url = getattr(settings, "PROD_GEOSERVER_URL", "") + prod_response = None + prod_ok = None if prod_url: try: prod_geo = Geoserver( @@ -348,12 +360,37 @@ def push_local_vector_to_geoserver(path, layer_name, workspace, file_type="gpkg" 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) + prod_response = _push_vector_to_geoserver_instance( + prod_geo, path, layer_name, workspace, file_type + ) + prod_ok = isinstance(prod_response, dict) and prod_response.get( + "status_code" + ) in (200, 201) + _log.info( + "Pushed vector %s to prod GeoServer (%s) (ok=%s).", + layer_name, + prod_url, + prod_ok, + ) except Exception as e: + prod_ok = False + prod_response = {"error": str(e)} _log.error("Failed to push vector %s to prod GeoServer: %s", layer_name, e) - return response + overall_ok = local_ok and (prod_ok if prod_ok is not None else True) + failure_status = None + if isinstance(prod_response, dict): + failure_status = prod_response.get("status_code") + if failure_status is None and isinstance(local_response, dict): + failure_status = local_response.get("status_code") + + return { + "status_code": 201 if overall_ok else (failure_status or 500), + "local_ok": local_ok, + "prod_ok": prod_ok, + "local_response": local_response, + "prod_response": prod_response, + } def push_local_raster_to_geoserver(file_path, layer_name, workspace, style_name=None): diff --git a/computing/lulc/lulc_v3_local.py b/computing/lulc/lulc_v3_local.py index 13e273d4..99c1a7d2 100644 --- a/computing/lulc/lulc_v3_local.py +++ b/computing/lulc/lulc_v3_local.py @@ -1,6 +1,3 @@ -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, @@ -13,7 +10,11 @@ resolve_lulc_raster_paths, ) from computing.models import Dataset +from computing.STAC_specs import generate_STAC_layerwise from computing.utils import save_layer_info_to_db, update_layer_sync_status +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + GEOSERVER_WORKSPACE = "LULC_v3" GEOSERVER_STYLE = "lulc_level_3_style" LOCAL_ALGORITHM = "local_lulc_v3_clip" @@ -51,10 +52,7 @@ def _resolve_roi(state, district, block, roi_path, precomputed_roi_dir): def _resolve_filename_prefix(district, block, asset_suffix): if district and block: - return ( - f"{_slug(district, 'unknown_district')}_" - f"{_slug(block, 'unknown_block')}" - ) + return f"{_slug(district, 'unknown_district')}_{_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." @@ -63,10 +61,7 @@ def _resolve_filename_prefix(district, block, asset_suffix): def _build_output_stub(filename_prefix, start_year): - return ( - f"{filename_prefix}_{start_year}-07-01_" - f"{start_year + 1}-06-30_LULCmap_10m" - ) + return f"{filename_prefix}_{start_year}-07-01_{start_year + 1}-06-30_LULCmap_10m" def _build_layer_name(start_year, end_year, filename_prefix): @@ -74,11 +69,12 @@ def _build_layer_name(start_year, end_year, filename_prefix): def _resolve_dataset_name(): - return "LULC_v3" if Dataset.objects.filter(name="LULC_v3").exists() else "LULC_level_3" + 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, diff --git a/computing/lulc/lulc_vector_local.py b/computing/lulc/lulc_vector_local.py index 702c83b6..be523cd0 100644 --- a/computing/lulc/lulc_vector_local.py +++ b/computing/lulc/lulc_vector_local.py @@ -11,11 +11,11 @@ build_output_vector_path, compute_categorical_raster_areas_for_watersheds, load_precomputed_watersheds, + push_local_vector_to_geoserver, resolve_lulc_raster_paths, write_vector_output, ) from computing.utils import ( - push_shape_to_geoserver, save_layer_info_to_db, update_layer_sync_status, ) @@ -124,9 +124,10 @@ def run_lulc_vector_local( logger.info("Saved local LULC vector: %s", asset_id) geoserver_ok = False + geoserver_response = None if push_to_geoserver: - geoserver_response = push_shape_to_geoserver( - os.path.splitext(asset_id)[0], + geoserver_response = push_local_vector_to_geoserver( + path=os.path.splitext(asset_id)[0], workspace=GEOSERVER_WORKSPACE, layer_name=layer_name, file_type="gpkg", @@ -156,6 +157,7 @@ def run_lulc_vector_local( "end_year": end_year, "is_generated_locally": True, "geoserver_available": geoserver_ok, + "geoserver_sync_response": geoserver_response, }, algorithm=LOCAL_ALGORITHM, algorithm_version=LOCAL_ALGORITHM_VERSION, diff --git a/computing/misc/admin_boundary.py b/computing/misc/admin_boundary.py index 0cd5c080..7f74f87e 100644 --- a/computing/misc/admin_boundary.py +++ b/computing/misc/admin_boundary.py @@ -8,6 +8,7 @@ generate_shape_files, push_shape_to_geoserver, ) +from computing.base_layer_setup import with_base_layers from utilities.gee_utils import ( ee_initialize, valid_gee_text, @@ -26,6 +27,7 @@ @app.task(bind=True) +@with_base_layers("soi_tehsil", "admin_boundary") def generate_tehsil_shape_file_data(self, state, district, block, gee_account_id): """ It will generate Admin boundary of given location as tehsil levels @@ -92,6 +94,7 @@ def create_shp_files(collection, state_dir, district, block, layer_id): return path +@with_base_layers("soi_tehsil", "admin_boundary") def clip_block_from_admin_boundary(state, district, block): census_2011 = None try: diff --git a/computing/surface_water_bodies/clip_swb_local.py b/computing/surface_water_bodies/clip_swb_local.py new file mode 100644 index 00000000..a98660ec --- /dev/null +++ b/computing/surface_water_bodies/clip_swb_local.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Any + +import geopandas as gpd +import shapely +from shapely.geometry import shape +from shapely.geometry.base import BaseGeometry +from shapely.ops import unary_union + + +def _to_geom(roi: Any) -> BaseGeometry: + """Normalize a GeoJSON ROI payload into a valid Shapely geometry.""" + if isinstance(roi, BaseGeometry): + geometry = roi + elif isinstance(roi, dict): + roi_type = roi.get("type") + if roi_type == "Feature": + geometry = shape(roi["geometry"]) + elif roi_type == "FeatureCollection": + features = roi.get("features") or [] + if not features: + raise ValueError("Empty FeatureCollection") + geometry = unary_union([shape(feature["geometry"]) for feature in features]) + else: + geometry = shape(roi) + else: + raise ValueError("ROI must be a GeoJSON object") + + if not geometry.is_valid: + geometry = shapely.make_valid(geometry) + if geometry.is_empty: + raise ValueError("Empty ROI geometry") + return geometry + + +def _clip_gdf(swb_path: str, roi: BaseGeometry) -> gpd.GeoDataFrame: + """Clip SWB features against the ROI after a bbox-prefiltered read.""" + minx, miny, maxx, maxy = roi.bounds + gdf = gpd.read_file(swb_path, bbox=(minx, miny, maxx, maxy), engine="pyogrio") + if gdf.empty: + return gdf + + if gdf.crs is not None and gdf.crs.to_epsg() != 4326: + gdf = gdf.to_crs(4326) + + clipped = gdf[gdf.geometry.intersects(roi)] + if clipped.empty: + return clipped + + clipped = clipped.copy() + clipped["geometry"] = clipped.geometry.intersection(roi) + return clipped[~clipped.geometry.is_empty].reset_index(drop=True) diff --git a/computing/surface_water_bodies/swb_local.py b/computing/surface_water_bodies/swb_local.py new file mode 100644 index 00000000..acf65f20 --- /dev/null +++ b/computing/surface_water_bodies/swb_local.py @@ -0,0 +1,220 @@ +import os +from pathlib import Path + +import geopandas as gpd + +from computing.config_loader import ( + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + SWB_VECTOR_OUTPUT_DIR as LOCAL_OUTPUT_BASE_DIR, + SWB_VECTOR_PATH, +) +from computing.local_compute_helper import ( + build_output_vector_path, + load_precomputed_roi, + push_local_vector_to_geoserver, + read_validated_vector_file, + write_vector_output, +) +from computing.surface_water_bodies.clip_swb_local import _clip_gdf, _to_geom +from computing.utils import save_layer_info_to_db, update_layer_sync_status +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + +GEOSERVER_WORKSPACE = "swb" +LOCAL_ALGORITHM = "local_surface_water_bodies_clip" +LOCAL_ALGORITHM_VERSION = "local-1.0" +DATASET_NAME = "Surface Water Bodies" + + +def _slug(value, fallback): + if value is None: + return fallback + return valid_gee_text(str(value).strip().lower()) or fallback + + +def _resolve_source_path(vector_path): + src = Path(vector_path).expanduser().resolve() + if not src.exists(): + raise FileNotFoundError(f"Surface water bodies source not found: {src}") + + prepared = src.with_suffix(".fgb") + return str(prepared if prepared.exists() else src) + + +def _resolve_roi_gdf(state, district, block, roi=None, roi_path=None): + if state and district and block: + return load_precomputed_roi( + state=state, + district=district, + block=block, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + ) + + if roi is not None: + geometry = _to_geom(roi) + return gpd.GeoDataFrame({"geometry": [geometry]}, crs="EPSG:4326") + + if roi_path: + return read_validated_vector_file( + roi_path, + f"ROI file has no valid geometries: {roi_path}", + ) + + raise ValueError( + "Provide either state/district/block or a valid `roi`/`roi_path` for local SWB clipping." + ) + + +def _resolve_asset_suffix(state, district, block, asset_suffix): + if state and district and block: + return f"{_slug(district, 'unknown_district')}_{_slug(block, 'unknown_block')}" + if asset_suffix and str(asset_suffix).strip(): + return _slug(asset_suffix, "custom") + raise ValueError( + "For non state/district/block runs, `asset_suffix` must be provided." + ) + + +def _layer_name(asset_suffix): + return f"surface_waterbodies_{asset_suffix}" + + +def run_swb_local( + state=None, + district=None, + block=None, + roi=None, + roi_path=None, + asset_suffix=None, + swb_path=SWB_VECTOR_PATH, + 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 + + roi_gdf = _resolve_roi_gdf( + state=state, + district=district, + block=block, + roi=roi, + roi_path=roi_path, + ) + roi_geometry = roi_gdf.union_all() + if roi_geometry.is_empty: + raise ValueError("ROI geometry is empty after validation.") + + asset_suffix = _resolve_asset_suffix(state, district, block, asset_suffix) + layer_name = _layer_name(asset_suffix) + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + custom_subdir=asset_suffix, + block_fallback="unknown_block", + ) + + clipped_gdf = _clip_gdf(_resolve_source_path(swb_path), roi_geometry) + if clipped_gdf.empty: + raise ValueError("No surface water body features intersect the provided ROI.") + + asset_id = write_vector_output( + gdf=clipped_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local SWB vector: {asset_id}") + + geoserver_ok = False + if push_to_geoserver: + geoserver_response = push_local_vector_to_geoserver( + path=os.path.splitext(asset_id)[0], + layer_name=layer_name, + workspace=GEOSERVER_WORKSPACE, + file_type="gpkg", + ) + geoserver_ok = ( + isinstance(geoserver_response, dict) + and geoserver_response.get("status_code") in (200, 201) + ) + print(f"GeoServer response for {layer_name}: {geoserver_response}") + + layer_id = None + is_admin_run = bool(state and district and block) + 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=asset_id, + dataset_name=DATASET_NAME, + misc={ + "is_generated_locally": True, + "feature_count": int(len(clipped_gdf)), + "geoserver_available": geoserver_ok, + }, + algorithm=LOCAL_ALGORITHM, + algorithm_version=LOCAL_ALGORITHM_VERSION, + ) + if layer_id and geoserver_ok: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + + return geoserver_ok if push_to_geoserver else True + + +def _generate_swb_local_task( + state=None, + district=None, + block=None, + roi=None, + roi_path=None, + asset_suffix=None, + start_year=None, + end_year=None, + gee_account_id=None, + app_type="MWS", +): + _ = start_year, end_year, gee_account_id, app_type + return run_swb_local( + state=state, + district=district, + block=block, + roi=roi, + roi_path=roi_path, + asset_suffix=asset_suffix, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + + +@app.task(bind=True) +def generate_swb_layer( + self, + state=None, + district=None, + block=None, + roi=None, + roi_path=None, + asset_suffix=None, + start_year=None, + end_year=None, + gee_account_id=None, + app_type="MWS", +): + _ = self + return _generate_swb_local_task( + state=state, + district=district, + block=block, + roi=roi, + roi_path=roi_path, + asset_suffix=asset_suffix, + start_year=start_year, + end_year=end_year, + gee_account_id=gee_account_id, + app_type=app_type, + ) diff --git a/computing/terrain_descriptor/store_watersheds_for_tehsils.py b/computing/terrain_descriptor/store_watersheds_for_tehsils.py index 530a8ef8..6986b4f0 100644 --- a/computing/terrain_descriptor/store_watersheds_for_tehsils.py +++ b/computing/terrain_descriptor/store_watersheds_for_tehsils.py @@ -185,6 +185,36 @@ def _save_subset(subset_gdf, output_path, driver): subset_gdf.to_file(output_path, driver=driver) +def _clip_microwatersheds_to_tehsil(candidates_gdf, tehsil_geom): + clipped_gdf = candidates_gdf.copy() + clipped_gdf["geometry"] = clipped_gdf.geometry.intersection(tehsil_geom) + clipped_gdf = _validate_geometry(clipped_gdf, fix_invalid=False) + + if clipped_gdf.empty: + return clipped_gdf + + # Polygon-on-polygon intersection can still produce line/point results for + # features that only touch the tehsil boundary. Keep only polygonal output. + polygonal_mask = clipped_gdf.geometry.geom_type.isin(["Polygon", "MultiPolygon"]) + clipped_gdf = clipped_gdf[polygonal_mask].copy() + + return clipped_gdf + + +def _select_microwatersheds_for_tehsil(candidates_gdf, tehsil_geom, clip_to_tehsil): + """ + Match GEE `FeatureCollection.filterBounds(...)` by default: + keep full microwatershed features whose geometry intersects the tehsil. + Optional clipping is exposed only for workflows that explicitly need it. + """ + selected_gdf = candidates_gdf[candidates_gdf.intersects(tehsil_geom)].copy() + + if not clip_to_tehsil or selected_gdf.empty: + return selected_gdf + + return _clip_microwatersheds_to_tehsil(selected_gdf, tehsil_geom) + + def generate_tehsil_watershed_copies( microwatershed_path=DEFAULT_MICROWATERSHED_PATH, tehsil_path=DEFAULT_TEHSIL_PATH, @@ -193,10 +223,18 @@ def generate_tehsil_watershed_copies( overwrite=False, include_empty=False, fix_invalid_mws=False, + clip_to_tehsil=False, state=None, district=None, tehsil=None, ): + """ + Generate per-tehsil MWS layers. + + Default behavior mirrors the current GEE pipeline in `computing/mws/mws.py`, + where `filterBounds(admin_boundary.geometry())` selects full intersecting + microwatershed features without clipping them to the admin boundary. + """ if output_format not in OUTPUT_FORMATS: raise ValueError( f"Unsupported format: {output_format}. Supported: {sorted(OUTPUT_FORMATS)}" @@ -254,7 +292,11 @@ def generate_tehsil_watershed_copies( intersection_gdf = microwatersheds.iloc[0:0].copy() else: candidates = microwatersheds.iloc[list(candidate_ids)] - intersection_gdf = candidates[candidates.intersects(tehsil_geom)].copy() + intersection_gdf = _select_microwatersheds_for_tehsil( + candidates, + tehsil_geom, + clip_to_tehsil=clip_to_tehsil, + ) feature_count = len(intersection_gdf) state_dir = valid_gee_text(str(state_name).strip().lower()) or "unknown_state" @@ -372,6 +414,14 @@ def _build_parser(): action="store_true", help="Fix invalid microwatershed geometries (slower, use only if needed)", ) + parser.add_argument( + "--clip-to-tehsil", + action="store_true", + help=( + "Clip exported microwatershed geometries to the tehsil boundary " + "instead of matching GEE filterBounds behavior" + ), + ) parser.add_argument("--state", help="Optional state filter") parser.add_argument("--district", help="Optional district filter") parser.add_argument("--tehsil", help="Optional tehsil filter") @@ -388,6 +438,7 @@ def _build_parser(): overwrite=args.overwrite, include_empty=args.include_empty, fix_invalid_mws=args.fix_invalid_mws, + clip_to_tehsil=args.clip_to_tehsil, state=args.state, district=args.district, tehsil=args.tehsil, diff --git a/computing/utils.py b/computing/utils.py index f102b82c..20ad5c57 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -3,9 +3,8 @@ import logging import os import shutil +import time import zipfile -from django.conf import settings - from datetime import datetime, timedelta import ee @@ -13,9 +12,11 @@ import geopandas as gpd import requests from django.conf import settings +from django.core.mail import EmailMessage from shapely.geometry import shape from shapely.validation import explain_validity +from computing.base_layer_setup import with_base_layers from computing.models import Dataset, Layer from geoadmin.models import ( DistrictSOI, @@ -102,6 +103,7 @@ def push_shape_to_geoserver( return response +@with_base_layers("admin_boundary") def kml_to_geojson(state_name, district_name, block_name, kml_path): fiona.drvsupport.supported_drivers["kml"] = ( "rw" # enable KML support which is disabled by default @@ -640,14 +642,20 @@ def _sync_layer_to_prod_db(payload: dict): ) 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) + 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) + 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): +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 @@ -675,7 +683,9 @@ def _update_layer_sync_remote(layer_id, sync_to_geoserver=None, is_stac_specs_ge 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) + logger.error( + "Failed to update layer sync status on prod DB for id=%s: %s", layer_id, e + ) @@ -903,24 +913,27 @@ def save_layer_info_to_db( 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, - }) + 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 + "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/nrm_app/settings.py b/nrm_app/settings.py index 6a353c40..c16f202f 100755 --- a/nrm_app/settings.py +++ b/nrm_app/settings.py @@ -11,6 +11,7 @@ """ import os +import site from datetime import timedelta from pathlib import Path @@ -25,6 +26,18 @@ BASE_DIR = Path(__file__).resolve().parent.parent os.environ.setdefault("BACKEND_DIR", str(BASE_DIR)) +PYTHON_ENV_DIR = Path(site.__file__).resolve() +if "lib" in PYTHON_ENV_DIR.parts: + PYTHON_ENV_DIR = Path(*PYTHON_ENV_DIR.parts[: PYTHON_ENV_DIR.parts.index("lib")]) + GDAL_DATA_DIR = PYTHON_ENV_DIR / "share" / "gdal" + PROJ_DATA_DIR = PYTHON_ENV_DIR / "share" / "proj" + + if GDAL_DATA_DIR.is_dir(): + os.environ.setdefault("GDAL_DATA", str(GDAL_DATA_DIR)) + if PROJ_DATA_DIR.is_dir(): + os.environ.setdefault("PROJ_LIB", str(PROJ_DATA_DIR)) + os.environ.setdefault("PROJ_DATA", str(PROJ_DATA_DIR)) + def resolve_env_path(name, default="", *, trailing_sep=False): raw_value = str(os.environ.get(name, default) or "").strip() @@ -74,7 +87,7 @@ def resolve_env_path(name, default="", *, trailing_sep=False): USERNAME_GESDISC = env("USERNAME_GESDISC") PASSWORD_GESDISC = env("PASSWORD_GESDISC") -STATIC_ROOT = "static/" +STATIC_ROOT = BASE_DIR / "static" GEE_HELPER_ACCOUNT_ID = env("GEE_HELPER_ACCOUNT_ID") GEE_DEFAULT_ACCOUNT_ID = env("GEE_DEFAULT_ACCOUNT_ID") ADMIN_GROUP_ID = env("ADMIN_GROUP_ID") @@ -274,8 +287,8 @@ def resolve_env_path(name, default="", *, trailing_sep=False): # https://docs.djangoproject.com/en/4.2/howto/static-files/ AUTH_USER_MODEL = "users.User" -STATIC_URL = "static/" -STATIC_ROOT = "static/" +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "static" ASSET_DIR = "/home/ubuntu/cfpt/core-stack-backend/assets/" # Media files (User uploaded content) diff --git a/nrm_app/wsgi.py b/nrm_app/wsgi.py index 8d89c4c9..f2cc7948 100755 --- a/nrm_app/wsgi.py +++ b/nrm_app/wsgi.py @@ -21,13 +21,20 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nrm_app.settings") +conda_env = os.path.dirname(site.__file__).split("/lib")[0] +gdal_data = os.path.join(conda_env, "share", "gdal") +proj_lib = os.path.join(conda_env, "share", "proj") +lib_path = os.path.join(conda_env, "lib") + +if os.path.isdir(gdal_data): + os.environ["GDAL_DATA"] = gdal_data +if os.path.isdir(proj_lib): + os.environ["PROJ_LIB"] = proj_lib + os.environ["PROJ_DATA"] = proj_lib +if os.path.isdir(lib_path): + os.environ["LD_LIBRARY_PATH"] = lib_path + if DEBUG: - conda_env = os.path.dirname(site.__file__).split('/lib')[0] print("CONDA ENV: ", conda_env) - os.environ['GDAL_DATA'] = f"{conda_env}/share/gdal" - os.environ['LD_LIBRARY_PATH'] = f"{conda_env}/lib" -else: - os.environ['GDAL_DATA'] = '/home/ubuntu/prod_dir/nrm-app/venv/envs/corestack/share/gdal' - os.environ['LD_LIBRARY_PATH'] = '/home/ubuntu/prod_dir/nrm-app/venv/envs/corestack/lib' application = get_wsgi_application() From 002e736ec71637483e3b9320d3a3647a98938642 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Mon, 8 Jun 2026 17:15:17 +0530 Subject: [PATCH 014/120] swb chunks upload to gee --- computing/api.py | 149 +++++--- .../surface_water_bodies/clip_swb_local.py | 24 +- computing/surface_water_bodies/swb_local.py | 349 ++++++++++++++++-- 3 files changed, 425 insertions(+), 97 deletions(-) diff --git a/computing/api.py b/computing/api.py index 5c1ad484..cb25df10 100644 --- a/computing/api.py +++ b/computing/api.py @@ -1,7 +1,10 @@ import json import os + import requests -from nrm_app.settings import BASE_DIR, LOCAL_COMPUTE_API_URL +from django.conf import settings +from django.core.files.storage import FileSystemStorage +from rest_framework import status from rest_framework.decorators import ( api_view, authentication_classes, @@ -9,23 +12,47 @@ permission_classes, schema, ) +from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.permissions import AllowAny from rest_framework.response import Response -from rest_framework import status -from rest_framework.parsers import MultiPartParser, FormParser +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 computing.change_detection.change_detection_vector import ( 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, - update_layer_sync_status, +from computing.layer_dependency.layer_generation_in_order import layer_generate_map +from computing.misc.drainage_lines import clip_drainage_lines +from computing.STAC_specs.stac_collection import STACConfig, sanitize_text +from nrm_app.settings import BASE_DIR, LOCAL_COMPUTE_API_URL +from utilities.auth_check_decorator import api_security_check +from utilities.constants import KML_PATH +from utilities.gee_utils import check_gee_task_status, download_gee_layer + +from .clart.clart import generate_clart_layer +from .clart.fes_clart_to_geoserver import generate_fes_clart_layer +from .crop_grid.crop_grid import create_crop_grids +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 django.conf import settings -from computing.STAC_specs.stac_collection import sanitize_text, STACConfig +from .drought.drought import calculate_drought +from .drought.drought_causality import drought_causality +from .local_compute_helper import ( + get_compute_mode as _get_compute_mode, +) +from .local_compute_helper import ( + select_compute_task as _select_compute_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 .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 @@ -33,31 +60,51 @@ from .lulc.tehsil_level.lulc_v2 import generate_lulc_v2_tehsil from .lulc.tehsil_level.lulc_v3 import generate_lulc_v3_tehsil from .lulc.v4.lulc_v4 import generate_lulc_v4 +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 .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 .misc.admin_boundary import generate_tehsil_shape_file_data +from .misc.agroecological_space import generate_agroecological_data +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.catchment_area import generate_catchment_area_singleflow +from .misc.distancetonearestdrainage import generate_distance_to_nearest_drainage_line +from .misc.facilities_proximity import generate_facilities_proximity_task +from .misc.factory_csr import generate_factory_csr_data +from .misc.green_credit import generate_green_credit_data +from .misc.lcw_conflict import generate_lcw_conflict_data +from .misc.mining_data import generate_mining_data +from .misc.naturaldepression import generate_natural_depression_data from .misc.ndvi_time_series import ndvi_timeseries +from .misc.nrega import clip_nrega_district_block from .misc.restoration_opportunity import generate_restoration_opportunity +from .misc.slope_percentage import generate_slope_percentage_data +from .misc.soge_vector import generate_soge_vector from .misc.stream_order import generate_stream_order from .mws.generate_hydrology import generate_hydrology -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 -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 .mws.mws_centroid import generate_mws_centroid_data +from .mws.mws_connectivity import generate_mws_connectivity_data +from .plantation.site_suitability import site_suitability +from .STAC_specs.stac_collection import _make_celery_task as _make_stac_task +from .surface_water_bodies.merge_swb_ponds import merge_swb_ponds from .surface_water_bodies.swb import generate_swb_layer as generate_swb_gee_task -from .surface_water_bodies.swb_local import generate_swb_layer as generate_swb_local_task -from .drought.drought import calculate_drought +from .surface_water_bodies.swb_local import ( + generate_swb_layer as generate_swb_local_task, +) from .terrain_descriptor.terrain_clusters import ( generate_terrain_clusters as generate_terrain_clusters_gee_task, ) @@ -73,41 +120,17 @@ 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 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 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 -from .tree_health.overall_change import tree_health_overall_change_raster -from .drought.drought_causality import drought_causality -from .tree_health.overall_change_vector import tree_health_overall_change_vector from .tree_health.canopy_height_vector import tree_health_ch_vector +from .tree_health.ccd import tree_health_ccd_raster 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 as generate_aquifer_vector_gee_task, +from .tree_health.overall_change import tree_health_overall_change_raster +from .tree_health.overall_change_vector import tree_health_overall_change_vector +from .utils import ( + Geoserver, + kml_to_shp, + save_layer_info_to_db, + update_layer_sync_status, ) from .misc.aquifer_vector_local import ( generate_aquifer_vector as generate_aquifer_vector_local_task, @@ -118,6 +141,7 @@ from utilities.auth_check_decorator import api_security_check from computing.layer_dependency.layer_generation_in_order import layer_generate_map from .views import ( + check_missing_layers, layer_status, get_layers_of_workspace, missing_layer_for_all_workspace, @@ -886,6 +910,7 @@ def change_detection_vector(request): vectorise_change_detection_gee_task, vectorise_change_detection_local_task, ) + print("What is task? ", task) task.apply_async( args=[state, district, block, start_year, end_year, gee_account_id], queue="nrm", @@ -1219,7 +1244,7 @@ def fes_clart_upload_layer(request): # Save file to temp location file_extension = os.path.splitext(uploaded_file.name)[1] - filename = f'{district.strip().replace(" ", "_")}_{block.strip().replace(" ", "_")}_clart_fes{file_extension}' + filename = f"{district.strip().replace(' ', '_')}_{block.strip().replace(' ', '_')}_clart_fes{file_extension}" temp_upload_dir = os.path.join( BASE_DIR, @@ -2027,7 +2052,9 @@ def update_layer_sync_remote(request): 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) + return Response( + {"error": "layer_id is required"}, status=status.HTTP_400_BAD_REQUEST + ) result = update_layer_sync_status( layer_id=layer_id, diff --git a/computing/surface_water_bodies/clip_swb_local.py b/computing/surface_water_bodies/clip_swb_local.py index a98660ec..ed15c10b 100644 --- a/computing/surface_water_bodies/clip_swb_local.py +++ b/computing/surface_water_bodies/clip_swb_local.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging +import os from typing import Any import geopandas as gpd @@ -8,6 +10,8 @@ from shapely.geometry.base import BaseGeometry from shapely.ops import unary_union +logger = logging.getLogger(__name__) + def _to_geom(roi: Any) -> BaseGeometry: """Normalize a GeoJSON ROI payload into a valid Shapely geometry.""" @@ -37,8 +41,21 @@ def _to_geom(roi: Any) -> BaseGeometry: def _clip_gdf(swb_path: str, roi: BaseGeometry) -> gpd.GeoDataFrame: """Clip SWB features against the ROI after a bbox-prefiltered read.""" minx, miny, maxx, maxy = roi.bounds - gdf = gpd.read_file(swb_path, bbox=(minx, miny, maxx, maxy), engine="pyogrio") + bbox = (minx, miny, maxx, maxy) + logger.info("Reading SWB source for clipping: path=%s bbox=%s", swb_path, bbox) + + previous_max_obj_size = os.environ.get("OGR_GEOJSON_MAX_OBJ_SIZE") + os.environ["OGR_GEOJSON_MAX_OBJ_SIZE"] = "0" + try: + gdf = gpd.read_file(swb_path, bbox=bbox, engine="pyogrio") + finally: + if previous_max_obj_size is None: + os.environ.pop("OGR_GEOJSON_MAX_OBJ_SIZE", None) + else: + os.environ["OGR_GEOJSON_MAX_OBJ_SIZE"] = previous_max_obj_size + if gdf.empty: + logger.info("No SWB features found in bbox for source=%s", swb_path) return gdf if gdf.crs is not None and gdf.crs.to_epsg() != 4326: @@ -46,8 +63,11 @@ def _clip_gdf(swb_path: str, roi: BaseGeometry) -> gpd.GeoDataFrame: clipped = gdf[gdf.geometry.intersects(roi)] if clipped.empty: + logger.info("No SWB features intersect ROI after bbox read for source=%s", swb_path) return clipped clipped = clipped.copy() clipped["geometry"] = clipped.geometry.intersection(roi) - return clipped[~clipped.geometry.is_empty].reset_index(drop=True) + clipped = clipped[~clipped.geometry.is_empty].reset_index(drop=True) + logger.info("Finished SWB clipping: retained_features=%s", len(clipped)) + return clipped diff --git a/computing/surface_water_bodies/swb_local.py b/computing/surface_water_bodies/swb_local.py index acf65f20..69ae6090 100644 --- a/computing/surface_water_bodies/swb_local.py +++ b/computing/surface_water_bodies/swb_local.py @@ -1,6 +1,7 @@ -import os +import logging from pathlib import Path +import ee import geopandas as gpd from computing.config_loader import ( @@ -11,19 +12,31 @@ from computing.local_compute_helper import ( build_output_vector_path, load_precomputed_roi, - push_local_vector_to_geoserver, read_validated_vector_file, write_vector_output, ) from computing.surface_water_bodies.clip_swb_local import _clip_gdf, _to_geom -from computing.utils import save_layer_info_to_db, update_layer_sync_status +from computing.utils import save_layer_info_to_db from nrm_app.celery import app -from utilities.gee_utils import valid_gee_text +from utilities.constants import GEE_PATHS +from utilities.gee_utils import ( + check_task_status, + create_gee_dir, + ee_initialize, + export_vector_asset_to_gee, + gdf_to_ee_fc, + get_gee_dir_path, + is_gee_asset_exists, + make_asset_public, + valid_gee_text, +) -GEOSERVER_WORKSPACE = "swb" LOCAL_ALGORITHM = "local_surface_water_bodies_clip" LOCAL_ALGORITHM_VERSION = "local-1.0" DATASET_NAME = "Surface Water Bodies" +logger = logging.getLogger(__name__) +SQM_PER_HECTARE = 10000.0 +GEE_EXPORT_CHUNK_SIZE = 1000 def _slug(value, fallback): @@ -34,11 +47,20 @@ def _slug(value, fallback): def _resolve_source_path(vector_path): src = Path(vector_path).expanduser().resolve() - if not src.exists(): - raise FileNotFoundError(f"Surface water bodies source not found: {src}") + candidates = [ + src.parent / "pan_india_waterbodies_with_mws.fgb", + src.with_suffix(".fgb"), + src, + ] + for candidate in candidates: + if candidate.exists(): + logger.info("Using SWB source path: %s", candidate) + return str(candidate) - prepared = src.with_suffix(".fgb") - return str(prepared if prepared.exists() else src) + raise FileNotFoundError( + "Surface water bodies source not found. Checked: " + + ", ".join(str(candidate) for candidate in candidates) + ) def _resolve_roi_gdf(state, district, block, roi=None, roi_path=None): @@ -76,7 +98,162 @@ def _resolve_asset_suffix(state, district, block, asset_suffix): def _layer_name(asset_suffix): - return f"surface_waterbodies_{asset_suffix}" + return f"surface_waterbodies_{asset_suffix}_local" + + +def _gee_description(asset_suffix): + return f"swb2_{asset_suffix}_local" + + +def _resolve_asset_folder_list(state, district, block): + if state and district and block: + return [state, district, block] + return None + + +def _resolve_gee_asset_id(state, district, block, asset_suffix, app_type): + asset_folder_list = _resolve_asset_folder_list(state, district, block) + if not asset_folder_list: + raise ValueError( + "Local SWB GEE export requires state, district, and block so the asset can be saved alongside the existing swb2 assets." + ) + + description = _gee_description(asset_suffix) + asset_id = ( + get_gee_dir_path( + asset_folder_list, asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"] + ) + + description + ) + if "projects//" in asset_id: + raise ValueError( + "Invalid GEE asset path resolved. " + f"asset_id={asset_id}. " + "Check GEE_STORAGE_PROJECT / GEE_PATHS configuration." + ) + return description, asset_id, asset_folder_list + + +def _prepare_gdf_for_gee(gdf): + prepared = gdf.copy() + if prepared.crs is None: + prepared = prepared.set_crs("EPSG:4326") + elif prepared.crs.to_epsg() != 4326: + prepared = prepared.to_crs("EPSG:4326") + + for column in prepared.columns: + if column == "geometry": + continue + prepared[column] = prepared[column].where(prepared[column].notna(), None) + return prepared + + +def _convert_area_columns_to_hectares(gdf): + converted = gdf.copy() + converted_columns = {} + + for column in list(converted.columns): + if column == "geometry": + continue + + target_column = None + if column == "total_area_m2": + target_column = "total_area" + elif column.startswith("area_"): + target_column = column + + if target_column is None: + continue + + converted[target_column] = converted[column] / SQM_PER_HECTARE + if target_column != column: + converted.drop(columns=[column], inplace=True) + converted_columns[column] = "converted_in_place_to_hectares" + + return converted, converted_columns + + +def _union_geometry(gdf): + try: + return gdf.union_all() + except AttributeError: + return gdf.geometry.unary_union + + +def _chunk_asset_id(base_asset_id, index): + return f"{base_asset_id}_chunk_{index:04d}" + + +def _chunk_description(base_description, index): + return f"{base_description}_chunk_{index:04d}" + + +def _export_gdf_to_gee_in_chunks(gdf, base_description, base_asset_id): + chunk_asset_ids = [] + task_ids = [] + + for index, start in enumerate(range(0, len(gdf), GEE_EXPORT_CHUNK_SIZE)): + chunk = gdf.iloc[start : start + GEE_EXPORT_CHUNK_SIZE].copy() + chunk_description = _chunk_description(base_description, index) + chunk_asset_id = _chunk_asset_id(base_asset_id, index) + logger.info( + "Starting chunked SWB export: chunk_index=%s start=%s end=%s chunk_size=%s chunk_asset_id=%s", + index, + start, + start + len(chunk), + len(chunk), + chunk_asset_id, + ) + chunk_fc = gdf_to_ee_fc(_prepare_gdf_for_gee(chunk)) + task_id = export_vector_asset_to_gee( + chunk_fc, + chunk_description, + chunk_asset_id, + ) + if not task_id: + raise RuntimeError( + f"Failed to start chunk export for {chunk_asset_id}" + ) + chunk_asset_ids.append(chunk_asset_id) + task_ids.append(task_id) + + check_task_status(task_ids) + logger.info( + "Completed all chunk exports for base_asset_id=%s chunk_count=%s", + base_asset_id, + len(chunk_asset_ids), + ) + + merged_fc = ee.FeatureCollection( + [ee.FeatureCollection(chunk_asset_id) for chunk_asset_id in chunk_asset_ids] + ).flatten() + merge_task_id = export_vector_asset_to_gee(merged_fc, base_description, base_asset_id) + if not merge_task_id: + raise RuntimeError(f"Failed to start merged export for {base_asset_id}") + logger.info( + "Started merged SWB export from chunks: task_id=%s gee_asset_id=%s", + merge_task_id, + base_asset_id, + ) + check_task_status([merge_task_id]) + logger.info("Completed merged SWB export: gee_asset_id=%s", base_asset_id) + + if not is_gee_asset_exists(base_asset_id): + raise RuntimeError( + f"Merged SWB asset was not created after chunk merge: {base_asset_id}" + ) + + for chunk_asset_id in chunk_asset_ids: + try: + if is_gee_asset_exists(chunk_asset_id): + ee.data.deleteAsset(chunk_asset_id) + logger.info("Deleted temporary SWB chunk asset: %s", chunk_asset_id) + except Exception as exc: + logger.warning( + "Failed to delete temporary SWB chunk asset %s: %s", + chunk_asset_id, + exc, + ) def run_swb_local( @@ -87,8 +264,10 @@ def run_swb_local( roi_path=None, asset_suffix=None, swb_path=SWB_VECTOR_PATH, - push_to_geoserver=True, + push_to_geoserver=False, sync_layer_metadata=True, + gee_account_id=None, + app_type="MWS", ): state = str(state).strip().lower() if state else None district = str(district).strip().lower() if district else None @@ -101,12 +280,53 @@ def run_swb_local( roi=roi, roi_path=roi_path, ) - roi_geometry = roi_gdf.union_all() + logger.info( + "Starting local SWB generation: state=%s district=%s block=%s asset_suffix=%s app_type=%s", + state, + district, + block, + asset_suffix, + app_type, + ) + logger.info("Resolved ROI rows=%s crs=%s", len(roi_gdf), roi_gdf.crs) + roi_geometry = _union_geometry(roi_gdf) if roi_geometry.is_empty: raise ValueError("ROI geometry is empty after validation.") asset_suffix = _resolve_asset_suffix(state, district, block, asset_suffix) layer_name = _layer_name(asset_suffix) + gee_description, gee_asset_id, asset_folder_list = _resolve_gee_asset_id( + state=state, + district=district, + block=block, + asset_suffix=asset_suffix, + app_type=app_type, + ) + logger.info( + "Resolved local SWB names: layer_name=%s gee_description=%s gee_asset_id=%s asset_folder_list=%s", + layer_name, + gee_description, + gee_asset_id, + asset_folder_list, + ) + + if is_gee_asset_exists(gee_asset_id): + logger.info("GEE asset already exists, reusing: %s", gee_asset_id) + if sync_layer_metadata: + save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=gee_asset_id, + dataset_name=DATASET_NAME, + misc={"is_generated_locally": True, "source_stage": "swb2_local"}, + algorithm=LOCAL_ALGORITHM, + algorithm_version=LOCAL_ALGORITHM_VERSION, + ) + make_asset_public(gee_asset_id) + return True + output_path = build_output_vector_path( layer_name=layer_name, state=state, @@ -116,54 +336,113 @@ def run_swb_local( custom_subdir=asset_suffix, block_fallback="unknown_block", ) + logger.info("Local SWB output path: %s", output_path) clipped_gdf = _clip_gdf(_resolve_source_path(swb_path), roi_geometry) if clipped_gdf.empty: raise ValueError("No surface water body features intersect the provided ROI.") + clipped_gdf, converted_area_columns = _convert_area_columns_to_hectares(clipped_gdf) + logger.info( + "Clipped SWB features: count=%s columns=%s", + len(clipped_gdf), + list(clipped_gdf.columns), + ) + logger.info( + "Converted SWB area columns from square meters to hectares: %s", + converted_area_columns, + ) - asset_id = write_vector_output( + local_asset_path = write_vector_output( gdf=clipped_gdf, output_path=output_path, layer_name=layer_name, ) - print(f"Saved local SWB vector: {asset_id}") + logger.info("Saved local SWB vector to disk: %s", local_asset_path) - geoserver_ok = False - if push_to_geoserver: - geoserver_response = push_local_vector_to_geoserver( - path=os.path.splitext(asset_id)[0], - layer_name=layer_name, - workspace=GEOSERVER_WORKSPACE, - file_type="gpkg", + _ = push_to_geoserver + ee_initialize(gee_account_id) + logger.info( + "Initialized Earth Engine for local SWB export: gee_account_id=%s", + gee_account_id, + ) + create_gee_dir(asset_folder_list, GEE_PATHS[app_type]["GEE_ASSET_PATH"]) + logger.info( + "Ensured GEE directory exists under base path=%s for folders=%s", + GEE_PATHS[app_type]["GEE_ASSET_PATH"], + asset_folder_list, + ) + ee_fc = gdf_to_ee_fc(_prepare_gdf_for_gee(clipped_gdf)) + logger.info("Prepared EE FeatureCollection for export: gee_asset_id=%s", gee_asset_id) + task_id = None + try: + task_id = export_vector_asset_to_gee(ee_fc, gee_description, gee_asset_id) + except Exception as exc: + logger.warning( + "Inline GEE export raised an exception for asset_id=%s: %s", + gee_asset_id, + exc, ) - geoserver_ok = ( - isinstance(geoserver_response, dict) - and geoserver_response.get("status_code") in (200, 201) + + if task_id: + logger.info( + "Started GEE export task: task_id=%s gee_description=%s gee_asset_id=%s", + task_id, + gee_description, + gee_asset_id, + ) + check_task_status([task_id]) + logger.info( + "Completed GEE export task: task_id=%s gee_asset_id=%s", + task_id, + gee_asset_id, + ) + else: + logger.info( + "Inline GEE export could not be started for asset_id=%s. Falling back to chunked FeatureCollection export.", + gee_asset_id, + ) + _export_gdf_to_gee_in_chunks( + gdf=clipped_gdf, + base_description=gee_description, + base_asset_id=gee_asset_id, + ) + logger.info( + "Triggered chunked SWB GEE export fallback: gee_asset_id=%s chunk_size=%s", + gee_asset_id, + GEE_EXPORT_CHUNK_SIZE, ) - print(f"GeoServer response for {layer_name}: {geoserver_response}") + + if not is_gee_asset_exists(gee_asset_id): + raise RuntimeError(f"GEE asset was not created: {gee_asset_id}") + make_asset_public(gee_asset_id) + logger.info("Marked GEE asset public: %s", gee_asset_id) layer_id = None - is_admin_run = bool(state and district and block) - if sync_layer_metadata and is_admin_run: + 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, + asset_id=gee_asset_id, dataset_name=DATASET_NAME, misc={ "is_generated_locally": True, "feature_count": int(len(clipped_gdf)), - "geoserver_available": geoserver_ok, + "local_vector_path": local_asset_path, + "source_stage": "swb2_local", }, algorithm=LOCAL_ALGORITHM, algorithm_version=LOCAL_ALGORITHM_VERSION, ) - if layer_id and geoserver_ok: - update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + logger.info( + "Saved local SWB layer metadata: layer_id=%s gee_asset_id=%s layer_name=%s", + layer_id, + gee_asset_id, + layer_name, + ) - return geoserver_ok if push_to_geoserver else True + return True def _generate_swb_local_task( @@ -178,7 +457,7 @@ def _generate_swb_local_task( gee_account_id=None, app_type="MWS", ): - _ = start_year, end_year, gee_account_id, app_type + _ = start_year, end_year return run_swb_local( state=state, district=district, @@ -186,8 +465,10 @@ def _generate_swb_local_task( roi=roi, roi_path=roi_path, asset_suffix=asset_suffix, - push_to_geoserver=True, + push_to_geoserver=False, sync_layer_metadata=True, + gee_account_id=gee_account_id, + app_type=app_type, ) From d9f8fb43b2f0339a1fded154bf268cfab8407ba8 Mon Sep 17 00:00:00 2001 From: aman verma Date: Mon, 18 May 2026 06:41:32 +0000 Subject: [PATCH 015/120] 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 5da6935fd1b2add51e7a48573e48958b2d5fb092 Mon Sep 17 00:00:00 2001 From: aman verma Date: Tue, 19 May 2026 11:41:09 +0000 Subject: [PATCH 016/120] spei download input datasets --- .../drought/spei/download_chirps_local.py | 395 ++++++++++++++++++ .../drought/spei/export_ppet_single_state.py | 170 ++++++++ 2 files changed, 565 insertions(+) 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/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 aa9392b23f4032ab0443c90d2e13ca747279035f Mon Sep 17 00:00:00 2001 From: aman verma Date: Wed, 20 May 2026 16:22:12 +0000 Subject: [PATCH 017/120] 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 02a01cdafa0d5c4487266db58217af1d3c36593e Mon Sep 17 00:00:00 2001 From: aman verma Date: Wed, 20 May 2026 16:22:57 +0000 Subject: [PATCH 018/120] 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 fb7ffbdeef6cc24d1d2c0f16b7524af34108c2b4 Mon Sep 17 00:00:00 2001 From: aman verma Date: Sat, 30 May 2026 20:49:27 +0000 Subject: [PATCH 019/120] drought spei on aez --- ...rps_local.py => download_base_datasets.py} | 76 +++++++++---------- computing/spei/drought/drought_spei.R | 42 +++++----- ...le_state.py => generate_ppet_multiband.py} | 44 ++++++----- computing/spei/drought/spei_runner.py | 8 +- 4 files changed, 89 insertions(+), 81 deletions(-) rename computing/spei/drought/{download_chirps_local.py => download_base_datasets.py} (89%) rename computing/spei/drought/{export_ppet_single_state.py => generate_ppet_multiband.py} (87%) diff --git a/computing/spei/drought/download_chirps_local.py b/computing/spei/drought/download_base_datasets.py similarity index 89% rename from computing/spei/drought/download_chirps_local.py rename to computing/spei/drought/download_base_datasets.py index e2bbbb3e..8d94b28f 100644 --- a/computing/spei/drought/download_chirps_local.py +++ b/computing/spei/drought/download_base_datasets.py @@ -22,6 +22,7 @@ import ee import requests +from utilities.constants import AEZ from utilities.gee_utils import ee_initialize CHIRPS_COLLECTION = "UCSB-CHG/CHIRPS/DAILY" @@ -37,22 +38,24 @@ def initialize_earth_engine(project: str) -> None: 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 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" +# # ) +# +# admin = ee.FeatureCollection(AEZ) +# +# 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( @@ -212,8 +215,7 @@ def dataset_label(dataset: str) -> str: def build_dataset_image( - aoi: str, - state: str, + aez: int, dataset: str, start_date: str, end_date: str, @@ -224,12 +226,11 @@ def build_dataset_image( start_ee_date = ee.Date(start_date) end_date_exclusive = ee.Date(end_date).advance(1, "day") - region = get_aoi(aoi, state) + # region = get_aoi(aoi, state) + region = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)) 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) @@ -255,15 +256,13 @@ def build_dataset_image( labeled_dates.append((date, label)) print( - f"Preparing {len(labeled_dates)} {frequency} {name} image(s) " - f"for {aoi_label}" + f"Preparing {len(labeled_dates)} {frequency} {name} image(s) " f"for aez {aez}" ) return collection, region, name, labeled_dates def download_dataset_images( - aoi: str, - state: str, + aez: int, dataset: str, start_date: str, end_date: str, @@ -277,14 +276,13 @@ def download_dataset_images( ) -> 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(" ", "_") + # aoi_label = "India" if aez == "india" else state + # safe_aoi = aoi_label.replace(" ", "_") name = dataset_label(dataset) - dataset_output_dir = Path(output_dir) / safe_aoi / frequency / dataset + dataset_output_dir = Path(output_dir) / str(aez) / frequency / dataset collection, region, _, labeled_dates = build_dataset_image( - aoi=aoi, - state=state, + aez=aez, dataset=dataset, start_date=start_date, end_date=end_date, @@ -320,7 +318,9 @@ def download_dataset_images( 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) + image, _ = make_chirps_image( + collection, region, current_date, frequency + ) else: image, _ = make_modis_pet_image( collection, region, current_date, frequency, target_projection @@ -334,7 +334,9 @@ def download_dataset_images( 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) + image, _ = make_chirps_image( + collection, region, current_date, frequency + ) else: image, _ = make_modis_pet_image( collection, region, current_date, frequency, target_projection @@ -354,13 +356,12 @@ def download_one(job: tuple[int, ee.Date, str, Path]) -> tuple[int, str]: print(f"Downloaded {len(labeled_dates)} {name} image(s) to {dataset_output_dir}") -def main( - aoi: str = "india", +def download_data_locally( + aez: int, 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, @@ -370,7 +371,7 @@ def main( # 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) + # validate_inputs(aez, selected_datasets, frequency) initialize_earth_engine(project) expanded_datasets = expand_datasets(selected_datasets) @@ -378,8 +379,7 @@ def main( scale = 5500 crs = "EPSG:4326" download_dataset_images( - aoi=aoi, - state=state, + aez=aez, dataset=dataset, start_date=start_date, end_date=end_date, diff --git a/computing/spei/drought/drought_spei.R b/computing/spei/drought/drought_spei.R index 61d0c15c..559e25f5 100644 --- a/computing/spei/drought/drought_spei.R +++ b/computing/spei/drought/drought_spei.R @@ -18,13 +18,13 @@ library(raster) # ============================================================================= # MAIN FUNCTION # ============================================================================= -run_spei_pipeline <- function( - state_safe, - input_file, - output_dir -) { +run_spei_pipeline <- function(aez) { paste("Starting R script") + paste("aez", aez) + + input_file = paste0("data/drought_inputs/", aez, "/monthly/P_PET_", aez, "_monthly_multiband.tif") + output_dir = paste0("data/drought_inputs/", aez, "/monthly") # ------------------------------------------------------------------------- # Create output directory @@ -38,14 +38,14 @@ run_spei_pipeline <- function( # ------------------------------------------------------------------------- out_check <- file.path( output_dir, - paste0("SPEI12_", state_safe, ".tif") + paste0("SPEI12_", aez, ".tif") ) if (file.exists(out_check)) { stop( paste( "Already processed:", - state_safe, + aez, "— delete output files to rerun." ) ) @@ -162,7 +162,7 @@ run_spei_pipeline <- function( temp_file <- file.path( output_dir, - paste0(state_safe, "_temp.tif") + paste0(aez, "_temp.tif") ) result_brick <- brick( @@ -255,7 +255,7 @@ run_spei_pipeline <- function( spei1_brick, file.path( output_dir, - paste0("SPEI1_", state_safe, ".tif") + paste0("SPEI1_", aez, ".tif") ), format = "GTiff", overwrite = TRUE, @@ -266,7 +266,7 @@ run_spei_pipeline <- function( spei3_brick, file.path( output_dir, - paste0("SPEI3_", state_safe, ".tif") + paste0("SPEI3_", aez, ".tif") ), format = "GTiff", overwrite = TRUE, @@ -277,7 +277,7 @@ run_spei_pipeline <- function( spei12_brick, file.path( output_dir, - paste0("SPEI12_", state_safe, ".tif") + paste0("SPEI12_", aez, ".tif") ), format = "GTiff", overwrite = TRUE, @@ -303,7 +303,7 @@ run_spei_pipeline <- function( cat( paste0( " SPEI1_", - state_safe, + aez, ".tif — ", nlayers(spei1_brick), " bands\n" @@ -313,7 +313,7 @@ run_spei_pipeline <- function( cat( paste0( " SPEI3_", - state_safe, + aez, ".tif — ", nlayers(spei3_brick), " bands\n" @@ -323,7 +323,7 @@ run_spei_pipeline <- function( cat( paste0( " SPEI12_", - state_safe, + aez, ".tif — ", nlayers(spei12_brick), " bands\n" @@ -334,8 +334,12 @@ run_spei_pipeline <- function( # ============================================================================= # 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 +args <- commandArgs(trailingOnly = TRUE) + +if (length(args) < 1) { + stop("AEZ argument is required") +} + +aez <- args[1] + +run_spei_pipeline(aez) \ No newline at end of file diff --git a/computing/spei/drought/export_ppet_single_state.py b/computing/spei/drought/generate_ppet_multiband.py similarity index 87% rename from computing/spei/drought/export_ppet_single_state.py rename to computing/spei/drought/generate_ppet_multiband.py index c3c7f52a..04e3c2b1 100644 --- a/computing/spei/drought/export_ppet_single_state.py +++ b/computing/spei/drought/generate_ppet_multiband.py @@ -20,14 +20,14 @@ # --- 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 +# 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: @@ -54,6 +54,7 @@ def reproject_modis_to_chirps_grid( chirps_dataset: rasterio.DatasetReader, log_metadata: bool = False, ) -> np.ma.MaskedArray: + OUTPUT_NODATA = -9999.0 """Reproject/resample MODIS PET onto the CHIRPS projection and pixel grid.""" with rasterio.open(modis_path) as modis_src: if log_metadata: @@ -103,18 +104,23 @@ def reproject_modis_to_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, + aez: str = None, + start: int = 2004, + end: int = 2023, ) -> 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" + data_root = Path("data/drought_inputs") + chirps_dir = data_root / aez / "monthly" / "chirps" + modis_dir = data_root / aez / "monthly" / "modis_pet" + + # input_root = Path("data/drought_inputs") + output_dir = Path("data/drought_inputs") / aez / "monthly" + output = output_dir / f"P_PET_{aez}_monthly_multiband.tif" + OUTPUT_NODATA = -9999.0 + + output_file = ( + Path(output) + if output + else data_root / aez / f"P_PET_{aez}_monthly_multiband.tif" ) output_file.parent.mkdir(parents=True, exist_ok=True) diff --git a/computing/spei/drought/spei_runner.py b/computing/spei/drought/spei_runner.py index 0f528c4c..676be6a4 100644 --- a/computing/spei/drought/spei_runner.py +++ b/computing/spei/drought/spei_runner.py @@ -7,14 +7,12 @@ R_SCRIPT = BASE_DIR / "drought" / "drought_spei.R" -def run_spei_pipeline(state_safe=None, input_file=None, output_dir=None): - +def run_spei_pipeline(aez=None): + print(aez) command = [ "Rscript", str(R_SCRIPT), - state_safe, - input_file, - output_dir, + aez, ] print("COMMAND:", command) From d5a5bedd716523739ffcfed02a98ebd9f4249da7 Mon Sep 17 00:00:00 2001 From: aman verma Date: Tue, 2 Jun 2026 12:50:35 +0000 Subject: [PATCH 020/120] removed commented code --- computing/spei/drought/generate_ppet_multiband.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/computing/spei/drought/generate_ppet_multiband.py b/computing/spei/drought/generate_ppet_multiband.py index 04e3c2b1..9fca4feb 100644 --- a/computing/spei/drought/generate_ppet_multiband.py +++ b/computing/spei/drought/generate_ppet_multiband.py @@ -19,17 +19,6 @@ 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}" From cb1ed99fe45104306032f019ac52fde336a13fd0 Mon Sep 17 00:00:00 2001 From: aman verma Date: Thu, 4 Jun 2026 15:13:33 +0000 Subject: [PATCH 021/120] drought and rainfall - resistance and resilience --- .../__init__.py | 0 .../drought_resistance_resilience.py | 183 ++++++++++++++++ computing/spei/generate_spei/__init__.py | 0 .../download_base_datasets.py | 0 .../{drought => generate_spei}/drought_spei.R | 0 .../generate_ppet_multiband.py | 0 .../{drought => generate_spei}/spei_runner.py | 0 computing/spei/hybrid_tree_mask.py | 161 ++++++++++++++ .../export_rainfall_index.py | 127 +++++++++++ .../rainfall_resistance_resilience.py | 199 ++++++++++++++++++ 10 files changed, 670 insertions(+) rename computing/spei/{drought => drought_sensitivity}/__init__.py (100%) create mode 100644 computing/spei/drought_sensitivity/drought_resistance_resilience.py create mode 100644 computing/spei/generate_spei/__init__.py rename computing/spei/{drought => generate_spei}/download_base_datasets.py (100%) rename computing/spei/{drought => generate_spei}/drought_spei.R (100%) rename computing/spei/{drought => generate_spei}/generate_ppet_multiband.py (100%) rename computing/spei/{drought => generate_spei}/spei_runner.py (100%) create mode 100644 computing/spei/hybrid_tree_mask.py create mode 100644 computing/spei/rainfall_sensitivity/export_rainfall_index.py create mode 100644 computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py diff --git a/computing/spei/drought/__init__.py b/computing/spei/drought_sensitivity/__init__.py similarity index 100% rename from computing/spei/drought/__init__.py rename to computing/spei/drought_sensitivity/__init__.py diff --git a/computing/spei/drought_sensitivity/drought_resistance_resilience.py b/computing/spei/drought_sensitivity/drought_resistance_resilience.py new file mode 100644 index 00000000..8cca841e --- /dev/null +++ b/computing/spei/drought_sensitivity/drought_resistance_resilience.py @@ -0,0 +1,183 @@ +import ee + +from utilities.constants import AEZ +from utilities.gee_utils import export_raster_asset_to_gee, ee_initialize + + +# /** +# * Forest Sensitivity Analysis Pipeline — Script 2 +# * Drought Resistance & Resilience +# * +# * For each forest pixel, computes mean resistance and resilience +# * across all drought years (SPEI-12 < threshold). +# * +# * Resistance = Yn_bar / |Ye - Yn_bar| +# * Resilience = |Ye - Yn_bar| / |Y(e+1) - Yn_bar| +# * +# * Where: +# * Yn_bar = mean NDVI across non-drought years (baseline) +# * Ye = NDVI during drought year +# * Y(e+1) = NDVI the year after drought +# * +# * Requires: +# * - Forest mask asset from Script 1 +# * - SPEI-12 assets from spei-drought-analysis-pipeline +# */ +# +# # CONFIGURATION := +def generate_drought_resistance( + aez, start_year=2004, end_year=2022, gee_account_id=None +): + ee_initialize(7) + + TREE_COVER_ASSET = f"projects/corestack-datasets-alpha/assets/datasets/SPEI/Hybrid_Tree_AEZ_{aez}_Period_{str(2003)}_{str(end_year)}" + OUTPUT_DESC = f"Drought_Metrics_AEZ_{aez}" + OUTPUT_ASSET_ID = ( + f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" + ) + + STATE_NAME = "Madhya Pradesh" + DROUGHT_THRESHOLD = -1.0 # SPEI-12 below this = drought year + + aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + + # Loading the assets := + + treeMeta = ee.Image(TREE_COVER_ASSET) + startYear = treeMeta.select("start_year") + endYear = treeMeta.select("end_year") + + # Load SPEI-12 collection from single multiband asset + SPEI12_ASSET = ( + f"projects/corestack-datasets-alpha/assets/datasets/SPEI/SPEI12_{str(aez)}" + ) + spei12_raw = ee.Image(SPEI12_ASSET) + spei12_bandnames = [] + + # for (yn = 2004 yn <= 2023 yn++) { + # spei12_bandnames.push('y' + yn) + # } + for yn in range(2004, 2024): + spei12_bandnames.append("y" + str(yn)) + + spei12_named = spei12_raw.rename(spei12_bandnames) + + # Build per-year SPEI collection + speiImages = [] + # for (y = START_YEAR y <= END_YEAR y++) { + # speiImages.push( + # spei12_named.select('y' + y) + # .rename('spei') + # .set('year', y) + # ) + # } + + for y in range(start_year, end_year + 1): + speiImages.append( + spei12_named.select("y" + str(y)).rename("spei").set("year", y) + ) + + speiCol = ee.ImageCollection(speiImages) + + # LANDSAT NDVI := + def maskClouds(image): + qa = image.select("QA_PIXEL") + mask = qa.bitwiseAnd(1 << 3).eq(0).And(qa.bitwiseAnd(1 << 4).eq(0)) + return image.updateMask(mask) + + def getAnnualNDVI(year): + start = ee.Date.fromYMD(year, 1, 1) + end = ee.Date.fromYMD(year, 12, 31) + + l89 = ( + ee.ImageCollection("LANDSAT/LC08/C02/T1_L2") + .merge(ee.ImageCollection("LANDSAT/LC09/C02/T1_L2")) + .filterDate(start, end) + .filterBounds(aoi) + .map(maskClouds) + .map( + lambda img: img.normalizedDifference(["SR_B5", "SR_B4"]).rename("ndvi") + ) + ) + + l57 = ( + ee.ImageCollection("LANDSAT/LT05/C02/T1_L2") + .merge(ee.ImageCollection("LANDSAT/LE07/C02/T1_L2")) + .filterDate(start, end) + .filterBounds(aoi) + .map(maskClouds) + .map( + lambda img: img.normalizedDifference(["SR_B4", "SR_B3"]).rename("ndvi") + ) + ) + + return l89.merge(l57).median().set("year", year).rename("ndvi") + + # Load NDVI for START_YEAR to END_YEAR+1 (need next year for resilience) + ndviYears = ee.List.sequence(start_year, end_year + 1) + ndviCol = ee.ImageCollection(ndviYears.map(getAnnualNDVI)) + + # BASELINE NDVI (Yn_bar) := + # Mean NDVI across non-drought years only + # Uses simple masked ImageCollection mean — we are trying to avoid GEE array scalign issues here + + analysisYears = ee.List.sequence(start_year, end_year) + + def ndviNonDroughtFunc(y): + year = ee.Number(y) + ndvi = ndviCol.filter(ee.Filter.eq("year", year)).first() + spei = ( + speiCol.filter(ee.Filter.eq("year", year)) + .first() + .resample("bilinear") + .reproject(crs=ndvi.projection(), scale=30) + ) + + isNonDrought = spei.gte(DROUGHT_THRESHOLD) + return ndvi.updateMask(isNonDrought).set("year", year) + + ndviNonDrought = ee.ImageCollection(analysisYears.map(ndviNonDroughtFunc)) + + Yn_bar = ndviNonDrought.mean().rename("ndvi_baseline") + + # RESISTANCE & RESILIENCE := + + def metricsColFunc(y): + year = ee.Number(y) + + ndviYe = ndviCol.filter(ee.Filter.eq("year", year)).first() + speiYe = ( + speiCol.filter(ee.Filter.eq("year", year)) + .first() + .resample("bilinear") + .reproject(crs=ndviYe.projection(), scale=30) + ) + + # Only compute on forest pixels during drought years + isForest = startYear.lte(year).And(endYear.gte(year)) + isDrought = speiYe.lt(DROUGHT_THRESHOLD) + mask = isForest.And(isDrought) + + diff = ndviYe.subtract(Yn_bar).abs().max(1e-6) + resistance = Yn_bar.divide(diff).rename("resistance") + + ndviNext = ndviCol.filter(ee.Filter.eq("year", year.add(1))).first() + diffNext = ndviNext.subtract(Yn_bar).abs().max(1e-6) + resilience = diff.divide(diffNext).rename("resilience") + + return ee.Image.cat([resistance, resilience]).updateMask(mask).set("year", year) + + metricsCol = ee.ImageCollection(analysisYears.map(metricsColFunc)) + + # AGGREGATE & EXPORT := + + meanResistance = metricsCol.select("resistance").mean().clip(aoi) + meanResilience = metricsCol.select("resilience").mean().clip(aoi) + + finalOutput = meanResistance.rename("resistance").addBands( + meanResilience.rename("resilience") + ) + + export_raster_asset_to_gee( + finalOutput, OUTPUT_DESC, OUTPUT_ASSET_ID, scale=30, region=aoi + ) diff --git a/computing/spei/generate_spei/__init__.py b/computing/spei/generate_spei/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/computing/spei/drought/download_base_datasets.py b/computing/spei/generate_spei/download_base_datasets.py similarity index 100% rename from computing/spei/drought/download_base_datasets.py rename to computing/spei/generate_spei/download_base_datasets.py diff --git a/computing/spei/drought/drought_spei.R b/computing/spei/generate_spei/drought_spei.R similarity index 100% rename from computing/spei/drought/drought_spei.R rename to computing/spei/generate_spei/drought_spei.R diff --git a/computing/spei/drought/generate_ppet_multiband.py b/computing/spei/generate_spei/generate_ppet_multiband.py similarity index 100% rename from computing/spei/drought/generate_ppet_multiband.py rename to computing/spei/generate_spei/generate_ppet_multiband.py diff --git a/computing/spei/drought/spei_runner.py b/computing/spei/generate_spei/spei_runner.py similarity index 100% rename from computing/spei/drought/spei_runner.py rename to computing/spei/generate_spei/spei_runner.py diff --git a/computing/spei/hybrid_tree_mask.py b/computing/spei/hybrid_tree_mask.py new file mode 100644 index 00000000..d0500d5d --- /dev/null +++ b/computing/spei/hybrid_tree_mask.py @@ -0,0 +1,161 @@ +import ee +from utilities.constants import AEZ +from utilities.gee_utils import export_raster_asset_to_gee, ee_initialize + +# /** +# * Forest Sensitivity Analysis Pipeline — Script 1 +# * Hybrid 30m Annual Tree Cover Mask + Contiguous Forest Period +# * +# * Produces per-pixel: length (years), start_year, end_year +# * of the most recent unbroken forest period (2003–2022). +# * +# * Sources: +# * 1. GLC-FCS30D (2003–2022) +# * 2. Dynamic World (2015–2022) +# * 3. IndiaSat LULC (2017–2022), Core-stack. +# * +# * Union logic: majority vote among active datasets per year. +# * Temporal correction: ±2 year window as used in other places too by the team. +# */ + + +def generate_hybrid_tree_mask(aez, start_year=2003, end_year=2022, gee_account_id=None): + ee_initialize(7) + + TEMPORAL_WINDOW = 2 + + aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + + OUTPUT_DESC = f"Hybrid_Tree_AEZ_{aez}_Period_{str(start_year)}_{str(end_year)}" + OUTPUT_ASSET_ID = ( + f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" + ) + + # DATASET PREPARATION := + # --- GLC-FCS30D --- + glcMosaic = ee.ImageCollection( + "projects/sat-io/open-datasets/GLC-FCS30D/annual" + ).mosaic() + + # --- Dynamic World --- + dwCol = ee.ImageCollection("GOOGLE/DYNAMICWORLD/V1").filterBounds(aoi) + + # --- IndiaSat LULC --- + indiaSatList = [] + for year in range(2017, end_year + 1): + indiaSatList.append( + ee.Image( + f"projects/corestack-datasets/assets/datasets/LULC_v3_river_basin/pan_india_lulc_v3_{year}_{year+1}" + ).set("year", year) + ) + + indiaSatCol = ee.ImageCollection(indiaSatList) + + def get_indiaSat_mask(y): + img = indiaSatCol.filter(ee.Filter.eq("year", y)).first() + return ee.Image( + ee.Algorithms.If( + img, + ee.Image(img).select("predicted_label").eq(6).unmask(0), + ee.Image(0), + ) + ).rename("tree") + + # HYBRID MASK GENERATION := + years = ee.List.sequence(start_year, end_year) + + def annual_tree_cover(year): + year = ee.Number(year) + + # GLC — always active, classes 51–92 are forests + bandName = ee.String("b").cat(year.subtract(1999).format("%.0f")) + glcMask = ( + glcMosaic.select(bandName) + .gte(51) + .And(glcMosaic.select(bandName).lte(92)) + .rename("tree") + ) + proj = glcMask.projection() + + # Dynamic World — active from 2015 + dwYear = dwCol.filter(ee.Filter.calendarRange(year, year, "year")).select( + "label" + ) + dwMask = ( + ee.Image( + ee.Algorithms.If( + dwYear.size().gt(0), dwYear.mode().eq(1).unmask(0), ee.Image(0) + ) + ) + .rename("tree") + .reproject(crs=proj, scale=30) + ) + + # IndiaSat — active from 2017 + indiaSatMask = get_indiaSat_mask(year).reproject(crs=proj, scale=30) + + # Majority vote + forestSum = glcMask.unmask(0).add(dwMask.unmask(0)).add(indiaSatMask.unmask(0)) + dwActive = ee.Algorithms.If(year.gte(2015), 1, 0) + indiasatActive = ee.Algorithms.If(year.gte(2017), 1, 0) + activeDatasets = ee.Number(1).add(dwActive).add(indiasatActive) + requiredVotes = ee.Algorithms.If(activeDatasets.eq(1), 1, 2) + hybridMask = forestSum.gte(ee.Number(requiredVotes)) + + return hybridMask.set("year", year).rename("tree") + + annualTreeCoverMasks = ee.ImageCollection(years.map(annual_tree_cover)) + + # TEMPORAL CORRECTION := + def corrected_tree_cover(year): + year = ee.Number(year) + originalMask = annualTreeCoverMasks.filter(ee.Filter.eq("year", year)).first() + + windowMasks = annualTreeCoverMasks.filter( + ee.Filter.And( + ee.Filter.neq("year", year), + ee.Filter.gte("year", year.subtract(TEMPORAL_WINDOW)), + ee.Filter.lte("year", year.add(TEMPORAL_WINDOW)), + ) + ) + + corrected = originalMask.unmask(0).where(windowMasks.max().eq(1), 1) + return corrected.set("year", year).rename("tree") + + correctedTreeCoverMasks = ee.ImageCollection(years.map(corrected_tree_cover)) + + # CONTIGUOUS FOREST PERIOD (LENGTH, START, END) := + def calculate_consecutive(currentImage, previousState): + prevCount = ee.Image(ee.List(previousState).get(0)) + prevStop = ee.Image(ee.List(previousState).get(1)) + currentMask = currentImage.select("tree") + stillCounting = prevStop.Not() + newCount = prevCount.add(currentMask.multiply(stillCounting)) + newStop = prevStop.Or(currentMask.Not()) + return ee.List([newCount, newStop]) + + # Iterate backwards so we get the MOST RECENT contiguous period + reversedCollection = correctedTreeCoverMasks.sort("year", False) + initialState = ee.List([ee.Image(0).byte(), ee.Image(0).byte()]) + finalState = ee.List( + reversedCollection.iterate(calculate_consecutive, initialState) + ) + + recentLength = ee.Image(finalState.get(0)).rename("length") + forestEndYear = ee.Image(end_year).multiply(recentLength.gt(0)).rename("end_year") + forestStartYear = ( + forestEndYear.subtract(recentLength) + .add(1) + .multiply(recentLength.gt(0)) + .rename("start_year") + ) + + finalOutput = ( + recentLength.addBands(forestStartYear).addBands(forestEndYear).clip(aoi) + ) + + task_id = export_raster_asset_to_gee( + finalOutput, OUTPUT_DESC, OUTPUT_ASSET_ID, scale=30, region=aoi + ) + + return task_id diff --git a/computing/spei/rainfall_sensitivity/export_rainfall_index.py b/computing/spei/rainfall_sensitivity/export_rainfall_index.py new file mode 100644 index 00000000..adf9496e --- /dev/null +++ b/computing/spei/rainfall_sensitivity/export_rainfall_index.py @@ -0,0 +1,127 @@ +import ee + +from utilities.constants import AEZ +from utilities.gee_utils import export_raster_asset_to_gee, ee_initialize + + +def rainfall_index(aez, start_year=2004, end_year=2022, gee_account_id=None): + # /** + # * Forest Sensitivity Analysis Pipeline — Script 3a + # * Heavy Rainfall Index Export + # * + # * Computes two quantities per pixel per year and exports as a single + # * multiband asset — one band per year for each quantity: + # * + # * Hm_{year} = annual sum of precipitation on heavy days + # * zScore_{year} = z-score of Hm relative to the full period mean/stddev + # * + # * Heavy day definition: daily precipitation > long-term 95th percentile (CHIRPS) + # * Z-score computed across all years in the period. + # * + # * Output asset bands: + # * Hm_2004, Hm_2005, ..., Hm_2023 (19 bands) + # * zScore_2004, ..., zScore_2023 (19 bands) + # * Total: 38 bands + # * + # * This asset is the direct input to Script 3b, analogous to how + # * SPEI assets are the input to Script 2 (drought). + # * + # * Requires: nothing — only public datasets (CHIRPS) + # */ + # + # #=========================================================================== + # # 1. CONFIGURATION + # #=========================================================================== + ee_initialize(7) + OUTPUT_DESC = f"Rain_Index_AEZ_{aez}" + OUTPUT_ASSET_ID = ( + f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" + ) + + # =========================================================================== + # 2. AOI + # =========================================================================== + + aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + # =========================================================================== + # 3. HEAVY RAINFALL INDEX (Hm) + # =========================================================================== + + chirps = ( + ee.ImageCollection("UCSB-CHG/CHIRPS/DAILY") + .filterBounds(aoi) + .filterDate("2000-01-01", "2023-12-31") + .select("precipitation") + ) + + proj = chirps.first().projection() + + # Long-term 95th percentile — defines what counts as a heavy day + p95 = ( + chirps.reduce(ee.Reducer.percentile([95])) + .setDefaultProjection(proj) + .rename("p95") + ) + + # Annual heavy rain sum per year + years = ee.List.sequence(start_year, end_year) + + def annualHmFunc(y): + start = ee.Date.fromYMD(y, 1, 1) + end = ee.Date.fromYMD(y, 12, 31) + + heavySum = ( + chirps.filterDate(start, end) + .map(lambda img: img.multiply(img.gt(p95))) + .sum() + .setDefaultProjection(proj) + .rename("Hm") + .set("year", y) + ) + return heavySum + + annualHm = ee.ImageCollection(years.map(annualHmFunc)) + + # =========================================================================== + # 4. Z-SCORE ACROSS ALL YEARS + # =========================================================================== + + hmMean = annualHm.mean().rename("Hm_mean") + hmStdDev = annualHm.reduce(ee.Reducer.stdDev()).rename("Hm_stdDev") + + def annualZScoreFunc(y): + year = ee.Number(y) + hm = annualHm.filter(ee.Filter.eq("year", year)).first() + z = hm.subtract(hmMean).divide(hmStdDev).rename("zScore") + return z.set("year", year) + + annualZScore = ee.ImageCollection(years.map(annualZScoreFunc)) + + # =========================================================================== + # 5. STACK INTO SINGLE MULTIBAND IMAGE + # =========================================================================== + + # Build one image with 38 named bands: + # Hm_2004 ... Hm_2022, zScore_2004 ... zScore_2022 + + def add_bands_for_year(y, img): + y = ee.Number(y).toInt() + hm_band = ( + annualHm.filter(ee.Filter.eq("year", y)) + .first() + .rename(ee.String("Hm_").cat(ee.Number(y).format())) + ) + z_band = ( + annualZScore.filter(ee.Filter.eq("year", y)) + .first() + .rename(ee.String("zScore_").cat(ee.Number(y).format())) + ) + return ee.Image(img).addBands(hm_band).addBands(z_band) + + empty_image = ee.Image().mask(ee.Image(0)) + output_image = ee.Image(years.iterate(add_bands_for_year, empty_image)) + output_image = output_image.select(output_image.bandNames().remove("constant")) + + export_raster_asset_to_gee( + output_image.clip(aoi), OUTPUT_DESC, OUTPUT_ASSET_ID, scale=5566, region=aoi + ) diff --git a/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py b/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py new file mode 100644 index 00000000..4b7139ff --- /dev/null +++ b/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py @@ -0,0 +1,199 @@ +import ee + +from utilities.constants import AEZ +from utilities.gee_utils import export_raster_asset_to_gee, ee_initialize + + +def rainfall_resilience(aez, start_year=2004, end_year=2022, gee_account_id=None): + # /** + # * Forest Sensitivity Analysis Pipeline — Script 3b + # * Heavy Rainfall Resistance & Resilience + # * + # * Signed resistance (both +ve and -ve events): + # * Resistance = Yn_bar / |Ye - Yn_bar| × sign(Ye - Yn_bar) + # * + # * Resilience computed ONLY when Ye < Yn_bar (negative effect years): + # * Resilience = |Ye - Yn_bar| / |Ye+1 - Yn_bar| × sign(Ye+1 - Yn_bar) + # * + # * Requires: + # * - Forest mask asset (Script 1) + # * - Rainfall index asset (Script 3a) + # */ + # + # # Configuration := + # + ee_initialize(7) + TREE_COVER_ASSET = f"projects/corestack-datasets-alpha/assets/datasets/SPEI/Hybrid_Tree_AEZ_{aez}_Period_{str(2003)}_{str(end_year)}" + RAIN_INDEX_ASSET = ( + f"projects/corestack-datasets-alpha/assets/datasets/SPEI/Rain_Index_AEZ_{aez}" + ) + + OUTPUT_DESC = f"Rain_Metrics_AEZ_{aez}" + OUTPUT_ASSET_ID = ( + f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" + ) + + Z_THRESHOLD = 1.0 + + # AOI := + aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + + # Loading the assets := + treeMeta = ee.Image(TREE_COVER_ASSET) + startYearTree = treeMeta.select("start_year") + endYearTree = treeMeta.select("end_year") + + rainIndex_raw = ee.Image(RAIN_INDEX_ASSET) + rainBandNames = [] + + for yr in range(start_year, end_year + 1): + rainBandNames.append("Hm_" + str(yr)) + rainBandNames.append("zScore_" + str(yr)) + + rainIndex = rainIndex_raw.rename(rainBandNames) + + hmCol_list = [] + zScoreCol_list = [] + for y in range(start_year, end_year + 1): + hmCol_list.append(rainIndex.select("Hm_" + str(y)).rename("Hm").set("year", y)) + zScoreCol_list.append( + rainIndex.select("zScore_" + str(y)).rename("zScore").set("year", y) + ) + + hmCol = ee.ImageCollection(hmCol_list) + zScoreCol = ee.ImageCollection(zScoreCol_list) + + # LANDSAT NDVI := + def maskClouds(image): + qa = image.select("QA_PIXEL") + mask = qa.bitwiseAnd(1 << 3).eq(0).And(qa.bitwiseAnd(1 << 4).eq(0)) + return image.updateMask(mask) + + def getAnnualNDVI(year): + start = ee.Date.fromYMD(year, 1, 1) + end = ee.Date.fromYMD(year, 12, 31) + + l89 = ( + ee.ImageCollection("LANDSAT/LC08/C02/T1_L2") + .merge(ee.ImageCollection("LANDSAT/LC09/C02/T1_L2")) + .filterDate(start, end) + .filterBounds(aoi) + .map(maskClouds) + .map( + lambda img: img.normalizedDifference(["SR_B5", "SR_B4"]).rename("ndvi") + ) + ) + + l57 = ( + ee.ImageCollection("LANDSAT/LT05/C02/T1_L2") + .merge(ee.ImageCollection("LANDSAT/LE07/C02/T1_L2")) + .filterDate(start, end) + .filterBounds(aoi) + .map(maskClouds) + .map( + lambda img: img.normalizedDifference(["SR_B4", "SR_B3"]).rename("ndvi") + ) + ) + + return l89.merge(l57).median().set("year", year).rename("ndvi") + + ndviCol = ee.ImageCollection( + ee.List.sequence(start_year, end_year + 1).map(getAnnualNDVI) + ) + + # BASELINE NDVI (Yn_bar) := + # Mean NDVI across non-anomalous years only + + analysisYears = ee.List.sequence(start_year, end_year) + + # Yn_bar = ee.ImageCollection(analysisYears.map(function(y) { + # year = ee.Number(y) + # ndvi = ee.Image(ndviCol.filter(ee.Filter.eq('year', year)).first()) + # zScore = ee.Image(zScoreCol.filter(ee.Filter.eq('year', year)).first()) + # .resample('bilinear') + # .reproject({crs: ndvi.projection(), scale: 30}) + # isNormal = zScore.select('zScore').abs().lt(Z_THRESHOLD) + # isForest = startYearTree.lte(year).and(endYearTree.gte(year)) + # return ndvi.updateMask(isNormal.and(isForest)).set('year', year) + # })).mean().rename('ndvi_baseline') + + def ndvi_forest(y): + year = ee.Number(y) + ndvi = ee.Image(ndviCol.filter(ee.Filter.eq("year", year)).first()) + zScore = ( + ee.Image(zScoreCol.filter(ee.Filter.eq("year", year)).first()) + .resample("bilinear") + .reproject(crs=ndvi.projection(), scale=30) + ) + + isNormal = zScore.select("zScore").abs().lt(Z_THRESHOLD) + isForest = startYearTree.lte(year).And(endYearTree.gte(year)) + return ndvi.updateMask(isNormal.And(isForest)).set("year", year) + + ndviNonDrought = ee.ImageCollection(analysisYears.map(ndvi_forest)) + + Yn_bar = ndviNonDrought.mean().rename("ndvi_baseline") + # SIGNED RESISTANCE & RESILIENCE := + + def metricsColFunc(y): + year = ee.Number(y) + + ndviYe = ee.Image(ndviCol.filter(ee.Filter.eq("year", year)).first()) + zScore = ( + ee.Image(zScoreCol.filter(ee.Filter.eq("year", year)).first()) + .resample("bilinear") + .reproject(crs=ndviYe.projection(), scale=30) + ) + + # Only compute on forest pixels during anomalous rainfall years + isAnomalous = zScore.select("zScore").gt(Z_THRESHOLD) + isForest = startYearTree.lte(year).And(endYearTree.gte(year)) + eventMask = isAnomalous.And(isForest) + + diffRaw = ndviYe.subtract(Yn_bar) + diffAbs = diffRaw.abs().max(1e-6) + + # Resistance: signed, computed for ALL anomalous years (both +ve and -ve) + resistance = ( + Yn_bar.divide(diffAbs) + .multiply(diffRaw.signum()) + .rename("resistance") + .updateMask(eventMask) + ) + + # Resilience: ONLY computed when Ye < Yn_bar (negative effect years) + # This avoids the 2D interpretation problem when Ye > Yn_bar + # and also thinking about it, resilience only makes sense, + # when Ye < Yn_bar , as if NDVI has increased from baseline, no point in calculating the recovering + # as the nae sugegsts. ALthough we are missing out on cases , if increase happened, and due to some lasting effect of rainfall, + # ndvi decreased in further years. But we're ignoring that case here, just for simplicity of understanding in 2-D. + isNegativeEffect = ndviYe.lt(Yn_bar) + resilMask = eventMask.And(isNegativeEffect) + + ndviNext = ee.Image(ndviCol.filter(ee.Filter.eq("year", year.add(1))).first()) + diffNext = ndviNext.subtract(Yn_bar) + diffNextAbs = diffNext.abs().max(1e-6) + + resilience = ( + diffAbs.divide(diffNextAbs) + .multiply(diffNext.signum()) + .rename("resilience") + .updateMask(resilMask) + ) + + return ee.Image.cat([resistance, resilience]).set("year", year) + + metricsCol = ee.ImageCollection(analysisYears.map(metricsColFunc)) + + # AGGREGATE & EXPORT := + + meanResist = metricsCol.select("resistance").mean().clip(aoi) + meanResil = metricsCol.select("resilience").mean().clip(aoi) + + finalOutput = meanResist.rename("resistance").addBands( + meanResil.rename("resilience") + ) + + task_id = export_raster_asset_to_gee( + finalOutput, OUTPUT_DESC, OUTPUT_ASSET_ID, scale=30, region=aoi + ) From be42c74ba895a158102145b53cdc5f96b93c0b56 Mon Sep 17 00:00:00 2001 From: aman verma Date: Thu, 4 Jun 2026 15:25:52 +0000 Subject: [PATCH 022/120] SPEI tree mask update --- computing/spei/hybrid_tree_mask.py | 61 ++++++++++-------- utilities/scripts/hwsd.py | 99 ++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 26 deletions(-) create mode 100644 utilities/scripts/hwsd.py diff --git a/computing/spei/hybrid_tree_mask.py b/computing/spei/hybrid_tree_mask.py index d0500d5d..4f2210e6 100644 --- a/computing/spei/hybrid_tree_mask.py +++ b/computing/spei/hybrid_tree_mask.py @@ -2,24 +2,28 @@ from utilities.constants import AEZ from utilities.gee_utils import export_raster_asset_to_gee, ee_initialize -# /** -# * Forest Sensitivity Analysis Pipeline — Script 1 -# * Hybrid 30m Annual Tree Cover Mask + Contiguous Forest Period -# * -# * Produces per-pixel: length (years), start_year, end_year -# * of the most recent unbroken forest period (2003–2022). -# * -# * Sources: -# * 1. GLC-FCS30D (2003–2022) -# * 2. Dynamic World (2015–2022) -# * 3. IndiaSat LULC (2017–2022), Core-stack. -# * -# * Union logic: majority vote among active datasets per year. -# * Temporal correction: ±2 year window as used in other places too by the team. -# */ - - -def generate_hybrid_tree_mask(aez, start_year=2003, end_year=2022, gee_account_id=None): +""" + Forest Sensitivity Analysis Pipeline — Script 1 + Hybrid 30m Annual Tree Cover Mask + Contiguous Forest Period + + Produces per-pixel: length (years), start_year, end_year + of the most recent unbroken forest period (2003–2022). + + Sources: + 1. GLC-FCS30D (2003–2022) + 2. . Dynamic World (2015–present) + 3. IndiaSat LULC (2017–2024), Core-stack. + + Union logic: majority vote among active datasets per year. + GLC-FCS30D: 2003–2022 (classes 51–92) + Dynamic World: 2015–present (class 1 = Trees) + IndiaSat LULC: 2017–2024 (class 6 = Trees) + + Temporal correction: ±2 year window as used in other places too by the team. +""" + + +def generate_hybrid_tree_mask(aez, start_year=2003, end_year=2024, gee_account_id=None): ee_initialize(7) TEMPORAL_WINDOW = 2 @@ -69,12 +73,16 @@ def annual_tree_cover(year): # GLC — always active, classes 51–92 are forests bandName = ee.String("b").cat(year.subtract(1999).format("%.0f")) - glcMask = ( - glcMosaic.select(bandName) - .gte(51) - .And(glcMosaic.select(bandName).lte(92)) - .rename("tree") - ) + + glcMask = ee.Image( + ee.Algorithms.If( + year.lte(2022), + glcMosaic.select(bandName) + .gte(51) + .And(glcMosaic.select(bandName).lte(92)), + ee.Image(0), + ) + ).rename("tree") proj = glcMask.projection() # Dynamic World — active from 2015 @@ -95,10 +103,11 @@ def annual_tree_cover(year): indiaSatMask = get_indiaSat_mask(year).reproject(crs=proj, scale=30) # Majority vote + glcActive = ee.Algorithms.If(year.lte(2022), 1, 0) forestSum = glcMask.unmask(0).add(dwMask.unmask(0)).add(indiaSatMask.unmask(0)) dwActive = ee.Algorithms.If(year.gte(2015), 1, 0) - indiasatActive = ee.Algorithms.If(year.gte(2017), 1, 0) - activeDatasets = ee.Number(1).add(dwActive).add(indiasatActive) + indiasatActive = ee.Algorithms.If(year.gte(2017).And(year.lte(2024)), 1, 0) + activeDatasets = ee.Number(glcActive).add(dwActive).add(indiasatActive) requiredVotes = ee.Algorithms.If(activeDatasets.eq(1), 1, 2) hybridMask = forestSum.gte(ee.Number(requiredVotes)) diff --git a/utilities/scripts/hwsd.py b/utilities/scripts/hwsd.py new file mode 100644 index 00000000..376caa9a --- /dev/null +++ b/utilities/scripts/hwsd.py @@ -0,0 +1,99 @@ +import geopandas as gpd +import pandas as pd +import numpy as np +import rasterio +from rasterio.mask import mask + +# ------------------------------------------------------------------ +# INPUTS +# ------------------------------------------------------------------ + +HWSD_RASTER = "data/HWSD_RASTER/hwsd_wgs84.tif" +HWSD_CSV = "data/HWSD_DATA.csv" +INDIA_BOUNDARY = "data/india_state_outer_no_islands.geojson" + +OUTPUT_RASTER = "data/india_subsoil_texture.tif" + +# ------------------------------------------------------------------ +# LOAD INDIA BOUNDARY +# ------------------------------------------------------------------ + +india = gpd.read_file(INDIA_BOUNDARY) + +if india.crs is None: + india = india.set_crs("EPSG:4326") +# ------------------------------------------------------------------ +# LOAD LOOKUP TABLE +# ------------------------------------------------------------------ + +df = pd.read_csv(HWSD_CSV, usecols=["MU_GLOBAL", "S_USDA_TEX_CLASS"], low_memory=False) + +lookup_df = df[["MU_GLOBAL", "S_USDA_TEX_CLASS"]].dropna().drop_duplicates("MU_GLOBAL") + +max_mu = int(lookup_df["MU_GLOBAL"].max()) + +# Lookup table: +# lut[MU_GLOBAL] = S_USDA_TEX_CLASS +lut = np.zeros(max_mu + 1, dtype=np.int16) + +lut[lookup_df["MU_GLOBAL"].astype(int)] = lookup_df["S_USDA_TEX_CLASS"].astype(int) + +# ------------------------------------------------------------------ +# CLIP HWSD TO INDIA +# ------------------------------------------------------------------ + +with rasterio.open(HWSD_RASTER) as src: + print(src.crs) # None + + if src.crs is None: + raster_crs = "EPSG:4326" + else: + raster_crs = src.crs + + if india.crs != raster_crs: + india = india.to_crs(raster_crs) + + clipped, transform = mask(src, india.geometry, crop=True, nodata=0) + + profile = src.profile.copy() + +mu_global = clipped[0] + +# ------------------------------------------------------------------ +# CONVERT MU_GLOBAL -> SUBSOIL TEXTURE CLASS +# ------------------------------------------------------------------ + +texture = np.zeros(mu_global.shape, dtype=np.int16) + +valid = (mu_global > 0) & (mu_global <= max_mu) + +texture[valid] = lut[mu_global[valid]] + +# ------------------------------------------------------------------ +# SAVE OUTPUT +# ------------------------------------------------------------------ + +profile.update( + driver="GTiff", + height=texture.shape[0], + width=texture.shape[1], + transform=transform, + dtype=rasterio.int16, + nodata=0, + compress="lzw", +) + +with rasterio.open(OUTPUT_RASTER, "w", **profile) as dst: + dst.write(texture, 1) + +print(f"Saved: {OUTPUT_RASTER}") + +# ------------------------------------------------------------------ +# OPTIONAL: PRINT CLASS DISTRIBUTION +# ------------------------------------------------------------------ + +unique, counts = np.unique(texture[texture > 0], return_counts=True) + +print("\nTexture classes found in India:") +for cls, cnt in zip(unique, counts): + print(f"Class {cls}: {cnt:,} pixels") From 6a8450e5e76e150eff6b9301044a7d52e061bca1 Mon Sep 17 00:00:00 2001 From: aman verma Date: Mon, 8 Jun 2026 12:08:17 +0000 Subject: [PATCH 023/120] spei_resistance_resilience_pipelines --- computing/api.py | 77 +- .../drought_resistance_resilience.py | 83 +- .../{drought_spei.R => compute_spei.R} | 723 +++++++++--------- .../generate_spei/download_base_datasets.py | 33 +- .../generate_spei/generate_ppet_multiband.py | 36 +- computing/spei/generate_spei/spei_runner.py | 10 +- computing/spei/hybrid_tree_mask.py | 30 +- .../export_rainfall_index.py | 74 +- .../rainfall_resistance_resilience.py | 66 +- computing/spei/spei.py | 82 ++ computing/urls.py | 11 + 11 files changed, 705 insertions(+), 520 deletions(-) rename computing/spei/generate_spei/{drought_spei.R => compute_spei.R} (80%) create mode 100644 computing/spei/spei.py diff --git a/computing/api.py b/computing/api.py index cb25df10..12fd3e86 100644 --- a/computing/api.py +++ b/computing/api.py @@ -43,6 +43,11 @@ from .cropping_intensity.cropping_intesity_local import ( generate_cropping_intensity as generate_cropping_intensity_local_task, ) +from .spei.spei import ( + generate_spei_pipeline, + run_drought_resistance_resilience, + run_rainfall_resistance_resilience, +) from .drought.drought import calculate_drought from .drought.drought_causality import drought_causality from .local_compute_helper import ( @@ -165,7 +170,6 @@ from .misc.canal_layer import canal_vector from .STAC_specs.stac_collection import generate_stac_collection_task - @api_security_check(allowed_methods="POST") @schema(None) def generate_admin_boundary(request): @@ -2150,6 +2154,29 @@ def generate_fabdem_layer(request): return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) +@api_view(["POST"]) +@schema(None) +def generate_spei(request): + print("Inside generate_spei API.") + try: + aez = request.data.get("aez") + start_year = request.data.get("start_year") + end_year = request.data.get("end_year") + gee_account_id = request.data.get("gee_account_id") + overwrite = request.data.get("overwrite") or False + + generate_spei_pipeline.apply_async( + args=[aez, start_year, end_year, gee_account_id, overwrite], queue="nrm" + ) + return Response( + {"Success": "Successfully initiated generate_spei task"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + print("Exception in generate_spei api :: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @api_view(["POST"]) @schema(None) def generate_canal_vector(request): @@ -2170,3 +2197,51 @@ def generate_canal_vector(request): f"Exception in generate canal vector layer for {district} - {block}:: ", e ) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@schema(None) +def drought_resilience_resistance(request): + print("Inside drought_resilience_resistance API.") + try: + aez = request.data.get("aez") + start_year = request.data.get("start_year") + end_year = request.data.get("end_year") + gee_account_id = request.data.get("gee_account_id") + + run_drought_resistance_resilience.apply_async( + args=[aez, start_year, end_year, gee_account_id], queue="nrm" + ) + return Response( + { + "Success": "Successfully drought_resilience_resistance generate_spei task" + }, + status=status.HTTP_200_OK, + ) + except Exception as e: + print("Exception in drought_resilience_resistance api :: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@schema(None) +def rainfall_resilience_resistance(request): + print("Inside rainfall_resilience_resistance API.") + try: + aez = request.data.get("aez") + start_year = request.data.get("start_year") + end_year = request.data.get("end_year") + gee_account_id = request.data.get("gee_account_id") + + run_rainfall_resistance_resilience.apply_async( + args=[aez, start_year, end_year, gee_account_id], queue="nrm" + ) + return Response( + { + "Success": "Successfully rainfall_resilience_resistance generate_spei task" + }, + status=status.HTTP_200_OK, + ) + except Exception as e: + print("Exception in rainfall_resilience_resistance api :: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/computing/spei/drought_sensitivity/drought_resistance_resilience.py b/computing/spei/drought_sensitivity/drought_resistance_resilience.py index 8cca841e..fef4ca95 100644 --- a/computing/spei/drought_sensitivity/drought_resistance_resilience.py +++ b/computing/spei/drought_sensitivity/drought_resistance_resilience.py @@ -1,34 +1,37 @@ import ee from utilities.constants import AEZ -from utilities.gee_utils import export_raster_asset_to_gee, ee_initialize - - -# /** -# * Forest Sensitivity Analysis Pipeline — Script 2 -# * Drought Resistance & Resilience -# * -# * For each forest pixel, computes mean resistance and resilience -# * across all drought years (SPEI-12 < threshold). -# * -# * Resistance = Yn_bar / |Ye - Yn_bar| -# * Resilience = |Ye - Yn_bar| / |Y(e+1) - Yn_bar| -# * -# * Where: -# * Yn_bar = mean NDVI across non-drought years (baseline) -# * Ye = NDVI during drought year -# * Y(e+1) = NDVI the year after drought -# * -# * Requires: -# * - Forest mask asset from Script 1 -# * - SPEI-12 assets from spei-drought-analysis-pipeline -# */ -# -# # CONFIGURATION := +from utilities.gee_utils import ( + export_raster_asset_to_gee, + ee_initialize, + is_gee_asset_exists, +) + + def generate_drought_resistance( aez, start_year=2004, end_year=2022, gee_account_id=None ): - ee_initialize(7) + """ + Forest Sensitivity Analysis Pipeline — Script 2 + Drought Resistance & Resilience + + For each forest pixel, computes mean resistance and resilience + across all drought years (SPEI-12 < threshold). + + Resistance = Yn_bar / |Ye - Yn_bar| + Resilience = |Ye - Yn_bar| / |Y(e+1) - Yn_bar| + + Where: + Yn_bar = mean NDVI across non-drought years (baseline) + Ye = NDVI during drought year + Y(e+1) = NDVI the year after drought + + Requires: + - Forest mask asset from Script 1 + - SPEI-12 assets from spei-drought-analysis-pipeline + """ + + ee_initialize(gee_account_id) TREE_COVER_ASSET = f"projects/corestack-datasets-alpha/assets/datasets/SPEI/Hybrid_Tree_AEZ_{aez}_Period_{str(2003)}_{str(end_year)}" OUTPUT_DESC = f"Drought_Metrics_AEZ_{aez}" @@ -36,7 +39,9 @@ def generate_drought_resistance( f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" ) - STATE_NAME = "Madhya Pradesh" + if is_gee_asset_exists(OUTPUT_ASSET_ID): + return None + DROUGHT_THRESHOLD = -1.0 # SPEI-12 below this = drought year aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() @@ -54,9 +59,6 @@ def generate_drought_resistance( spei12_raw = ee.Image(SPEI12_ASSET) spei12_bandnames = [] - # for (yn = 2004 yn <= 2023 yn++) { - # spei12_bandnames.push('y' + yn) - # } for yn in range(2004, 2024): spei12_bandnames.append("y" + str(yn)) @@ -64,13 +66,6 @@ def generate_drought_resistance( # Build per-year SPEI collection speiImages = [] - # for (y = START_YEAR y <= END_YEAR y++) { - # speiImages.push( - # spei12_named.select('y' + y) - # .rename('spei') - # .set('year', y) - # ) - # } for y in range(start_year, end_year + 1): speiImages.append( @@ -85,7 +80,7 @@ def maskClouds(image): mask = qa.bitwiseAnd(1 << 3).eq(0).And(qa.bitwiseAnd(1 << 4).eq(0)) return image.updateMask(mask) - def getAnnualNDVI(year): + def get_annual_ndvi(year): start = ee.Date.fromYMD(year, 1, 1) end = ee.Date.fromYMD(year, 12, 31) @@ -115,7 +110,7 @@ def getAnnualNDVI(year): # Load NDVI for START_YEAR to END_YEAR+1 (need next year for resilience) ndviYears = ee.List.sequence(start_year, end_year + 1) - ndviCol = ee.ImageCollection(ndviYears.map(getAnnualNDVI)) + ndviCol = ee.ImageCollection(ndviYears.map(get_annual_ndvi)) # BASELINE NDVI (Yn_bar) := # Mean NDVI across non-drought years only @@ -123,7 +118,7 @@ def getAnnualNDVI(year): analysisYears = ee.List.sequence(start_year, end_year) - def ndviNonDroughtFunc(y): + def ndvi_non_drought_func(y): year = ee.Number(y) ndvi = ndviCol.filter(ee.Filter.eq("year", year)).first() spei = ( @@ -136,13 +131,13 @@ def ndviNonDroughtFunc(y): isNonDrought = spei.gte(DROUGHT_THRESHOLD) return ndvi.updateMask(isNonDrought).set("year", year) - ndviNonDrought = ee.ImageCollection(analysisYears.map(ndviNonDroughtFunc)) + ndviNonDrought = ee.ImageCollection(analysisYears.map(ndvi_non_drought_func)) Yn_bar = ndviNonDrought.mean().rename("ndvi_baseline") # RESISTANCE & RESILIENCE := - def metricsColFunc(y): + def metrics_col_func(y): year = ee.Number(y) ndviYe = ndviCol.filter(ee.Filter.eq("year", year)).first() @@ -167,7 +162,7 @@ def metricsColFunc(y): return ee.Image.cat([resistance, resilience]).updateMask(mask).set("year", year) - metricsCol = ee.ImageCollection(analysisYears.map(metricsColFunc)) + metricsCol = ee.ImageCollection(analysisYears.map(metrics_col_func)) # AGGREGATE & EXPORT := @@ -178,6 +173,8 @@ def metricsColFunc(y): meanResilience.rename("resilience") ) - export_raster_asset_to_gee( + task_id = export_raster_asset_to_gee( finalOutput, OUTPUT_DESC, OUTPUT_ASSET_ID, scale=30, region=aoi ) + + return task_id diff --git a/computing/spei/generate_spei/drought_spei.R b/computing/spei/generate_spei/compute_spei.R similarity index 80% rename from computing/spei/generate_spei/drought_spei.R rename to computing/spei/generate_spei/compute_spei.R index 559e25f5..d5cb06b9 100644 --- a/computing/spei/generate_spei/drought_spei.R +++ b/computing/spei/generate_spei/compute_spei.R @@ -1,345 +1,378 @@ -# ============================================================================= -# 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(aez) { - - paste("Starting R script") - paste("aez", aez) - - input_file = paste0("data/drought_inputs/", aez, "/monthly/P_PET_", aez, "_monthly_multiband.tif") - output_dir = paste0("data/drought_inputs/", aez, "/monthly") - - # ------------------------------------------------------------------------- - # Create output directory - # ------------------------------------------------------------------------- - if (!dir.exists(output_dir)) { - dir.create(output_dir, recursive = TRUE) - } - - # ------------------------------------------------------------------------- - # Resume check - # ------------------------------------------------------------------------- - out_check <- file.path( - output_dir, - paste0("SPEI12_", aez, ".tif") - ) - - if (file.exists(out_check)) { - stop( - paste( - "Already processed:", - aez, - "— 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(aez, "_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_", aez, ".tif") - ), - format = "GTiff", - overwrite = TRUE, - NAflag = -9999 - ) - - writeRaster( - spei3_brick, - file.path( - output_dir, - paste0("SPEI3_", aez, ".tif") - ), - format = "GTiff", - overwrite = TRUE, - NAflag = -9999 - ) - - writeRaster( - spei12_brick, - file.path( - output_dir, - paste0("SPEI12_", aez, ".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_", - aez, - ".tif — ", - nlayers(spei1_brick), - " bands\n" - ) - ) - - cat( - paste0( - " SPEI3_", - aez, - ".tif — ", - nlayers(spei3_brick), - " bands\n" - ) - ) - - cat( - paste0( - " SPEI12_", - aez, - ".tif — ", - nlayers(spei12_brick), - " bands\n" - ) - ) -} - -# ============================================================================= -# FUNCTION CALL -# ============================================================================= -args <- commandArgs(trailingOnly = TRUE) - -if (length(args) < 1) { - stop("AEZ argument is required") -} - -aez <- args[1] - -run_spei_pipeline(aez) \ No newline at end of file +# ============================================================================= +# SPEI Pipeline — +# 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(aez, start_year, end_year) { + + paste("Starting R script") + paste("aez", aez) + paste("start_year", start_year) + + input_file = paste0("data/drought_inputs/", aez, "/monthly/P_PET_AEZ_", aez, "_monthly_multiband.tif") + output_dir = paste0("data/drought_inputs/", aez, "/monthly") + + n_years <- end_year - start_year + 1 + + n_monthly <- n_years * 12 + n_seasonal <- n_years * 4 + n_annual <- n_years + + n_output <- n_monthly + n_seasonal + n_annual + + # ------------------------------------------------------------------------- + # Create output directory + # ------------------------------------------------------------------------- + if (!dir.exists(output_dir)) { + dir.create(output_dir, recursive = TRUE) + } + + # ------------------------------------------------------------------------- + # Resume check + # ------------------------------------------------------------------------- + out_check <- file.path( + output_dir, + paste0("SPEI12_", aez, ".tif") + ) + + if (file.exists(out_check)) { + stop( + paste( + "Already processed:", + aez, + "— delete output files to rerun." + ) + ) + } + + # ========================================================================= + # SPEI FUNCTION + # Input: + # x = n_monthly-length vector of monthly P-PET values + # Output: + # n_output-length vector: + # [1 : n_monthly] SPEI-1 + # [n_monthly+1 : n_seasonal] SPEI-3 seasonal months only + # [n_seasonal+1 : n_annual] SPEI-12 annual only + # ========================================================================= + spei_function <- function(x, ...) { + + tryCatch({ + + if (all(is.na(x))) { + return(rep(NA, n_output)) + } + + pixel_ts <- ts( + x, + start = c(start_year, 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) != n_monthly) { + 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, n_monthly, by = 12) + + spei12_sel <- spei12_all[annual_idx] + + return( + c( + spei1_all, + spei3_sel, + spei12_sel + ) + ) + + }, error = function(e) { + + return(rep(NA, n_output)) + + }) + } + + # ========================================================================= + # LOAD INPUT + # ========================================================================= + cat(paste("Loading:", input_file, "\n")) + + p_pet_brick <- brick(input_file) + + + cat( + paste( + "Loaded", + nlayers(p_pet_brick), + "bands (expected", + n_monthly, + ")\n" + ) + ) + + # ========================================================================= + # VALIDATE INPUT + # ========================================================================= + if (nlayers(p_pet_brick) != n_monthly) { + stop( + paste( + "Expected", + n_monthly, + "bands but found", + nlayers(p_pet_brick) + ) + ) + } + + # ========================================================================= + # COMPUTE BLOCK BY BLOCK + # ========================================================================= + cat("Running SPEI computation...\n") + + temp_file <- file.path( + output_dir, + paste0(aez, "_temp.tif") + ) + + result_brick <- brick( + p_pet_brick, + nl = n_output + ) + + 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(start_year:end_year, each = 12), + "_m", + sprintf("%02d", rep(1:12, n_years)) + ) + + spei3_names <- paste0( + "y", + rep(start_year:end_year, each = 4), + "_m", + sprintf("%02d", rep(c(3, 6, 9, 12), n_years)) + ) + + spei12_names <- paste0( + "y", + start_year:end_year + ) + + # ========================================================================= + # 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]] + + spei1_end <- n_monthly + spei3_end <- n_monthly + n_seasonal + + spei1_brick <- all_b[[1:spei1_end]] + spei3_brick <- all_b[[(spei1_end + 1):spei3_end]] + spei12_brick <- all_b[[(spei3_end + 1):n_output]] + + 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_", aez, ".tif") + ), + format = "GTiff", + overwrite = TRUE, + NAflag = -9999 + ) + + writeRaster( + spei3_brick, + file.path( + output_dir, + paste0("SPEI3_", aez, ".tif") + ), + format = "GTiff", + overwrite = TRUE, + NAflag = -9999 + ) + + writeRaster( + spei12_brick, + file.path( + output_dir, + paste0("SPEI12_", aez, ".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_", + aez, + ".tif — ", + nlayers(spei1_brick), + " bands\n" + ) + ) + + cat( + paste0( + " SPEI3_", + aez, + ".tif — ", + nlayers(spei3_brick), + " bands\n" + ) + ) + + cat( + paste0( + " SPEI12_", + aez, + ".tif — ", + nlayers(spei12_brick), + " bands\n" + ) + ) +} + +# ============================================================================= +# FUNCTION CALL +# ============================================================================= +args <- commandArgs(trailingOnly = TRUE) + +if (length(args) < 1) { + stop("AEZ argument is required") +} + +aez <- args[1] +start_year <- as.integer(args[2]) +end_year <- as.integer(args[3]) + +run_spei_pipeline(aez, start_year, end_year) \ No newline at end of file diff --git a/computing/spei/generate_spei/download_base_datasets.py b/computing/spei/generate_spei/download_base_datasets.py index 8d94b28f..899297a4 100644 --- a/computing/spei/generate_spei/download_base_datasets.py +++ b/computing/spei/generate_spei/download_base_datasets.py @@ -23,7 +23,6 @@ import requests from utilities.constants import AEZ -from utilities.gee_utils import ee_initialize CHIRPS_COLLECTION = "UCSB-CHG/CHIRPS/DAILY" MODIS_PET_COLLECTION = "MODIS/061/MOD16A2GF" @@ -31,33 +30,6 @@ 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" -# # ) -# -# admin = ee.FeatureCollection(AEZ) -# -# 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]: @@ -279,6 +251,7 @@ def download_dataset_images( # aoi_label = "India" if aez == "india" else state # safe_aoi = aoi_label.replace(" ", "_") name = dataset_label(dataset) + print(output_dir, aez, frequency, dataset) dataset_output_dir = Path(output_dir) / str(aez) / frequency / dataset collection, region, _, labeled_dates = build_dataset_image( @@ -362,7 +335,6 @@ def download_data_locally( start_date: str = "2004-01-01", end_date: str = "2023-12-31", frequency: str = "monthly", - project: str = DEFAULT_PROJECT, output_dir: str = "data/drought_inputs", sleep: float = 0.2, max_workers: int = 4, @@ -372,7 +344,6 @@ def download_data_locally( # separate CHIRPS and MODIS PET time-step GeoTIFFs. selected_datasets = datasets or ["both"] # validate_inputs(aez, selected_datasets, frequency) - initialize_earth_engine(project) expanded_datasets = expand_datasets(selected_datasets) for dataset in expanded_datasets: @@ -392,4 +363,4 @@ def download_data_locally( max_workers=max_workers, ) - print("Done.") + print("Done downloading data.") diff --git a/computing/spei/generate_spei/generate_ppet_multiband.py b/computing/spei/generate_spei/generate_ppet_multiband.py index 9fca4feb..6ec01acc 100644 --- a/computing/spei/generate_spei/generate_ppet_multiband.py +++ b/computing/spei/generate_spei/generate_ppet_multiband.py @@ -1,14 +1,3 @@ -# ============================================================================= -# 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 @@ -92,24 +81,35 @@ def reproject_modis_to_chirps_grid( ) -def main( - aez: str = None, +def ppet_multiband( + aez=None, start: int = 2004, end: int = 2023, ) -> Path: + """ + 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. + + """ data_root = Path("data/drought_inputs") - chirps_dir = data_root / aez / "monthly" / "chirps" - modis_dir = data_root / aez / "monthly" / "modis_pet" + chirps_dir = data_root / str(aez) / "monthly" / "chirps" + modis_dir = data_root / str(aez) / "monthly" / "modis_pet" # input_root = Path("data/drought_inputs") - output_dir = Path("data/drought_inputs") / aez / "monthly" - output = output_dir / f"P_PET_{aez}_monthly_multiband.tif" + output_dir = Path("data/drought_inputs") / str(aez) / "monthly" + output = output_dir / f"P_PET_AEZ_{str(aez)}_monthly_multiband.tif" OUTPUT_NODATA = -9999.0 output_file = ( Path(output) if output - else data_root / aez / f"P_PET_{aez}_monthly_multiband.tif" + else data_root / str(aez) / f"P_PET_{str(aez)}_monthly_multiband.tif" ) output_file.parent.mkdir(parents=True, exist_ok=True) diff --git a/computing/spei/generate_spei/spei_runner.py b/computing/spei/generate_spei/spei_runner.py index 676be6a4..39308606 100644 --- a/computing/spei/generate_spei/spei_runner.py +++ b/computing/spei/generate_spei/spei_runner.py @@ -4,16 +4,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent -R_SCRIPT = BASE_DIR / "drought" / "drought_spei.R" +R_SCRIPT = BASE_DIR / "generate_spei" / "compute_spei.R" -def run_spei_pipeline(aez=None): +def run_spei(aez=None, start_year=None, end_year=None): print(aez) - command = [ - "Rscript", - str(R_SCRIPT), - aez, - ] + command = ["Rscript", str(R_SCRIPT), str(aez), str(start_year), str(end_year)] print("COMMAND:", command) diff --git a/computing/spei/hybrid_tree_mask.py b/computing/spei/hybrid_tree_mask.py index 4f2210e6..4b350294 100644 --- a/computing/spei/hybrid_tree_mask.py +++ b/computing/spei/hybrid_tree_mask.py @@ -1,8 +1,14 @@ import ee from utilities.constants import AEZ -from utilities.gee_utils import export_raster_asset_to_gee, ee_initialize +from utilities.gee_utils import ( + export_raster_asset_to_gee, + ee_initialize, + is_gee_asset_exists, +) -""" + +def generate_hybrid_tree_mask(aez, start_year=2003, end_year=2024, gee_account_id=None): + """ Forest Sensitivity Analysis Pipeline — Script 1 Hybrid 30m Annual Tree Cover Mask + Contiguous Forest Period @@ -12,22 +18,23 @@ Sources: 1. GLC-FCS30D (2003–2022) 2. . Dynamic World (2015–present) - 3. IndiaSat LULC (2017–2024), Core-stack. + 3. IndiaSat LULC (2017–present), Core-stack. Union logic: majority vote among active datasets per year. GLC-FCS30D: 2003–2022 (classes 51–92) Dynamic World: 2015–present (class 1 = Trees) - IndiaSat LULC: 2017–2024 (class 6 = Trees) + IndiaSat LULC: 2017–present (class 6 = Trees) Temporal correction: ±2 year window as used in other places too by the team. -""" - + """ -def generate_hybrid_tree_mask(aez, start_year=2003, end_year=2024, gee_account_id=None): - ee_initialize(7) + ee_initialize(gee_account_id) TEMPORAL_WINDOW = 2 + start_year = 2003 + LULC_START_YEAR = 2017 + aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() OUTPUT_DESC = f"Hybrid_Tree_AEZ_{aez}_Period_{str(start_year)}_{str(end_year)}" @@ -35,6 +42,9 @@ def generate_hybrid_tree_mask(aez, start_year=2003, end_year=2024, gee_account_i f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" ) + if is_gee_asset_exists(OUTPUT_ASSET_ID): + return None, OUTPUT_ASSET_ID + # DATASET PREPARATION := # --- GLC-FCS30D --- glcMosaic = ee.ImageCollection( @@ -46,7 +56,7 @@ def generate_hybrid_tree_mask(aez, start_year=2003, end_year=2024, gee_account_i # --- IndiaSat LULC --- indiaSatList = [] - for year in range(2017, end_year + 1): + for year in range(LULC_START_YEAR, end_year + 1): indiaSatList.append( ee.Image( f"projects/corestack-datasets/assets/datasets/LULC_v3_river_basin/pan_india_lulc_v3_{year}_{year+1}" @@ -167,4 +177,4 @@ def calculate_consecutive(currentImage, previousState): finalOutput, OUTPUT_DESC, OUTPUT_ASSET_ID, scale=30, region=aoi ) - return task_id + return task_id, OUTPUT_ASSET_ID diff --git a/computing/spei/rainfall_sensitivity/export_rainfall_index.py b/computing/spei/rainfall_sensitivity/export_rainfall_index.py index adf9496e..7f9a85ec 100644 --- a/computing/spei/rainfall_sensitivity/export_rainfall_index.py +++ b/computing/spei/rainfall_sensitivity/export_rainfall_index.py @@ -1,50 +1,50 @@ import ee from utilities.constants import AEZ -from utilities.gee_utils import export_raster_asset_to_gee, ee_initialize +from utilities.gee_utils import ( + export_raster_asset_to_gee, + ee_initialize, + is_gee_asset_exists, +) def rainfall_index(aez, start_year=2004, end_year=2022, gee_account_id=None): - # /** - # * Forest Sensitivity Analysis Pipeline — Script 3a - # * Heavy Rainfall Index Export - # * - # * Computes two quantities per pixel per year and exports as a single - # * multiband asset — one band per year for each quantity: - # * - # * Hm_{year} = annual sum of precipitation on heavy days - # * zScore_{year} = z-score of Hm relative to the full period mean/stddev - # * - # * Heavy day definition: daily precipitation > long-term 95th percentile (CHIRPS) - # * Z-score computed across all years in the period. - # * - # * Output asset bands: - # * Hm_2004, Hm_2005, ..., Hm_2023 (19 bands) - # * zScore_2004, ..., zScore_2023 (19 bands) - # * Total: 38 bands - # * - # * This asset is the direct input to Script 3b, analogous to how - # * SPEI assets are the input to Script 2 (drought). - # * - # * Requires: nothing — only public datasets (CHIRPS) - # */ - # - # #=========================================================================== - # # 1. CONFIGURATION - # #=========================================================================== - ee_initialize(7) + """ + Forest Sensitivity Analysis Pipeline — Script 3a + Heavy Rainfall Index Export + + Computes two quantities per pixel per year and exports as a single + multiband asset — one band per year for each quantity: + + Hm_{year} = annual sum of precipitation on heavy days + zScore_{year} = z-score of Hm relative to the full period mean/stddev + + Heavy day definition: daily precipitation > long-term 95th percentile (CHIRPS) + Z-score computed across all years in the period. + + Output asset bands: + Hm_2004, Hm_2005, ..., Hm_2023 (19 bands) + zScore_2004, ..., zScore_2023 (19 bands) + Total: 38 bands + + This asset is the direct input to Script 3b, analogous to how + SPEI assets are the input to Script 2 (drought). + + Requires: nothing — only public datasets (CHIRPS) + """ + + ee_initialize(gee_account_id) OUTPUT_DESC = f"Rain_Index_AEZ_{aez}" OUTPUT_ASSET_ID = ( f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" ) - # =========================================================================== - # 2. AOI - # =========================================================================== + if is_gee_asset_exists(OUTPUT_ASSET_ID): + return None aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # =========================================================================== - # 3. HEAVY RAINFALL INDEX (Hm) + # HEAVY RAINFALL INDEX (Hm) # =========================================================================== chirps = ( @@ -83,7 +83,7 @@ def annualHmFunc(y): annualHm = ee.ImageCollection(years.map(annualHmFunc)) # =========================================================================== - # 4. Z-SCORE ACROSS ALL YEARS + # Z-SCORE ACROSS ALL YEARS # =========================================================================== hmMean = annualHm.mean().rename("Hm_mean") @@ -98,7 +98,7 @@ def annualZScoreFunc(y): annualZScore = ee.ImageCollection(years.map(annualZScoreFunc)) # =========================================================================== - # 5. STACK INTO SINGLE MULTIBAND IMAGE + # STACK INTO SINGLE MULTIBAND IMAGE # =========================================================================== # Build one image with 38 named bands: @@ -122,6 +122,8 @@ def add_bands_for_year(y, img): output_image = ee.Image(years.iterate(add_bands_for_year, empty_image)) output_image = output_image.select(output_image.bandNames().remove("constant")) - export_raster_asset_to_gee( + task_id = export_raster_asset_to_gee( output_image.clip(aoi), OUTPUT_DESC, OUTPUT_ASSET_ID, scale=5566, region=aoi ) + + return task_id diff --git a/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py b/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py index 4b7139ff..0991677b 100644 --- a/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py +++ b/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py @@ -1,28 +1,31 @@ import ee from utilities.constants import AEZ -from utilities.gee_utils import export_raster_asset_to_gee, ee_initialize - - -def rainfall_resilience(aez, start_year=2004, end_year=2022, gee_account_id=None): - # /** - # * Forest Sensitivity Analysis Pipeline — Script 3b - # * Heavy Rainfall Resistance & Resilience - # * - # * Signed resistance (both +ve and -ve events): - # * Resistance = Yn_bar / |Ye - Yn_bar| × sign(Ye - Yn_bar) - # * - # * Resilience computed ONLY when Ye < Yn_bar (negative effect years): - # * Resilience = |Ye - Yn_bar| / |Ye+1 - Yn_bar| × sign(Ye+1 - Yn_bar) - # * - # * Requires: - # * - Forest mask asset (Script 1) - # * - Rainfall index asset (Script 3a) - # */ - # - # # Configuration := - # - ee_initialize(7) +from utilities.gee_utils import ( + export_raster_asset_to_gee, + ee_initialize, + is_gee_asset_exists, +) + + +def generate_rainfall_resilience( + aez, start_year=2004, end_year=2022, gee_account_id=None +): + """ + Forest Sensitivity Analysis Pipeline — Script 3b + Heavy Rainfall Resistance & Resilience + + Signed resistance (both +ve and -ve events): + Resistance = Yn_bar / |Ye - Yn_bar| × sign(Ye - Yn_bar) + + Resilience computed ONLY when Ye < Yn_bar (negative effect years): + Resilience = |Ye - Yn_bar| / |Ye+1 - Yn_bar| × sign(Ye+1 - Yn_bar) + + Requires: + - Forest mask asset (Script 1) + - Rainfall index asset (Script 3a) + """ + ee_initialize(gee_account_id) TREE_COVER_ASSET = f"projects/corestack-datasets-alpha/assets/datasets/SPEI/Hybrid_Tree_AEZ_{aez}_Period_{str(2003)}_{str(end_year)}" RAIN_INDEX_ASSET = ( f"projects/corestack-datasets-alpha/assets/datasets/SPEI/Rain_Index_AEZ_{aez}" @@ -33,6 +36,9 @@ def rainfall_resilience(aez, start_year=2004, end_year=2022, gee_account_id=None f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" ) + if is_gee_asset_exists(OUTPUT_ASSET_ID): + return None + Z_THRESHOLD = 1.0 # AOI := @@ -64,12 +70,12 @@ def rainfall_resilience(aez, start_year=2004, end_year=2022, gee_account_id=None zScoreCol = ee.ImageCollection(zScoreCol_list) # LANDSAT NDVI := - def maskClouds(image): + def mask_clouds(image): qa = image.select("QA_PIXEL") mask = qa.bitwiseAnd(1 << 3).eq(0).And(qa.bitwiseAnd(1 << 4).eq(0)) return image.updateMask(mask) - def getAnnualNDVI(year): + def get_annual_ndvi(year): start = ee.Date.fromYMD(year, 1, 1) end = ee.Date.fromYMD(year, 12, 31) @@ -78,7 +84,7 @@ def getAnnualNDVI(year): .merge(ee.ImageCollection("LANDSAT/LC09/C02/T1_L2")) .filterDate(start, end) .filterBounds(aoi) - .map(maskClouds) + .map(mask_clouds) .map( lambda img: img.normalizedDifference(["SR_B5", "SR_B4"]).rename("ndvi") ) @@ -89,7 +95,7 @@ def getAnnualNDVI(year): .merge(ee.ImageCollection("LANDSAT/LE07/C02/T1_L2")) .filterDate(start, end) .filterBounds(aoi) - .map(maskClouds) + .map(mask_clouds) .map( lambda img: img.normalizedDifference(["SR_B4", "SR_B3"]).rename("ndvi") ) @@ -98,7 +104,7 @@ def getAnnualNDVI(year): return l89.merge(l57).median().set("year", year).rename("ndvi") ndviCol = ee.ImageCollection( - ee.List.sequence(start_year, end_year + 1).map(getAnnualNDVI) + ee.List.sequence(start_year, end_year + 1).map(get_annual_ndvi) ) # BASELINE NDVI (Yn_bar) := @@ -135,7 +141,7 @@ def ndvi_forest(y): Yn_bar = ndviNonDrought.mean().rename("ndvi_baseline") # SIGNED RESISTANCE & RESILIENCE := - def metricsColFunc(y): + def metrics_col_func(y): year = ee.Number(y) ndviYe = ee.Image(ndviCol.filter(ee.Filter.eq("year", year)).first()) @@ -183,7 +189,7 @@ def metricsColFunc(y): return ee.Image.cat([resistance, resilience]).set("year", year) - metricsCol = ee.ImageCollection(analysisYears.map(metricsColFunc)) + metricsCol = ee.ImageCollection(analysisYears.map(metrics_col_func)) # AGGREGATE & EXPORT := @@ -197,3 +203,5 @@ def metricsColFunc(y): task_id = export_raster_asset_to_gee( finalOutput, OUTPUT_DESC, OUTPUT_ASSET_ID, scale=30, region=aoi ) + + return task_id diff --git a/computing/spei/spei.py b/computing/spei/spei.py new file mode 100644 index 00000000..50642d35 --- /dev/null +++ b/computing/spei/spei.py @@ -0,0 +1,82 @@ +from computing.spei.drought_sensitivity.drought_resistance_resilience import ( + generate_drought_resistance, +) +from computing.spei.generate_spei.download_base_datasets import download_data_locally +from computing.spei.generate_spei.generate_ppet_multiband import ppet_multiband +from computing.spei.generate_spei.spei_runner import run_spei +from computing.spei.hybrid_tree_mask import generate_hybrid_tree_mask +from computing.spei.rainfall_sensitivity.export_rainfall_index import rainfall_index +from computing.spei.rainfall_sensitivity.rainfall_resistance_resilience import ( + generate_rainfall_resilience, +) +from utilities.gee_utils import ee_initialize, check_task_status, is_gee_asset_exists +from nrm_app.celery import app + + +@app.task(bind=True) +def generate_spei_pipeline( + self, + aez, + start_year, + end_year, + gee_account_id=None, + overwrite=False, +): + ee_initialize(gee_account_id) + start_date = f"{str(start_year)}-01-01" + end_date = f"{str(end_year)}-12-31" + + download_data_locally( + aez=aez, + start_date=start_date, + end_date=end_date, + frequency="monthly", + datasets=None, + overwrite=overwrite, + ) + + ppet_multiband( + aez=aez, + start=start_year, + end=end_year, + ) + + run_spei(aez, start_year, end_year) + + +@app.task(bind=True) +def run_drought_resistance_resilience( + self, aez, start_year=None, end_year=None, gee_account_id=None +): + task_id, asset_id = generate_hybrid_tree_mask( + aez, start_year=start_year, end_year=end_year, gee_account_id=gee_account_id + ) + if task_id: + check_task_status([task_id]) + + if is_gee_asset_exists(asset_id): + generate_drought_resistance( + aez, start_year=2004, end_year=end_year, gee_account_id=gee_account_id + ) + + +@app.task(bind=True) +def run_rainfall_resistance_resilience( + self, aez, start_year=None, end_year=None, gee_account_id=None +): + task_id = rainfall_index( + aez, start_year=start_year, end_year=end_year, gee_account_id=gee_account_id + ) + if task_id: + check_task_status([task_id]) + + task_id, asset_id = generate_hybrid_tree_mask( + aez, start_year=start_year, end_year=end_year, gee_account_id=gee_account_id + ) + if task_id: + check_task_status([task_id]) + + if is_gee_asset_exists(asset_id): + generate_rainfall_resilience( + aez, start_year=2004, end_year=end_year, gee_account_id=gee_account_id + ) diff --git a/computing/urls.py b/computing/urls.py index e7eb2c9c..48d6777b 100644 --- a/computing/urls.py +++ b/computing/urls.py @@ -247,4 +247,15 @@ path( "refresh_cache//", api.refresh_layer_cache, name="refresh_cache" ), + path("spei/", api.generate_spei, name="spei"), + path( + "drought_resilience_resistance/", + api.drought_resilience_resistance, + name="drought_resilience_resistance", + ), + path( + "rainfall_resilience_resistance/", + api.rainfall_resilience_resistance, + name="rainfall_resilience_resistance", + ), ] From ee2192e86269f8e9d7efd0b68b9d52eb28feac93 Mon Sep 17 00:00:00 2001 From: aman verma Date: Tue, 9 Jun 2026 11:00:28 +0000 Subject: [PATCH 024/120] spei R script update with ref years --- computing/spei/generate_spei/compute_spei.R | 411 +++++--------------- 1 file changed, 91 insertions(+), 320 deletions(-) diff --git a/computing/spei/generate_spei/compute_spei.R b/computing/spei/generate_spei/compute_spei.R index d5cb06b9..35d31b3d 100644 --- a/computing/spei/generate_spei/compute_spei.R +++ b/computing/spei/generate_spei/compute_spei.R @@ -1,367 +1,138 @@ -# ============================================================================= -# SPEI Pipeline — -# Read multiband P-PET GeoTIFF, compute SPEI-1/3/12 pixel-wise, -# write 3 multiband output GeoTIFFs with named bands. -# ============================================================================= +""" + SPEI Pipeline + Read multiband P-PET GeoTIFF, compute SPEI-1/3/12 pixel-wise, + write 3 multiband output GeoTIFFs with named bands. + Here the reference baseline period is taken as 2004-2023. + Change the end_year variable to whatever year you wanna extend the pipeline to. + If it is not intentional, don't touch the ref_start and ref_end variables for + extending the pipeline as it will change the SPEI values for all previous years too. +""" -# ============================================================================= -# 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(aez, start_year, end_year) { - paste("Starting R script") - paste("aez", aez) - paste("start_year", start_year) + input_file <- paste0("data/drought_inputs/", aez, "/monthly/P_PET_AEZ_", aez, "_monthly_multiband.tif") + output_dir <- paste0("data/drought_inputs/", aez, "/monthly") - input_file = paste0("data/drought_inputs/", aez, "/monthly/P_PET_AEZ_", aez, "_monthly_multiband.tif") - output_dir = paste0("data/drought_inputs/", aez, "/monthly") + if (!dir.exists(output_dir)) dir.create(output_dir, recursive = TRUE) - n_years <- end_year - start_year + 1 + # --- YEAR RANGE --- + ref_start <- 2004 # baseline period start — distribution fitted on this range + ref_end <- 2023 # baseline period end — freeze this when extending to future years + n_years <- end_year - start_year + 1 n_monthly <- n_years * 12 n_seasonal <- n_years * 4 n_annual <- n_years + n_output <- n_monthly + n_seasonal + n_annual - n_output <- n_monthly + n_seasonal + n_annual - - # ------------------------------------------------------------------------- - # Create output directory - # ------------------------------------------------------------------------- - if (!dir.exists(output_dir)) { - dir.create(output_dir, recursive = TRUE) - } - - # ------------------------------------------------------------------------- - # Resume check - # ------------------------------------------------------------------------- - out_check <- file.path( - output_dir, - paste0("SPEI12_", aez, ".tif") - ) - + # --- Resume check --- + out_check <- file.path(output_dir, paste0("SPEI12_", aez, ".tif")) if (file.exists(out_check)) { - stop( - paste( - "Already processed:", - aez, - "— delete output files to rerun." - ) - ) + stop(paste("Already processed:", aez, "— delete output files to rerun.")) } - # ========================================================================= - # SPEI FUNCTION - # Input: - # x = n_monthly-length vector of monthly P-PET values - # Output: - # n_output-length vector: - # [1 : n_monthly] SPEI-1 - # [n_monthly+1 : n_seasonal] SPEI-3 seasonal months only - # [n_seasonal+1 : n_annual] SPEI-12 annual only - # ========================================================================= + # ============================================================================= + # SPEI FUNCTION — do not modify + # Input: variable-length P-PET time series + # Output: variable-length vector (SPEI-1 all: 12*(no. of years), SPEI-3 seasonal: 4*(no. of years), SPEI-12 annual: no. of years) + # ============================================================================= spei_function <- function(x, ...) { - - tryCatch({ - - if (all(is.na(x))) { - return(rep(NA, n_output)) - } - - pixel_ts <- ts( - x, - start = c(start_year, 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) != n_monthly) { - 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, n_monthly, by = 12) - - spei12_sel <- spei12_all[annual_idx] - - return( - c( - spei1_all, - spei3_sel, - spei12_sel - ) - ) - - }, error = function(e) { - - return(rep(NA, n_output)) - - }) + tryCatch({ + if (all(is.na(x))) return(rep(NA, n_output)) + + pixel_ts <- ts(x, start = c(start_year, 1), frequency = 12) + + spei1_all <- as.vector(spei(pixel_ts, 1, + distribution = 'log-Logistic', + ref.start = c(ref_start, 1), + ref.end = c(ref_end, 12), + na.rm = TRUE)$fitted) + spei3_all <- as.vector(spei(pixel_ts, 3, + distribution = 'log-Logistic', + ref.start = c(ref_start, 1), + ref.end = c(ref_end, 12), + na.rm = TRUE)$fitted) + spei12_all <- as.vector(spei(pixel_ts, 12, + distribution = 'log-Logistic', + ref.start = c(ref_start, 1), + ref.end = c(ref_end, 12), + na.rm = TRUE)$fitted) + + if (length(spei1_all) != n_monthly) stop("Incorrect output length.") + + seasonal_idx <- which(((seq_along(spei3_all) - 1) %% 12 + 1) %in% c(3,6,9,12)) + annual_idx <- seq(12, n_monthly, by = 12) + + c(spei1_all, spei3_all[seasonal_idx], spei12_all[annual_idx]) + + }, error = function(e) rep(NA, n_output)) } - # ========================================================================= - # LOAD INPUT - # ========================================================================= + # --- Load input --- cat(paste("Loading:", input_file, "\n")) - p_pet_brick <- brick(input_file) + cat(paste("Loaded", nlayers(p_pet_brick), "bands (expected", n_monthly, ")\n")) + # --- Band names --- + spei1_names <- paste0('y', rep(start_year:end_year, each = 12), + '_m', sprintf('%02d', rep(1:12, n_years))) + spei3_names <- paste0('y', rep(start_year:end_year, each = 4), + '_m', sprintf('%02d', rep(c(3,6,9,12), n_years))) + spei12_names <- paste0('y', start_year:end_year) - cat( - paste( - "Loaded", - nlayers(p_pet_brick), - "bands (expected", - n_monthly, - ")\n" - ) - ) - - # ========================================================================= - # VALIDATE INPUT - # ========================================================================= - if (nlayers(p_pet_brick) != n_monthly) { - stop( - paste( - "Expected", - n_monthly, - "bands but found", - nlayers(p_pet_brick) - ) - ) - } - - # ========================================================================= - # COMPUTE BLOCK BY BLOCK - # ========================================================================= + # --- Compute block by block --- cat("Running SPEI computation...\n") - - temp_file <- file.path( - output_dir, - paste0(aez, "_temp.tif") - ) - - result_brick <- brick( - p_pet_brick, - nl = n_output - ) - - result_brick <- writeStart( - result_brick, - filename = temp_file, - overwrite = TRUE - ) + temp_file <- file.path(output_dir, paste0(aez, "_temp.tif")) + result_brick <- brick(p_pet_brick, nl = n_output) + 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" - ) - ) + 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]) + if (i %% 5 == 0) cat(paste(" Chunk", i, "/", bs$n, "\n")) } - result_brick <- writeStop(result_brick) - cat("Computation complete.\n") - # ========================================================================= - # GENERATE BAND NAMES - # ========================================================================= - spei1_names <- paste0( - "y", - rep(start_year:end_year, each = 12), - "_m", - sprintf("%02d", rep(1:12, n_years)) - ) - - spei3_names <- paste0( - "y", - rep(start_year:end_year, each = 4), - "_m", - sprintf("%02d", rep(c(3, 6, 9, 12), n_years)) - ) - - spei12_names <- paste0( - "y", - start_year:end_year - ) - - # ========================================================================= - # SPLIT OUTPUTS - # ========================================================================= + # --- Split and save --- 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]] - spei1_end <- n_monthly spei3_end <- n_monthly + n_seasonal - spei1_brick <- all_b[[1:spei1_end]] - spei3_brick <- all_b[[(spei1_end + 1):spei3_end]] - spei12_brick <- all_b[[(spei3_end + 1):n_output]] + spei1_b <- all_b[[1:spei1_end]] + spei3_b <- all_b[[(spei1_end + 1):spei3_end]] + spei12_b <- all_b[[(spei3_end + 1):n_output]] - names(spei1_brick) <- spei1_names - names(spei3_brick) <- spei3_names - names(spei12_brick) <- spei12_names + names(spei1_b) <- spei1_names + names(spei3_b) <- spei3_names + names(spei12_b) <- spei12_names - # ========================================================================= - # WRITE OUTPUTS - # ========================================================================= - writeRaster( - spei1_brick, - file.path( - output_dir, - paste0("SPEI1_", aez, ".tif") - ), - format = "GTiff", - overwrite = TRUE, - NAflag = -9999 - ) + writeRaster(spei1_b, + file.path(output_dir, paste0("SPEI1_", aez, ".tif")), + format = "GTiff", overwrite = TRUE, NAflag = -9999) + writeRaster(spei3_b, + file.path(output_dir, paste0("SPEI3_", aez, ".tif")), + format = "GTiff", overwrite = TRUE, NAflag = -9999) + writeRaster(spei12_b, + file.path(output_dir, paste0("SPEI12_", aez, ".tif")), + format = "GTiff", overwrite = TRUE, NAflag = -9999) - writeRaster( - spei3_brick, - file.path( - output_dir, - paste0("SPEI3_", aez, ".tif") - ), - format = "GTiff", - overwrite = TRUE, - NAflag = -9999 - ) - - writeRaster( - spei12_brick, - file.path( - output_dir, - paste0("SPEI12_", aez, ".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_", - aez, - ".tif — ", - nlayers(spei1_brick), - " bands\n" - ) - ) - - cat( - paste0( - " SPEI3_", - aez, - ".tif — ", - nlayers(spei3_brick), - " bands\n" - ) - ) - - cat( - paste0( - " SPEI12_", - aez, - ".tif — ", - nlayers(spei12_brick), - " bands\n" - ) - ) + cat(paste0("\n Done. Output files saved to: ", output_dir, "\n")) + cat(paste0(" SPEI1_", aez, ".tif — ", nlayers(spei1_b), " bands\n")) + cat(paste0(" SPEI3_", aez, ".tif — ", nlayers(spei3_b), " bands\n")) + cat(paste0(" SPEI12_", aez, ".tif — ", nlayers(spei12_b), " bands\n")) } + # ============================================================================= # FUNCTION CALL # ============================================================================= From 4bf2d26df4f4c8b180d77c4988fd7b60b1693ad5 Mon Sep 17 00:00:00 2001 From: shiv1122prakash Date: Fri, 12 Jun 2026 15:38:47 +0530 Subject: [PATCH 025/120] clipping local compute pipeline (#1002) * clipping local compute pipeline * changes for antyodaya * changes for antyodaya * added pnachayt boundry * added pnachayt boundry * added pnachayt boundry * changes fix * changes fix * chnages for lc * chnages for lc * chnages for lc * chnages for lc * chnages for lc * chnages for lc * chnages for lc * chnages for lc * chnages for handle exception * updated queue name * update for facilities * deleted some unused file * updated file * updated file --- computing/api.py | 388 ++++++++++++++++-- computing/config_loader.py | 66 +++ computing/local_compute_helper.py | 172 +++++++- .../agroecological_space_local_compute.py | 147 +++++++ computing/misc/antyodaya_local_compute.py | 153 +++++++ computing/misc/canal_local_compute.py | 202 +++++++++ .../misc/catchment_area_local_compute.py | 120 ++++++ .../misc/digital_elevation_model_local.py | 324 +++++++++++++++ ...distancetonearestdrainage_local_compute.py | 119 ++++++ .../misc/drainage_density_local_compute.py | 188 +++++++++ .../misc/drainage_lines_local_compute.py | 149 +++++++ .../facilities_proximity_local_compute.py | 156 +++++++ computing/misc/factory_csr_local_compute.py | 176 ++++++++ computing/misc/green_credit_local_compute.py | 147 +++++++ computing/misc/lcw_conflict_local_compute.py | 147 +++++++ computing/misc/livestocks_local_compute.py | 168 ++++++++ computing/misc/mining_data_local_compute.py | 147 +++++++ .../misc/naturaldepression_local_compute.py | 119 ++++++ computing/misc/nrega_local_compute.py | 166 ++++++++ .../restoration_opportunity_local_compute.py | 274 +++++++++++++ computing/misc/river_local_compute.py | 293 +++++++++++++ .../misc/slope_percentage_local_compute.py | 117 ++++++ computing/misc/soge_vector_local_compute.py | 218 ++++++++++ computing/mws/mws_centroid_local_compute.py | 125 ++++++ .../mws/mws_connectivity_local_compute.py | 150 +++++++ computing/urls.py | 30 ++ utilities/active_loc_layer_generation.py | 110 ----- utilities/active_location_for_layer_gen.json | 232 ----------- utilities/download_gpkg_from_geoserver.py | 126 ++++++ 29 files changed, 4538 insertions(+), 391 deletions(-) create mode 100644 computing/misc/agroecological_space_local_compute.py create mode 100644 computing/misc/antyodaya_local_compute.py create mode 100644 computing/misc/canal_local_compute.py create mode 100644 computing/misc/catchment_area_local_compute.py create mode 100644 computing/misc/digital_elevation_model_local.py create mode 100644 computing/misc/distancetonearestdrainage_local_compute.py create mode 100644 computing/misc/drainage_density_local_compute.py create mode 100644 computing/misc/drainage_lines_local_compute.py create mode 100644 computing/misc/facilities_proximity_local_compute.py create mode 100644 computing/misc/factory_csr_local_compute.py create mode 100644 computing/misc/green_credit_local_compute.py create mode 100644 computing/misc/lcw_conflict_local_compute.py create mode 100644 computing/misc/livestocks_local_compute.py create mode 100644 computing/misc/mining_data_local_compute.py create mode 100644 computing/misc/naturaldepression_local_compute.py create mode 100644 computing/misc/nrega_local_compute.py create mode 100644 computing/misc/restoration_opportunity_local_compute.py create mode 100644 computing/misc/river_local_compute.py create mode 100644 computing/misc/slope_percentage_local_compute.py create mode 100644 computing/misc/soge_vector_local_compute.py create mode 100644 computing/mws/mws_centroid_local_compute.py create mode 100644 computing/mws/mws_connectivity_local_compute.py delete mode 100644 utilities/active_loc_layer_generation.py delete mode 100644 utilities/active_location_for_layer_gen.json create mode 100644 utilities/download_gpkg_from_geoserver.py diff --git a/computing/api.py b/computing/api.py index 12fd3e86..3b78db0e 100644 --- a/computing/api.py +++ b/computing/api.py @@ -29,7 +29,12 @@ vectorise_change_detection as vectorise_change_detection_local_task, ) from computing.layer_dependency.layer_generation_in_order import layer_generate_map -from computing.misc.drainage_lines import clip_drainage_lines +from computing.misc.drainage_lines import ( + clip_drainage_lines as clip_drainage_lines_gee_task, +) +from computing.misc.drainage_lines_local_compute import ( + clip_drainage_lines as clip_drainage_lines_local_task, +) from computing.STAC_specs.stac_collection import STACConfig, sanitize_text from nrm_app.settings import BASE_DIR, LOCAL_COMPUTE_API_URL from utilities.auth_check_decorator import api_security_check @@ -162,13 +167,74 @@ from .misc.distancetonearestdrainage import generate_distance_to_nearest_drainage_line from .misc.catchment_area import generate_catchment_area_singleflow from .zoi_layers.zoi import generate_zoi -from .mws.mws_connectivity import generate_mws_connectivity_data +from .mws.mws_connectivity import ( + generate_mws_connectivity_data as generate_mws_connectivity_gee_task, +) +from .mws.mws_connectivity_local_compute import ( + mws_connectivity_vector as generate_mws_connectivity_local_task, +) from .mws.mws_centroid import generate_mws_centroid_data from .misc.facilities_proximity import generate_facilities_proximity_task from .misc.antyodaya import generate_antyodaya_layer_task from .misc.digital_elevation_model import generate_dem_layer from .misc.canal_layer import canal_vector from .STAC_specs.stac_collection import generate_stac_collection_task +from .mws.mws_centroid_local_compute import ( + generate_mws_centroid_data_local as generate_mws_centroid_data_local_task, +) +from .misc.facilities_proximity_local_compute import ( + generate_facilities_proximity_local as generate_facilities_proximity_local_task, +) +from .misc.digital_elevation_model_local import ( + generate_febdem_raster_vector_clip as generate_febdem_raster_vector_clip_local_task, +) +from .misc.canal_local_compute import canal_vector as canal_vector_local_task +from .misc.river_local_compute import river_vector as river_vector_local_task +from .misc.drainage_density_local_compute import ( + drainage_density as drainage_density_vector_local_task, +) +from .misc.restoration_opportunity_local_compute import ( + generate_restoration_opportunity_local as generate_restoration_opportunity_local_task, +) +from .misc.soge_vector_local_compute import ( + generate_soge_vector_local as generate_soge_vector_local_task, +) +from .misc.nrega_local_compute import ( + generate_nrega_data_local as generate_nrega_data_local_task, +) +from .misc.catchment_area_local_compute import ( + generate_catchment_area_singleflow_local as generate_catchment_area_singleflow_local_task, +) +from .misc.distancetonearestdrainage_local_compute import ( + generate_distance_to_nearest_drainage_line_local as generate_distance_to_nearest_drainage_line_local_task, +) +from .misc.naturaldepression_local_compute import ( + generate_natural_depression_data_local as generate_natural_depression_data_local_task, +) +from .misc.slope_percentage_local_compute import ( + generate_slope_percentage_data_local as generate_slope_percentage_data_local_task, +) +from .misc.mining_data_local_compute import ( + generate_mining_data_local as generate_mining_data_local_task, +) +from .misc.green_credit_local_compute import ( + generate_green_credit_data_local as generate_green_credit_data_local_task, +) +from .misc.factory_csr_local_compute import ( + generate_factory_csr_data_local as generate_factory_csr_data_local_task, +) +from .misc.agroecological_space_local_compute import ( + generate_agroecological_data_local as generate_agroecological_data_local_task, +) +from .misc.lcw_conflict_local_compute import ( + generate_lcw_conflict_data_local as generate_lcw_conflict_data_local_task, +) +from .misc.antyodaya_local_compute import ( + generate_antyodaya_data_local as generate_antyodaya_data_local_task, +) +from .misc.livestocks_local_compute import ( + generate_livestocks_data_local as generate_livestocks_data_local_task, +) @api_security_check(allowed_methods="POST") @schema(None) @@ -199,8 +265,20 @@ def generate_nrega_layer(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - clip_nrega_district_block.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + clip_nrega_district_block, + generate_nrega_data_local_task, + ) + task.apply_async( + kwargs={ + "state": state, + "district": district, + "block": block, + "gee_account_id": gee_account_id, + }, + queue="nrm", ) return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK @@ -219,7 +297,13 @@ def generate_drainage_layer(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - clip_drainage_lines.apply_async( + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + clip_drainage_lines_gee_task, + clip_drainage_lines_local_task, + ) + task.apply_async( kwargs={ "state": state, "district": district, @@ -231,6 +315,9 @@ def generate_drainage_layer(request): return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) + except ValueError as e: + print("Invalid request in generate_drainage_layer api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in generate_drainage_layer api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -1127,9 +1214,13 @@ def restoration_opportunity(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_restoration_opportunity.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_restoration_opportunity, + generate_restoration_opportunity_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "restoration_opportunity task initiated"}, status=status.HTTP_200_OK, @@ -1217,9 +1308,13 @@ def soge_vector(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_soge_vector.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_soge_vector, + generate_soge_vector_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "SOGE vector task initiated"}, status=status.HTTP_200_OK, @@ -1486,9 +1581,13 @@ def generate_lcw(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_lcw_conflict_data.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_lcw_conflict_data, + generate_lcw_conflict_data_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) @@ -1506,9 +1605,13 @@ def generate_agroecological(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_agroecological_data.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_agroecological_data, + generate_agroecological_data_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) @@ -1526,9 +1629,13 @@ def generate_factory_csr(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_factory_csr_data.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_factory_csr_data, + generate_factory_csr_data_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) @@ -1546,9 +1653,13 @@ def generate_green_credit(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_green_credit_data.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_green_credit_data, + generate_green_credit_data_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) @@ -1566,9 +1677,13 @@ def generate_mining(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_mining_data.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_mining_data, + generate_mining_data_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) @@ -1599,9 +1714,13 @@ def generate_natural_depression(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_natural_depression_data.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_natural_depression_data, + generate_natural_depression_data_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) @@ -1619,9 +1738,13 @@ def generate_distance_nearest_upstream_DL(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_distance_to_nearest_drainage_line.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_distance_to_nearest_drainage_line, + generate_distance_to_nearest_drainage_line_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) @@ -1639,9 +1762,13 @@ def generate_catchment_area_SF(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_catchment_area_singleflow.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_catchment_area_singleflow, + generate_catchment_area_singleflow_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) @@ -1659,9 +1786,13 @@ def generate_slope_percentage(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_slope_percentage_data.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_slope_percentage_data, + generate_slope_percentage_data_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) @@ -1736,20 +1867,36 @@ def generate_zoi_to_gee(request): @api_view(["POST"]) @schema(None) def generate_mws_connectivity(request): - print("Inside generate_mws_connectivity_to_gee API.") + print("Inside generate_mws_connectivity API.") try: state = request.data.get("state").lower() district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_mws_connectivity_data.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_mws_connectivity_gee_task, + generate_mws_connectivity_local_task, + ) + task.apply_async( + kwargs={ + "state": state, + "district": district, + "block": block, + "gee_account_id": gee_account_id, + }, + queue="nrm", ) return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) + except ValueError as e: + print("Invalid request in generate_mws_connectivity api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: - print("Exception in generate_mws_connectivity_to_gee api :: ", e) + print("Exception in generate_mws_connectivity api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -1762,9 +1909,13 @@ def generate_mws_centroid(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_mws_centroid_data.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_mws_centroid_data, + generate_mws_centroid_data_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) @@ -1782,9 +1933,13 @@ def generate_facilities_proximity(request): district = request.data.get("district").lower() block = request.data.get("block").lower() gee_account_id = request.data.get("gee_account_id") - generate_facilities_proximity_task.apply_async( - args=[state, district, block, gee_account_id], queue="nrm" + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + generate_facilities_proximity_task, + generate_facilities_proximity_local_task, ) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm1") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) @@ -2245,3 +2400,166 @@ def rainfall_resilience_resistance(request): except Exception as e: print("Exception in rainfall_resilience_resistance api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@schema(None) +def generate_fabdem_raster_vector(request): + print("Inside generate DEM raster layer API.") + try: + state = request.data.get("state").lower() + district = request.data.get("district").lower() + block = request.data.get("block").lower() + gee_account_id = request.data.get("gee_account_id") + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + None, + generate_febdem_raster_vector_clip_local_task, + ) + if task is None: + return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") + return Response( + {"Success": "Successfully initiated"}, status=status.HTTP_200_OK + ) + except Exception as e: + print(f"Exception in generate DEM raster layer for {district} - {block}:: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@schema(None) +def generate_canal_vector(request): + print("Inside generate canal vector layer API.") + try: + state = request.data.get("state").lower() + district = request.data.get("district").lower() + block = request.data.get("block").lower() + gee_account_id = request.data.get("gee_account_id") + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + None, + canal_vector_local_task, + ) + if task is None: + return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") + return Response( + {"Success": f"Successfully initiated {compute} task"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + print( + f"Exception in generate canal vector layer for {district} - {block}:: ", e + ) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@schema(None) +def generate_river_data(request): + print("Inside river data API.") + try: + state = request.data.get("state").lower() + district = request.data.get("district").lower() + block = request.data.get("block").lower() + gee_account_id = request.data.get("gee_account_id") + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + None, + river_vector_local_task, + ) + if task is None: + return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") + return Response( + {"Success": f"Successfully initiated {compute} task"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + print("Exception in river data api :: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@schema(None) +def generate_drainage_density_data(request): + print("Inside river data API.") + try: + state = request.data.get("state").lower() + district = request.data.get("district").lower() + block = request.data.get("block").lower() + gee_account_id = request.data.get("gee_account_id") + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + None, + drainage_density_vector_local_task, + ) + if task is None: + return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") + return Response( + {"Success": f"Successfully initiated {compute} task"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + print("Exception in river data api :: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@schema(None) +def generate_antyodaya(request): + print("Inside generate_antyodaya API.") + try: + state = request.data.get("state").lower() + district = request.data.get("district").lower() + block = request.data.get("block").lower() + gee_account_id = request.data.get("gee_account_id") + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + None, + generate_antyodaya_data_local_task, + ) + if task is None: + return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") + return Response( + {"Success": f"Successfully initiated {compute} task"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + print("Exception in generate_antyodaya api :: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@schema(None) +def generate_livestocks(request): + print("Inside generate_livestocks API.") + try: + state = request.data.get("state").lower() + district = request.data.get("district").lower() + block = request.data.get("block").lower() + gee_account_id = request.data.get("gee_account_id") + compute = _get_compute_mode(request) + task = _select_compute_task( + compute, + None, + generate_livestocks_data_local_task, + ) + if task is None: + return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") + return Response( + {"Success": f"Successfully initiated {compute} task"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + print("Exception in generate_livestocks api :: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/computing/config_loader.py b/computing/config_loader.py index fc7fb5e1..41df82d9 100644 --- a/computing/config_loader.py +++ b/computing/config_loader.py @@ -103,3 +103,69 @@ def _output_entry(module: str, index: int = 0) -> dict: SWB_VECTOR_OUTPUT_DIR: Path = _abs( _output_entry("surface_water_bodies", 0)["path"] ) + + +PAN_INDIA_DRAINAGE_LINES_GPKG_PATH = ( + PROJECT_ROOT / "data/base_layers/drainage_lines_pan_india.gpkg" +) + +PAN_INDIA_DRAINAGE_LINES_PATH = PROJECT_ROOT / "data/layers/drainage_lines/Pan_India_drainage_lines.gpkg" +LOCAL_DRAINAGE_LINES_OUTPUT = PROJECT_ROOT / "data/layers/drainage_lines/drainage_lines_local" + +LOCAL_DRAINAGE_DENSITY_OUTPUT = PROJECT_ROOT / "data/drainage_density" + +PAN_INDIA_CANAL_PATH = PROJECT_ROOT / "data/canal/Canal_pan_india.geojson" +LOCAL_CANAL_OUTPUT = PROJECT_ROOT / "data/canal/canal_local" + +PAN_INDIA_AGROECOLOGICAL_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_agroecological_farming.geojson" +LOCAL_AGROECOLOGICAL_OUTPUT = PROJECT_ROOT / "data/layers/agroecological" + +PAN_INDIA_LCW_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_lcw_conflict.geojson" +LOCAL_LCW_OUTPUT = PROJECT_ROOT / "data/layers/lcw_conflict" + +PAN_INDIA_SOGE_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_SOGE_2020.geojson" +LOCAL_SOGE_OUTPUT = PROJECT_ROOT / "data/layers/SOGE_vector" + +PAN_INDIA_FACTORY_CSR_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_factory_csr.geojson" +LOCAL_FACTORY_CSR_OUTPUT = PROJECT_ROOT / "data/layers/factory_csr" + +PAN_INDIA_GREEN_CREDIT_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_green_credit.geojson" +LOCAL_GREEN_CREDIT_OUTPUT = PROJECT_ROOT / "data/layers/green_credit" + +PAN_INDIA_MINING_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_mining.geojson" +LOCAL_MINING_OUTPUT = PROJECT_ROOT / "data/layers/mining" + +PAN_INDIA_NATURALDEPRESSION_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_natural_depression.tif" +LOCAL_NATURALDEPRESSION_OUTPUT = PROJECT_ROOT / "data/layers/natural_depression" + +PAN_INDIA_DISTANCETONEARESTDRAINAGE_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_distance_to_nearest_drainage.tif" +LOCAL_DISTANCETONEARESTDRAINAGE_OUTPUT = PROJECT_ROOT / "data/layers/distance_nearest_upstream_DL" + +PAN_INDIA_FACILITIES_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_facilities_polygon.geojson" +LOCAL_FACILITIES_OUTPUT = PROJECT_ROOT / "data/layers/facilities" +PAN_INDIA_CATCHMENT_AREA_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_catchment_area.tif" +LOCAL_CATCHMENT_AREA_OUTPUT = PROJECT_ROOT / "data/layers/catchment_area_singleflow" + +PAN_INDIA_SLOPE_PERCENTAGE_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_slope_percentage.tif" +LOCAL_SLOPE_PERCENTAGE_OUTPUT = PROJECT_ROOT / "data/layers/slope_percentage" + +PAN_INDIA_MWS_CONNECTIVITY_PATH = PROJECT_ROOT / "data/layers/mws_connectivity/Pan_India_mws_connectivity.geojson" +LOCAL_MWS_CONNECTIVITY_OUTPUT = PROJECT_ROOT / "data/layers/mws_connectivity/mws_connectivity_local" + +LOCAL_MWS_CENTROID_OUTPUT = PROJECT_ROOT / "data/layers/mws_centroid" + +NREGA_LOCAL_OUTPUT = PROJECT_ROOT / "data/layers/nrega_assets" +PAN_INDIA_RESTORATION_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_WRI_Restoration.tif" +LOCAL_RESTORATION_OUTPUT = PROJECT_ROOT / "data/layers/restoration_opportunity" + +PAN_INDIA_RIVER_PATH = PROJECT_ROOT / "data/river/River_pan_india.geojson" +LOCAL_RIVER_OUTPUT = PROJECT_ROOT / "data/river/river_local" + +PAN_INDIA_FABDEM_PATH = PROJECT_ROOT / "data/fabdem/fabdem_pan_india.tif" +LOCAL_FABDEM_OUTPUT = PROJECT_ROOT / "data/fabdem/fabdem_local" + +PAN_INDIA_ANTYODAYA_2020 = PROJECT_ROOT / "data/base_layers/pan_india_antyodaya_2020.gpkg" +LOCAL_ANTYODAYA_2020_OUTPUT = PROJECT_ROOT / "data/antyodaya/output/antyodaya_local" + +PAN_INDIA_LIVESTOCKS = PROJECT_ROOT / "data/base_layers/pan_india_livestock.gpkg" +LOCAL_LIVESTOCKS_OUTPUT = PROJECT_ROOT / "data/livestock/output/livestock_local" \ No newline at end of file diff --git a/computing/local_compute_helper.py b/computing/local_compute_helper.py index 07c0bf3b..7353f472 100644 --- a/computing/local_compute_helper.py +++ b/computing/local_compute_helper.py @@ -21,6 +21,9 @@ PROJECT_ROOT, TERRAIN_RASTER_PATH, ) +from utilities.download_gpkg_from_geoserver import generate_gpkg + +PRECOMPUTED_PANCHAYAT_DIR = PROJECT_ROOT / "data/base_layers/village_boundaries" PRECOMPUTED_ROI_EXTENSIONS = (".gpkg", ".geojson") VALID_COMPUTE_TYPES = {"gee", "local"} @@ -99,19 +102,60 @@ def resolve_precomputed_vector_file( ) +def resolve_precomputed_panchayat_vector_file( + state, + district, + block, + precomputed_roi_dir=PRECOMPUTED_PANCHAYAT_DIR, + extensions=PRECOMPUTED_ROI_EXTENSIONS, + missing_file_label="Precomputed vector file", +): + roi_dir = Path(precomputed_roi_dir or PRECOMPUTED_PANCHAYAT_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", - ) + try: + 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", + ) + + except FileNotFoundError: + print(f"Precomputed watershed not found for " f"{state}/{district}/{block}") + generate_gpkg(state=state, district=district, block=block, workspace="mws") + watershed_path = resolve_precomputed_vector_file( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + missing_file_label="Generated watershed boundary file not found", + ) + watersheds_gdf = read_validated_vector_file( watershed_path, f"Precomputed watershed file has no valid geometries: {watershed_path}", @@ -120,19 +164,72 @@ def load_precomputed_watersheds( return watersheds_gdf, str(watershed_path) +def load_precomputed_panchayat( + state, + district, + block, + precomputed_roi_dir=PRECOMPUTED_PANCHAYAT_DIR, +): + try: + panchayat_path = resolve_precomputed_panchayat_vector_file( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + missing_file_label="Precomputed panchayat boundary file", + ) + + except FileNotFoundError: + print(f"Precomputed panchayat not found for " f"{state}/{district}/{block}") + generate_gpkg( + state=state, + district=district, + block=block, + workspace="panchayat_boundaries", + ) + + panchayat_path = resolve_precomputed_panchayat_vector_file( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + missing_file_label="Generated panchayat boundary file not found", + ) + + panchayat_gdf = read_validated_vector_file( + panchayat_path, + f"Precomputed panchayat file has no valid geometries: {panchayat_path}", + ) + + print(f"Loaded panchayat boundaries: {panchayat_path}") + return panchayat_gdf, str(panchayat_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", - ) + try: + 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", + ) + except FileNotFoundError: + print(f"Precomputed ROI not found for {state}/{district}/{block}. Downloading...") + generate_gpkg(state=state, district=district, block=block, workspace="mws") + roi_path = resolve_precomputed_vector_file( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + missing_file_label="Generated tehsil watershed file", + ) + roi_gdf = read_validated_vector_file( roi_path, f"Precomputed ROI file has no valid geometries: {roi_path}", @@ -905,3 +1002,50 @@ def get_compute_mode(request, default="local"): def select_compute_task(compute, gee_task, local_task): return gee_task if compute == "gee" else local_task + + +def read_geojson_with_string_coords(path, mask_gdf): + """ + Safely reads a GeoJSON file with malformed string coordinates that cause fiona to crash. + Pre-filters using the bounding box of mask_gdf to prevent high memory usage. + """ + import json + with open(path, 'r') as f: + data = json.load(f) + + def _convert_coords(coords): + if not coords: + return coords + if isinstance(coords[0], (list, tuple)): + return [_convert_coords(c) for c in coords] + return [float(c) for c in coords] + + minx, miny, maxx, maxy = mask_gdf.total_bounds + buffer_deg = 0.05 + + def _is_roughly_in_bounds(coords): + if not coords: return False + if isinstance(coords[0], (list, tuple)): + for c in coords: + if _is_roughly_in_bounds(c): return True + return False + else: + try: + x, y = float(coords[0]), float(coords[1]) + return (minx - buffer_deg <= x <= maxx + buffer_deg) and (miny - buffer_deg <= y <= maxy + buffer_deg) + except Exception: + return True + + filtered_features = [] + for feature in data.get("features", []): + geom = feature.get("geometry") + if geom and "coordinates" in geom: + if _is_roughly_in_bounds(geom["coordinates"]): + try: + geom["coordinates"] = _convert_coords(geom["coordinates"]) + filtered_features.append(feature) + except Exception: + pass + + return gpd.GeoDataFrame.from_features(filtered_features, crs="EPSG:4326") + diff --git a/computing/misc/agroecological_space_local_compute.py b/computing/misc/agroecological_space_local_compute.py new file mode 100644 index 00000000..d6a8fc2f --- /dev/null +++ b/computing/misc/agroecological_space_local_compute.py @@ -0,0 +1,147 @@ +import os +import geopandas as gpd + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_vector_path, + load_precomputed_watersheds, + read_validated_vector_file, + write_vector_output, + validate_geometry, +) +from computing.config_loader import ( + PAN_INDIA_AGROECOLOGICAL_PATH, + LOCAL_AGROECOLOGICAL_OUTPUT, +) + +GEOSERVER_WORKSPACE = "agroecological" + + +def _compute_agroecological_for_watersheds(watersheds_gdf, agro_gdf): + """ + Spatially joins Agroecological features with watershed polygons. + Equivalent to the GEE Join.saveFirst() with spatial intersection. + """ + if agro_gdf.empty: + return agro_gdf + + if watersheds_gdf.crs and agro_gdf.crs and watersheds_gdf.crs != agro_gdf.crs: + agro_gdf = agro_gdf.to_crs(watersheds_gdf.crs) + + # We only need the 'uid' from watersheds + target_watersheds = watersheds_gdf[["uid", "geometry"]].copy() + + joined_gdf = gpd.sjoin( + agro_gdf, + target_watersheds, + how="inner", + predicate="intersects" + ) + + # To mimic ee.Join.saveFirst(), drop duplicates based on the original feature index + joined_gdf = joined_gdf[~joined_gdf.index.duplicated(keep="first")] + + if "index_right" in joined_gdf.columns: + joined_gdf = joined_gdf.drop(columns=["index_right"]) + + return joined_gdf + + +@app.task(bind=True) +def generate_agroecological_data_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_agroecological" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name = f"{valid_gee_text(asset_suffix).lower()}_agroecological" + watersheds_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") + print(f"ROI source: {roi_path}") + + if not os.path.exists(PAN_INDIA_AGROECOLOGICAL_PATH): + raise FileNotFoundError(f"PAN INDIA Agroecological file not found at {PAN_INDIA_AGROECOLOGICAL_PATH}") + + print("Loading Agroecological data overlapping ROI...") + agro_gdf = gpd.read_file(PAN_INDIA_AGROECOLOGICAL_PATH, mask=watersheds_gdf) + agro_gdf = validate_geometry(agro_gdf) + if agro_gdf.empty: + raise ValueError("PAN INDIA Agroecological file has no valid geometries overlapping ROI") + print(f"Loaded {len(agro_gdf)} Agroecological features") + + result_gdf = _compute_agroecological_for_watersheds( + watersheds_gdf=watersheds_gdf, + agro_gdf=agro_gdf, + ) + print(f"Final valid Agroecological features after spatial join: {len(result_gdf)}") + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_AGROECOLOGICAL_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local Agroecological vector: {asset_id}") + + layer_at_geoserver = False + + 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 geoserver_response and geoserver_response.get("status_code") in (200, 201): + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Agroecological", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for Agroecological vector") + + return layer_at_geoserver + + + diff --git a/computing/misc/antyodaya_local_compute.py b/computing/misc/antyodaya_local_compute.py new file mode 100644 index 00000000..9911ba18 --- /dev/null +++ b/computing/misc/antyodaya_local_compute.py @@ -0,0 +1,153 @@ +"""Mission Antyodaya tehsil clipping from the pan-India local GeoPackage. + +Runtime contract: + +- request names may be spaces or snake_case; stored names are resolved from the + GeoPackage before clipping +- local output is always written first +- GeoServer publish is enabled by default and can be disabled per request; + failure is reported without breaking local generation + +The source GeoPackage should have an attribute index on +``(state_name, district_name, TEHSIL)``. The task creates it once if missing, +which keeps reads sub-second for normal tehsil clips on the local server. +""" + +import os +import geopandas as gpd + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_vector_path, + load_precomputed_watersheds, + read_validated_vector_file, + write_vector_output, + validate_geometry, +) +from computing.config_loader import ( + PAN_INDIA_ANTYODAYA_2020, + LOCAL_ANTYODAYA_2020_OUTPUT, +) + +GEOSERVER_WORKSPACE = "antyodaya_2020" + + +def _compute_antyodaya_for_watersheds(watersheds_gdf, antyodaya_gdf): + """ + Spatially filters Antyodaya features with watershed/ROI boundaries. + """ + if antyodaya_gdf.empty: + return antyodaya_gdf + + if watersheds_gdf.crs and antyodaya_gdf.crs and watersheds_gdf.crs != antyodaya_gdf.crs: + antyodaya_gdf = antyodaya_gdf.to_crs(watersheds_gdf.crs) + + outer_boundary = watersheds_gdf.geometry.unary_union + + # Precise intersection check + antyodaya_in_roi = antyodaya_gdf[antyodaya_gdf.intersects(outer_boundary)].copy() + + # Final cleanup + antyodaya_in_roi = antyodaya_in_roi[~antyodaya_in_roi.geometry.is_empty] + antyodaya_in_roi = antyodaya_in_roi[antyodaya_in_roi.geometry.is_valid] + antyodaya_in_roi = antyodaya_in_roi[antyodaya_in_roi.geometry.notna()] + + return antyodaya_in_roi + + +@app.task(bind=True) +def generate_antyodaya_data_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name = f"antyodaya20_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name = f"antyodaya20_{valid_gee_text(asset_suffix).lower()}" + watersheds_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") + print(f"ROI source: {roi_path}") + + if not os.path.exists(PAN_INDIA_ANTYODAYA_2020): + raise FileNotFoundError(f"PAN INDIA Antyodaya file not found at {PAN_INDIA_ANTYODAYA_2020}") + + print("Loading Antyodaya data overlapping ROI...") + antyodaya_gdf = gpd.read_file(PAN_INDIA_ANTYODAYA_2020, mask=watersheds_gdf) + antyodaya_gdf = validate_geometry(antyodaya_gdf) + if antyodaya_gdf.empty: + print("Warning: PAN INDIA Antyodaya file has no valid geometries overlapping ROI") + else: + print(f"Loaded {len(antyodaya_gdf)} Antyodaya features") + + result_gdf = _compute_antyodaya_for_watersheds( + watersheds_gdf=watersheds_gdf, + antyodaya_gdf=antyodaya_gdf, + ) + print(f"Final valid Antyodaya features after spatial filter: {len(result_gdf)}") + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_ANTYODAYA_2020_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local Antyodaya vector: {asset_id}") + + layer_at_geoserver = False + + 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 geoserver_response and geoserver_response.get("status_code") in (200, 201): + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Antyodaya 2020", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for Antyodaya vector") + + return layer_at_geoserver diff --git a/computing/misc/canal_local_compute.py b/computing/misc/canal_local_compute.py new file mode 100644 index 00000000..41ee2dc3 --- /dev/null +++ b/computing/misc/canal_local_compute.py @@ -0,0 +1,202 @@ +import os +import json +import datetime +import pandas as pd +import geopandas as gpd +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.local_compute_helper import ( + PROJECT_ROOT, + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + 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, + fix_invalid_geometry_in_gdf, +) + +from computing.config_loader import ( + PAN_INDIA_CANAL_PATH, + LOCAL_CANAL_OUTPUT, +) + +GEOSERVER_WORKSPACE = "canal" + + +def _compute_canal_properties_for_watersheds(watersheds_gdf, canals_gdf): + watersheds_gdf = validate_geometry(watersheds_gdf) + canals_gdf = validate_geometry(canals_gdf) + + watersheds_gdf = watersheds_gdf.reset_index(drop=True) + outer_boundary = watersheds_gdf.geometry.unary_union + + canals_in_roi = canals_gdf[canals_gdf.intersects(outer_boundary)].copy() + + if canals_in_roi.empty: + print("No canals found within the outer boundary.") + return canals_in_roi + + # For each canal, collect every watershed it touches + matched_joined = gpd.sjoin( + canals_in_roi, + watersheds_gdf[["uid", "area_in_ha", "geometry"]], + how="inner", + predicate="intersects", + ) + + #Identify Gap Canals (no watershed match) + matched_indices = matched_joined.index.unique() + gap_canals = canals_in_roi.loc[~canals_in_roi.index.isin(matched_indices)].copy() + + result_segments = [] + + # Expand matched canals → clip to individual watersheds + if not matched_joined.empty: + def clip_matched(row): + watershed_geom = watersheds_gdf.geometry.iloc[row.index_right] + return row.geometry.intersection(watershed_geom) + + matched_joined["geometry"] = matched_joined.apply(clip_matched, axis=1) + # Keep only LineStrings + matched_joined = matched_joined[ + matched_joined.geometry.type.isin(["LineString", "MultiLineString"]) + ] + result_segments.append(matched_joined) + + #Handle gap canals → clip to outer ROI boundary + if not gap_canals.empty: + gap_canals["uid"] = "" + gap_canals["area_in_ha"] = "" + + def clip_gap(row): + return row.geometry.intersection(outer_boundary) + + gap_canals["geometry"] = gap_canals.apply(clip_gap, axis=1) + gap_canals = gap_canals[ + gap_canals.geometry.type.isin(["LineString", "MultiLineString"]) + ] + result_segments.append(gap_canals) + + if not result_segments: + return gpd.GeoDataFrame(columns=canals_gdf.columns, crs=canals_gdf.crs) + + # Merge, Clean and Fix Geometries + final_gdf = gpd.GeoDataFrame(pd.concat(result_segments, ignore_index=True), crs=canals_gdf.crs) + final_gdf["uid"] = final_gdf["uid"].astype(str) + final_gdf["area_in_ha"] = final_gdf["area_in_ha"].astype(str) + + rename_cols = {} + if "st_length(" in final_gdf.columns: + rename_cols["st_length("] = "st_length" + + if rename_cols: + final_gdf = final_gdf.rename(columns=rename_cols) + + final_gdf = final_gdf[~final_gdf.geometry.is_empty] + final_gdf = fix_invalid_geometry_in_gdf(final_gdf) + + if "index_right" in final_gdf.columns: + final_gdf = final_gdf.drop(columns=["index_right"]) + + return final_gdf + + +@app.task(bind=True) +def canal_vector( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi=None, + asset_folder_list=None, + app_type="MWS", + gee_account_id=None, + canal_vector_path=PAN_INDIA_CANAL_PATH, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_canal_vector" + 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}") + 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}_canal_vector".lower() + watersheds_gdf = read_validated_vector_file( + roi, + f"ROI file has no valid geometries: {roi}", + ) + print(f"ROI source: {roi}") + + if not os.path.exists(canal_vector_path): + raise FileNotFoundError(f"Canal source file not found: {canal_vector_path}") + + print(f"Loading canal source: {canal_vector_path}") + canals_gdf = read_validated_vector_file( + canal_vector_path, + f"Canal source file has no valid geometries: {canal_vector_path}", + ) + + result_gdf = _compute_canal_properties_for_watersheds( + watersheds_gdf=watersheds_gdf, + canals_gdf=canals_gdf, + ) + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_CANAL_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local canal 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 sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Canal Vector", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for canal vector") + + return True + + diff --git a/computing/misc/catchment_area_local_compute.py b/computing/misc/catchment_area_local_compute.py new file mode 100644 index 00000000..67443f97 --- /dev/null +++ b/computing/misc/catchment_area_local_compute.py @@ -0,0 +1,120 @@ +import os +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_raster_path, + load_precomputed_watersheds, + read_validated_vector_file, + clip_raster_with_roi, + push_local_raster_to_geoserver, +) +from computing.STAC_specs import generate_STAC_layerwise +from computing.config_loader import ( + PAN_INDIA_CATCHMENT_AREA_PATH, + LOCAL_CATCHMENT_AREA_OUTPUT, +) + +GEOSERVER_WORKSPACE = "catchment_area_singleflow" +CATCHMENT_AREA_STYLE_NAME = "catchment_area_singleflow" + + +@app.task(bind=True) +def generate_catchment_area_singleflow_local( + self, + state=None, + district=None, + block=None, + gee_account_id=None, + proj_id=None, + roi_path=None, + asset_suffix=None, + asset_folder=None, + app_type="MWS", + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name_base = f"catchment_area_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_raster" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name_base = f"catchment_area_{asset_suffix}_raster".lower() + watersheds_gdf = read_validated_vector_file( + roi_path, f"Invalid ROI file: {roi_path}" + ) + print(f"ROI source: {roi_path}") + + # Raster Processing + raster_layer_name = f"{layer_name_base}" + output_raster_path = build_output_raster_path( + layer_name=raster_layer_name, + output_base_dir=LOCAL_CATCHMENT_AREA_OUTPUT, + state=state, + district=district, + block=block, + ) + + print("Clipping Catchment Area raster...") + clipped_raster_path = clip_raster_with_roi( + roi_gdf=watersheds_gdf, + raster_path=PAN_INDIA_CATCHMENT_AREA_PATH, + output_path=output_raster_path, + raster_label="Catchment Area Raster", + ) + print(f"Saved clipped Catchment Area raster to: {clipped_raster_path}") + + layer_at_geoserver = False + if push_to_geoserver: + push_local_raster_to_geoserver( + file_path=str(clipped_raster_path), + layer_name=raster_layer_name, + workspace=GEOSERVER_WORKSPACE, + style_name=CATCHMENT_AREA_STYLE_NAME, + ) + print(f"Pushed raster {raster_layer_name} to GeoServer.") + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + raster_layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=raster_layer_name, + asset_id=str(clipped_raster_path), + dataset_name="Catchment Area", + misc={"is_generated_locally": True}, + ) + if raster_layer_id: + update_layer_sync_status(layer_id=raster_layer_id, sync_to_geoserver=True) + print(f"Database record updated for raster layer_id: {raster_layer_id}") + + # STAC Specs for Raster + try: + layer_STAC_generated = generate_STAC_layerwise.generate_raster_stac( + state=state, + district=district, + block=block, + layer_name="catchment_area_raster", + ) + update_layer_sync_status( + layer_id=raster_layer_id, + is_stac_specs_generated=layer_STAC_generated, + ) + print("STAC metadata updated for Catchment Area raster") + except Exception as e: + print(f"Error generating STAC for raster: {e}") + + return layer_at_geoserver diff --git a/computing/misc/digital_elevation_model_local.py b/computing/misc/digital_elevation_model_local.py new file mode 100644 index 00000000..53e737b5 --- /dev/null +++ b/computing/misc/digital_elevation_model_local.py @@ -0,0 +1,324 @@ +import os +import rasterio +from rasterio.mask import mask +from shapely.geometry import mapping + +from utilities.gee_utils import valid_gee_text +from nrm_app.celery import app +from computing.utils import push_shape_to_geoserver + +from computing.local_compute_helper import ( + PROJECT_ROOT, + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + build_output_raster_path, + build_output_vector_path, + get_union_geometry, + load_precomputed_roi, + load_precomputed_watersheds, + push_local_raster_to_geoserver, + read_validated_vector_file, + write_vector_output, +) + + +from computing.config_loader import ( + PAN_INDIA_FABDEM_PATH, + LOCAL_FABDEM_OUTPUT, +) + +GEOSERVER_STYLE = None +GEOSERVER_WORKSPACE = "dem" +ZERO_NODATA = -9999 + + +def _clip_fabdem_with_roi(roi_gdf, output_path): + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + with rasterio.open(PAN_INDIA_FABDEM_PATH) as src: + raster_crs = src.crs + + # Reproject ROI to raster CRS — use pyproj data dir to avoid broken PROJ + if roi_gdf.crs != raster_crs: + roi_in_raster_crs = roi_gdf.to_crs("EPSG:3857") + else: + roi_in_raster_crs = roi_gdf + + roi_union = get_union_geometry(roi_in_raster_crs) + if roi_union is None or roi_union.is_empty: + raise ValueError("ROI union geometry is empty — cannot clip FABDEM.") + + roi_shape = mapping(roi_union) + + clipped_array, clipped_transform = mask( + src, + shapes=[roi_shape], + crop=True, + filled=True, + nodata=ZERO_NODATA, + ) + out_meta = src.meta.copy() + out_meta.update( + { + "driver": "GTiff", + "height": clipped_array.shape[1], + "width": clipped_array.shape[2], + "transform": clipped_transform, + "nodata": ZERO_NODATA, + "compress": "lzw", + } + ) + + with rasterio.open(output_path, "w", **out_meta) as dst: + dst.write(clipped_array) + + print(f"Local clipped FABDEM raster written to: {output_path}") + return str(output_path) + + +def run_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(district.lower())}_{valid_gee_text(block.lower())}_dem_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}_dem_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_FABDEM_OUTPUT, + state=state, + district=district, + block=block, + ) + + clipped_raster_path = _clip_fabdem_with_roi( + roi_gdf=roi_gdf, + output_path=str(output_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, None + + 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="DEM Raster", + algorithm="FABDEM", + algorithm_version="1.0", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated") + + return True, clipped_raster_path + + +def _compute_watershed_dem_stats(watersheds_gdf, raster_path): + from rasterstats import zonal_stats + + with rasterio.open(raster_path) as src: + raster_crs = src.crs + pixel_area_ha = (abs(src.res[0]) * abs(src.res[1])) / 10_000.0 + + # Reproject watersheds to raster CRS for accurate zonal stats + watersheds_for_stats = ( + watersheds_gdf + if watersheds_gdf.crs == raster_crs + else watersheds_gdf.to_crs(raster_crs.to_epsg()) + ) + + stats = zonal_stats( + watersheds_for_stats, + raster_path, + stats=["min", "max", "mean", "count"], + nodata=ZERO_NODATA, + all_touched=False, + ) + + result_gdf = watersheds_gdf.copy() + result_gdf["min_elevation"] = [s.get("min") for s in stats] + result_gdf["max_elevation"] = [s.get("max") for s in stats] + result_gdf["mean_elevation"] = [s.get("mean") for s in stats] + + keep_cols = [ + "uid", + "min_elevation", + "max_elevation", + "mean_elevation", + "geometry", + ] + return result_gdf[[c for c in keep_cols if c in result_gdf.columns]] + + +def run_vector_fabdem_local( + state=None, + district=None, + block=None, + asset_suffix=None, + raster_path=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=False, +): + if not raster_path: + raise ValueError( + "`raster_path` is required for vector stage — pass Stage 1 output." + ) + + if state and district and block: + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_dem_vector" + 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}") + else: + if not asset_suffix: + raise ValueError( + "For non state/district/block runs, `asset_suffix` is required." + ) + layer_name = f"{asset_suffix}_dem_vector".lower() + 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}") + + result_gdf = _compute_watershed_dem_stats(watersheds_gdf, raster_path) + print(f"Computed DEM stats for {len(result_gdf)} watersheds") + + output_path = build_output_vector_path( + layer_name=layer_name, + output_base_dir=LOCAL_FABDEM_OUTPUT, + state=state, + district=district, + block=block, + ) + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local DEM vector: {asset_id}") + + if push_to_geoserver: + try: + 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 vector response: {geoserver_response}") + if not isinstance(geoserver_response, dict) or geoserver_response.get( + "status_code" + ) not in (200, 201): + return False + except Exception as error: + print(f"Failed to sync local FABDEM vector to GeoServer: {error}") + return False + + if sync_layer_metadata and state and district and block: + 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=asset_id, + dataset_name="DEM Vector", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for DEM vector") + + return True + + +@app.task(bind=True) +def generate_febdem_raster_vector_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", +): + raster_ok, clipped_raster_path = run_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, + ) + + if not raster_ok or not clipped_raster_path: + print("Raster stage failed — skipping vector stage.") + return False + + vector_ok = run_vector_fabdem_local( + state=state, + district=district, + block=block, + asset_suffix=asset_suffix, + raster_path=clipped_raster_path, + precomputed_roi_dir=precomputed_roi_dir, + push_to_geoserver=True, + sync_layer_metadata=True, + ) + + return raster_ok and vector_ok diff --git a/computing/misc/distancetonearestdrainage_local_compute.py b/computing/misc/distancetonearestdrainage_local_compute.py new file mode 100644 index 00000000..1b8c016f --- /dev/null +++ b/computing/misc/distancetonearestdrainage_local_compute.py @@ -0,0 +1,119 @@ +import os +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_raster_path, + load_precomputed_watersheds, + read_validated_vector_file, + clip_raster_with_roi, + push_local_raster_to_geoserver, +) +from computing.STAC_specs import generate_STAC_layerwise +from computing.config_loader import ( + PAN_INDIA_DISTANCETONEARESTDRAINAGE_PATH, + LOCAL_DISTANCETONEARESTDRAINAGE_OUTPUT, +) + +GEOSERVER_WORKSPACE = "distance_nearest_upstream_DL" +DISTANCE_TO_DRAINAGE_STYLE_NAME = "distance_nearest_upstream_DL" + + +@app.task(bind=True) +def generate_distance_to_nearest_drainage_line_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name_base = f"distance_to_drainage_line_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_raster" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name_base = ( + f"distance_to_drainage_line_{valid_gee_text(asset_suffix).lower()}_raster" + ) + watersheds_gdf = read_validated_vector_file( + roi_path, f"Invalid ROI file: {roi_path}" + ) + print(f"ROI source: {roi_path}") + + # Raster Processing + raster_layer_name = f"{layer_name_base}" + output_raster_path = build_output_raster_path( + layer_name=raster_layer_name, + output_base_dir=LOCAL_DISTANCETONEARESTDRAINAGE_OUTPUT, + state=state, + district=district, + block=block, + ) + + print("Clipping Distance to Nearest Drainage Line raster...") + clipped_raster_path = clip_raster_with_roi( + roi_gdf=watersheds_gdf, + raster_path=PAN_INDIA_DISTANCETONEARESTDRAINAGE_PATH, + output_path=output_raster_path, + raster_label="Distance to Drainage Line Raster", + ) + print(f"Saved clipped raster to: {clipped_raster_path}") + + layer_at_geoserver = False + if push_to_geoserver: + push_local_raster_to_geoserver( + file_path=str(clipped_raster_path), + layer_name=raster_layer_name, + workspace=GEOSERVER_WORKSPACE, + style_name=DISTANCE_TO_DRAINAGE_STYLE_NAME, + ) + print(f"Pushed raster {raster_layer_name} to GeoServer.") + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + raster_layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=raster_layer_name, + asset_id=str(clipped_raster_path), + dataset_name="Distance to Drainage Line", + misc={"is_generated_locally": True}, + ) + if raster_layer_id: + update_layer_sync_status(layer_id=raster_layer_id, sync_to_geoserver=True) + print(f"Database record updated for raster layer_id: {raster_layer_id}") + + # STAC Specs for Raster + try: + layer_STAC_generated = generate_STAC_layerwise.generate_raster_stac( + state=state, + district=district, + block=block, + layer_name="distance_to_nearest_drainage_line_raster", + ) + update_layer_sync_status( + layer_id=raster_layer_id, + is_stac_specs_generated=layer_STAC_generated, + ) + print("STAC metadata updated for Distance to Drainage Line raster") + except Exception as e: + print(f"Error generating STAC for raster: {e}") + + return layer_at_geoserver diff --git a/computing/misc/drainage_density_local_compute.py b/computing/misc/drainage_density_local_compute.py new file mode 100644 index 00000000..3591194b --- /dev/null +++ b/computing/misc/drainage_density_local_compute.py @@ -0,0 +1,188 @@ +import os +import geopandas as gpd +from shapely.geometry import box + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + build_output_vector_path, + load_precomputed_watersheds, + read_validated_vector_file, + write_vector_output, +) + +from computing.config_loader import ( + PAN_INDIA_DRAINAGE_LINES_GPKG_PATH, + LOCAL_DRAINAGE_DENSITY_OUTPUT, +) + +GEOSERVER_WORKSPACE = "drainage_density" + +# Influence factors for stream orders 1 to 11 +INFLUENCE_FACTORS = [ + 60 / 385, + 55 / 385, + 50 / 385, + 45 / 385, + 40 / 385, + 35 / 385, + 30 / 385, + 25 / 385, + 20 / 385, + 15 / 385, + 10 / 385, +] + + +def _load_drainage_lines_for_roi(watersheds_gdf): + bounds = watersheds_gdf.geometry.total_bounds + bbox_geom = box(*bounds) + + print(f"Loading drainage lines from: {PAN_INDIA_DRAINAGE_LINES_GPKG_PATH}") + if not os.path.exists(PAN_INDIA_DRAINAGE_LINES_GPKG_PATH): + # Fallback to checking common locations if the exact path is missing + print( + f"Warning: {PAN_INDIA_DRAINAGE_LINES_GPKG_PATH} not found. Drainage density calculation will fail." + ) + + lines_gdf = gpd.read_file(PAN_INDIA_DRAINAGE_LINES_GPKG_PATH, bbox=bbox_geom) + print(f"Loaded {len(lines_gdf)} drainage line features within bounding box") + return lines_gdf + + +def _compute_drainage_density(watersheds_gdf, drainage_lines_gdf): + """ + Core calculation logic for Drainage Density. + Matches the GEE version's methodology. + """ + # Reproject to metric CRS for accurate length/area calculation + # (7755 is India-specific metric projection) + drainage_lines_gdf = drainage_lines_gdf.to_crs(crs=7755) + watersheds_gdf = watersheds_gdf.to_crs(crs=7755) + + for index, watershed in watersheds_gdf.iterrows(): + # Clip drainage lines to this watershed boundary + clipped_lines = gpd.clip(drainage_lines_gdf, watershed.geometry) + + # Area in km² (area_in_ha / 100) + area_km2 = watershed["area_in_ha"] / 100 + if area_km2 <= 0: + continue + + stream_length = {} + stream_dd = {} + + for stream_order, factor in zip(range(1, 12), INFLUENCE_FACTORS): + # Filter lines for this stream order + order_lines = clipped_lines[clipped_lines["ORDER"] == stream_order] + # Total length in km + length_km = order_lines.geometry.length.sum() / 1000 + # Weighted drainage density for this stream order (formula from GEE version) + dd = length_km * factor * 100 / area_km2 + + stream_length[stream_order] = length_km + stream_dd[stream_order] = dd + + # Store results as strings of lists to match GEE output + watersheds_gdf.at[index, "drainage_density_std"] = (float(sum(stream_length.values())) / area_km2) + watersheds_gdf.at[index, "drainage_density_weighted"] = float(sum(stream_dd.values())) + watersheds_gdf.at[index, "drainage_density_stream"] = str( + [float(v) for v in stream_dd.values()] + ) + watersheds_gdf.at[index, "stream_length_km"] = str( + [float(v) for v in stream_length.values()] + ) + + # Restore geographic CRS + watersheds_gdf = watersheds_gdf.to_crs(crs=4326) + return watersheds_gdf + + +@app.task(bind=True) +def drainage_density( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi=None, + app_type="MWS", + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + """ + Main entry point for local drainage density computation. + Produces MWS polygons with drainage_density attributes. + """ + if state and district and block: + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_drainage_density" + watersheds_gdf, watershed_source = load_precomputed_watersheds( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + print(f"Loaded watersheds from {watershed_source}") + else: + if not roi or not asset_suffix: + raise ValueError("ROI and asset_suffix are required for custom runs.") + layer_name = f"{asset_suffix}_drainage_density_vector".lower() + watersheds_gdf = read_validated_vector_file(roi, f"Invalid ROI file: {roi}") + + # 1. Load drainage lines + try: + drainage_lines_gdf = _load_drainage_lines_for_roi(watersheds_gdf) + except Exception as e: + print(f"Error loading drainage lines: {e}") + return False + + print("Computing drainage density per watershed...") + result_gdf = _compute_drainage_density(watersheds_gdf, drainage_lines_gdf) + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_DRAINAGE_DENSITY_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local drainage_density vector: {asset_id}") + + # 4. Push to GeoServer + 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", + ) + + # 5. Sync to database + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Drainage Density Vector", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print(f"Sync Data for layer_id: {layer_id}") + + return True diff --git a/computing/misc/drainage_lines_local_compute.py b/computing/misc/drainage_lines_local_compute.py new file mode 100644 index 00000000..2f147af5 --- /dev/null +++ b/computing/misc/drainage_lines_local_compute.py @@ -0,0 +1,149 @@ +import os +import geopandas as gpd +from shapely.geometry import box +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_vector_path, + 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, + fix_invalid_geometry_in_gdf, +) +from projects.models import Project + +from computing.config_loader import ( + PAN_INDIA_DRAINAGE_LINES_PATH, + LOCAL_DRAINAGE_LINES_OUTPUT, +) + +GEOSERVER_WORKSPACE = "drainage" + + +def _compute_drainage_lines_for_watersheds(watersheds_gdf, drainage_gdf): + watersheds_gdf = validate_geometry(watersheds_gdf).reset_index(drop=True) + drainage_gdf = validate_geometry(drainage_gdf).reset_index(drop=True) + + outer_boundary = watersheds_gdf.geometry.unary_union + + # Step 1: Filter drainage features that intersect the ROI (equivalent to GEE filterBounds) + drainage_in_roi = drainage_gdf[drainage_gdf.intersects(outer_boundary)].copy() + + if drainage_in_roi.empty: + print("No drainage lines found within the outer boundary.") + return gpd.GeoDataFrame(columns=drainage_gdf.columns, crs=drainage_gdf.crs) + + print(f"Drainage lines within outer boundary: {len(drainage_in_roi)}") + + # Step 2: Drop empty/invalid geometries + drainage_in_roi = fix_invalid_geometry_in_gdf(drainage_in_roi) + drainage_in_roi = drainage_in_roi[ + drainage_in_roi.geometry.notna() + & ~drainage_in_roi.geometry.is_empty + & drainage_in_roi.geometry.is_valid + ] + + print(f"Final valid drainage lines: {len(drainage_in_roi)}") + return drainage_in_roi + + +@app.task(bind=True) +def clip_drainage_lines( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + asset_folder=None, + gee_account_id=None, + roi_path=None, + app_type="MWS", + proj_id=None, + drainage_lines_path=PAN_INDIA_DRAINAGE_LINES_PATH, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + """ + Celery task for local drainage lines vector generation. + """ + + if state and district and block: + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" + 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}") + else: + proj_obj = Project.objects.get(pk=proj_id) + state = proj_obj.name + layer_name = asset_suffix + watersheds_gdf = read_validated_vector_file( + roi_path, + f"ROI file has no valid geometries: {roi_path}", + ) + print(f"ROI source: {roi_path}") + + if not os.path.exists(drainage_lines_path): + raise FileNotFoundError(f"PAN INDIA drainage lines file not found at {drainage_lines_path}") + + bounds = watersheds_gdf.geometry.total_bounds + bbox_geom = box(*bounds) + drainage_gdf = gpd.read_file(drainage_lines_path, bbox=bbox_geom) + + result_gdf = _compute_drainage_lines_for_watersheds( + watersheds_gdf=watersheds_gdf, + drainage_gdf=drainage_gdf, + ) + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_DRAINAGE_LINES_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local drainage lines 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 sync_layer_metadata: + layer_id = None + if state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Drainage", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for drainage lines vector") + + return True diff --git a/computing/misc/facilities_proximity_local_compute.py b/computing/misc/facilities_proximity_local_compute.py new file mode 100644 index 00000000..9f28eb6a --- /dev/null +++ b/computing/misc/facilities_proximity_local_compute.py @@ -0,0 +1,156 @@ +import os +import geopandas as gpd + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_vector_path, + load_precomputed_panchayat, + read_validated_vector_file, + write_vector_output, + validate_geometry, + read_geojson_with_string_coords, +) +from computing.config_loader import ( + PAN_INDIA_FACILITIES_PATH, + LOCAL_FACILITIES_OUTPUT, +) + +GEOSERVER_WORKSPACE = "facilities_proximity" + + +def _compute_proximity_for_panchayat(panchayat_gdf, facilities_gdf): + """ + Filters facilities to strictly those intersecting the panchayat boundaries, + without altering/clipping their geometries. + """ + if facilities_gdf.empty: + return facilities_gdf + + # Ensure CRS matches + if panchayat_gdf.crs and facilities_gdf.crs and panchayat_gdf.crs != facilities_gdf.crs: + facilities_gdf = facilities_gdf.to_crs(panchayat_gdf.crs) + + outer_boundary = panchayat_gdf.geometry.unary_union + + # Keep facilities that intersect the boundary, geometries unchanged + facilities_in_roi = facilities_gdf[facilities_gdf.intersects(outer_boundary)].copy() + + # Final cleanup + facilities_in_roi = facilities_in_roi[~facilities_in_roi.geometry.is_empty] + facilities_in_roi = facilities_in_roi[facilities_in_roi.geometry.is_valid] + facilities_in_roi = facilities_in_roi[facilities_in_roi.geometry.notna()] + + return facilities_in_roi + + +@app.task(bind=True) +def generate_facilities_proximity_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name = f"facilities_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" + panchayat_gdf, panchayat_source = load_precomputed_panchayat( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + print(f"Panchayat boundary source: {panchayat_source}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name = f"facilities_{valid_gee_text(asset_suffix).lower()}" + panchayat_gdf = read_validated_vector_file( + roi_path, f"Invalid ROI file: {roi_path}" + ) + print(f"ROI source: {roi_path}") + + if not os.path.exists(PAN_INDIA_FACILITIES_PATH): + raise FileNotFoundError( + f"PAN INDIA Facilities file not found at {PAN_INDIA_FACILITIES_PATH}" + ) + + print("Loading Facilities data overlapping ROI...") + + # Ensure mask is in EPSG:4326 since the source GeoJSON is in EPSG:4326 + if panchayat_gdf.crs and panchayat_gdf.crs.to_epsg() != 4326: + mask_gdf = panchayat_gdf.to_crs(epsg=4326) + else: + mask_gdf = panchayat_gdf + + facilities_gdf = read_geojson_with_string_coords( + PAN_INDIA_FACILITIES_PATH, mask_gdf=mask_gdf + ) + facilities_gdf = validate_geometry(facilities_gdf) + + if facilities_gdf.empty: + print( + "Warning: PAN INDIA Facilities file has no valid geometries overlapping ROI" + ) + print(f"Loaded {len(facilities_gdf)} Facilities features") + + result_gdf = _compute_proximity_for_panchayat( + panchayat_gdf=panchayat_gdf, + facilities_gdf=facilities_gdf, + ) + print(f"Final valid Facilities features after spatial filter: {len(result_gdf)}") + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_FACILITIES_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local Facilities vector: {asset_id}") + + layer_at_geoserver = False + + 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 geoserver_response and geoserver_response.get("status_code") in (200, 201): + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Facilities Proximity", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for Facilities vector") + + return layer_at_geoserver \ No newline at end of file diff --git a/computing/misc/factory_csr_local_compute.py b/computing/misc/factory_csr_local_compute.py new file mode 100644 index 00000000..bf812ff6 --- /dev/null +++ b/computing/misc/factory_csr_local_compute.py @@ -0,0 +1,176 @@ +import os +import geopandas as gpd + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_vector_path, + load_precomputed_watersheds, + read_validated_vector_file, + write_vector_output, + validate_geometry, +) +from computing.config_loader import ( + PAN_INDIA_FACTORY_CSR_PATH, + LOCAL_FACTORY_CSR_OUTPUT, +) + +GEOSERVER_WORKSPACE = "factory_csr" + + +def _compute_factory_csr_for_watersheds(watersheds_gdf, factory_gdf): + """ + Spatially joins Factory CSR features with watershed polygons. + Equivalent to the GEE Join.saveFirst() with spatial intersection. + """ + if factory_gdf.empty: + return factory_gdf + + if watersheds_gdf.crs and factory_gdf.crs and watersheds_gdf.crs != factory_gdf.crs: + factory_gdf = factory_gdf.to_crs(watersheds_gdf.crs) + + # We only need the 'uid' from watersheds + target_watersheds = watersheds_gdf[["uid", "geometry"]].copy() + + joined_gdf = gpd.sjoin( + factory_gdf, + target_watersheds, + how="inner", + predicate="intersects" + ) + + # To mimic ee.Join.saveFirst(), drop duplicates based on the original feature index + joined_gdf = joined_gdf[~joined_gdf.index.duplicated(keep="first")] + + if "index_right" in joined_gdf.columns: + joined_gdf = joined_gdf.drop(columns=["index_right"]) + + rename_map = {} + for col in joined_gdf.columns: + cl = col.lower().strip() + if cl in ["company_na", "company na", "company name", "company_name"]: rename_map[col] = "COMPANY NA" + elif cl in ["location n", "location_n", "location t", "location_t", "location type", "location_type"]: rename_map[col] = "LOCATION T" + elif cl == "address": rename_map[col] = "ADDRESS" + elif cl in ["level 1", "level_1"]: rename_map[col] = "LEVEL 1" + elif cl in ["level 2", "level_2"]: rename_map[col] = "LEVEL 2" + elif cl in ["level 3", "level_3"]: rename_map[col] = "LEVEL 3" + elif cl == "uuid": rename_map[col] = "UUID" + elif col == "" or "unnamed" in cl: rename_map[col] = "Unnamed_ 9" + elif cl == "lat": rename_map[col] = "LAT" + elif cl in ["lng", "lon", "long", "longitude"]: rename_map[col] = "LNG" + + joined_gdf = joined_gdf.rename(columns=rename_map) + + if "LAT" not in joined_gdf.columns and not joined_gdf.empty: + joined_gdf["LAT"] = joined_gdf.geometry.y + if "LNG" not in joined_gdf.columns and not joined_gdf.empty: + joined_gdf["LNG"] = joined_gdf.geometry.x + + target_cols = [ + "ADDRESS", "COMPANY NA", "LAT", "LEVEL 1", "LEVEL 2", + "LEVEL 3", "LNG", "LOCATION T", "UUID", "Unnamed_ 9", + "uid", "geometry" + ] + cols_to_keep = [col for col in target_cols if col in joined_gdf.columns] + joined_gdf = joined_gdf[cols_to_keep] + + return joined_gdf + + +@app.task(bind=True) +def generate_factory_csr_data_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_factory_csr" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name = f"{valid_gee_text(asset_suffix).lower()}_factory_csr" + watersheds_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") + print(f"ROI source: {roi_path}") + + if not os.path.exists(PAN_INDIA_FACTORY_CSR_PATH): + raise FileNotFoundError(f"PAN INDIA Factory CSR file not found at {PAN_INDIA_FACTORY_CSR_PATH}") + + print("Loading Factory CSR data overlapping ROI...") + factory_gdf = gpd.read_file(PAN_INDIA_FACTORY_CSR_PATH, mask=watersheds_gdf) + factory_gdf = validate_geometry(factory_gdf) + if factory_gdf.empty: + print("Warning: PAN INDIA Factory CSR file has no valid geometries overlapping ROI") + print(f"Loaded {len(factory_gdf)} Factory CSR features") + + result_gdf = _compute_factory_csr_for_watersheds( + watersheds_gdf=watersheds_gdf, + factory_gdf=factory_gdf, + ) + print(f"Final valid Factory CSR features after spatial join: {len(result_gdf)}") + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_FACTORY_CSR_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local Factory CSR vector: {asset_id}") + + layer_at_geoserver = False + + 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 geoserver_response and geoserver_response.get("status_code") in (200, 201): + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Factory CSR", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for Factory CSR vector") + + return layer_at_geoserver + + + diff --git a/computing/misc/green_credit_local_compute.py b/computing/misc/green_credit_local_compute.py new file mode 100644 index 00000000..3c92f2fa --- /dev/null +++ b/computing/misc/green_credit_local_compute.py @@ -0,0 +1,147 @@ +import os +import geopandas as gpd + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_vector_path, + load_precomputed_watersheds, + read_validated_vector_file, + write_vector_output, + validate_geometry, +) +from computing.config_loader import ( + PAN_INDIA_GREEN_CREDIT_PATH, + LOCAL_GREEN_CREDIT_OUTPUT, +) + +GEOSERVER_WORKSPACE = "green_credit" + + +def _compute_green_credit_for_watersheds(watersheds_gdf, green_credit_gdf): + """ + Spatially joins Green Credit features with watershed polygons. + Equivalent to the GEE Join.saveFirst() with spatial intersection. + """ + if green_credit_gdf.empty: + return green_credit_gdf + + if watersheds_gdf.crs and green_credit_gdf.crs and watersheds_gdf.crs != green_credit_gdf.crs: + green_credit_gdf = green_credit_gdf.to_crs(watersheds_gdf.crs) + + # We only need the 'uid' from watersheds + target_watersheds = watersheds_gdf[["uid", "geometry"]].copy() + + joined_gdf = gpd.sjoin( + green_credit_gdf, + target_watersheds, + how="inner", + predicate="intersects" + ) + + # To mimic ee.Join.saveFirst(), drop duplicates based on the original feature index + joined_gdf = joined_gdf[~joined_gdf.index.duplicated(keep="first")] + + if "index_right" in joined_gdf.columns: + joined_gdf = joined_gdf.drop(columns=["index_right"]) + + return joined_gdf + + +@app.task(bind=True) +def generate_green_credit_data_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_green_credit" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name = f"{valid_gee_text(asset_suffix).lower()}_green_credit" + watersheds_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") + print(f"ROI source: {roi_path}") + + if not os.path.exists(PAN_INDIA_GREEN_CREDIT_PATH): + raise FileNotFoundError(f"PAN INDIA Green Credit file not found at {PAN_INDIA_GREEN_CREDIT_PATH}") + + print("Loading Green Credit data overlapping ROI...") + green_credit_gdf = gpd.read_file(PAN_INDIA_GREEN_CREDIT_PATH, mask=watersheds_gdf) + green_credit_gdf = validate_geometry(green_credit_gdf) + if green_credit_gdf.empty: + print("Warning: PAN INDIA Green Credit file has no valid geometries overlapping ROI") + print(f"Loaded {len(green_credit_gdf)} Green Credit features") + + result_gdf = _compute_green_credit_for_watersheds( + watersheds_gdf=watersheds_gdf, + green_credit_gdf=green_credit_gdf, + ) + print(f"Final valid Green Credit features after spatial join: {len(result_gdf)}") + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_GREEN_CREDIT_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local Green Credit vector: {asset_id}") + + layer_at_geoserver = False + + 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 geoserver_response and geoserver_response.get("status_code") in (200, 201): + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Green Credit", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for Green Credit vector") + + return layer_at_geoserver + + + diff --git a/computing/misc/lcw_conflict_local_compute.py b/computing/misc/lcw_conflict_local_compute.py new file mode 100644 index 00000000..75aea6c4 --- /dev/null +++ b/computing/misc/lcw_conflict_local_compute.py @@ -0,0 +1,147 @@ +import os +import geopandas as gpd + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_vector_path, + load_precomputed_watersheds, + read_validated_vector_file, + write_vector_output, + validate_geometry, +) +from computing.config_loader import ( + PAN_INDIA_LCW_PATH, + LOCAL_LCW_OUTPUT, +) + +GEOSERVER_WORKSPACE = "lcw" + + +def _compute_lcw_conflict_for_watersheds(watersheds_gdf, lcw_gdf): + """ + Spatially joins LCW conflict features with watershed polygons. + Equivalent to the GEE Join.saveFirst() with spatial intersection. + """ + if lcw_gdf.empty: + return lcw_gdf + + if watersheds_gdf.crs and lcw_gdf.crs and watersheds_gdf.crs != lcw_gdf.crs: + lcw_gdf = lcw_gdf.to_crs(watersheds_gdf.crs) + + # We only need the 'uid' from watersheds + target_watersheds = watersheds_gdf[["uid", "geometry"]].copy() + + joined_gdf = gpd.sjoin( + lcw_gdf, + target_watersheds, + how="inner", + predicate="intersects" + ) + + # To mimic ee.Join.saveFirst(), drop duplicates based on the original LCW feature index + joined_gdf = joined_gdf[~joined_gdf.index.duplicated(keep="first")] + + if "index_right" in joined_gdf.columns: + joined_gdf = joined_gdf.drop(columns=["index_right"]) + + return joined_gdf + + +@app.task(bind=True) +def generate_lcw_conflict_data_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_lcw_conflict" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name = f"{valid_gee_text(asset_suffix).lower()}_lcw_conflict" + watersheds_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") + print(f"ROI source: {roi_path}") + + if not os.path.exists(PAN_INDIA_LCW_PATH): + raise FileNotFoundError(f"PAN INDIA LCW conflict file not found at {PAN_INDIA_LCW_PATH}") + + print("Loading LCW conflict data overlapping ROI...") + lcw_gdf = gpd.read_file(PAN_INDIA_LCW_PATH, mask=watersheds_gdf) + lcw_gdf = validate_geometry(lcw_gdf) + if lcw_gdf.empty: + print("Warning: PAN INDIA LCW conflict file has no valid geometries overlapping ROI") + print(f"Loaded {len(lcw_gdf)} LCW features") + + result_gdf = _compute_lcw_conflict_for_watersheds( + watersheds_gdf=watersheds_gdf, + lcw_gdf=lcw_gdf, + ) + print(f"Final valid LCW features after spatial join: {len(result_gdf)}") + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_LCW_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local LCW conflict vector: {asset_id}") + + layer_at_geoserver = False + + 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 geoserver_response and geoserver_response.get("status_code") in (200, 201): + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="LCW Conflict", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for LCW conflict vector") + + return layer_at_geoserver + + + diff --git a/computing/misc/livestocks_local_compute.py b/computing/misc/livestocks_local_compute.py new file mode 100644 index 00000000..67120c27 --- /dev/null +++ b/computing/misc/livestocks_local_compute.py @@ -0,0 +1,168 @@ +import os +import geopandas as gpd + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_vector_path, + load_precomputed_watersheds, + read_validated_vector_file, + write_vector_output, + validate_geometry, +) +from computing.config_loader import ( + PAN_INDIA_LIVESTOCKS, + LOCAL_LIVESTOCKS_OUTPUT, +) + +GEOSERVER_WORKSPACE = "livestocks" + +INTEGER_COLUMNS = ( + "pc11_village_id", + "pc11_state_id", + "pc11_district_id", + "pc11_subdistrict_id", + "cattle_male", + "cattle_female", + "cattle_total", + "buffalo_male", + "buffalo_female", + "buffalo_total", + "sheep_male", + "sheep_female", + "sheep_total", + "goat_male", + "goat_female", + "goat_total", + "pig_male", + "pig_female", + "pig_total", +) + +def _coerce_nullable_integer_columns(gdf): + """ + Ensure the livestock columns are the correct integer types. + """ + for column in INTEGER_COLUMNS: + if column in gdf.columns: + gdf[column] = gdf[column].astype("Int64") + return gdf + +def _compute_livestocks_for_watersheds(watersheds_gdf, livestocks_gdf): + """ + Spatially filters Livestock features with watershed/ROI boundaries. + """ + if livestocks_gdf.empty: + return livestocks_gdf + + if watersheds_gdf.crs and livestocks_gdf.crs and watersheds_gdf.crs != livestocks_gdf.crs: + livestocks_gdf = livestocks_gdf.to_crs(watersheds_gdf.crs) + + outer_boundary = watersheds_gdf.geometry.unary_union + + # Precise intersection check + livestocks_in_roi = livestocks_gdf[livestocks_gdf.intersects(outer_boundary)].copy() + + # Final cleanup + livestocks_in_roi = livestocks_in_roi[~livestocks_in_roi.geometry.is_empty] + livestocks_in_roi = livestocks_in_roi[livestocks_in_roi.geometry.is_valid] + livestocks_in_roi = livestocks_in_roi[livestocks_in_roi.geometry.notna()] + + return _coerce_nullable_integer_columns(livestocks_in_roi) + + +@app.task(bind=True) +def generate_livestocks_data_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name = f"livestocks_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name = f"livestocks_{valid_gee_text(asset_suffix).lower()}" + watersheds_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") + print(f"ROI source: {roi_path}") + + if not os.path.exists(PAN_INDIA_LIVESTOCKS): + raise FileNotFoundError(f"PAN INDIA Livestocks file not found at {PAN_INDIA_LIVESTOCKS}") + + print("Loading Livestocks data overlapping ROI...") + livestocks_gdf = gpd.read_file(PAN_INDIA_LIVESTOCKS, mask=watersheds_gdf) + livestocks_gdf = validate_geometry(livestocks_gdf) + if livestocks_gdf.empty: + print("Warning: PAN INDIA Livestocks file has no valid geometries overlapping ROI") + else: + print(f"Loaded {len(livestocks_gdf)} Livestock features") + + result_gdf = _compute_livestocks_for_watersheds( + watersheds_gdf=watersheds_gdf, + livestocks_gdf=livestocks_gdf, + ) + print(f"Final valid Livestock features after spatial filter: {len(result_gdf)}") + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_LIVESTOCKS_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local Livestock vector: {asset_id}") + + layer_at_geoserver = False + + 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 geoserver_response and geoserver_response.get("status_code") in (200, 201): + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Livestock Census", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for Livestock vector") + + return layer_at_geoserver diff --git a/computing/misc/mining_data_local_compute.py b/computing/misc/mining_data_local_compute.py new file mode 100644 index 00000000..b5bb9cc5 --- /dev/null +++ b/computing/misc/mining_data_local_compute.py @@ -0,0 +1,147 @@ +import os +import geopandas as gpd + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_vector_path, + load_precomputed_watersheds, + read_validated_vector_file, + write_vector_output, + validate_geometry, +) +from computing.config_loader import ( + PAN_INDIA_MINING_PATH, + LOCAL_MINING_OUTPUT, +) + +GEOSERVER_WORKSPACE = "mining" + + +def _compute_mining_data_for_watersheds(watersheds_gdf, mining_gdf): + """ + Spatially joins Mining features with watershed polygons. + Equivalent to the GEE Join.saveFirst() with spatial intersection. + """ + if mining_gdf.empty: + return mining_gdf + + if watersheds_gdf.crs and mining_gdf.crs and watersheds_gdf.crs != mining_gdf.crs: + mining_gdf = mining_gdf.to_crs(watersheds_gdf.crs) + + # We only need the 'uid' from watersheds + target_watersheds = watersheds_gdf[["uid", "geometry"]].copy() + + joined_gdf = gpd.sjoin( + mining_gdf, + target_watersheds, + how="inner", + predicate="intersects" + ) + + # To mimic ee.Join.saveFirst(), drop duplicates based on the original feature index + joined_gdf = joined_gdf[~joined_gdf.index.duplicated(keep="first")] + + if "index_right" in joined_gdf.columns: + joined_gdf = joined_gdf.drop(columns=["index_right"]) + + return joined_gdf + + +@app.task(bind=True) +def generate_mining_data_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_mining" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name = f"{valid_gee_text(asset_suffix).lower()}_mining" + watersheds_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") + print(f"ROI source: {roi_path}") + + if not os.path.exists(PAN_INDIA_MINING_PATH): + raise FileNotFoundError(f"PAN INDIA Mining data file not found at {PAN_INDIA_MINING_PATH}") + + print("Loading Mining data overlapping ROI...") + mining_gdf = gpd.read_file(PAN_INDIA_MINING_PATH, mask=watersheds_gdf) + mining_gdf = validate_geometry(mining_gdf) + if mining_gdf.empty: + print("Warning: PAN INDIA Mining data file has no valid geometries overlapping ROI") + print(f"Loaded {len(mining_gdf)} Mining features") + + result_gdf = _compute_mining_data_for_watersheds( + watersheds_gdf=watersheds_gdf, + mining_gdf=mining_gdf, + ) + print(f"Final valid Mining features after spatial join: {len(result_gdf)}") + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_MINING_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local Mining data vector: {asset_id}") + + layer_at_geoserver = False + + 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 geoserver_response and geoserver_response.get("status_code") in (200, 201): + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Mining", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for Mining data vector") + + return layer_at_geoserver + + + diff --git a/computing/misc/naturaldepression_local_compute.py b/computing/misc/naturaldepression_local_compute.py new file mode 100644 index 00000000..29871f55 --- /dev/null +++ b/computing/misc/naturaldepression_local_compute.py @@ -0,0 +1,119 @@ +import os +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_raster_path, + load_precomputed_watersheds, + read_validated_vector_file, + clip_raster_with_roi, + push_local_raster_to_geoserver, +) +from computing.STAC_specs import generate_STAC_layerwise +from computing.config_loader import ( + PAN_INDIA_NATURALDEPRESSION_PATH, + LOCAL_NATURALDEPRESSION_OUTPUT, +) + +GEOSERVER_WORKSPACE = "natural_depression" +NATURAL_DEPRESSION_STYLE_NAME = "natural_depression" + + +@app.task(bind=True) +def generate_natural_depression_data_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name_base = f"natural_depression_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_raster" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name_base = ( + f"natural_depression_{valid_gee_text(asset_suffix).lower()}_raster" + ) + watersheds_gdf = read_validated_vector_file( + roi_path, f"Invalid ROI file: {roi_path}" + ) + print(f"ROI source: {roi_path}") + + # Raster Processing + raster_layer_name = f"{layer_name_base}" + output_raster_path = build_output_raster_path( + layer_name=raster_layer_name, + output_base_dir=LOCAL_NATURALDEPRESSION_OUTPUT, + state=state, + district=district, + block=block, + ) + + print("Clipping Natural Depression raster...") + clipped_raster_path = clip_raster_with_roi( + roi_gdf=watersheds_gdf, + raster_path=PAN_INDIA_NATURALDEPRESSION_PATH, + output_path=output_raster_path, + raster_label="Natural Depression Raster", + ) + print(f"Saved clipped Natural Depression raster to: {clipped_raster_path}") + + layer_at_geoserver = False + if push_to_geoserver: + push_local_raster_to_geoserver( + file_path=str(clipped_raster_path), + layer_name=raster_layer_name, + workspace=GEOSERVER_WORKSPACE, + style_name=NATURAL_DEPRESSION_STYLE_NAME, + ) + print(f"Pushed raster {raster_layer_name} to GeoServer.") + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + raster_layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=raster_layer_name, + asset_id=str(clipped_raster_path), + dataset_name="Natural Depression", + misc={"is_generated_locally": True}, + ) + if raster_layer_id: + update_layer_sync_status(layer_id=raster_layer_id, sync_to_geoserver=True) + print(f"Database record updated for raster layer_id: {raster_layer_id}") + + # STAC Specs for Raster + try: + layer_STAC_generated = generate_STAC_layerwise.generate_raster_stac( + state=state, + district=district, + block=block, + layer_name="natural_depression_raster", + ) + update_layer_sync_status( + layer_id=raster_layer_id, + is_stac_specs_generated=layer_STAC_generated, + ) + print("STAC metadata updated for Natural Depression raster") + except Exception as e: + print(f"Error generating STAC for raster: {e}") + + return layer_at_geoserver diff --git a/computing/misc/nrega_local_compute.py b/computing/misc/nrega_local_compute.py new file mode 100644 index 00000000..ede5398b --- /dev/null +++ b/computing/misc/nrega_local_compute.py @@ -0,0 +1,166 @@ +import os +import boto3 +import geopandas as gpd +import pandas as pd +import numpy as np +from io import BytesIO + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from nrm_app.settings import NREGA_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + load_precomputed_watersheds, + read_validated_vector_file, + write_vector_output, + build_output_vector_path, + validate_geometry, +) +from computing.config_loader import NREGA_LOCAL_OUTPUT + +GEOSERVER_WORKSPACE = "nrega_assets" + + +def _compute_nrega_for_watersheds(watersheds_gdf, nrega_gdf): + if nrega_gdf.empty: + return nrega_gdf + + watersheds_gdf = validate_geometry(watersheds_gdf) + nrega_gdf = validate_geometry(nrega_gdf) + + if watersheds_gdf.crs and nrega_gdf.crs and watersheds_gdf.crs != nrega_gdf.crs: + nrega_gdf = nrega_gdf.to_crs(watersheds_gdf.crs) + + outer_boundary = watersheds_gdf.geometry.unary_union + + nrega_in_roi = nrega_gdf[nrega_gdf.intersects(outer_boundary)].copy() + + # Clean column names + cleaned_columns = [] + for i, col in enumerate(nrega_in_roi.columns): + if not str(col).strip(): + cleaned_columns.append(f"col_{i}") + else: + cleaned = str(col).strip().replace(" ", "_").replace(".", "_") + cleaned_columns.append(cleaned) + nrega_in_roi.columns = cleaned_columns + + # Replace NaN + nrega_in_roi = nrega_in_roi.replace({np.nan: None}) + + # Convert datetime columns + for col in nrega_in_roi.columns: + if col != "geometry": + if pd.api.types.is_datetime64_any_dtype(nrega_in_roi[col]): + nrega_in_roi[col] = nrega_in_roi[col].astype(str).replace("NaT", None) + + return nrega_in_roi + + +@app.task(bind=True) +def generate_nrega_data_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name = f"nrega_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name = f"{valid_gee_text(asset_suffix).lower()}_nrega" + watersheds_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") + print(f"ROI source: {roi_path}") + + s3 = boto3.resource( + "s3", + region_name="ap-south-1", + aws_access_key_id=S3_ACCESS_KEY, + aws_secret_access_key=S3_SECRET_KEY, + ) + + print("Fetching NREGA data from S3...") + # It fetches district level geojson from S3 + if state and district: + key = f"{valid_gee_text(state).upper()}/{valid_gee_text(district).upper()}.geojson" + else: + # Fallback or error if custom ROI does not provide state/district mapping to S3 + print("Warning: Custom ROI run without state/district cannot fetch from S3 reliably.") + return False + + try: + file_obj = s3.Object(NREGA_BUCKET, key).get() + nrega_gdf = gpd.read_file(BytesIO(file_obj["Body"].read())) + except Exception as e: + print("Error while reading NREGA file from S3:", e) + return False + + print(f"Loaded {len(nrega_gdf)} NREGA features from S3") + + result_gdf = _compute_nrega_for_watersheds( + watersheds_gdf=watersheds_gdf, + nrega_gdf=nrega_gdf, + ) + print(f"Final valid NREGA features after spatial filter: {len(result_gdf)}") + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=NREGA_LOCAL_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local NREGA vector: {asset_id}") + + layer_at_geoserver = False + + 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 geoserver_response and geoserver_response.get("status_code") in (200, 201): + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="NREGA Assets", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for NREGA vector") + + return layer_at_geoserver diff --git a/computing/misc/restoration_opportunity_local_compute.py b/computing/misc/restoration_opportunity_local_compute.py new file mode 100644 index 00000000..a3353373 --- /dev/null +++ b/computing/misc/restoration_opportunity_local_compute.py @@ -0,0 +1,274 @@ +import os +from pathlib import Path + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + load_precomputed_watersheds, + read_validated_vector_file, + clip_raster_with_roi, + push_local_raster_to_geoserver, + compute_categorical_raster_areas_for_watersheds, + build_output_raster_path, + build_output_vector_path, + write_vector_output, +) +from computing.STAC_specs import generate_STAC_layerwise + + +from computing.config_loader import ( + PAN_INDIA_RESTORATION_PATH, + LOCAL_RESTORATION_OUTPUT, +) + +GEOSERVER_WORKSPACE = "restoration" +RESTORATION_STYLE_NAME = "restoration_style" + + +RESTORATION_CLASSES = [ + {"value": 0, "label": "Excluded A"}, + {"value": 1, "label": "Mosaic Res"}, + {"value": 2, "label": "Wide-scale"}, + {"value": 3, "label": "Protection"}, +] + + +def run_raster_restoration_local( + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name = f"restoration_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_raster" + watersheds_gdf, watershed_source = load_precomputed_watersheds( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + print(f"Loaded watersheds from {watershed_source}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name = f"{asset_suffix}_restoration_raster".lower() + watersheds_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") + + # ========================================== + # 1. Raster Processing + # ========================================== + output_raster_path = build_output_raster_path( + layer_name=layer_name, + output_base_dir=LOCAL_RESTORATION_OUTPUT, + state=state, + district=district, + block=block, + ) + + print("Clipping restoration raster...") + clipped_raster_path = clip_raster_with_roi( + roi_gdf=watersheds_gdf, + raster_path=PAN_INDIA_RESTORATION_PATH, + output_path=output_raster_path, + raster_label="WRI Restoration Raster", + ) + print(f"Saved clipped restoration raster to: {clipped_raster_path}") + + if push_to_geoserver: + push_local_raster_to_geoserver( + file_path=str(clipped_raster_path), + layer_name=layer_name, + workspace=GEOSERVER_WORKSPACE, + style_name=RESTORATION_STYLE_NAME, + ) + print(f"Pushed raster {layer_name} to GeoServer.") + + if sync_layer_metadata and state and district and block: + raster_layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=str(clipped_raster_path), + dataset_name="Restoration Raster", + misc={"is_generated_locally": True}, + ) + if raster_layer_id: + update_layer_sync_status(layer_id=raster_layer_id, sync_to_geoserver=True) + print(f"Database record updated for raster layer_id: {raster_layer_id}") + + # STAC Specs for Raster + # try: + # layer_STAC_generated = generate_STAC_layerwise.generate_raster_stac( + # state=state, + # district=district, + # block=block, + # layer_name="wri_restoration_raster", + # ) + # update_layer_sync_status( + # layer_id=raster_layer_id, is_stac_specs_generated=layer_STAC_generated + # ) + # print("STAC metadata updated for restoration raster") + # except Exception as e: + # print(f"Error generating STAC for raster: {e}") + + return True, clipped_raster_path + + +def run_vector_restoration_local( + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + raster_path=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if not raster_path: + raise ValueError("`raster_path` is required for vector stage.") + + if state and district and block: + layer_name = f"restoration_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_vector" + watersheds_gdf, watershed_source = load_precomputed_watersheds( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + else: + layer_name = f"{asset_suffix}_restoration_vector".lower() + watersheds_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") + + # ========================================== + # 2. Vector Processing + # ========================================== + print("Computing restoration class areas per watershed...") + vector_result_gdf = compute_categorical_raster_areas_for_watersheds( + watersheds_gdf=watersheds_gdf, + raster_path=raster_path, + class_definitions=RESTORATION_CLASSES, + ) + + desired_cols = [ + "uid", + "id", + "area_in_ha", + "Excluded A", + "Mosaic Res", + "Protection", + "Wide-scale", + "geometry" + ] + keep_cols = [c for c in desired_cols if c in vector_result_gdf.columns] + vector_result_gdf = vector_result_gdf[keep_cols] + + output_vector_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_RESTORATION_OUTPUT, + ) + + vector_asset_id = write_vector_output( + gdf=vector_result_gdf, + output_path=output_vector_path, + layer_name=layer_name, + ) + print(f"Saved local restoration vector: {vector_asset_id}") + + if push_to_geoserver: + geoserver_response = push_shape_to_geoserver( + os.path.splitext(vector_asset_id)[0], + workspace=GEOSERVER_WORKSPACE, + layer_name=layer_name, + file_type="gpkg", + ) + print(f"GeoServer response for vector: {geoserver_response}") + + if sync_layer_metadata and state and district and block: + vector_layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=vector_asset_id, + dataset_name="Restoration Vector", + misc={"is_generated_locally": True}, + ) + if vector_layer_id: + update_layer_sync_status(layer_id=vector_layer_id, sync_to_geoserver=True) + print(f"Database record updated for vector layer_id: {vector_layer_id}") + + # try: + # layer_STAC_generated = generate_STAC_layerwise.generate_vector_stac( + # state=state, + # district=district, + # block=block, + # layer_name="wri_restoration_vector", + # ) + # update_layer_sync_status( + # layer_id=vector_layer_id, + # is_stac_specs_generated=layer_STAC_generated, + # ) + # print("STAC metadata updated for restoration vector") + # except Exception as e: + # print(f"Error generating STAC for vector: {e}") + + return True + + +@app.task(bind=True) +def generate_restoration_opportunity_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + + raster_ok, clipped_raster_path = run_raster_restoration_local( + state=state, + district=district, + block=block, + asset_suffix=asset_suffix, + roi_path=roi_path, + precomputed_roi_dir=precomputed_roi_dir, + push_to_geoserver=push_to_geoserver, + sync_layer_metadata=sync_layer_metadata, + ) + + if not raster_ok or not clipped_raster_path: + print("Raster stage failed — skipping vector stage.") + return False + + vector_ok = run_vector_restoration_local( + state=state, + district=district, + block=block, + asset_suffix=asset_suffix, + roi_path=roi_path, + raster_path=clipped_raster_path, + precomputed_roi_dir=precomputed_roi_dir, + push_to_geoserver=push_to_geoserver, + sync_layer_metadata=sync_layer_metadata, + ) + + return raster_ok and vector_ok diff --git a/computing/misc/river_local_compute.py b/computing/misc/river_local_compute.py new file mode 100644 index 00000000..6e5e83ec --- /dev/null +++ b/computing/misc/river_local_compute.py @@ -0,0 +1,293 @@ +import os +import json +import datetime +import pandas as pd +import geopandas as gpd +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.local_compute_helper import ( + PROJECT_ROOT, + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + 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, + fix_invalid_geometry_in_gdf, +) + +from computing.config_loader import ( + PAN_INDIA_RIVER_PATH, + LOCAL_RIVER_OUTPUT, +) +from shapely.ops import unary_union + +GEOSERVER_WORKSPACE = "river" + +def _extract_lines(geom): + line_types = {"LineString", "MultiLineString", "LinearRing"} + if geom is None or geom.is_empty: + return None + + if geom.geom_type in line_types: + return geom + + # Polygon/MultiPolygon → take boundary + if geom.geom_type in {"Polygon", "MultiPolygon"}: + b = geom.boundary + return b if not b.is_empty else None + + # GeometryCollection → recurse and collect lines + if geom.geom_type == "GeometryCollection": + lines = [] + for part in geom.geoms: + extracted = _extract_lines(part) + if extracted and not extracted.is_empty: + lines.append(extracted) + if not lines: + return None + return unary_union(lines) + + return None + + +def _compute_river_properties_for_watersheds(watersheds_gdf, rivers_gdf): + + watersheds_gdf = validate_geometry(watersheds_gdf).reset_index(drop=True) + rivers_gdf = validate_geometry(rivers_gdf).reset_index(drop=True) + polygon_types = {"Polygon", "MultiPolygon"} + + if rivers_gdf.geometry.geom_type.isin(polygon_types).any(): + rivers_gdf = rivers_gdf.copy() + rivers_gdf["geometry"] = rivers_gdf.geometry.boundary + rivers_gdf = rivers_gdf[ + rivers_gdf.geometry.geom_type.isin( + ["LineString", "MultiLineString", "LinearRing"] + ) + ] + print(f"After boundary conversion: {len(rivers_gdf)} river features") + + outer_boundary = watersheds_gdf.geometry.unary_union + rivers_in_roi = rivers_gdf[rivers_gdf.intersects(outer_boundary)].copy() + + if rivers_in_roi.empty: + print("No rivers found within the outer boundary.") + return gpd.GeoDataFrame(columns=rivers_gdf.columns, crs=rivers_gdf.crs) + + watersheds_indexed = watersheds_gdf[["uid", "area_in_ha", "geometry"]].copy() + joined = gpd.sjoin( + rivers_in_roi, + watersheds_indexed, + how="inner", + predicate="intersects", + ) + + matched_river_indices = joined.index.unique() + gap_rivers = rivers_in_roi.loc[ + ~rivers_in_roi.index.isin(matched_river_indices) + ].copy() + + result_segments = [] + if not joined.empty: + clipped_rows = [] + for idx, row in joined.iterrows(): + try: + ws_idx = int(row["index_right"]) + ws_geom = watersheds_gdf.loc[ws_idx, "geometry"] + river_geom = row.geometry + + if not river_geom.is_valid: + river_geom = river_geom.buffer(0) + if not ws_geom.is_valid: + ws_geom = ws_geom.buffer(0) + + clipped = river_geom.intersection(ws_geom) + if clipped is None or clipped.is_empty: + continue + + clipped = _extract_lines(clipped) + if clipped is None or clipped.is_empty: + continue + + new_row = row.copy() + new_row["geometry"] = clipped + clipped_rows.append(new_row) + + except Exception as e: + print(f"Clip error river idx={idx}: {e}") + continue + + if clipped_rows: + matched_fc = gpd.GeoDataFrame(clipped_rows, crs=rivers_gdf.crs) + result_segments.append(matched_fc) + print(f"Valid matched segments: {len(clipped_rows)}") + + if not gap_rivers.empty: + clipped_gaps = [] + for idx, row in gap_rivers.iterrows(): + try: + clipped = row.geometry.intersection(outer_boundary) + if clipped is None or clipped.is_empty: + continue + + clipped = _extract_lines(clipped) + if clipped is None or clipped.is_empty: + continue + + new_row = row.copy() + new_row["geometry"] = clipped + new_row["uid"] = "" + new_row["area_in_ha"] = "" + clipped_gaps.append(new_row) + + except Exception as e: + print(f"Gap clip error river idx={idx}: {e}") + continue + + if clipped_gaps: + gap_fc = gpd.GeoDataFrame(clipped_gaps, crs=rivers_gdf.crs) + result_segments.append(gap_fc) + print(f"Valid gap segments: {len(clipped_gaps)}") + + if not result_segments: + print("No valid river segments after clipping.") + return gpd.GeoDataFrame(columns=rivers_gdf.columns, crs=rivers_gdf.crs) + + final_gdf = gpd.GeoDataFrame( + pd.concat(result_segments, ignore_index=True), + crs=rivers_gdf.crs, + ) + + final_gdf["uid"] = final_gdf["uid"].astype(str) + final_gdf["area_in_ha"] = final_gdf["area_in_ha"].astype(str) + + rename_cols = {} + if "st_area(sh" in final_gdf.columns: + rename_cols["st_area(sh"] = "st_area" + if "st_length(" in final_gdf.columns: + rename_cols["st_length("] = "st_length" + + if rename_cols: + final_gdf = final_gdf.rename(columns=rename_cols) + + for col in ["index_right"]: + if col in final_gdf.columns: + final_gdf = final_gdf.drop(columns=[col]) + + final_gdf = final_gdf[~final_gdf.geometry.is_empty] + final_gdf = final_gdf[final_gdf.geometry.is_valid] + final_gdf = final_gdf[final_gdf.geometry.notna()] + final_gdf = fix_invalid_geometry_in_gdf(final_gdf) + + final_gdf = final_gdf[ + final_gdf.geometry.apply( + lambda g: g is not None + and not g.is_empty + and g.bounds[0] <= g.bounds[2] + and g.bounds[1] <= g.bounds[3] + ) + ] + + print(f"Final valid river segments: {len(final_gdf)}") + return final_gdf + + +@app.task(bind=True) +def river_vector( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi=None, + asset_folder_list=None, + app_type="MWS", + gee_account_id=None, + river_vector_path=PAN_INDIA_RIVER_PATH, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + """ + Celery task for local river vector generation. + """ + if state and district and block: + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_river_vector" + 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}") + 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}_river_vector".lower() + watersheds_gdf = read_validated_vector_file( + roi, + f"ROI file has no valid geometries: {roi}", + ) + + if not os.path.exists(river_vector_path): + raise FileNotFoundError(f"River source file not found: {river_vector_path}") + + print(f"Loading river source: {river_vector_path}") + rivers_gdf = read_validated_vector_file( + river_vector_path, + f"River source file has no valid geometries: {river_vector_path}", + ) + + result_gdf = _compute_river_properties_for_watersheds( + watersheds_gdf=watersheds_gdf, + rivers_gdf=rivers_gdf, + ) + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_RIVER_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local river vector: {asset_id}") + + if push_to_geoserver: + push_shape_to_geoserver( + os.path.splitext(asset_id)[0], + workspace=GEOSERVER_WORKSPACE, + layer_name=layer_name, + file_type="gpkg", + ) + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="River Vector", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for river vector") + + return True + + diff --git a/computing/misc/slope_percentage_local_compute.py b/computing/misc/slope_percentage_local_compute.py new file mode 100644 index 00000000..fd29de2a --- /dev/null +++ b/computing/misc/slope_percentage_local_compute.py @@ -0,0 +1,117 @@ +import os +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_raster_path, + load_precomputed_watersheds, + read_validated_vector_file, + clip_raster_with_roi, + push_local_raster_to_geoserver, +) +from computing.STAC_specs import generate_STAC_layerwise +from computing.config_loader import ( + PAN_INDIA_SLOPE_PERCENTAGE_PATH, + LOCAL_SLOPE_PERCENTAGE_OUTPUT, +) + +GEOSERVER_WORKSPACE = "slope_percentage" +SLOPE_PERCENTAGE_STYLE_NAME = "slope_percentage" + + +@app.task(bind=True) +def generate_slope_percentage_data_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name_base = f"slope_percentage_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name_base = f"slope_percentage_{valid_gee_text(asset_suffix).lower()}" + watersheds_gdf = read_validated_vector_file( + roi_path, f"Invalid ROI file: {roi_path}" + ) + print(f"ROI source: {roi_path}") + + # Raster Processing + raster_layer_name = f"{layer_name_base}" + output_raster_path = build_output_raster_path( + layer_name=raster_layer_name, + output_base_dir=LOCAL_SLOPE_PERCENTAGE_OUTPUT, + state=state, + district=district, + block=block, + ) + + print("Clipping Slope Percentage raster...") + clipped_raster_path = clip_raster_with_roi( + roi_gdf=watersheds_gdf, + raster_path=PAN_INDIA_SLOPE_PERCENTAGE_PATH, + output_path=output_raster_path, + raster_label="Slope Percentage Raster", + ) + print(f"Saved clipped Slope Percentage raster to: {clipped_raster_path}") + + layer_at_geoserver = False + if push_to_geoserver: + push_local_raster_to_geoserver( + file_path=str(clipped_raster_path), + layer_name=raster_layer_name, + workspace=GEOSERVER_WORKSPACE, + style_name=SLOPE_PERCENTAGE_STYLE_NAME, + ) + print(f"Pushed raster {raster_layer_name} to GeoServer.") + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + raster_layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=raster_layer_name, + asset_id=str(clipped_raster_path), + dataset_name="Slope Percentage", + misc={"is_generated_locally": True}, + ) + if raster_layer_id: + update_layer_sync_status(layer_id=raster_layer_id, sync_to_geoserver=True) + print(f"Database record updated for raster layer_id: {raster_layer_id}") + + # STAC Specs for Raster + try: + layer_STAC_generated = generate_STAC_layerwise.generate_raster_stac( + state=state, + district=district, + block=block, + layer_name="slope_percentage_raster", + ) + update_layer_sync_status( + layer_id=raster_layer_id, + is_stac_specs_generated=layer_STAC_generated, + ) + print("STAC metadata updated for Slope Percentage raster") + except Exception as e: + print(f"Error generating STAC for raster: {e}") + + return layer_at_geoserver diff --git a/computing/misc/soge_vector_local_compute.py b/computing/misc/soge_vector_local_compute.py new file mode 100644 index 00000000..b022d92f --- /dev/null +++ b/computing/misc/soge_vector_local_compute.py @@ -0,0 +1,218 @@ +import os +import geopandas as gpd +import pandas as pd + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_vector_path, + load_precomputed_watersheds, + read_validated_vector_file, + write_vector_output, + validate_geometry, +) +from computing.STAC_specs import generate_STAC_layerwise + +from computing.config_loader import ( + PAN_INDIA_SOGE_PATH, + LOCAL_SOGE_OUTPUT, +) + +GEOSERVER_WORKSPACE = "soge" + + +def _compute_soge_for_watersheds(watersheds_gdf, soge_gdf): + """ + Finds the largest intersecting SOGE feature for each watershed and transfers its properties. + Mimics the Earth Engine implementation using GeoPandas overlay and area calculations. + """ + # Calculate watershed area in ha if missing + if "area_in_ha" not in watersheds_gdf.columns: + mws_metric = watersheds_gdf.to_crs("EPSG:6933") + watersheds_gdf["area_in_ha"] = mws_metric.geometry.area / 10000.0 + + if soge_gdf.empty: + # Create an empty dataframe to merge, forcing the No Data logic below + largest_intersections = pd.DataFrame(columns=["uid"]) + else: + # Reproject to metric CRS for accurate area intersection + metric_crs = "EPSG:6933" + mws_metric = watersheds_gdf.to_crs(metric_crs) + soge_metric = soge_gdf.to_crs(metric_crs) + + # Calculate intersections + intersection_gdf = gpd.overlay(mws_metric, soge_metric, how='intersection') + intersection_gdf["intersection_area_ha"] = intersection_gdf.geometry.area / 10000.0 + + # Sort and keep the largest intersection per watershed uid + intersection_gdf = intersection_gdf.sort_values(by="intersection_area_ha", ascending=False) + largest_intersections = intersection_gdf.drop_duplicates(subset=["uid"], keep="first").copy() + + # Calculate percentage area + largest_intersections["pct_area_soge"] = ( + largest_intersections["intersection_area_ha"] / largest_intersections["area_in_ha"] + ) * 100.0 + + # Rename columns to match desired output + col_mapping = { + "intersection_area_ha": "max_intersection_area_ha", + "block": "soge_block", + "district": "soge_district", + "objectid": "soge_objectid", + "state": "soge_state", + "tehsil": "soge_tehsil", + } + largest_intersections = largest_intersections.rename(columns=col_mapping) + + # Columns to transfer from SOGE to MWS + target_cols = [ + "uid", "max_intersection_area_ha", "pct_area_soge", "class", + "agwd_dom_i", "agwd_irr", "agwd_tot", "ar_gwr_tot", "code", + "gwr_2011_2", "na_gwa", "nat_discha", "sgw_dev_pe", + "soge_block", "soge_district", "soge_objectid", "soge_state", "soge_tehsil" + ] + + # Keep only target columns that actually exist in the intersections + cols_to_keep = [c for c in target_cols if c in largest_intersections.columns] + + # Left join to retain all original watersheds + result_gdf = watersheds_gdf.merge(largest_intersections[cols_to_keep], on="uid", how="left") + + # Fill defaults for No Data / non-intersecting + result_gdf["max_intersection_area_ha"] = result_gdf.get("max_intersection_area_ha", pd.Series(dtype=float)).fillna(0) + result_gdf["pct_area_soge"] = result_gdf.get("pct_area_soge", pd.Series(dtype=float)).fillna(0) + result_gdf["class"] = result_gdf.get("class", pd.Series(dtype=str)).fillna("No Data") + + numeric_cols = [ + "agwd_dom_i", "agwd_irr", "agwd_tot", "ar_gwr_tot", "code", + "gwr_2011_2", "na_gwa", "nat_discha", "sgw_dev_pe", "soge_objectid" + ] + for c in numeric_cols: + if c in result_gdf.columns: + result_gdf[c] = result_gdf[c].fillna(-9999) + else: + result_gdf[c] = -9999 + + string_cols = ["soge_block", "soge_district", "soge_state", "soge_tehsil"] + for c in string_cols: + if c in result_gdf.columns: + result_gdf[c] = result_gdf[c].fillna("") + else: + result_gdf[c] = "" + + return result_gdf + + +@app.task(bind=True) +def generate_soge_vector_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name = f"soge_vector_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name = f"soge_vector_{asset_suffix}".lower() + watersheds_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") + print(f"ROI source: {roi_path}") + + if not os.path.exists(PAN_INDIA_SOGE_PATH): + print(f"Warning: PAN INDIA SOGE file not found at {PAN_INDIA_SOGE_PATH}. Proceeding with No Data.") + # Create empty dummy GDF + soge_gdf = gpd.GeoDataFrame(geometry=[]) + else: + print("Loading SOGE data overlapping ROI...") + soge_gdf = gpd.read_file(PAN_INDIA_SOGE_PATH, mask=watersheds_gdf) + soge_gdf = validate_geometry(soge_gdf) + if soge_gdf.empty: + raise ValueError("PAN INDIA SOGE file has no valid geometries overlapping ROI") + print(f"Loaded {len(soge_gdf)} SOGE features") + + result_gdf = _compute_soge_for_watersheds( + watersheds_gdf=watersheds_gdf, + soge_gdf=soge_gdf, + ) + print(f"Final valid SOGE mapped features: {len(result_gdf)}") + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_SOGE_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local SOGE vector: {asset_id}") + + layer_at_geoserver = False + + 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 geoserver_response and geoserver_response.get("status_code") in (200, 201): + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="SOGE", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for SOGE vector") + + try: + layer_STAC_generated = generate_STAC_layerwise.generate_vector_stac( + state=state, + district=district, + block=block, + layer_name="stage_of_groundwater_extraction_vector", + ) + update_layer_sync_status( + layer_id=layer_id, is_stac_specs_generated=layer_STAC_generated + ) + print("STAC metadata updated for SOGE vector") + except Exception as e: + print(f"Error generating STAC: {e}") + + return layer_at_geoserver + + + diff --git a/computing/mws/mws_centroid_local_compute.py b/computing/mws/mws_centroid_local_compute.py new file mode 100644 index 00000000..679dfdc0 --- /dev/null +++ b/computing/mws/mws_centroid_local_compute.py @@ -0,0 +1,125 @@ +import os +import geopandas as gpd + +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) +from computing.local_compute_helper import ( + PROJECT_ROOT, + build_output_vector_path, + load_precomputed_watersheds, + read_validated_vector_file, + write_vector_output, +) +from computing.config_loader import LOCAL_MWS_CENTROID_OUTPUT + +GEOSERVER_WORKSPACE = "mws_centroid" + + +def _compute_mws_centroids(watersheds_gdf): + """ + Computes the centroid of each watershed polygon and extracts lat/lon. + """ + if watersheds_gdf.empty: + return watersheds_gdf + + # Create a copy so we don't modify the original + centroids_gdf = watersheds_gdf.copy() + + # Ensure WGS84 for correct lat/lon coordinate extraction + if centroids_gdf.crs != "EPSG:4326": + centroids_gdf = centroids_gdf.to_crs("EPSG:4326") + + # Replace polygon geometry with point centroid + centroids_gdf["geometry"] = centroids_gdf.geometry.centroid + + # Extract coordinates + centroids_gdf["centroid_lon"] = centroids_gdf.geometry.x + centroids_gdf["centroid_lat"] = centroids_gdf.geometry.y + + return centroids_gdf + + +@app.task(bind=True) +def generate_mws_centroid_data_local( + self, + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + gee_account_id=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + _ = self, gee_account_id + if state and district and block: + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_mws_centroid" + 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}") + else: + if not roi_path or not asset_suffix: + raise ValueError("ROI path and asset_suffix are required for custom runs.") + layer_name = f"{valid_gee_text(asset_suffix).lower()}_mws_centroid" + watersheds_gdf = read_validated_vector_file( + roi_path, f"Invalid ROI file: {roi_path}" + ) + print(f"ROI source: {roi_path}") + + print("Computing MWS centroids...") + result_gdf = _compute_mws_centroids(watersheds_gdf=watersheds_gdf) + print(f"Computed centroids for {len(result_gdf)} features") + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_MWS_CENTROID_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local MWS centroid vector: {asset_id}") + + layer_at_geoserver = False + + 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 geoserver_response and geoserver_response.get("status_code") in (200, 201): + layer_at_geoserver = True + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Mws Centroid", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for MWS Centroid vector") + + return layer_at_geoserver diff --git a/computing/mws/mws_connectivity_local_compute.py b/computing/mws/mws_connectivity_local_compute.py new file mode 100644 index 00000000..e1765896 --- /dev/null +++ b/computing/mws/mws_connectivity_local_compute.py @@ -0,0 +1,150 @@ +import os +import json +import datetime +import pandas as pd +import geopandas as gpd +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text +from computing.local_compute_helper import ( + PROJECT_ROOT, + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + build_output_vector_path, + get_watershed_areas_in_hectares, + load_precomputed_watersheds, + read_validated_vector_file, + validate_geometry, + write_vector_output, +) +from computing.config_loader import ( + PAN_INDIA_MWS_CONNECTIVITY_PATH, + LOCAL_MWS_CONNECTIVITY_OUTPUT, +) +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, + fix_invalid_geometry_in_gdf, +) + +GEOSERVER_WORKSPACE = "mws_connectivity" + +from shapely.ops import unary_union + + +def _compute_mws_connectivity_for_watersheds(watersheds_gdf, mws_gdf): + mws_in_roi = mws_gdf.copy() + + if mws_in_roi.empty: + print("No MWS connectivity found within the outer boundary.") + return mws_in_roi + + print(f"MWS connectivity within outer boundary: {len(mws_in_roi)}") + + # Step 2: Spatial join to clip results to individual watersheds + mws_in_roi = gpd.sjoin( + mws_in_roi, + watersheds_gdf[["geometry"]], # no uid, no collision + how="inner", + predicate="intersects", + ).drop(columns=["index_right"], errors="ignore") + + # Step 3: Drop empty/invalid geometries + mws_in_roi = fix_invalid_geometry_in_gdf(mws_in_roi) + mws_in_roi = mws_in_roi[ + mws_in_roi.geometry.notna() + & ~mws_in_roi.geometry.is_empty + & mws_in_roi.geometry.is_valid + ] + + print(f"Final valid MWS connectivity: {len(mws_in_roi)}") + return mws_in_roi + + +@app.task(bind=True) +def mws_connectivity_vector( + self, + asset_folder_list=None, + app_type=None, + gee_account_id=None, + state=None, + district=None, + block=None, + asset_suffix=None, + roi=None, + precomputed_roi_dir=None, + push_to_geoserver=True, + sync_layer_metadata=True, +): + if state and district and block: + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}_mws_connectivity" + 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}") + 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}_mws_connectivity".lower() + watersheds_gdf = read_validated_vector_file( + roi, + f"ROI file has no valid geometries: {roi}", + ) + print(f"ROI source: {roi}") + + if not os.path.exists(PAN_INDIA_MWS_CONNECTIVITY_PATH): + raise FileNotFoundError(f"PAN INDIA MWS connectivity file not found") + + mws_gdf = gpd.read_file(PAN_INDIA_MWS_CONNECTIVITY_PATH, mask=watersheds_gdf) + mws_gdf = validate_geometry(mws_gdf) + if mws_gdf.empty: + print("Warning: PAN INDIA MWS connectivity file has no valid geometries overlapping ROI") + + result_gdf = _compute_mws_connectivity_for_watersheds( + watersheds_gdf=watersheds_gdf, + mws_gdf=mws_gdf, + ) + + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_MWS_CONNECTIVITY_OUTPUT, + ) + + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local MWS connectivity 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 sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Mws Connectivity", + misc={"is_generated_locally": True}, + ) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for MWS connectivity vector") + + return True diff --git a/computing/urls.py b/computing/urls.py index 48d6777b..aae36158 100644 --- a/computing/urls.py +++ b/computing/urls.py @@ -258,4 +258,34 @@ api.rainfall_resilience_resistance, name="rainfall_resilience_resistance", ), + path( + "generate_dem_raster_vector/", + api.generate_fabdem_raster_vector, + name="generate-dem-raster-vector", + ), + path( + "generate_canal_vector/", + api.generate_canal_vector, + name="generate-canal-vector", + ), + path( + "generate_river_data/", + api.generate_river_data, + name="generate-river-data", + ), + path( + "generate_density_vector/", + api.generate_drainage_density_data, + name="generate-drainage-density-vector", + ), + path( + "generate_antyodaya/", + api.generate_antyodaya, + name="generate_antyodaya", + ), + path( + "generate_livestocks/", + api.generate_livestocks, + name="generate_livestocks", + ), ] diff --git a/utilities/active_loc_layer_generation.py b/utilities/active_loc_layer_generation.py deleted file mode 100644 index 7eaf1144..00000000 --- a/utilities/active_loc_layer_generation.py +++ /dev/null @@ -1,110 +0,0 @@ -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 deleted file mode 100644 index 54dcc84d..00000000 --- a/utilities/active_location_for_layer_gen.json +++ /dev/null @@ -1,232 +0,0 @@ -[ -{ - "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/download_gpkg_from_geoserver.py b/utilities/download_gpkg_from_geoserver.py new file mode 100644 index 00000000..79a8ee3f --- /dev/null +++ b/utilities/download_gpkg_from_geoserver.py @@ -0,0 +1,126 @@ +import requests +import geopandas as gpd +from pathlib import Path + +from nrm_app import settings +from utilities.gee_utils import valid_gee_text +from computing.config_loader import PROJECT_ROOT + +""" +from utilities.download_gpkg_from_geoserver import generate_gpkg +generate_gpkg(state="Odisha", district="Srikakulam", block="Bhamini", workspace="panchayat_boundaries") +""" + +BASE_OUTPUT_DIR = PROJECT_ROOT / "data/base_layers/" + + +def build_layer_and_path(state, district, block, workspace): + """ + Create: + - sanitized names + - geoserver layer name + - gpkg output path + + Example Output: + mws_gpkg/odisha/srikakulam/bhamini.gpkg + """ + + state = valid_gee_text(state.lower()) + district = valid_gee_text(district.lower()) + block = valid_gee_text(block.lower()) + + # GeoServer layer + output_base = BASE_OUTPUT_DIR + + if workspace == "mws": + layer_name = f"mws:mws_{district}_{block}" + output_base = output_base / "tehsil_watersheds" + elif workspace == "panchayat_boundaries": + layer_name = f"panchayat_boundaries:{district}_{block}" + output_base = output_base / "village_boundaries" + + output_dir = output_base / state / district + output_dir.mkdir(parents=True, exist_ok=True) + gpkg_path = output_dir / f"{block}.gpkg" + + return layer_name, gpkg_path, district, block + + +def read_layer_from_geoserver(layer_name): + """ + Read GeoServer WFS layer directly into GeoDataFrame. + """ + + params = { + "service": "WFS", + "version": "2.0.0", + "request": "GetFeature", + "typeName": layer_name, + "outputFormat": "application/json", + "srsName": "EPSG:4326", + } + + geoserver_url = settings.PROD_GEOSERVER_URL.rstrip("/") + wfs_url = f"{geoserver_url}/wfs" + + # Some workspaces might be protected, use auth if available + auth = None + if hasattr(settings, "GEOSERVER_USER") and hasattr(settings, "GEOSERVER_PASSWORD"): + auth = (settings.GEOSERVER_USER, settings.GEOSERVER_PASSWORD) + + response = requests.get( + wfs_url, + params=params, + auth=auth, + timeout=120, + verify=False, + ) + + response.raise_for_status() + + # Verify we actually got JSON and not an HTML redirect page + if "text/html" in response.headers.get("Content-Type", ""): + raise ValueError( + f"GeoServer returned HTML instead of JSON. Check the WFS URL and layer name: {layer_name}" + ) + + return gpd.read_file(response.text) + + +def generate_gpkg(state, district, block, workspace): + """ + Generate GPKG for each location. + """ + + try: + layer_name, gpkg_path, district, block = build_layer_and_path( + state, district, block, workspace + ) + gdf = read_layer_from_geoserver(layer_name) + + if gdf.empty: + print("Layer is empty") + return None + + # CRS handling + if gdf.crs is None: + gdf = gdf.set_crs("EPSG:4326") + else: + gdf = gdf.to_crs("EPSG:4326") + + # Remove existing gpkg + if gpkg_path.exists(): + gpkg_path.unlink() + + # Write gpkg + gdf.to_file( + gpkg_path, + layer=f"{district}_{block}", + driver="GPKG", + ) + print(f"GPKG file created successfully : {gpkg_path}") + return str(gpkg_path) + + except Exception as e: + print(f"FAILED : {e}") + return None From ce17357c1127f089bfe9d8f130eee73d4c1dadd3 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Mon, 22 Jun 2026 17:41:56 +0530 Subject: [PATCH 026/120] sync to dev + clean --- computing/api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/computing/api.py b/computing/api.py index 3b78db0e..04ad7d62 100644 --- a/computing/api.py +++ b/computing/api.py @@ -109,7 +109,6 @@ from .mws.mws_centroid import generate_mws_centroid_data from .mws.mws_connectivity import generate_mws_connectivity_data from .plantation.site_suitability import site_suitability -from .STAC_specs.stac_collection import _make_celery_task as _make_stac_task from .surface_water_bodies.merge_swb_ponds import merge_swb_ponds from .surface_water_bodies.swb import generate_swb_layer as generate_swb_gee_task from .surface_water_bodies.swb_local import ( From cb30193b4be21611e2949a4e6b950395856d7289 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Mon, 22 Jun 2026 18:33:41 +0530 Subject: [PATCH 027/120] config --- computing/config_new.yaml | 132 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 computing/config_new.yaml diff --git a/computing/config_new.yaml b/computing/config_new.yaml new file mode 100644 index 00000000..a3efb5a9 --- /dev/null +++ b/computing/config_new.yaml @@ -0,0 +1,132 @@ +# Local compute manifest. + +base_layers: + static_layers: + - name: mws + local_path: data/base_layers/static_layers/mws/Microwatershed_v2_with_details.geojson + source: s3://corestack-datasets/base_layers/static_layers/mws/Microwatershed_v2_with_details.geojson + type: file + + - name: terrain + local_path: data/base_layers/terrain_raster_fabdam_pan_india.tif + source: s3://corestack-datasets/base_layers/static_layers/terrain/terrain_raster_fabdam_pan_india.tif + type: file + + - name: slope percentage + local_path: data/base_layers/slope_percentage/slope_percentage.tif + source: s3://corestack-datasets/base_layers/static_layers/slope_percentage/slope_percentage.tif + type: file + + - name: aquifer + local_path: data/base_layers/aquifer/aquifer.tif + source: s3://corestack-datasets/base_layers/static_layers/aquifer/aquifer.tif + type: file + + - name: aez + local_path: data/base_layers/AEZs/Agro_Ecological_Regions.shp + source: s3://corestack-datasets/base_layers/AEZs/Agro_Ecological_Regions.shp + type: file + + - name: restoration opportunity + local_path: data/base_layers/restoration_opportunity/restoration_opportunity.geojson + source: s3://corestack-datasets/base_layers/static_layers/restoration_opportunity/restoration_opportunity.geojson + type: file + + - name: soge + local_path: data/base_layers/soge/soge.geojson + source: s3://corestack-datasets/base_layers/static_layers/soge/soge.geojson + type: file + + - name: lcw + local_path: data/base_layers/lcw/lcw.tif + source: s3://corestack-datasets/base_layers/static_layers/lcw/lcw.tif + type: file + + - name: factory csr + local_path: data/base_layers/factory_csr/factory_csr.geojson + source: s3://corestack-datasets/base_layers/static_layers/factory_csr/factory_csr.geojson + type: file + + - name: mining + local_path: data/base_layers/mining/mining.geojson + source: s3://corestack-datasets/base_layers/static_layers/mining/mining.geojson + type: file + + - name: facilities + local_path: data/base_layers/facilities/facilities.geojson + source: s3://corestack-datasets/base_layers/static_layers/facilities/facilities.geojson + type: file + + - name: dem + local_path: data/base_layers/dem/dem.tif + source: s3://corestack-datasets/base_layers/static_layers/dem/dem.tif + type: file + + - name: river + local_path: data/base_layers/river/river.geojson + source: s3://corestack-datasets/base_layers/static_layers/river/river.geojson + type: file + + - name: canal + local_path: data/base_layers/canal/canal.geojson + source: s3://corestack-datasets/base_layers/static_layers/canal/canal.geojson + type: file + + - name: hydrological soil group + local_path: data/base_layers/hydrological_soil_group/hydrological_soil_group.geojson + source: s3://corestack-datasets/base_layers/static_layers/hydrological_soil_group/hydrological_soil_group.geojson + type: file + + periodic_layers: + - name: lulc_v3 + filename: lulc_v3_{year}_{year+1}.tif + local_path: data/base_layers/lulc/{filename} + periodicity: annual + start_year: 2017 + end_year: 2024 + source: s3://corestack-datasets/base_layers/periodic_layers/lulc/lulc_v3/{filename} + type: file + + - name: lulc_v4 + filename: lulc_v4_{year}_{year+1}.tif + local_path: data/base_layers/lulc/{filename} + periodicity: annual + start_year: 2017 + end_year: 2024 + source: s3://corestack-datasets/base_layers/periodic_layers/lulc/lulc_v4/{filename} + type: file + + on_demand_layers: + - name: farm_boundary + local_path: data/base_layers/farm_boundary/farm_boundary.geojson + +derived_layers: # layers that are computed + - name: change detection + params: [ + Afforestation, + Deforestation, + Degradation, + Urbanization, + CropIntensity + ] + filename: change_{district}_{tehsil}_{param_name}_{start_year}_{end_year}.tif + local_path: data/derived_layers/change_detection/{filename} + geoserver_workspace: change_detection + layer_type: raster + + - name: change detection vector + params: [ + Afforestation, + Deforestation, + Degradation, + Urbanization, + CropIntensity + ] + filename: change_vector_{district}_{tehsil}_{param_name}_{start_year}_{end_year}.geojson + local_path: data/derived_layers/change_detection/{filename} + geoserver_workspace: change_detection + layer_type: vector + + + + \ No newline at end of file From cbbadd7c66a342c0dea6bed4739bf29337663719 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Tue, 30 Jun 2026 12:19:13 +0530 Subject: [PATCH 028/120] changes made to terrain descriptor to not generate layers when tehsil is not passed --- computing/api.py | 7 + computing/base_layer_setup.py | 196 +++++++++++++-- computing/config_loader.py | 226 ++++++++++++++---- computing/config_new.yaml | 3 + .../terrain_compute_all_local.py | 103 +------- 5 files changed, 364 insertions(+), 171 deletions(-) diff --git a/computing/api.py b/computing/api.py index 04ad7d62..c225b99e 100644 --- a/computing/api.py +++ b/computing/api.py @@ -810,6 +810,10 @@ def generate_terrain_compute_all(request): state = request.data.get("state") district = request.data.get("district") block = request.data.get("block") + if block is None or str(block).strip().lower() in {"", "null", "none"}: + raise ValueError( + "block is null. Please provide a block value for terrain compute-all." + ) start_year = request.data.get("start_year") end_year = request.data.get("end_year") gee_account_id = request.data.get("gee_account_id") @@ -821,6 +825,9 @@ def generate_terrain_compute_all(request): {"Success": "generate_terrain_compute_all task initiated"}, status=status.HTTP_200_OK, ) + except ValueError as e: + print("Invalid request in generate_terrain_compute_all api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in generate_terrain_compute_all api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/computing/base_layer_setup.py b/computing/base_layer_setup.py index 98ddedee..036cfdc4 100644 --- a/computing/base_layer_setup.py +++ b/computing/base_layer_setup.py @@ -2,8 +2,10 @@ import subprocess from functools import wraps from pathlib import Path +from urllib.parse import urlparse import requests +import yaml from computing.config_loader import ( ADMIN_BOUNDARY_INPUT_DIR, @@ -28,13 +30,10 @@ from computing.config_loader import ( PRECOMPUTED_TEHSIL_WATERSHED_DIR as TEHSIL_WATERSHEDS_DIR, ) -from computing.terrain_descriptor.store_watersheds_for_tehsils import ( - generate_tehsil_watershed_copies, -) -from utilities.constants import GEOSERVER_BASE - logger = logging.getLogger(__name__) +CONFIG_NEW_PATH = Path(__file__).resolve().parent / "config_new.yaml" + _SOI_WFS_PARAMS = { "service": "WFS", "version": "1.0.0", @@ -48,6 +47,154 @@ def _is_dir_populated(path: Path) -> bool: return path.is_dir() and any(path.iterdir()) +def _layer_key(name: str) -> str: + return name.strip().lower().replace(" ", "_").replace("-", "_") + + +def _load_new_config() -> dict: + with open(CONFIG_NEW_PATH) as f: + return yaml.safe_load(f) or {} + + +def _format_periodic_value(template: str, year: int) -> str: + return template.replace("{year+1}", str(year + 1)).replace("{year}", str(year)) + + +def _expand_periodic_layer(layer: dict) -> list[dict]: + if layer.get("periodicity") != "annual": + raise ValueError( + f"Unsupported periodicity for base layer '{layer.get('name')}': " + f"{layer.get('periodicity')}" + ) + + expanded_layers = [] + for year in range(int(layer["start_year"]), int(layer["end_year"]) + 1): + filename = _format_periodic_value(layer["filename"], year) + expanded = dict(layer) + expanded["year"] = year + expanded["filename"] = filename + expanded["local_path"] = _format_periodic_value( + layer["local_path"].replace("{filename}", filename), year + ) + expanded["source"] = _format_periodic_value( + layer["source"].replace("{filename}", filename), year + ) + expanded_layers.append(expanded) + + return expanded_layers + + +def _manifest_layer_groups() -> dict[str, list[dict]]: + base_layers = _load_new_config().get("base_layers", {}) + groups = { + "static_layers": list(base_layers.get("static_layers", [])), + "on_demand_layers": list(base_layers.get("on_demand_layers", [])), + "periodic_layers": [], + } + + for layer in base_layers.get("periodic_layers", []): + groups["periodic_layers"].extend(_expand_periodic_layer(layer)) + + return groups + + +def _manifest_layer_index() -> dict[str, list[dict]]: + index = {} + for group_name, layers in _manifest_layer_groups().items(): + index.setdefault(group_name, []).extend(layers) + for layer in layers: + index.setdefault(_layer_key(layer["name"]), []).append(layer) + + all_layers = [] + for layers in index.values(): + all_layers.extend(layers) + index["all"] = list({id(layer): layer for layer in all_layers}.values()) + return index + + +def _download_s3_file(source: str, destination: Path): + parsed = urlparse(source) + if parsed.scheme != "s3" or not parsed.netloc or not parsed.path: + raise ValueError(f"Invalid S3 source: {source}") + + try: + import boto3 + except ImportError as exc: + raise RuntimeError("boto3 is required to download base layers from S3") from exc + + destination.parent.mkdir(parents=True, exist_ok=True) + temp_destination = destination.with_suffix(destination.suffix + ".part") + + logger.info("Downloading %s to %s", source, destination) + try: + boto3.client("s3").download_file( + parsed.netloc, + parsed.path.lstrip("/"), + str(temp_destination), + ) + temp_destination.replace(destination) + except Exception: + if temp_destination.exists(): + temp_destination.unlink() + raise + + +def ensure_manifest_base_layers(*layers): + """ + Downloads base layers declared in config_new.yaml. + + Accepted selectors: + - concrete layer names: terrain, mws, lulc_v3, slope_percentage + - group names: static_layers, periodic_layers, on_demand_layers + - all + """ + selected_layers = layers or ("static_layers", "periodic_layers") + index = _manifest_layer_index() + + for selected_layer in selected_layers: + layer_key = _layer_key(selected_layer) + layer_specs = index.get(layer_key) + if layer_specs is None: + available_layers = ", ".join(sorted(index)) + raise ValueError( + f"Unknown manifest base layer '{selected_layer}'. " + f"Available layers/groups: {available_layers}" + ) + + for layer in layer_specs: + if layer.get("type") != "file": + raise ValueError( + f"Unsupported base layer type for '{layer.get('name')}': " + f"{layer.get('type')}" + ) + + local_path = PROJECT_ROOT / layer["local_path"] + if local_path.exists(): + logger.info( + "Base layer %s already exists at %s, skipping.", + layer["name"], + local_path, + ) + continue + + source = layer.get("source") + if not source: + logger.warning( + "Base layer %s has no source in %s; create %s manually.", + layer["name"], + CONFIG_NEW_PATH, + local_path, + ) + continue + + if source.startswith("s3://"): + _download_s3_file(source, local_path) + else: + raise ValueError( + f"Unsupported source for base layer '{layer['name']}': {source}" + ) + + def ensure_soi_tehsil(): """ Downloads the SOI tehsil GeoJSON from GeoServer if not already present. @@ -60,6 +207,8 @@ def ensure_soi_tehsil(): SOI_TEHSIL_PATH.parent.mkdir(parents=True, exist_ok=True) + from utilities.constants import GEOSERVER_BASE + wfs_url = f"{GEOSERVER_BASE}pan_india_asset/ows" logger.info("Downloading SOI tehsil layer from GeoServer...") try: @@ -226,6 +375,10 @@ def ensure_tehsil_watersheds(): 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), @@ -255,24 +408,30 @@ def ensure_village_boundaries_dir(): } DEFAULT_BASE_LAYERS = ( - "soi_tehsil", - "admin_boundary", - "village_boundaries", + "static_layers", + "periodic_layers", ) def setup_base_layers(*layers): selected_layers = layers or DEFAULT_BASE_LAYERS - for layer in selected_layers: - try: - ensure_layer = _BASE_LAYER_ENSURERS[layer] - except KeyError as exc: - available_layers = ", ".join(sorted(_BASE_LAYER_ENSURERS)) - raise ValueError( - f"Unknown base layer '{layer}'. Available layers: {available_layers}" - ) from exc + manifest_index = _manifest_layer_index() - ensure_layer() + for layer in selected_layers: + if layer in _BASE_LAYER_ENSURERS: + _BASE_LAYER_ENSURERS[layer]() + continue + + if _layer_key(layer) in manifest_index: + ensure_manifest_base_layers(layer) + continue + + legacy_layers = ", ".join(sorted(_BASE_LAYER_ENSURERS)) + manifest_layers = ", ".join(sorted(manifest_index)) + raise ValueError( + f"Unknown base layer '{layer}'. Legacy layers: {legacy_layers}. " + f"Manifest layers/groups: {manifest_layers}" + ) def with_base_layers(*layers): @@ -285,3 +444,6 @@ def wrapper(*args, **kwargs): return wrapper return decorator + + +download_base_layers = with_base_layers diff --git a/computing/config_loader.py b/computing/config_loader.py index 41df82d9..3969f32d 100644 --- a/computing/config_loader.py +++ b/computing/config_loader.py @@ -2,16 +2,18 @@ import yaml -_CONFIG_PATH = Path(__file__).resolve().parent / "config.yaml" +_CONFIG_PATH = Path(__file__).resolve().parent / "config_new.yaml" +_LEGACY_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) +def _load(path: Path) -> dict: + with open(path) as f: + return yaml.safe_load(f) or {} -_cfg = _load() +_cfg = _load(_CONFIG_PATH) +_legacy_cfg = _load(_LEGACY_CONFIG_PATH) def _abs(rel_path: str) -> Path: @@ -19,15 +21,103 @@ def _abs(rel_path: str) -> Path: return PROJECT_ROOT / base -def _find_input(path_suffix: str) -> dict: - for item in _cfg["base_layers"]["inputs"]: +def _layer_key(name: str) -> str: + return name.strip().lower().replace(" ", "_").replace("-", "_") + + +def _format_periodic_value(template: str, year: int) -> str: + return template.replace("{year+1}", str(year + 1)).replace("{year}", str(year)) + + +def _expand_periodic_layer(layer: dict) -> list[dict]: + if layer.get("periodicity") != "annual": + raise ValueError( + f"Unsupported periodicity for base layer '{layer.get('name')}': " + f"{layer.get('periodicity')}" + ) + + expanded_layers = [] + for year in range(int(layer["start_year"]), int(layer["end_year"]) + 1): + filename = _format_periodic_value(layer["filename"], year) + expanded = dict(layer) + expanded["year"] = year + expanded["filename"] = filename + expanded["local_path"] = _format_periodic_value( + layer["local_path"].replace("{filename}", filename), year + ) + source = layer.get("source") + if source: + expanded["source"] = _format_periodic_value( + source.replace("{filename}", filename), year + ) + expanded_layers.append(expanded) + + return expanded_layers + + +def _manifest_base_layers() -> list[dict]: + base_layers = _cfg.get("base_layers", {}) + layers = [] + layers.extend(base_layers.get("static_layers", [])) + layers.extend(base_layers.get("on_demand_layers", [])) + for layer in base_layers.get("periodic_layers", []): + layers.extend(_expand_periodic_layer(layer)) + return layers + + +def _base_layer(name: str, *, required: bool = True) -> dict | None: + key = _layer_key(name) + for layer in _manifest_base_layers(): + if _layer_key(layer["name"]) == key: + return layer + if required: + raise KeyError(f"No base layer found in {_CONFIG_PATH.name} for name: {name}") + return None + + +def _base_layer_path( + name: str, + fallback: str | None = None, + allowed_suffixes: tuple[str, ...] | None = None, +) -> Path: + layer = _base_layer(name, required=False) + if layer and layer.get("local_path"): + local_path = Path(layer["local_path"]) + if not allowed_suffixes or local_path.suffix.lower() in allowed_suffixes: + return PROJECT_ROOT / local_path + if fallback: + return PROJECT_ROOT / fallback + raise KeyError(f"No local_path found in {_CONFIG_PATH.name} for base layer: {name}") + + +def _find_legacy_input(path_suffix: str) -> dict: + for item in _legacy_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] +def _derived_layer(name: str) -> dict | None: + key = _layer_key(name) + for layer in _cfg.get("derived_layers", []): + if _layer_key(layer.get("name", "")) == key and layer.get("local_path"): + return layer + return None + + +def _derived_output_dir( + name: str, + legacy_module: str, + legacy_index: int = 0, +) -> Path: + layer = _derived_layer(name) + if layer: + return _abs(layer["local_path"]) + return _abs(_legacy_output_entry(legacy_module, legacy_index)["path"]) + + +def _legacy_output_entry(module: str, index: int = 0) -> dict: + return _legacy_cfg["local_compute_outputs"][module][index] # --------------------------------------------------------------------------- @@ -36,37 +126,34 @@ def _output_entry(module: str, index: int = 0) -> dict: LULC_BASE_DIR: Path = _abs( next( - item["path"] - for item in _cfg["base_layers"]["inputs"] - if item["path"].startswith("data/base_layers/lulc/") + layer["local_path"] + for layer in _manifest_base_layers() + if layer["local_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"] +TERRAIN_RASTER_PATH: Path = _base_layer_path("terrain") -AEZ_VECTOR_PATH: Path = PROJECT_ROOT / _find_input( - "data/base_layers/AEZs/Agro_Ecological_Regions.shp" -)["path"] +AEZ_VECTOR_PATH: Path = _base_layer_path("aez") PRECOMPUTED_TEHSIL_WATERSHED_DIR: Path = _abs( - _find_input("data/base_layers/tehsil_watersheds/")["path"] + _find_legacy_input("data/base_layers/tehsil_watersheds/")["path"] ) -MICROWATERSHED_PATH: Path = PROJECT_ROOT / _find_input( - "data/base_layers/Microwatershed_v2_with_details.geojson" -)["path"] +MICROWATERSHED_PATH: Path = _base_layer_path("mws") -AQUIFER_VECTOR_PATH: Path = PROJECT_ROOT / _find_input( - "data/base_layers/Aquifer_vector.geojson" -)["path"] +AQUIFER_VECTOR_PATH: Path = _base_layer_path( + "aquifer", + fallback="data/base_layers/Aquifer_vector.geojson", + allowed_suffixes=(".geojson", ".gpkg", ".shp"), +) -SWB_VECTOR_PATH: Path = PROJECT_ROOT / _find_input( - "data/base_layers/pan_india_waterbodies.geojson" -)["path"] +SWB_VECTOR_PATH: Path = _base_layer_path( + "surface water bodies", + fallback="data/base_layers/pan_india_waterbodies.geojson", +) -SOI_TEHSIL_PATH: Path = PROJECT_ROOT / _find_input( +SOI_TEHSIL_PATH: Path = PROJECT_ROOT / _find_legacy_input( "data/admin-boundary/input/soi_tehsil.geojson" )["path"] @@ -78,30 +165,41 @@ def _output_entry(module: str, index: int = 0) -> dict: # Google Drive IDs # --------------------------------------------------------------------------- -GDRIVE_ADMIN_BOUNDARY_FILE_ID: str = _find_input("data/admin-boundary/input/")["gdrive_id"] -GDRIVE_MICROWATERSHED_FILE_ID: str = _find_input( +GDRIVE_ADMIN_BOUNDARY_FILE_ID: str = _find_legacy_input( + "data/admin-boundary/input/" +)["gdrive_id"] +GDRIVE_MICROWATERSHED_FILE_ID: str = _find_legacy_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" + for item in _legacy_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_V3_OUTPUT_DIR: Path = _abs(_output_entry("lulc", 1)["path"]) -LULC_SLOPE_CLUSTER_OUTPUT_DIR: Path = _abs(_output_entry("lulc_x_terrain", 0)["path"]) -LULC_PLAIN_CLUSTER_OUTPUT_DIR: Path = _abs(_output_entry("lulc_x_terrain", 1)["path"]) -AQUIFER_VECTOR_OUTPUT_DIR: Path = _abs(_output_entry("misc", 0)["path"]) +CHANGE_DETECTION_RASTER_OUTPUT_DIR: Path = _derived_output_dir( + "change detection", "change_detection", 0 +) +CHANGE_DETECTION_VECTOR_OUTPUT_DIR: Path = _derived_output_dir( + "change detection vector", "change_detection", 1 +) +LULC_VECTOR_OUTPUT_DIR: Path = _derived_output_dir("lulc vector", "lulc", 0) +LULC_V3_OUTPUT_DIR: Path = _derived_output_dir("lulc v3", "lulc", 1) +LULC_SLOPE_CLUSTER_OUTPUT_DIR: Path = _derived_output_dir( + "lulc slope clusters", "lulc_x_terrain", 0 +) +LULC_PLAIN_CLUSTER_OUTPUT_DIR: Path = _derived_output_dir( + "lulc plain clusters", "lulc_x_terrain", 1 +) +AQUIFER_VECTOR_OUTPUT_DIR: Path = _derived_output_dir("aquifer vector", "misc", 0) SWB_VECTOR_OUTPUT_DIR: Path = _abs( - _output_entry("surface_water_bodies", 0)["path"] + _legacy_output_entry("surface_water_bodies", 0)["path"] ) @@ -114,25 +212,38 @@ def _output_entry(module: str, index: int = 0) -> dict: LOCAL_DRAINAGE_DENSITY_OUTPUT = PROJECT_ROOT / "data/drainage_density" -PAN_INDIA_CANAL_PATH = PROJECT_ROOT / "data/canal/Canal_pan_india.geojson" +PAN_INDIA_CANAL_PATH = _base_layer_path( + "canal", fallback="data/canal/Canal_pan_india.geojson" +) LOCAL_CANAL_OUTPUT = PROJECT_ROOT / "data/canal/canal_local" -PAN_INDIA_AGROECOLOGICAL_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_agroecological_farming.geojson" +PAN_INDIA_AGROECOLOGICAL_PATH = _base_layer_path( + "aez", + fallback="data/base_layers/Pan_India_agroecological_farming.geojson", +) LOCAL_AGROECOLOGICAL_OUTPUT = PROJECT_ROOT / "data/layers/agroecological" -PAN_INDIA_LCW_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_lcw_conflict.geojson" +PAN_INDIA_LCW_PATH = _base_layer_path( + "lcw", fallback="data/base_layers/Pan_India_lcw_conflict.geojson" +) LOCAL_LCW_OUTPUT = PROJECT_ROOT / "data/layers/lcw_conflict" -PAN_INDIA_SOGE_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_SOGE_2020.geojson" +PAN_INDIA_SOGE_PATH = _base_layer_path( + "soge", fallback="data/base_layers/Pan_India_SOGE_2020.geojson" +) LOCAL_SOGE_OUTPUT = PROJECT_ROOT / "data/layers/SOGE_vector" -PAN_INDIA_FACTORY_CSR_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_factory_csr.geojson" +PAN_INDIA_FACTORY_CSR_PATH = _base_layer_path( + "factory csr", fallback="data/base_layers/Pan_India_factory_csr.geojson" +) LOCAL_FACTORY_CSR_OUTPUT = PROJECT_ROOT / "data/layers/factory_csr" PAN_INDIA_GREEN_CREDIT_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_green_credit.geojson" LOCAL_GREEN_CREDIT_OUTPUT = PROJECT_ROOT / "data/layers/green_credit" -PAN_INDIA_MINING_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_mining.geojson" +PAN_INDIA_MINING_PATH = _base_layer_path( + "mining", fallback="data/base_layers/Pan_India_mining.geojson" +) LOCAL_MINING_OUTPUT = PROJECT_ROOT / "data/layers/mining" PAN_INDIA_NATURALDEPRESSION_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_natural_depression.tif" @@ -141,12 +252,16 @@ def _output_entry(module: str, index: int = 0) -> dict: PAN_INDIA_DISTANCETONEARESTDRAINAGE_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_distance_to_nearest_drainage.tif" LOCAL_DISTANCETONEARESTDRAINAGE_OUTPUT = PROJECT_ROOT / "data/layers/distance_nearest_upstream_DL" -PAN_INDIA_FACILITIES_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_facilities_polygon.geojson" +PAN_INDIA_FACILITIES_PATH = _base_layer_path( + "facilities", fallback="data/base_layers/Pan_India_facilities_polygon.geojson" +) LOCAL_FACILITIES_OUTPUT = PROJECT_ROOT / "data/layers/facilities" PAN_INDIA_CATCHMENT_AREA_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_catchment_area.tif" LOCAL_CATCHMENT_AREA_OUTPUT = PROJECT_ROOT / "data/layers/catchment_area_singleflow" -PAN_INDIA_SLOPE_PERCENTAGE_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_slope_percentage.tif" +PAN_INDIA_SLOPE_PERCENTAGE_PATH = _base_layer_path( + "slope percentage", fallback="data/base_layers/Pan_India_slope_percentage.tif" +) LOCAL_SLOPE_PERCENTAGE_OUTPUT = PROJECT_ROOT / "data/layers/slope_percentage" PAN_INDIA_MWS_CONNECTIVITY_PATH = PROJECT_ROOT / "data/layers/mws_connectivity/Pan_India_mws_connectivity.geojson" @@ -155,17 +270,22 @@ def _output_entry(module: str, index: int = 0) -> dict: LOCAL_MWS_CENTROID_OUTPUT = PROJECT_ROOT / "data/layers/mws_centroid" NREGA_LOCAL_OUTPUT = PROJECT_ROOT / "data/layers/nrega_assets" -PAN_INDIA_RESTORATION_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_WRI_Restoration.tif" +PAN_INDIA_RESTORATION_PATH = _base_layer_path( + "restoration opportunity", + fallback="data/base_layers/Pan_India_WRI_Restoration.tif", +) LOCAL_RESTORATION_OUTPUT = PROJECT_ROOT / "data/layers/restoration_opportunity" -PAN_INDIA_RIVER_PATH = PROJECT_ROOT / "data/river/River_pan_india.geojson" +PAN_INDIA_RIVER_PATH = _base_layer_path( + "river", fallback="data/river/River_pan_india.geojson" +) LOCAL_RIVER_OUTPUT = PROJECT_ROOT / "data/river/river_local" -PAN_INDIA_FABDEM_PATH = PROJECT_ROOT / "data/fabdem/fabdem_pan_india.tif" +PAN_INDIA_FABDEM_PATH = _base_layer_path("dem", fallback="data/fabdem/fabdem_pan_india.tif") LOCAL_FABDEM_OUTPUT = PROJECT_ROOT / "data/fabdem/fabdem_local" PAN_INDIA_ANTYODAYA_2020 = PROJECT_ROOT / "data/base_layers/pan_india_antyodaya_2020.gpkg" LOCAL_ANTYODAYA_2020_OUTPUT = PROJECT_ROOT / "data/antyodaya/output/antyodaya_local" PAN_INDIA_LIVESTOCKS = PROJECT_ROOT / "data/base_layers/pan_india_livestock.gpkg" -LOCAL_LIVESTOCKS_OUTPUT = PROJECT_ROOT / "data/livestock/output/livestock_local" \ No newline at end of file +LOCAL_LIVESTOCKS_OUTPUT = PROJECT_ROOT / "data/livestock/output/livestock_local" diff --git a/computing/config_new.yaml b/computing/config_new.yaml index a3efb5a9..ad33a707 100644 --- a/computing/config_new.yaml +++ b/computing/config_new.yaml @@ -127,6 +127,9 @@ derived_layers: # layers that are computed geoserver_workspace: change_detection layer_type: vector + - name: lulc vector + + \ No newline at end of file diff --git a/computing/terrain_descriptor/terrain_compute_all_local.py b/computing/terrain_descriptor/terrain_compute_all_local.py index 27e98847..2dedd9aa 100644 --- a/computing/terrain_descriptor/terrain_compute_all_local.py +++ b/computing/terrain_descriptor/terrain_compute_all_local.py @@ -1,8 +1,5 @@ -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, ) @@ -15,7 +12,6 @@ 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): @@ -33,65 +29,6 @@ def _is_missing_block(block): 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, @@ -167,45 +104,9 @@ def run_terrain_compute_all_local( end_year = int(end_year) if _is_missing_block(block): - block_names = _resolve_blocks_for_district( - state=state, - district=district, + raise ValueError( + "block is null. Please provide a block value for terrain compute-all." ) - 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, From 859d92594d03fc3545879225b9a3e03b4361622b Mon Sep 17 00:00:00 2001 From: Ankit K Date: Tue, 30 Jun 2026 16:09:21 +0530 Subject: [PATCH 029/120] config updated for all the layers --- README.md | 43 ++- computing/base_layer_setup.py | 25 +- computing/config_loader.py | 12 +- computing/config_new.yaml | 512 ++++++++++++++++++++++++++++++++-- 4 files changed, 546 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 42054b77..f2ff8924 100644 --- a/README.md +++ b/README.md @@ -43,16 +43,39 @@ chmod +x install.sh > For any installation issues, check [Installation Documentation](https://docs.core-stack.org/developers/installer/) and [Troubleshooting Guide](https://docs.core-stack.org/developers/setup-troubleshooting/). -#### 3. Running the server -After the successfull installation of all the packages, run the following commands to start the Django server: -```bash -conda activate corestack-backend (or whatever is the name of your virtual environment) -python manage.py runserver -``` -- **Running celery:** -If you are running some tasks, you need to run -```bash -celery -A nrm_app worker -l info -Q nrm +#### 3. Running the server +After the successfull installation of all the packages, run the following commands to start the Django server: +```bash +conda activate corestack-backend (or whatever is the name of your virtual environment) +python manage.py runserver +``` + +#### Download base layers + +After installation, download the local base layers into `data/` before running local compute pipelines: + +```bash +conda activate corestack-backend +python manage.py base_layer_setup +``` + +To inspect available layer selectors: + +```bash +python manage.py base_layer_setup --list +``` + +To download only specific layers or groups: + +```bash +python manage.py base_layer_setup terrain mws lulc_v3 +python manage.py base_layer_setup static_layers +``` + +- **Running celery:** +If you are running some tasks, you need to run +```bash +celery -A nrm_app worker -l info -Q nrm ``` where 'nrm_app' is the app_name and 'nrm' is the rabbitmq queue. diff --git a/computing/base_layer_setup.py b/computing/base_layer_setup.py index 036cfdc4..fec72e94 100644 --- a/computing/base_layer_setup.py +++ b/computing/base_layer_setup.py @@ -61,6 +61,9 @@ def _format_periodic_value(template: str, year: int) -> str: def _expand_periodic_layer(layer: dict) -> list[dict]: + if not layer.get("periodicity"): + return [layer] + if layer.get("periodicity") != "annual": raise ValueError( f"Unsupported periodicity for base layer '{layer.get('name')}': " @@ -104,6 +107,8 @@ def _manifest_layer_index() -> dict[str, list[dict]]: index.setdefault(group_name, []).extend(layers) for layer in layers: index.setdefault(_layer_key(layer["name"]), []).append(layer) + for alias in layer.get("aliases", []): + index.setdefault(_layer_key(alias), []).append(layer) all_layers = [] for layers in index.values(): @@ -162,6 +167,16 @@ def ensure_manifest_base_layers(*layers): ) for layer in layer_specs: + source = layer.get("source") + if not source: + logger.warning( + "Base layer %s has no source in %s; create it manually at %s.", + layer["name"], + CONFIG_NEW_PATH, + PROJECT_ROOT / layer["local_path"], + ) + continue + if layer.get("type") != "file": raise ValueError( f"Unsupported base layer type for '{layer.get('name')}': " @@ -177,16 +192,6 @@ def ensure_manifest_base_layers(*layers): ) continue - source = layer.get("source") - if not source: - logger.warning( - "Base layer %s has no source in %s; create %s manually.", - layer["name"], - CONFIG_NEW_PATH, - local_path, - ) - continue - if source.startswith("s3://"): _download_s3_file(source, local_path) else: diff --git a/computing/config_loader.py b/computing/config_loader.py index 3969f32d..99350e8a 100644 --- a/computing/config_loader.py +++ b/computing/config_loader.py @@ -25,11 +25,19 @@ def _layer_key(name: str) -> str: return name.strip().lower().replace(" ", "_").replace("-", "_") +def _layer_matches(layer: dict, key: str) -> bool: + names = [layer.get("name", ""), *layer.get("aliases", [])] + return key in {_layer_key(name) for name in names} + + def _format_periodic_value(template: str, year: int) -> str: return template.replace("{year+1}", str(year + 1)).replace("{year}", str(year)) def _expand_periodic_layer(layer: dict) -> list[dict]: + if not layer.get("periodicity"): + return [layer] + if layer.get("periodicity") != "annual": raise ValueError( f"Unsupported periodicity for base layer '{layer.get('name')}': " @@ -68,7 +76,7 @@ def _manifest_base_layers() -> list[dict]: def _base_layer(name: str, *, required: bool = True) -> dict | None: key = _layer_key(name) for layer in _manifest_base_layers(): - if _layer_key(layer["name"]) == key: + if _layer_matches(layer, key): return layer if required: raise KeyError(f"No base layer found in {_CONFIG_PATH.name} for name: {name}") @@ -100,7 +108,7 @@ def _find_legacy_input(path_suffix: str) -> dict: def _derived_layer(name: str) -> dict | None: key = _layer_key(name) for layer in _cfg.get("derived_layers", []): - if _layer_key(layer.get("name", "")) == key and layer.get("local_path"): + if _layer_matches(layer, key) and layer.get("local_path"): return layer return None diff --git a/computing/config_new.yaml b/computing/config_new.yaml index ad33a707..11ba792c 100644 --- a/computing/config_new.yaml +++ b/computing/config_new.yaml @@ -2,11 +2,6 @@ base_layers: static_layers: - - name: mws - local_path: data/base_layers/static_layers/mws/Microwatershed_v2_with_details.geojson - source: s3://corestack-datasets/base_layers/static_layers/mws/Microwatershed_v2_with_details.geojson - type: file - - name: terrain local_path: data/base_layers/terrain_raster_fabdam_pan_india.tif source: s3://corestack-datasets/base_layers/static_layers/terrain/terrain_raster_fabdam_pan_india.tif @@ -16,17 +11,14 @@ base_layers: local_path: data/base_layers/slope_percentage/slope_percentage.tif source: s3://corestack-datasets/base_layers/static_layers/slope_percentage/slope_percentage.tif type: file - - - name: aquifer + + - name: Aquifer Layer + aliases: + - aquifer local_path: data/base_layers/aquifer/aquifer.tif source: s3://corestack-datasets/base_layers/static_layers/aquifer/aquifer.tif type: file - - name: aez - local_path: data/base_layers/AEZs/Agro_Ecological_Regions.shp - source: s3://corestack-datasets/base_layers/AEZs/Agro_Ecological_Regions.shp - type: file - - name: restoration opportunity local_path: data/base_layers/restoration_opportunity/restoration_opportunity.geojson source: s3://corestack-datasets/base_layers/static_layers/restoration_opportunity/restoration_opportunity.geojson @@ -77,6 +69,87 @@ base_layers: source: s3://corestack-datasets/base_layers/static_layers/hydrological_soil_group/hydrological_soil_group.geojson type: file + - name: mission antyodaya + local_path: data/base_layers/mission_antyodaya/mission_antyodaya.gpkg + source: "" + type: file + + - name: ceew climate data + local_path: data/base_layers/ceew_climate_data/ceew_climate_data.tif + source: "" + type: file + + - name: water quality aikosh + local_path: data/base_layers/water_quality_aikosh/water_quality_aikosh.geojson + source: "" + type: file + + - name: groundwater quality + local_path: data/base_layers/groundwater_quality/groundwater_quality.geojson + source: "" + type: file + + - name: admin boundaries + local_path: data/admin-boundary/input/soi_tehsil.geojson + source: "" + type: file + + - name: MWS Boundaries + aliases: + - mws + local_path: data/base_layers/static_layers/mws/Microwatershed_v2_with_details.geojson + source: s3://corestack-datasets/base_layers/static_layers/mws/Microwatershed_v2_with_details.geojson + type: file + + - name: stream order + local_path: data/base_layers/stream_order/stream_order.tif + source: "" + type: file + + - name: drainage lines + local_path: data/base_layers/drainage_lines_pan_india.gpkg + source: "" + type: file + + - name: natural depression + local_path: data/base_layers/natural_depression/natural_depression.tif + source: "" + type: file + + - name: catchment area + local_path: data/base_layers/catchment_area/catchment_area.tif + source: "" + type: file + + - name: soil health + local_path: data/base_layers/soil_health/soil_health.tif + source: "" + type: file + + - name: Nrega Layer (Scrapping) + aliases: + - nrega layer + local_path: data/base_layers/nrega/nrega.gpkg + source: "" + type: file + + - name: tree health + local_path: data/base_layers/tree_health/tree_health.tif + source: "" + type: file + + - name: green credit + local_path: data/base_layers/green_credit/green_credit.geojson + source: "" + type: file + + - name: Agroecological Natural Farming + aliases: + - aez + local_path: data/base_layers/AEZs/Agro_Ecological_Regions.shp + source: s3://corestack-datasets/base_layers/AEZs/Agro_Ecological_Regions.shp + type: file + periodic_layers: - name: lulc_v3 filename: lulc_v3_{year}_{year+1}.tif @@ -96,11 +169,250 @@ base_layers: source: s3://corestack-datasets/base_layers/periodic_layers/lulc/lulc_v4/{filename} type: file + - name: lulc_v4_local_compute_cold_start + local_path: data/base_layers/periodic/lulc_v4_local_compute_cold_start/ + source: "" + type: file + + - name: lulc_v4_sampling_cold_start + local_path: data/base_layers/periodic/lulc_v4_sampling_cold_start/ + source: "" + type: file + + - name: lulc_v2_river_basin + local_path: data/base_layers/periodic/lulc_v2_river_basin/ + source: "" + type: file + + - name: lulc_v3_river_basin + local_path: data/base_layers/periodic/lulc_v3_river_basin/ + source: "" + type: file + + - name: lulc_for_tehsil + local_path: data/base_layers/periodic/lulc_for_tehsil/ + source: "" + type: file + + - name: lulc_v4_aez_cold_start + local_path: data/base_layers/periodic/lulc_v4_aez_cold_start/ + source: "" + type: file + + - name: lulc_v4_temporal_correction_cold_start + local_path: data/base_layers/periodic/lulc_v4_temporal_correction_cold_start/ + source: "" + type: file + + - name: lulc_v4_aez_after_6_years + local_path: data/base_layers/periodic/lulc_v4_aez_after_6_years/ + source: "" + type: file + + - name: lulc_v4_temporal_correction_after_6_years + local_path: data/base_layers/periodic/lulc_v4_temporal_correction_after_6_years/ + source: "" + type: file + + - name: lulc_v4_local_compute_adding_new_grids + local_path: data/base_layers/periodic/lulc_v4_local_compute_adding_new_grids/ + source: "" + type: file + + - name: lulc_v4_sampling_adding_new_grids + local_path: data/base_layers/periodic/lulc_v4_sampling_adding_new_grids/ + source: "" + type: file + + - name: lulc_v4_aez_adding_new_grids + local_path: data/base_layers/periodic/lulc_v4_aez_adding_new_grids/ + source: "" + type: file + + - name: lulc_v4_temporal_correction_adding_new_grids + local_path: data/base_layers/periodic/lulc_v4_temporal_correction_adding_new_grids/ + source: "" + type: file + + - name: lulc_v4_temporal_correction_adding_new_grids_after_6_years + local_path: data/base_layers/periodic/lulc_v4_temporal_correction_adding_new_grids_after_6_years/ + source: "" + type: file + + - name: rainfall jaxa + local_path: data/base_layers/periodic/rainfall_jaxa/ + source: "" + type: file + + - name: rainfall chirps + local_path: data/base_layers/periodic/rainfall_chirps/ + source: "" + type: file + + - name: et fldas + local_path: data/base_layers/periodic/et_fldas/ + source: "" + type: file + + - name: et gldas + local_path: data/base_layers/periodic/et_gldas/ + source: "" + type: file + + - name: pet + local_path: data/base_layers/periodic/pet/ + source: "" + type: file + + - name: dw lulc + local_path: data/base_layers/periodic/dw_lulc/ + source: "" + type: file + + - name: runoff + local_path: data/base_layers/periodic/runoff/ + source: "" + type: file + + - name: swb + local_path: data/base_layers/periodic/swb/ + source: "" + type: file + + - name: drought spei + local_path: data/base_layers/periodic/drought_spei/ + source: "" + type: file + + - name: forest resistance and resilience to drought + local_path: data/base_layers/periodic/forest_resistance_resilience_drought/ + source: "" + type: file + + - name: extreme rainfall + local_path: data/base_layers/periodic/extreme_rainfall/ + source: "" + type: file + + - name: forest resistance and resilience to extreme rainfall + local_path: data/base_layers/periodic/forest_resistance_resilience_extreme_rainfall/ + source: "" + type: file + on_demand_layers: - - name: farm_boundary - local_path: data/base_layers/farm_boundary/farm_boundary.geojson + - name: lulc_farm_boundary + local_path: data/base_layers/on_demand/lulc_farm_boundary/ + source: "" + type: file + + - name: generate_ponds + local_path: data/base_layers/on_demand/ponds/ + source: "" + type: file + + - name: generate_wells + local_path: data/base_layers/on_demand/wells/ + source: "" + type: file + + - name: forest additonality + aliases: + - forest additionality + local_path: data/base_layers/on_demand/forest_additionality/ + source: "" + type: file + + - name: fes_clart_layer + local_path: data/base_layers/on_demand/fes_clart_layer/ + source: "" + type: file + + - name: generate_clart + local_path: data/base_layers/on_demand/clart/ + source: "" + type: file + + - name: generate_ndvi_timeseries + local_path: data/base_layers/on_demand/ndvi_timeseries/ + source: "" + type: file + + - name: downscaling et + local_path: data/base_layers/on_demand/downscaling_et/ + source: "" + type: file + + - name: forest fire + local_path: data/base_layers/on_demand/forest_fire/ + source: "" + type: file + + - name: grassland degradation + local_path: data/base_layers/on_demand/grassland_degradation/ + source: "" + type: file + + - name: deforestation on forest fringes core + local_path: data/base_layers/on_demand/deforestation_forest_fringes_core/ + source: "" + type: file + + - name: plantation_site_suitability + local_path: data/base_layers/on_demand/plantation_site_suitability/ + source: "" + type: file + + - name: temperature and humidity fortnightly timeseries + local_path: data/base_layers/on_demand/temperature_humidity_fortnightly/ + source: "" + type: file + + - name: pollution through satellite measured aerosol density + local_path: data/base_layers/on_demand/aerosol_density/ + source: "" + type: file + +derived_layers: + - name: generate_mws_layer + + - name: lulc v3 + filename: "{district}_{block}_{start_year}-07-01_{end_year}-06-30_LULCmap_10m.tif" + local_path: data/lulc/lulc_v3_local/{state}/{district}/{block}/{filename} + geoserver_workspace: LULC_v3 + layer_type: raster + + - name: lulc v4 + + - name: generate_ci_layer + + - name: generate_terrain_raster + + - name: generate_terrain_descriptor + + - name: terrain_lulc_slope_cluster + aliases: + - lulc slope clusters + filename: "{district}_{block}_lulc_slope.gpkg" + local_path: data/lulc_X_terrain/lulc_slope_clusters_local/{state}/{district}/{block}/{filename} + geoserver_workspace: terrain_lulc + layer_type: vector + + - name: terrain_lulc_plain_cluster + aliases: + - lulc plain clusters + filename: "{district}_{block}_lulc_plain.gpkg" + local_path: data/lulc_X_terrain/lulc_plain_clusters_local/{state}/{district}/{block}/{filename} + geoserver_workspace: terrain_lulc + layer_type: vector + + - name: generate_terrain_compute_all + + - name: lulc vector + filename: lulc_vector_{district}_{block}.gpkg + local_path: data/lulc/lulc_vector_local/{state}/{district}/{block}/{filename} + geoserver_workspace: lulc_vector + layer_type: vector -derived_layers: # layers that are computed - name: change detection params: [ Afforestation, @@ -109,8 +421,8 @@ derived_layers: # layers that are computed Urbanization, CropIntensity ] - filename: change_{district}_{tehsil}_{param_name}_{start_year}_{end_year}.tif - local_path: data/derived_layers/change_detection/{filename} + filename: change_{district}_{block}_{param_name}_{start_year}_{end_year}.tif + local_path: data/change_detection/change_detection_local/{state}/{district}/{block}/{filename} geoserver_workspace: change_detection layer_type: raster @@ -122,14 +434,166 @@ derived_layers: # layers that are computed Urbanization, CropIntensity ] - filename: change_vector_{district}_{tehsil}_{param_name}_{start_year}_{end_year}.geojson - local_path: data/derived_layers/change_detection/{filename} + filename: change_vector_{district}_{block}_{param_name}_{start_year}_{end_year}.gpkg + local_path: data/change_detection/change_detection_vector_local/{state}/{district}/{block}/{filename} geoserver_workspace: change_detection layer_type: vector - - name: lulc vector - + - name: aquifer vector + local_path: data/layers/aquifer_vector/ + geoserver_workspace: aquifer_vector + layer_type: vector + + - name: generate_swb + local_path: data/surface_water_bodies/ + geoserver_workspace: surface_water_bodies + layer_type: vector + + - name: crop_grid + + - name: dem_raster + local_path: data/fabdem/fabdem_local/ + geoserver_workspace: dem + layer_type: raster + + - name: dem_vector + + - name: drainage_density_vector + local_path: data/drainage_density/ + geoserver_workspace: drainage_density + layer_type: vector + + - name: generate_canal_vector + local_path: data/canal/canal_local/ + geoserver_workspace: canal + layer_type: vector + + - name: generate_river_data + local_path: data/river/river_local/ + geoserver_workspace: river + layer_type: vector + + - name: run_off_daily_and_fortnightly + + - name: clip_soil_health + + - name: soil_health_vector + + - name: tree_health_raster + + - name: tree_health_vector + + - name: stream_order + + - name: hydrology_fortnightly + + - name: hydrology_annual + + - name: generate_block_layer + + - name: generate_drainage_layer + local_path: data/layers/drainage_lines/drainage_lines_local/ + geoserver_workspace: drainage_lines + layer_type: vector + + - name: restoration_opportunity + local_path: data/layers/restoration_opportunity/ + geoserver_workspace: restoration + layer_type: raster + + - name: soge_vector + local_path: data/layers/SOGE_vector/ + geoserver_workspace: soge + layer_type: vector + + - name: generate_lcw + local_path: data/layers/lcw_conflict/ + geoserver_workspace: lcw + layer_type: raster + + - name: generate_agroecological + local_path: data/layers/agroecological/ + geoserver_workspace: agroecological + layer_type: vector + + - name: generate_factory_csr + local_path: data/layers/factory_csr/ + geoserver_workspace: factory_csr + layer_type: vector + + - name: generate_green_credit + local_path: data/layers/green_credit/ + geoserver_workspace: green_credit + layer_type: vector + + - name: generate_mining + local_path: data/layers/mining/ + geoserver_workspace: mining + layer_type: vector + + - name: generate_natural_depression + local_path: data/layers/natural_depression/ + geoserver_workspace: natural_depression + layer_type: raster + + - name: generate_distance_nearest_DL + local_path: data/layers/distance_nearest_upstream_DL/ + geoserver_workspace: distance_nearest_upstream_DL + layer_type: raster + + - name: generate_catchment_area_singleflow + local_path: data/layers/catchment_area_singleflow/ + geoserver_workspace: catchment_area_singleflow + layer_type: raster + + - name: generate_slope_percentage + local_path: data/layers/slope_percentage/ + geoserver_workspace: slope_percentage + layer_type: raster + + - name: generate_mws_connectivity_data + local_path: data/layers/mws_connectivity/mws_connectivity_local/ + geoserver_workspace: mws_connectivity + layer_type: vector + + - name: generate_mws_centroid + local_path: data/layers/mws_centroid/ + geoserver_workspace: mws_centroid + layer_type: vector + + - name: generate_facilities_proximity + local_path: data/layers/facilities/ + geoserver_workspace: facilities + layer_type: vector + + - name: generate_nrega_layer + local_path: data/layers/nrega_assets/ + geoserver_workspace: nrega_assets + layer_type: vector + + - name: merge_swb_ponds + + - name: generate_drought_layer + + - name: mws_drought_causality + + - name: clip_drought_spei + + - name: forest resistance and resilience to drought + + - name: extreme rainfall + + - name: forest resistance and resilience to extreme rainfall + + - name: drought sensitivity + + - name: grassland health + + - name: generate_zoi_data + + - name: generate_antyodaya + local_path: data/antyodaya/output/antyodaya_local/ + geoserver_workspace: antyodaya + layer_type: vector - - - \ No newline at end of file + - name: plantation_site_suitability From 367fd1c7cd1f307b03bf54a4b8e30492079f5541 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Wed, 1 Jul 2026 15:18:29 +0530 Subject: [PATCH 030/120] json map configuration --- computing/api.py | 20 +- .../layer_generation_in_order.py | 490 +++++++++++++++--- .../local_end_year_rules.json | 1 + .../layer_dependency/local_layer_map.json | 101 ++++ .../management/commands/base_layer_setup.py | 72 +++ 5 files changed, 613 insertions(+), 71 deletions(-) create mode 100644 computing/layer_dependency/local_end_year_rules.json create mode 100644 computing/layer_dependency/local_layer_map.json create mode 100644 computing/management/commands/base_layer_setup.py diff --git a/computing/api.py b/computing/api.py index c225b99e..e7bde1b5 100644 --- a/computing/api.py +++ b/computing/api.py @@ -28,7 +28,11 @@ from computing.change_detection.change_detection_vector_local import ( vectorise_change_detection as vectorise_change_detection_local_task, ) -from computing.layer_dependency.layer_generation_in_order import layer_generate_map +from computing.layer_dependency.layer_generation_in_order import ( + layer_generate_map, + normalize_compute as _normalize_layer_order_compute, + validate_layer_map_request, +) from computing.misc.drainage_lines import ( clip_drainage_lines as clip_drainage_lines_gee_task, ) @@ -148,7 +152,6 @@ from .clart.fes_clart_to_geoserver import generate_fes_clart_layer from .surface_water_bodies.merge_swb_ponds import merge_swb_ponds from utilities.auth_check_decorator import api_security_check -from computing.layer_dependency.layer_generation_in_order import layer_generate_map from .views import ( check_missing_layers, layer_status, @@ -1538,8 +1541,17 @@ def generate_layer_in_order(request): gee_account_id = request.data.get("gee_account_id") start_year = request.data.get("start_year") end_year = request.data.get("end_year") + compute = _normalize_layer_order_compute(request.data.get("compute") or "gee") start_year = int(start_year) if start_year is not None else None end_year = int(end_year) if end_year is not None else None + + validation_errors = validate_layer_map_request(map_order, compute=compute) + if validation_errors: + return Response( + {"Exception": "; ".join(validation_errors)}, + status=status.HTTP_400_BAD_REQUEST, + ) + layer_generate_map.apply_async( kwargs={ "state": state, @@ -1549,12 +1561,16 @@ def generate_layer_in_order(request): "gee_account_id": gee_account_id, "start_year": start_year, "end_year": end_year, + "compute": compute, }, queue="nrm", ) return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) + except ValueError as e: + print("Invalid request in generate_layer_order_first api :: ", e) + return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: print("Exception in generate_layer_order_first api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/computing/layer_dependency/layer_generation_in_order.py b/computing/layer_dependency/layer_generation_in_order.py index 66e2e379..b5926b89 100644 --- a/computing/layer_dependency/layer_generation_in_order.py +++ b/computing/layer_dependency/layer_generation_in_order.py @@ -49,15 +49,236 @@ from computing.zoi_layers.zoi import generate_zoi from computing.misc.facilities_proximity import generate_facilities_proximity_task from computing.mws.mws_centroid import generate_mws_centroid_data +from computing.change_detection.change_detection_local import ( + get_change_detection as get_change_detection_local, +) +from computing.change_detection.change_detection_vector_local import ( + vectorise_change_detection as vectorise_change_detection_local, +) +from computing.cropping_intensity.cropping_intesity_local import ( + generate_cropping_intensity as generate_cropping_intensity_local, +) +from computing.lulc.lulc_v3_local import clip_lulc_v3 as clip_lulc_v3_local +from computing.lulc.lulc_vector_local import vectorise_lulc as vectorise_lulc_local +from computing.lulc_X_terrain.lulc_on_plain_cluster_local import ( + lulc_on_plain_cluster_local, +) +from computing.lulc_X_terrain.lulc_on_slope_cluster_local import ( + lulc_on_slope_cluster_local, +) +from computing.misc.agroecological_space_local_compute import ( + generate_agroecological_data_local, +) +from computing.misc.aquifer_vector_local import ( + generate_aquifer_vector as generate_aquifer_vector_local, +) +from computing.misc.catchment_area_local_compute import ( + generate_catchment_area_singleflow_local, +) +from computing.misc.distancetonearestdrainage_local_compute import ( + generate_distance_to_nearest_drainage_line_local, +) +from computing.misc.drainage_lines_local_compute import ( + clip_drainage_lines as clip_drainage_lines_local, +) +from computing.misc.facilities_proximity_local_compute import ( + generate_facilities_proximity_local, +) +from computing.misc.factory_csr_local_compute import generate_factory_csr_data_local +from computing.misc.green_credit_local_compute import generate_green_credit_data_local +from computing.misc.lcw_conflict_local_compute import generate_lcw_conflict_data_local +from computing.misc.mining_data_local_compute import generate_mining_data_local +from computing.misc.naturaldepression_local_compute import ( + generate_natural_depression_data_local, +) +from computing.misc.nrega_local_compute import generate_nrega_data_local +from computing.misc.restoration_opportunity_local_compute import ( + generate_restoration_opportunity_local, +) +from computing.misc.slope_percentage_local_compute import ( + generate_slope_percentage_data_local, +) +from computing.misc.soge_vector_local_compute import generate_soge_vector_local +from computing.mws.mws_centroid_local_compute import generate_mws_centroid_data_local +from computing.mws.mws_connectivity_local_compute import mws_connectivity_vector +from computing.surface_water_bodies.swb_local import ( + generate_swb_layer as generate_swb_layer_local, +) +from computing.terrain_descriptor.terrain_clusters_local import ( + generate_terrain_clusters as generate_terrain_clusters_local, +) +from computing.terrain_descriptor.terrain_raster_fabdem_local import ( + generate_terrain_raster_clip as terrain_raster_local, +) from utilities.gee_utils import valid_gee_text import os -from nrm_app.celery import app from computing.models import Layer import json +VALID_COMPUTE_TYPES = {"gee", "local"} + +CONFIG_DIR = os.path.dirname(__file__) +EXTERNAL_CONFIG_DIR = os.path.join("data", "layers", "layer_dependency") + +MAP_CONFIG_FILES = { + "gee": "layer_map.json", + "local": "local_layer_map.json", +} + +END_YEAR_RULE_FILES = { + "gee": "end_year_rules.json", + "local": "local_end_year_rules.json", +} + status = {} +GEE_TASK_REGISTRY = { + "generate_tehsil_shape_file_data": generate_tehsil_shape_file_data, + "clip_nrega_district_block": clip_nrega_district_block, + "generate_nrega_data": clip_nrega_district_block, + "generate_nrega_layer": clip_nrega_district_block, + "mws_layer": mws_layer, + "generate_mws_layer": mws_layer, + "generate_hydrology": generate_hydrology, + "clip_lulc_v3": clip_lulc_v3, + "lulc_v3": clip_lulc_v3, + "vectorise_lulc": vectorise_lulc, + "lulc_vector": vectorise_lulc, + "generate_cropping_intensity": generate_cropping_intensity, + "generate_ci_layer": generate_cropping_intensity, + "generate_swb_layer": generate_swb_layer, + "generate_swb": generate_swb_layer, + "calculate_drought": calculate_drought, + "generate_drought_layer": calculate_drought, + "drought_causality": drought_causality, + "mws_drought_causality": drought_causality, + "create_crop_grids": create_crop_grids, + "crop_grid": create_crop_grids, + "get_change_detection": get_change_detection, + "change_detection": get_change_detection, + "vectorise_change_detection": vectorise_change_detection, + "change_detection_vector": vectorise_change_detection, + "generate_restoration_opportunity": generate_restoration_opportunity, + "restoration_opportunity": generate_restoration_opportunity, + "generate_aquifer_vector": generate_aquifer_vector, + "aquifer_vector": generate_aquifer_vector, + "terrain_raster": terrain_raster, + "generate_terrain_raster": terrain_raster, + "generate_terrain_clusters": generate_terrain_clusters, + "generate_terrain_descriptor": generate_terrain_clusters, + "lulc_on_plain_cluster": lulc_on_plain_cluster, + "terrain_lulc_plain_cluster": lulc_on_plain_cluster, + "lulc_on_slope_cluster": lulc_on_slope_cluster, + "terrain_lulc_slope_cluster": lulc_on_slope_cluster, + "generate_soge_vector": generate_soge_vector, + "soge_vector": generate_soge_vector, + "generate_stream_order": generate_stream_order, + "stream_order": generate_stream_order, + "clip_drainage_lines": clip_drainage_lines, + "generate_drainage_layer": clip_drainage_lines, + "generate_clart_layer": generate_clart_layer, + "generate_clart": generate_clart_layer, + "tree_health_ch_raster": tree_health_ch_raster, + "tree_health_ch_vector": tree_health_ch_vector, + "tree_health_ccd_raster": tree_health_ccd_raster, + "tree_health_ccd_vector": tree_health_ccd_vector, + "tree_health_overall_change_raster": tree_health_overall_change_raster, + "tree_health_overall_change_vector": tree_health_overall_change_vector, + "generate_natural_depression_data": generate_natural_depression_data, + "generate_natural_depression": generate_natural_depression_data, + "generate_distance_to_nearest_drainage_line": generate_distance_to_nearest_drainage_line, + "generate_distance_nearest_DL": generate_distance_to_nearest_drainage_line, + "generate_catchment_area_singleflow": generate_catchment_area_singleflow, + "generate_slope_percentage_data": generate_slope_percentage_data, + "generate_slope_percentage": generate_slope_percentage_data, + "generate_lcw_conflict_data": generate_lcw_conflict_data, + "generate_lcw": generate_lcw_conflict_data, + "generate_agroecological_data": generate_agroecological_data, + "generate_agroecological": generate_agroecological_data, + "generate_factory_csr_data": generate_factory_csr_data, + "generate_factory_csr": generate_factory_csr_data, + "generate_green_credit_data": generate_green_credit_data, + "generate_green_credit": generate_green_credit_data, + "generate_mining_data": generate_mining_data, + "generate_mining": generate_mining_data, + "site_suitability": site_suitability, + "plantation_site_suitability": site_suitability, + "generate_mws_connectivity_data": generate_mws_connectivity_data, + "generate_mws_connectivity": generate_mws_connectivity_data, + "ndvi_timeseries": ndvi_timeseries, + "generate_ndvi_timeseries": ndvi_timeseries, + "generate_zoi": generate_zoi, + "generate_zoi_data": generate_zoi, + "generate_facilities_proximity_task": generate_facilities_proximity_task, + "generate_facilities_proximity": generate_facilities_proximity_task, + "generate_mws_centroid_data": generate_mws_centroid_data, + "generate_mws_centroid": generate_mws_centroid_data, +} + +LOCAL_TASK_REGISTRY = { + "clip_nrega_district_block": generate_nrega_data_local, + "generate_nrega_data": generate_nrega_data_local, + "generate_nrega_layer": generate_nrega_data_local, + "clip_lulc_v3": clip_lulc_v3_local, + "lulc_v3": clip_lulc_v3_local, + "vectorise_lulc": vectorise_lulc_local, + "lulc_vector": vectorise_lulc_local, + "generate_cropping_intensity": generate_cropping_intensity_local, + "generate_ci_layer": generate_cropping_intensity_local, + "generate_swb_layer": generate_swb_layer_local, + "generate_swb": generate_swb_layer_local, + "get_change_detection": get_change_detection_local, + "change_detection": get_change_detection_local, + "vectorise_change_detection": vectorise_change_detection_local, + "change_detection_vector": vectorise_change_detection_local, + "generate_aquifer_vector": generate_aquifer_vector_local, + "aquifer_vector": generate_aquifer_vector_local, + "terrain_raster": terrain_raster_local, + "generate_terrain_raster": terrain_raster_local, + "generate_terrain_clusters": generate_terrain_clusters_local, + "generate_terrain_descriptor": generate_terrain_clusters_local, + "lulc_on_plain_cluster": lulc_on_plain_cluster_local, + "terrain_lulc_plain_cluster": lulc_on_plain_cluster_local, + "lulc_on_slope_cluster": lulc_on_slope_cluster_local, + "terrain_lulc_slope_cluster": lulc_on_slope_cluster_local, + "generate_soge_vector": generate_soge_vector_local, + "soge_vector": generate_soge_vector_local, + "clip_drainage_lines": clip_drainage_lines_local, + "generate_drainage_layer": clip_drainage_lines_local, + "generate_natural_depression_data": generate_natural_depression_data_local, + "generate_natural_depression": generate_natural_depression_data_local, + "generate_distance_to_nearest_drainage_line": generate_distance_to_nearest_drainage_line_local, + "generate_distance_nearest_DL": generate_distance_to_nearest_drainage_line_local, + "generate_catchment_area_singleflow": generate_catchment_area_singleflow_local, + "generate_slope_percentage_data": generate_slope_percentage_data_local, + "generate_slope_percentage": generate_slope_percentage_data_local, + "generate_lcw_conflict_data": generate_lcw_conflict_data_local, + "generate_lcw": generate_lcw_conflict_data_local, + "generate_agroecological_data": generate_agroecological_data_local, + "generate_agroecological": generate_agroecological_data_local, + "generate_factory_csr_data": generate_factory_csr_data_local, + "generate_factory_csr": generate_factory_csr_data_local, + "generate_green_credit_data": generate_green_credit_data_local, + "generate_green_credit": generate_green_credit_data_local, + "generate_mining_data": generate_mining_data_local, + "generate_mining": generate_mining_data_local, + "generate_restoration_opportunity": generate_restoration_opportunity_local, + "restoration_opportunity": generate_restoration_opportunity_local, + "generate_mws_connectivity_data": mws_connectivity_vector, + "generate_mws_connectivity": mws_connectivity_vector, + "generate_facilities_proximity_task": generate_facilities_proximity_local, + "generate_facilities_proximity": generate_facilities_proximity_local, + "generate_mws_centroid_data": generate_mws_centroid_data_local, + "generate_mws_centroid": generate_mws_centroid_data_local, +} + +TASK_REGISTRIES = { + "gee": GEE_TASK_REGISTRY, + "local": LOCAL_TASK_REGISTRY, +} + + @app.task(bind=True) def layer_generate_map( self, @@ -68,13 +289,17 @@ def layer_generate_map( gee_account_id, start_year=None, end_year=None, + compute="gee", ): """ This function take state, district,block and map_order(map to trigger, it can be map_1, map_2_1, map_2_2, map_3, map_4). One map trigger more numbers of pipeline. """ + compute = normalize_compute(compute) + status.clear() + # checking:- is mws layer generated? try: - if map_order in ["map_2_1", "map_2_2", "map_3", "map_4"]: + if compute == "gee" and map_order in ["map_2_1", "map_2_2", "map_3", "map_4"]: layer = ( Layer.objects.filter( layer_name=f"mws_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" @@ -94,97 +319,147 @@ def layer_generate_map( global_args["end_year"] = end_year # Load JSON configuration - map_config = load_map_config(map_order) + map_config = load_map_config(map_order, compute=compute) if not map_config: - return f"Map configuration not found for {map_order}" + return f"Map configuration not found for {map_order} using compute={compute}" - # triggering map at parent level - for func in map_config: - parent_function = func["name"] - parent_func = globals().get(parent_function) - args = get_args( - iterator_name=func, global_args=global_args, gee_account_id=gee_account_id + validation_errors = validate_map_config(map_config, compute=compute) + if validation_errors: + return ( + f"Invalid {compute} map configuration for {map_order}: " + f"{'; '.join(validation_errors)}" ) - deps = func.get("depends_on", []) - run_layer_with_dependency( - deps=deps, - node_func_name=parent_function, - node_func_obj=parent_func, + + task_registry = TASK_REGISTRIES[compute] + for func in map_config: + run_node_tree( + node=func, + task_registry=task_registry, + compute=compute, state=state, district=district, block=block, - args=args, + global_args=global_args, + gee_account_id=gee_account_id, ) - # triggering map at children level - if status.get(parent_function, False) and "children" in func: - for child in func["children"]: - child_function = child["name"] - child_func = globals().get(child_function) - child_args = get_args( - iterator_name=child, - global_args=global_args, - gee_account_id=gee_account_id, - ) - child_deps = child.get("depends_on", []) - run_layer_with_dependency( - deps=child_deps, - node_func_name=child_function, - node_func_obj=child_func, - state=state, - district=district, - block=block, - args=child_args, - ) + return f"{status = }" - # triggering map at sub children level - if status.get(child_function, False) and "children" in child: - for sub_child in child["children"]: - sub_child_function = sub_child["name"] - sub_child_func = globals().get(sub_child_function) - sub_child_args = get_args( - iterator_name=sub_child, - global_args=global_args, - gee_account_id=gee_account_id, - ) - sub_child_deps = sub_child.get("depends_on", []) - run_layer_with_dependency( - deps=sub_child_deps, - node_func_name=sub_child_function, - node_func_obj=sub_child_func, - state=state, - district=district, - block=block, - args=sub_child_args, - ) - return f"{status = }" +def normalize_compute(compute): + compute = str(compute or "gee").strip().lower() + if compute not in VALID_COMPUTE_TYPES: + raise ValueError("compute must be either 'gee' or 'local'") + return compute + + +def _candidate_config_paths(filename): + return [ + os.path.join(EXTERNAL_CONFIG_DIR, filename), + os.path.join(CONFIG_DIR, filename), + ] + + +def _load_json_config(filename, default=None): + for config_path in _candidate_config_paths(filename): + if os.path.exists(config_path): + with open(config_path, "r") as f: + return json.load(f) + if default is not None: + return default + raise FileNotFoundError( + f"Layer dependency config not found. Checked: " + f"{_candidate_config_paths(filename)}" + ) -def load_map_config(map_order): +def load_map_config(map_order, compute="gee"): """ Load map configuration from JSON file based on map_order. """ - config_path = os.path.join("data", "layers", "layer_dependency", "layer_map.json") - with open(config_path, "r") as f: - all_configs = json.load(f) + compute = normalize_compute(compute) + all_configs = _load_json_config(MAP_CONFIG_FILES[compute], default={}) return all_configs.get(map_order, []) -def load_end_year_rules(): +def load_end_year_rules(compute="gee"): """ Load end year rules from JSON. """ - config_path = os.path.join( - "data", "layers", "layer_dependency", "end_year_rules.json" + compute = normalize_compute(compute) + return _load_json_config(END_YEAR_RULE_FILES[compute], default={}) + + +def flatten_map_nodes(map_config): + nodes = [] + for node in map_config: + nodes.append(node) + nodes.extend(flatten_map_nodes(node.get("children", []))) + return nodes + + +def validate_layer_map_request(map_order, compute="gee"): + compute = normalize_compute(compute) + map_config = load_map_config(map_order, compute=compute) + if not map_config: + return [f"Map configuration not found for {map_order} using compute={compute}"] + return validate_map_config(map_config, compute=compute) + + +def validate_map_config(map_config, compute="gee"): + compute = normalize_compute(compute) + task_registry = TASK_REGISTRIES[compute] + node_names = {node.get("name") for node in flatten_map_nodes(map_config)} + validator_names = set(DependencyValidator.names_for_compute(compute)) + + unsupported_nodes = sorted( + node_name for node_name in node_names if node_name not in task_registry ) - with open(config_path, "r") as f: - return json.load(f) + unsupported_deps = sorted( + dep + for node in flatten_map_nodes(map_config) + for dep in node.get("depends_on", []) + if dep not in node_names and dep not in validator_names + ) + + errors = [] + if unsupported_nodes: + errors.append(f"unsupported {compute} nodes: {', '.join(unsupported_nodes)}") + if unsupported_deps: + errors.append( + f"unsupported {compute} dependencies: {', '.join(unsupported_deps)}" + ) + return errors # check the dependency layer is available or not class DependencyValidator: + GEE_VALIDATORS = {} + LOCAL_VALIDATORS = {} + + @staticmethod + def _local_layer_exists(layer_name=None, dataset_name=None): + qs = Layer.objects.filter(misc__is_generated_locally=True) + if layer_name: + qs = qs.filter(layer_name=layer_name) + if dataset_name: + qs = qs.filter(dataset__name=dataset_name) + return qs.exists() + + @classmethod + def get_checker(cls, dep, compute): + compute = normalize_compute(compute) + if compute == "local": + return cls.LOCAL_VALIDATORS.get(dep) + return cls.GEE_VALIDATORS.get(dep) + + @classmethod + def names_for_compute(cls, compute): + compute = normalize_compute(compute) + if compute == "local": + return set(cls.LOCAL_VALIDATORS) + return set(cls.GEE_VALIDATORS) @staticmethod def clip_lulc_v3(district, block): @@ -239,15 +514,92 @@ def generate_tehsil_shape_file_data(district, block): dataset__name="Admin Boundary", ).exists() + @staticmethod + def local_layer_generated(district, block): + return ( + Layer.objects.filter( + layer_name__icontains=valid_gee_text(district.lower()), + misc__is_generated_locally=True, + ) + .filter(layer_name__icontains=valid_gee_text(block.lower())) + .exists() + ) + + +DependencyValidator.GEE_VALIDATORS = { + name: getattr(DependencyValidator, name) + for name in [ + "clip_lulc_v3", + "terrain_raster", + "generate_catchment_area_singleflow", + "generate_stream_order", + "clip_drainage_lines", + "generate_cropping_intensity", + "generate_swb_layer", + "generate_tehsil_shape_file_data", + ] +} + +DependencyValidator.LOCAL_VALIDATORS = { + # Local ordered maps mostly depend on nodes generated earlier in the same run. + # This fallback is for externally pre-generated local dependencies. + "local_layer_generated": DependencyValidator.local_layer_generated, +} + + +def run_node_tree( + node, + task_registry, + compute, + state, + district, + block, + global_args, + gee_account_id, +): + node_func_name = node["name"] + node_func_obj = task_registry[node_func_name] + args = get_args( + iterator_name=node, global_args=global_args, gee_account_id=gee_account_id + ) + deps = node.get("depends_on", []) + run_layer_with_dependency( + deps=deps, + node_func_name=node_func_name, + node_func_obj=node_func_obj, + compute=compute, + state=state, + district=district, + block=block, + args=args, + ) + + if not status.get(node_func_name, False): + return + + for child in node.get("children", []): + run_node_tree( + node=child, + task_registry=task_registry, + compute=compute, + state=state, + district=district, + block=block, + global_args=global_args, + gee_account_id=gee_account_id, + ) + def run_layer_with_dependency( - deps, node_func_name, node_func_obj, state, district, block, args + deps, node_func_name, node_func_obj, compute, state, district, block, args ): """ This function checks dependency of layer if it is generated or not and call the pipeline functions and maintain status of each function, """ for dep in deps: - checker = getattr(DependencyValidator, dep, None) + if status.get(dep, False): + continue + checker = DependencyValidator.get_checker(dep, compute) status[dep] = checker(district, block) if checker else False if not status[dep]: print( @@ -257,7 +609,7 @@ def run_layer_with_dependency( break else: try: - end_year_rules = load_end_year_rules() + end_year_rules = load_end_year_rules(compute=compute) if node_func_name in end_year_rules: args["end_year"] = end_year_rules[node_func_name] if node_func_name == "site_suitability": diff --git a/computing/layer_dependency/local_end_year_rules.json b/computing/layer_dependency/local_end_year_rules.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/computing/layer_dependency/local_end_year_rules.json @@ -0,0 +1 @@ +{} diff --git a/computing/layer_dependency/local_layer_map.json b/computing/layer_dependency/local_layer_map.json new file mode 100644 index 00000000..2ad0ecfb --- /dev/null +++ b/computing/layer_dependency/local_layer_map.json @@ -0,0 +1,101 @@ +{ + "map_2_1": [ + { + "name": "generate_terrain_raster" + }, + { + "name": "generate_terrain_descriptor", + "depends_on": ["generate_terrain_raster"] + }, + { + "name": "lulc_v3", + "use_global_args": true + }, + { + "name": "lulc_vector", + "depends_on": ["lulc_v3"], + "use_global_args": true + }, + { + "name": "terrain_lulc_slope_cluster", + "depends_on": ["generate_terrain_descriptor", "lulc_v3"], + "use_global_args": true + }, + { + "name": "terrain_lulc_plain_cluster", + "depends_on": ["generate_terrain_descriptor", "lulc_v3"], + "use_global_args": true + } + ], + "map_2_2": [ + { + "name": "generate_ci_layer", + "use_global_args": true + }, + { + "name": "generate_swb", + "use_global_args": true + } + ], + "map_3": [ + { + "name": "change_detection", + "use_global_args": true + }, + { + "name": "change_detection_vector", + "depends_on": ["change_detection"], + "use_global_args": true + } + ], + "map_static": [ + { + "name": "generate_drainage_layer" + }, + { + "name": "generate_slope_percentage" + }, + { + "name": "generate_catchment_area_singleflow" + }, + { + "name": "generate_natural_depression" + }, + { + "name": "generate_lcw" + }, + { + "name": "generate_agroecological" + }, + { + "name": "generate_factory_csr" + }, + { + "name": "generate_green_credit" + }, + { + "name": "generate_mining" + }, + { + "name": "soge_vector" + }, + { + "name": "aquifer_vector" + }, + { + "name": "restoration_opportunity" + }, + { + "name": "generate_mws_connectivity" + }, + { + "name": "generate_mws_centroid" + }, + { + "name": "generate_facilities_proximity" + }, + { + "name": "generate_nrega_layer" + } + ] +} diff --git a/computing/management/commands/base_layer_setup.py b/computing/management/commands/base_layer_setup.py new file mode 100644 index 00000000..9e1edb23 --- /dev/null +++ b/computing/management/commands/base_layer_setup.py @@ -0,0 +1,72 @@ +import logging + +from django.core.management.base import BaseCommand, CommandError + +from computing.base_layer_setup import ( + DEFAULT_BASE_LAYERS, + _BASE_LAYER_ENSURERS, + _manifest_layer_groups, + setup_base_layers, +) + + +class Command(BaseCommand): + help = ( + "Download required base layers into the local data directory. " + "Defaults to static_layers and periodic_layers." + ) + + def add_arguments(self, parser): + parser.add_argument( + "layers", + nargs="*", + help=( + "Layer names or groups to download, for example: terrain, mws, " + "lulc_v3, static_layers, periodic_layers. Defaults to the bootstrap set." + ), + ) + parser.add_argument( + "--list", + action="store_true", + help="List available layer selectors without downloading anything.", + ) + + def handle(self, *args, **options): + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + if options["list"]: + self._print_available_layers() + return + + layers = tuple(options["layers"]) or DEFAULT_BASE_LAYERS + self.stdout.write(f"Setting up base layers: {', '.join(layers)}") + + try: + setup_base_layers(*layers) + except ValueError as exc: + raise CommandError(str(exc)) from exc + + self.stdout.write(self.style.SUCCESS("Base layer setup complete.")) + + def _print_available_layers(self): + self.stdout.write("Downloadable manifest groups:") + for group_name, layers in _manifest_layer_groups().items(): + if not layers: + continue + self.stdout.write(f" {group_name}") + for layer in layers: + source = layer.get("source") + status = ( + "downloadable" + if source and layer.get("type") == "file" + else "manual" + ) + label = layer["name"] + if "year" in layer: + label = f"{label} {layer['year']}-{layer['year'] + 1}" + self.stdout.write(f" - {label} ({status})") + + self.stdout.write("") + self.stdout.write("Legacy/generated selectors:") + for selector in sorted(_BASE_LAYER_ENSURERS): + self.stdout.write(f" - {selector}") From c2eb1c30149ecec7d0e0ea30acc25dc6510565e7 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Thu, 2 Jul 2026 17:27:36 +0530 Subject: [PATCH 031/120] boto config --- computing/base_layer_setup.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/computing/base_layer_setup.py b/computing/base_layer_setup.py index fec72e94..030c5911 100644 --- a/computing/base_layer_setup.py +++ b/computing/base_layer_setup.py @@ -132,7 +132,24 @@ def _download_s3_file(source: str, destination: Path): logger.info("Downloading %s to %s", source, destination) try: - boto3.client("s3").download_file( + client_kwargs = {} + try: + from django.conf import settings + + if settings.S3_ACCESS_KEY and settings.S3_SECRET_KEY: + client_kwargs.update( + aws_access_key_id=settings.S3_ACCESS_KEY, + aws_secret_access_key=settings.S3_SECRET_KEY, + ) + if getattr(settings, "S3_REGION", None): + client_kwargs["region_name"] = settings.S3_REGION + except Exception: + logger.debug( + "Django S3 settings unavailable; using boto3 credential provider chain.", + exc_info=True, + ) + + boto3.client("s3", **client_kwargs).download_file( parsed.netloc, parsed.path.lstrip("/"), str(temp_destination), @@ -207,7 +224,7 @@ def ensure_soi_tehsil(): 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) + logger.info("SOI tehsil layer already exists atrestack-da %s, skipping.", SOI_TEHSIL_PATH) return SOI_TEHSIL_PATH.parent.mkdir(parents=True, exist_ok=True) @@ -390,7 +407,7 @@ def ensure_tehsil_watersheds(): output_dir=str(TEHSIL_WATERSHEDS_DIR), output_format="gpkg", overwrite=False, - clip_to_tehsil=False, + clip_to_tehsil=False,restack-da ) logger.info("Tehsil watershed files ready at %s", TEHSIL_WATERSHEDS_DIR) From e8a51934cc132a3ed5e22fd60d0b1321612953ca Mon Sep 17 00:00:00 2001 From: Ankit K Date: Fri, 3 Jul 2026 11:55:58 +0530 Subject: [PATCH 032/120] refactored the codebase for setting up --- README.md | 10 +- computing/base_layer_setup.py | 59 +++++- computing/config_loader.py | 26 ++- computing/config_new.yaml | 176 +++++++++--------- .../management/commands/base_layer_setup.py | 72 ------- .../commands/local_compute_layer_setup.py | 124 ++++++++++++ 6 files changed, 288 insertions(+), 179 deletions(-) delete mode 100644 computing/management/commands/base_layer_setup.py create mode 100644 computing/management/commands/local_compute_layer_setup.py diff --git a/README.md b/README.md index f2ff8924..001a4cc9 100644 --- a/README.md +++ b/README.md @@ -56,20 +56,22 @@ After installation, download the local base layers into `data/` before running l ```bash conda activate corestack-backend -python manage.py base_layer_setup +python manage.py local_compute_layer_setup ``` To inspect available layer selectors: ```bash -python manage.py base_layer_setup --list +python manage.py local_compute_layer_setup --list ``` To download only specific layers or groups: ```bash -python manage.py base_layer_setup terrain mws lulc_v3 -python manage.py base_layer_setup static_layers +python manage.py local_compute_layer_setup terrain mws lulc_v3 +python manage.py local_compute_layer_setup static_layers +python manage.py local_compute_layer_setup tehsil_level +python manage.py local_compute_layer_setup --ensure-soi-tehsil --ensure-tehsil-watersheds ``` - **Running celery:** diff --git a/computing/base_layer_setup.py b/computing/base_layer_setup.py index 030c5911..45ebba9c 100644 --- a/computing/base_layer_setup.py +++ b/computing/base_layer_setup.py @@ -87,16 +87,55 @@ def _expand_periodic_layer(layer: dict) -> list[dict]: return expanded_layers +def _expand_manifest_layers(layers: list[dict]) -> list[dict]: + expanded_layers = [] + for layer in layers: + expanded_layers.extend(_expand_periodic_layer(layer)) + return expanded_layers + + +def _manifest_group_key(path: tuple[str, ...]) -> str: + return "_".join(path) + + +def _walk_manifest_groups(node, path: tuple[str, ...] = ()): + if isinstance(node, list): + yield _manifest_group_key(path), _expand_manifest_layers(node), path + return + + if not isinstance(node, dict): + return + + descendant_layers = [] + for key, value in node.items(): + child_path = (*path, key) + if isinstance(value, list): + layers = _expand_manifest_layers(value) + descendant_layers.extend(layers) + yield _manifest_group_key(child_path), layers, child_path + elif isinstance(value, dict): + child_groups = list(_walk_manifest_groups(value, child_path)) + for group_name, layers, group_path in child_groups: + descendant_layers.extend(layers) + yield group_name, layers, group_path + + if path and descendant_layers: + yield _manifest_group_key(path), descendant_layers, path + + def _manifest_layer_groups() -> dict[str, list[dict]]: base_layers = _load_new_config().get("base_layers", {}) - groups = { - "static_layers": list(base_layers.get("static_layers", [])), - "on_demand_layers": list(base_layers.get("on_demand_layers", [])), - "periodic_layers": [], - } + groups = {} + leaf_aliases = {} + + for group_name, layers, group_path in _walk_manifest_groups(base_layers): + groups[group_name] = layers + leaf_name = group_path[-1] + leaf_aliases.setdefault(leaf_name, []).append(group_name) - for layer in base_layers.get("periodic_layers", []): - groups["periodic_layers"].extend(_expand_periodic_layer(layer)) + for leaf_name, group_names in leaf_aliases.items(): + if len(group_names) == 1: + groups.setdefault(leaf_name, groups[group_names[0]]) return groups @@ -167,7 +206,7 @@ def ensure_manifest_base_layers(*layers): Accepted selectors: - concrete layer names: terrain, mws, lulc_v3, slope_percentage - - group names: static_layers, periodic_layers, on_demand_layers + - group names: static_layers, periodic_layers, tehsil_level - all """ selected_layers = layers or ("static_layers", "periodic_layers") @@ -224,7 +263,7 @@ def ensure_soi_tehsil(): more data but takes much longer to acquire. """ if SOI_TEHSIL_PATH.exists(): - logger.info("SOI tehsil layer already exists atrestack-da %s, skipping.", SOI_TEHSIL_PATH) + logger.info("SOI tehsil layer already exists at %s, skipping.", SOI_TEHSIL_PATH) return SOI_TEHSIL_PATH.parent.mkdir(parents=True, exist_ok=True) @@ -407,7 +446,7 @@ def ensure_tehsil_watersheds(): output_dir=str(TEHSIL_WATERSHEDS_DIR), output_format="gpkg", overwrite=False, - clip_to_tehsil=False,restack-da + clip_to_tehsil=False, ) logger.info("Tehsil watershed files ready at %s", TEHSIL_WATERSHEDS_DIR) diff --git a/computing/config_loader.py b/computing/config_loader.py index 99350e8a..6ee89a41 100644 --- a/computing/config_loader.py +++ b/computing/config_loader.py @@ -63,14 +63,28 @@ def _expand_periodic_layer(layer: dict) -> list[dict]: return expanded_layers +def _expand_manifest_layers(layers: list[dict]) -> list[dict]: + expanded_layers = [] + for layer in layers: + expanded_layers.extend(_expand_periodic_layer(layer)) + return expanded_layers + + +def _walk_manifest_layers(node): + if isinstance(node, list): + yield from _expand_manifest_layers(node) + return + + if not isinstance(node, dict): + return + + for value in node.values(): + yield from _walk_manifest_layers(value) + + def _manifest_base_layers() -> list[dict]: base_layers = _cfg.get("base_layers", {}) - layers = [] - layers.extend(base_layers.get("static_layers", [])) - layers.extend(base_layers.get("on_demand_layers", [])) - for layer in base_layers.get("periodic_layers", []): - layers.extend(_expand_periodic_layer(layer)) - return layers + return list(_walk_manifest_layers(base_layers)) def _base_layer(name: str, *, required: bool = True) -> dict | None: diff --git a/computing/config_new.yaml b/computing/config_new.yaml index 11ba792c..cb8c9bf1 100644 --- a/computing/config_new.yaml +++ b/computing/config_new.yaml @@ -299,79 +299,79 @@ base_layers: source: "" type: file - on_demand_layers: - - name: lulc_farm_boundary - local_path: data/base_layers/on_demand/lulc_farm_boundary/ - source: "" - type: file - - - name: generate_ponds - local_path: data/base_layers/on_demand/ponds/ - source: "" - type: file - - - name: generate_wells - local_path: data/base_layers/on_demand/wells/ - source: "" - type: file - - - name: forest additonality - aliases: - - forest additionality - local_path: data/base_layers/on_demand/forest_additionality/ - source: "" - type: file - - - name: fes_clart_layer - local_path: data/base_layers/on_demand/fes_clart_layer/ - source: "" - type: file - - - name: generate_clart - local_path: data/base_layers/on_demand/clart/ - source: "" - type: file - - - name: generate_ndvi_timeseries - local_path: data/base_layers/on_demand/ndvi_timeseries/ - source: "" - type: file - - - name: downscaling et - local_path: data/base_layers/on_demand/downscaling_et/ - source: "" - type: file - - - name: forest fire - local_path: data/base_layers/on_demand/forest_fire/ - source: "" - type: file - - - name: grassland degradation - local_path: data/base_layers/on_demand/grassland_degradation/ - source: "" - type: file - - - name: deforestation on forest fringes core - local_path: data/base_layers/on_demand/deforestation_forest_fringes_core/ - source: "" - type: file - - - name: plantation_site_suitability - local_path: data/base_layers/on_demand/plantation_site_suitability/ - source: "" - type: file - - - name: temperature and humidity fortnightly timeseries - local_path: data/base_layers/on_demand/temperature_humidity_fortnightly/ - source: "" - type: file - - - name: pollution through satellite measured aerosol density - local_path: data/base_layers/on_demand/aerosol_density/ - source: "" - type: file - + tehsil_level: + on_demand_layers: + - name: lulc_farm_boundary + local_path: data/base_layers/on_demand/lulc_farm_boundary/ + source: "" + type: file + + - name: generate_ponds + local_path: data/base_layers/on_demand/ponds/ + source: "" + type: file + + - name: generate_wells + local_path: data/base_layers/on_demand/wells/ + source: "" + type: file + + - name: forest additonality + aliases: + - forest additionality + local_path: data/base_layers/on_demand/forest_additionality/ + source: "" + type: file + gee_only: + - name: fes_clart_layer + local_path: data/base_layers/on_demand/fes_clart_layer/ + source: "" + type: file + + - name: generate_clart + local_path: data/base_layers/on_demand/clart/ + source: "" + type: file + + - name: generate_ndvi_timeseries + local_path: data/base_layers/on_demand/ndvi_timeseries/ + source: "" + type: file + + - name: downscaling et + local_path: data/base_layers/on_demand/downscaling_et/ + source: "" + type: file + + - name: forest fire + local_path: data/base_layers/on_demand/forest_fire/ + source: "" + type: file + + - name: grassland degradation + local_path: data/base_layers/on_demand/grassland_degradation/ + source: "" + type: file + + - name: deforestation on forest fringes core + local_path: data/base_layers/on_demand/deforestation_forest_fringes_core/ + source: "" + type: file + + - name: plantation_site_suitability + local_path: data/base_layers/on_demand/plantation_site_suitability/ + source: "" + type: file + + - name: temperature and humidity fortnightly timeseries + local_path: data/base_layers/on_demand/temperature_humidity_fortnightly/ + source: "" + type: file + + - name: pollution through satellite measured aerosol density + local_path: data/base_layers/on_demand/aerosol_density/ + source: "" + type: file derived_layers: - name: generate_mws_layer @@ -414,26 +414,28 @@ derived_layers: layer_type: vector - name: change detection - params: [ - Afforestation, - Deforestation, - Degradation, - Urbanization, - CropIntensity - ] + params: + [ + Afforestation, + Deforestation, + Degradation, + Urbanization, + CropIntensity, + ] filename: change_{district}_{block}_{param_name}_{start_year}_{end_year}.tif local_path: data/change_detection/change_detection_local/{state}/{district}/{block}/{filename} geoserver_workspace: change_detection layer_type: raster - name: change detection vector - params: [ - Afforestation, - Deforestation, - Degradation, - Urbanization, - CropIntensity - ] + params: + [ + Afforestation, + Deforestation, + Degradation, + Urbanization, + CropIntensity, + ] filename: change_vector_{district}_{block}_{param_name}_{start_year}_{end_year}.gpkg local_path: data/change_detection/change_detection_vector_local/{state}/{district}/{block}/{filename} geoserver_workspace: change_detection diff --git a/computing/management/commands/base_layer_setup.py b/computing/management/commands/base_layer_setup.py deleted file mode 100644 index 9e1edb23..00000000 --- a/computing/management/commands/base_layer_setup.py +++ /dev/null @@ -1,72 +0,0 @@ -import logging - -from django.core.management.base import BaseCommand, CommandError - -from computing.base_layer_setup import ( - DEFAULT_BASE_LAYERS, - _BASE_LAYER_ENSURERS, - _manifest_layer_groups, - setup_base_layers, -) - - -class Command(BaseCommand): - help = ( - "Download required base layers into the local data directory. " - "Defaults to static_layers and periodic_layers." - ) - - def add_arguments(self, parser): - parser.add_argument( - "layers", - nargs="*", - help=( - "Layer names or groups to download, for example: terrain, mws, " - "lulc_v3, static_layers, periodic_layers. Defaults to the bootstrap set." - ), - ) - parser.add_argument( - "--list", - action="store_true", - help="List available layer selectors without downloading anything.", - ) - - def handle(self, *args, **options): - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - - if options["list"]: - self._print_available_layers() - return - - layers = tuple(options["layers"]) or DEFAULT_BASE_LAYERS - self.stdout.write(f"Setting up base layers: {', '.join(layers)}") - - try: - setup_base_layers(*layers) - except ValueError as exc: - raise CommandError(str(exc)) from exc - - self.stdout.write(self.style.SUCCESS("Base layer setup complete.")) - - def _print_available_layers(self): - self.stdout.write("Downloadable manifest groups:") - for group_name, layers in _manifest_layer_groups().items(): - if not layers: - continue - self.stdout.write(f" {group_name}") - for layer in layers: - source = layer.get("source") - status = ( - "downloadable" - if source and layer.get("type") == "file" - else "manual" - ) - label = layer["name"] - if "year" in layer: - label = f"{label} {layer['year']}-{layer['year'] + 1}" - self.stdout.write(f" - {label} ({status})") - - self.stdout.write("") - self.stdout.write("Legacy/generated selectors:") - for selector in sorted(_BASE_LAYER_ENSURERS): - self.stdout.write(f" - {selector}") diff --git a/computing/management/commands/local_compute_layer_setup.py b/computing/management/commands/local_compute_layer_setup.py new file mode 100644 index 00000000..1c156766 --- /dev/null +++ b/computing/management/commands/local_compute_layer_setup.py @@ -0,0 +1,124 @@ +import logging + +from django.core.management.base import BaseCommand, CommandError + +from computing.base_layer_setup import ( + DEFAULT_BASE_LAYERS, + _BASE_LAYER_ENSURERS, + _manifest_layer_groups, + setup_base_layers, +) + + +class Command(BaseCommand): + help = ( + "Set up required local compute layers into the local data directory. " + "Defaults to static_layers and periodic_layers." + ) + + def add_arguments(self, parser): + parser.add_argument( + "layers", + nargs="*", + help=( + "Layer names or groups to download, for example: terrain, mws, " + "lulc_v3, static_layers, periodic_layers. Defaults to the bootstrap set." + ), + ) + parser.add_argument( + "--ensure-soi-tehsil", + action="store_true", + help="Download the SOI tehsil layer if it is missing.", + ) + parser.add_argument( + "--ensure-admin-boundary", + action="store_true", + help="Download and extract admin boundary data if it is missing.", + ) + parser.add_argument( + "--ensure-lulc-rasters", + action="store_true", + help="Download missing LULC raster files.", + ) + parser.add_argument( + "--ensure-microwatershed", + action="store_true", + help="Download the microwatershed layer if it is missing.", + ) + parser.add_argument( + "--ensure-tehsil-watersheds", + action="store_true", + help="Generate per-tehsil watershed files if they are missing.", + ) + parser.add_argument( + "--ensure-village-boundaries", + action="store_true", + help="Create the village boundaries directory if it is missing.", + ) + parser.add_argument( + "--list", + action="store_true", + help="List available layer selectors without downloading anything.", + ) + + def handle(self, *args, **options): + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + if options["list"]: + self._print_available_layers() + return + + layers = self._selected_layers(options) + self.stdout.write(f"Setting up local compute layers: {', '.join(layers)}") + + try: + setup_base_layers(*layers) + except ValueError as exc: + raise CommandError(str(exc)) from exc + + self.stdout.write(self.style.SUCCESS("Local compute layer setup complete.")) + + def _selected_layers(self, options): + layers = list(options["layers"]) + flag_layers = { + "ensure_soi_tehsil": "soi_tehsil", + "ensure_admin_boundary": "admin_boundary", + "ensure_lulc_rasters": "lulc_rasters", + "ensure_microwatershed": "microwatershed", + "ensure_tehsil_watersheds": "tehsil_watersheds", + "ensure_village_boundaries": "village_boundaries", + } + + for option_name, layer_name in flag_layers.items(): + if options[option_name]: + layers.append(layer_name) + + return tuple(dict.fromkeys(layers)) or DEFAULT_BASE_LAYERS + + def _print_available_layers(self): + self.stdout.write("Downloadable manifest groups:") + for group_name, layers in _manifest_layer_groups().items(): + if not layers: + continue + self.stdout.write(f" {group_name}") + for layer in layers: + source = layer.get("source") + status = ( + "downloadable" + if source and layer.get("type") == "file" + else "manual" + ) + label = layer["name"] + if "year" in layer: + label = f"{label} {layer['year']}-{layer['year'] + 1}" + self.stdout.write(f" - {label} ({status})") + + self.stdout.write("") + self.stdout.write("Local/generated selectors:") + for selector in sorted(_BASE_LAYER_ENSURERS): + self.stdout.write(f" - {selector}") + + self.stdout.write("") + self.stdout.write("Local/generated flags:") + for selector in sorted(_BASE_LAYER_ENSURERS): + self.stdout.write(f" --ensure-{selector.replace('_', '-')}") From 58c3cb3ea4038a47c68d9644f18877aa9beb43c3 Mon Sep 17 00:00:00 2001 From: "shiv.prakash1" Date: Sun, 5 Jul 2026 14:23:05 +0530 Subject: [PATCH 033/120] chnages for local compute --- computing/api.py | 2 +- computing/misc/antyodaya_local_compute.py | 28 +++++++--------- .../facilities_proximity_local_compute.py | 21 +++++++----- computing/misc/livestocks_local_compute.py | 33 +++++++------------ .../mws/mws_connectivity_local_compute.py | 22 ++++--------- 5 files changed, 43 insertions(+), 63 deletions(-) diff --git a/computing/api.py b/computing/api.py index e7bde1b5..6000bbda 100644 --- a/computing/api.py +++ b/computing/api.py @@ -1961,7 +1961,7 @@ def generate_facilities_proximity(request): generate_facilities_proximity_task, generate_facilities_proximity_local_task, ) - task.apply_async(args=[state, district, block, gee_account_id], queue="nrm1") + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) diff --git a/computing/misc/antyodaya_local_compute.py b/computing/misc/antyodaya_local_compute.py index 9911ba18..0b8b3dce 100644 --- a/computing/misc/antyodaya_local_compute.py +++ b/computing/misc/antyodaya_local_compute.py @@ -26,7 +26,7 @@ from computing.local_compute_helper import ( PROJECT_ROOT, build_output_vector_path, - load_precomputed_watersheds, + load_precomputed_panchayat, read_validated_vector_file, write_vector_output, validate_geometry, @@ -39,20 +39,14 @@ GEOSERVER_WORKSPACE = "antyodaya_2020" -def _compute_antyodaya_for_watersheds(watersheds_gdf, antyodaya_gdf): - """ - Spatially filters Antyodaya features with watershed/ROI boundaries. - """ +def _compute_antyodaya_for_panchayat(panchayat_gdf, antyodaya_gdf): if antyodaya_gdf.empty: return antyodaya_gdf - - if watersheds_gdf.crs and antyodaya_gdf.crs and watersheds_gdf.crs != antyodaya_gdf.crs: - antyodaya_gdf = antyodaya_gdf.to_crs(watersheds_gdf.crs) - outer_boundary = watersheds_gdf.geometry.unary_union + outer_boundary = panchayat_gdf.geometry.unary_union - # Precise intersection check - antyodaya_in_roi = antyodaya_gdf[antyodaya_gdf.intersects(outer_boundary)].copy() + # Clip Antyodaya geometries to the panchayat boundary + antyodaya_in_roi = gpd.clip(antyodaya_gdf, outer_boundary).copy() # Final cleanup antyodaya_in_roi = antyodaya_in_roi[~antyodaya_in_roi.geometry.is_empty] @@ -77,7 +71,7 @@ def generate_antyodaya_data_local( ): if state and district and block: layer_name = f"antyodaya20_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" - watersheds_gdf, watershed_source = load_precomputed_watersheds( + panchayat_gdf, watershed_source = load_precomputed_panchayat( state=state, district=district, block=block, @@ -88,22 +82,22 @@ def generate_antyodaya_data_local( if not roi_path or not asset_suffix: raise ValueError("ROI path and asset_suffix are required for custom runs.") layer_name = f"antyodaya20_{valid_gee_text(asset_suffix).lower()}" - watersheds_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") + panchayat_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") print(f"ROI source: {roi_path}") if not os.path.exists(PAN_INDIA_ANTYODAYA_2020): raise FileNotFoundError(f"PAN INDIA Antyodaya file not found at {PAN_INDIA_ANTYODAYA_2020}") print("Loading Antyodaya data overlapping ROI...") - antyodaya_gdf = gpd.read_file(PAN_INDIA_ANTYODAYA_2020, mask=watersheds_gdf) + antyodaya_gdf = gpd.read_file(PAN_INDIA_ANTYODAYA_2020, mask=panchayat_gdf) antyodaya_gdf = validate_geometry(antyodaya_gdf) if antyodaya_gdf.empty: print("Warning: PAN INDIA Antyodaya file has no valid geometries overlapping ROI") else: print(f"Loaded {len(antyodaya_gdf)} Antyodaya features") - result_gdf = _compute_antyodaya_for_watersheds( - watersheds_gdf=watersheds_gdf, + result_gdf = _compute_antyodaya_for_panchayat( + panchayat_gdf=panchayat_gdf, antyodaya_gdf=antyodaya_gdf, ) print(f"Final valid Antyodaya features after spatial filter: {len(result_gdf)}") @@ -150,4 +144,4 @@ def generate_antyodaya_data_local( update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) print("Sync to GeoServer flag updated for Antyodaya vector") - return layer_at_geoserver + return layer_at_geoserver \ No newline at end of file diff --git a/computing/misc/facilities_proximity_local_compute.py b/computing/misc/facilities_proximity_local_compute.py index 9f28eb6a..7cd4225d 100644 --- a/computing/misc/facilities_proximity_local_compute.py +++ b/computing/misc/facilities_proximity_local_compute.py @@ -26,27 +26,30 @@ def _compute_proximity_for_panchayat(panchayat_gdf, facilities_gdf): - """ - Filters facilities to strictly those intersecting the panchayat boundaries, - without altering/clipping their geometries. - """ if facilities_gdf.empty: return facilities_gdf - # Ensure CRS matches - if panchayat_gdf.crs and facilities_gdf.crs and panchayat_gdf.crs != facilities_gdf.crs: - facilities_gdf = facilities_gdf.to_crs(panchayat_gdf.crs) - outer_boundary = panchayat_gdf.geometry.unary_union # Keep facilities that intersect the boundary, geometries unchanged - facilities_in_roi = facilities_gdf[facilities_gdf.intersects(outer_boundary)].copy() + facilities_in_roi = gpd.clip(facilities_gdf, outer_boundary).copy() # Final cleanup facilities_in_roi = facilities_in_roi[~facilities_in_roi.geometry.is_empty] facilities_in_roi = facilities_in_roi[facilities_in_roi.geometry.is_valid] facilities_in_roi = facilities_in_roi[facilities_in_roi.geometry.notna()] + # Rename NAME to censusname + if "NAME" in facilities_in_roi.columns: + facilities_in_roi = facilities_in_roi.rename(columns={"NAME": "censusname"}) + + # Add state, district, tehsil from panchayat_gdf + for col in ["state", "district", "tehsil"]: + if col in panchayat_gdf.columns: + # Assign the value for the tehsil (taking the first valid row) + first_val = panchayat_gdf[col].dropna().iloc[0] if not panchayat_gdf[col].dropna().empty else None + facilities_in_roi[col] = first_val + return facilities_in_roi diff --git a/computing/misc/livestocks_local_compute.py b/computing/misc/livestocks_local_compute.py index 67120c27..37ed5764 100644 --- a/computing/misc/livestocks_local_compute.py +++ b/computing/misc/livestocks_local_compute.py @@ -11,7 +11,7 @@ from computing.local_compute_helper import ( PROJECT_ROOT, build_output_vector_path, - load_precomputed_watersheds, + load_precomputed_panchayat, read_validated_vector_file, write_vector_output, validate_geometry, @@ -54,22 +54,14 @@ def _coerce_nullable_integer_columns(gdf): gdf[column] = gdf[column].astype("Int64") return gdf -def _compute_livestocks_for_watersheds(watersheds_gdf, livestocks_gdf): - """ - Spatially filters Livestock features with watershed/ROI boundaries. - """ +def _compute_livestocks_for_panchayat(panchayat_gdf, livestocks_gdf): if livestocks_gdf.empty: return livestocks_gdf - - if watersheds_gdf.crs and livestocks_gdf.crs and watersheds_gdf.crs != livestocks_gdf.crs: - livestocks_gdf = livestocks_gdf.to_crs(watersheds_gdf.crs) - - outer_boundary = watersheds_gdf.geometry.unary_union - # Precise intersection check - livestocks_in_roi = livestocks_gdf[livestocks_gdf.intersects(outer_boundary)].copy() + outer_boundary = panchayat_gdf.geometry.unary_union - # Final cleanup + # Clip Antyodaya geometries to the panchayat boundary + livestocks_in_roi = gpd.clip(livestocks_gdf, outer_boundary).copy() livestocks_in_roi = livestocks_in_roi[~livestocks_in_roi.geometry.is_empty] livestocks_in_roi = livestocks_in_roi[livestocks_in_roi.geometry.is_valid] livestocks_in_roi = livestocks_in_roi[livestocks_in_roi.geometry.notna()] @@ -92,33 +84,32 @@ def generate_livestocks_data_local( ): if state and district and block: layer_name = f"livestocks_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" - watersheds_gdf, watershed_source = load_precomputed_watersheds( + panchayat_gdf, watershed_source = load_precomputed_panchayat( state=state, district=district, block=block, precomputed_roi_dir=precomputed_roi_dir, ) - print(f"Watershed boundary source: {watershed_source}") else: if not roi_path or not asset_suffix: raise ValueError("ROI path and asset_suffix are required for custom runs.") layer_name = f"livestocks_{valid_gee_text(asset_suffix).lower()}" - watersheds_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") + panchayat_gdf = read_validated_vector_file(roi_path, f"Invalid ROI file: {roi_path}") print(f"ROI source: {roi_path}") if not os.path.exists(PAN_INDIA_LIVESTOCKS): raise FileNotFoundError(f"PAN INDIA Livestocks file not found at {PAN_INDIA_LIVESTOCKS}") print("Loading Livestocks data overlapping ROI...") - livestocks_gdf = gpd.read_file(PAN_INDIA_LIVESTOCKS, mask=watersheds_gdf) + livestocks_gdf = gpd.read_file(PAN_INDIA_LIVESTOCKS, mask=panchayat_gdf) livestocks_gdf = validate_geometry(livestocks_gdf) if livestocks_gdf.empty: print("Warning: PAN INDIA Livestocks file has no valid geometries overlapping ROI") else: print(f"Loaded {len(livestocks_gdf)} Livestock features") - result_gdf = _compute_livestocks_for_watersheds( - watersheds_gdf=watersheds_gdf, + result_gdf = _compute_livestocks_for_panchayat( + panchayat_gdf=panchayat_gdf, livestocks_gdf=livestocks_gdf, ) print(f"Final valid Livestock features after spatial filter: {len(result_gdf)}") @@ -158,11 +149,11 @@ def generate_livestocks_data_local( block=block, layer_name=layer_name, asset_id=asset_id, - dataset_name="Livestock Census", + dataset_name="Livestock Census 2019", misc={"is_generated_locally": True}, ) if layer_id: update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) print("Sync to GeoServer flag updated for Livestock vector") - return layer_at_geoserver + return layer_at_geoserver \ No newline at end of file diff --git a/computing/mws/mws_connectivity_local_compute.py b/computing/mws/mws_connectivity_local_compute.py index e1765896..79dbfc49 100644 --- a/computing/mws/mws_connectivity_local_compute.py +++ b/computing/mws/mws_connectivity_local_compute.py @@ -32,23 +32,15 @@ def _compute_mws_connectivity_for_watersheds(watersheds_gdf, mws_gdf): - mws_in_roi = mws_gdf.copy() - - if mws_in_roi.empty: + if mws_gdf.empty: print("No MWS connectivity found within the outer boundary.") - return mws_in_roi - - print(f"MWS connectivity within outer boundary: {len(mws_in_roi)}") + return mws_gdf - # Step 2: Spatial join to clip results to individual watersheds - mws_in_roi = gpd.sjoin( - mws_in_roi, - watersheds_gdf[["geometry"]], # no uid, no collision - how="inner", - predicate="intersects", - ).drop(columns=["index_right"], errors="ignore") + # Step 1: Clip to the outer boundary of watersheds + outer_boundary = watersheds_gdf.geometry.unary_union + mws_in_roi = gpd.clip(mws_gdf, outer_boundary).copy() - # Step 3: Drop empty/invalid geometries + # Step 2: Drop empty/invalid geometries mws_in_roi = fix_invalid_geometry_in_gdf(mws_in_roi) mws_in_roi = mws_in_roi[ mws_in_roi.geometry.notna() @@ -147,4 +139,4 @@ def mws_connectivity_vector( update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) print("Sync to GeoServer flag updated for MWS connectivity vector") - return True + return True \ No newline at end of file From a0f1d3d79d654eef38f65b9ef01a592e29c3262e Mon Sep 17 00:00:00 2001 From: Ankit K Date: Mon, 6 Jul 2026 15:37:32 +0530 Subject: [PATCH 034/120] tree health local refactor --- computing/api.py | 77 ++++- computing/spei/generate_spei/compute_spei.R | 72 ++-- .../generate_spei/download_base_datasets.py | 80 ++++- .../generate_spei/generate_ppet_multiband.py | 6 +- computing/spei/spei.py | 26 +- .../tree_health/{ => gee}/canopy_height.py | 14 +- .../{ => gee}/canopy_height_vector.py | 2 +- computing/tree_health/{ => gee}/ccd.py | 14 +- computing/tree_health/{ => gee}/ccd_vector.py | 2 +- .../tree_health/{ => gee}/overall_change.py | 10 +- .../{ => gee}/overall_change_vector.py | 2 +- .../tree_health/local/canopy_height_local.py | 278 ++++++++++++++++ .../local/canopy_height_vector_local.py | 174 ++++++++++ computing/tree_health/local/ccd_local.py | 275 +++++++++++++++ .../tree_health/local/ccd_vector_local.py | 171 ++++++++++ .../tree_health/local/overall_change_local.py | 315 ++++++++++++++++++ .../local/overall_change_vector_local.py | 158 +++++++++ 17 files changed, 1617 insertions(+), 59 deletions(-) rename computing/tree_health/{ => gee}/canopy_height.py (88%) rename computing/tree_health/{ => gee}/canopy_height_vector.py (99%) rename computing/tree_health/{ => gee}/ccd.py (88%) rename computing/tree_health/{ => gee}/ccd_vector.py (99%) rename computing/tree_health/{ => gee}/overall_change.py (92%) rename computing/tree_health/{ => gee}/overall_change_vector.py (98%) create mode 100644 computing/tree_health/local/canopy_height_local.py create mode 100644 computing/tree_health/local/canopy_height_vector_local.py create mode 100644 computing/tree_health/local/ccd_local.py create mode 100644 computing/tree_health/local/ccd_vector_local.py create mode 100644 computing/tree_health/local/overall_change_local.py create mode 100644 computing/tree_health/local/overall_change_vector_local.py diff --git a/computing/api.py b/computing/api.py index e7bde1b5..fbcb4717 100644 --- a/computing/api.py +++ b/computing/api.py @@ -133,12 +133,19 @@ from .terrain_descriptor.terrain_raster_fabdem_local import ( generate_terrain_raster_clip as generate_terrain_raster_clip_local_task, ) -from .tree_health.canopy_height import tree_health_ch_raster -from .tree_health.canopy_height_vector import tree_health_ch_vector -from .tree_health.ccd import tree_health_ccd_raster -from .tree_health.ccd_vector import tree_health_ccd_vector -from .tree_health.overall_change import tree_health_overall_change_raster -from .tree_health.overall_change_vector import tree_health_overall_change_vector +from .tree_health.gee.canopy_height import tree_health_ch_raster +from .tree_health.gee.canopy_height_vector import tree_health_ch_vector +from .tree_health.gee.ccd import tree_health_ccd_raster +from .tree_health.gee.ccd_vector import tree_health_ccd_vector +from .tree_health.gee.overall_change import tree_health_overall_change_raster +from .tree_health.gee.overall_change_vector import tree_health_overall_change_vector +from .tree_health.local.canopy_height_local import tree_health_ch_raster_local +from .tree_health.local.canopy_height_vector_local import tree_health_ch_vector_local +from .tree_health.local.ccd_local import tree_health_ccd_raster_local +from .tree_health.local.ccd_vector_local import tree_health_ccd_vector_local +from .tree_health.local.overall_change_local import tree_health_overall_change_raster_local +from .tree_health.local.overall_change_vector_local import tree_health_overall_change_vector_local + from .utils import ( Geoserver, kml_to_shp, @@ -1083,7 +1090,16 @@ def tree_health_raster(request): start_year = request.data.get("start_year") end_year = request.data.get("end_year") gee_account_id = request.data.get("gee_account_id") - tree_health_ccd_raster.apply_async( + + compute = _get_compute_mode(request) + ccd_task = _select_compute_task( + compute, + tree_health_ccd_raster, + tree_health_ccd_raster_local, + ) + print("What is task? ", ccd_task) + + ccd_task.apply_async( kwargs={ "state": state, "district": district, @@ -1094,7 +1110,14 @@ def tree_health_raster(request): }, queue="nrm", ) - tree_health_ch_raster.apply_async( + + ch_task = _select_compute_task( + compute, + tree_health_ch_raster, + tree_health_ch_raster_local, + ) + print("What is task? ", ch_task) + ch_task.apply_async( kwargs={ "state": state, "district": district, @@ -1105,7 +1128,13 @@ def tree_health_raster(request): }, queue="nrm", ) - tree_health_overall_change_raster.apply_async( + overall_task = _select_compute_task( + compute, + tree_health_overall_change_raster, + tree_health_overall_change_raster_local, + ) + print("What is task? ", overall_task) + overall_task.apply_async( kwargs={ "state": state, "district": district, @@ -1117,6 +1146,7 @@ def tree_health_raster(request): queue="nrm", ) + return Response( {"Success": "tree_health task initiated"}, status=status.HTTP_200_OK, @@ -1138,7 +1168,15 @@ def tree_health_vector(request): end_year = request.data.get("end_year") gee_account_id = request.data.get("gee_account_id") - tree_health_ch_vector.apply_async( + compute = _get_compute_mode(request) + ccd_task = _select_compute_task( + compute, + tree_health_ccd_vector, + tree_health_ccd_vector_local, + ) + print("What is task? ", ccd_task) + + ccd_task.apply_async( kwargs={ "state": state, "district": district, @@ -1150,7 +1188,14 @@ def tree_health_vector(request): queue="nrm", ) - tree_health_ccd_vector.apply_async( + ch_task = _select_compute_task( + compute, + tree_health_ch_vector, + tree_health_ch_vector_local, + ) + print("What is task? ", ch_task) + + ch_task.apply_async( kwargs={ "state": state, "district": district, @@ -1162,7 +1207,14 @@ def tree_health_vector(request): queue="nrm", ) - tree_health_overall_change_vector.apply_async( + overall_task = _select_compute_task( + compute, + tree_health_overall_change_vector, + tree_health_overall_change_vector_local, + ) + print("What is task? ", overall_task) + + overall_task.apply_async( kwargs={ "state": state, "district": district, @@ -1171,6 +1223,7 @@ def tree_health_vector(request): }, queue="nrm", ) + return Response( {"Success": "Overall_change_vector task initiated"}, status=status.HTTP_200_OK, diff --git a/computing/spei/generate_spei/compute_spei.R b/computing/spei/generate_spei/compute_spei.R index 35d31b3d..c80cf8e9 100644 --- a/computing/spei/generate_spei/compute_spei.R +++ b/computing/spei/generate_spei/compute_spei.R @@ -1,22 +1,36 @@ -""" - SPEI Pipeline - Read multiband P-PET GeoTIFF, compute SPEI-1/3/12 pixel-wise, - write 3 multiband output GeoTIFFs with named bands. - Here the reference baseline period is taken as 2004-2023. - Change the end_year variable to whatever year you wanna extend the pipeline to. - If it is not intentional, don't touch the ref_start and ref_end variables for - extending the pipeline as it will change the SPEI values for all previous years too. -""" + +# SPEI Pipeline +# Read multiband P-PET GeoTIFF, compute SPEI-1/3/12 pixel-wise, +# write 3 multiband output GeoTIFFs with named bands. +# Here the reference baseline period is taken as 2004-2023. +# Change the end_year variable to whatever year you wanna extend the pipeline to. +# If it is not intentional, don't touch the ref_start and ref_end variables for +# extending the pipeline as it will change the SPEI values for all previous years too. +# Run below commands for installing the dependencies if not already installed: +# sudo apt install r-base-core +# conda install -c conda-forge r-spei r-raster +# Rscript -e "install.packages('terra', repos='https://cloud.r-project.org')" +# Rscript -e "install.packages('raster', repos='https://cloud.r-project.org')" library(SPEI) library(raster) run_spei_pipeline <- function(aez, start_year, end_year) { - input_file <- paste0("data/drought_inputs/", aez, "/monthly/P_PET_AEZ_", aez, "_monthly_multiband.tif") - output_dir <- paste0("data/drought_inputs/", aez, "/monthly") + input_file <- paste0("data/base_layers/spei/inputs/", aez, "/monthly/P_PET_AEZ_", aez, "_monthly_multiband.tif") + # output_dir <- paste0("data/base_layers/spei/outputs") + + # if (!dir.exists(output_dir)) dir.create(output_dir, recursive = TRUE) + + output_base <- "data/base_layers/spei/outputs" - if (!dir.exists(output_dir)) dir.create(output_dir, recursive = TRUE) + output_dir1 <- file.path(output_base, "SPEI_1") + output_dir3 <- file.path(output_base, "SPEI_3") + output_dir12 <- file.path(output_base, "SPEI_12") + + dir.create(output_dir1, recursive = TRUE, showWarnings = FALSE) + dir.create(output_dir3, recursive = TRUE, showWarnings = FALSE) + dir.create(output_dir12, recursive = TRUE, showWarnings = FALSE) # --- YEAR RANGE --- ref_start <- 2004 # baseline period start — distribution fitted on this range @@ -29,10 +43,10 @@ run_spei_pipeline <- function(aez, start_year, end_year) { n_output <- n_monthly + n_seasonal + n_annual # --- Resume check --- - out_check <- file.path(output_dir, paste0("SPEI12_", aez, ".tif")) - if (file.exists(out_check)) { - stop(paste("Already processed:", aez, "— delete output files to rerun.")) - } + # out_check <- file.path(output_dir, paste0("SPEI12_", aez, ".tif")) + # if (file.exists(out_check)) { + # stop(paste("Already processed:", aez, "— delete output files to rerun.")) + # } # ============================================================================= # SPEI FUNCTION — do not modify @@ -85,7 +99,7 @@ run_spei_pipeline <- function(aez, start_year, end_year) { # --- Compute block by block --- cat("Running SPEI computation...\n") - temp_file <- file.path(output_dir, paste0(aez, "_temp.tif")) + temp_file <- file.path(output_base, paste0(aez, "_temp.tif")) result_brick <- brick(p_pet_brick, nl = n_output) result_brick <- writeStart(result_brick, filename = temp_file, overwrite = TRUE) @@ -114,19 +128,33 @@ run_spei_pipeline <- function(aez, start_year, end_year) { names(spei3_b) <- spei3_names names(spei12_b) <- spei12_names + spei1_file <- file.path(output_dir1, paste0("SPEI1_", aez, ".tif")) + spei3_file <- file.path(output_dir3, paste0("SPEI3_", aez, ".tif")) + spei12_file <- file.path(output_dir12, paste0("SPEI12_", aez, ".tif")) + + if (file.exists(spei1_file)) file.remove(spei1_file) + if (file.exists(spei3_file)) file.remove(spei3_file) + if (file.exists(spei12_file)) file.remove(spei12_file) + writeRaster(spei1_b, - file.path(output_dir, paste0("SPEI1_", aez, ".tif")), - format = "GTiff", overwrite = TRUE, NAflag = -9999) + spei1_file, + format = "GTiff", overwrite = TRUE, NAflag = -9999) + writeRaster(spei3_b, - file.path(output_dir, paste0("SPEI3_", aez, ".tif")), + spei3_file, format = "GTiff", overwrite = TRUE, NAflag = -9999) + writeRaster(spei12_b, - file.path(output_dir, paste0("SPEI12_", aez, ".tif")), + spei12_file, format = "GTiff", overwrite = TRUE, NAflag = -9999) file.remove(temp_file) - cat(paste0("\n Done. Output files saved to: ", output_dir, "\n")) + cat("\nDone.\n") + cat(paste0("SPEI-1 : ", spei1_file, "\n")) + cat(paste0("SPEI-3 : ", spei3_file, "\n")) + cat(paste0("SPEI-12 : ", spei12_file, "\n")) + cat(paste0(" SPEI1_", aez, ".tif — ", nlayers(spei1_b), " bands\n")) cat(paste0(" SPEI3_", aez, ".tif — ", nlayers(spei3_b), " bands\n")) cat(paste0(" SPEI12_", aez, ".tif — ", nlayers(spei12_b), " bands\n")) diff --git a/computing/spei/generate_spei/download_base_datasets.py b/computing/spei/generate_spei/download_base_datasets.py index 899297a4..d21c8747 100644 --- a/computing/spei/generate_spei/download_base_datasets.py +++ b/computing/spei/generate_spei/download_base_datasets.py @@ -17,6 +17,7 @@ import time from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta from pathlib import Path import ee @@ -163,6 +164,64 @@ def download_image( time.sleep(2 * attempt) +def get_last_downloaded_date( + output_dir: Path, aez: int, frequency: str, dataset: str +) -> str | None: + """ + Check the output directory and find the last downloaded date. + Returns the latest date as a string (YYYYMMDD or YYYYMM format). + """ + dataset_output_dir = output_dir / str(aez) / frequency / dataset + + if not dataset_output_dir.exists(): + return None + + # Get all .tif files in the directory + tif_files = list(dataset_output_dir.glob("*.tif")) + + if not tif_files: + return None + + # Extract dates from filenames (e.g., "CHIRPS_20240101.tif" or "CHIRPS_202401.tif") + dates = [] + for file_path in tif_files: + # Extract the date part from filename (last part before .tif) + filename = file_path.stem # e.g., "CHIRPS_20240101" + parts = filename.split("_") + if len(parts) >= 2: + date_str = parts[-1] # e.g., "20240101" + try: + # Validate it's a proper date format + if len(date_str) in (8, 6): # YYYYMMDD or YYYYMM + dates.append(date_str) + except (ValueError, IndexError): + continue + + if not dates: + return None + + # Sort and return the latest date + return sorted(dates)[-1] + + +def get_next_date(current_date: str, frequency: str) -> str: + """ + Given a downloaded-date label, return the next start date in the format + expected by Earth Engine's ee.Date parsing. + """ + if frequency in ("daily", "native"): + date_obj = datetime.strptime(current_date, "%Y%m%d") + next_date_obj = date_obj + timedelta(days=1) + return next_date_obj.strftime("%Y-%m-%d") + + date_obj = datetime.strptime(current_date, "%Y%m") + if date_obj.month == 12: + next_date_obj = date_obj.replace(year=date_obj.year + 1, month=1) + else: + next_date_obj = date_obj.replace(month=date_obj.month + 1) + return next_date_obj.strftime("%Y-%m-01") + + 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") @@ -253,7 +312,17 @@ def download_dataset_images( name = dataset_label(dataset) print(output_dir, aez, frequency, dataset) dataset_output_dir = Path(output_dir) / str(aez) / frequency / dataset - + print("start_date",start_date) + # Check if files already exist and find the last downloaded date + last_downloaded = get_last_downloaded_date(Path(output_dir), aez, frequency, dataset) + + if last_downloaded and not overwrite: + # Convert last downloaded date to next date + next_date_str = get_next_date(last_downloaded, frequency) + print(f"Last downloaded date: {last_downloaded}") + print(f"Resuming downloads from: {next_date_str}") + start_date = next_date_str + collection, region, _, labeled_dates = build_dataset_image( aez=aez, dataset=dataset, @@ -262,6 +331,11 @@ def download_dataset_images( frequency=frequency, ) + # If no dates to download, skip processing + if not labeled_dates: + print(f"No new {name} image(s) to download for aez {aez}") + return + download_jobs = [] target_projection = None if dataset == "modis_pet" and frequency == "monthly": @@ -333,9 +407,9 @@ def download_data_locally( aez: int, datasets: list[str] | str | None = None, start_date: str = "2004-01-01", - end_date: str = "2023-12-31", + end_date: str = "2025-12-31", frequency: str = "monthly", - output_dir: str = "data/drought_inputs", + output_dir: str = "data/base_layers/spei/inputs", sleep: float = 0.2, max_workers: int = 4, overwrite: bool = False, diff --git a/computing/spei/generate_spei/generate_ppet_multiband.py b/computing/spei/generate_spei/generate_ppet_multiband.py index 6ec01acc..a05cd155 100644 --- a/computing/spei/generate_spei/generate_ppet_multiband.py +++ b/computing/spei/generate_spei/generate_ppet_multiband.py @@ -84,7 +84,7 @@ def reproject_modis_to_chirps_grid( def ppet_multiband( aez=None, start: int = 2004, - end: int = 2023, + end: int = 2024, ) -> Path: """ SPEI Pipeline - Step 1 (Local P-PET) @@ -97,12 +97,12 @@ def ppet_multiband( Band names follow the original script: y{year}_m{month}, e.g. y2015_m06. """ - data_root = Path("data/drought_inputs") + data_root = Path("data/base_layers/spei/inputs") chirps_dir = data_root / str(aez) / "monthly" / "chirps" modis_dir = data_root / str(aez) / "monthly" / "modis_pet" # input_root = Path("data/drought_inputs") - output_dir = Path("data/drought_inputs") / str(aez) / "monthly" + output_dir = data_root / str(aez) / "monthly" output = output_dir / f"P_PET_AEZ_{str(aez)}_monthly_multiband.tif" OUTPUT_NODATA = -9999.0 diff --git a/computing/spei/spei.py b/computing/spei/spei.py index 50642d35..b3d0030b 100644 --- a/computing/spei/spei.py +++ b/computing/spei/spei.py @@ -26,20 +26,20 @@ def generate_spei_pipeline( start_date = f"{str(start_year)}-01-01" end_date = f"{str(end_year)}-12-31" - download_data_locally( - aez=aez, - start_date=start_date, - end_date=end_date, - frequency="monthly", - datasets=None, - overwrite=overwrite, - ) + # download_data_locally( + # aez=aez, + # start_date=start_date, + # end_date=end_date, + # frequency="monthly", + # datasets=None, + # overwrite=overwrite, + # ) - ppet_multiband( - aez=aez, - start=start_year, - end=end_year, - ) + # ppet_multiband( + # aez=aez, + # start=start_year, + # end=end_year, + # ) run_spei(aez, start_year, end_year) diff --git a/computing/tree_health/canopy_height.py b/computing/tree_health/gee/canopy_height.py similarity index 88% rename from computing/tree_health/canopy_height.py rename to computing/tree_health/gee/canopy_height.py index 50686ba6..c7d804ff 100644 --- a/computing/tree_health/canopy_height.py +++ b/computing/tree_health/gee/canopy_height.py @@ -14,6 +14,7 @@ get_gee_dir_path, ) from computing.utils import save_layer_info_to_db, update_layer_sync_status +from computing.STAC_specs import generate_STAC_layerwise # Celery task to generate canopy height raster @@ -127,11 +128,22 @@ def tree_health_ch_raster( # Sync raster from GCS to GeoServer res = sync_raster_gcs_to_geoserver( - "canopy_height", description, description, "ch_style" + "tree_ch_raster", description, description, "tree_ch_style" ) if res and layer_id: layer_at_geoserver = True + + # layer_STAC_generated = False + # layer_STAC_generated = generate_STAC_layerwise.generate_raster_stac( + # state=state, + # district=district, + # block=block, + # layer_name="ch_raster", + # start_year=year, + # ) + + # Update sync flag in DB update_layer_sync_status( layer_id=layer_id, sync_to_geoserver=layer_at_geoserver, diff --git a/computing/tree_health/canopy_height_vector.py b/computing/tree_health/gee/canopy_height_vector.py similarity index 99% rename from computing/tree_health/canopy_height_vector.py rename to computing/tree_health/gee/canopy_height_vector.py index c0ea6102..9631c0a6 100644 --- a/computing/tree_health/canopy_height_vector.py +++ b/computing/tree_health/gee/canopy_height_vector.py @@ -100,7 +100,7 @@ def tree_health_ch_vector( merged_fc = ee.FeatureCollection(asset_id) # Sync to GeoServer - sync_res = sync_fc_to_geoserver(merged_fc, state, description, "canopy_height") + sync_res = sync_fc_to_geoserver(merged_fc, state, description, "tree_ch_vector") # Save layer info if asset exists if is_gee_asset_exists(asset_id): diff --git a/computing/tree_health/ccd.py b/computing/tree_health/gee/ccd.py similarity index 88% rename from computing/tree_health/ccd.py rename to computing/tree_health/gee/ccd.py index 40c958c1..f3155e0f 100644 --- a/computing/tree_health/ccd.py +++ b/computing/tree_health/gee/ccd.py @@ -14,6 +14,7 @@ get_gee_dir_path, ) from computing.utils import save_layer_info_to_db, update_layer_sync_status +from computing.STAC_specs import generate_STAC_layerwise # Celery task to generate CCD raster @@ -128,11 +129,22 @@ def tree_health_ccd_raster( # Sync raster from GCS to GeoServer res = sync_raster_gcs_to_geoserver( - "ccd", description, description, "ccd_style" + "tree_ccd_raster", description, description, "tree_ccd_style" ) if res and layer_id: layer_at_geoserver = True + + # layer_STAC_generated = False + # layer_STAC_generated = generate_STAC_layerwise.generate_raster_stac( + # state=state, + # district=district, + # block=block, + # layer_name="ccd_raster", + # start_year=year, + # ) + + # Update sync status in DB update_layer_sync_status( layer_id=layer_id, sync_to_geoserver=layer_at_geoserver, diff --git a/computing/tree_health/ccd_vector.py b/computing/tree_health/gee/ccd_vector.py similarity index 99% rename from computing/tree_health/ccd_vector.py rename to computing/tree_health/gee/ccd_vector.py index 9d150d00..183b2845 100644 --- a/computing/tree_health/ccd_vector.py +++ b/computing/tree_health/gee/ccd_vector.py @@ -123,7 +123,7 @@ def tree_health_ccd_vector( merged_fc = ee.FeatureCollection(asset_id) # Sync to GeoServer - sync_res = sync_fc_to_geoserver(merged_fc, state, description, "ccd") + sync_res = sync_fc_to_geoserver(merged_fc, state, description, "tree_ccd_vector") # Update DB sync status if sync_res["status_code"] == 201 and layer_id: diff --git a/computing/tree_health/overall_change.py b/computing/tree_health/gee/overall_change.py similarity index 92% rename from computing/tree_health/overall_change.py rename to computing/tree_health/gee/overall_change.py index 2603b3c1..a3c3c21a 100644 --- a/computing/tree_health/overall_change.py +++ b/computing/tree_health/gee/overall_change.py @@ -14,6 +14,7 @@ get_gee_dir_path, ) from computing.utils import save_layer_info_to_db, update_layer_sync_status +from computing.STAC_specs import generate_STAC_layerwise @app.task(bind=True) @@ -104,12 +105,19 @@ def tree_health_overall_change_raster( print("task_id_list sync to GCS", task_id_list) res = sync_raster_gcs_to_geoserver( - "tree_overall_ch", description, description, "tree_overall_ch_style" + "tree_overall_raster", description, description, "tree_overall_style" ) layer_at_geoserver = True if res and layer_id: layer_at_geoserver = True + # layer_STAC_generated = False + # layer_STAC_generated = generate_STAC_layerwise.generate_raster_stac( + # state=state, + # district=district, + # block=block, + # layer_name="tree_cover_change_raster", + # ) update_layer_sync_status( layer_id=layer_id, sync_to_geoserver=layer_at_geoserver, diff --git a/computing/tree_health/overall_change_vector.py b/computing/tree_health/gee/overall_change_vector.py similarity index 98% rename from computing/tree_health/overall_change_vector.py rename to computing/tree_health/gee/overall_change_vector.py index be9fa335..cc78dee8 100644 --- a/computing/tree_health/overall_change_vector.py +++ b/computing/tree_health/gee/overall_change_vector.py @@ -89,7 +89,7 @@ def tree_health_overall_change_vector( # Sync to GeoServer sync_res = sync_fc_to_geoserver( - merged_fc, state, description, "tree_overall_ch" + merged_fc, state, description, "tree_overall_vector" ) # Update sync status diff --git a/computing/tree_health/local/canopy_height_local.py b/computing/tree_health/local/canopy_height_local.py new file mode 100644 index 00000000..5032701a --- /dev/null +++ b/computing/tree_health/local/canopy_height_local.py @@ -0,0 +1,278 @@ +from pathlib import Path + +import numpy as np +import rasterio +from rasterio.mask import mask +from rasterio.warp import Resampling, reproject +from shapely.geometry import mapping + +from computing.config_loader import LULC_BASE_DIR, PROJECT_ROOT +from computing.local_compute_helper import ( + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + build_output_raster_path, + get_union_geometry, + load_precomputed_roi, + push_local_raster_to_geoserver, + read_validated_vector_file, + resolve_lulc_raster_paths, + validate_geometry, +) +from computing.utils import save_layer_info_to_db, update_layer_sync_status +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + + +LOCAL_CH_BASE_DIR = PROJECT_ROOT / "data/base_layers/tree_health/ch" +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/tree_health" +GEOSERVER_WORKSPACE = "tree_ch_raster" +GEOSERVER_STYLE = "tree_ch_style" + +# LULC class 6 is tree cover. CH values are retained only on tree pixels. +TREE_LULC_CLASS = 6 + + +def _slug(value, fallback): + if value is None: + return fallback + return valid_gee_text(str(value).strip().lower()) or fallback + + +def _resolve_ch_raster(year, ch_dir=LOCAL_CH_BASE_DIR): + # Local canopy height files are expected to be pre-downloaded. + ch_dir = Path(ch_dir) + possible_names = [ + f"CH_raster_{year}.tif", + f"ch_raster_{year}.tif", + f"canopy_height_{year}.tif", + f"{year}.tif", + ] + + for name in possible_names: + path = ch_dir / name + if path.exists(): + return str(path) + + matches = sorted(ch_dir.glob(f"*{year}*.tif")) + if matches: + return str(matches[0]) + + raise FileNotFoundError( + f"Local canopy height raster for {year} not found in {ch_dir}." + ) + + +def _pick_output_nodata(dtype, source_nodata): + # CH class values are 0, 1, 2 and 3, so avoid using those as nodata. + if source_nodata is not None: + source_nodata = float(source_nodata) + if not np.isnan(source_nodata) and source_nodata not in (0.0, 1.0, 2.0, 3.0): + return source_nodata + + dtype = np.dtype(dtype) + if np.issubdtype(dtype, np.floating): + return -9999.0 + + info = np.iinfo(dtype) + if np.issubdtype(dtype, np.signedinteger): + return info.min + return info.max + + +def _clip_and_mask_ch(ch_path, lulc_path, roi_gdf, output_path): + with rasterio.open(ch_path) as ch_src: + # Align ROI with the canopy height raster before clipping. + roi_gdf = validate_geometry(roi_gdf) + if roi_gdf.empty: + raise ValueError("No valid ROI geometry available for local CH clipping.") + if roi_gdf.crs is None: + raise ValueError("ROI CRS is missing; cannot align canopy height raster.") + if ch_src.crs and roi_gdf.crs != ch_src.crs: + roi_gdf = roi_gdf.to_crs(ch_src.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 CH clipping.") + + # Prefer the class band if the source raster has band descriptions. + band_index = 1 + for index, description in enumerate(ch_src.descriptions, start=1): + if description and description.strip().lower() in ("ch_class", "ch"): + band_index = index + break + + nodata = _pick_output_nodata( + dtype=ch_src.dtypes[band_index - 1], + source_nodata=ch_src.nodata, + ) + ch_array, ch_transform = mask( + ch_src, + shapes=[mapping(roi_union)], + crop=True, + filled=True, + nodata=nodata, + indexes=band_index, + ) + if ch_array.ndim == 3: + ch_array = ch_array[0] + + output_meta = ch_src.meta.copy() + output_meta.update( + { + "driver": "GTiff", + "height": ch_array.shape[0], + "width": ch_array.shape[1], + "transform": ch_transform, + "count": 1, + "dtype": ch_array.dtype, + "nodata": nodata, + "compress": "lzw", + } + ) + + # Reproject LULC to the clipped CH grid and use it as the tree mask. + lulc_array = np.zeros((output_meta["height"], output_meta["width"]), dtype=np.uint8) + with rasterio.open(lulc_path) as lulc_src: + reproject( + source=rasterio.band(lulc_src, 1), + destination=lulc_array, + src_transform=lulc_src.transform, + src_crs=lulc_src.crs, + src_nodata=lulc_src.nodata, + dst_transform=output_meta["transform"], + dst_crs=output_meta["crs"], + dst_nodata=0, + resampling=Resampling.mode, + ) + + tree_mask = lulc_array == TREE_LULC_CLASS + valid_ch = ch_array != nodata + output_array = np.where(tree_mask & valid_ch, ch_array, nodata).astype( + ch_array.dtype, + copy=False, + ) + + with rasterio.open(output_path, "w", **output_meta) as dst: + dst.write(output_array, 1) + dst.set_band_description(1, "ch_class") + + return str(output_path) + + +@app.task(bind=True) +def tree_health_ch_raster_local( + self, + state=None, + district=None, + block=None, + roi=None, + asset_suffix=None, + start_year=None, + end_year=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + ch_dir=LOCAL_CH_BASE_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") + + # Admin runs use the precomputed watershed boundary. Custom runs use the ROI path. + if state and district and block: + asset_suffix = ( + f"{_slug(district, 'unknown_district')}_" + f"{_slug(block, 'unknown_block')}" + ) + 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." + ) + asset_suffix = _slug(asset_suffix, "custom") + roi_gdf = read_validated_vector_file( + roi, + f"ROI file has no valid geometries: {roi}", + ) + + lulc_paths = resolve_lulc_raster_paths( + start_year=start_year, + end_year=end_year, + lulc_dir=lulc_dir, + ) + + layer_at_geoserver = True + + for year, lulc_path in zip(range(start_year, end_year + 1), lulc_paths): + layer_name = f"ch_raster_{asset_suffix}_{year}" + ch_path = _resolve_ch_raster(year=year, ch_dir=ch_dir) + output_path = build_output_raster_path( + layer_name=layer_name, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + state=state, + district=district, + block=block, + custom_subdir=asset_suffix, + ) + + # Local replacement for: + # ImageCollection(CH).mean().clip(roi).updateMask(lulc.eq(6)) + raster_path = _clip_and_mask_ch( + ch_path=ch_path, + lulc_path=lulc_path, + roi_gdf=roi_gdf, + output_path=output_path, + ) + print(f"Saved local canopy height raster: {raster_path}") + + layer_id = None + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=raster_path, + dataset_name="Canopy Height Raster", + misc={ + "start_year": start_year, + "end_year": end_year, + "is_generated_locally": True, + }, + algorithm="local_ch_clip_tree_mask", + algorithm_version="local-1.0", + ) + + if not push_to_geoserver: + continue + + try: + upload_res, style_res = push_local_raster_to_geoserver( + file_path=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 CH raster {layer_name}: {error}") + layer_at_geoserver = False + continue + + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + + return layer_at_geoserver if push_to_geoserver else True \ No newline at end of file diff --git a/computing/tree_health/local/canopy_height_vector_local.py b/computing/tree_health/local/canopy_height_vector_local.py new file mode 100644 index 00000000..f14a6ec8 --- /dev/null +++ b/computing/tree_health/local/canopy_height_vector_local.py @@ -0,0 +1,174 @@ +from pathlib import Path + +from computing.config_loader import PROJECT_ROOT +from computing.local_compute_helper import ( + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + build_output_vector_path, + compute_categorical_raster_areas_for_watersheds, + load_precomputed_watersheds, + push_local_vector_to_geoserver, + read_validated_vector_file, + write_vector_output, +) +from computing.utils import save_layer_info_to_db, update_layer_sync_status +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + +from computing.tree_health.canopy_height_local import ( + LOCAL_OUTPUT_BASE_DIR as CH_RASTER_DIR, +) + + +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/tree_health" +GEOSERVER_WORKSPACE = "canopy_height_vector" + +CH_CLASSES = [ + {"value": 0, "label_prefix": "Short_Trees_"}, + {"value": 1, "label_prefix": "Medium_Height_Trees_"}, + {"value": 2, "label_prefix": "Tall_Trees_"}, + {"value": 3, "label_prefix": "Missing_Data_"}, +] + + +def _slug(value, fallback): + if value is None: + return fallback + return valid_gee_text(str(value).strip().lower()) or fallback + + +def _resolve_ch_output_raster(asset_suffix, year, state=None, district=None, block=None): + raster_name = f"ch_raster_{asset_suffix}_{year}.tif" + if state and district and block: + path = ( + Path(CH_RASTER_DIR) + / _slug(state, "unknown_state") + / _slug(district, "unknown_district") + / _slug(block, "unknown_block") + / raster_name + ) + else: + path = Path(CH_RASTER_DIR) / asset_suffix / raster_name + + if path.exists(): + return str(path) + + raise FileNotFoundError( + f"Local canopy height raster not found for vectorisation: {path}. " + "Run canopy_height_local.py first." + ) + +@app.task(bind=True) +def tree_health_ch_vector_local( + self, + state=None, + district=None, + block=None, + roi=None, + asset_suffix=None, + start_year=None, + end_year=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_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") + + # Vector outputs are generated over watershed polygons, same as reduceRegions in GEE. + if state and district and block: + asset_suffix = ( + f"{_slug(district, 'unknown_district')}_" + f"{_slug(block, 'unknown_block')}" + ) + result_gdf, _ = load_precomputed_watersheds( + 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." + ) + asset_suffix = _slug(asset_suffix, "custom") + result_gdf = read_validated_vector_file( + roi, + f"ROI file has no valid geometries: {roi}", + ) + + for year in range(start_year, end_year + 1): + raster_path = _resolve_ch_output_raster( + asset_suffix=asset_suffix, + year=year, + state=state, + district=district, + block=block, + ) + class_definitions = [ + {"value": item["value"], "label": f"{item['label_prefix']}{year}"} + for item in CH_CLASSES + ] + print(f"Computing local CH vector columns for {year}: {raster_path}") + year_gdf = compute_categorical_raster_areas_for_watersheds( + watersheds_gdf=result_gdf, + raster_path=raster_path, + class_definitions=class_definitions, + ) + for definition in class_definitions: + result_gdf[definition["label"]] = year_gdf[definition["label"]] + + layer_name = f"ch_vector_{asset_suffix}_{start_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, + custom_subdir=asset_suffix, + ) + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local canopy height vector: {asset_id}") + + if push_to_geoserver: + res = push_local_vector_to_geoserver( + path=asset_id, + layer_name=layer_name, + workspace=GEOSERVER_WORKSPACE, + file_type="gpkg", + ) + print(f"GeoServer response for {layer_name}: {res}") + if not isinstance(res, dict) or res.get("status_code") not in (200, 201): + return False + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Canopy Height Vector", + misc={ + "start_year": start_year, + "end_year": end_year, + "is_generated_locally": True, + }, + algorithm="local_ch_vector", + algorithm_version="local-1.0", + ) + if layer_id and push_to_geoserver: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + + return True diff --git a/computing/tree_health/local/ccd_local.py b/computing/tree_health/local/ccd_local.py new file mode 100644 index 00000000..53381d3b --- /dev/null +++ b/computing/tree_health/local/ccd_local.py @@ -0,0 +1,275 @@ +from pathlib import Path + +import numpy as np +import rasterio +from rasterio.mask import mask +from rasterio.warp import Resampling, reproject +from shapely.geometry import mapping + +from computing.config_loader import LULC_BASE_DIR, PROJECT_ROOT +from computing.local_compute_helper import ( + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + build_output_raster_path, + get_union_geometry, + load_precomputed_roi, + push_local_raster_to_geoserver, + read_validated_vector_file, + resolve_lulc_raster_paths, + validate_geometry, +) +from computing.utils import save_layer_info_to_db, update_layer_sync_status +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + + +LOCAL_CCD_BASE_DIR = PROJECT_ROOT / "data/base_layers/tree_health/ccd" +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/tree_health" +GEOSERVER_WORKSPACE = "tree_ccd_raster" +GEOSERVER_STYLE = "tree_ccd_style" + +# LULC class 6 is tree cover. CCD values are retained only where this mask is true. +TREE_LULC_CLASS = 6 + + +def _slug(value, fallback): + # Keep layer/file names compatible with the naming style used by GEE tasks. + if value is None: + return fallback + return valid_gee_text(str(value).strip().lower()) or fallback + + +def _resolve_ccd_raster(year, ccd_dir=LOCAL_CCD_BASE_DIR): + # Local CCD files are expected to be pre-downloaded in the base layer folder. + print(ccd_dir) + ccd_dir = Path(ccd_dir) + path = ccd_dir / f"CCD_raster_{year}.tif" + print(path) + if path.exists(): + return str(path) + + raise FileNotFoundError( + f"Local CCD raster for {year} not found in {ccd_dir}. " + ) + + +def _pick_output_nodata(dtype, source_nodata): + # CCD has valid class values 0, 1 and 2, so avoid using those as nodata. + if source_nodata is not None: + source_nodata = float(source_nodata) + if not np.isnan(source_nodata) and source_nodata not in (0.0, 1.0, 2.0): + return source_nodata + + dtype = np.dtype(dtype) + if np.issubdtype(dtype, np.floating): + return -9999.0 + + info = np.iinfo(dtype) + if np.issubdtype(dtype, np.signedinteger): + return info.min + return info.max + + +def _clip_and_mask_ccd(ccd_path, lulc_path, roi_gdf, output_path): + with rasterio.open(ccd_path) as ccd_src: + # Match the ROI CRS to the CCD raster before clipping. + roi_gdf = validate_geometry(roi_gdf) + if roi_gdf.empty: + raise ValueError("No valid ROI geometry available for local CCD clipping.") + if roi_gdf.crs is None: + raise ValueError("ROI CRS is missing; cannot align CCD raster.") + if ccd_src.crs and roi_gdf.crs != ccd_src.crs: + roi_gdf = roi_gdf.to_crs(ccd_src.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 CCD clipping.") + + # Prefer the band named "cc" if present; otherwise use the first band. + band_index = 1 + for index, description in enumerate(ccd_src.descriptions, start=1): + if description and description.strip().lower() == "cc": + band_index = index + break + + nodata = _pick_output_nodata( + dtype=ccd_src.dtypes[band_index - 1], + source_nodata=ccd_src.nodata, + ) + + # Clip CCD to the block/ROI boundary and keep the clipped metadata for output. + ccd_array, ccd_transform = mask( + ccd_src, + shapes=[mapping(roi_union)], + crop=True, + filled=True, + nodata=nodata, + indexes=band_index, + ) + if ccd_array.ndim == 3: + ccd_array = ccd_array[0] + + output_meta = ccd_src.meta.copy() + output_meta.update( + { + "driver": "GTiff", + "height": ccd_array.shape[0], + "width": ccd_array.shape[1], + "transform": ccd_transform, + "count": 1, + "dtype": ccd_array.dtype, + "nodata": nodata, + "compress": "lzw", + } + ) + + # Reproject local LULC to the clipped CCD grid so both arrays line up pixel-to-pixel. + lulc_array = np.zeros((output_meta["height"], output_meta["width"]), dtype=np.uint8) + with rasterio.open(lulc_path) as lulc_src: + reproject( + source=rasterio.band(lulc_src, 1), + destination=lulc_array, + src_transform=lulc_src.transform, + src_crs=lulc_src.crs, + src_nodata=lulc_src.nodata, + dst_transform=output_meta["transform"], + dst_crs=output_meta["crs"], + dst_nodata=0, + resampling=Resampling.mode, + ) + + # Keep CCD values only for tree pixels. Everything else becomes nodata. + tree_mask = lulc_array == TREE_LULC_CLASS + + valid_ccd = ccd_array != nodata + output_array = np.where(tree_mask & valid_ccd, ccd_array, nodata).astype( + ccd_array.dtype, + copy=False, + ) + + # Final output is a single-band GeoTIFF, same as the GEE-created CCD raster. + with rasterio.open(output_path, "w", **output_meta) as dst: + dst.write(output_array, 1) + dst.set_band_description(1, "cc") + + return str(output_path) + +@app.task(bind=True) +def tree_health_ccd_raster_local( + self, + state=None, + district=None, + block=None, + roi=None, + asset_suffix=None, + start_year=None, + end_year=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + ccd_dir=LOCAL_CCD_BASE_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") + + # Admin runs use the precomputed watershed boundary. Custom runs use the ROI path. + if state and district and block: + asset_suffix = ( + f"{_slug(district, 'unknown_district')}_" + f"{_slug(block, 'unknown_block')}" + ) + 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." + ) + asset_suffix = _slug(asset_suffix, "custom") + roi_gdf = read_validated_vector_file( + roi, + f"ROI file has no valid geometries: {roi}", + ) + + # LULC rasters provide the yearly tree mask for the same hydro-year range. + lulc_paths = resolve_lulc_raster_paths( + start_year=start_year, + end_year=end_year, + lulc_dir=lulc_dir, + ) + + layer_at_geoserver = False + + for year, lulc_path in zip(range(start_year, end_year + 1), lulc_paths): + # Build one CCD output per year, matching the original GEE layer naming. + layer_name = f"ccd_raster_{asset_suffix}_{year}" + ccd_path = _resolve_ccd_raster(year=year, ccd_dir=ccd_dir) + output_path = build_output_raster_path( + layer_name=layer_name, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + state=state, + district=district, + block=block, + custom_subdir=asset_suffix, + ) + + # This is the local replacement for: + # ImageCollection(CCD).mean().clip(roi).updateMask(lulc.eq(6)) + raster_path = _clip_and_mask_ccd( + ccd_path=ccd_path, + lulc_path=lulc_path, + roi_gdf=roi_gdf, + output_path=output_path, + ) + print(f"Saved local CCD raster: {raster_path}") + + layer_id = None + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=raster_path, + dataset_name="Ccd Raster", + misc={ + "start_year": start_year, + "end_year": end_year, + "is_generated_locally": True, + }, + algorithm="local_ccd_clip_tree_mask", + algorithm_version="local-1.0", + ) + + if not push_to_geoserver: + continue + + try: + upload_res, style_res = push_local_raster_to_geoserver( + file_path=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 CCD raster {layer_name}: {error}") + layer_at_geoserver = False + continue + + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + + return layer_at_geoserver if push_to_geoserver else True diff --git a/computing/tree_health/local/ccd_vector_local.py b/computing/tree_health/local/ccd_vector_local.py new file mode 100644 index 00000000..433d2c10 --- /dev/null +++ b/computing/tree_health/local/ccd_vector_local.py @@ -0,0 +1,171 @@ +from pathlib import Path + +from computing.config_loader import PROJECT_ROOT +from computing.local_compute_helper import ( + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + build_output_vector_path, + compute_categorical_raster_areas_for_watersheds, + load_precomputed_watersheds, + push_local_vector_to_geoserver, + read_validated_vector_file, + write_vector_output, +) +from computing.utils import save_layer_info_to_db, update_layer_sync_status +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + +from computing.tree_health.ccd_local import LOCAL_OUTPUT_BASE_DIR as CCD_RASTER_DIR + + +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/tree_health" +GEOSERVER_WORKSPACE = "tree_ccd_vector" + +CCD_CLASSES = [ + {"value": 0, "label_prefix": "Low_Density_"}, + {"value": 1, "label_prefix": "High_Density_"}, + {"value": 2, "label_prefix": "Missing_Data_"}, +] + + +def _slug(value, fallback): + if value is None: + return fallback + return valid_gee_text(str(value).strip().lower()) or fallback + + +def _resolve_ccd_output_raster(asset_suffix, year, state=None, district=None, block=None): + raster_name = f"ccd_raster_{asset_suffix}_{year}.tif" + if state and district and block: + path = ( + Path(CCD_RASTER_DIR) + / _slug(state, "unknown_state") + / _slug(district, "unknown_district") + / _slug(block, "unknown_block") + / raster_name + ) + else: + path = Path(CCD_RASTER_DIR) / asset_suffix / raster_name + + if path.exists(): + return str(path) + + raise FileNotFoundError( + f"Local CCD raster not found for vectorisation: {path}. " + "Run ccd_local.py first." + ) + +@app.task(bind=True) +def tree_health_ccd_vector_local( + self, + state=None, + district=None, + block=None, + roi=None, + asset_suffix=None, + start_year=None, + end_year=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_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") + + # Vector outputs are generated over watershed polygons, same as reduceRegions in GEE. + if state and district and block: + asset_suffix = ( + f"{_slug(district, 'unknown_district')}_" + f"{_slug(block, 'unknown_block')}" + ) + result_gdf, _ = load_precomputed_watersheds( + 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." + ) + asset_suffix = _slug(asset_suffix, "custom") + result_gdf = read_validated_vector_file( + roi, + f"ROI file has no valid geometries: {roi}", + ) + + for year in range(start_year, end_year + 1): + raster_path = _resolve_ccd_output_raster( + asset_suffix=asset_suffix, + year=year, + state=state, + district=district, + block=block, + ) + class_definitions = [ + {"value": item["value"], "label": f"{item['label_prefix']}{year}"} + for item in CCD_CLASSES + ] + print(f"Computing local CCD vector columns for {year}: {raster_path}") + year_gdf = compute_categorical_raster_areas_for_watersheds( + watersheds_gdf=result_gdf, + raster_path=raster_path, + class_definitions=class_definitions, + ) + for definition in class_definitions: + result_gdf[definition["label"]] = year_gdf[definition["label"]] + + layer_name = f"ccd_vector_{asset_suffix}_{start_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, + custom_subdir=asset_suffix, + ) + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local CCD vector: {asset_id}") + + if push_to_geoserver: + res = push_local_vector_to_geoserver( + path=asset_id, + layer_name=layer_name, + workspace=GEOSERVER_WORKSPACE, + file_type="gpkg", + ) + print(f"GeoServer response for {layer_name}: {res}") + if not isinstance(res, dict) or res.get("status_code") not in (200, 201): + return False + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Ccd Vector", + misc={ + "start_year": start_year, + "end_year": end_year, + "is_generated_locally": True, + }, + algorithm="local_ccd_vector", + algorithm_version="local-1.0", + ) + if layer_id and push_to_geoserver: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + + return True diff --git a/computing/tree_health/local/overall_change_local.py b/computing/tree_health/local/overall_change_local.py new file mode 100644 index 00000000..7924d466 --- /dev/null +++ b/computing/tree_health/local/overall_change_local.py @@ -0,0 +1,315 @@ +from pathlib import Path + +import numpy as np +import rasterio +from rasterio.mask import mask +from rasterio.warp import Resampling, reproject +from shapely.geometry import mapping + +from computing.config_loader import CHANGE_DETECTION_RASTER_OUTPUT_DIR, PROJECT_ROOT +from computing.local_compute_helper import ( + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + build_output_raster_path, + get_union_geometry, + load_precomputed_roi, + push_local_raster_to_geoserver, + read_validated_vector_file, + validate_geometry, +) +from computing.utils import save_layer_info_to_db, update_layer_sync_status +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + + +LOCAL_TREE_CHANGE_BASE_DIR = PROJECT_ROOT / "data/base_layers/tree_health" +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/tree_health" +GEOSERVER_WORKSPACE = "tree_overall_raster" +GEOSERVER_STYLE = "tree_overall_style" +BACKGROUND = -9999 + + +def _slug(value, fallback): + if value is None: + return fallback + return valid_gee_text(str(value).strip().lower()) or fallback + + +def _resolve_tree_change_raster( + start_year, + end_year, + tree_change_dir=LOCAL_TREE_CHANGE_BASE_DIR, +): + # Local equivalent of TREE_OVERALL_CHANGE image collection. + tree_change_dir = Path(tree_change_dir) + possible_names = [ + f"overall_change_{start_year}_{end_year}.tif", + f"overall_change_{start_year}_{int(end_year) + 1}.tif", + "overall_change.tif", + "TREE_OVERALL_CHANGE.tif", + ] + + for name in possible_names: + path = tree_change_dir / name + if path.exists(): + return str(path) + + matches = sorted(tree_change_dir.glob("*.tif")) + if matches: + return str(matches[0]) + + raise FileNotFoundError(f"Local overall change raster not found in {tree_change_dir}.") + + +def _resolve_change_detection_raster( + state, + district, + block, + asset_suffix, + param_name, + start_year, + end_year, + change_dir=CHANGE_DETECTION_RASTER_OUTPUT_DIR, +): + # Original GEE code reads change outputs ending at end_year + 1. + candidate_end_years = [int(end_year) + 1, int(end_year)] + + for candidate_end_year in candidate_end_years: + raster_name = f"change_{asset_suffix}_{param_name}_{start_year}_{candidate_end_year}.tif" + path = ( + Path(change_dir) + / _slug(state, "unknown_state") + / _slug(district, "unknown_district") + / _slug(block, "unknown_block") + / raster_name + ) + if path.exists(): + return str(path) + + raise FileNotFoundError( + f"Local {param_name} change raster not found for " + f"{asset_suffix}, start_year={start_year}, end_year={end_year}." + ) + + +def _clip_tree_change(tree_change_path, roi_gdf): + with rasterio.open(tree_change_path) as src: + roi_gdf = validate_geometry(roi_gdf) + if roi_gdf.empty: + raise ValueError("No valid ROI geometry available for overall change clipping.") + if roi_gdf.crs is None: + raise ValueError("ROI CRS is missing; cannot align overall change raster.") + if src.crs and roi_gdf.crs != src.crs: + roi_gdf = roi_gdf.to_crs(src.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 overall change clipping.") + + tree_change, transform = mask( + src, + shapes=[mapping(roi_union)], + crop=True, + filled=True, + nodata=BACKGROUND, + indexes=1, + ) + if tree_change.ndim == 3: + tree_change = tree_change[0] + + meta = src.meta.copy() + meta.update( + { + "driver": "GTiff", + "height": tree_change.shape[0], + "width": tree_change.shape[1], + "transform": transform, + "count": 1, + "dtype": "int16", + "nodata": BACKGROUND, + "compress": "lzw", + } + ) + + tree_change = np.where(np.isfinite(tree_change), tree_change, BACKGROUND) + return np.rint(tree_change).astype(np.int16), meta + + +def _reproject_to_match(raster_path, meta): + output = np.full((meta["height"], meta["width"]), BACKGROUND, dtype=np.int16) + with rasterio.open(raster_path) as src: + reproject( + source=rasterio.band(src, 1), + destination=output, + src_transform=src.transform, + src_crs=src.crs, + src_nodata=src.nodata, + dst_transform=meta["transform"], + dst_crs=meta["crs"], + dst_nodata=BACKGROUND, + resampling=Resampling.mode, + ) + return output + + +def _build_overall_change_raster( + tree_change_path, + deforestation_path, + afforestation_path, + roi_gdf, + output_path, +): + tree_change, output_meta = _clip_tree_change( + tree_change_path=tree_change_path, + roi_gdf=roi_gdf, + ) + deforestation = _reproject_to_match(deforestation_path, output_meta) + afforestation = _reproject_to_match(afforestation_path, output_meta) + + # Same class priority as mask_raster() in the GEE implementation. + output = np.full(tree_change.shape, BACKGROUND, dtype=np.int16) + + no_change_mask = afforestation == 1 + output[no_change_mask] = 0 + + deforestation_mask = (deforestation >= 2) & (deforestation <= 5) + output[deforestation_mask] = -2 + + afforestation_mask = (afforestation >= 2) & (afforestation <= 5) + output[afforestation_mask] = 2 + + allowed_inside_no_change = np.isin(tree_change, [-1, 1, 3, 4, 5]) + output[no_change_mask & allowed_inside_no_change] = tree_change[ + no_change_mask & allowed_inside_no_change + ] + + with rasterio.open(output_path, "w", **output_meta) as dst: + dst.write(output, 1) + dst.set_band_description(1, "constant") + + return str(output_path) + +@app.task(bind=True) +def tree_health_overall_change_raster_local( + self, + state=None, + district=None, + block=None, + start_year=None, + end_year=None, + roi=None, + asset_suffix=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + tree_change_dir=LOCAL_TREE_CHANGE_BASE_DIR, + change_dir=CHANGE_DETECTION_RASTER_OUTPUT_DIR, + deforestation_path=None, + afforestation_path=None, + 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 state and district and block: + asset_suffix = ( + f"{_slug(district, 'unknown_district')}_" + f"{_slug(block, 'unknown_block')}" + ) + 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." + ) + asset_suffix = _slug(asset_suffix, "custom") + roi_gdf = read_validated_vector_file( + roi, + f"ROI file has no valid geometries: {roi}", + ) + + layer_name = f"overall_change_raster_{asset_suffix}" + tree_change_path = _resolve_tree_change_raster( + start_year=start_year, + end_year=end_year, + tree_change_dir=tree_change_dir, + ) + if state and district and block: + deforestation_path = deforestation_path or _resolve_change_detection_raster( + state=state, + district=district, + block=block, + asset_suffix=asset_suffix, + param_name="Deforestation", + start_year=start_year, + end_year=end_year, + change_dir=change_dir, + ) + afforestation_path = afforestation_path or _resolve_change_detection_raster( + state=state, + district=district, + block=block, + asset_suffix=asset_suffix, + param_name="Afforestation", + start_year=start_year, + end_year=end_year, + change_dir=change_dir, + ) + elif not deforestation_path or not afforestation_path: + raise ValueError( + "For custom overall change runs, `deforestation_path` and " + "`afforestation_path` are required." + ) + output_path = build_output_raster_path( + layer_name=layer_name, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + state=state, + district=district, + block=block, + custom_subdir=asset_suffix, + ) + + raster_path = _build_overall_change_raster( + tree_change_path=tree_change_path, + deforestation_path=deforestation_path, + afforestation_path=afforestation_path, + roi_gdf=roi_gdf, + output_path=output_path, + ) + print(f"Saved local overall tree change raster: {raster_path}") + + layer_id = None + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=raster_path, + dataset_name="Tree Overall Change Raster", + misc={"is_generated_locally": True}, + algorithm="local_tree_overall_change", + algorithm_version="local-1.0", + ) + + if push_to_geoserver: + upload_res, style_res = push_local_raster_to_geoserver( + file_path=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}") + + if layer_id and push_to_geoserver: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + + return True diff --git a/computing/tree_health/local/overall_change_vector_local.py b/computing/tree_health/local/overall_change_vector_local.py new file mode 100644 index 00000000..ca6ac7a7 --- /dev/null +++ b/computing/tree_health/local/overall_change_vector_local.py @@ -0,0 +1,158 @@ +from pathlib import Path + +from computing.config_loader import PROJECT_ROOT +from computing.local_compute_helper import ( + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + build_output_vector_path, + compute_categorical_raster_areas_for_watersheds, + load_precomputed_watersheds, + push_local_vector_to_geoserver, + read_validated_vector_file, + write_vector_output, +) +from computing.utils import save_layer_info_to_db, update_layer_sync_status +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + +from computing.tree_health.overall_change_local import ( + LOCAL_OUTPUT_BASE_DIR as OVERALL_CHANGE_RASTER_DIR, +) + + +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/tree_health" +GEOSERVER_WORKSPACE = "tree_overall_vector" + +OVERALL_CHANGE_CLASSES = [ + {"value": -2, "label": "Deforestation"}, + {"value": -1, "label": "Degradation"}, + {"value": 0, "label": "No_Change"}, + {"value": 1, "label": "Improvement"}, + {"value": 2, "label": "Afforestation"}, + {"values": [3, 4], "label": "Partially_Degraded"}, + {"value": 5, "label": "Missing Data"}, +] + + +def _slug(value, fallback): + if value is None: + return fallback + return valid_gee_text(str(value).strip().lower()) or fallback + + +def _resolve_overall_change_raster(asset_suffix, state=None, district=None, block=None): + raster_name = f"overall_change_raster_{asset_suffix}.tif" + if state and district and block: + path = ( + Path(OVERALL_CHANGE_RASTER_DIR) + / _slug(state, "unknown_state") + / _slug(district, "unknown_district") + / _slug(block, "unknown_block") + / raster_name + ) + else: + path = Path(OVERALL_CHANGE_RASTER_DIR) / asset_suffix / raster_name + + if path.exists(): + return str(path) + + raise FileNotFoundError( + f"Local overall change raster not found for vectorisation: {path}. " + "Run overall_change_local.py first." + ) + +@app.task(bind=True) +def tree_health_overall_change_vector_local( + self, + state=None, + district=None, + block=None, + roi=None, + asset_suffix=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_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 + + # Vector outputs are generated over watershed polygons, same as reduceRegions in GEE. + if state and district and block: + asset_suffix = ( + f"{_slug(district, 'unknown_district')}_" + f"{_slug(block, 'unknown_block')}" + ) + result_gdf, _ = load_precomputed_watersheds( + 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." + ) + asset_suffix = _slug(asset_suffix, "custom") + result_gdf = read_validated_vector_file( + roi, + f"ROI file has no valid geometries: {roi}", + ) + + raster_path = _resolve_overall_change_raster( + asset_suffix=asset_suffix, + state=state, + district=district, + block=block, + ) + print(f"Computing local overall change vector columns: {raster_path}") + result_gdf = compute_categorical_raster_areas_for_watersheds( + watersheds_gdf=result_gdf, + raster_path=raster_path, + class_definitions=OVERALL_CHANGE_CLASSES, + ) + + layer_name = f"overall_change_vector_{asset_suffix}" + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + custom_subdir=asset_suffix, + ) + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=layer_name, + ) + print(f"Saved local overall tree change vector: {asset_id}") + + if push_to_geoserver: + res = push_local_vector_to_geoserver( + path=asset_id, + layer_name=layer_name, + workspace=GEOSERVER_WORKSPACE, + file_type="gpkg", + ) + print(f"GeoServer response for {layer_name}: {res}") + if not isinstance(res, dict) or res.get("status_code") not in (200, 201): + return False + + if sync_layer_metadata and state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Tree Overall Change Vector", + misc={"is_generated_locally": True}, + algorithm="local_tree_overall_change_vector", + algorithm_version="local-1.0", + ) + if layer_id and push_to_geoserver: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + + return True From 7684be234a0208ff3f36d9592279846cf5104c45 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Mon, 6 Jul 2026 15:43:25 +0530 Subject: [PATCH 035/120] queue update --- computing/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/computing/api.py b/computing/api.py index fbcb4717..a83232d7 100644 --- a/computing/api.py +++ b/computing/api.py @@ -2014,7 +2014,7 @@ def generate_facilities_proximity(request): generate_facilities_proximity_task, generate_facilities_proximity_local_task, ) - task.apply_async(args=[state, district, block, gee_account_id], queue="nrm1") + task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) @@ -2475,7 +2475,7 @@ def rainfall_resilience_resistance(request): except Exception as e: print("Exception in rainfall_resilience_resistance api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - + @api_view(["POST"]) @schema(None) @@ -2584,7 +2584,7 @@ def generate_drainage_density_data(request): except Exception as e: print("Exception in river data api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - + @api_view(["POST"]) @schema(None) From e67a47b7a49b57734382cf2cf771f5ad722b1be6 Mon Sep 17 00:00:00 2001 From: Ankit K Date: Mon, 6 Jul 2026 16:48:50 +0530 Subject: [PATCH 036/120] tree health fixes --- computing/api.py | 92 +++++++++---------- .../layer_generation_in_order.py | 12 +-- .../local/canopy_height_vector_local.py | 2 +- .../tree_health/local/ccd_vector_local.py | 2 +- .../local/overall_change_vector_local.py | 2 +- computing/utils.py | 4 +- 6 files changed, 58 insertions(+), 56 deletions(-) diff --git a/computing/api.py b/computing/api.py index a83232d7..6cb20f89 100644 --- a/computing/api.py +++ b/computing/api.py @@ -1092,6 +1092,19 @@ def tree_health_raster(request): gee_account_id = request.data.get("gee_account_id") compute = _get_compute_mode(request) + task_kwargs = { + "state": state, + "district": district, + "block": block, + "start_year": start_year, + "end_year": end_year, + } + if not compute == "local": + task_kwargs.update( + { + "gee_account_id": gee_account_id, + } + ) ccd_task = _select_compute_task( compute, tree_health_ccd_raster, @@ -1100,14 +1113,7 @@ def tree_health_raster(request): print("What is task? ", ccd_task) ccd_task.apply_async( - kwargs={ - "state": state, - "district": district, - "block": block, - "start_year": start_year, - "end_year": end_year, - "gee_account_id": gee_account_id, - }, + kwargs=task_kwargs, queue="nrm", ) @@ -1118,14 +1124,7 @@ def tree_health_raster(request): ) print("What is task? ", ch_task) ch_task.apply_async( - kwargs={ - "state": state, - "district": district, - "block": block, - "start_year": start_year, - "end_year": end_year, - "gee_account_id": gee_account_id, - }, + kwargs=task_kwargs, queue="nrm", ) overall_task = _select_compute_task( @@ -1135,14 +1134,7 @@ def tree_health_raster(request): ) print("What is task? ", overall_task) overall_task.apply_async( - kwargs={ - "state": state, - "district": district, - "block": block, - "start_year": start_year, - "end_year": end_year, - "gee_account_id": gee_account_id, - }, + kwargs=task_kwargs, queue="nrm", ) @@ -1169,6 +1161,21 @@ def tree_health_vector(request): gee_account_id = request.data.get("gee_account_id") compute = _get_compute_mode(request) + + task_kwargs = { + "state": state, + "district": district, + "block": block, + "start_year": start_year, + "end_year": end_year, + } + if not compute == "local": + task_kwargs.update( + { + "gee_account_id": gee_account_id, + } + ) + ccd_task = _select_compute_task( compute, tree_health_ccd_vector, @@ -1177,14 +1184,7 @@ def tree_health_vector(request): print("What is task? ", ccd_task) ccd_task.apply_async( - kwargs={ - "state": state, - "district": district, - "block": block, - "start_year": start_year, - "end_year": end_year, - "gee_account_id": gee_account_id, - }, + kwargs=task_kwargs, queue="nrm", ) @@ -1196,14 +1196,7 @@ def tree_health_vector(request): print("What is task? ", ch_task) ch_task.apply_async( - kwargs={ - "state": state, - "district": district, - "block": block, - "start_year": start_year, - "end_year": end_year, - "gee_account_id": gee_account_id, - }, + kwargs=task_kwargs, queue="nrm", ) @@ -1214,13 +1207,20 @@ def tree_health_vector(request): ) print("What is task? ", overall_task) + task_kwargs = { + "state": state, + "district": district, + "block": block + } + if not compute == "local": + task_kwargs.update( + { + "gee_account_id": gee_account_id, + } + ) + overall_task.apply_async( - kwargs={ - "state": state, - "district": district, - "block": block, - "gee_account_id": gee_account_id, - }, + kwargs=task_kwargs, queue="nrm", ) diff --git a/computing/layer_dependency/layer_generation_in_order.py b/computing/layer_dependency/layer_generation_in_order.py index b5926b89..1f84d8b9 100644 --- a/computing/layer_dependency/layer_generation_in_order.py +++ b/computing/layer_dependency/layer_generation_in_order.py @@ -24,12 +24,12 @@ from computing.misc.stream_order import generate_stream_order from computing.misc.drainage_lines import clip_drainage_lines from computing.clart.clart import generate_clart_layer -from computing.tree_health.canopy_height import tree_health_ch_raster -from computing.tree_health.canopy_height_vector import tree_health_ch_vector -from computing.tree_health.ccd import tree_health_ccd_raster -from computing.tree_health.ccd_vector import tree_health_ccd_vector -from computing.tree_health.overall_change import tree_health_overall_change_raster -from computing.tree_health.overall_change_vector import ( +from computing.tree_health.gee.canopy_height import tree_health_ch_raster +from computing.tree_health.gee.canopy_height_vector import tree_health_ch_vector +from computing.tree_health.gee.ccd import tree_health_ccd_raster +from computing.tree_health.gee.ccd_vector import tree_health_ccd_vector +from computing.tree_health.gee.overall_change import tree_health_overall_change_raster +from computing.tree_health.gee.overall_change_vector import ( tree_health_overall_change_vector, ) from computing.misc.naturaldepression import generate_natural_depression_data diff --git a/computing/tree_health/local/canopy_height_vector_local.py b/computing/tree_health/local/canopy_height_vector_local.py index f14a6ec8..01e4e7b0 100644 --- a/computing/tree_health/local/canopy_height_vector_local.py +++ b/computing/tree_health/local/canopy_height_vector_local.py @@ -14,7 +14,7 @@ from nrm_app.celery import app from utilities.gee_utils import valid_gee_text -from computing.tree_health.canopy_height_local import ( +from computing.tree_health.local.canopy_height_local import ( LOCAL_OUTPUT_BASE_DIR as CH_RASTER_DIR, ) diff --git a/computing/tree_health/local/ccd_vector_local.py b/computing/tree_health/local/ccd_vector_local.py index 433d2c10..a86a7fe8 100644 --- a/computing/tree_health/local/ccd_vector_local.py +++ b/computing/tree_health/local/ccd_vector_local.py @@ -14,7 +14,7 @@ from nrm_app.celery import app from utilities.gee_utils import valid_gee_text -from computing.tree_health.ccd_local import LOCAL_OUTPUT_BASE_DIR as CCD_RASTER_DIR +from computing.tree_health.local.ccd_local import LOCAL_OUTPUT_BASE_DIR as CCD_RASTER_DIR LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/tree_health" diff --git a/computing/tree_health/local/overall_change_vector_local.py b/computing/tree_health/local/overall_change_vector_local.py index ca6ac7a7..37d9faf0 100644 --- a/computing/tree_health/local/overall_change_vector_local.py +++ b/computing/tree_health/local/overall_change_vector_local.py @@ -14,7 +14,7 @@ from nrm_app.celery import app from utilities.gee_utils import valid_gee_text -from computing.tree_health.overall_change_local import ( +from computing.tree_health.local.overall_change_local import ( LOCAL_OUTPUT_BASE_DIR as OVERALL_CHANGE_RASTER_DIR, ) diff --git a/computing/utils.py b/computing/utils.py index 20ad5c57..3c9a29a5 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -67,8 +67,10 @@ def generate_shape_files(path): def convert_to_zip(dir_name, file_type): if file_type == "gpkg": + if dir_name.split(".")[-1] != "gpkg": + dir_name += ".gpkg" with zipfile.ZipFile(dir_name + ".zip", "w", zipfile.ZIP_DEFLATED) as zipf: - zipf.write(dir_name + ".gpkg", arcname=os.path.basename(dir_name + ".gpkg")) + zipf.write(dir_name, arcname=os.path.basename(dir_name)) return dir_name + ".zip" else: return shutil.make_archive(dir_name, "zip", dir_name + "/") From 37e5ab4b097f4796f7feb3cc4fd697f2331575ca Mon Sep 17 00:00:00 2001 From: Ankit K Date: Wed, 8 Jul 2026 14:38:34 +0530 Subject: [PATCH 037/120] fixes to change detection --- .gitignore | 3 +++ computing/change_detection/change_detection_local.py | 12 ++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 65411952..499f13e5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ venv/ .idea/ .DS_Store .cursor/ +.agents/ +.codex/ +.installation_state/ # Django *.log diff --git a/computing/change_detection/change_detection_local.py b/computing/change_detection/change_detection_local.py index 7ad4dbba..c74b046f 100644 --- a/computing/change_detection/change_detection_local.py +++ b/computing/change_detection/change_detection_local.py @@ -1,4 +1,5 @@ import os +import uuid from concurrent.futures import ThreadPoolExecutor import numpy as np @@ -279,6 +280,8 @@ def _load_masked_lulc_arrays(roi_gdf, raster_paths): def _write_change_raster(array, output_path, output_meta): + output_path = os.fspath(output_path) + temp_output_path = f"{output_path}.{os.getpid()}.{uuid.uuid4().hex}.tmp" raster = np.asarray(array, dtype=np.uint8) meta = output_meta.copy() meta.update( @@ -290,8 +293,13 @@ def _write_change_raster(array, output_path, output_meta): "compress": "lzw", } ) - with rasterio.open(output_path, "w", **meta) as dst: - dst.write(raster, 1) + try: + with rasterio.open(temp_output_path, "w", **meta) as dst: + dst.write(raster, 1) + os.replace(temp_output_path, output_path) + finally: + if os.path.exists(temp_output_path): + os.remove(temp_output_path) return str(output_path) From 23bb89dd2545856877a9d66aa688b043f1f3eb93 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 9 Jul 2026 18:03:39 +0530 Subject: [PATCH 038/120] gitignore modification --- .gitignore | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 65411952..5b42d133 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ venv/ *.pyc .idea/ .DS_Store -.cursor/ +.installation_state/ # Django *.log @@ -48,3 +48,10 @@ users/migrations/*.py */migrations/*.py */migrations/__pycache__/ + + +# ai +AGENTS.md +CLAUDE.md +.codex/ +.cursor/ From dbed61b65cc3d138e02775237d949bc7d17c6e46 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 9 Jul 2026 18:07:38 +0530 Subject: [PATCH 039/120] gitignore --- .gitignore | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.gitignore b/.gitignore index 499f13e5..75fb3319 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,12 @@ venv/ *.pyc .idea/ .DS_Store +<<<<<<< HEAD .cursor/ .agents/ .codex/ +======= +>>>>>>> 23bb89dd (gitignore modification) .installation_state/ # Django @@ -51,3 +54,10 @@ users/migrations/*.py */migrations/*.py */migrations/__pycache__/ + + +# ai +AGENTS.md +CLAUDE.md +.codex/ +.cursor/ From bd0d88e133c7f3695a4b11c6b440da72056cfa06 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 9 Jul 2026 18:07:57 +0530 Subject: [PATCH 040/120] gitignore modification --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 75fb3319..ed940891 100644 --- a/.gitignore +++ b/.gitignore @@ -9,12 +9,9 @@ venv/ *.pyc .idea/ .DS_Store -<<<<<<< HEAD .cursor/ .agents/ .codex/ -======= ->>>>>>> 23bb89dd (gitignore modification) .installation_state/ # Django From dfd071b8283479d2a6443d1b924a9b3f04ef3e91 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 9 Jul 2026 18:09:41 +0530 Subject: [PATCH 041/120] Update .gitignore --- .gitignore | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.gitignore b/.gitignore index 12f2d85b..5b42d133 100644 --- a/.gitignore +++ b/.gitignore @@ -9,12 +9,6 @@ venv/ *.pyc .idea/ .DS_Store -<<<<<<< HEAD -.cursor/ -.agents/ -.codex/ -======= ->>>>>>> hotfix/agents .installation_state/ # Django From 65be55e2c4bb2e2bfcfe40dddba2740397547dc0 Mon Sep 17 00:00:00 2001 From: aman verma Date: Mon, 13 Jul 2026 08:48:16 +0000 Subject: [PATCH 042/120] api and stac integration --- computing/api.py | 66 ++++-- computing/soil_health/soil_health.py | 322 ++++++++++++++++++++++++--- computing/urls.py | 5 + 3 files changed, 352 insertions(+), 41 deletions(-) diff --git a/computing/api.py b/computing/api.py index 6cb20f89..97087bff 100644 --- a/computing/api.py +++ b/computing/api.py @@ -52,6 +52,7 @@ from .cropping_intensity.cropping_intesity_local import ( generate_cropping_intensity as generate_cropping_intensity_local_task, ) +from .soil_health.soil_health import soil_health_local from .spei.spei import ( generate_spei_pipeline, run_drought_resistance_resilience, @@ -143,8 +144,12 @@ from .tree_health.local.canopy_height_vector_local import tree_health_ch_vector_local from .tree_health.local.ccd_local import tree_health_ccd_raster_local from .tree_health.local.ccd_vector_local import tree_health_ccd_vector_local -from .tree_health.local.overall_change_local import tree_health_overall_change_raster_local -from .tree_health.local.overall_change_vector_local import tree_health_overall_change_vector_local +from .tree_health.local.overall_change_local import ( + tree_health_overall_change_raster_local, +) +from .tree_health.local.overall_change_vector_local import ( + tree_health_overall_change_vector_local, +) from .utils import ( Geoserver, @@ -245,6 +250,7 @@ generate_livestocks_data_local as generate_livestocks_data_local_task, ) + @api_security_check(allowed_methods="POST") @schema(None) def generate_admin_boundary(request): @@ -1138,7 +1144,6 @@ def tree_health_raster(request): queue="nrm", ) - return Response( {"Success": "tree_health task initiated"}, status=status.HTTP_200_OK, @@ -1207,11 +1212,7 @@ def tree_health_vector(request): ) print("What is task? ", overall_task) - task_kwargs = { - "state": state, - "district": district, - "block": block - } + task_kwargs = {"state": state, "district": district, "block": block} if not compute == "local": task_kwargs.update( { @@ -2493,7 +2494,10 @@ def generate_fabdem_raster_vector(request): generate_febdem_raster_vector_clip_local_task, ) if task is None: - return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"Error": "GEE execution not supported for this module."}, + status=status.HTTP_400_BAD_REQUEST, + ) task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK @@ -2519,7 +2523,10 @@ def generate_canal_vector(request): canal_vector_local_task, ) if task is None: - return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"Error": "GEE execution not supported for this module."}, + status=status.HTTP_400_BAD_REQUEST, + ) task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": f"Successfully initiated {compute} task"}, @@ -2548,7 +2555,10 @@ def generate_river_data(request): river_vector_local_task, ) if task is None: - return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"Error": "GEE execution not supported for this module."}, + status=status.HTTP_400_BAD_REQUEST, + ) task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": f"Successfully initiated {compute} task"}, @@ -2575,7 +2585,10 @@ def generate_drainage_density_data(request): drainage_density_vector_local_task, ) if task is None: - return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"Error": "GEE execution not supported for this module."}, + status=status.HTTP_400_BAD_REQUEST, + ) task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": f"Successfully initiated {compute} task"}, @@ -2602,7 +2615,10 @@ def generate_antyodaya(request): generate_antyodaya_data_local_task, ) if task is None: - return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"Error": "GEE execution not supported for this module."}, + status=status.HTTP_400_BAD_REQUEST, + ) task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": f"Successfully initiated {compute} task"}, @@ -2629,7 +2645,10 @@ def generate_livestocks(request): generate_livestocks_data_local_task, ) if task is None: - return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"Error": "GEE execution not supported for this module."}, + status=status.HTTP_400_BAD_REQUEST, + ) task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": f"Successfully initiated {compute} task"}, @@ -2638,3 +2657,22 @@ def generate_livestocks(request): except Exception as e: print("Exception in generate_livestocks api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@schema(None) +def generate_soil_health(request): + print("Inside generate_soil_health API.") + try: + state = request.data.get("state").lower() + district = request.data.get("district").lower() + block = request.data.get("block").lower() + + soil_health_local.apply_async(args=[state, district, block], queue="nrm") + return Response( + {"Success": f"Successfully initiated generate_soil_health task"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + print("Exception in generate_soil_health api :: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/computing/soil_health/soil_health.py b/computing/soil_health/soil_health.py index c11cb120..bb091478 100644 --- a/computing/soil_health/soil_health.py +++ b/computing/soil_health/soil_health.py @@ -1,26 +1,176 @@ import os -import geopandas as gpd +from pathlib import Path +import logging +import numpy as np +import rasterio +from rasterio.mask import mask +from rasterio.warp import Resampling, reproject +from shapely.geometry import mapping + +from computing.STAC_specs import generate_STAC_layerwise +from computing.config_loader import LULC_BASE_DIR from computing.soil_health.soil_health_helper import nutrient_stats_for_geometries +from computing.utils import save_layer_info_to_db, update_layer_sync_status from utilities.gee_utils import valid_gee_text from computing.local_compute_helper import ( PROJECT_ROOT, + PRECOMPUTED_TEHSIL_WATERSHED_DIR, build_output_vector_path, - clip_raster_with_roi, build_output_raster_path, + compute_mode_lulc_array, read_validated_vector_file, validate_geometry, write_vector_output, push_local_vector_to_geoserver, push_local_raster_to_geoserver, + load_precomputed_roi, ) -ROI_PATH = str(PROJECT_ROOT / "data/yalburga_mws.json") +from nrm_app.celery import app + +logger = logging.getLogger(__name__) + LOCAL_OUTPUT_BASE_DIR = "data/soil_health" GEOSERVER_STYLE = "" -GEOSERVER_WORKSPACE = "soil_health" +GEOSERVER_RASTER_WORKSPACE = "soil_health_raster" +GEOSERVER_VECTOR_WORKSPACE = "soil_health_vector" NUTRIENTS = ["N", "K", "P", "OC"] NUTRIENT_PERCENTILES = (5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95) +LOCAL_ALGORITHM = "local_soil_health" +LOCAL_ALGORITHM_VERSION = "local-1.0" + + +def _get_lulc_mask_classes(nutrient): + nutrient = str(nutrient).strip().upper() + if nutrient == "OC": + return {6} + if nutrient in {"N", "P", "K"}: + return {8, 9, 10, 11} + raise ValueError(f"Unsupported nutrient for LULC masking: {nutrient}") + + +def _pick_output_nodata(dtype, source_nodata): + if source_nodata is not None: + source_nodata = float(source_nodata) + if not np.isnan(source_nodata): + return source_nodata + + dtype = np.dtype(dtype) + if np.issubdtype(dtype, np.floating): + return -9999.0 + + info = np.iinfo(dtype) + if np.issubdtype(dtype, np.signedinteger): + return info.min + return info.max + + +def _resolve_latest_lulc_raster_paths(count=3, lulc_dir=LULC_BASE_DIR): + lulc_dir = Path(lulc_dir) + if not lulc_dir.exists(): + return [] + + candidates = sorted(lulc_dir.glob("lulc_v3_*.tif")) + if not candidates: + return [] + selected = candidates[-count:] if len(candidates) >= count else candidates + return [str(path) for path in selected] + + +def _clip_and_mask_soil_health_raster( + roi_gdf, + soil_raster_path, + output_path, + nutrient, + lulc_paths=None, +): + with rasterio.open(soil_raster_path) as soil_src: + roi_gdf = validate_geometry(roi_gdf) + if roi_gdf.empty: + raise ValueError( + "No valid ROI geometry available for soil health clipping." + ) + if roi_gdf.crs is None: + raise ValueError( + "ROI CRS is missing; cannot align with soil health raster." + ) + if soil_src.crs and roi_gdf.crs != soil_src.crs: + roi_gdf = roi_gdf.to_crs(soil_src.crs) + + shapes = [ + mapping(geom) + for geom in roi_gdf.geometry + if geom is not None and not geom.is_empty + ] + if not shapes: + raise ValueError( + "No valid ROI geometry available for soil health clipping." + ) + + band_index = 1 + nodata = _pick_output_nodata( + dtype=soil_src.dtypes[band_index - 1], + source_nodata=soil_src.nodata, + ) + clipped_data, clipped_transform = mask( + soil_src, + shapes=shapes, + crop=True, + filled=True, + nodata=nodata, + indexes=band_index, + ) + if clipped_data.ndim == 3: + clipped_data = clipped_data[0] + + clipped_meta = soil_src.meta.copy() + clipped_meta.update( + { + "driver": "GTiff", + "height": clipped_data.shape[0], + "width": clipped_data.shape[1], + "transform": clipped_transform, + "count": 1, + "dtype": clipped_data.dtype, + "nodata": nodata, + "compress": "lzw", + } + ) + + if lulc_paths: + reprojected_arrays = [] + for lulc_path in lulc_paths: + lulc_array = np.zeros( + (clipped_meta["height"], clipped_meta["width"]), + dtype=np.float32, + ) + with rasterio.open(lulc_path) as lulc_src: + reproject( + source=rasterio.band(lulc_src, 1), + destination=lulc_array, + src_transform=lulc_src.transform, + src_crs=lulc_src.crs, + src_nodata=lulc_src.nodata, + dst_transform=clipped_meta["transform"], + dst_crs=clipped_meta["crs"], + dst_nodata=0, + resampling=Resampling.mode, + ) + reprojected_arrays.append(lulc_array) + + lulc_mode_array = compute_mode_lulc_array(reprojected_arrays) + allowed_mask_classes = _get_lulc_mask_classes(nutrient) + valid_pixels = np.isin(lulc_mode_array, list(allowed_mask_classes)) + valid_soil_pixels = clipped_data != nodata + output_array = np.where(valid_pixels & valid_soil_pixels, clipped_data, nodata) + else: + output_array = clipped_data + + with rasterio.open(output_path, "w", **clipped_meta) as dst: + dst.write(output_array.astype(clipped_meta["dtype"], copy=False), 1) + + return str(output_path) def clip_soil_health_raster( @@ -29,18 +179,25 @@ def clip_soil_health_raster( block=None, asset_suffix=None, roi=None, - precomputed_roi_dir=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, push_to_geoserver=True, sync_layer_metadata=False, ): - asset_suffix, roi_gdf = get_roi(asset_suffix, block, district, roi, state) + asset_suffix, roi_gdf = get_roi( + asset_suffix, + block, + district, + roi, + state, + precomputed_roi_dir=precomputed_roi_dir, + ) layer_name = f"{asset_suffix}_soil_health_raster" + lulc_paths = _resolve_latest_lulc_raster_paths() 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 + PROJECT_ROOT / f"data/base_layers/soil_health/soil_health_{nutrient}.tif" ) output_raster_path = build_output_raster_path( layer_name=f"{layer_name}_{nutrient}", @@ -50,32 +207,66 @@ def clip_soil_health_raster( block=block, ) - clip_raster_with_roi( - roi_gdf, SOIL_MAP_PATH, output_raster_path, raster_label="Raster" + asset_id = _clip_and_mask_soil_health_raster( + roi_gdf=roi_gdf, + soil_raster_path=SOIL_MAP_PATH, + output_path=output_raster_path, + nutrient=nutrient, + lulc_paths=lulc_paths, ) 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, + workspace=GEOSERVER_RASTER_WORKSPACE, ) print(f"GeoServer upload response for {nutrient}: {upload_res}") geoserver_statuses.append(True) + 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="Soil Health Raster", + misc={"is_generated_locally": True}, + algorithm=LOCAL_ALGORITHM, + algorithm_version=LOCAL_ALGORITHM_VERSION, + ) + logger.info("Saved layer metadata to DB: layer_id=%s", layer_id) + if layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for Soil health raster") + + try: + layer_STAC_generated = generate_STAC_layerwise.generate_raster_stac( + state=state, + district=district, + block=block, + layer_name=layer_name, + ) + update_layer_sync_status( + layer_id=layer_id, is_stac_specs_generated=layer_STAC_generated + ) + print("STAC metadata updated for Soil health raster") + except Exception as e: + print(f"Error generating STAC: {e}") + return all(geoserver_statuses) if push_to_geoserver else True # TODO Add Stac specs -def get_roi(asset_suffix, block, district, roi, state): +def get_roi(asset_suffix, block, district, roi, state, precomputed_roi_dir): 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) + 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( @@ -96,13 +287,20 @@ def vectorize_soil_health( block=None, asset_suffix=None, roi=None, - percentiles=NUTRIENT_PERCENTILES, push_to_geoserver=True, + sync_layer_metadata=True, ): - asset_suffix, roi_gdf = get_roi(asset_suffix, block, district, roi, state) + asset_suffix, roi_gdf = get_roi( + asset_suffix, + block, + district, + roi, + state, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + ) layer_name = f"{asset_suffix}_soil_health" - + geoserver_statuses = [] for nutrient in NUTRIENTS: # This produces one output feature per ROI geometry with Nitrogen summary columns. raster_path = build_output_raster_path( @@ -116,7 +314,7 @@ def vectorize_soil_health( result_gdf = nutrient_stats_for_geometries( roi_gdf=roi_gdf, raster_path=raster_path, - percentiles=tuple(percentiles), + percentiles=tuple(NUTRIENT_PERCENTILES), nutrient=nutrient, ) @@ -127,7 +325,7 @@ def vectorize_soil_health( block=block, output_base_dir=LOCAL_OUTPUT_BASE_DIR, ) - write_vector_output( + asset_id = write_vector_output( gdf=result_gdf, output_path=output_path, layer_name=layer_name, @@ -138,9 +336,79 @@ def vectorize_soil_health( geoserver_response = push_local_vector_to_geoserver( path=os.path.splitext(output_path)[0], layer_name=layer_name, - workspace=GEOSERVER_WORKSPACE, + workspace=GEOSERVER_VECTOR_WORKSPACE, file_type="gpkg", ) - print(f"GeoServer response: {geoserver_response}") + print(f"GeoServer response for {nutrient}: {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=layer_name, + asset_id=asset_id, + dataset_name="Soil Health Vector", + misc={"is_generated_locally": True}, + algorithm=LOCAL_ALGORITHM, + algorithm_version=LOCAL_ALGORITHM_VERSION, + ) + logger.info("Saved layer metadata to DB: layer_id=%s", layer_id) + if layer_id and push_to_geoserver: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for Soil health vector") + + try: + layer_STAC_generated = generate_STAC_layerwise.generate_vector_stac( + state=state, + district=district, + block=block, + layer_name="soil_health_vector", + ) + update_layer_sync_status( + layer_id=layer_id, is_stac_specs_generated=layer_STAC_generated + ) + print("STAC metadata updated for Soil health vector") + except Exception as e: + print(f"Error generating STAC: {e}") + + return all(geoserver_statuses) if push_to_geoserver else True + + +@app.task(bind=True) +def soil_health_local( + state=None, + district=None, + block=None, + asset_suffix=None, + roi=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + push_to_geoserver=True, + sync_layer_metadata=True, +): + clip_soil_health_raster( + state, + district, + block, + asset_suffix, + roi, + precomputed_roi_dir, + push_to_geoserver, + sync_layer_metadata, + ) - # TODO Add Stac specs + vectorize_soil_health( + state, + district, + block, + asset_suffix, + roi, + push_to_geoserver, + sync_layer_metadata, + ) diff --git a/computing/urls.py b/computing/urls.py index aae36158..8f3892ab 100644 --- a/computing/urls.py +++ b/computing/urls.py @@ -288,4 +288,9 @@ api.generate_livestocks, name="generate_livestocks", ), + path( + "generate_soil_health/", + api.generate_soil_health, + name="generate_soil_health", + ), ] From 5fcaf7daf4b342b6d8bc21ddfca8a179f5ed44eb Mon Sep 17 00:00:00 2001 From: aman verma Date: Tue, 14 Jul 2026 08:31:42 +0000 Subject: [PATCH 043/120] added OLM OC --- computing/soil_health/soil_health.py | 17 +++++++++-------- computing/soil_health/soil_health_helper.py | 2 -- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/computing/soil_health/soil_health.py b/computing/soil_health/soil_health.py index bb091478..f2c06826 100644 --- a/computing/soil_health/soil_health.py +++ b/computing/soil_health/soil_health.py @@ -35,7 +35,7 @@ GEOSERVER_STYLE = "" GEOSERVER_RASTER_WORKSPACE = "soil_health_raster" GEOSERVER_VECTOR_WORKSPACE = "soil_health_vector" -NUTRIENTS = ["N", "K", "P", "OC"] +NUTRIENTS = ["N", "K", "P", "OC", "OLM"] NUTRIENT_PERCENTILES = (5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95) LOCAL_ALGORITHM = "local_soil_health" LOCAL_ALGORITHM_VERSION = "local-1.0" @@ -43,7 +43,7 @@ def _get_lulc_mask_classes(nutrient): nutrient = str(nutrient).strip().upper() - if nutrient == "OC": + if nutrient in {"OC", "OLM"}: return {6} if nutrient in {"N", "P", "K"}: return {8, 9, 10, 11} @@ -255,7 +255,7 @@ def clip_soil_health_raster( except Exception as e: print(f"Error generating STAC: {e}") - return all(geoserver_statuses) if push_to_geoserver else True # TODO Add Stac specs + return all(geoserver_statuses) if push_to_geoserver else True def get_roi(asset_suffix, block, district, roi, state, precomputed_roi_dir): @@ -317,9 +317,9 @@ def vectorize_soil_health( percentiles=tuple(NUTRIENT_PERCENTILES), nutrient=nutrient, ) - + output_layer_name = f"{layer_name}_vector_{nutrient}" output_path = build_output_vector_path( - layer_name=f"{layer_name}_vector_{nutrient}", + layer_name=output_layer_name, state=state, district=district, block=block, @@ -328,14 +328,14 @@ def vectorize_soil_health( asset_id = write_vector_output( gdf=result_gdf, output_path=output_path, - layer_name=layer_name, + layer_name=output_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, + layer_name=output_layer_name, workspace=GEOSERVER_VECTOR_WORKSPACE, file_type="gpkg", ) @@ -352,7 +352,7 @@ def vectorize_soil_health( state=state, district=district, block=block, - layer_name=layer_name, + layer_name=output_layer_name, asset_id=asset_id, dataset_name="Soil Health Vector", misc={"is_generated_locally": True}, @@ -383,6 +383,7 @@ def vectorize_soil_health( @app.task(bind=True) def soil_health_local( + self, state=None, district=None, block=None, diff --git a/computing/soil_health/soil_health_helper.py b/computing/soil_health/soil_health_helper.py index 8d96acb5..c360bf4e 100644 --- a/computing/soil_health/soil_health_helper.py +++ b/computing/soil_health/soil_health_helper.py @@ -63,9 +63,7 @@ def nutrient_stats_for_geometries(roi_gdf, raster_path, percentiles, nutrient): 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) From c440427f9cee3070858f05f8bf0601bf9eb073bf Mon Sep 17 00:00:00 2001 From: aman verma Date: Wed, 15 Jul 2026 10:24:16 +0000 Subject: [PATCH 044/120] ltp stp updated --- .../local/generate_ltp_stp_local.py | 303 ++ .../Generate_LTP_STP_Polygons.ipynb | 3349 +++++++++++++++++ 2 files changed, 3652 insertions(+) create mode 100644 computing/tree_health/local/generate_ltp_stp_local.py create mode 100644 utilities/scripts/tree_health/colab_notebooks/Generate_LTP_STP_Polygons.ipynb diff --git a/computing/tree_health/local/generate_ltp_stp_local.py b/computing/tree_health/local/generate_ltp_stp_local.py new file mode 100644 index 00000000..4af69c18 --- /dev/null +++ b/computing/tree_health/local/generate_ltp_stp_local.py @@ -0,0 +1,303 @@ +""" +This is written to closely follow the original GEE notebook: + Year + -> ACZ + -> District + -> Clip LULC + -> Tree mask + -> Polygonize + -> Compute patch area + -> Classify LTP/STP + -> Rasterize + -> Save GeoTIFF +""" + +import os +import re + +import geopandas as gpd +import numpy as np +import pandas as pd +import rasterio +from rasterio.mask import mask +from rasterio.features import shapes, rasterize +from shapely.geometry import shape +from rasterio.enums import Resampling +from rasterio.warp import calculate_default_transform, reproject +from computing.config_loader import LULC_BASE_DIR, PROJECT_ROOT +from rasterio.merge import merge +from glob import glob + +TREE_CLASS = 6 +AREA_THRESHOLD_HA = 1.0 +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/tree_health/ltp_stp" + +ACZS = { + "Eastern Plateau & Hills Region": "EPAHR", + "Southern Plateau and Hills Region": "SPAHR", + "East Coast Plains & Hills Region": "ECPHR", + "Western Plateau and Hills Region": "WPAHR", + "Central Plateau & Hills Region": "CPAHR", + "Lower Gangetic Plain Region": "LGPR", + "Middle Gangetic Plain Region": "MGPR", + "Eastern Himalayan Region": "EHR", + "Western Himalayan Region": "WHR", + "Upper Gangetic Plain Region": "UGPR", + "Trans Gangetic Plain Region": "TGPR", +} + + +def generate_ltp_stp_local(year): + """ + Generate LTP/STP rasters for each year, ACZ, and district. + """ + + lulc_file = f"data/base_layers/lulc/lulc_v3_{year}_{year+1}.tif" + print(f"\nYear: {year} -> LULC file: {lulc_file}") + if not os.path.exists(lulc_file): + print("ERROR: LULC file does not exist:", lulc_file) + return + + district_boundaries = gpd.read_file( + "data/base_layers/india_district_boundaries.geojson" + ) + print( + "Loaded DISTRICTS:", + len(district_boundaries), + "rows from data/base_layers/india_district_boundaries.geojson", + ) + + for acz, acronym in ACZS.items(): + + print(f"\nProcessing {acz}") + + district_csv = f"data/base_layers/tree_health/Agroclimatic_regions/{acz}.csv" + district_names = pd.read_csv(district_csv)["Name"].tolist() + + output_dir = os.path.join( + LOCAL_OUTPUT_BASE_DIR, + f"ltp_{year}", + acronym, + ) + os.makedirs(output_dir, exist_ok=True) + + generate_district_tiff( + acronym, district_boundaries, district_names, lulc_file, output_dir, year + ) + + merge_district_tiffs(acz, output_dir, year, acronym) + + +def resample_to_25m( + clipped, + transform, + src_crs, + nodata, +): + """ + Resample clipped raster from its native resolution + to approximately 25 m using MODE resampling. + """ + + # Approximate 25 m in degrees + target_res = 25.0 / 111320.0 + + left = transform.c + top = transform.f + + right = left + clipped.shape[1] * transform.a + bottom = top + clipped.shape[0] * transform.e + + dst_transform, dst_width, dst_height = calculate_default_transform( + src_crs, + src_crs, + clipped.shape[1], + clipped.shape[0], + left, + bottom, + right, + top, + resolution=target_res, + ) + + dst = np.full( + (dst_height, dst_width), + nodata, + dtype=clipped.dtype, + ) + + reproject( + source=clipped, + destination=dst, + src_transform=transform, + src_crs=src_crs, + dst_transform=dst_transform, + dst_crs=src_crs, + src_nodata=nodata, + dst_nodata=nodata, + resampling=Resampling.mode, + ) + + return dst, dst_transform + + +def generate_district_tiff( + acronym, district_boundaries, district_names, lulc_file, output_dir, year +): + + for district_name in district_names: + + print(" Processing district:", district_name) + district = district_boundaries[district_boundaries["Name"] == district_name] + + if district.empty: + print(" ERROR: District not found in boundary file.") + continue + + with rasterio.open(lulc_file) as src: + # print("Opened LULC raster. CRS:", src.crs) + # print("District CRS:", district.crs) + + if district.crs != src.crs: + district = district.to_crs(src.crs) + # print("Reprojected district to raster CRS.") + + clipped, transform = mask( + src, + district.geometry, + crop=True, + filled=False, + indexes=1, + ) + # print("Clipped raster shape:", clipped.shape) + # print("Masked pixels count:", np.count_nonzero(~clipped.mask)) + + profile = src.profile.copy() + + nodata = src.nodata + if nodata is None: + nodata = 255 + + # print("Original shape:", clipped.shape) + # print("Original resolution:", src.res) + + resampled, transform = resample_to_25m( + clipped.filled(nodata), + transform, + src.crs, + nodata, + ) + + tree = (resampled == TREE_CLASS).astype(np.uint8) + + # print("Resampled shape:", tree.shape) + # print("Tree pixels:", np.count_nonzero(tree)) + # print("Tree pixels count:", np.count_nonzero(tree == 1)) + + polygons = [] + + for geom, value in shapes( + tree, + mask=tree == 1, + transform=transform, + connectivity=8, + ): + if value == 1: + polygons.append(shape(geom)) + + # print("Polygonized tree patches:", len(polygons)) + if len(polygons) == 0: + print("No tree patches found for this district.") + continue + + gdf = gpd.GeoDataFrame( + geometry=polygons, + crs=profile["crs"], + ) + + projected = gdf.to_crs(gdf.estimate_utm_crs()) + + gdf["area_ha"] = projected.area / 10000.0 + + gdf["large_tree_patch"] = (gdf["area_ha"] >= AREA_THRESHOLD_HA).astype(np.uint8) + + gdf = gdf.to_crs(profile["crs"]) + + ltp = rasterize( + ( + (geom, value) + for geom, value in zip( + gdf.geometry, + gdf.large_tree_patch, + ) + ), + out_shape=tree.shape, + transform=transform, + fill=0, + dtype=np.uint8, + ) + + output = np.full(tree.shape, 255, dtype=np.uint8) + output[tree == 1] = ltp[tree == 1] + + profile.update( + driver="GTiff", + height=output.shape[0], + width=output.shape[1], + transform=transform, + count=1, + dtype="uint8", + nodata=255, + compress="lzw", + ) + + outfile = os.path.join( + output_dir, + f"ltp_{year}_{acronym}_{re.sub('[^A-Za-z0-9]', '', district_name)}.tif", + ) + + with rasterio.open(outfile, "w", **profile) as dst: + dst.write(output, 1) + + print("Saved:", outfile) + + +def merge_district_tiffs(acz, output_dir, year, acronym): + print(f"\nMerging district rasters for {acz}...") + + district_tiffs = sorted(glob(os.path.join(output_dir, "*.tif"))) + + if len(district_tiffs) == 0: + print("No district rasters found.") + return + + src_files = [rasterio.open(fp) for fp in district_tiffs] + + mosaic, out_transform = merge( + src_files, + method="first", + ) + + out_meta = src_files[0].meta.copy() + + out_meta.update( + { + "height": mosaic.shape[1], + "width": mosaic.shape[2], + "transform": out_transform, + "compress": "lzw", + } + ) + + acz_output = os.path.join( + output_dir, + f"ltp_{year}_{acronym}.tif", + ) + + with rasterio.open(acz_output, "w", **out_meta) as dst: + dst.write(mosaic) + + for src in src_files: + src.close() + + print(f"Saved ACZ raster: {acz_output}") diff --git a/utilities/scripts/tree_health/colab_notebooks/Generate_LTP_STP_Polygons.ipynb b/utilities/scripts/tree_health/colab_notebooks/Generate_LTP_STP_Polygons.ipynb new file mode 100644 index 00000000..b08f77ae --- /dev/null +++ b/utilities/scripts/tree_health/colab_notebooks/Generate_LTP_STP_Polygons.ipynb @@ -0,0 +1,3349 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 23, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 17 + }, + "id": "-KTc04_9KI79", + "outputId": "708f410f-2793-4db6-a61c-9a516f60a9c2" + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + " \n", + " " + ] + }, + "metadata": {} + } + ], + "source": [ + "import geemap, ee\n", + "\n", + "ee.Authenticate()\n", + "ee.Initialize(project=\"corestack-datasets-alpha\")" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 35 + }, + "id": "sZrP5IEgKWXq", + "outputId": "2265e164-9a63-4df8-f2a4-c185e44b4adb" + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + " \n", + " " + ] + }, + "metadata": {} + }, + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Mounted at /content/drive\n" + ] + } + ], + "source": [ + "from google.colab import drive\n", + "drive.mount('/content/drive')" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 17 + }, + "id": "Y49bxpDIbQZ7", + "outputId": "eb288a20-b66c-49df-cb55-39a231af8793" + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + " \n", + " " + ] + }, + "metadata": {} + } + ], + "source": [ + "import geemap\n", + "import pandas as pd\n", + "import re" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 17 + }, + "id": "wxzXAgvvbpzx", + "outputId": "70556940-30a3-4b08-f396-7109de247858" + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + " \n", + " " + ] + }, + "metadata": {} + } + ], + "source": [ + "agroclimaticZone_acronym_dict = {\n", + " # 'Eastern Plateau & Hills Region': 'EPAHR',\n", + " # 'Southern Plateau and Hills Region': 'SPAHR',\n", + " 'East Coast Plains & Hills Region': 'ECPHR',\n", + " # 'Western Plateau and Hills Region': 'WPAHR',\n", + " # 'Central Plateau & Hills Region': 'CPAHR',\n", + " # 'Lower Gangetic Plain Region': 'LGPR',\n", + " # 'Middle Gangetic Plain Region': 'MGPR',\n", + " # 'Eastern Himalayan Region': 'EHR',\n", + " # 'Western Himalayan Region': 'WHR',\n", + " # 'Upper Gangetic Plain Region': 'UGPR',\n", + " # 'Trans Gangetic Plain Region': 'TGPR',\n", + " # 'West Coast Plains & Ghat Region': 'WCPGR',\n", + " # 'Gujarat Plains & Hills Region': 'GPHR',\n", + " # 'Western Dry Region': 'WDR'\n", + "}\n", + "agro_zones = list(agroclimaticZone_acronym_dict.keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 17 + }, + "id": "gMYImDlfLoHR", + "outputId": "62590a91-47d8-4201-a318-aedceb8bb825" + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + " \n", + " " + ] + }, + "metadata": {} + } + ], + "source": [ + "years = [2017, 2023]\n", + "india_districts = ee.FeatureCollection(\"projects/ee-indiasat/assets/india_district_boundaries\")\n", + "india_acz = ee.FeatureCollection(\"projects/ee-mtpictd/assets/harsh/Agroclimatic_regions\")\n", + "# agro_dist_df = pd.read_csv(\"drive/MyDrive/dhruvi/data/distict_agroclimate_map.csv\")\n", + "# dist_list = list(agro_dist_df['Name'])" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 17 + }, + "id": "wxOORQU1GIxv", + "outputId": "5b1c19dd-0e80-41c1-b72b-5adb11896c65" + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + " \n", + " " + ] + }, + "metadata": {} + } + ], + "source": [ + "err = ee.ErrorMargin(200)\n", + "\n", + "def addArea(feature):\n", + " # Computes the feature's geometry area and adds it as a property\n", + " patch_area = ee.Number(feature.geometry(err).area(err).divide(1e4)) #Converting area to hectare\n", + " ltp = ee.Algorithms.If(patch_area.gte(ee.Number(1)), 1, 0)\n", + " return feature.set({\"area_ha\": patch_area, \"large_tree_patch\": ltp})\n", + "\n", + "def export_image_asset(img, aoi, img_name, year):\n", + " # projection = ltp_img.select('large_tree_patch').projection().getInfo()\n", + " task = ee.batch.Export.image.toAsset(\n", + " image = img,\n", + " description = img_name,\n", + " assetId = 'projects/corestack-trees/assets/tree_characteristics/final_ltp_stp_'+ year + '/' + img_name,\n", + " region = aoi,\n", + " scale = 25,\n", + " crs = 'EPSG:4326',\n", + " # crs = projection['crs'],\n", + " # crsTransform = projection['transform'],\n", + " maxPixels = 1e13\n", + " )\n", + " task.start()\n", + " return task" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 17 + }, + "id": "AXhs2wCl-q79", + "outputId": "6956cc45-ccf2-4de4-d800-984cfbb537c2" + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + " \n", + " " + ] + }, + "metadata": {} + } + ], + "source": [ + "# tree_cover.projection().getInfo()\n", + "\n", + "def get_is_tree_cover(aoi, curr_year, scale = 25):\n", + " curr_year = int(curr_year)\n", + " indiasat_asset = f\"projects/corestack-datasets/assets/datasets/LULC_v3_river_basin/pan_india_lulc_v3_{curr_year}_{curr_year+1}\"\n", + " lulc_image = ee.Image(indiasat_asset).select(\"predicted_label\").clip(aoi)\n", + " return lulc_image.updateMask(lulc_image.eq(6)).toInt().reproject(crs='EPSG:4326', scale=scale)" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1000 + }, + "id": "debm1g-saQo1", + "outputId": "f4f3d9a2-b975-4cb2-8dea-3e5f80145676", + "collapsed": true + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + " \n", + " " + ] + }, + "metadata": {} + }, + { + "output_type": "stream", + "name": "stdout", + "text": [ + "East Coast Plains & Hills Region\n", + "Total Districts: 68\n", + "0 Chittoor\n", + "1 East Godavari\n", + "2 Guntur\n", + "3 Krishna\n", + "4 Kurnool\n", + "5 Nellore\n", + "6 Prakasam\n", + "7 Srikakulam\n", + "8 Visakhapatnam\n", + "9 Vizianagaram\n", + "10 West Godavari\n", + "11 Y.S.R.\n", + "12 Idukki\n", + "13 Kollam\n", + "14 Pathanamthitta\n", + "15 Thiruvananthapuram\n", + "16 Anugul\n", + "17 Baleshwar\n", + "18 Bauda\n", + "19 Bhadrak\n", + "20 Cuttack\n", + "21 Dhenkanal\n", + "22 Gajapati\n", + "23 Ganjam\n", + "24 Jagatsinghapur\n", + "25 Jajapur\n", + "26 Kandhamal\n", + "27 Kendrapara\n", + "28 Kendujhar\n", + "29 Khordha\n", + "30 Koraput\n", + "31 Malkangiri\n", + "32 Mayurbhanj\n", + "33 Nayagarh\n", + "34 Puri\n", + "35 Rayagada\n", + "36 Karaikal\n", + "37 Puducherry\n", + "38 Yanam\n", + "39 Ariyalur\n", + "40 Chennai\n", + "41 Cuddalore\n", + "42 Dharmapuri\n", + "43 Kancheepuram\n", + "44 Kanniyakumari\n", + "45 Krishnagiri\n", + "46 Madurai\n", + "47 Nagappattinam\n", + "48 Pudukkottai\n", + "49 Ramanathapuram\n", + "50 Salem\n", + "51 Sivaganga\n", + "52 Thanjavur\n", + "53 Theni\n", + "54 Thiruvallur\n", + "55 Thiruvarur\n", + "56 Thoothukkudi\n", + "57 Tiruchirappalli\n", + "58 Tirunelveli\n", + "59 Tiruvannamalai\n", + "60 Vellore\n", + "61 Viluppuram\n", + "62 Virudunagar\n", + "63 Khammam\n", + "64 Mahbubnagar\n", + "65 Nalgonda\n", + "66 Pashchim Medinipur\n", + "67 Purba Medinipur\n", + "East Coast Plains & Hills Region\n", + "Total Districts: 68\n", + "0 Chittoor\n", + "1 East Godavari\n", + "2 Guntur\n", + "3 Krishna\n", + "4 Kurnool\n", + "5 Nellore\n", + "6 Prakasam\n", + "7 Srikakulam\n", + "8 Visakhapatnam\n", + "9 Vizianagaram\n", + "10 West Godavari\n", + "11 Y.S.R.\n", + "12 Idukki\n", + "13 Kollam\n", + "14 Pathanamthitta\n", + "15 Thiruvananthapuram\n", + "16 Anugul\n", + "17 Baleshwar\n", + "18 Bauda\n", + "19 Bhadrak\n", + "20 Cuttack\n", + "21 Dhenkanal\n", + "22 Gajapati\n", + "23 Ganjam\n", + "24 Jagatsinghapur\n", + "25 Jajapur\n", + "26 Kandhamal\n", + "27 Kendrapara\n", + "28 Kendujhar\n", + "29 Khordha\n", + "30 Koraput\n", + "31 Malkangiri\n", + "32 Mayurbhanj\n", + "33 Nayagarh\n", + "34 Puri\n", + "35 Rayagada\n", + "36 Karaikal\n", + "37 Puducherry\n", + "38 Yanam\n", + "39 Ariyalur\n", + "40 Chennai\n", + "41 Cuddalore\n", + "42 Dharmapuri\n", + "43 Kancheepuram\n", + "44 Kanniyakumari\n", + "45 Krishnagiri\n", + "46 Madurai\n", + "47 Nagappattinam\n", + "48 Pudukkottai\n", + "49 Ramanathapuram\n", + "50 Salem\n", + "51 Sivaganga\n", + "52 Thanjavur\n", + "53 Theni\n", + "54 Thiruvallur\n", + "55 Thiruvarur\n", + "56 Thoothukkudi\n", + "57 Tiruchirappalli\n", + "58 Tirunelveli\n", + "59 Tiruvannamalai\n", + "60 Vellore\n", + "61 Viluppuram\n", + "62 Virudunagar\n", + "63 Khammam\n", + "64 Mahbubnagar\n", + "65 Nalgonda\n", + "66 Pashchim Medinipur\n", + "67 Purba Medinipur\n" + ] + } + ], + "source": [ + "ltp_task = dict()\n", + "for year in years:\n", + " year_0 = str(year-1)\n", + " year_1 = str(year)\n", + " year_2 = str(year+1)\n", + " # tree_cover_india = ee.ImageCollection(f'projects/ee-mtpictd/assets/harsh/dw_corrected_{year}')\n", + " for acz in agro_zones:\n", + " # for acz, acronym in agroclimaticZone_acronym_dict.items():\n", + " print(acz)\n", + " ltp_task[year] = dict()\n", + " # aoi = india_acz.filter(ee.Filter.eq('regionname', acz)).geometry()\n", + " df = pd.read_csv(f'/content/drive/MyDrive/TreeHealth/Agroclimatic_regions/{acz}.csv')\n", + " dist_list = list(df['Name'])\n", + " print(\"Total Districts:\", len(dist_list))\n", + " for i, district in enumerate(dist_list):\n", + " print(i, district)\n", + " aoi = india_districts.filter(ee.Filter.eq('Name', district)).geometry()\n", + "\n", + " # Load DW tree cover corrected data for year_0\n", + " # tree_cover = ee.ImageCollection(f'projects/ee-mtpictd/assets/harsh/dw_corrected_{year}') \\\n", + " # .select(f'label_{year_0[-2:]}') \\\n", + " ######################################\n", + " # ccd = ee.ImageCollection(f'projects/corestack-trees/assets/tree_characteristics/modal_ccd_{year_1}') \\\n", + " # .filterBounds(aoi) \\\n", + " # .mode() \\\n", + " # .clip(aoi)\n", + " # tree_cover = ccd.unmask(-9999)\n", + " # tree_cover = ccd.expression(\n", + " # \"((b('cc')!=-9999)) ? 1 : (-9999)\"\n", + " # ).clip(aoi);\n", + " tree_cover = get_is_tree_cover(aoi, year) # Added this\n", + " tree_cover = tree_cover.rename(['label']);\n", + " tree_cover = tree_cover.updateMask(tree_cover.neq(-9999))\n", + " #########################################\n", + "\n", + " # tree_cover_0 = ee.ImageCollection(f'projects/ee-mtpictd/assets/harsh/tree_cover_{year_0}') \\\n", + " # .filterBounds(aoi) \\\n", + " # .mode() \\\n", + " # .clip(aoi)\n", + " # tree_cover_0 = tree_cover_0.rename(['label_0'])\n", + " # # tree_cover = tree_cover.updateMask(tree_cover.eq(1))\n", + " # # tree_cover = tree_cover.reproject(crs='EPSG:4326', scale=25)\n", + " # # print(\"Tree Cover:\", tree_cover.getInfo())\n", + "\n", + " # # Load DW tree cover corrected data for year_1\n", + " # tree_cover_1 = ee.ImageCollection(f'projects/ee-mtpictd/assets/harsh/tree_cover_{year_1}') \\\n", + " # .filterBounds(aoi) \\\n", + " # .mode() \\\n", + " # .clip(aoi)\n", + " # tree_cover_1 = tree_cover_1.rename(['label_1'])\n", + "\n", + " # # Load DW tree cover corrected data for year_2\n", + " # tree_cover_2 = ee.ImageCollection(f'projects/ee-mtpictd/assets/harsh/tree_cover_{year_2}') \\\n", + " # .filterBounds(aoi) \\\n", + " # .mode() \\\n", + " # .clip(aoi)\n", + " # tree_cover_2 = tree_cover_2.rename(['label_2'])\n", + "\n", + " # # Take modal of all the 3-years tree cover images\n", + " # tree_cover = tree_cover_0.addBands(tree_cover_1).addBands(tree_cover_2);\n", + " # tree_cover = tree_cover.unmask(-9999)\n", + " # tree_cover = tree_cover.expression(\n", + " # \"((b('label_1')!=-9999)) ? (b('label_1'))\"+\n", + " # \":((b('label_0')!=-9999) and (b('label_1')==-9999) and (b('label_2')!=-9999)) ? (b('label_0'))\"+\n", + " # \":(-9999)\"\n", + " # ).clip(aoi);\n", + " # tree_cover = tree_cover.rename(['label']);\n", + " # tree_cover = tree_cover.updateMask(tree_cover.neq(-9999))\n", + "\n", + "\n", + " # tree_patches = tree_cover.reduceToVectors(geometry=aoi, maxPixels=1e13)\n", + " tree_patches = tree_cover.reduceToVectors(crs='EPSG:4326', scale=25, geometry=aoi, maxPixels=1e13, geometryType='polygon', bestEffort=True)\n", + " # print(\"Tree Patches:\", tree_patches.first().getInfo())\n", + "\n", + " tree_patches = tree_patches.map(addArea)\n", + " # print(\"Consecutive tree patches: \", tree_patches.first().getInfo());\n", + " # print(\"Total tree patches: \", tree_patches.size());\n", + "\n", + " ltp_img = ee.Image(0)\n", + " ltp_class = tree_patches.reduceToImage(properties = ['large_tree_patch'], reducer = ee.Reducer.mean()).rename(\"large_tree_patch\")\n", + " ltp_img = ltp_img.addBands(ltp_class).select(['large_tree_patch']).clip(aoi)\n", + " ltp_img = ltp_img.updateMask(tree_cover.select('label'))\n", + " ltp_img = ltp_img.reproject('EPSG:4326', None, 25).clip(aoi).select('large_tree_patch')\n", + "\n", + " # tp_area = tree_patches.reduceToImage(properties = ['area_ha'], reducer = ee.Reducer.first()).rename(\"area_ha\")\n", + " # ltp_img = ltp_img.addBands(ltp_class).addBands(tp_area).select(['large_tree_patch', 'area_ha']).clip(aoi)\n", + " ltp_img = ltp_img.addBands(ltp_class).select(['large_tree_patch']).clip(aoi)\n", + " # ltp_img = ltp_img.updateMask(tree_cover.select(f'label_{year[-2:]}'))\n", + " ltp_img = ltp_img.updateMask(tree_cover.select('label'))\n", + " ltp_img = ltp_img.reproject('EPSG:4326', None, 25).clip(aoi).select('large_tree_patch')\n", + " # ltp_img = ltp_img.reproject('EPSG:4326', tree_cover.projection().getInfo()['transform'], None).clip(aoi).select('large_tree_patch')\n", + " # print(\"LTP Image:\", ltp_img.getInfo());\n", + "\n", + " # var large_tree_patches = tree_patches.filter(ee.Filter.eq('large_tree_patch', 1));\n", + " # print(\"Consecutive large tree patches: \", large_tree_patches.first());\n", + " # # print(\"Number of large tree patches: \", large_tree_patches.size());\n", + "\n", + " # // var empty = ee.Image().byte();\n", + " # // var ltp_boundary = empty.paint(large_tree_patches).paint(large_tree_patches, 0, 1);\n", + " # Map.addLayer(large_tree_patches, {color: 'blue'}, 'LTP');\n", + "\n", + " # var small_tree_patches = tree_patches.filter(ee.Filter.eq('large_tree_patch', 0));\n", + " # // var small_tree_patches = tree_patches.filter(ee.Filter.lt('areaHa', 1));\n", + " # print(\"Consecutive small tree patches: \", small_tree_patches.first());\n", + "\n", + " img_name = 'ltp_' + year_1 + '_' + agroclimaticZone_acronym_dict[acz] + '_' + re.sub(\"[^A-Za-z0-9]\", \"\", district)\n", + " ltp_task[year][district] = export_image_asset(ltp_img, aoi, img_name, year_1)\n", + " # img_name = 'ltp_' + year + '_' + acronym\n", + " # ltp_task[year][acronym] = export_image_asset(ltp_img, aoi, img_name, year)\n", + " # print(\"Done!\")\n", + " # break\n", + " # break\n", + "\n", + " # break" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "pN6jV4oNowEl" + }, + "outputs": [], + "source": [ + "# ltp_task['2021']['Shamli'].status()" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 621, + "referenced_widgets": [ + "857ed1d74c924dc38b4216a6b3b3a407", + "efcbaec5b39242ccbf43b53307a91db3", + "2fa90b5240214e6db1a234a6ac9f9d26", + "a3384db27f0e4512b8f801cb446550b8", + "8430b385dd0943cc8515e46374fb5353", + "1a841aa5972647d087d24fd036f4768a", + "c63675a6e3264b4aa1177102b7ee30d7", + "bfaf542bd2514506aa5f675efa7cb807", + "816db69faa5f403782a95534ae89e5b0", + "45e9f57280494b81bedec5a978abd60d", + "3ce4cd936a65472285609d916a781836", + "2ede49326b5c40228d616aab967910e0", + "ceae30f50513407ab97fb3321255b65e", + "a3a057d1ddb0491bbbac924c775080ea", + "23acd2e7dfea4fb7891dd6241fe91626", + "f70085e859494024a296847249f0019d", + "4f56216671684026b4d7dc7131aaeced", + "6ac4b53d3f9a477a8a50b91f3c1a78a1", + "6d191e6e3f2944c89e802cfaf2f4d973", + "11573ba3646148968e821ccd3cc82ee3", + "08df80e9b6e94b60a1f7b30a1e96b6b8", + "0164447f09534c85a9bc9ba3b0a8ced5", + "a0a9213c4e3e464287da4bb6517408e5", + "50efdb138316463cbdb7fcafc36e4f0d", + "d6061d8c11874c50a4379be53a5e3029", + "0f756d3b935c4b14a4aca5d85723c84d", + "d25e876b06b84a57a476a14ce924b60a", + "320be3525f7244e59fa1e32a4cd9bcf3", + "018bc35395594b90912c927e2211212e", + "1e753e5cf2ff4b53ac41e818e9f7bf7b", + "d110ce8e35f140d585e81e88cce4a413", + "060518aa3c224037930316d880e07fc8", + "b9aa9ab9f4044505bdf204d794b88df3", + "9b1e99aba6cb4b7782d78e56ea005d9d", + "c6576ff54bff4e7a9fb410443d3ecc5b", + "4e5bda9157e7494bafcaf8e44f864424", + "e6c172ac8c3f4e4ca08400f839230174", + "b97ac9c1ea2e46e4a0bd07ab88ae012c", + "262c8654c40643898621dfb02718aeb4", + "6907b83887184f18a9811508ec9887f9", + "db63bba9f9c64f4787c328766671edcb", + "5c89fe5b921d45bbbe5594552c80ddd6", + "ac2cd52fca484632832a8ffc460f2e98", + "9b1aa26a079f4cdb933d90202e373ea5", + "7dc38477ca5145948632181e660a72ca", + "3117c8f8dc454974ad725820bbaff1b6", + "f306390cbeee4c17acad2a7faea2bda7", + "bd44dc656406487e96cbe194c6261bd0", + "e9f6bd2414c84f258c433913fe8cf03e", + "f0268b4fa5f8426aab5467c9b24a5fdd", + "c5f72af9d368407dbaf5d9e4d7b91392", + "91e3f52fd7364d319305e5af41f1c82c", + "125e8e3b0ac04f6993b13c51a0380138", + "ea343d9e0bb749998ee948e3b48e087f", + "8038eb785016462b8c9b593921119002", + "fbb8c80ca0784cef9d6139a5fbbb6b46", + "49cfc07507794d6aab0419f0f0f17ef4", + "44d6efda35304e3b82becaa0554cc00a", + "a386181ab2a948f39200afa36498a19e", + "313ce92ad01147d1bd2f3e3491f0fabf", + "b6f2cd884b4140c2b954178b4a3f5ffb", + "8dc8b607f2e64e47a962f0a6a9932837", + "e3c9b813e43e46708e9a73470ba31ce6", + "13e1ae2b10a04cf9a8dfb8bcdfda78b9" + ] + }, + "id": "0SOa06BPnvCw", + "outputId": "28a535c0-56e2-40ae-91da-fcc23a0ce6f4" + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + " \n", + " " + ] + }, + "metadata": {} + }, + { + "output_type": "display_data", + "data": { + "text/plain": [ + "Map(center=[22.02646193620022, 87.77414646078844], controls=(WidgetControl(options=['position', 'transparent_b…" + ], + "application/vnd.jupyter.widget-view+json": { + "version_major": 2, + "version_minor": 0, + "model_id": "857ed1d74c924dc38b4216a6b3b3a407" + } + }, + "metadata": { + "application/vnd.jupyter.widget-view+json": { + "colab": { + "custom_widget_manager": { + "url": "https://ssl.gstatic.com/colaboratory-static/widgets/colab-cdn-widget-manager/2b70e893a8ba7c0f/manager.min.js" + } + } + } + } + } + ], + "source": [ + "Map = geemap.Map(basemap='HYBRID')\n", + "Map.addLayer(tree_cover, {'palette': 'green'}, '25m DW Tree Cover')\n", + "# Map.addLayer(tree_patches, {'palette': 'yellow'}, 'Tree Patches')\n", + "Map.addLayer(ltp_img, {'bands':['large_tree_patch'], 'min': 0, 'max': 1, 'palette': ['FFA500', '007500']}, 'LTP-STP Image')\n", + "Map.centerObject(ltp_img, 15);\n", + "Map" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "uGqE3hCKKan0" + }, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "9ANwJOILKWGn" + }, + "outputs": [], + "source": [] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "857ed1d74c924dc38b4216a6b3b3a407": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletMapModel", + "model_module_version": "^0.20", + "state": { + "_dom_classes": [], + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletMapModel", + "_view_count": null, + "_view_module": "jupyter-leaflet", + "_view_module_version": "^0.20", + "_view_name": "LeafletMapView", + "bottom": 3668220, + "bounce_at_zoom_limits": true, + "box_zoom": true, + "center": [ + 22.02646193620022, + 87.77414646078844 + ], + "close_popup_on_click": true, + "controls": [ + "IPY_MODEL_efcbaec5b39242ccbf43b53307a91db3", + "IPY_MODEL_2fa90b5240214e6db1a234a6ac9f9d26", + "IPY_MODEL_a3384db27f0e4512b8f801cb446550b8", + "IPY_MODEL_8430b385dd0943cc8515e46374fb5353", + "IPY_MODEL_1a841aa5972647d087d24fd036f4768a", + "IPY_MODEL_c63675a6e3264b4aa1177102b7ee30d7", + "IPY_MODEL_bfaf542bd2514506aa5f675efa7cb807", + "IPY_MODEL_816db69faa5f403782a95534ae89e5b0" + ], + "crs": { + "name": "EPSG3857", + "custom": false + }, + "default_style": "IPY_MODEL_45e9f57280494b81bedec5a978abd60d", + "double_click_zoom": true, + "dragging": true, + "dragging_style": "IPY_MODEL_3ce4cd936a65472285609d916a781836", + "east": 87.80651092529298, + "fullscreen": false, + "inertia": true, + "inertia_deceleration": 3000, + "inertia_max_speed": 1500, + "interpolation": "bilinear", + "keyboard": true, + "keyboard_pan_offset": 80, + "keyboard_zoom_offset": 1, + "layers": [ + "IPY_MODEL_2ede49326b5c40228d616aab967910e0", + "IPY_MODEL_ceae30f50513407ab97fb3321255b65e", + "IPY_MODEL_a3a057d1ddb0491bbbac924c775080ea" + ], + "layout": "IPY_MODEL_23acd2e7dfea4fb7891dd6241fe91626", + "left": 6238836, + "max_zoom": 24, + "min_zoom": null, + "modisdate": "2026-07-10", + "north": 22.038389590665307, + "options": [ + "bounce_at_zoom_limits", + "box_zoom", + "center", + "close_popup_on_click", + "double_click_zoom", + "dragging", + "fullscreen", + "inertia", + "inertia_deceleration", + "inertia_max_speed", + "interpolation", + "keyboard", + "keyboard_pan_offset", + "keyboard_zoom_offset", + "max_zoom", + "min_zoom", + "prefer_canvas", + "scroll_wheel_zoom", + "tap", + "tap_tolerance", + "touch_zoom", + "world_copy_jump", + "zoom", + "zoom_animation_threshold", + "zoom_delta", + "zoom_snap" + ], + "panes": {}, + "prefer_canvas": false, + "right": 6240344, + "scroll_wheel_zoom": true, + "south": 22.014519798548438, + "style": "IPY_MODEL_f70085e859494024a296847249f0019d", + "tap": true, + "tap_tolerance": 15, + "top": 3667620, + "touch_zoom": true, + "west": 87.74179458618164, + "window_url": "https://zlji9riebaj-496ff2e9c6d22116-0-colab.googleusercontent.com/outputframe.html?vrz=colab-external_20260707-060055_RC00_943753072", + "world_copy_jump": false, + "zoom": 15, + "zoom_animation_threshold": 4, + "zoom_delta": 1, + "zoom_snap": 1 + } + }, + "efcbaec5b39242ccbf43b53307a91db3": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletWidgetControlModel", + "model_module_version": "^0.20", + "state": { + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletWidgetControlModel", + "_view_count": null, + "_view_module": "jupyter-leaflet", + "_view_module_version": "^0.20", + "_view_name": "LeafletWidgetControlView", + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "options": [ + "position", + "transparent_bg" + ], + "position": "topright", + "transparent_bg": true, + "widget": "IPY_MODEL_4f56216671684026b4d7dc7131aaeced" + } + }, + "2fa90b5240214e6db1a234a6ac9f9d26": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletWidgetControlModel", + "model_module_version": "^0.20", + "state": { + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletWidgetControlModel", + "_view_count": null, + "_view_module": "jupyter-leaflet", + "_view_module_version": "^0.20", + "_view_name": "LeafletWidgetControlView", + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "options": [ + "position", + "transparent_bg" + ], + "position": "topleft", + "transparent_bg": true, + "widget": "IPY_MODEL_6ac4b53d3f9a477a8a50b91f3c1a78a1" + } + }, + "a3384db27f0e4512b8f801cb446550b8": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletZoomControlModel", + "model_module_version": "^0.20", + "state": { + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletZoomControlModel", + "_view_count": null, + "_view_module": "jupyter-leaflet", + "_view_module_version": "^0.20", + "_view_name": "LeafletZoomControlView", + "options": [ + "position", + "zoom_in_text", + "zoom_in_title", + "zoom_out_text", + "zoom_out_title" + ], + "position": "topleft", + "zoom_in_text": "+", + "zoom_in_title": "Zoom in", + "zoom_out_text": "-", + "zoom_out_title": "Zoom out" + } + }, + "8430b385dd0943cc8515e46374fb5353": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletFullScreenControlModel", + "model_module_version": "^0.20", + "state": { + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletFullScreenControlModel", + "_view_count": null, + "_view_module": "jupyter-leaflet", + "_view_module_version": "^0.20", + "_view_name": "LeafletFullScreenControlView", + "options": [ + "position" + ], + "position": "topleft" + } + }, + "1a841aa5972647d087d24fd036f4768a": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletDrawControlModel", + "model_module_version": "^0.20", + "state": { + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletDrawControlModel", + "_view_count": null, + "_view_module": "jupyter-leaflet", + "_view_module_version": "^0.20", + "_view_name": "LeafletDrawControlView", + "circle": {}, + "circlemarker": {}, + "data": [], + "edit": true, + "marker": { + "shapeOptions": { + "color": "#3388ff" + } + }, + "options": [ + "position" + ], + "polygon": { + "shapeOptions": {} + }, + "polyline": { + "shapeOptions": {} + }, + "position": "topleft", + "rectangle": { + "shapeOptions": { + "color": "#3388ff" + } + }, + "remove": true + } + }, + "c63675a6e3264b4aa1177102b7ee30d7": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletScaleControlModel", + "model_module_version": "^0.20", + "state": { + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletScaleControlModel", + "_view_count": null, + "_view_module": "jupyter-leaflet", + "_view_module_version": "^0.20", + "_view_name": "LeafletScaleControlView", + "imperial": true, + "max_width": 100, + "metric": true, + "options": [ + "imperial", + "max_width", + "metric", + "position", + "update_when_idle" + ], + "position": "bottomleft", + "update_when_idle": false + } + }, + "bfaf542bd2514506aa5f675efa7cb807": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletMeasureControlModel", + "model_module_version": "^0.20", + "state": { + "_custom_units": {}, + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletMeasureControlModel", + "_view_count": null, + "_view_module": "jupyter-leaflet", + "_view_module_version": "^0.20", + "_view_name": "LeafletMeasureControlView", + "active_color": "orange", + "capture_z_index": 10000, + "completed_color": "#C8F2BE", + "options": [ + "active_color", + "capture_z_index", + "completed_color", + "popup_options", + "position", + "primary_area_unit", + "primary_length_unit", + "secondary_area_unit", + "secondary_length_unit" + ], + "popup_options": { + "className": "leaflet-measure-resultpopup", + "autoPanPadding": [ + 10, + 10 + ] + }, + "position": "bottomleft", + "primary_area_unit": "acres", + "primary_length_unit": "kilometers", + "secondary_area_unit": null, + "secondary_length_unit": null + } + }, + "816db69faa5f403782a95534ae89e5b0": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletAttributionControlModel", + "model_module_version": "^0.20", + "state": { + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletAttributionControlModel", + "_view_count": null, + "_view_module": "jupyter-leaflet", + "_view_module_version": "^0.20", + "_view_name": "LeafletAttributionControlView", + "options": [ + "position", + "prefix" + ], + "position": "bottomright", + "prefix": "ipyleaflet" + } + }, + "45e9f57280494b81bedec5a978abd60d": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletMapStyleModel", + "model_module_version": "^0.20", + "state": { + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletMapStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "cursor": "grab" + } + }, + "3ce4cd936a65472285609d916a781836": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletMapStyleModel", + "model_module_version": "^0.20", + "state": { + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletMapStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "cursor": "move" + } + }, + "2ede49326b5c40228d616aab967910e0": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletTileLayerModel", + "model_module_version": "^0.20", + "state": { + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletTileLayerModel", + "_view_count": null, + "_view_module": "jupyter-leaflet", + "_view_module_version": "^0.20", + "_view_name": "LeafletTileLayerView", + "attribution": "Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community", + "base": true, + "bottom": true, + "bounds": null, + "detect_retina": false, + "loading": false, + "max_native_zoom": null, + "max_zoom": 30, + "min_native_zoom": null, + "min_zoom": 1, + "name": "Esri.WorldImagery", + "no_wrap": false, + "opacity": 1, + "options": [ + "attribution", + "bounds", + "detect_retina", + "max_native_zoom", + "max_zoom", + "min_native_zoom", + "min_zoom", + "no_wrap", + "pm_ignore", + "tile_size", + "tms", + "zoom_offset" + ], + "pane": "", + "pm_ignore": true, + "popup": null, + "popup_max_height": null, + "popup_max_width": 300, + "popup_min_width": 50, + "show_loading": false, + "snap_ignore": true, + "subitems": [], + "tile_size": 256, + "tms": false, + "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + "visible": true, + "zoom_offset": 0 + } + }, + "ceae30f50513407ab97fb3321255b65e": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletTileLayerModel", + "model_module_version": "^0.20", + "state": { + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletTileLayerModel", + "_view_count": null, + "_view_module": "jupyter-leaflet", + "_view_module_version": "^0.20", + "_view_name": "LeafletTileLayerView", + "attribution": "Google Earth Engine", + "base": false, + "bottom": true, + "bounds": null, + "detect_retina": false, + "loading": false, + "max_native_zoom": null, + "max_zoom": 24, + "min_native_zoom": null, + "min_zoom": 0, + "name": "25m DW Tree Cover", + "no_wrap": false, + "opacity": 1, + "options": [ + "attribution", + "bounds", + "detect_retina", + "max_native_zoom", + "max_zoom", + "min_native_zoom", + "min_zoom", + "no_wrap", + "pm_ignore", + "tile_size", + "tms", + "zoom_offset" + ], + "pane": "", + "pm_ignore": true, + "popup": null, + "popup_max_height": null, + "popup_max_width": 300, + "popup_min_width": 50, + "show_loading": false, + "snap_ignore": true, + "subitems": [], + "tile_size": 256, + "tms": false, + "url": "https://earthengine.googleapis.com/v1/projects/corestack-datasets-alpha/maps/0c7f61c2c8e9a1c1ddd6d497bc868f34-ac075ded404547da832abc6f7332bc4e/tiles/{z}/{x}/{y}", + "visible": true, + "zoom_offset": 0 + } + }, + "a3a057d1ddb0491bbbac924c775080ea": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletTileLayerModel", + "model_module_version": "^0.20", + "state": { + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletTileLayerModel", + "_view_count": null, + "_view_module": "jupyter-leaflet", + "_view_module_version": "^0.20", + "_view_name": "LeafletTileLayerView", + "attribution": "Google Earth Engine", + "base": false, + "bottom": true, + "bounds": null, + "detect_retina": false, + "loading": false, + "max_native_zoom": null, + "max_zoom": 24, + "min_native_zoom": null, + "min_zoom": 0, + "name": "LTP-STP Image", + "no_wrap": false, + "opacity": 1, + "options": [ + "attribution", + "bounds", + "detect_retina", + "max_native_zoom", + "max_zoom", + "min_native_zoom", + "min_zoom", + "no_wrap", + "pm_ignore", + "tile_size", + "tms", + "zoom_offset" + ], + "pane": "", + "pm_ignore": true, + "popup": null, + "popup_max_height": null, + "popup_max_width": 300, + "popup_min_width": 50, + "show_loading": false, + "snap_ignore": true, + "subitems": [], + "tile_size": 256, + "tms": false, + "url": "https://earthengine.googleapis.com/v1/projects/corestack-datasets-alpha/maps/c4b980920fd4c2a0b768d12ccef47b01-af1a034eef46a65aa406908b7a0852e1/tiles/{z}/{x}/{y}", + "visible": true, + "zoom_offset": 0 + } + }, + "23acd2e7dfea4fb7891dd6241fe91626": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": "600px", + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "f70085e859494024a296847249f0019d": { + "model_module": "jupyter-leaflet", + "model_name": "LeafletMapStyleModel", + "model_module_version": "^0.20", + "state": { + "_model_module": "jupyter-leaflet", + "_model_module_version": "^0.20", + "_model_name": "LeafletMapStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "cursor": "grab" + } + }, + "4f56216671684026b4d7dc7131aaeced": { + "model_module": "@jupyter-widgets/controls", + "model_name": "GridBoxModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "GridBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "GridBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_6d191e6e3f2944c89e802cfaf2f4d973", + "IPY_MODEL_11573ba3646148968e821ccd3cc82ee3" + ], + "layout": "IPY_MODEL_08df80e9b6e94b60a1f7b30a1e96b6b8" + } + }, + "6ac4b53d3f9a477a8a50b91f3c1a78a1": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.map_widgets.SearchBar", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e7, o5) {\n if (this._$cssResult$ = true, o5 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e7;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e7 = void 0 !== s2 && 1 === s2.length;\n e7 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e7 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e7) => {\n const o5 = 1 === t3.length ? t3[0] : e7.reduce((e8, s2, o6) => e8 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o6 + 1], t3[0]);\n return new n(o5, t3, s);\n};\nvar S = (s2, o5) => {\n if (e) s2.adoptedStyleSheets = o5.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e7 of o5) {\n const o6 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o6.setAttribute(\"nonce\", n5), o6.textContent = e7.cssText, s2.appendChild(o6);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e7 = \"\";\n for (const s2 of t4.cssRules) e7 += s2.cssText;\n return r(e7);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r5 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r5 && e2(this.prototype, t3, r5);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e7, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e7?.call(this);\n }, set(s3) {\n const r5 = e7?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r5, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e7 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e7) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e7 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e7 && true === i4.reflect) {\n const r5 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r5 ? this.removeAttribute(e7) : this.setAttribute(e7, r5), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e7 = i4._$Eh.get(t3);\n if (void 0 !== e7 && this._$Em !== e7) {\n const t4 = i4.getPropertyOptions(e7), r5 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e7, this[e7] = r5.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e7 = [];\n let h4, o5 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r5, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r5 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o5 += n5 === T ? s3 + _ : c4 >= 0 ? (e7.push(r5), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o5 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e7];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e7;\n this.parts = [];\n let h4 = 0, o5 = 0;\n const n5 = t3.length - 1, r5 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e7 = I.nextNode()) && r5.length < n5; ) {\n if (1 === e7.nodeType) {\n if (e7.hasAttributes()) for (const t4 of e7.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o5++], s3 = e7.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r5.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e7.removeAttribute(t4);\n } else t4.startsWith(v) && (r5.push({ type: 6, index: h4 }), e7.removeAttribute(t4));\n if (M.test(e7.tagName)) {\n const t4 = e7.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e7.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e7.append(t4[s3], lt()), I.nextNode(), r5.push({ type: 2, index: ++h4 });\n e7.append(t4[i5], lt());\n }\n }\n } else if (8 === e7.nodeType) if (e7.data === m) r5.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e7.data.indexOf(v, t4 + 1)); ) r5.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e7) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e7 ? s2.o?.[e7] : s2.l;\n const o5 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o5 && (h4?._$AO?.(false), void 0 === o5 ? h4 = void 0 : (h4 = new o5(t3), h4._$AT(t3, s2, e7)), void 0 !== e7 ? (s2.o ??= [])[e7] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e7)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e7 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e7;\n let h4 = I.nextNode(), o5 = 0, n5 = 0, r5 = s2[0];\n for (; void 0 !== r5; ) {\n if (o5 === r5.index) {\n let i5;\n 2 === r5.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r5.type ? i5 = new r5.ctor(h4, r5.name, r5.strings, this, t3) : 6 === r5.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r5 = s2[++n5];\n }\n o5 !== r5?.index && (h4 = I.nextNode(), o5++);\n }\n return I.currentNode = w, e7;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e7) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e7, this.v = e7?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e7 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e7) this._$AH.p(i4);\n else {\n const t4 = new F(e7, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e7 = 0;\n for (const h4 of t3) e7 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e7], s2._$AI(h4), e7++;\n e7 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e7), i4.length = e7);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e7, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e7, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e7) {\n const h4 = this.strings;\n let o5 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o5 = !st(t3) || t3 !== this._$AH && t3 !== R, o5 && (this._$AH = t3);\n else {\n const e8 = t3;\n let n5, r5;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r5 = z(this, e8[s2 + n5], i4, n5), r5 === R && (r5 = this._$AH[n5]), o5 ||= !st(r5) || r5 !== this._$AH[n5], r5 === D ? t3 = D : t3 !== D && (t3 += (r5 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r5;\n }\n o5 && !e7 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e7, h4) {\n super(t3, i4, s2, e7, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e7 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e7);\n e7 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e7 = s2?.renderBefore ?? i4;\n let h4 = e7._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e7._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e7 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e7, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e7, r5) => {\n const { kind: n5, metadata: i4 } = r5;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r5.name, t3), \"accessor\" === n5) {\n const { name: o5 } = r5;\n return { set(r6) {\n const n6 = e7.get.call(this);\n e7.set.call(this, r6), this.requestUpdate(o5, n6, t3);\n }, init(e8) {\n return void 0 !== e8 && this.P(o5, void 0, t3), e8;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o5 } = r5;\n return function(r6) {\n const n6 = this[o5];\n e7.call(this, r6), this.requestUpdate(o5, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e7, o5) => \"object\" == typeof o5 ? r3(t3, e7, o5) : ((t4, e8, o6) => {\n const r5 = e8.hasOwnProperty(o6);\n return e8.constructor.createProperty(o6, r5 ? { ...t4, wrapped: true } : t4), r5 ? Object.getOwnPropertyDescriptor(e8, o6) : void 0;\n })(t3, e7, o5);\n}\n\n// node_modules/@lit/reactive-element/decorators/base.js\nvar e3 = (e7, t3, c4) => (c4.configurable = true, c4.enumerable = true, Reflect.decorate && \"object\" != typeof t3 && Object.defineProperty(e7, t3, c4), c4);\n\n// node_modules/@lit/reactive-element/decorators/query.js\nfunction e4(e7, r5) {\n return (n5, s2, i4) => {\n const o5 = (t3) => t3.renderRoot?.querySelector(e7) ?? null;\n if (r5) {\n const { get: e8, set: r6 } = \"object\" == typeof s2 ? n5 : i4 ?? (() => {\n const t3 = Symbol();\n return { get() {\n return this[t3];\n }, set(e9) {\n this[t3] = e9;\n } };\n })();\n return e3(n5, s2, { get() {\n let t3 = e8.call(this);\n return void 0 === t3 && (t3 = o5(this), (null !== t3 || this.hasUpdated) && r6.call(this, t3)), t3;\n } });\n }\n return e3(n5, s2, { get() {\n return o5(this);\n } });\n };\n}\n\n// node_modules/@lit/reactive-element/decorators/query-all.js\nvar e5;\nfunction r4(r5) {\n return (n5, o5) => e3(n5, o5, { get() {\n return (this.renderRoot ?? (e5 ??= document.createDocumentFragment())).querySelectorAll(r5);\n } });\n}\n\n// node_modules/@lit/reactive-element/decorators/query-assigned-elements.js\nfunction o4(o5) {\n return (e7, n5) => {\n const { slot: r5, selector: s2 } = o5 ?? {}, c4 = \"slot\" + (r5 ? `[name=${r5}]` : \":not([name])\");\n return e3(e7, n5, { get() {\n const t3 = this.renderRoot?.querySelector(c4), e8 = t3?.assignedElements(o5) ?? [];\n return void 0 === s2 ? e8 : e8.filter((t4) => t4.matches(s2));\n } });\n };\n}\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e6 = (t3) => (...e7) => ({ _$litDirective$: t3, values: e7 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e7, i4) {\n this.t = t3, this._$AM = e7, this.i = i4;\n }\n _$AS(t3, e7) {\n return this.update(t3, e7);\n }\n update(t3, e7) {\n return this.render(...e7);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e6(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r5 = !!s2[t4];\n r5 === this.st.has(t4) || this.nt?.has(t4) || (r5 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// node_modules/lit-html/directives/style-map.js\nvar ee = \"important\";\nvar ie = \" !\" + ee;\nvar se = e6(class extends i3 {\n constructor(e7) {\n if (super(e7), e7.type !== t2.ATTRIBUTE || \"style\" !== e7.name || e7.strings?.length > 2) throw Error(\"The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return Object.keys(t3).reduce((e7, r5) => {\n const s2 = t3[r5];\n return null == s2 ? e7 : e7 + `${r5 = r5.includes(\"-\") ? r5 : r5.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g, \"-$&\").toLowerCase()}:${s2};`;\n }, \"\");\n }\n update(t3, [e7]) {\n const { style: r5 } = t3.element;\n if (void 0 === this.ft) return this.ft = new Set(Object.keys(e7)), this.render(e7);\n for (const t4 of this.ft) null == e7[t4] && (this.ft.delete(t4), t4.includes(\"-\") ? r5.removeProperty(t4) : r5[t4] = null);\n for (const t4 in e7) {\n const s2 = e7[t4];\n if (null != s2) {\n this.ft.add(t4);\n const e8 = \"string\" == typeof s2 && s2.endsWith(ie);\n t4.includes(\"-\") || e8 ? r5.setProperty(t4, e8 ? s2.slice(0, -11) : s2, e8 ? ee : \"\") : r5[t4] = s2;\n }\n }\n return R;\n }\n});\n\n// js/tab_panel.ts\nfunction convertToId(name) {\n return (name || \"\").trim().replace(\" \", \"-\").toLowerCase();\n}\nvar TabPanel = class extends h3 {\n constructor() {\n super(...arguments);\n this.tabs = [];\n this.index = 0;\n this.mode = 1 /* HIDE_ON_SECOND_CLICK */;\n this.alignment = \"left\" /* LEFT */;\n }\n static get componentName() {\n return `tab-panel`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n ::slotted(*) {\n display: none;\n }\n\n ::slotted(.show-tab) {\n display: block;\n }\n\n .container {\n padding: 0;\n width: 100%;\n }\n\n .tab-container {\n align-items: center;\n display: flex;\n flex-direction: row;\n }\n\n .tab-container.center {\n justify-content: center;\n }\n\n .tab-container.left {\n justify-content: flex-start;\n }\n\n .tab-container.right {\n justify-content: flex-end;\n }\n\n .tab-container button {\n border-radius: 5px;\n height: 28px;\n margin: 2px 0 2px 8px;\n user-select: none;\n }\n\n .tab-container button.icon {\n font-size: 16px;\n width: 28px;\n }\n\n .tab-container button.name {\n padding: 0 8px;\n }\n `\n ];\n }\n render() {\n return ke`\n
\n \n ${this.renderTabs()}\n
\n \n \n `;\n }\n update(changedProperties) {\n super.update(changedProperties);\n if (changedProperties.has(\"index\") && changedProperties.get(\"index\") != null) {\n this.updateSlotChildren();\n }\n }\n updateSlotChildren() {\n if (!this.tabContentElements) {\n return;\n }\n this.tabContentElements.forEach((element, i4) => {\n element.classList.remove(\"show-tab\");\n const id = convertToId(this.tabs[i4].name);\n element.setAttribute(\"id\", `tabpanel-${id}-${i4}`);\n element.setAttribute(\"role\", \"tabpanel\");\n element.setAttribute(\"aria-labelledby\", `tab-${id}-${i4}`);\n });\n this.tabContentElements[this.index]?.classList.add(\"show-tab\");\n }\n renderTabs() {\n return this.tabs.map((tab, i4) => {\n const id = convertToId(this.tabs[i4].name);\n return ke` {\n this.onTabClick(i4);\n }}>\n ${tab.icon ? ke`${tab.icon}` : D}\n ${tab.name}\n `;\n });\n }\n onTabClick(index) {\n switch (this.mode) {\n case 1 /* HIDE_ON_SECOND_CLICK */:\n this.index = this.index === index ? -1 : index;\n break;\n case 0 /* ALWAYS_SHOW */:\n default:\n this.index = index;\n }\n this.dispatchEvent(new CustomEvent(\"tab-changed\", {\n detail: index\n }));\n }\n};\n__decorateClass([\n n4({ type: Array })\n], TabPanel.prototype, \"tabs\", 2);\n__decorateClass([\n n4({ type: Number })\n], TabPanel.prototype, \"index\", 2);\n__decorateClass([\n n4({ type: Number })\n], TabPanel.prototype, \"mode\", 2);\n__decorateClass([\n n4({ type: String })\n], TabPanel.prototype, \"alignment\", 2);\n__decorateClass([\n r4(\".tab\")\n], TabPanel.prototype, \"tabElements\", 2);\n__decorateClass([\n o4()\n], TabPanel.prototype, \"tabContentElements\", 2);\nif (!customElements.get(TabPanel.componentName)) {\n customElements.define(TabPanel.componentName, TabPanel);\n}\n\n// node_modules/lit-html/directives/unsafe-html.js\nvar le = class extends i3 {\n constructor(i4) {\n if (super(i4), this.it = D, i4.type !== t2.CHILD) throw Error(this.constructor.directiveName + \"() can only be used in child bindings\");\n }\n render(t3) {\n if (t3 === D || null == t3) return this._t = void 0, this.it = t3;\n if (t3 === R) return t3;\n if (\"string\" != typeof t3) throw Error(this.constructor.directiveName + \"() called with a non-string value\");\n if (t3 === this.it) return this._t;\n this.it = t3;\n const i4 = [t3];\n return i4.raw = i4, this._t = { _$litType$: this.constructor.resultType, strings: i4, values: [] };\n }\n};\nle.directiveName = \"unsafeHTML\", le.resultType = 1;\nvar ae = e6(le);\n\n// js/container.ts\nvar Container = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.icon = \"\";\n this.title = \"\";\n this.collapsed = false;\n this.hideCloseButton = false;\n this.compactMode = false;\n this.noHeader = false;\n this.reverseHeader = false;\n }\n static get componentName() {\n return `widget-container`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n .container {\n background: var(--jp-layout-color1);\n border-radius: 4.5px;\n box-shadow: 4px 5px 8px 0px #9e9e9e;\n }\n\n div {\n background-color: var(--colab-primary-surface-color, --jp-layout-color1, white);\n }\n\n .header {\n display: flex;\n gap: 4px;\n padding: 4px;\n }\n\n .reversed {\n flex-direction: row-reverse;\n }\n\n .icon {\n align-items: center;\n display: flex;\n font-size: 20px;\n height: 28px;\n justify-content: center;\n padding: 0 4px;\n }\n\n .widget-container {\n padding: 8px 12px 12px 12px;\n }\n\n .hidden {\n display: none;\n }\n\n .header-button {\n font-size: 16px;\n height: 28px;\n width: 28px;\n }\n\n .compact-header-button {\n background: transparent;\n font-size: 16px;\n height: 28px;\n width: 28px;\n }\n\n .header-text {\n align-content: center;\n flex-grow: 1;\n padding: 0 12px 0 0;\n }\n\n .left-padding {\n padding-left: 8px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"icon\", \"icon\"],\n [\"collapsed\", \"collapsed\"],\n [\"title\", \"title\"],\n [\"hide_close_button\", \"hideCloseButton\"]\n ]);\n }\n render() {\n return ke`\n
\n ${this.noHeader ? D : this.renderHeader()}\n
\n \n
\n
\n `;\n }\n renderHeader() {\n return this.compactMode ? this.renderCompactHeader() : ke`\n
\n ${this.renderIcon()}\n ${this.title ? this.renderTitle() : D}\n ${this.renderCollapseButton()}\n ${this.renderCloseButton()}\n
`;\n }\n renderCompactHeader() {\n return ke`
\n ${this.renderCollapseButton()}\n ${this.title && !this.collapsed ? this.renderTitle() : D}\n ${this.renderCloseButton()}\n
`;\n }\n renderCloseButton() {\n if (this.hideCloseButton) {\n return D;\n }\n return ke`\n \n \n \n `;\n }\n renderTitle() {\n return ke`${this.title}`;\n }\n onCloseButtonClicked() {\n this.dispatchEvent(new CustomEvent(\"close-clicked\", {}));\n }\n onCollapseToggled() {\n this.collapsed = !this.collapsed;\n this.dispatchEvent(new CustomEvent(\"collapse-clicked\", {}));\n }\n renderIcon() {\n return ke`\n ${this.icon}\n `;\n }\n renderCollapseButton() {\n let icon;\n if (this.compactMode) {\n icon = this.renderIcon();\n } else if (this.collapsed) {\n icon = ke``;\n } else {\n icon = ke``;\n }\n return ke`\n ${icon}\n `;\n }\n};\n__decorateClass([\n n4({ type: String })\n], Container.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], Container.prototype, \"title\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"collapsed\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"hideCloseButton\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"compactMode\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"noHeader\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"reverseHeader\", 2);\nif (!customElements.get(Container.componentName)) {\n customElements.define(Container.componentName, Container);\n}\n\n// js/search_bar.ts\nvar SearchBar = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.collapsed = true;\n this.tab_index = 0;\n this.locationModel = JSON.stringify({\n search: \"\",\n results: [],\n selected: \"\",\n additional_html: \"\"\n });\n this.datasetModel = JSON.stringify({\n search: \"\",\n results: [],\n selected: \"\",\n additional_html: \"\"\n });\n }\n static get componentName() {\n return `search-bar`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n .row {\n display: flex;\n gap: 6px;\n }\n\n .input-container {\n max-width: 320px;\n }\n\n .input-container > p {\n margin: 8px 3px;\n }\n\n input.search {\n margin: 2px 2px 8px 2px;\n width: calc(100% - 4px);\n }\n\n ul.results {\n list-style-type: none;\n margin: 0;\n margin-bottom: 4px;\n padding: 8px 0;\n }\n\n label.result {\n align-items: center;\n display: flex;\n margin-bottom: 4px;\n }\n\n .import-button, .reset-button {\n margin: 0 2px 2px 2px;\n padding: 0 8px;\n white-space: nowrap;\n }\n\n .dataset-select {\n margin-bottom: 2px;\n margin-right: 2px;\n }\n\n .additional-html-container {\n max-height: 300px;\n overflow: auto;\n padding: 8px 0;\n }\n\n .additional-html-container pre {\n white-space: break-spaces;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"collapsed\", \"collapsed\"],\n [\"tab_index\", \"tab_index\"],\n [\"location_model\", \"locationModel\"],\n [\"dataset_model\", \"datasetModel\"]\n ]);\n }\n render() {\n return ke`\n \n {\n this.tab_index = e7.detail;\n }}\n .mode=\"${0 /* ALWAYS_SHOW */}\">\n
\n ${this.renderLocationSearch()}\n
\n
\n ${this.renderDatasetSearch()}\n
\n \n `;\n }\n renderLocationSearch() {\n const locationModel = JSON.parse(this.locationModel);\n const helpText = ke`

\n Find your point of interest (by place name,\n address, or coordinates, e.g. 40,-100)\n

`;\n const searchInput = ke` {\n if (e7.key === \"Enter\") {\n e7.preventDefault();\n const locationModel2 = JSON.parse(this.locationModel);\n locationModel2.search = this.locationSearch.value || \"\";\n this.locationModel = JSON.stringify(locationModel2);\n }\n }}\" />`;\n const renderedInputs = [helpText, searchInput];\n if (locationModel.results.length) {\n const results = ke`\n ${locationModel.results.map((result) => ke`\n
  • \n \n
  • `)}\n `;\n renderedInputs.push(ke`
      \n ${results}\n
    `);\n }\n if (locationModel.additional_html) {\n renderedInputs.push(ke`
    \n ${ae(locationModel.additional_html)}\n
    `);\n }\n if (locationModel.search || locationModel.results.length || locationModel.selected) {\n renderedInputs.push(ke` {\n this.locationModel = JSON.stringify({\n search: \"\",\n results: [],\n selected: \"\",\n additional_html: \"\"\n });\n if (this.locationSearch) {\n this.locationSearch.value = \"\";\n }\n }}\">Reset`);\n }\n return renderedInputs;\n }\n renderDatasetSearch() {\n const datasetModel = JSON.parse(this.datasetModel);\n const helpText = ke`

    \n Find a dataset by GEE data catalog name or keywords, e.g. elevation\n

    `;\n const searchInput = ke` {\n if (e7.key === \"Enter\") {\n e7.preventDefault();\n const datasetModel2 = JSON.parse(this.datasetModel);\n datasetModel2.search = this.datasetSearch?.value || \"\";\n this.datasetModel = JSON.stringify(datasetModel2);\n }\n }}\" />`;\n const renderedInputs = [helpText, searchInput];\n const importButton = ke` {\n this.model?.send({ type: \"click\", id: \"import\" });\n }}\">\n Reveal Code\n `;\n const results = ke`\n {\n const input = e7.target;\n const datasetModel2 = JSON.parse(this.datasetModel);\n datasetModel2.selected = input.value || \"\";\n this.datasetModel = JSON.stringify(datasetModel2);\n }}\">\n ${datasetModel.results.map((result) => ke`\n \n `)}\n \n `;\n renderedInputs.push(\n ke`
    \n ${importButton}\n ${results}\n
    `\n );\n if (datasetModel.additional_html) {\n renderedInputs.push(ke`
    \n ${ae(datasetModel.additional_html)}\n
    `);\n }\n return renderedInputs;\n }\n};\n__decorateClass([\n n4()\n], SearchBar.prototype, \"collapsed\", 2);\n__decorateClass([\n n4()\n], SearchBar.prototype, \"tab_index\", 2);\n__decorateClass([\n n4()\n], SearchBar.prototype, \"locationModel\", 2);\n__decorateClass([\n n4()\n], SearchBar.prototype, \"datasetModel\", 2);\n__decorateClass([\n e4(\".location-search\")\n], SearchBar.prototype, \"locationSearch\", 2);\n__decorateClass([\n r4(\".location-results input\")\n], SearchBar.prototype, \"locationResults\", 2);\n__decorateClass([\n e4(\".dataset-search\")\n], SearchBar.prototype, \"datasetSearch\", 2);\nif (!customElements.get(SearchBar.componentName)) {\n customElements.define(SearchBar.componentName, SearchBar);\n}\nasync function render({ model, el }) {\n loadFonts();\n const row = document.createElement(SearchBar.componentName);\n row.model = model;\n el.appendChild(row);\n}\nvar search_bar_default = { render };\nexport {\n SearchBar,\n search_bar_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/style-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/unsafe-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "collapsed": true, + "dataset_model": "{\"search\": \"\", \"results\": [], \"selected\": \"\", \"additional_html\": \"\"}", + "layout": "IPY_MODEL_0164447f09534c85a9bc9ba3b0a8ced5", + "location_model": "{\"search\": \"\", \"results\": [], \"selected\": \"\", \"additional_html\": \"\"}", + "tab_index": 0 + } + }, + "6d191e6e3f2944c89e802cfaf2f4d973": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.map_widgets.LayerManager", + "_dom_classes": [], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nasync function unpackModels(modelIds, manager) {\n return Promise.all(\n modelIds.map((id) => manager.get_model(id.slice(\"IPY_MODEL_\".length)))\n );\n}\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nasync function updateChildren(container, model, property = \"children\") {\n let children = model.get(property);\n if (!Array.isArray(children)) {\n children = [children];\n }\n const child_models = await unpackModels(children, model.widget_manager);\n const child_views = await Promise.all(\n child_models.map((model2) => model2.widget_manager.create_view(model2))\n );\n container.innerHTML = ``;\n for (const child_view of child_views) {\n container.appendChild(child_view.el);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/container.ts\nvar Container = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.icon = \"\";\n this.title = \"\";\n this.collapsed = false;\n this.hideCloseButton = false;\n this.compactMode = false;\n this.noHeader = false;\n this.reverseHeader = false;\n }\n static get componentName() {\n return `widget-container`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n .container {\n background: var(--jp-layout-color1);\n border-radius: 4.5px;\n box-shadow: 4px 5px 8px 0px #9e9e9e;\n }\n\n div {\n background-color: var(--colab-primary-surface-color, --jp-layout-color1, white);\n }\n\n .header {\n display: flex;\n gap: 4px;\n padding: 4px;\n }\n\n .reversed {\n flex-direction: row-reverse;\n }\n\n .icon {\n align-items: center;\n display: flex;\n font-size: 20px;\n height: 28px;\n justify-content: center;\n padding: 0 4px;\n }\n\n .widget-container {\n padding: 8px 12px 12px 12px;\n }\n\n .hidden {\n display: none;\n }\n\n .header-button {\n font-size: 16px;\n height: 28px;\n width: 28px;\n }\n\n .compact-header-button {\n background: transparent;\n font-size: 16px;\n height: 28px;\n width: 28px;\n }\n\n .header-text {\n align-content: center;\n flex-grow: 1;\n padding: 0 12px 0 0;\n }\n\n .left-padding {\n padding-left: 8px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"icon\", \"icon\"],\n [\"collapsed\", \"collapsed\"],\n [\"title\", \"title\"],\n [\"hide_close_button\", \"hideCloseButton\"]\n ]);\n }\n render() {\n return ke`\n
    \n ${this.noHeader ? D : this.renderHeader()}\n
    \n \n
    \n
    \n `;\n }\n renderHeader() {\n return this.compactMode ? this.renderCompactHeader() : ke`\n
    \n ${this.renderIcon()}\n ${this.title ? this.renderTitle() : D}\n ${this.renderCollapseButton()}\n ${this.renderCloseButton()}\n
    `;\n }\n renderCompactHeader() {\n return ke`
    \n ${this.renderCollapseButton()}\n ${this.title && !this.collapsed ? this.renderTitle() : D}\n ${this.renderCloseButton()}\n
    `;\n }\n renderCloseButton() {\n if (this.hideCloseButton) {\n return D;\n }\n return ke`\n \n \n \n `;\n }\n renderTitle() {\n return ke`${this.title}`;\n }\n onCloseButtonClicked() {\n this.dispatchEvent(new CustomEvent(\"close-clicked\", {}));\n }\n onCollapseToggled() {\n this.collapsed = !this.collapsed;\n this.dispatchEvent(new CustomEvent(\"collapse-clicked\", {}));\n }\n renderIcon() {\n return ke`\n ${this.icon}\n `;\n }\n renderCollapseButton() {\n let icon;\n if (this.compactMode) {\n icon = this.renderIcon();\n } else if (this.collapsed) {\n icon = ke``;\n } else {\n icon = ke``;\n }\n return ke`\n ${icon}\n `;\n }\n};\n__decorateClass([\n n4({ type: String })\n], Container.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], Container.prototype, \"title\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"collapsed\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"hideCloseButton\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"compactMode\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"noHeader\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"reverseHeader\", 2);\nif (!customElements.get(Container.componentName)) {\n customElements.define(Container.componentName, Container);\n}\n\n// js/layer_manager.ts\nvar LayerManager = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.visible = false;\n this.tabIndex = 0;\n this.collapsed = true;\n }\n static get componentName() {\n return `layer-manager`;\n }\n static {\n this.styles = [\n legacyStyles,\n i`\n .row {\n align-items: center;\n display: flex;\n gap: 4px;\n height: 28px;\n }\n\n .visibility-checkbox {\n margin: 2px;\n }\n\n .layer-manager-rows {\n display: flex;\n flex-direction: column;\n gap: 10px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"children\", null],\n [\"visible\", \"visible\"]\n ]);\n }\n render() {\n return ke`\n \n
    \n
    \n
    \n \n \n All layers on/off\n
    \n \n
    \n
    \n \n `;\n }\n onLayerVisibilityChanged(_event) {\n this.visible = !this.visible;\n }\n};\n__decorateClass([\n n4()\n], LayerManager.prototype, \"visible\", 2);\n__decorateClass([\n n4()\n], LayerManager.prototype, \"tabIndex\", 2);\n__decorateClass([\n n4()\n], LayerManager.prototype, \"collapsed\", 2);\nif (!customElements.get(LayerManager.componentName)) {\n customElements.define(LayerManager.componentName, LayerManager);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(LayerManager.componentName);\n manager.model = model;\n el.appendChild(manager);\n updateChildren(manager, model);\n model.on(\"change:children\", () => {\n updateChildren(manager, model);\n });\n}\nvar layer_manager_default = { render };\nexport {\n LayerManager,\n layer_manager_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "children": [ + "IPY_MODEL_d6061d8c11874c50a4379be53a5e3029", + "IPY_MODEL_0f756d3b935c4b14a4aca5d85723c84d", + "IPY_MODEL_d25e876b06b84a57a476a14ce924b60a" + ], + "layout": "IPY_MODEL_a0a9213c4e3e464287da4bb6517408e5", + "visible": true + } + }, + "11573ba3646148968e821ccd3cc82ee3": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.Toolbar", + "_dom_classes": [], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e6, o5) {\n if (this._$cssResult$ = true, o5 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e6;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e6 = void 0 !== s2 && 1 === s2.length;\n e6 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e6 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e6) => {\n const o5 = 1 === t3.length ? t3[0] : e6.reduce((e7, s2, o6) => e7 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o6 + 1], t3[0]);\n return new n(o5, t3, s);\n};\nvar S = (s2, o5) => {\n if (e) s2.adoptedStyleSheets = o5.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e6 of o5) {\n const o6 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o6.setAttribute(\"nonce\", n5), o6.textContent = e6.cssText, s2.appendChild(o6);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e6 = \"\";\n for (const s2 of t4.cssRules) e6 += s2.cssText;\n return r(e6);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r5 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r5 && e2(this.prototype, t3, r5);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e6, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e6?.call(this);\n }, set(s3) {\n const r5 = e6?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r5, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e6 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e6) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e6 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e6 && true === i4.reflect) {\n const r5 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r5 ? this.removeAttribute(e6) : this.setAttribute(e6, r5), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e6 = i4._$Eh.get(t3);\n if (void 0 !== e6 && this._$Em !== e6) {\n const t4 = i4.getPropertyOptions(e6), r5 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e6, this[e6] = r5.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e6 = [];\n let h4, o5 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r5, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r5 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o5 += n5 === T ? s3 + _ : c4 >= 0 ? (e6.push(r5), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o5 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e6];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e6;\n this.parts = [];\n let h4 = 0, o5 = 0;\n const n5 = t3.length - 1, r5 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e6 = I.nextNode()) && r5.length < n5; ) {\n if (1 === e6.nodeType) {\n if (e6.hasAttributes()) for (const t4 of e6.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o5++], s3 = e6.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r5.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e6.removeAttribute(t4);\n } else t4.startsWith(v) && (r5.push({ type: 6, index: h4 }), e6.removeAttribute(t4));\n if (M.test(e6.tagName)) {\n const t4 = e6.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e6.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e6.append(t4[s3], lt()), I.nextNode(), r5.push({ type: 2, index: ++h4 });\n e6.append(t4[i5], lt());\n }\n }\n } else if (8 === e6.nodeType) if (e6.data === m) r5.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e6.data.indexOf(v, t4 + 1)); ) r5.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e6) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e6 ? s2.o?.[e6] : s2.l;\n const o5 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o5 && (h4?._$AO?.(false), void 0 === o5 ? h4 = void 0 : (h4 = new o5(t3), h4._$AT(t3, s2, e6)), void 0 !== e6 ? (s2.o ??= [])[e6] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e6)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e6 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e6;\n let h4 = I.nextNode(), o5 = 0, n5 = 0, r5 = s2[0];\n for (; void 0 !== r5; ) {\n if (o5 === r5.index) {\n let i5;\n 2 === r5.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r5.type ? i5 = new r5.ctor(h4, r5.name, r5.strings, this, t3) : 6 === r5.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r5 = s2[++n5];\n }\n o5 !== r5?.index && (h4 = I.nextNode(), o5++);\n }\n return I.currentNode = w, e6;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e6) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e6, this.v = e6?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e6 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e6) this._$AH.p(i4);\n else {\n const t4 = new F(e6, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e6 = 0;\n for (const h4 of t3) e6 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e6], s2._$AI(h4), e6++;\n e6 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e6), i4.length = e6);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e6, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e6, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e6) {\n const h4 = this.strings;\n let o5 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o5 = !st(t3) || t3 !== this._$AH && t3 !== R, o5 && (this._$AH = t3);\n else {\n const e7 = t3;\n let n5, r5;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r5 = z(this, e7[s2 + n5], i4, n5), r5 === R && (r5 = this._$AH[n5]), o5 ||= !st(r5) || r5 !== this._$AH[n5], r5 === D ? t3 = D : t3 !== D && (t3 += (r5 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r5;\n }\n o5 && !e6 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e6, h4) {\n super(t3, i4, s2, e6, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e6 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e6);\n e6 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e6 = s2?.renderBefore ?? i4;\n let h4 = e6._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e6._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e6 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e6, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e6, r5) => {\n const { kind: n5, metadata: i4 } = r5;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r5.name, t3), \"accessor\" === n5) {\n const { name: o5 } = r5;\n return { set(r6) {\n const n6 = e6.get.call(this);\n e6.set.call(this, r6), this.requestUpdate(o5, n6, t3);\n }, init(e7) {\n return void 0 !== e7 && this.P(o5, void 0, t3), e7;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o5 } = r5;\n return function(r6) {\n const n6 = this[o5];\n e6.call(this, r6), this.requestUpdate(o5, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e6, o5) => \"object\" == typeof o5 ? r3(t3, e6, o5) : ((t4, e7, o6) => {\n const r5 = e7.hasOwnProperty(o6);\n return e7.constructor.createProperty(o6, r5 ? { ...t4, wrapped: true } : t4), r5 ? Object.getOwnPropertyDescriptor(e7, o6) : void 0;\n })(t3, e6, o5);\n}\n\n// node_modules/@lit/reactive-element/decorators/base.js\nvar e3 = (e6, t3, c4) => (c4.configurable = true, c4.enumerable = true, Reflect.decorate && \"object\" != typeof t3 && Object.defineProperty(e6, t3, c4), c4);\n\n// node_modules/@lit/reactive-element/decorators/query-all.js\nvar e4;\nfunction r4(r5) {\n return (n5, o5) => e3(n5, o5, { get() {\n return (this.renderRoot ?? (e4 ??= document.createDocumentFragment())).querySelectorAll(r5);\n } });\n}\n\n// node_modules/@lit/reactive-element/decorators/query-assigned-elements.js\nfunction o4(o5) {\n return (e6, n5) => {\n const { slot: r5, selector: s2 } = o5 ?? {}, c4 = \"slot\" + (r5 ? `[name=${r5}]` : \":not([name])\");\n return e3(e6, n5, { get() {\n const t3 = this.renderRoot?.querySelector(c4), e7 = t3?.assignedElements(o5) ?? [];\n return void 0 === s2 ? e7 : e7.filter((t4) => t4.matches(s2));\n } });\n };\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e5 = (t3) => (...e6) => ({ _$litDirective$: t3, values: e6 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e6, i4) {\n this.t = t3, this._$AM = e6, this.i = i4;\n }\n _$AS(t3, e6) {\n return this.update(t3, e6);\n }\n update(t3, e6) {\n return this.render(...e6);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e5(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r5 = !!s2[t4];\n r5 === this.st.has(t4) || this.nt?.has(t4) || (r5 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nasync function unpackModels(modelIds, manager) {\n return Promise.all(\n modelIds.map((id) => manager.get_model(id.slice(\"IPY_MODEL_\".length)))\n );\n}\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nasync function updateChildren(container, model, property = \"children\") {\n let children = model.get(property);\n if (!Array.isArray(children)) {\n children = [children];\n }\n const child_models = await unpackModels(children, model.widget_manager);\n const child_views = await Promise.all(\n child_models.map((model2) => model2.widget_manager.create_view(model2))\n );\n container.innerHTML = ``;\n for (const child_view of child_views) {\n container.appendChild(child_view.el);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/container.ts\nvar Container = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.icon = \"\";\n this.title = \"\";\n this.collapsed = false;\n this.hideCloseButton = false;\n this.compactMode = false;\n this.noHeader = false;\n this.reverseHeader = false;\n }\n static get componentName() {\n return `widget-container`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n .container {\n background: var(--jp-layout-color1);\n border-radius: 4.5px;\n box-shadow: 4px 5px 8px 0px #9e9e9e;\n }\n\n div {\n background-color: var(--colab-primary-surface-color, --jp-layout-color1, white);\n }\n\n .header {\n display: flex;\n gap: 4px;\n padding: 4px;\n }\n\n .reversed {\n flex-direction: row-reverse;\n }\n\n .icon {\n align-items: center;\n display: flex;\n font-size: 20px;\n height: 28px;\n justify-content: center;\n padding: 0 4px;\n }\n\n .widget-container {\n padding: 8px 12px 12px 12px;\n }\n\n .hidden {\n display: none;\n }\n\n .header-button {\n font-size: 16px;\n height: 28px;\n width: 28px;\n }\n\n .compact-header-button {\n background: transparent;\n font-size: 16px;\n height: 28px;\n width: 28px;\n }\n\n .header-text {\n align-content: center;\n flex-grow: 1;\n padding: 0 12px 0 0;\n }\n\n .left-padding {\n padding-left: 8px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"icon\", \"icon\"],\n [\"collapsed\", \"collapsed\"],\n [\"title\", \"title\"],\n [\"hide_close_button\", \"hideCloseButton\"]\n ]);\n }\n render() {\n return ke`\n
    \n ${this.noHeader ? D : this.renderHeader()}\n
    \n \n
    \n
    \n `;\n }\n renderHeader() {\n return this.compactMode ? this.renderCompactHeader() : ke`\n
    \n ${this.renderIcon()}\n ${this.title ? this.renderTitle() : D}\n ${this.renderCollapseButton()}\n ${this.renderCloseButton()}\n
    `;\n }\n renderCompactHeader() {\n return ke`
    \n ${this.renderCollapseButton()}\n ${this.title && !this.collapsed ? this.renderTitle() : D}\n ${this.renderCloseButton()}\n
    `;\n }\n renderCloseButton() {\n if (this.hideCloseButton) {\n return D;\n }\n return ke`\n \n \n \n `;\n }\n renderTitle() {\n return ke`${this.title}`;\n }\n onCloseButtonClicked() {\n this.dispatchEvent(new CustomEvent(\"close-clicked\", {}));\n }\n onCollapseToggled() {\n this.collapsed = !this.collapsed;\n this.dispatchEvent(new CustomEvent(\"collapse-clicked\", {}));\n }\n renderIcon() {\n return ke`\n ${this.icon}\n `;\n }\n renderCollapseButton() {\n let icon;\n if (this.compactMode) {\n icon = this.renderIcon();\n } else if (this.collapsed) {\n icon = ke``;\n } else {\n icon = ke``;\n }\n return ke`\n ${icon}\n `;\n }\n};\n__decorateClass([\n n4({ type: String })\n], Container.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], Container.prototype, \"title\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"collapsed\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"hideCloseButton\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"compactMode\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"noHeader\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], Container.prototype, \"reverseHeader\", 2);\nif (!customElements.get(Container.componentName)) {\n customElements.define(Container.componentName, Container);\n}\n\n// node_modules/lit-html/directives/style-map.js\nvar ee = \"important\";\nvar ie = \" !\" + ee;\nvar se = e5(class extends i3 {\n constructor(e6) {\n if (super(e6), e6.type !== t2.ATTRIBUTE || \"style\" !== e6.name || e6.strings?.length > 2) throw Error(\"The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return Object.keys(t3).reduce((e6, r5) => {\n const s2 = t3[r5];\n return null == s2 ? e6 : e6 + `${r5 = r5.includes(\"-\") ? r5 : r5.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g, \"-$&\").toLowerCase()}:${s2};`;\n }, \"\");\n }\n update(t3, [e6]) {\n const { style: r5 } = t3.element;\n if (void 0 === this.ft) return this.ft = new Set(Object.keys(e6)), this.render(e6);\n for (const t4 of this.ft) null == e6[t4] && (this.ft.delete(t4), t4.includes(\"-\") ? r5.removeProperty(t4) : r5[t4] = null);\n for (const t4 in e6) {\n const s2 = e6[t4];\n if (null != s2) {\n this.ft.add(t4);\n const e7 = \"string\" == typeof s2 && s2.endsWith(ie);\n t4.includes(\"-\") || e7 ? r5.setProperty(t4, e7 ? s2.slice(0, -11) : s2, e7 ? ee : \"\") : r5[t4] = s2;\n }\n }\n return R;\n }\n});\n\n// js/tab_panel.ts\nfunction convertToId(name) {\n return (name || \"\").trim().replace(\" \", \"-\").toLowerCase();\n}\nvar TabPanel = class extends h3 {\n constructor() {\n super(...arguments);\n this.tabs = [];\n this.index = 0;\n this.mode = 1 /* HIDE_ON_SECOND_CLICK */;\n this.alignment = \"left\" /* LEFT */;\n }\n static get componentName() {\n return `tab-panel`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n ::slotted(*) {\n display: none;\n }\n\n ::slotted(.show-tab) {\n display: block;\n }\n\n .container {\n padding: 0;\n width: 100%;\n }\n\n .tab-container {\n align-items: center;\n display: flex;\n flex-direction: row;\n }\n\n .tab-container.center {\n justify-content: center;\n }\n\n .tab-container.left {\n justify-content: flex-start;\n }\n\n .tab-container.right {\n justify-content: flex-end;\n }\n\n .tab-container button {\n border-radius: 5px;\n height: 28px;\n margin: 2px 0 2px 8px;\n user-select: none;\n }\n\n .tab-container button.icon {\n font-size: 16px;\n width: 28px;\n }\n\n .tab-container button.name {\n padding: 0 8px;\n }\n `\n ];\n }\n render() {\n return ke`\n
    \n \n ${this.renderTabs()}\n
    \n \n \n `;\n }\n update(changedProperties) {\n super.update(changedProperties);\n if (changedProperties.has(\"index\") && changedProperties.get(\"index\") != null) {\n this.updateSlotChildren();\n }\n }\n updateSlotChildren() {\n if (!this.tabContentElements) {\n return;\n }\n this.tabContentElements.forEach((element, i4) => {\n element.classList.remove(\"show-tab\");\n const id = convertToId(this.tabs[i4].name);\n element.setAttribute(\"id\", `tabpanel-${id}-${i4}`);\n element.setAttribute(\"role\", \"tabpanel\");\n element.setAttribute(\"aria-labelledby\", `tab-${id}-${i4}`);\n });\n this.tabContentElements[this.index]?.classList.add(\"show-tab\");\n }\n renderTabs() {\n return this.tabs.map((tab, i4) => {\n const id = convertToId(this.tabs[i4].name);\n return ke` {\n this.onTabClick(i4);\n }}>\n ${tab.icon ? ke`${tab.icon}` : D}\n ${tab.name}\n `;\n });\n }\n onTabClick(index) {\n switch (this.mode) {\n case 1 /* HIDE_ON_SECOND_CLICK */:\n this.index = this.index === index ? -1 : index;\n break;\n case 0 /* ALWAYS_SHOW */:\n default:\n this.index = index;\n }\n this.dispatchEvent(new CustomEvent(\"tab-changed\", {\n detail: index\n }));\n }\n};\n__decorateClass([\n n4({ type: Array })\n], TabPanel.prototype, \"tabs\", 2);\n__decorateClass([\n n4({ type: Number })\n], TabPanel.prototype, \"index\", 2);\n__decorateClass([\n n4({ type: Number })\n], TabPanel.prototype, \"mode\", 2);\n__decorateClass([\n n4({ type: String })\n], TabPanel.prototype, \"alignment\", 2);\n__decorateClass([\n r4(\".tab\")\n], TabPanel.prototype, \"tabElements\", 2);\n__decorateClass([\n o4()\n], TabPanel.prototype, \"tabContentElements\", 2);\nif (!customElements.get(TabPanel.componentName)) {\n customElements.define(TabPanel.componentName, TabPanel);\n}\n\n// js/toolbar.ts\nvar Toolbar = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.expanded = false;\n }\n static get componentName() {\n return `toolbar-panel`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n .hide {\n display: none;\n }\n\n .expanded {\n display: block; !important\n }\n\n slot[name=\"extra-tools\"] {\n margin-top: 4px;\n }\n\n ::slotted([slot=\"main-tools\"]),\n ::slotted([slot=\"extra-tools\"]) {\n align-items: center;\n display: inline-grid;\n grid-template-columns: auto auto auto;\n grid-gap: 4px;\n justify-items: center;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"main_tools\", null],\n [\"extra_tools\", null],\n [\"expanded\", \"expanded\"]\n ]);\n }\n render() {\n return ke`\n \n
    \n \n \n
    \n \n `;\n }\n};\n__decorateClass([\n n4()\n], Toolbar.prototype, \"expanded\", 2);\nif (!customElements.get(Toolbar.componentName)) {\n customElements.define(Toolbar.componentName, Toolbar);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(Toolbar.componentName);\n manager.model = model;\n el.appendChild(manager);\n const mainToolsEl = document.createElement(\"div\");\n mainToolsEl.slot = \"main-tools\";\n manager.appendChild(mainToolsEl);\n updateChildren(mainToolsEl, model, \"main_tools\");\n model.on(\"change:main_tools\", () => {\n updateChildren(mainToolsEl, model, \"main_tools\");\n });\n const extraToolsEl = document.createElement(\"div\");\n extraToolsEl.slot = \"extra-tools\";\n manager.appendChild(extraToolsEl);\n updateChildren(extraToolsEl, model, \"extra_tools\");\n model.on(\"change:extra_tools\", () => {\n updateChildren(extraToolsEl, model, \"extra_tools\");\n });\n}\nvar toolbar_default = { render };\nexport {\n Toolbar,\n toolbar_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/style-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "expanded": false, + "extra_tools": [ + "IPY_MODEL_9b1e99aba6cb4b7782d78e56ea005d9d", + "IPY_MODEL_c6576ff54bff4e7a9fb410443d3ecc5b", + "IPY_MODEL_4e5bda9157e7494bafcaf8e44f864424", + "IPY_MODEL_e6c172ac8c3f4e4ca08400f839230174", + "IPY_MODEL_b97ac9c1ea2e46e4a0bd07ab88ae012c", + "IPY_MODEL_262c8654c40643898621dfb02718aeb4", + "IPY_MODEL_6907b83887184f18a9811508ec9887f9", + "IPY_MODEL_db63bba9f9c64f4787c328766671edcb", + "IPY_MODEL_5c89fe5b921d45bbbe5594552c80ddd6", + "IPY_MODEL_ac2cd52fca484632832a8ffc460f2e98", + "IPY_MODEL_9b1aa26a079f4cdb933d90202e373ea5" + ], + "layout": "IPY_MODEL_50efdb138316463cbdb7fcafc36e4f0d", + "main_tools": [ + "IPY_MODEL_320be3525f7244e59fa1e32a4cd9bcf3", + "IPY_MODEL_018bc35395594b90912c927e2211212e", + "IPY_MODEL_1e753e5cf2ff4b53ac41e818e9f7bf7b", + "IPY_MODEL_d110ce8e35f140d585e81e88cce4a413", + "IPY_MODEL_060518aa3c224037930316d880e07fc8", + "IPY_MODEL_b9aa9ab9f4044505bdf204d794b88df3" + ] + } + }, + "08df80e9b6e94b60a1f7b30a1e96b6b8": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": "0px 10px", + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": "auto auto", + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": "visible", + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "0164447f09534c85a9bc9ba3b0a8ced5": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "a0a9213c4e3e464287da4bb6517408e5": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "50efdb138316463cbdb7fcafc36e4f0d": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "d6061d8c11874c50a4379be53a5e3029": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.map_widgets.LayerManagerRow", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/layer_manager_row.ts\nvar LayerManagerRow = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.name = \"\";\n this.visible = true;\n this.opacity = 1;\n this.isLoading = false;\n this.isConfirmDialogVisible = false;\n }\n static get componentName() {\n return `layer-manager-row`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n .row {\n align-items: center;\n display: flex;\n gap: 4px;\n }\n\n .layer-name {\n cursor: pointer;\n flex-grow: 1;\n max-width: 150px;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .row-button {\n font-size: 16px;\n height: 28px;\n width: 28px;\n }\n\n .layer-opacity-slider {\n width: 70px;\n }\n\n .layer-visibility-checkbox {\n margin: 2px;\n }\n\n .spinner {\n -webkit-animation: spin 2s linear infinite;\n animation: spin 2s linear infinite;\n border-radius: 50%;\n border: 4px solid var(--jp-widgets-input-border-color);\n border-top: 4px solid var(--jp-widgets-color);\n height: 12px;\n width: 12px;\n }\n\n @-webkit-keyframes spin {\n 0% {\n -webkit-transform: rotate(0deg);\n }\n 100% {\n -webkit-transform: rotate(360deg);\n }\n }\n\n @keyframes spin {\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n }\n\n button.loading .spinner,\n button.loading:hover .close-icon,\n button.done-loading .close-icon {\n display: block;\n }\n\n button.loading .close-icon,\n button.loading:hover .spinner,\n button.done-loading .spinner {\n display: none;\n }\n\n .remove-layer-text {\n flex-grow: 1;\n padding-left: 22px;\n }\n\n .confirm-deletion-container {\n margin-top: 4px;\n }\n\n .confirm-deletion-container button {\n height: 28px;\n width: 70px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"name\", \"name\"],\n [\"visible\", \"visible\"],\n [\"opacity\", \"opacity\"],\n [\"is_loading\", \"isLoading\"]\n ]);\n }\n render() {\n return ke`\n
    \n \n \n ${this.name}\n \n \n \n settings\n \n \n
    \n delete
    \n \n
    \n ${this.renderConfirmDialog()}\n `;\n }\n renderConfirmDialog() {\n if (!this.isConfirmDialogVisible) {\n return D;\n }\n return ke`\n
    \n Remove layer?\n \n No\n \n \n Yes\n \n
    \n `;\n }\n onLayerVisibilityChanged(_event) {\n this.visible = !this.visible;\n }\n onLayerOpacityChanged(event) {\n const target = event.target;\n this.opacity = parseFloat(target.value);\n }\n onSettingsClicked(_2) {\n this.model?.send({ type: \"click\", id: \"settings\" });\n }\n onDeleteClicked(_2) {\n this.isConfirmDialogVisible = true;\n }\n confirmDeletion(_2) {\n this.model?.send({ type: \"click\", id: \"delete\" });\n }\n cancelDeletion(_2) {\n this.isConfirmDialogVisible = false;\n }\n};\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"name\", 2);\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"visible\", 2);\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"opacity\", 2);\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"isLoading\", 2);\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"isConfirmDialogVisible\", 2);\nif (!customElements.get(LayerManagerRow.componentName)) {\n customElements.define(LayerManagerRow.componentName, LayerManagerRow);\n}\nfunction render({ model, el }) {\n loadFonts();\n const row = document.createElement(LayerManagerRow.componentName);\n row.model = model;\n el.appendChild(row);\n}\nvar layer_manager_row_default = { render };\nexport {\n LayerManagerRow,\n layer_manager_row_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "is_loading": false, + "layout": "IPY_MODEL_7dc38477ca5145948632181e660a72ca", + "name": "Esri.WorldImagery", + "opacity": 1, + "visible": true + } + }, + "0f756d3b935c4b14a4aca5d85723c84d": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.map_widgets.LayerManagerRow", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/layer_manager_row.ts\nvar LayerManagerRow = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.name = \"\";\n this.visible = true;\n this.opacity = 1;\n this.isLoading = false;\n this.isConfirmDialogVisible = false;\n }\n static get componentName() {\n return `layer-manager-row`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n .row {\n align-items: center;\n display: flex;\n gap: 4px;\n }\n\n .layer-name {\n cursor: pointer;\n flex-grow: 1;\n max-width: 150px;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .row-button {\n font-size: 16px;\n height: 28px;\n width: 28px;\n }\n\n .layer-opacity-slider {\n width: 70px;\n }\n\n .layer-visibility-checkbox {\n margin: 2px;\n }\n\n .spinner {\n -webkit-animation: spin 2s linear infinite;\n animation: spin 2s linear infinite;\n border-radius: 50%;\n border: 4px solid var(--jp-widgets-input-border-color);\n border-top: 4px solid var(--jp-widgets-color);\n height: 12px;\n width: 12px;\n }\n\n @-webkit-keyframes spin {\n 0% {\n -webkit-transform: rotate(0deg);\n }\n 100% {\n -webkit-transform: rotate(360deg);\n }\n }\n\n @keyframes spin {\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n }\n\n button.loading .spinner,\n button.loading:hover .close-icon,\n button.done-loading .close-icon {\n display: block;\n }\n\n button.loading .close-icon,\n button.loading:hover .spinner,\n button.done-loading .spinner {\n display: none;\n }\n\n .remove-layer-text {\n flex-grow: 1;\n padding-left: 22px;\n }\n\n .confirm-deletion-container {\n margin-top: 4px;\n }\n\n .confirm-deletion-container button {\n height: 28px;\n width: 70px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"name\", \"name\"],\n [\"visible\", \"visible\"],\n [\"opacity\", \"opacity\"],\n [\"is_loading\", \"isLoading\"]\n ]);\n }\n render() {\n return ke`\n
    \n \n \n ${this.name}\n \n \n \n settings\n \n \n
    \n delete
    \n \n
    \n ${this.renderConfirmDialog()}\n `;\n }\n renderConfirmDialog() {\n if (!this.isConfirmDialogVisible) {\n return D;\n }\n return ke`\n
    \n Remove layer?\n \n No\n \n \n Yes\n \n
    \n `;\n }\n onLayerVisibilityChanged(_event) {\n this.visible = !this.visible;\n }\n onLayerOpacityChanged(event) {\n const target = event.target;\n this.opacity = parseFloat(target.value);\n }\n onSettingsClicked(_2) {\n this.model?.send({ type: \"click\", id: \"settings\" });\n }\n onDeleteClicked(_2) {\n this.isConfirmDialogVisible = true;\n }\n confirmDeletion(_2) {\n this.model?.send({ type: \"click\", id: \"delete\" });\n }\n cancelDeletion(_2) {\n this.isConfirmDialogVisible = false;\n }\n};\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"name\", 2);\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"visible\", 2);\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"opacity\", 2);\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"isLoading\", 2);\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"isConfirmDialogVisible\", 2);\nif (!customElements.get(LayerManagerRow.componentName)) {\n customElements.define(LayerManagerRow.componentName, LayerManagerRow);\n}\nfunction render({ model, el }) {\n loadFonts();\n const row = document.createElement(LayerManagerRow.componentName);\n row.model = model;\n el.appendChild(row);\n}\nvar layer_manager_row_default = { render };\nexport {\n LayerManagerRow,\n layer_manager_row_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "is_loading": false, + "layout": "IPY_MODEL_3117c8f8dc454974ad725820bbaff1b6", + "name": "25m DW Tree Cover", + "opacity": 1, + "visible": true + } + }, + "d25e876b06b84a57a476a14ce924b60a": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.map_widgets.LayerManagerRow", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/layer_manager_row.ts\nvar LayerManagerRow = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.name = \"\";\n this.visible = true;\n this.opacity = 1;\n this.isLoading = false;\n this.isConfirmDialogVisible = false;\n }\n static get componentName() {\n return `layer-manager-row`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n .row {\n align-items: center;\n display: flex;\n gap: 4px;\n }\n\n .layer-name {\n cursor: pointer;\n flex-grow: 1;\n max-width: 150px;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .row-button {\n font-size: 16px;\n height: 28px;\n width: 28px;\n }\n\n .layer-opacity-slider {\n width: 70px;\n }\n\n .layer-visibility-checkbox {\n margin: 2px;\n }\n\n .spinner {\n -webkit-animation: spin 2s linear infinite;\n animation: spin 2s linear infinite;\n border-radius: 50%;\n border: 4px solid var(--jp-widgets-input-border-color);\n border-top: 4px solid var(--jp-widgets-color);\n height: 12px;\n width: 12px;\n }\n\n @-webkit-keyframes spin {\n 0% {\n -webkit-transform: rotate(0deg);\n }\n 100% {\n -webkit-transform: rotate(360deg);\n }\n }\n\n @keyframes spin {\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n }\n\n button.loading .spinner,\n button.loading:hover .close-icon,\n button.done-loading .close-icon {\n display: block;\n }\n\n button.loading .close-icon,\n button.loading:hover .spinner,\n button.done-loading .spinner {\n display: none;\n }\n\n .remove-layer-text {\n flex-grow: 1;\n padding-left: 22px;\n }\n\n .confirm-deletion-container {\n margin-top: 4px;\n }\n\n .confirm-deletion-container button {\n height: 28px;\n width: 70px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"name\", \"name\"],\n [\"visible\", \"visible\"],\n [\"opacity\", \"opacity\"],\n [\"is_loading\", \"isLoading\"]\n ]);\n }\n render() {\n return ke`\n
    \n \n \n ${this.name}\n \n \n \n settings\n \n \n
    \n delete
    \n \n
    \n ${this.renderConfirmDialog()}\n `;\n }\n renderConfirmDialog() {\n if (!this.isConfirmDialogVisible) {\n return D;\n }\n return ke`\n
    \n Remove layer?\n \n No\n \n \n Yes\n \n
    \n `;\n }\n onLayerVisibilityChanged(_event) {\n this.visible = !this.visible;\n }\n onLayerOpacityChanged(event) {\n const target = event.target;\n this.opacity = parseFloat(target.value);\n }\n onSettingsClicked(_2) {\n this.model?.send({ type: \"click\", id: \"settings\" });\n }\n onDeleteClicked(_2) {\n this.isConfirmDialogVisible = true;\n }\n confirmDeletion(_2) {\n this.model?.send({ type: \"click\", id: \"delete\" });\n }\n cancelDeletion(_2) {\n this.isConfirmDialogVisible = false;\n }\n};\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"name\", 2);\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"visible\", 2);\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"opacity\", 2);\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"isLoading\", 2);\n__decorateClass([\n n4()\n], LayerManagerRow.prototype, \"isConfirmDialogVisible\", 2);\nif (!customElements.get(LayerManagerRow.componentName)) {\n customElements.define(LayerManagerRow.componentName, LayerManagerRow);\n}\nfunction render({ model, el }) {\n loadFonts();\n const row = document.createElement(LayerManagerRow.componentName);\n row.model = model;\n el.appendChild(row);\n}\nvar layer_manager_row_default = { render };\nexport {\n LayerManagerRow,\n layer_manager_row_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "is_loading": false, + "layout": "IPY_MODEL_f306390cbeee4c17acad2a7faea2bda7", + "name": "LTP-STP Image", + "opacity": 1, + "visible": true + } + }, + "320be3525f7244e59fa1e32a4cd9bcf3": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "point_scan", + "layout": "IPY_MODEL_bd44dc656406487e96cbe194c6261bd0", + "primary": true, + "tooltip_text": "Inspector" + } + }, + "018bc35395594b90912c927e2211212e": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "bar_chart", + "layout": "IPY_MODEL_e9f6bd2414c84f258c433913fe8cf03e", + "primary": true, + "tooltip_text": "Plotting" + } + }, + "1e753e5cf2ff4b53ac41e818e9f7bf7b": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "history", + "layout": "IPY_MODEL_f0268b4fa5f8426aab5467c9b24a5fdd", + "primary": true, + "tooltip_text": "Create timelapse" + } + }, + "d110ce8e35f140d585e81e88cce4a413": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "map", + "layout": "IPY_MODEL_c5f72af9d368407dbaf5d9e4d7b91392", + "primary": true, + "tooltip_text": "Change basemap" + } + }, + "060518aa3c224037930316d880e07fc8": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "code", + "layout": "IPY_MODEL_91e3f52fd7364d319305e5af41f1c82c", + "primary": true, + "tooltip_text": "Convert Earth Engine JavaScript to Python" + } + }, + "b9aa9ab9f4044505bdf204d794b88df3": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "add", + "layout": "IPY_MODEL_125e8e3b0ac04f6993b13c51a0380138", + "primary": true, + "tooltip_text": "Expand toolbar" + } + }, + "9b1e99aba6cb4b7782d78e56ea005d9d": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "ink_eraser", + "layout": "IPY_MODEL_ea343d9e0bb749998ee948e3b48e087f", + "primary": true, + "tooltip_text": "Remove all drawn features" + } + }, + "c6576ff54bff4e7a9fb410443d3ecc5b": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "upload", + "layout": "IPY_MODEL_8038eb785016462b8c9b593921119002", + "primary": true, + "tooltip_text": "Open local vector/raster data" + } + }, + "4e5bda9157e7494bafcaf8e44f864424": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "manufacturing", + "layout": "IPY_MODEL_fbb8c80ca0784cef9d6139a5fbbb6b46", + "primary": true, + "tooltip_text": "WhiteboxTools for local geoprocessing" + } + }, + "e6c172ac8c3f4e4ca08400f839230174": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "dns", + "layout": "IPY_MODEL_49cfc07507794d6aab0419f0f0f17ef4", + "primary": true, + "tooltip_text": "GEE Toolbox for cloud computing" + } + }, + "b97ac9c1ea2e46e4a0bd07ab88ae012c": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "fast_forward", + "layout": "IPY_MODEL_44d6efda35304e3b82becaa0554cc00a", + "primary": true, + "tooltip_text": "Activate timeslider" + } + }, + "262c8654c40643898621dfb02718aeb4": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "pan_tool_alt", + "layout": "IPY_MODEL_a386181ab2a948f39200afa36498a19e", + "primary": true, + "tooltip_text": "Collect training samples" + } + }, + "6907b83887184f18a9811508ec9887f9": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "show_chart", + "layout": "IPY_MODEL_313ce92ad01147d1bd2f3e3491f0fabf", + "primary": true, + "tooltip_text": "Creating and plotting transects" + } + }, + "db63bba9f9c64f4787c328766671edcb": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "shuffle", + "layout": "IPY_MODEL_b6f2cd884b4140c2b954178b4a3f5ffb", + "primary": true, + "tooltip_text": "Sankey plots" + } + }, + "5c89fe5b921d45bbbe5594552c80ddd6": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "image", + "layout": "IPY_MODEL_8dc8b607f2e64e47a962f0a6a9932837", + "primary": true, + "tooltip_text": "Planet imagery" + } + }, + "ac2cd52fca484632832a8ffc460f2e98": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "target", + "layout": "IPY_MODEL_e3c9b813e43e46708e9a73470ba31ce6", + "primary": true, + "tooltip_text": "Get COG/STAC pixel value" + } + }, + "9b1aa26a079f4cdb933d90202e373ea5": { + "model_module": "anywidget", + "model_name": "AnyModel", + "model_module_version": "~0.9.*", + "state": { + "_anywidget_id": "geemap.toolbar.ToolbarItem", + "_dom_classes": [ + "geemap-colab" + ], + "_esm": "var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __decorateClass = (decorators, target, key, kind) => {\n var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;\n for (var i4 = decorators.length - 1, decorator; i4 >= 0; i4--)\n if (decorator = decorators[i4])\n result = (kind ? decorator(target, key, result) : decorator(result)) || result;\n if (kind && result) __defProp(target, key, result);\n return result;\n};\n\n// node_modules/@lit/reactive-element/css-tag.js\nvar t = globalThis;\nvar e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && \"adoptedStyleSheets\" in Document.prototype && \"replace\" in CSSStyleSheet.prototype;\nvar s = Symbol();\nvar o = /* @__PURE__ */ new WeakMap();\nvar n = class {\n constructor(t3, e5, o4) {\n if (this._$cssResult$ = true, o4 !== s) throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");\n this.cssText = t3, this.t = e5;\n }\n get styleSheet() {\n let t3 = this.o;\n const s2 = this.t;\n if (e && void 0 === t3) {\n const e5 = void 0 !== s2 && 1 === s2.length;\n e5 && (t3 = o.get(s2)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e5 && o.set(s2, t3));\n }\n return t3;\n }\n toString() {\n return this.cssText;\n }\n};\nvar r = (t3) => new n(\"string\" == typeof t3 ? t3 : t3 + \"\", void 0, s);\nvar i = (t3, ...e5) => {\n const o4 = 1 === t3.length ? t3[0] : e5.reduce((e6, s2, o5) => e6 + ((t4) => {\n if (true === t4._$cssResult$) return t4.cssText;\n if (\"number\" == typeof t4) return t4;\n throw Error(\"Value passed to 'css' function must be a 'css' function result: \" + t4 + \". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\");\n })(s2) + t3[o5 + 1], t3[0]);\n return new n(o4, t3, s);\n};\nvar S = (s2, o4) => {\n if (e) s2.adoptedStyleSheets = o4.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet);\n else for (const e5 of o4) {\n const o5 = document.createElement(\"style\"), n5 = t.litNonce;\n void 0 !== n5 && o5.setAttribute(\"nonce\", n5), o5.textContent = e5.cssText, s2.appendChild(o5);\n }\n};\nvar c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => {\n let e5 = \"\";\n for (const s2 of t4.cssRules) e5 += s2.cssText;\n return r(e5);\n})(t3) : t3;\n\n// node_modules/@lit/reactive-element/reactive-element.js\nvar { is: i2, defineProperty: e2, getOwnPropertyDescriptor: r2, getOwnPropertyNames: h, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object;\nvar a = globalThis;\nvar c2 = a.trustedTypes;\nvar l = c2 ? c2.emptyScript : \"\";\nvar p = a.reactiveElementPolyfillSupport;\nvar d = (t3, s2) => t3;\nvar u = { toAttribute(t3, s2) {\n switch (s2) {\n case Boolean:\n t3 = t3 ? l : null;\n break;\n case Object:\n case Array:\n t3 = null == t3 ? t3 : JSON.stringify(t3);\n }\n return t3;\n}, fromAttribute(t3, s2) {\n let i4 = t3;\n switch (s2) {\n case Boolean:\n i4 = null !== t3;\n break;\n case Number:\n i4 = null === t3 ? null : Number(t3);\n break;\n case Object:\n case Array:\n try {\n i4 = JSON.parse(t3);\n } catch (t4) {\n i4 = null;\n }\n }\n return i4;\n} };\nvar f = (t3, s2) => !i2(t3, s2);\nvar y = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nSymbol.metadata ??= Symbol(\"metadata\"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();\nvar b = class extends HTMLElement {\n static addInitializer(t3) {\n this._$Ei(), (this.l ??= []).push(t3);\n }\n static get observedAttributes() {\n return this.finalize(), this._$Eh && [...this._$Eh.keys()];\n }\n static createProperty(t3, s2 = y) {\n if (s2.state && (s2.attribute = false), this._$Ei(), this.elementProperties.set(t3, s2), !s2.noAccessor) {\n const i4 = Symbol(), r4 = this.getPropertyDescriptor(t3, i4, s2);\n void 0 !== r4 && e2(this.prototype, t3, r4);\n }\n }\n static getPropertyDescriptor(t3, s2, i4) {\n const { get: e5, set: h4 } = r2(this.prototype, t3) ?? { get() {\n return this[s2];\n }, set(t4) {\n this[s2] = t4;\n } };\n return { get() {\n return e5?.call(this);\n }, set(s3) {\n const r4 = e5?.call(this);\n h4.call(this, s3), this.requestUpdate(t3, r4, i4);\n }, configurable: true, enumerable: true };\n }\n static getPropertyOptions(t3) {\n return this.elementProperties.get(t3) ?? y;\n }\n static _$Ei() {\n if (this.hasOwnProperty(d(\"elementProperties\"))) return;\n const t3 = n2(this);\n t3.finalize(), void 0 !== t3.l && (this.l = [...t3.l]), this.elementProperties = new Map(t3.elementProperties);\n }\n static finalize() {\n if (this.hasOwnProperty(d(\"finalized\"))) return;\n if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d(\"properties\"))) {\n const t4 = this.properties, s2 = [...h(t4), ...o2(t4)];\n for (const i4 of s2) this.createProperty(i4, t4[i4]);\n }\n const t3 = this[Symbol.metadata];\n if (null !== t3) {\n const s2 = litPropertyMetadata.get(t3);\n if (void 0 !== s2) for (const [t4, i4] of s2) this.elementProperties.set(t4, i4);\n }\n this._$Eh = /* @__PURE__ */ new Map();\n for (const [t4, s2] of this.elementProperties) {\n const i4 = this._$Eu(t4, s2);\n void 0 !== i4 && this._$Eh.set(i4, t4);\n }\n this.elementStyles = this.finalizeStyles(this.styles);\n }\n static finalizeStyles(s2) {\n const i4 = [];\n if (Array.isArray(s2)) {\n const e5 = new Set(s2.flat(1 / 0).reverse());\n for (const s3 of e5) i4.unshift(c(s3));\n } else void 0 !== s2 && i4.push(c(s2));\n return i4;\n }\n static _$Eu(t3, s2) {\n const i4 = s2.attribute;\n return false === i4 ? void 0 : \"string\" == typeof i4 ? i4 : \"string\" == typeof t3 ? t3.toLowerCase() : void 0;\n }\n constructor() {\n super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();\n }\n _$Ev() {\n this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this));\n }\n addController(t3) {\n (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.();\n }\n removeController(t3) {\n this._$EO?.delete(t3);\n }\n _$E_() {\n const t3 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;\n for (const i4 of s2.keys()) this.hasOwnProperty(i4) && (t3.set(i4, this[i4]), delete this[i4]);\n t3.size > 0 && (this._$Ep = t3);\n }\n createRenderRoot() {\n const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);\n return S(t3, this.constructor.elementStyles), t3;\n }\n connectedCallback() {\n this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.());\n }\n enableUpdating(t3) {\n }\n disconnectedCallback() {\n this._$EO?.forEach((t3) => t3.hostDisconnected?.());\n }\n attributeChangedCallback(t3, s2, i4) {\n this._$AK(t3, i4);\n }\n _$EC(t3, s2) {\n const i4 = this.constructor.elementProperties.get(t3), e5 = this.constructor._$Eu(t3, i4);\n if (void 0 !== e5 && true === i4.reflect) {\n const r4 = (void 0 !== i4.converter?.toAttribute ? i4.converter : u).toAttribute(s2, i4.type);\n this._$Em = t3, null == r4 ? this.removeAttribute(e5) : this.setAttribute(e5, r4), this._$Em = null;\n }\n }\n _$AK(t3, s2) {\n const i4 = this.constructor, e5 = i4._$Eh.get(t3);\n if (void 0 !== e5 && this._$Em !== e5) {\n const t4 = i4.getPropertyOptions(e5), r4 = \"function\" == typeof t4.converter ? { fromAttribute: t4.converter } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u;\n this._$Em = e5, this[e5] = r4.fromAttribute(s2, t4.type), this._$Em = null;\n }\n }\n requestUpdate(t3, s2, i4) {\n if (void 0 !== t3) {\n if (i4 ??= this.constructor.getPropertyOptions(t3), !(i4.hasChanged ?? f)(this[t3], s2)) return;\n this.P(t3, s2, i4);\n }\n false === this.isUpdatePending && (this._$ES = this._$ET());\n }\n P(t3, s2, i4) {\n this._$AL.has(t3) || this._$AL.set(t3, s2), true === i4.reflect && this._$Em !== t3 && (this._$Ej ??= /* @__PURE__ */ new Set()).add(t3);\n }\n async _$ET() {\n this.isUpdatePending = true;\n try {\n await this._$ES;\n } catch (t4) {\n Promise.reject(t4);\n }\n const t3 = this.scheduleUpdate();\n return null != t3 && await t3, !this.isUpdatePending;\n }\n scheduleUpdate() {\n return this.performUpdate();\n }\n performUpdate() {\n if (!this.isUpdatePending) return;\n if (!this.hasUpdated) {\n if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) {\n for (const [t5, s3] of this._$Ep) this[t5] = s3;\n this._$Ep = void 0;\n }\n const t4 = this.constructor.elementProperties;\n if (t4.size > 0) for (const [s3, i4] of t4) true !== i4.wrapped || this._$AL.has(s3) || void 0 === this[s3] || this.P(s3, this[s3], i4);\n }\n let t3 = false;\n const s2 = this._$AL;\n try {\n t3 = this.shouldUpdate(s2), t3 ? (this.willUpdate(s2), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s2)) : this._$EU();\n } catch (s3) {\n throw t3 = false, this._$EU(), s3;\n }\n t3 && this._$AE(s2);\n }\n willUpdate(t3) {\n }\n _$AE(t3) {\n this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3);\n }\n _$EU() {\n this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;\n }\n get updateComplete() {\n return this.getUpdateComplete();\n }\n getUpdateComplete() {\n return this._$ES;\n }\n shouldUpdate(t3) {\n return true;\n }\n update(t3) {\n this._$Ej &&= this._$Ej.forEach((t4) => this._$EC(t4, this[t4])), this._$EU();\n }\n updated(t3) {\n }\n firstUpdated(t3) {\n }\n};\nb.elementStyles = [], b.shadowRootOptions = { mode: \"open\" }, b[d(\"elementProperties\")] = /* @__PURE__ */ new Map(), b[d(\"finalized\")] = /* @__PURE__ */ new Map(), p?.({ ReactiveElement: b }), (a.reactiveElementVersions ??= []).push(\"2.0.4\");\n\n// node_modules/lit-html/lit-html.js\nvar n3 = globalThis;\nvar c3 = n3.trustedTypes;\nvar h2 = c3 ? c3.createPolicy(\"lit-html\", { createHTML: (t3) => t3 }) : void 0;\nvar f2 = \"$lit$\";\nvar v = `lit$${Math.random().toFixed(9).slice(2)}$`;\nvar m = \"?\" + v;\nvar _ = `<${m}>`;\nvar w = document;\nvar lt = () => w.createComment(\"\");\nvar st = (t3) => null === t3 || \"object\" != typeof t3 && \"function\" != typeof t3;\nvar g = Array.isArray;\nvar $ = (t3) => g(t3) || \"function\" == typeof t3?.[Symbol.iterator];\nvar x = \"[ \t\\n\\f\\r]\";\nvar T = /<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g;\nvar E = /-->/g;\nvar k = />/g;\nvar O = RegExp(`>|${x}(?:([^\\\\s\"'>=/]+)(${x}*=${x}*(?:[^ \t\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`, \"g\");\nvar S2 = /'/g;\nvar j = /\"/g;\nvar M = /^(?:script|style|textarea|title)$/i;\nvar P = (t3) => (i4, ...s2) => ({ _$litType$: t3, strings: i4, values: s2 });\nvar ke = P(1);\nvar Oe = P(2);\nvar Se = P(3);\nvar R = Symbol.for(\"lit-noChange\");\nvar D = Symbol.for(\"lit-nothing\");\nvar V = /* @__PURE__ */ new WeakMap();\nvar I = w.createTreeWalker(w, 129);\nfunction N(t3, i4) {\n if (!g(t3) || !t3.hasOwnProperty(\"raw\")) throw Error(\"invalid template strings array\");\n return void 0 !== h2 ? h2.createHTML(i4) : i4;\n}\nvar U = (t3, i4) => {\n const s2 = t3.length - 1, e5 = [];\n let h4, o4 = 2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\", n5 = T;\n for (let i5 = 0; i5 < s2; i5++) {\n const s3 = t3[i5];\n let r4, l2, c4 = -1, a2 = 0;\n for (; a2 < s3.length && (n5.lastIndex = a2, l2 = n5.exec(s3), null !== l2); ) a2 = n5.lastIndex, n5 === T ? \"!--\" === l2[1] ? n5 = E : void 0 !== l2[1] ? n5 = k : void 0 !== l2[2] ? (M.test(l2[2]) && (h4 = RegExp(\"\" === l2[0] ? (n5 = h4 ?? T, c4 = -1) : void 0 === l2[1] ? c4 = -2 : (c4 = n5.lastIndex - l2[2].length, r4 = l2[1], n5 = void 0 === l2[3] ? O : '\"' === l2[3] ? j : S2) : n5 === j || n5 === S2 ? n5 = O : n5 === E || n5 === k ? n5 = T : (n5 = O, h4 = void 0);\n const u2 = n5 === O && t3[i5 + 1].startsWith(\"/>\") ? \" \" : \"\";\n o4 += n5 === T ? s3 + _ : c4 >= 0 ? (e5.push(r4), s3.slice(0, c4) + f2 + s3.slice(c4) + v + u2) : s3 + v + (-2 === c4 ? i5 : u2);\n }\n return [N(t3, o4 + (t3[s2] || \"\") + (2 === i4 ? \"\" : 3 === i4 ? \"\" : \"\")), e5];\n};\nvar B = class _B {\n constructor({ strings: t3, _$litType$: i4 }, s2) {\n let e5;\n this.parts = [];\n let h4 = 0, o4 = 0;\n const n5 = t3.length - 1, r4 = this.parts, [l2, a2] = U(t3, i4);\n if (this.el = _B.createElement(l2, s2), I.currentNode = this.el.content, 2 === i4 || 3 === i4) {\n const t4 = this.el.content.firstChild;\n t4.replaceWith(...t4.childNodes);\n }\n for (; null !== (e5 = I.nextNode()) && r4.length < n5; ) {\n if (1 === e5.nodeType) {\n if (e5.hasAttributes()) for (const t4 of e5.getAttributeNames()) if (t4.endsWith(f2)) {\n const i5 = a2[o4++], s3 = e5.getAttribute(t4).split(v), n6 = /([.?@])?(.*)/.exec(i5);\n r4.push({ type: 1, index: h4, name: n6[2], strings: s3, ctor: \".\" === n6[1] ? Y : \"?\" === n6[1] ? Z : \"@\" === n6[1] ? q : G }), e5.removeAttribute(t4);\n } else t4.startsWith(v) && (r4.push({ type: 6, index: h4 }), e5.removeAttribute(t4));\n if (M.test(e5.tagName)) {\n const t4 = e5.textContent.split(v), i5 = t4.length - 1;\n if (i5 > 0) {\n e5.textContent = c3 ? c3.emptyScript : \"\";\n for (let s3 = 0; s3 < i5; s3++) e5.append(t4[s3], lt()), I.nextNode(), r4.push({ type: 2, index: ++h4 });\n e5.append(t4[i5], lt());\n }\n }\n } else if (8 === e5.nodeType) if (e5.data === m) r4.push({ type: 2, index: h4 });\n else {\n let t4 = -1;\n for (; -1 !== (t4 = e5.data.indexOf(v, t4 + 1)); ) r4.push({ type: 7, index: h4 }), t4 += v.length - 1;\n }\n h4++;\n }\n }\n static createElement(t3, i4) {\n const s2 = w.createElement(\"template\");\n return s2.innerHTML = t3, s2;\n }\n};\nfunction z(t3, i4, s2 = t3, e5) {\n if (i4 === R) return i4;\n let h4 = void 0 !== e5 ? s2.o?.[e5] : s2.l;\n const o4 = st(i4) ? void 0 : i4._$litDirective$;\n return h4?.constructor !== o4 && (h4?._$AO?.(false), void 0 === o4 ? h4 = void 0 : (h4 = new o4(t3), h4._$AT(t3, s2, e5)), void 0 !== e5 ? (s2.o ??= [])[e5] = h4 : s2.l = h4), void 0 !== h4 && (i4 = z(t3, h4._$AS(t3, i4.values), h4, e5)), i4;\n}\nvar F = class {\n constructor(t3, i4) {\n this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i4;\n }\n get parentNode() {\n return this._$AM.parentNode;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n u(t3) {\n const { el: { content: i4 }, parts: s2 } = this._$AD, e5 = (t3?.creationScope ?? w).importNode(i4, true);\n I.currentNode = e5;\n let h4 = I.nextNode(), o4 = 0, n5 = 0, r4 = s2[0];\n for (; void 0 !== r4; ) {\n if (o4 === r4.index) {\n let i5;\n 2 === r4.type ? i5 = new et(h4, h4.nextSibling, this, t3) : 1 === r4.type ? i5 = new r4.ctor(h4, r4.name, r4.strings, this, t3) : 6 === r4.type && (i5 = new K(h4, this, t3)), this._$AV.push(i5), r4 = s2[++n5];\n }\n o4 !== r4?.index && (h4 = I.nextNode(), o4++);\n }\n return I.currentNode = w, e5;\n }\n p(t3) {\n let i4 = 0;\n for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t3, s2, i4), i4 += s2.strings.length - 2) : s2._$AI(t3[i4])), i4++;\n }\n};\nvar et = class _et {\n get _$AU() {\n return this._$AM?._$AU ?? this.v;\n }\n constructor(t3, i4, s2, e5) {\n this.type = 2, this._$AH = D, this._$AN = void 0, this._$AA = t3, this._$AB = i4, this._$AM = s2, this.options = e5, this.v = e5?.isConnected ?? true;\n }\n get parentNode() {\n let t3 = this._$AA.parentNode;\n const i4 = this._$AM;\n return void 0 !== i4 && 11 === t3?.nodeType && (t3 = i4.parentNode), t3;\n }\n get startNode() {\n return this._$AA;\n }\n get endNode() {\n return this._$AB;\n }\n _$AI(t3, i4 = this) {\n t3 = z(this, t3, i4), st(t3) ? t3 === D || null == t3 || \"\" === t3 ? (this._$AH !== D && this._$AR(), this._$AH = D) : t3 !== this._$AH && t3 !== R && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : $(t3) ? this.k(t3) : this._(t3);\n }\n O(t3) {\n return this._$AA.parentNode.insertBefore(t3, this._$AB);\n }\n T(t3) {\n this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3));\n }\n _(t3) {\n this._$AH !== D && st(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(w.createTextNode(t3)), this._$AH = t3;\n }\n $(t3) {\n const { values: i4, _$litType$: s2 } = t3, e5 = \"number\" == typeof s2 ? this._$AC(t3) : (void 0 === s2.el && (s2.el = B.createElement(N(s2.h, s2.h[0]), this.options)), s2);\n if (this._$AH?._$AD === e5) this._$AH.p(i4);\n else {\n const t4 = new F(e5, this), s3 = t4.u(this.options);\n t4.p(i4), this.T(s3), this._$AH = t4;\n }\n }\n _$AC(t3) {\n let i4 = V.get(t3.strings);\n return void 0 === i4 && V.set(t3.strings, i4 = new B(t3)), i4;\n }\n k(t3) {\n g(this._$AH) || (this._$AH = [], this._$AR());\n const i4 = this._$AH;\n let s2, e5 = 0;\n for (const h4 of t3) e5 === i4.length ? i4.push(s2 = new _et(this.O(lt()), this.O(lt()), this, this.options)) : s2 = i4[e5], s2._$AI(h4), e5++;\n e5 < i4.length && (this._$AR(s2 && s2._$AB.nextSibling, e5), i4.length = e5);\n }\n _$AR(t3 = this._$AA.nextSibling, i4) {\n for (this._$AP?.(false, true, i4); t3 && t3 !== this._$AB; ) {\n const i5 = t3.nextSibling;\n t3.remove(), t3 = i5;\n }\n }\n setConnected(t3) {\n void 0 === this._$AM && (this.v = t3, this._$AP?.(t3));\n }\n};\nvar G = class {\n get tagName() {\n return this.element.tagName;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n constructor(t3, i4, s2, e5, h4) {\n this.type = 1, this._$AH = D, this._$AN = void 0, this.element = t3, this.name = i4, this._$AM = e5, this.options = h4, s2.length > 2 || \"\" !== s2[0] || \"\" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = D;\n }\n _$AI(t3, i4 = this, s2, e5) {\n const h4 = this.strings;\n let o4 = false;\n if (void 0 === h4) t3 = z(this, t3, i4, 0), o4 = !st(t3) || t3 !== this._$AH && t3 !== R, o4 && (this._$AH = t3);\n else {\n const e6 = t3;\n let n5, r4;\n for (t3 = h4[0], n5 = 0; n5 < h4.length - 1; n5++) r4 = z(this, e6[s2 + n5], i4, n5), r4 === R && (r4 = this._$AH[n5]), o4 ||= !st(r4) || r4 !== this._$AH[n5], r4 === D ? t3 = D : t3 !== D && (t3 += (r4 ?? \"\") + h4[n5 + 1]), this._$AH[n5] = r4;\n }\n o4 && !e5 && this.j(t3);\n }\n j(t3) {\n t3 === D ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? \"\");\n }\n};\nvar Y = class extends G {\n constructor() {\n super(...arguments), this.type = 3;\n }\n j(t3) {\n this.element[this.name] = t3 === D ? void 0 : t3;\n }\n};\nvar Z = class extends G {\n constructor() {\n super(...arguments), this.type = 4;\n }\n j(t3) {\n this.element.toggleAttribute(this.name, !!t3 && t3 !== D);\n }\n};\nvar q = class extends G {\n constructor(t3, i4, s2, e5, h4) {\n super(t3, i4, s2, e5, h4), this.type = 5;\n }\n _$AI(t3, i4 = this) {\n if ((t3 = z(this, t3, i4, 0) ?? D) === R) return;\n const s2 = this._$AH, e5 = t3 === D && s2 !== D || t3.capture !== s2.capture || t3.once !== s2.once || t3.passive !== s2.passive, h4 = t3 !== D && (s2 === D || e5);\n e5 && this.element.removeEventListener(this.name, this, s2), h4 && this.element.addEventListener(this.name, this, t3), this._$AH = t3;\n }\n handleEvent(t3) {\n \"function\" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3);\n }\n};\nvar K = class {\n constructor(t3, i4, s2) {\n this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i4, this.options = s2;\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AI(t3) {\n z(this, t3);\n }\n};\nvar Re = n3.litHtmlPolyfillSupport;\nRe?.(B, et), (n3.litHtmlVersions ??= []).push(\"3.2.0\");\nvar Q = (t3, i4, s2) => {\n const e5 = s2?.renderBefore ?? i4;\n let h4 = e5._$litPart$;\n if (void 0 === h4) {\n const t4 = s2?.renderBefore ?? null;\n e5._$litPart$ = h4 = new et(i4.insertBefore(lt(), t4), t4, void 0, s2 ?? {});\n }\n return h4._$AI(t3), h4;\n};\n\n// node_modules/lit-element/lit-element.js\nvar h3 = class extends b {\n constructor() {\n super(...arguments), this.renderOptions = { host: this }, this.o = void 0;\n }\n createRenderRoot() {\n const t3 = super.createRenderRoot();\n return this.renderOptions.renderBefore ??= t3.firstChild, t3;\n }\n update(t3) {\n const e5 = this.render();\n this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this.o = Q(e5, this.renderRoot, this.renderOptions);\n }\n connectedCallback() {\n super.connectedCallback(), this.o?.setConnected(true);\n }\n disconnectedCallback() {\n super.disconnectedCallback(), this.o?.setConnected(false);\n }\n render() {\n return R;\n }\n};\nh3._$litElement$ = true, h3[\"finalized\"] = true, globalThis.litElementHydrateSupport?.({ LitElement: h3 });\nvar f3 = globalThis.litElementPolyfillSupport;\nf3?.({ LitElement: h3 });\n(globalThis.litElementVersions ??= []).push(\"4.1.0\");\n\n// node_modules/@lit/reactive-element/decorators/property.js\nvar o3 = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f };\nvar r3 = (t3 = o3, e5, r4) => {\n const { kind: n5, metadata: i4 } = r4;\n let s2 = globalThis.litPropertyMetadata.get(i4);\n if (void 0 === s2 && globalThis.litPropertyMetadata.set(i4, s2 = /* @__PURE__ */ new Map()), s2.set(r4.name, t3), \"accessor\" === n5) {\n const { name: o4 } = r4;\n return { set(r5) {\n const n6 = e5.get.call(this);\n e5.set.call(this, r5), this.requestUpdate(o4, n6, t3);\n }, init(e6) {\n return void 0 !== e6 && this.P(o4, void 0, t3), e6;\n } };\n }\n if (\"setter\" === n5) {\n const { name: o4 } = r4;\n return function(r5) {\n const n6 = this[o4];\n e5.call(this, r5), this.requestUpdate(o4, n6, t3);\n };\n }\n throw Error(\"Unsupported decorator location: \" + n5);\n};\nfunction n4(t3) {\n return (e5, o4) => \"object\" == typeof o4 ? r3(t3, e5, o4) : ((t4, e6, o5) => {\n const r4 = e6.hasOwnProperty(o5);\n return e6.constructor.createProperty(o5, r4 ? { ...t4, wrapped: true } : t4), r4 ? Object.getOwnPropertyDescriptor(e6, o5) : void 0;\n })(t3, e5, o4);\n}\n\n// node_modules/lit-html/directive.js\nvar t2 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };\nvar e4 = (t3) => (...e5) => ({ _$litDirective$: t3, values: e5 });\nvar i3 = class {\n constructor(t3) {\n }\n get _$AU() {\n return this._$AM._$AU;\n }\n _$AT(t3, e5, i4) {\n this.t = t3, this._$AM = e5, this.i = i4;\n }\n _$AS(t3, e5) {\n return this.update(t3, e5);\n }\n update(t3, e5) {\n return this.render(...e5);\n }\n};\n\n// node_modules/lit-html/directives/class-map.js\nvar Rt = e4(class extends i3 {\n constructor(s2) {\n if (super(s2), s2.type !== t2.ATTRIBUTE || \"class\" !== s2.name || s2.strings?.length > 2) throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\");\n }\n render(t3) {\n return \" \" + Object.keys(t3).filter((s2) => t3[s2]).join(\" \") + \" \";\n }\n update(t3, [s2]) {\n if (void 0 === this.st) {\n this.st = /* @__PURE__ */ new Set(), void 0 !== t3.strings && (this.nt = new Set(t3.strings.join(\" \").split(/\\s/).filter((t4) => \"\" !== t4)));\n for (const t4 in s2) s2[t4] && !this.nt?.has(t4) && this.st.add(t4);\n return this.render(s2);\n }\n const i4 = t3.element.classList;\n for (const t4 of this.st) t4 in s2 || (i4.remove(t4), this.st.delete(t4));\n for (const t4 in s2) {\n const r4 = !!s2[t4];\n r4 === this.st.has(t4) || this.nt?.has(t4) || (r4 ? (i4.add(t4), this.st.add(t4)) : (i4.remove(t4), this.st.delete(t4)));\n }\n return R;\n }\n});\n\n// js/ipywidgets_styles.ts\nvar legacyStyles = i`\n .legacy-button {\n align-items: center;\n background-color: var(--jp-layout-color2);\n border-width: 0;\n box-shadow: none;\n color: var(--jp-ui-font-color1);\n cursor: pointer;\n display: flex;\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n justify-content: center;\n line-height: var(--jp-widgets-inline-height);\n padding: 0;\n user-select: none;\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button:hover:enabled,\n .legacy-button:focus:enabled {\n box-shadow: 0 2px 2px 0\n rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 3px 1px -2px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)),\n 0 1px 5px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity));\n }\n\n .legacy-slider {\n -webkit-appearance: none;\n appearance: none;\n background: var(--jp-layout-color3);\n border-radius: 3px;\n height: 4px;\n outline: none;\n }\n\n .legacy-slider::-webkit-slider-thumb,\n .legacy-slider::-moz-range-thumb {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 50%;\n cursor: pointer;\n height: var(--jp-widgets-slider-handle-size);\n width: var(--jp-widgets-slider-handle-size);\n }\n\n .legacy-text {\n color: var(--jp-widgets-label-color);\n font-family: \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n vertical-align: middle;\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: 28px;\n line-height: 28px;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-select[disabled] {\n opacity: 0.4;\n cursor: not-allowed;\n }\n\n .legacy-text-input {\n background: var(--jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding) * 2);\n }\n\n .legacy-text-input:disabled {\n opacity: var(--jp-widgets-disabled-opacity);\n }\n\n .legacy-color {\n align-self: stretch;\n background: var(--jp-widgets-input-background-color);\n border-left: none;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex-grow: 0;\n flex-shrink: 0;\n height: var(--jp-widgets-inline-height);\n outline: none !important;\n padding: 0 2px;\n width: var(--jp-widgets-inline-height);\n }\n\n .legacy-radio {\n margin: 0;\n vertical-align: middle;\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-button.active {\n background-color: var(--colab-primary-surface-color, --jp-layout-color3);\n color: var(--jp-ui-font-color1);\n box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)),\n 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)),\n 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity));\n }\n\n .legacy-button.primary {\n background-color: var(--jp-brand-color1);\n color: var(--jp-ui-inverse-font-color1);\n }\n\n .legacy-button.primary.active {\n background-color: var(--jp-brand-color0);\n color: var(--jp-ui-inverse-font-color0);\n }\n\n .legacy-select {\n -moz-appearance: none;\n -webkit-appearance: none;\n appearance: none;\n background-color: var(--jp-widgets-input-background-color);\n background-image: var(--jp-widgets-dropdown-arrow);\n background-position: right center;\n background-repeat: no-repeat;\n background-size: 20px;\n border-radius: 0;\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n box-shadow: none;\n box-sizing: border-box;\n color: var(--jp-widgets-input-color);\n flex: 1 1 var(--jp-widgets-inline-width-short);\n font-size: var(--jp-widgets-font-size);\n height: inherit;\n min-width: 0;\n outline: none !important;\n padding-left: calc(var(--jp-widgets-input-padding)* 2);\n padding-right: 20px;\n vertical-align: top;\n }\n\n .legacy-input {\n box-sizing: border-box;\n background-color: var(--colab-primary-surface-color, --jp-widgets-input-background-color);\n border: var(--jp-widgets-input-border-width) solid var(--jp-widgets-input-border-color);\n color: var(--jp-widgets-input-color);\n flex-grow: 1;\n flex-shrink: 1;\n font-size: var(--jp-widgets-font-size);\n min-width: 0;\n outline: none !important;\n padding: var(--jp-widgets-input-padding) calc(var(--jp-widgets-input-padding)* 2);\n height: var(--jp-widgets-inline-height);\n line-height: var(--jp-widgets-inline-height);\n }\n`;\n\n// js/utils.ts\nfunction loadFonts() {\n if (!document.querySelector(\".custom-fonts\")) {\n const styleElement = document.createElement(\"style\");\n styleElement.classList.add(\"custom-fonts\");\n styleElement.textContent = '@import \"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\";';\n document.body.appendChild(styleElement);\n }\n}\nfunction reverseMap(map) {\n const reversedMap = /* @__PURE__ */ new Map();\n for (const [key, value] of map.entries()) {\n if (value != null) {\n reversedMap.set(value, key);\n }\n }\n return reversedMap;\n}\n\n// js/lit_widget.ts\nvar LitWidget = class extends h3 {\n constructor() {\n super(...arguments);\n this._model = void 0;\n }\n onCustomMessage(_msg) {\n }\n viewNameToModelName() {\n return reverseMap(this.modelNameToViewName());\n }\n set model(model) {\n this._model = model;\n for (const [modelKey, widgetKey] of this.modelNameToViewName()) {\n if (widgetKey) {\n this[widgetKey] = model.get(modelKey);\n model.on(`change:${String(modelKey)}`, () => {\n this[widgetKey] = model.get(modelKey);\n });\n }\n }\n model.on(\"msg:custom\", (msg) => {\n this.onCustomMessage?.(msg);\n });\n }\n get model() {\n return this._model;\n }\n updated(changedProperties) {\n const viewToModelMap = this.viewNameToModelName();\n for (const [viewProp, _2] of changedProperties) {\n const castViewProp = viewProp;\n if (viewToModelMap.has(castViewProp)) {\n const modelProp = viewToModelMap.get(castViewProp);\n this._model?.set(\n modelProp,\n this[castViewProp]\n );\n }\n }\n this._model?.save_changes();\n }\n};\n\n// js/styles.ts\nvar materialStyles = i`\n @font-face {\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v205/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOejbd5zrDAt.woff2)\n format(\"woff2\");\n }\n\n .material-symbols-outlined {\n -webkit-font-feature-settings: \"liga\";\n -webkit-font-smoothing: antialiased;\n direction: ltr;\n display: inline-block;\n font-family: \"Material Symbols Outlined\";\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-height: 1;\n text-transform: none;\n white-space: nowrap;\n word-wrap: normal;\n }\n`;\nvar flexStyles = i`\n .vertical-flex {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .horizontal-flex {\n align-items: center;\n display: flex;\n flex-wrap: nowrap;\n gap: 8px;\n }\n\n input:not([type=\"radio\"]):not([type=\"checkbox\"]) {\n width: 100%;\n }\n\n select {\n width: 100%;\n }\n\n .horizontal-flex .legacy-text {\n flex-shrink: 0;\n }\n`;\n\n// js/toolbar_item.ts\nvar ToolbarItem = class extends LitWidget {\n constructor() {\n super(...arguments);\n this.active = false;\n this.primary = true;\n this.icon = \"\";\n this.tooltip_text = \"\";\n }\n static get componentName() {\n return `tool-button`;\n }\n static {\n this.styles = [\n legacyStyles,\n materialStyles,\n i`\n button {\n font-size: 16px !important;\n height: 32px;\n padding: 0px 0px 0px 4px;\n width: 32px;\n }\n `\n ];\n }\n modelNameToViewName() {\n return /* @__PURE__ */ new Map([\n [\"active\", \"active\"],\n [\"primary\", \"primary\"],\n [\"icon\", \"icon\"],\n [\"tooltip_text\", \"tooltip_text\"]\n ]);\n }\n render() {\n return ke`\n \n ${this.icon}\n `;\n }\n onClick(_2) {\n this.active = !this.active;\n }\n};\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"active\", 2);\n__decorateClass([\n n4({ type: Boolean })\n], ToolbarItem.prototype, \"primary\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"icon\", 2);\n__decorateClass([\n n4({ type: String })\n], ToolbarItem.prototype, \"tooltip_text\", 2);\nif (!customElements.get(ToolbarItem.componentName)) {\n customElements.define(ToolbarItem.componentName, ToolbarItem);\n}\nasync function render({ model, el }) {\n loadFonts();\n const manager = document.createElement(ToolbarItem.componentName);\n manager.model = model;\n el.appendChild(manager);\n}\nvar toolbar_item_default = { render };\nexport {\n ToolbarItem,\n toolbar_item_default as default\n};\n/*! Bundled license information:\n\n@lit/reactive-element/css-tag.js:\n (**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/reactive-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/lit-html.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-element/lit-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/is-server.js:\n (**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/custom-element.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/property.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/state.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/event-options.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/base.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-all.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-async.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-elements.js:\n (**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\n@lit/reactive-element/decorators/query-assigned-nodes.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directive.js:\n (**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n\nlit-html/directives/class-map.js:\n (**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *)\n*/\n", + "_model_module": "anywidget", + "_model_module_version": "~0.9.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.9.*", + "_view_name": "AnyView", + "active": false, + "icon": "question_mark", + "layout": "IPY_MODEL_13e1ae2b10a04cf9a8dfb8bcdfda78b9", + "primary": true, + "tooltip_text": "Get help" + } + }, + "7dc38477ca5145948632181e660a72ca": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "3117c8f8dc454974ad725820bbaff1b6": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "f306390cbeee4c17acad2a7faea2bda7": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "bd44dc656406487e96cbe194c6261bd0": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "e9f6bd2414c84f258c433913fe8cf03e": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "f0268b4fa5f8426aab5467c9b24a5fdd": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "c5f72af9d368407dbaf5d9e4d7b91392": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "91e3f52fd7364d319305e5af41f1c82c": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "125e8e3b0ac04f6993b13c51a0380138": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "ea343d9e0bb749998ee948e3b48e087f": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "8038eb785016462b8c9b593921119002": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "fbb8c80ca0784cef9d6139a5fbbb6b46": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "49cfc07507794d6aab0419f0f0f17ef4": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "44d6efda35304e3b82becaa0554cc00a": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "a386181ab2a948f39200afa36498a19e": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "313ce92ad01147d1bd2f3e3491f0fabf": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "b6f2cd884b4140c2b954178b4a3f5ffb": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "8dc8b607f2e64e47a962f0a6a9932837": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "e3c9b813e43e46708e9a73470ba31ce6": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "13e1ae2b10a04cf9a8dfb8bcdfda78b9": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + } + } + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file From 3811bccf8244d504b17a5fe3cb14a36915beb63c Mon Sep 17 00:00:00 2001 From: aman verma Date: Thu, 16 Jul 2026 08:01:06 +0000 Subject: [PATCH 045/120] update --- computing/api.py | 81 +- computing/utils.py | 2462 ++++++++++++++++++++++---------------------- 2 files changed, 1291 insertions(+), 1252 deletions(-) diff --git a/computing/api.py b/computing/api.py index 29577fd6..febf9d53 100644 --- a/computing/api.py +++ b/computing/api.py @@ -40,10 +40,15 @@ from computing.misc.drainage_lines import ( clip_drainage_lines as clip_drainage_lines_gee_task, ) +from utilities.pipelines import api_request_payload from .et_downscale.et_downscale import generate_et_downscale +from .forest_fringe.forest_fringe import generate_forest_fringe_degradation +from .misc.livestocks import generate_livestocks_layer_task +from .tree_in_grassland.tree_in_grassland import generate_tree_in_grassland_layer from .utils import ( save_layer_info_to_db, - update_layer_sync_status,) + update_layer_sync_status, +) from computing.misc.drainage_lines_local_compute import ( clip_drainage_lines as clip_drainage_lines_local_task, ) @@ -151,8 +156,12 @@ from .tree_health.local.canopy_height_vector_local import tree_health_ch_vector_local from .tree_health.local.ccd_local import tree_health_ccd_raster_local from .tree_health.local.ccd_vector_local import tree_health_ccd_vector_local -from .tree_health.local.overall_change_local import tree_health_overall_change_raster_local -from .tree_health.local.overall_change_vector_local import tree_health_overall_change_vector_local +from .tree_health.local.overall_change_local import ( + tree_health_overall_change_raster_local, +) +from .tree_health.local.overall_change_vector_local import ( + tree_health_overall_change_vector_local, +) from .utils import ( Geoserver, @@ -173,6 +182,7 @@ get_layers_of_workspace, missing_layer_for_all_workspace, clear_layer_cache, + check_missing_excel_files, ) from .misc.lcw_conflict import generate_lcw_conflict_data from .misc.agroecological_space import generate_agroecological_data @@ -253,6 +263,7 @@ generate_livestocks_data_local as generate_livestocks_data_local_task, ) + @api_security_check(allowed_methods="POST") @schema(None) def generate_admin_boundary(request): @@ -1146,7 +1157,6 @@ def tree_health_raster(request): queue="nrm", ) - return Response( {"Success": "tree_health task initiated"}, status=status.HTTP_200_OK, @@ -1215,11 +1225,7 @@ def tree_health_vector(request): ) print("What is task? ", overall_task) - task_kwargs = { - "state": state, - "district": district, - "block": block - } + task_kwargs = {"state": state, "district": district, "block": block} if not compute == "local": task_kwargs.update( { @@ -2013,7 +2019,11 @@ def generate_facilities_proximity(request): print("Inside generate_facilities_proximity API.") try: payload = api_request_payload( - request.data.dict() if hasattr(request.data, "dict") else dict(request.data), + ( + request.data.dict() + if hasattr(request.data, "dict") + else dict(request.data) + ), overwrite=True, ) generate_facilities_proximity_task.apply_async( @@ -2021,7 +2031,8 @@ def generate_facilities_proximity(request): queue="nrm", ) return Response( - {"Success": "Successfully initiated", "request": payload}, status=status.HTTP_200_OK + {"Success": "Successfully initiated", "request": payload}, + status=status.HTTP_200_OK, ) except ValueError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -2036,7 +2047,11 @@ def generate_antyodaya(request): print("Inside generate_antyodaya API.") try: payload = api_request_payload( - request.data.dict() if hasattr(request.data, "dict") else dict(request.data), + ( + request.data.dict() + if hasattr(request.data, "dict") + else dict(request.data) + ), overwrite=False, ) generate_antyodaya_layer_task.apply_async( @@ -2044,7 +2059,8 @@ def generate_antyodaya(request): queue="nrm", ) return Response( - {"Success": "Successfully initiated", "request": payload}, status=status.HTTP_200_OK + {"Success": "Successfully initiated", "request": payload}, + status=status.HTTP_200_OK, ) except ValueError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -2059,7 +2075,11 @@ def generate_livestocks(request): print("Inside generate_livestocks API.") try: payload = api_request_payload( - request.data.dict() if hasattr(request.data, "dict") else dict(request.data), + ( + request.data.dict() + if hasattr(request.data, "dict") + else dict(request.data) + ), overwrite=False, ) generate_livestocks_layer_task.apply_async( @@ -2067,7 +2087,8 @@ def generate_livestocks(request): queue="nrm", ) return Response( - {"Success": "Successfully initiated", "request": payload}, status=status.HTTP_200_OK + {"Success": "Successfully initiated", "request": payload}, + status=status.HTTP_200_OK, ) except ValueError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -2557,7 +2578,10 @@ def generate_fabdem_raster_vector(request): generate_febdem_raster_vector_clip_local_task, ) if task is None: - return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"Error": "GEE execution not supported for this module."}, + status=status.HTTP_400_BAD_REQUEST, + ) task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": "Successfully initiated"}, status=status.HTTP_200_OK @@ -2583,7 +2607,10 @@ def generate_canal_vector(request): canal_vector_local_task, ) if task is None: - return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"Error": "GEE execution not supported for this module."}, + status=status.HTTP_400_BAD_REQUEST, + ) task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": f"Successfully initiated {compute} task"}, @@ -2612,7 +2639,10 @@ def generate_river_data(request): river_vector_local_task, ) if task is None: - return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"Error": "GEE execution not supported for this module."}, + status=status.HTTP_400_BAD_REQUEST, + ) task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": f"Successfully initiated {compute} task"}, @@ -2639,7 +2669,10 @@ def generate_drainage_density_data(request): drainage_density_vector_local_task, ) if task is None: - return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"Error": "GEE execution not supported for this module."}, + status=status.HTTP_400_BAD_REQUEST, + ) task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": f"Successfully initiated {compute} task"}, @@ -2666,7 +2699,10 @@ def generate_antyodaya(request): generate_antyodaya_data_local_task, ) if task is None: - return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"Error": "GEE execution not supported for this module."}, + status=status.HTTP_400_BAD_REQUEST, + ) task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": f"Successfully initiated {compute} task"}, @@ -2693,7 +2729,10 @@ def generate_livestocks(request): generate_livestocks_data_local_task, ) if task is None: - return Response({"Error": "GEE execution not supported for this module."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"Error": "GEE execution not supported for this module."}, + status=status.HTTP_400_BAD_REQUEST, + ) task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") return Response( {"Success": f"Successfully initiated {compute} task"}, diff --git a/computing/utils.py b/computing/utils.py index c097a423..884e1124 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -1,1231 +1,1231 @@ -import copy -import json -import logging -import os -import shutil -import time -import zipfile -from datetime import datetime, timedelta - -import ee -import fiona -import geopandas as gpd -import requests -from django.conf import settings -from django.core.mail import EmailMessage -from shapely.geometry import shape -from shapely.validation import explain_validity - -from computing.base_layer_setup import with_base_layers -from computing.models import Dataset, Layer -from geoadmin.models import ( - DistrictSOI, - State_Disritct_Block_Properties, - StateSOI, - TehsilSOI, -) -from projects.models import Project -from utilities.constants import ( - ADMIN_BOUNDARY_OUTPUT_DIR, - GEE_ASSET_PATH, - GEE_HELPER_PATH, - GEE_PATHS, - SHAPEFILE_DIR, -) -from utilities.gee_utils import ( - check_task_status, - ee_initialize, - get_gee_asset_path, - get_gee_dir_path, - get_geojson_from_gcs, - is_asset_public, - is_gee_asset_exists, - sync_vector_to_gcs, - valid_gee_text, -) -from utilities.geoserver_utils import Geoserver -from django.core.mail import EmailMessage, get_connection -import time - -logger = logging.getLogger(__name__) - - -def generate_shape_files(path): - gdf = gpd.read_file(path + ".json") - if os.path.exists(path): - # Only replace the target shapefile directory. Removing the parent - # state/workspace directory here corrupts sibling outputs on reruns. - shutil.rmtree(path) - - os.makedirs(os.path.dirname(path), exist_ok=True) - gdf.to_file( - path, - driver="ESRI Shapefile", - ) - return path - - -def convert_to_zip(dir_name, file_type): - if file_type == "gpkg": - if dir_name.split(".")[-1] != "gpkg": - dir_name += ".gpkg" - with zipfile.ZipFile(dir_name + ".zip", "w", zipfile.ZIP_DEFLATED) as zipf: - zipf.write(dir_name, arcname=os.path.basename(dir_name)) - return dir_name + ".zip" - else: - return shutil.make_archive(dir_name, "zip", dir_name + "/") - - -def push_shape_to_geoserver( - path, store_name=None, workspace=None, layer_name=None, file_type="shp" -): - geo = Geoserver() - - print(f"layer_name: {layer_name}") - if layer_name: - try: - print(f"Attempting to delete store: {layer_name}") - geo.delete_vector_store(workspace=workspace, store=layer_name) - print(f"Successfully deleted store: {layer_name}") - except Exception as e: - print(f"Store does not exist or error deleting: {str(e)}") - - zip_path = convert_to_zip(path, file_type) - print(f"Zip path: {zip_path}") - print(f"Store name: {store_name}") - print(f"Workspace: {workspace}") - - response = geo.create_shp_datastore( - path=zip_path, - store_name=store_name, - workspace=workspace, - file_extension=file_type, - ) - print(f"Response: {response}") - return response - - -@with_base_layers("admin_boundary") -def kml_to_geojson(state_name, district_name, block_name, kml_path): - fiona.drvsupport.supported_drivers["kml"] = ( - "rw" # enable KML support which is disabled by default - ) - fiona.drvsupport.supported_drivers["KML"] = ( - "rw" # enable KML support which is disabled by default - ) - gdf = gpd.read_file(kml_path) - geometry_types = gdf.geometry.geometry.type.unique() - state_dir = os.path.join(ADMIN_BOUNDARY_OUTPUT_DIR, state_name) - - for gtype in geometry_types: - df = gdf.loc[gdf.geometry.geometry.type == gtype] - path = os.path.join(state_dir, f"{district_name}_{block_name}_{gtype}") - df.to_file(path + ".json", driver="GeoJSON") - generate_shape_files(path) - push_shape_to_geoserver(path, workspace="test_workspace") - - -def convert_kml_to_shapefile(kml_path, output_dir, shapefile_name): - if not os.path.exists(output_dir + "/" + shapefile_name): - os.makedirs(output_dir + "/" + shapefile_name) - - shapefile_path = os.path.join( - output_dir + "/" + shapefile_name, shapefile_name + ".shp" - ) - print("path path", shapefile_path) - cmd = f"ogr2ogr -f 'ESRI Shapefile' {shapefile_path} {kml_path}" # output.shp input.kml - os.system(command=cmd) - - return output_dir + "/" + shapefile_name - - -def kml_to_shp(state_name, district_name, block_name, kml_path): - shapefile_name = f"{district_name}_{block_name}" - shapefile_layer_path = convert_kml_to_shapefile( - kml_path, SHAPEFILE_DIR, shapefile_name - ) - - push_shape_to_geoserver(shapefile_layer_path, workspace="customkml") - - # os.remove(kml_path) - # shutil.rmtree(shapefile_layer_path) - os.remove(shapefile_layer_path + ".zip") - - -def sync_layer_to_geoserver(state_name, fc, layer_name, workspace): - state_dir = os.path.join("data/fc_to_shape", state_name) - if not os.path.exists(state_dir): - os.mkdir(state_dir) - path = os.path.join(state_dir, f"{layer_name}") - # Write the feature collection into json file - with open(path + ".json", "w") as f: - try: - f.write(f"{json.dumps(fc)}") - except Exception as e: - print(e) - - path = generate_shape_files(path) - return push_shape_to_geoserver(path, workspace=workspace, layer_name=layer_name) - - -def sync_fc_to_geoserver(fc, shp_folder, layer_name, workspace, style_name=None): - try: - geojson_fc = fc.getInfo() - except Exception as e: - print("Exception in getInfo()", e) - task_id = sync_vector_to_gcs(fc, layer_name, "GeoJSON") - check_task_status([task_id]) - - geojson_fc = get_geojson_from_gcs(layer_name) - geo = Geoserver() - if len(geojson_fc["features"]) > 0: - state_dir = os.path.join("data/fc_to_shape", shp_folder) - if not os.path.exists(state_dir): - os.mkdir(state_dir) - path = os.path.join(state_dir, f"{layer_name}") - - # Convert to GeoDataFrame - gdf = gpd.GeoDataFrame.from_features(geojson_fc["features"]) - - # Set CRS (Earth Engine uses EPSG:4326 by default) - gdf.crs = "EPSG:4326" - - gdf = fix_invalid_geometry_in_gdf(gdf) - - # Save as GeoPackage - gdf.to_file(path + ".gpkg", driver="GPKG") - res = push_shape_to_geoserver(path, workspace=workspace, file_type="gpkg") - if style_name: - style_res = geo.publish_style( - layer_name=layer_name, style_name=style_name, workspace=workspace - ) - print("Style response:", style_res) - return res - else: - return "No features in FeatureCollection" - - -def sync_project_fc_to_geoserver(fc, project_name, layer_name, workspace): - print("inside") - print(layer_name) - try: - geojson_fc = fc.getInfo() - except Exception as e: - print("Exception in getInfo()", e) - task_id = sync_vector_to_gcs(fc, layer_name, "GeoJSON") - check_task_status([task_id]) - - geojson_fc = get_geojson_from_gcs(layer_name) - print(len(geojson_fc["features"])) - if len(geojson_fc["features"]) > 0: - state_dir = os.path.join("data/fc_to_shape", project_name) - if not os.path.exists(state_dir): - os.mkdir(state_dir) - path = os.path.join(state_dir, f"{layer_name}") - - # Convert to GeoDataFrame - gdf = gpd.GeoDataFrame.from_features(geojson_fc["features"]) - - # Set CRS (Earth Engine uses EPSG:4326 by default) - gdf.crs = "EPSG:4326" - - gdf = fix_invalid_geometry_in_gdf(gdf) - - # Save as GeoPackage - gdf.to_file(path + ".gpkg", driver="GPKG") - print("pushed to geoserver") - return push_shape_to_geoserver( - path, workspace=workspace, layer_name=layer_name, file_type="gpkg" - ) - else: - print("no features found") - return - - -def to_camelcase(text): - words = text.split() - camelcase = words[0].lower() - for word in words[1:]: - camelcase += word.capitalize() - return camelcase - - -def create_chunk(aoi, description, chunk_size): - size = aoi.size().getInfo() - parts = size // chunk_size - # task_ids = [] - rois = [] - descs = [] - for part in range(parts + 1): - start = part * chunk_size - end = start + chunk_size - block_name_for_parts = description + "_" + str(start) + "-" + str(end) - roi = ee.FeatureCollection(aoi.toList(aoi.size()).slice(start, end)) - if roi.size().getInfo() > 0: - descs.append(block_name_for_parts) - rois.append(roi) - - return rois, descs - - -def merge_chunks( - aoi, - folder_list, - description, - chunk_size, - chunk_asset_path=GEE_HELPER_PATH, - merge_asset_path=GEE_ASSET_PATH, - merge_asset_id=None, -): - print("Merge Chunk task initiated") - ee_initialize() - size = aoi.size().getInfo() - parts = size // chunk_size - assets = [] - for part in range(parts + 1): - start = part * chunk_size - end = start + chunk_size - block_name_for_parts = description + "_" + str(start) + "-" + str(end) - src_asset_id = ( - get_gee_dir_path(folder_list, chunk_asset_path) + block_name_for_parts - ) - if is_gee_asset_exists(src_asset_id): - assets.append(ee.FeatureCollection(src_asset_id)) - - asset = ee.FeatureCollection(assets).flatten() - - asset_id = merge_asset_id or ( - get_gee_dir_path(folder_list, merge_asset_path) + description - ) - try: - # Export an ee.FeatureCollection as an Earth Engine asset. - task = ee.batch.Export.table.toAsset( - **{ - "collection": asset, - "description": description, - "assetId": asset_id, - } - ) - - task.start() - print("Successfully started the merge chunk", task.status()) - return task.status()["id"] - except Exception as e: - print(f"Error occurred in running merge task: {e}") - return None - - -def fix_invalid_geometry_in_gdf(gdf): - invalid = gdf[~gdf.is_valid] - if not invalid.empty: - print("Invalid geometries found:") - for idx, geom in invalid.geometry.items(): - print(f"Index {idx}: {explain_validity(geom)}") - gdf.loc[idx, "geometry"] = gdf.loc[idx, "geometry"].buffer(0) - - return gdf - - -def get_season_key(date): - """Return season key like 'rabi_2017-2018' based on Indian cropping seasons.""" - month = date.month - year = date.year - next_year = year + 1 - - if month in [1, 2]: - return f"rabi_{year - 1}-{year}" # Jan–Feb → Rabi of previous year - elif month in [11, 12]: - return f"rabi_{year}-{next_year}" # Nov–Dec → Rabi starting this year - elif month in [3, 4, 5, 6]: - return f"zaid_{year}-{next_year}" - elif month in [7, 8, 9, 10]: - return f"kharif_{year}-{next_year}" - else: - return None - - -def get_agri_year_key(season_key): - """Convert a season key to agricultural year key (e.g., rabi_2017-2018 → 2017-2018).""" - season, years = season_key.split("_") - start_year, end_year = map(int, years.split("-")) - - if season in ["kharif", "rabi"]: - return f"{start_year}-{end_year}" - elif season == "zaid": - return f"{start_year - 1}-{start_year}" # Zaid 2018-2019 → Agri year 2017-2018 - else: - return None - - -def calculate_precipitation_season( - geojson_filepath, draught_asset_id, start_year=2017, end_year=2024 -): - - # Load the GeoJSON file - with open(geojson_filepath, "r") as f: - feature_collection = json.load(f) - - features_ee = [] - - for feature in feature_collection["features"]: - original_props = feature["properties"] - new_props = {} - - # Copy UID - if "uid" in original_props: - new_props["uid"] = original_props["uid"] - - agri_year_totals = {} - - # Parse precipitation date keys - for key, val in original_props.items(): - try: - date = datetime.strptime(key, "%Y-%m-%d") - season_key = get_season_key(date) - if not season_key: - continue - - agri_key = get_agri_year_key(season_key) - if not agri_key: - continue - - agri_start = int(agri_key.split("-")[0]) - if not (start_year <= agri_start <= end_year): - continue - - season = season_key.split("_")[0] # kharif, rabi etc - full_key = f"{season}_{agri_key}" - - agri_year_totals[full_key] = agri_year_totals.get(full_key, 0) + float( - val - ) - - except Exception: - continue - - # Add all seasonal totals to new_props - for agri_key, total in agri_year_totals.items(): - new_props[f"precipitation_{agri_key}"] = total - - # Create EE Feature - geom_ee = ee.Geometry(feature["geometry"]) - feature_ee = ee.Feature(geom_ee, new_props) - features_ee.append(feature_ee) - - # Left side FC - mws_fc = ee.FeatureCollection(features_ee) - - return mws_fc - - -def generate_geojson_with_ci_and_ndvi(zoi_asset, ci_asset, ndvi_asset, proj_id): - # Load project - proj_obj = Project.objects.get(pk=proj_id) - - # Build CI and NDVI asset paths - asset_path_ci = ( - get_gee_dir_path( - [proj_obj.name], asset_path=GEE_PATHS["WATER_REJ"]["GEE_ASSET_PATH"] - ) - + ci_asset - ) - - asset_path_ndvi = ( - get_gee_dir_path( - [proj_obj.name], asset_path=GEE_PATHS["WATER_REJ"]["GEE_ASSET_PATH"] - ) - + ndvi_asset - ) - - # Load FeatureCollections - zoi = ee.FeatureCollection(zoi_asset) - ci = ee.FeatureCollection(asset_path_ci) - ndvi = ee.FeatureCollection(asset_path_ndvi) - - # ------------------------- - # STEP 1: Join ZOI with Cropping Intensity - # ------------------------- - join = ee.Join.inner() - filter = ee.Filter.intersects(leftField=".geo", rightField=".geo") - zoi_ci_joined = join.apply(zoi, ci, filter) - - def merge_zoi_ci(pair): - zoi_feat = ee.Feature(pair.get("primary")) - ci_feat = ee.Feature(pair.get("secondary")) - merged_props = zoi_feat.toDictionary().combine(ci_feat.toDictionary(), True) - return ee.Feature(zoi_feat.geometry(), merged_props) - - zoi_with_ci = ee.FeatureCollection(zoi_ci_joined.map(merge_zoi_ci)) - - # ------------------------- - # STEP 2: Join ZOI+CI with NDVI - # ------------------------- - zoi_ndvi_joined = join.apply(zoi_with_ci, ndvi, filter) - - def merge_zoi_ci_ndvi(pair): - ci_feat = ee.Feature(pair.get("primary")) - ndvi_feat = ee.Feature(pair.get("secondary")) - merged_props = ci_feat.toDictionary().combine(ndvi_feat.toDictionary(), True) - return ee.Feature(ci_feat.geometry(), merged_props) - - final_merged = ee.FeatureCollection(zoi_ndvi_joined.map(merge_zoi_ci_ndvi)) - - # ------------------------- - # STEP 3: Export or Push to GeoServer - # ------------------------- - layer_name = f"WaterRejapp_zoi_{proj_obj.name}_{proj_obj.id}" - sync_project_fc_to_geoserver(final_merged, proj_obj.name, layer_name, "waterrej") - - -def get_directory_size(path): - total_size = 0 - for dirpath, dirnames, filenames in os.walk(path): - for filename in filenames: - file_path = os.path.join(dirpath, filename) - if os.path.isfile(file_path): - total_size += os.path.getsize(file_path) - return total_size - - -def generate_geojson_with_ci_ndvi_ndmi( - zoi_asset, ci_asset, ndvi_asset, ndmi_asset, proj_id -): - - # Load project - proj_obj = Project.objects.get(pk=proj_id) - - zoi = ee.FeatureCollection(zoi_asset) - print("Number of features zoi:", zoi.size().getInfo()) - - ci = ee.FeatureCollection(ci_asset) - print("Number of features zoi:", ci.size().getInfo()) - ndvi = ee.FeatureCollection(ndmi_asset) - print("Number of features zoi:", ndvi.size().getInfo()) - ndmi = ee.FeatureCollection(ndmi_asset) - print("Number of features zoi:", ndmi.size().getInfo()) - - # ------------------------- - # STEP 1: Join ZOI with CI - # ------------------------- - join = ee.Join.inner() - filter = ee.Filter.intersects(leftField=".geo", rightField=".geo") - zoi_ci_joined = join.apply(zoi, ci, filter) - - def merge_zoi_ci(pair): - zoi_feat = ee.Feature(pair.get("primary")) - ci_feat = ee.Feature(pair.get("secondary")) - merged_props = zoi_feat.toDictionary().combine(ci_feat.toDictionary(), True) - return ee.Feature(zoi_feat.geometry(), merged_props) # ✅ keep ZOI geom - - zoi_with_ci = ee.FeatureCollection(zoi_ci_joined.map(merge_zoi_ci)) - - # ------------------------- - # STEP 2: Join with NDVI - # ------------------------- - zoi_ndvi_joined = join.apply(zoi_with_ci, ndvi, filter) - - def merge_zoi_ci_ndvi(pair): - prev_feat = ee.Feature(pair.get("primary")) - ndvi_feat = ee.Feature(pair.get("secondary")) - merged_props = prev_feat.toDictionary().combine(ndvi_feat.toDictionary(), True) - return ee.Feature(prev_feat.geometry(), merged_props) # ✅ still ZOI geom - - zoi_ci_ndvi = ee.FeatureCollection(zoi_ndvi_joined.map(merge_zoi_ci_ndvi)) - - # ------------------------- - # STEP 3: Join with NDMI - # ------------------------- - zoi_ndmi_joined = join.apply(zoi_ci_ndvi, ndmi, filter) - - def merge_zoi_ci_ndvi_ndmi(pair): - prev_feat = ee.Feature(pair.get("primary")) - ndmi_feat = ee.Feature(pair.get("secondary")) - merged_props = prev_feat.toDictionary().combine(ndmi_feat.toDictionary(), True) - return ee.Feature(prev_feat.geometry(), merged_props) # ✅ keep ZOI geom - - final_merged = ee.FeatureCollection(zoi_ndmi_joined.map(merge_zoi_ci_ndvi_ndmi)) - - # ------------------------- - # STEP 4: Export or Push to GeoServer - # ------------------------- - layer_name = f"WaterRejapp_zoi_{proj_obj.name}_{proj_obj.id}" - print(layer_name) - sync_project_fc_to_geoserver(final_merged, proj_obj.name, layer_name, "waterrej") - - -def generate_geojson_with_ci_ndvi(zoi_asset, ci_asset, ndvi_asset, proj_id): - # Load project - proj_obj = Project.objects.get(pk=proj_id) - - # Initialize Earth Engine - ee_initialize(4) - - # Load FeatureCollections - zoi = ee.FeatureCollection(zoi_asset) - ci = ee.FeatureCollection(ci_asset) - ndvi = ee.FeatureCollection(ndvi_asset) - - print("ZOI:", zoi.size().getInfo()) - print("CI:", ci.size().getInfo()) - print("NDVI:", ndvi.size().getInfo()) - - # Common join logic on UID - join = ee.Join.inner() - uid_filter = ee.Filter.equals(leftField="UID", rightField="UID") - - # --- Join ZOI + CI --- - zoi_ci_joined = join.apply(zoi, ci, uid_filter) - - def merge_zoi_ci(pair): - zoi_feat = ee.Feature(pair.get("primary")) - ci_feat = ee.Feature(pair.get("secondary")) - merged_props = zoi_feat.toDictionary().combine(ci_feat.toDictionary(), True) - # Keep ZOI geometry only - return ee.Feature(zoi_feat.geometry(), merged_props) - - zoi_with_ci = ee.FeatureCollection(zoi_ci_joined.map(merge_zoi_ci)) - - # --- Join with NDVI --- - zoi_ndvi_joined = join.apply(zoi_with_ci, ndvi, uid_filter) - - def merge_with_ndvi(pair): - base_feat = ee.Feature(pair.get("primary")) - ndvi_feat = ee.Feature(pair.get("secondary")) - merged_props = base_feat.toDictionary().combine(ndvi_feat.toDictionary(), True) - # Always retain ZOI geometry - return ee.Feature(base_feat.geometry(), merged_props) - - merged_final = ee.FeatureCollection(zoi_ndvi_joined.map(merge_with_ndvi)) - - # --- Ensure ZOI geometry retained in all features --- - merged_final = merged_final.map( - lambda f: ee.Feature( - f.setGeometry( - ee.Feature( - zoi.filter(ee.Filter.eq("UID", f.get("UID"))).first() - ).geometry() - ) - ) - ) - - layer_name = f"WaterRejapp_zoi_{proj_obj.name}_{proj_obj.id}" - print(layer_name) - - sync_project_fc_to_geoserver(merged_final, proj_obj.name, layer_name, "waterrej") - - -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 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) - layer_obj = Layer.objects.get(dataset=dataset, layer_name=layer_name) - existing_end_date = layer_obj.misc["end_year"] - print("existing_end_date", existing_end_date) - return existing_end_date - - -def get_layer_object(state, district, block, layer_name, dataset_name): - 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) - layer_obj = ( - Layer.objects.filter( - state=state_obj, - district=district_obj, - block=block_obj, - layer_name=layer_name, - dataset__name=dataset_name, - ) - .order_by("-layer_version") - .first() - ) - return layer_obj - - -def update_dashboard_geojson( - state=None, - district=None, - block=None, - layer_name=None, - workspace_name=None, - proj_id=None, -): - if state and block and block: - print(f"🔄 Updating GeoJSON for {state}, {district}, {block}") - - # Get related objects - state_obj = StateSOI.objects.get(state_name=state) - district_obj = DistrictSOI.objects.get(district_name=district) - tehsil_obj = TehsilSOI.objects.get(tehsil_name=block) # fixed typo - - # Get or create main record - obj, created = State_Disritct_Block_Properties.objects.get_or_create( - state=state_obj, district=district_obj, tehsil=tehsil_obj - ) - else: - obj = Project.objects.get(pk=proj_id) - - # Map suffix to json_key - suffix_to_key = { - "wb": "wb_geojson", - "zoi": "zoi_geojson", - "mws": "mws_geojson", - } - - # Detect which key this layer corresponds to - json_key = None - for suffix, key in suffix_to_key.items(): - if layer_name == f"{state}_{district}_{block}_{suffix}": - json_key = key - break - - if not json_key: - print(f"⚠️ Layer name {layer_name} did not match any known type.") - return - - # Construct GeoServer URL - waterrej_url = ( - f"https://geoserver.core-stack.org:8443/geoserver/waterrej/ows?" - f"service=WFS&version=1.0.0&request=GetFeature&typeName={workspace_name}:{layer_name}" - f"&outputFormat=application%2Fjson" - ) - - # Load existing dashboard_geojson or create new - if proj_id: - misc = obj.dashboard_geojson or {} - else: - misc = obj.geojson_path or {} - - # Ensure waterrej section exists - if "waterrej" not in misc: - misc["waterrej"] = {} - - # Update or add this specific json_key - misc["waterrej"][json_key] = waterrej_url - - # Save the updated JSON field - obj.dashboard_geojson = misc - obj.save() - - print(f"✅ Added/Updated {json_key} for {state}, {district}, {block}") - - -def clean_geometry(geom): - """ - Clean geometry: - - Dissolve multipolygon → single polygon - - Remove holes automatically - - Fix invalid topology - - Buffer tiny polygons - """ - - # 1. Dissolve multi-polygons and remove holes - geom = geom.dissolve(maxError=1) - - # 2. Fix invalid rings by simplifying slightly (NEVER buffer(0)) - geom = geom.simplify(1) - - # 3. Buffer polygons smaller than 1 pixel (< 900 m²) - area = geom.area() - geom = ee.Algorithms.If( - area.lt(900), - geom.buffer(15), - geom, # ensure raster pixel center is captured - ) - - return ee.Geometry(geom) - - -def safe_reduce_max(image, geom, scale=30): - geom = clean_geometry(geom) - - val = ( - image.unmask(0) - .reduceRegion( - reducer=ee.Reducer.max(), - geometry=geom, - scale=scale, - maxPixels=1e13, - tileScale=4, - bestEffort=True, - ) - .get("b1") - ) - - return ee.Number(ee.Algorithms.If(val, val, 0)) - - -# ------------------------------------------------------ -# SAFE REDUCE MAX FUNCTION -# ------------------------------------------------------ -def safe_reduce_max(image, geom, scale=30): - geom = clean_geometry(geom) - - result = ( - image.unmask(0) - .reduceRegion( - reducer=ee.Reducer.max(), - geometry=geom, - scale=scale, - maxPixels=1e13, - tileScale=4, - bestEffort=True, - ) - .get("b1") - ) - - # Convert null → 0 - return ee.Number(ee.Algorithms.If(result, result, 0)) - - -# ------------------------------------------------------ -# MAIN FUNCTION TO PROCESS SWB LAYER -# ------------------------------------------------------ -def generate_swb_layer_with_max_so_catchment( - roi=None, - app_type="MWS", - asset_suffix=None, - asset_folder=None, - gee_account_id=None, -): - ee_initialize(gee_account_id) - - # Build asset paths - base_path = get_gee_dir_path( - asset_folder, asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"] - ) - - so_asset = f"{base_path}stream_order_{asset_suffix}_raster" - ca_asset = f"{base_path}catchment_area_{asset_suffix}_raster" - - # Load rasters - stream_order_band = ee.Image(so_asset).select("b1") - catchment_band = ee.Image(ca_asset).select("b1") - - # Processing per waterbody - def compute_for_feature(feature): - geom = feature.geometry() - - max_so = safe_reduce_max(stream_order_band, geom, scale=30) - max_ca = safe_reduce_max(catchment_band, geom, scale=30) - - return feature.set( - { - "max_stream_order": max_so, - "max_catchment_area": max_ca, - } - ) - - # Map over the feature collection - return roi.map(compute_for_feature) - - - - -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.") - _deprecated_misc_keys = {"is_computed_locally"} - base_misc = { - k: v - for k, v in (existing_layer.misc or {}).items() - if k not in _deprecated_misc_keys - } - merged_misc = {**base_misc, **(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": merged_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 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).first() - if layer_obj is None: - return None - - update_fields = [] - if sync_to_geoserver is not None: - layer_obj.is_sync_to_geoserver = sync_to_geoserver - update_fields.append("is_sync_to_geoserver") - if is_stac_specs_generated is not None: - layer_obj.is_stac_specs_generated = is_stac_specs_generated - update_fields.append("is_stac_specs_generated") - - # `save(update_fields=...)` fires the post_save signal so the STAC - # auto-trigger handler in `computing.signals` can pick up the flip. - if update_fields: - layer_obj.save(update_fields=update_fields) - print( - f"Updated {update_fields} for layer ID: {layer_id} " - f"(sync={sync_to_geoserver}, stac={is_stac_specs_generated})" - ) - return layer_id - - except Exception as e: - print(f"Error updating layer sync status: {e}") - - -def _is_cache_valid(cache: dict, workspace: str) -> bool: - if workspace not in cache: - return False - age = time.time() - cache[workspace]["cached_at"] - if age > 3600: - logger.info(f"Cache expired for {workspace} (age: {int(age)}s)") - return False - return True - - -def _set_cache(cache: dict, workspace: str, data: set): - cache[workspace] = { - "data": data, - "cached_at": time.time(), - } - - -def send_report_email( - result, - report_type: str = "missing_layers", - recipients: list = None, -) -> bool: - """ - Generic reusable function to email a JSON report. - report_type: "missing_layers" or "missing_excel_files" - """ - if recipients is None: - recipients = getattr(settings, "MISSING_LAYER_RECIPIENTS", []) - - if isinstance(recipients, str): - recipients = [recipients] - - if not recipients: - logger.error("No recipients configured for report email.") - return False - - if report_type == "missing_layers": - subject = "Missing Layers Report" - attachment_name = "missing_layers.json" - - strict_result = result.get("Mandatory", {}) - can_be_empty_result = result.get("can_be_empty", {}) - - # Filter out workspaces with zero missing layers - strict_filtered = { - ws: data for ws, data in strict_result.items() if data.get("missing_layers") - } - can_be_empty_filtered = { - ws: data - for ws, data in can_be_empty_result.items() - if data.get("missing_layers") - } - - strict_summary = [] - total_strict_missing = 0 - for layer, data in strict_filtered.items(): - count = len(data.get("missing_layers", [])) - total_strict_missing += count - strict_summary.append(f"{layer}: {count}") - - can_be_empty_summary = [] - total_can_be_empty_missing = 0 - for layer, data in can_be_empty_filtered.items(): - count = len(data.get("missing_layers", [])) - total_can_be_empty_missing += count - can_be_empty_summary.append(f"{layer}: {count}") - - total_missing = total_strict_missing + total_can_be_empty_missing - - body = ( - "Missing Layers Report\n\n" - f"Total Missing: {total_missing}\n" - f" - Mandatory (needs attention): {total_strict_missing}\n" - f" - Can-Be-Empty (may be legitimately absent): {total_can_be_empty_missing}\n\n" - "---- Mandatory Workspaces (data expected everywhere) ----\n" - + ( - "\n".join(strict_summary) - if strict_summary - else "None — nothing missing" - ) - + "\n\n" - "---- Can-Be-Empty Workspaces (some locations may legitimately have no data) ----\n" - + ( - "\n".join(can_be_empty_summary) - if can_be_empty_summary - else "None — nothing missing" - ) - + "\n\nDetailed report attached." - ) - result = { - "Mandatory": strict_filtered, - "can_be_empty": can_be_empty_filtered, - } - - elif report_type == "missing_excel_files": - subject = "Missing Stats Excel/JSON Files Report" - attachment_name = "missing_excel_files.json" - total_locations = len(result) - total_missing_files = sum(len(loc.get("missing_files", [])) for loc in result) - total_xlsx_issues = sum(1 for loc in result if loc.get("xlsx_issues")) - body = ( - "Missing Stats Excel/JSON Files Report\n\n" - f"Tehsils which files(json/excel) are missing: {total_locations}\n" - f"Total missing files: {total_missing_files}\n" - f"Tehsils with xlsx sheet missing: {total_xlsx_issues}\n\n" - "Detailed report attached." - ) - - else: - logger.error(f"Unknown report_type: {report_type}") - return False - - attachment_content = json.dumps(result, indent=4) - max_retries = 3 - - for attempt in range(max_retries): - connection = None - try: - connection = get_connection(timeout=120) - connection.open() - email = EmailMessage( - subject=subject, - body=body, - from_email=settings.EMAIL_HOST_USER, - to=recipients, - connection=connection, - ) - email.attach( - attachment_name, - attachment_content, - "application/json", - ) - email.send() - logger.info(f"{subject} sent to {recipients}") - logger.info( - f"Attachment size: " - f"{len(attachment_content.encode('utf-8')) / 1024:.2f} KB" - ) - return True - except Exception as e: - logger.exception(f"Attempt {attempt + 1}/{max_retries} failed: {e}") - if attempt < max_retries - 1: - wait_time = 5 * (attempt + 1) - logger.info(f"Retrying after {wait_time} seconds...") - time.sleep(wait_time) - else: - logger.error("All attempts to send email failed.") - return False - finally: - if connection: - try: - connection.close() - except Exception: - pass - return False +import copy +import json +import logging +import os +import shutil +import time +import zipfile +from datetime import datetime, timedelta + +import ee +import fiona +import geopandas as gpd +import requests +from django.conf import settings +from django.core.mail import EmailMessage +from shapely.geometry import shape +from shapely.validation import explain_validity + +from computing.base_layer_setup import with_base_layers +from computing.models import Dataset, Layer +from geoadmin.models import ( + DistrictSOI, + State_Disritct_Block_Properties, + StateSOI, + TehsilSOI, +) +from projects.models import Project +from utilities.constants import ( + ADMIN_BOUNDARY_OUTPUT_DIR, + GEE_ASSET_PATH, + GEE_HELPER_PATH, + GEE_PATHS, + SHAPEFILE_DIR, +) +from utilities.gee_utils import ( + check_task_status, + ee_initialize, + get_gee_asset_path, + get_gee_dir_path, + get_geojson_from_gcs, + is_asset_public, + is_gee_asset_exists, + sync_vector_to_gcs, + valid_gee_text, +) +from utilities.geoserver_utils import Geoserver +from django.core.mail import EmailMessage, get_connection +import time + +logger = logging.getLogger(__name__) + + +def generate_shape_files(path): + gdf = gpd.read_file(path + ".json") + if os.path.exists(path): + # Only replace the target shapefile directory. Removing the parent + # state/workspace directory here corrupts sibling outputs on reruns. + shutil.rmtree(path) + + os.makedirs(os.path.dirname(path), exist_ok=True) + gdf.to_file( + path, + driver="ESRI Shapefile", + ) + return path + + +def convert_to_zip(dir_name, file_type): + if file_type == "gpkg": + if dir_name.split(".")[-1] != "gpkg": + dir_name += ".gpkg" + with zipfile.ZipFile(dir_name + ".zip", "w", zipfile.ZIP_DEFLATED) as zipf: + zipf.write(dir_name, arcname=os.path.basename(dir_name)) + return dir_name + ".zip" + else: + return shutil.make_archive(dir_name, "zip", dir_name + "/") + + +def push_shape_to_geoserver( + path, store_name=None, workspace=None, layer_name=None, file_type="shp" +): + geo = Geoserver() + + print(f"layer_name: {layer_name}") + if layer_name: + try: + print(f"Attempting to delete store: {layer_name}") + geo.delete_vector_store(workspace=workspace, store=layer_name) + print(f"Successfully deleted store: {layer_name}") + except Exception as e: + print(f"Store does not exist or error deleting: {str(e)}") + + zip_path = convert_to_zip(path, file_type) + print(f"Zip path: {zip_path}") + print(f"Store name: {store_name}") + print(f"Workspace: {workspace}") + + response = geo.create_shp_datastore( + path=zip_path, + store_name=store_name, + workspace=workspace, + file_extension=file_type, + ) + print(f"Response: {response}") + return response + + +@with_base_layers("admin_boundary") +def kml_to_geojson(state_name, district_name, block_name, kml_path): + fiona.drvsupport.supported_drivers["kml"] = ( + "rw" # enable KML support which is disabled by default + ) + fiona.drvsupport.supported_drivers["KML"] = ( + "rw" # enable KML support which is disabled by default + ) + gdf = gpd.read_file(kml_path) + geometry_types = gdf.geometry.geometry.type.unique() + state_dir = os.path.join(ADMIN_BOUNDARY_OUTPUT_DIR, state_name) + + for gtype in geometry_types: + df = gdf.loc[gdf.geometry.geometry.type == gtype] + path = os.path.join(state_dir, f"{district_name}_{block_name}_{gtype}") + df.to_file(path + ".json", driver="GeoJSON") + generate_shape_files(path) + push_shape_to_geoserver(path, workspace="test_workspace") + + +def convert_kml_to_shapefile(kml_path, output_dir, shapefile_name): + if not os.path.exists(output_dir + "/" + shapefile_name): + os.makedirs(output_dir + "/" + shapefile_name) + + shapefile_path = os.path.join( + output_dir + "/" + shapefile_name, shapefile_name + ".shp" + ) + print("path path", shapefile_path) + cmd = f"ogr2ogr -f 'ESRI Shapefile' {shapefile_path} {kml_path}" # output.shp input.kml + os.system(command=cmd) + + return output_dir + "/" + shapefile_name + + +def kml_to_shp(state_name, district_name, block_name, kml_path): + shapefile_name = f"{district_name}_{block_name}" + shapefile_layer_path = convert_kml_to_shapefile( + kml_path, SHAPEFILE_DIR, shapefile_name + ) + + push_shape_to_geoserver(shapefile_layer_path, workspace="customkml") + + # os.remove(kml_path) + # shutil.rmtree(shapefile_layer_path) + os.remove(shapefile_layer_path + ".zip") + + +def sync_layer_to_geoserver(state_name, fc, layer_name, workspace): + state_dir = os.path.join("data/fc_to_shape", state_name) + if not os.path.exists(state_dir): + os.mkdir(state_dir) + path = os.path.join(state_dir, f"{layer_name}") + # Write the feature collection into json file + with open(path + ".json", "w") as f: + try: + f.write(f"{json.dumps(fc)}") + except Exception as e: + print(e) + + path = generate_shape_files(path) + return push_shape_to_geoserver(path, workspace=workspace, layer_name=layer_name) + + +def sync_fc_to_geoserver(fc, shp_folder, layer_name, workspace, style_name=None): + try: + geojson_fc = fc.getInfo() + except Exception as e: + print("Exception in getInfo()", e) + task_id = sync_vector_to_gcs(fc, layer_name, "GeoJSON") + check_task_status([task_id]) + + geojson_fc = get_geojson_from_gcs(layer_name) + geo = Geoserver() + if len(geojson_fc["features"]) > 0: + state_dir = os.path.join("data/fc_to_shape", shp_folder) + if not os.path.exists(state_dir): + os.mkdir(state_dir) + path = os.path.join(state_dir, f"{layer_name}") + + # Convert to GeoDataFrame + gdf = gpd.GeoDataFrame.from_features(geojson_fc["features"]) + + # Set CRS (Earth Engine uses EPSG:4326 by default) + gdf.crs = "EPSG:4326" + + gdf = fix_invalid_geometry_in_gdf(gdf) + + # Save as GeoPackage + gdf.to_file(path + ".gpkg", driver="GPKG") + res = push_shape_to_geoserver(path, workspace=workspace, file_type="gpkg") + if style_name: + style_res = geo.publish_style( + layer_name=layer_name, style_name=style_name, workspace=workspace + ) + print("Style response:", style_res) + return res + else: + return "No features in FeatureCollection" + + +def sync_project_fc_to_geoserver(fc, project_name, layer_name, workspace): + print("inside") + print(layer_name) + try: + geojson_fc = fc.getInfo() + except Exception as e: + print("Exception in getInfo()", e) + task_id = sync_vector_to_gcs(fc, layer_name, "GeoJSON") + check_task_status([task_id]) + + geojson_fc = get_geojson_from_gcs(layer_name) + print(len(geojson_fc["features"])) + if len(geojson_fc["features"]) > 0: + state_dir = os.path.join("data/fc_to_shape", project_name) + if not os.path.exists(state_dir): + os.mkdir(state_dir) + path = os.path.join(state_dir, f"{layer_name}") + + # Convert to GeoDataFrame + gdf = gpd.GeoDataFrame.from_features(geojson_fc["features"]) + + # Set CRS (Earth Engine uses EPSG:4326 by default) + gdf.crs = "EPSG:4326" + + gdf = fix_invalid_geometry_in_gdf(gdf) + + # Save as GeoPackage + gdf.to_file(path + ".gpkg", driver="GPKG") + print("pushed to geoserver") + return push_shape_to_geoserver( + path, workspace=workspace, layer_name=layer_name, file_type="gpkg" + ) + else: + print("no features found") + return + + +def to_camelcase(text): + words = text.split() + camelcase = words[0].lower() + for word in words[1:]: + camelcase += word.capitalize() + return camelcase + + +def create_chunk(aoi, description, chunk_size): + size = aoi.size().getInfo() + parts = size // chunk_size + # task_ids = [] + rois = [] + descs = [] + for part in range(parts + 1): + start = part * chunk_size + end = start + chunk_size + block_name_for_parts = description + "_" + str(start) + "-" + str(end) + roi = ee.FeatureCollection(aoi.toList(aoi.size()).slice(start, end)) + if roi.size().getInfo() > 0: + descs.append(block_name_for_parts) + rois.append(roi) + + return rois, descs + + +def merge_chunks( + aoi, + folder_list, + description, + chunk_size, + chunk_asset_path=GEE_HELPER_PATH, + merge_asset_path=GEE_ASSET_PATH, + merge_asset_id=None, +): + print("Merge Chunk task initiated") + ee_initialize() + size = aoi.size().getInfo() + parts = size // chunk_size + assets = [] + for part in range(parts + 1): + start = part * chunk_size + end = start + chunk_size + block_name_for_parts = description + "_" + str(start) + "-" + str(end) + src_asset_id = ( + get_gee_dir_path(folder_list, chunk_asset_path) + block_name_for_parts + ) + if is_gee_asset_exists(src_asset_id): + assets.append(ee.FeatureCollection(src_asset_id)) + + asset = ee.FeatureCollection(assets).flatten() + + asset_id = merge_asset_id or ( + get_gee_dir_path(folder_list, merge_asset_path) + description + ) + try: + # Export an ee.FeatureCollection as an Earth Engine asset. + task = ee.batch.Export.table.toAsset( + **{ + "collection": asset, + "description": description, + "assetId": asset_id, + } + ) + + task.start() + print("Successfully started the merge chunk", task.status()) + return task.status()["id"] + except Exception as e: + print(f"Error occurred in running merge task: {e}") + return None + + +def fix_invalid_geometry_in_gdf(gdf): + invalid = gdf[~gdf.is_valid] + if not invalid.empty: + print("Invalid geometries found:") + for idx, geom in invalid.geometry.items(): + print(f"Index {idx}: {explain_validity(geom)}") + gdf.loc[idx, "geometry"] = gdf.loc[idx, "geometry"].buffer(0) + + return gdf + + +def get_season_key(date): + """Return season key like 'rabi_2017-2018' based on Indian cropping seasons.""" + month = date.month + year = date.year + next_year = year + 1 + + if month in [1, 2]: + return f"rabi_{year - 1}-{year}" # Jan–Feb → Rabi of previous year + elif month in [11, 12]: + return f"rabi_{year}-{next_year}" # Nov–Dec → Rabi starting this year + elif month in [3, 4, 5, 6]: + return f"zaid_{year}-{next_year}" + elif month in [7, 8, 9, 10]: + return f"kharif_{year}-{next_year}" + else: + return None + + +def get_agri_year_key(season_key): + """Convert a season key to agricultural year key (e.g., rabi_2017-2018 → 2017-2018).""" + season, years = season_key.split("_") + start_year, end_year = map(int, years.split("-")) + + if season in ["kharif", "rabi"]: + return f"{start_year}-{end_year}" + elif season == "zaid": + return f"{start_year - 1}-{start_year}" # Zaid 2018-2019 → Agri year 2017-2018 + else: + return None + + +def calculate_precipitation_season( + geojson_filepath, draught_asset_id, start_year=2017, end_year=2024 +): + + # Load the GeoJSON file + with open(geojson_filepath, "r") as f: + feature_collection = json.load(f) + + features_ee = [] + + for feature in feature_collection["features"]: + original_props = feature["properties"] + new_props = {} + + # Copy UID + if "uid" in original_props: + new_props["uid"] = original_props["uid"] + + agri_year_totals = {} + + # Parse precipitation date keys + for key, val in original_props.items(): + try: + date = datetime.strptime(key, "%Y-%m-%d") + season_key = get_season_key(date) + if not season_key: + continue + + agri_key = get_agri_year_key(season_key) + if not agri_key: + continue + + agri_start = int(agri_key.split("-")[0]) + if not (start_year <= agri_start <= end_year): + continue + + season = season_key.split("_")[0] # kharif, rabi etc + full_key = f"{season}_{agri_key}" + + agri_year_totals[full_key] = agri_year_totals.get(full_key, 0) + float( + val + ) + + except Exception: + continue + + # Add all seasonal totals to new_props + for agri_key, total in agri_year_totals.items(): + new_props[f"precipitation_{agri_key}"] = total + + # Create EE Feature + geom_ee = ee.Geometry(feature["geometry"]) + feature_ee = ee.Feature(geom_ee, new_props) + features_ee.append(feature_ee) + + # Left side FC + mws_fc = ee.FeatureCollection(features_ee) + + return mws_fc + + +def generate_geojson_with_ci_and_ndvi(zoi_asset, ci_asset, ndvi_asset, proj_id): + # Load project + proj_obj = Project.objects.get(pk=proj_id) + + # Build CI and NDVI asset paths + asset_path_ci = ( + get_gee_dir_path( + [proj_obj.name], asset_path=GEE_PATHS["WATER_REJ"]["GEE_ASSET_PATH"] + ) + + ci_asset + ) + + asset_path_ndvi = ( + get_gee_dir_path( + [proj_obj.name], asset_path=GEE_PATHS["WATER_REJ"]["GEE_ASSET_PATH"] + ) + + ndvi_asset + ) + + # Load FeatureCollections + zoi = ee.FeatureCollection(zoi_asset) + ci = ee.FeatureCollection(asset_path_ci) + ndvi = ee.FeatureCollection(asset_path_ndvi) + + # ------------------------- + # STEP 1: Join ZOI with Cropping Intensity + # ------------------------- + join = ee.Join.inner() + filter = ee.Filter.intersects(leftField=".geo", rightField=".geo") + zoi_ci_joined = join.apply(zoi, ci, filter) + + def merge_zoi_ci(pair): + zoi_feat = ee.Feature(pair.get("primary")) + ci_feat = ee.Feature(pair.get("secondary")) + merged_props = zoi_feat.toDictionary().combine(ci_feat.toDictionary(), True) + return ee.Feature(zoi_feat.geometry(), merged_props) + + zoi_with_ci = ee.FeatureCollection(zoi_ci_joined.map(merge_zoi_ci)) + + # ------------------------- + # STEP 2: Join ZOI+CI with NDVI + # ------------------------- + zoi_ndvi_joined = join.apply(zoi_with_ci, ndvi, filter) + + def merge_zoi_ci_ndvi(pair): + ci_feat = ee.Feature(pair.get("primary")) + ndvi_feat = ee.Feature(pair.get("secondary")) + merged_props = ci_feat.toDictionary().combine(ndvi_feat.toDictionary(), True) + return ee.Feature(ci_feat.geometry(), merged_props) + + final_merged = ee.FeatureCollection(zoi_ndvi_joined.map(merge_zoi_ci_ndvi)) + + # ------------------------- + # STEP 3: Export or Push to GeoServer + # ------------------------- + layer_name = f"WaterRejapp_zoi_{proj_obj.name}_{proj_obj.id}" + sync_project_fc_to_geoserver(final_merged, proj_obj.name, layer_name, "waterrej") + + +def get_directory_size(path): + total_size = 0 + for dirpath, dirnames, filenames in os.walk(path): + for filename in filenames: + file_path = os.path.join(dirpath, filename) + if os.path.isfile(file_path): + total_size += os.path.getsize(file_path) + return total_size + + +def generate_geojson_with_ci_ndvi_ndmi( + zoi_asset, ci_asset, ndvi_asset, ndmi_asset, proj_id +): + + # Load project + proj_obj = Project.objects.get(pk=proj_id) + + zoi = ee.FeatureCollection(zoi_asset) + print("Number of features zoi:", zoi.size().getInfo()) + + ci = ee.FeatureCollection(ci_asset) + print("Number of features zoi:", ci.size().getInfo()) + ndvi = ee.FeatureCollection(ndmi_asset) + print("Number of features zoi:", ndvi.size().getInfo()) + ndmi = ee.FeatureCollection(ndmi_asset) + print("Number of features zoi:", ndmi.size().getInfo()) + + # ------------------------- + # STEP 1: Join ZOI with CI + # ------------------------- + join = ee.Join.inner() + filter = ee.Filter.intersects(leftField=".geo", rightField=".geo") + zoi_ci_joined = join.apply(zoi, ci, filter) + + def merge_zoi_ci(pair): + zoi_feat = ee.Feature(pair.get("primary")) + ci_feat = ee.Feature(pair.get("secondary")) + merged_props = zoi_feat.toDictionary().combine(ci_feat.toDictionary(), True) + return ee.Feature(zoi_feat.geometry(), merged_props) # ✅ keep ZOI geom + + zoi_with_ci = ee.FeatureCollection(zoi_ci_joined.map(merge_zoi_ci)) + + # ------------------------- + # STEP 2: Join with NDVI + # ------------------------- + zoi_ndvi_joined = join.apply(zoi_with_ci, ndvi, filter) + + def merge_zoi_ci_ndvi(pair): + prev_feat = ee.Feature(pair.get("primary")) + ndvi_feat = ee.Feature(pair.get("secondary")) + merged_props = prev_feat.toDictionary().combine(ndvi_feat.toDictionary(), True) + return ee.Feature(prev_feat.geometry(), merged_props) # ✅ still ZOI geom + + zoi_ci_ndvi = ee.FeatureCollection(zoi_ndvi_joined.map(merge_zoi_ci_ndvi)) + + # ------------------------- + # STEP 3: Join with NDMI + # ------------------------- + zoi_ndmi_joined = join.apply(zoi_ci_ndvi, ndmi, filter) + + def merge_zoi_ci_ndvi_ndmi(pair): + prev_feat = ee.Feature(pair.get("primary")) + ndmi_feat = ee.Feature(pair.get("secondary")) + merged_props = prev_feat.toDictionary().combine(ndmi_feat.toDictionary(), True) + return ee.Feature(prev_feat.geometry(), merged_props) # ✅ keep ZOI geom + + final_merged = ee.FeatureCollection(zoi_ndmi_joined.map(merge_zoi_ci_ndvi_ndmi)) + + # ------------------------- + # STEP 4: Export or Push to GeoServer + # ------------------------- + layer_name = f"WaterRejapp_zoi_{proj_obj.name}_{proj_obj.id}" + print(layer_name) + sync_project_fc_to_geoserver(final_merged, proj_obj.name, layer_name, "waterrej") + + +def generate_geojson_with_ci_ndvi(zoi_asset, ci_asset, ndvi_asset, proj_id): + # Load project + proj_obj = Project.objects.get(pk=proj_id) + + # Initialize Earth Engine + ee_initialize(4) + + # Load FeatureCollections + zoi = ee.FeatureCollection(zoi_asset) + ci = ee.FeatureCollection(ci_asset) + ndvi = ee.FeatureCollection(ndvi_asset) + + print("ZOI:", zoi.size().getInfo()) + print("CI:", ci.size().getInfo()) + print("NDVI:", ndvi.size().getInfo()) + + # Common join logic on UID + join = ee.Join.inner() + uid_filter = ee.Filter.equals(leftField="UID", rightField="UID") + + # --- Join ZOI + CI --- + zoi_ci_joined = join.apply(zoi, ci, uid_filter) + + def merge_zoi_ci(pair): + zoi_feat = ee.Feature(pair.get("primary")) + ci_feat = ee.Feature(pair.get("secondary")) + merged_props = zoi_feat.toDictionary().combine(ci_feat.toDictionary(), True) + # Keep ZOI geometry only + return ee.Feature(zoi_feat.geometry(), merged_props) + + zoi_with_ci = ee.FeatureCollection(zoi_ci_joined.map(merge_zoi_ci)) + + # --- Join with NDVI --- + zoi_ndvi_joined = join.apply(zoi_with_ci, ndvi, uid_filter) + + def merge_with_ndvi(pair): + base_feat = ee.Feature(pair.get("primary")) + ndvi_feat = ee.Feature(pair.get("secondary")) + merged_props = base_feat.toDictionary().combine(ndvi_feat.toDictionary(), True) + # Always retain ZOI geometry + return ee.Feature(base_feat.geometry(), merged_props) + + merged_final = ee.FeatureCollection(zoi_ndvi_joined.map(merge_with_ndvi)) + + # --- Ensure ZOI geometry retained in all features --- + merged_final = merged_final.map( + lambda f: ee.Feature( + f.setGeometry( + ee.Feature( + zoi.filter(ee.Filter.eq("UID", f.get("UID"))).first() + ).geometry() + ) + ) + ) + + layer_name = f"WaterRejapp_zoi_{proj_obj.name}_{proj_obj.id}" + print(layer_name) + + sync_project_fc_to_geoserver(merged_final, proj_obj.name, layer_name, "waterrej") + + +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 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) + layer_obj = Layer.objects.get(dataset=dataset, layer_name=layer_name) + existing_end_date = layer_obj.misc["end_year"] + print("existing_end_date", existing_end_date) + return existing_end_date + + +def get_layer_object(state, district, block, layer_name, dataset_name): + 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) + layer_obj = ( + Layer.objects.filter( + state=state_obj, + district=district_obj, + block=block_obj, + layer_name=layer_name, + dataset__name=dataset_name, + ) + .order_by("-layer_version") + .first() + ) + return layer_obj + + +def update_dashboard_geojson( + state=None, + district=None, + block=None, + layer_name=None, + workspace_name=None, + proj_id=None, +): + if state and block and block: + print(f"🔄 Updating GeoJSON for {state}, {district}, {block}") + + # Get related objects + state_obj = StateSOI.objects.get(state_name=state) + district_obj = DistrictSOI.objects.get(district_name=district) + tehsil_obj = TehsilSOI.objects.get(tehsil_name=block) # fixed typo + + # Get or create main record + obj, created = State_Disritct_Block_Properties.objects.get_or_create( + state=state_obj, district=district_obj, tehsil=tehsil_obj + ) + else: + obj = Project.objects.get(pk=proj_id) + + # Map suffix to json_key + suffix_to_key = { + "wb": "wb_geojson", + "zoi": "zoi_geojson", + "mws": "mws_geojson", + } + + # Detect which key this layer corresponds to + json_key = None + for suffix, key in suffix_to_key.items(): + if layer_name == f"{state}_{district}_{block}_{suffix}": + json_key = key + break + + if not json_key: + print(f"⚠️ Layer name {layer_name} did not match any known type.") + return + + # Construct GeoServer URL + waterrej_url = ( + f"https://geoserver.core-stack.org:8443/geoserver/waterrej/ows?" + f"service=WFS&version=1.0.0&request=GetFeature&typeName={workspace_name}:{layer_name}" + f"&outputFormat=application%2Fjson" + ) + + # Load existing dashboard_geojson or create new + if proj_id: + misc = obj.dashboard_geojson or {} + else: + misc = obj.geojson_path or {} + + # Ensure waterrej section exists + if "waterrej" not in misc: + misc["waterrej"] = {} + + # Update or add this specific json_key + misc["waterrej"][json_key] = waterrej_url + + # Save the updated JSON field + obj.dashboard_geojson = misc + obj.save() + + print(f"✅ Added/Updated {json_key} for {state}, {district}, {block}") + + +def clean_geometry(geom): + """ + Clean geometry: + - Dissolve multipolygon → single polygon + - Remove holes automatically + - Fix invalid topology + - Buffer tiny polygons + """ + + # 1. Dissolve multi-polygons and remove holes + geom = geom.dissolve(maxError=1) + + # 2. Fix invalid rings by simplifying slightly (NEVER buffer(0)) + geom = geom.simplify(1) + + # 3. Buffer polygons smaller than 1 pixel (< 900 m²) + area = geom.area() + geom = ee.Algorithms.If( + area.lt(900), + geom.buffer(15), + geom, # ensure raster pixel center is captured + ) + + return ee.Geometry(geom) + + +def safe_reduce_max(image, geom, scale=30): + geom = clean_geometry(geom) + + val = ( + image.unmask(0) + .reduceRegion( + reducer=ee.Reducer.max(), + geometry=geom, + scale=scale, + maxPixels=1e13, + tileScale=4, + bestEffort=True, + ) + .get("b1") + ) + + return ee.Number(ee.Algorithms.If(val, val, 0)) + + +# ------------------------------------------------------ +# SAFE REDUCE MAX FUNCTION +# ------------------------------------------------------ +def safe_reduce_max(image, geom, scale=30): + geom = clean_geometry(geom) + + result = ( + image.unmask(0) + .reduceRegion( + reducer=ee.Reducer.max(), + geometry=geom, + scale=scale, + maxPixels=1e13, + tileScale=4, + bestEffort=True, + ) + .get("b1") + ) + + # Convert null → 0 + return ee.Number(ee.Algorithms.If(result, result, 0)) + + +# ------------------------------------------------------ +# MAIN FUNCTION TO PROCESS SWB LAYER +# ------------------------------------------------------ +def generate_swb_layer_with_max_so_catchment( + roi=None, + app_type="MWS", + asset_suffix=None, + asset_folder=None, + gee_account_id=None, +): + ee_initialize(gee_account_id) + + # Build asset paths + base_path = get_gee_dir_path( + asset_folder, asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"] + ) + + so_asset = f"{base_path}stream_order_{asset_suffix}_raster" + ca_asset = f"{base_path}catchment_area_{asset_suffix}_raster" + + # Load rasters + stream_order_band = ee.Image(so_asset).select("b1") + catchment_band = ee.Image(ca_asset).select("b1") + + # Processing per waterbody + def compute_for_feature(feature): + geom = feature.geometry() + + max_so = safe_reduce_max(stream_order_band, geom, scale=30) + max_ca = safe_reduce_max(catchment_band, geom, scale=30) + + return feature.set( + { + "max_stream_order": max_so, + "max_catchment_area": max_ca, + } + ) + + # Map over the feature collection + return roi.map(compute_for_feature) + + + + +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.") + _deprecated_misc_keys = {"is_computed_locally"} + base_misc = { + k: v + for k, v in (existing_layer.misc or {}).items() + if k not in _deprecated_misc_keys + } + merged_misc = {**base_misc, **(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": merged_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 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).first() + if layer_obj is None: + return None + + update_fields = [] + if sync_to_geoserver is not None: + layer_obj.is_sync_to_geoserver = sync_to_geoserver + update_fields.append("is_sync_to_geoserver") + if is_stac_specs_generated is not None: + layer_obj.is_stac_specs_generated = is_stac_specs_generated + update_fields.append("is_stac_specs_generated") + + # `save(update_fields=...)` fires the post_save signal so the STAC + # auto-trigger handler in `computing.signals` can pick up the flip. + if update_fields: + layer_obj.save(update_fields=update_fields) + print( + f"Updated {update_fields} for layer ID: {layer_id} " + f"(sync={sync_to_geoserver}, stac={is_stac_specs_generated})" + ) + return layer_id + + except Exception as e: + print(f"Error updating layer sync status: {e}") + + +def _is_cache_valid(cache: dict, workspace: str) -> bool: + if workspace not in cache: + return False + age = time.time() - cache[workspace]["cached_at"] + if age > 3600: + logger.info(f"Cache expired for {workspace} (age: {int(age)}s)") + return False + return True + + +def _set_cache(cache: dict, workspace: str, data: set): + cache[workspace] = { + "data": data, + "cached_at": time.time(), + } + + +def send_report_email( + result, + report_type: str = "missing_layers", + recipients: list = None, +) -> bool: + """ + Generic reusable function to email a JSON report. + report_type: "missing_layers" or "missing_excel_files" + """ + if recipients is None: + recipients = getattr(settings, "MISSING_LAYER_RECIPIENTS", []) + + if isinstance(recipients, str): + recipients = [recipients] + + if not recipients: + logger.error("No recipients configured for report email.") + return False + + if report_type == "missing_layers": + subject = "Missing Layers Report" + attachment_name = "missing_layers.json" + + strict_result = result.get("Mandatory", {}) + can_be_empty_result = result.get("can_be_empty", {}) + + # Filter out workspaces with zero missing layers + strict_filtered = { + ws: data for ws, data in strict_result.items() if data.get("missing_layers") + } + can_be_empty_filtered = { + ws: data + for ws, data in can_be_empty_result.items() + if data.get("missing_layers") + } + + strict_summary = [] + total_strict_missing = 0 + for layer, data in strict_filtered.items(): + count = len(data.get("missing_layers", [])) + total_strict_missing += count + strict_summary.append(f"{layer}: {count}") + + can_be_empty_summary = [] + total_can_be_empty_missing = 0 + for layer, data in can_be_empty_filtered.items(): + count = len(data.get("missing_layers", [])) + total_can_be_empty_missing += count + can_be_empty_summary.append(f"{layer}: {count}") + + total_missing = total_strict_missing + total_can_be_empty_missing + + body = ( + "Missing Layers Report\n\n" + f"Total Missing: {total_missing}\n" + f" - Mandatory (needs attention): {total_strict_missing}\n" + f" - Can-Be-Empty (may be legitimately absent): {total_can_be_empty_missing}\n\n" + "---- Mandatory Workspaces (data expected everywhere) ----\n" + + ( + "\n".join(strict_summary) + if strict_summary + else "None — nothing missing" + ) + + "\n\n" + "---- Can-Be-Empty Workspaces (some locations may legitimately have no data) ----\n" + + ( + "\n".join(can_be_empty_summary) + if can_be_empty_summary + else "None — nothing missing" + ) + + "\n\nDetailed report attached." + ) + result = { + "Mandatory": strict_filtered, + "can_be_empty": can_be_empty_filtered, + } + + elif report_type == "missing_excel_files": + subject = "Missing Stats Excel/JSON Files Report" + attachment_name = "missing_excel_files.json" + total_locations = len(result) + total_missing_files = sum(len(loc.get("missing_files", [])) for loc in result) + total_xlsx_issues = sum(1 for loc in result if loc.get("xlsx_issues")) + body = ( + "Missing Stats Excel/JSON Files Report\n\n" + f"Tehsils which files(json/excel) are missing: {total_locations}\n" + f"Total missing files: {total_missing_files}\n" + f"Tehsils with xlsx sheet missing: {total_xlsx_issues}\n\n" + "Detailed report attached." + ) + + else: + logger.error(f"Unknown report_type: {report_type}") + return False + + attachment_content = json.dumps(result, indent=4) + max_retries = 3 + + for attempt in range(max_retries): + connection = None + try: + connection = get_connection(timeout=120) + connection.open() + email = EmailMessage( + subject=subject, + body=body, + from_email=settings.EMAIL_HOST_USER, + to=recipients, + connection=connection, + ) + email.attach( + attachment_name, + attachment_content, + "application/json", + ) + email.send() + logger.info(f"{subject} sent to {recipients}") + logger.info( + f"Attachment size: " + f"{len(attachment_content.encode('utf-8')) / 1024:.2f} KB" + ) + return True + except Exception as e: + logger.exception(f"Attempt {attempt + 1}/{max_retries} failed: {e}") + if attempt < max_retries - 1: + wait_time = 5 * (attempt + 1) + logger.info(f"Retrying after {wait_time} seconds...") + time.sleep(wait_time) + else: + logger.error("All attempts to send email failed.") + return False + finally: + if connection: + try: + connection.close() + except Exception: + pass + return False From 9b11a621bb2195097c936761939bc5bdebb590ed Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 16 Jul 2026 14:04:42 +0530 Subject: [PATCH 046/120] facilities error fix --- computing/api.py | 3 +-- computing/layer_dependency/layer_generation_in_order.py | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/computing/api.py b/computing/api.py index febf9d53..cb441de9 100644 --- a/computing/api.py +++ b/computing/api.py @@ -109,7 +109,7 @@ ) from .misc.catchment_area import generate_catchment_area_singleflow from .misc.distancetonearestdrainage import generate_distance_to_nearest_drainage_line -from .misc.facilities_proximity import generate_facilities_proximity_task +from .misc.facilities import generate_facilities_proximity_task from .misc.factory_csr import generate_factory_csr_data from .misc.green_credit import generate_green_credit_data from .misc.lcw_conflict import generate_lcw_conflict_data @@ -201,7 +201,6 @@ mws_connectivity_vector as generate_mws_connectivity_local_task, ) from .mws.mws_centroid import generate_mws_centroid_data -from .misc.facilities_proximity import generate_facilities_proximity_task from .misc.antyodaya import generate_antyodaya_layer_task from .misc.digital_elevation_model import generate_dem_layer from .misc.canal_layer import canal_vector diff --git a/computing/layer_dependency/layer_generation_in_order.py b/computing/layer_dependency/layer_generation_in_order.py index 9bd92b95..d3097346 100644 --- a/computing/layer_dependency/layer_generation_in_order.py +++ b/computing/layer_dependency/layer_generation_in_order.py @@ -47,7 +47,6 @@ from computing.mws.mws_connectivity import generate_mws_connectivity_data from computing.misc.ndvi_time_series import ndvi_timeseries from computing.zoi_layers.zoi import generate_zoi -from computing.misc.facilities_proximity import generate_facilities_proximity_task from computing.mws.mws_centroid import generate_mws_centroid_data from computing.change_detection.change_detection_local import ( get_change_detection as get_change_detection_local, @@ -212,8 +211,6 @@ "generate_ndvi_timeseries": ndvi_timeseries, "generate_zoi": generate_zoi, "generate_zoi_data": generate_zoi, - "generate_facilities_proximity_task": generate_facilities_proximity_task, - "generate_facilities_proximity": generate_facilities_proximity_task, "generate_mws_centroid_data": generate_mws_centroid_data, "generate_mws_centroid": generate_mws_centroid_data, } From 4da1cb8ca68a16e3e6414962abb1c320f34b1b61 Mon Sep 17 00:00:00 2001 From: aman verma Date: Thu, 16 Jul 2026 12:27:01 +0000 Subject: [PATCH 047/120] api fix --- computing/api.py | 187 ++++++++++++++++++++--------------------------- 1 file changed, 78 insertions(+), 109 deletions(-) diff --git a/computing/api.py b/computing/api.py index cb441de9..e19c3612 100644 --- a/computing/api.py +++ b/computing/api.py @@ -2,8 +2,6 @@ import os import requests -from computing.forest_fire.forest_fire_updated import generate_forest_fire_layer_updated -from nrm_app.settings import BASE_DIR, LOCAL_COMPUTE_API_URL from django.conf import settings from django.core.files.storage import FileSystemStorage from rest_framework import status @@ -14,12 +12,11 @@ permission_classes, schema, ) -from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.permissions import AllowAny from rest_framework.response import Response -from rest_framework import status -from rest_framework.parsers import MultiPartParser, FormParser +from computing.STAC_specs.stac_collection import STACConfig, sanitize_text from computing.change_detection.change_detection import ( get_change_detection as get_change_detection_gee_task, ) @@ -32,6 +29,7 @@ from computing.change_detection.change_detection_vector_local import ( vectorise_change_detection as vectorise_change_detection_local_task, ) +from computing.forest_fire.forest_fire_updated import generate_forest_fire_layer_updated from computing.layer_dependency.layer_generation_in_order import ( layer_generate_map, normalize_compute as _normalize_layer_order_compute, @@ -40,24 +38,15 @@ from computing.misc.drainage_lines import ( clip_drainage_lines as clip_drainage_lines_gee_task, ) -from utilities.pipelines import api_request_payload -from .et_downscale.et_downscale import generate_et_downscale -from .forest_fringe.forest_fringe import generate_forest_fringe_degradation -from .misc.livestocks import generate_livestocks_layer_task -from .tree_in_grassland.tree_in_grassland import generate_tree_in_grassland_layer -from .utils import ( - save_layer_info_to_db, - update_layer_sync_status, -) from computing.misc.drainage_lines_local_compute import ( clip_drainage_lines as clip_drainage_lines_local_task, ) -from computing.STAC_specs.stac_collection import STACConfig, sanitize_text from nrm_app.settings import BASE_DIR, LOCAL_COMPUTE_API_URL from utilities.auth_check_decorator import api_security_check from utilities.constants import KML_PATH from utilities.gee_utils import check_gee_task_status, download_gee_layer - +from utilities.pipelines import api_request_payload +from .STAC_specs.stac_collection import generate_stac_collection_task from .clart.clart import generate_clart_layer from .clart.fes_clart_to_geoserver import generate_fes_clart_layer from .crop_grid.crop_grid import create_crop_grids @@ -65,13 +54,10 @@ from .cropping_intensity.cropping_intesity_local import ( generate_cropping_intensity as generate_cropping_intensity_local_task, ) -from .spei.spei import ( - generate_spei_pipeline, - run_drought_resistance_resilience, - run_rainfall_resistance_resilience, -) from .drought.drought import calculate_drought from .drought.drought_causality import drought_causality +from .et_downscale.et_downscale import generate_et_downscale +from .forest_fringe.forest_fringe import generate_forest_fringe_degradation from .local_compute_helper import ( get_compute_mode as _get_compute_mode, ) @@ -101,31 +87,98 @@ ) from .misc.admin_boundary import generate_tehsil_shape_file_data from .misc.agroecological_space import generate_agroecological_data +from .misc.agroecological_space_local_compute import ( + generate_agroecological_data_local as generate_agroecological_data_local_task, +) +from .misc.antyodaya import generate_antyodaya_layer_task +from .misc.antyodaya_local_compute import ( + generate_antyodaya_data_local as generate_antyodaya_data_local_task, +) 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.canal_layer import canal_vector +from .misc.canal_local_compute import canal_vector as canal_vector_local_task from .misc.catchment_area import generate_catchment_area_singleflow +from .misc.catchment_area_local_compute import ( + generate_catchment_area_singleflow_local as generate_catchment_area_singleflow_local_task, +) +from .misc.digital_elevation_model import generate_dem_layer +from .misc.digital_elevation_model_local import ( + generate_febdem_raster_vector_clip as generate_febdem_raster_vector_clip_local_task, +) from .misc.distancetonearestdrainage import generate_distance_to_nearest_drainage_line -from .misc.facilities import generate_facilities_proximity_task +from .misc.distancetonearestdrainage_local_compute import ( + generate_distance_to_nearest_drainage_line_local as generate_distance_to_nearest_drainage_line_local_task, +) +from .misc.drainage_density_local_compute import ( + drainage_density as drainage_density_vector_local_task, +) +from .misc.facilities.pipeline import generate_facilities_proximity_task from .misc.factory_csr import generate_factory_csr_data +from .misc.factory_csr_local_compute import ( + generate_factory_csr_data_local as generate_factory_csr_data_local_task, +) from .misc.green_credit import generate_green_credit_data +from .misc.green_credit_local_compute import ( + generate_green_credit_data_local as generate_green_credit_data_local_task, +) from .misc.lcw_conflict import generate_lcw_conflict_data +from .misc.lcw_conflict_local_compute import ( + generate_lcw_conflict_data_local as generate_lcw_conflict_data_local_task, +) + +from .misc.livestocks.pipeline import generate_livestocks_layer_task +from .misc.antyodaya.pipeline import generate_antyodaya_layer_task + from .misc.mining_data import generate_mining_data +from .misc.mining_data_local_compute import ( + generate_mining_data_local as generate_mining_data_local_task, +) from .misc.naturaldepression import generate_natural_depression_data +from .misc.naturaldepression_local_compute import ( + generate_natural_depression_data_local as generate_natural_depression_data_local_task, +) from .misc.ndvi_time_series import ndvi_timeseries from .misc.nrega import clip_nrega_district_block +from .misc.nrega_local_compute import ( + generate_nrega_data_local as generate_nrega_data_local_task, +) from .misc.restoration_opportunity import generate_restoration_opportunity +from .misc.restoration_opportunity_local_compute import ( + generate_restoration_opportunity_local as generate_restoration_opportunity_local_task, +) +from .misc.river_local_compute import river_vector as river_vector_local_task from .misc.slope_percentage import generate_slope_percentage_data +from .misc.slope_percentage_local_compute import ( + generate_slope_percentage_data_local as generate_slope_percentage_data_local_task, +) from .misc.soge_vector import generate_soge_vector +from .misc.soge_vector_local_compute import ( + generate_soge_vector_local as generate_soge_vector_local_task, +) from .misc.stream_order import generate_stream_order from .mws.generate_hydrology import generate_hydrology from .mws.mws import mws_layer from .mws.mws_centroid import generate_mws_centroid_data -from .mws.mws_connectivity import generate_mws_connectivity_data +from .mws.mws_centroid_local_compute import ( + generate_mws_centroid_data_local as generate_mws_centroid_data_local_task, +) +from .mws.mws_connectivity import ( + generate_mws_connectivity_data as generate_mws_connectivity_gee_task, +) +from .mws.mws_connectivity_local_compute import ( + mws_connectivity_vector as generate_mws_connectivity_local_task, +) from .plantation.site_suitability import site_suitability +from .spei.spei import ( + generate_spei_pipeline, + run_drought_resistance_resilience, + run_rainfall_resistance_resilience, +) from .surface_water_bodies.merge_swb_ponds import merge_swb_ponds from .surface_water_bodies.swb import generate_swb_layer as generate_swb_gee_task from .surface_water_bodies.swb_local import ( @@ -162,105 +215,21 @@ from .tree_health.local.overall_change_vector_local import ( tree_health_overall_change_vector_local, ) - +from .tree_in_grassland.tree_in_grassland import generate_tree_in_grassland_layer from .utils import ( Geoserver, kml_to_shp, save_layer_info_to_db, update_layer_sync_status, ) -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 -from utilities.auth_check_decorator import api_security_check from .views import ( - check_missing_layers, layer_status, get_layers_of_workspace, missing_layer_for_all_workspace, clear_layer_cache, check_missing_excel_files, ) -from .misc.lcw_conflict import generate_lcw_conflict_data -from .misc.agroecological_space import generate_agroecological_data -from .misc.factory_csr import generate_factory_csr_data -from .misc.green_credit import generate_green_credit_data -from .misc.mining_data import generate_mining_data -from .misc.slope_percentage import generate_slope_percentage_data -from .misc.naturaldepression import generate_natural_depression_data -from .misc.distancetonearestdrainage import generate_distance_to_nearest_drainage_line -from .misc.catchment_area import generate_catchment_area_singleflow from .zoi_layers.zoi import generate_zoi -from .mws.mws_connectivity import ( - generate_mws_connectivity_data as generate_mws_connectivity_gee_task, -) -from .mws.mws_connectivity_local_compute import ( - mws_connectivity_vector as generate_mws_connectivity_local_task, -) -from .mws.mws_centroid import generate_mws_centroid_data -from .misc.antyodaya import generate_antyodaya_layer_task -from .misc.digital_elevation_model import generate_dem_layer -from .misc.canal_layer import canal_vector -from .STAC_specs.stac_collection import generate_stac_collection_task -from .mws.mws_centroid_local_compute import ( - generate_mws_centroid_data_local as generate_mws_centroid_data_local_task, -) -from .misc.facilities_proximity_local_compute import ( - generate_facilities_proximity_local as generate_facilities_proximity_local_task, -) -from .misc.digital_elevation_model_local import ( - generate_febdem_raster_vector_clip as generate_febdem_raster_vector_clip_local_task, -) -from .misc.canal_local_compute import canal_vector as canal_vector_local_task -from .misc.river_local_compute import river_vector as river_vector_local_task -from .misc.drainage_density_local_compute import ( - drainage_density as drainage_density_vector_local_task, -) -from .misc.restoration_opportunity_local_compute import ( - generate_restoration_opportunity_local as generate_restoration_opportunity_local_task, -) -from .misc.soge_vector_local_compute import ( - generate_soge_vector_local as generate_soge_vector_local_task, -) -from .misc.nrega_local_compute import ( - generate_nrega_data_local as generate_nrega_data_local_task, -) -from .misc.catchment_area_local_compute import ( - generate_catchment_area_singleflow_local as generate_catchment_area_singleflow_local_task, -) -from .misc.distancetonearestdrainage_local_compute import ( - generate_distance_to_nearest_drainage_line_local as generate_distance_to_nearest_drainage_line_local_task, -) -from .misc.naturaldepression_local_compute import ( - generate_natural_depression_data_local as generate_natural_depression_data_local_task, -) -from .misc.slope_percentage_local_compute import ( - generate_slope_percentage_data_local as generate_slope_percentage_data_local_task, -) -from .misc.mining_data_local_compute import ( - generate_mining_data_local as generate_mining_data_local_task, -) -from .misc.green_credit_local_compute import ( - generate_green_credit_data_local as generate_green_credit_data_local_task, -) -from .misc.factory_csr_local_compute import ( - generate_factory_csr_data_local as generate_factory_csr_data_local_task, -) -from .misc.agroecological_space_local_compute import ( - generate_agroecological_data_local as generate_agroecological_data_local_task, -) -from .misc.lcw_conflict_local_compute import ( - generate_lcw_conflict_data_local as generate_lcw_conflict_data_local_task, -) -from .misc.antyodaya_local_compute import ( - generate_antyodaya_data_local as generate_antyodaya_data_local_task, -) -from .misc.livestocks_local_compute import ( - generate_livestocks_data_local as generate_livestocks_data_local_task, -) @api_security_check(allowed_methods="POST") @@ -2725,7 +2694,7 @@ def generate_livestocks(request): task = _select_compute_task( compute, None, - generate_livestocks_data_local_task, + generate_livestocks_layer_task, ) if task is None: return Response( From b8acfb9e0a7146c96b6357ce4622d2d939f047c4 Mon Sep 17 00:00:00 2001 From: amit-spatial Date: Fri, 17 Jul 2026 09:47:38 +0000 Subject: [PATCH 048/120] resolved API calls for Facilities, Antyodaya, Livestocks --- computing/api.py | 70 ++---------------------------------------------- 1 file changed, 2 insertions(+), 68 deletions(-) diff --git a/computing/api.py b/computing/api.py index e19c3612..c60246ff 100644 --- a/computing/api.py +++ b/computing/api.py @@ -91,9 +91,6 @@ generate_agroecological_data_local as generate_agroecological_data_local_task, ) from .misc.antyodaya import generate_antyodaya_layer_task -from .misc.antyodaya_local_compute import ( - generate_antyodaya_data_local as generate_antyodaya_data_local_task, -) from .misc.aquifer_vector import ( generate_aquifer_vector as generate_aquifer_vector_gee_task, ) @@ -130,10 +127,7 @@ from .misc.lcw_conflict_local_compute import ( generate_lcw_conflict_data_local as generate_lcw_conflict_data_local_task, ) - from .misc.livestocks.pipeline import generate_livestocks_layer_task -from .misc.antyodaya.pipeline import generate_antyodaya_layer_task - from .misc.mining_data import generate_mining_data from .misc.mining_data_local_compute import ( generate_mining_data_local as generate_mining_data_local_task, @@ -2020,7 +2014,7 @@ def generate_antyodaya(request): if hasattr(request.data, "dict") else dict(request.data) ), - overwrite=False, + overwrite=True, ) generate_antyodaya_layer_task.apply_async( kwargs={"payload": payload}, @@ -2048,7 +2042,7 @@ def generate_livestocks(request): if hasattr(request.data, "dict") else dict(request.data) ), - overwrite=False, + overwrite=True, ) generate_livestocks_layer_task.apply_async( kwargs={"payload": payload}, @@ -2651,66 +2645,6 @@ def generate_drainage_density_data(request): return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) -@api_view(["POST"]) -@schema(None) -def generate_antyodaya(request): - print("Inside generate_antyodaya API.") - try: - state = request.data.get("state").lower() - district = request.data.get("district").lower() - block = request.data.get("block").lower() - gee_account_id = request.data.get("gee_account_id") - compute = _get_compute_mode(request) - task = _select_compute_task( - compute, - None, - generate_antyodaya_data_local_task, - ) - if task is None: - return Response( - {"Error": "GEE execution not supported for this module."}, - status=status.HTTP_400_BAD_REQUEST, - ) - task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") - return Response( - {"Success": f"Successfully initiated {compute} task"}, - status=status.HTTP_200_OK, - ) - except Exception as e: - print("Exception in generate_antyodaya api :: ", e) - return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -@api_view(["POST"]) -@schema(None) -def generate_livestocks(request): - print("Inside generate_livestocks API.") - try: - state = request.data.get("state").lower() - district = request.data.get("district").lower() - block = request.data.get("block").lower() - gee_account_id = request.data.get("gee_account_id") - compute = _get_compute_mode(request) - task = _select_compute_task( - compute, - None, - generate_livestocks_layer_task, - ) - if task is None: - return Response( - {"Error": "GEE execution not supported for this module."}, - status=status.HTTP_400_BAD_REQUEST, - ) - task.apply_async(args=[state, district, block, gee_account_id], queue="nrm") - return Response( - {"Success": f"Successfully initiated {compute} task"}, - status=status.HTTP_200_OK, - ) - except Exception as e: - print("Exception in generate_livestocks api :: ", e) - return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - @api_view(["POST"]) @schema(None) def generate_tree_in_grassland(request): From 54479d7bc9d79e6bc8d95b82de79e6a551d1e7ce Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Mon, 20 Jul 2026 14:48:54 +0530 Subject: [PATCH 049/120] JSON map and logging --- computing/api.py | 28 ++++-- .../layer_generation_in_order.py | 76 +++++++++++++--- .../layer_dependency/local_layer_map.json | 89 +++++++++++-------- docs/guide/api/api.md | 3 + 4 files changed, 140 insertions(+), 56 deletions(-) diff --git a/computing/api.py b/computing/api.py index cb441de9..c38aa173 100644 --- a/computing/api.py +++ b/computing/api.py @@ -1,4 +1,5 @@ import json +import logging import os import requests @@ -262,6 +263,8 @@ generate_livestocks_data_local as generate_livestocks_data_local_task, ) +logger = logging.getLogger(__name__) + @api_security_check(allowed_methods="POST") @schema(None) @@ -1598,7 +1601,6 @@ def wells_compute(request): @api_view(["POST"]) @schema(None) def generate_layer_in_order(request): - print("inside generate_layer_order_first") try: state = request.data.get("state").lower() district = request.data.get("district").lower() @@ -1611,8 +1613,17 @@ def generate_layer_in_order(request): start_year = int(start_year) if start_year is not None else None end_year = int(end_year) if end_year is not None else None + logger.info( + f"generate_layer_in_order requested: state={state}, district={district}, " + f"block={block}, map={map_order}, compute={compute}" + ) + validation_errors = validate_layer_map_request(map_order, compute=compute) if validation_errors: + logger.error( + f"generate_layer_in_order validation failed for map={map_order}, " + f"compute={compute}: {'; '.join(validation_errors)}" + ) return Response( {"Exception": "; ".join(validation_errors)}, status=status.HTTP_400_BAD_REQUEST, @@ -1635,17 +1646,18 @@ def generate_layer_in_order(request): {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) except ValueError as e: - print("Invalid request in generate_layer_order_first api :: ", e) + logger.warning(f"Invalid request in generate_layer_in_order api: {e}") return Response({"Exception": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: - print("Exception in generate_layer_order_first api :: ", e) - return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + logger.exception("Exception in generate_layer_in_order api") + return Response( + {"Exception": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) @api_view(["POST"]) @schema(None) def layer_status_dashboard(request): - print("inside layer_staus_dashboard") try: state = request.data.get("state").lower() district = request.data.get("district").lower() @@ -1656,8 +1668,10 @@ def layer_status_dashboard(request): status=status.HTTP_200_OK, ) except Exception as e: - print("Exception in layer_staus_dashboard api :: ", e) - return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + logger.exception("Exception in layer_status_dashboard api") + return Response( + {"Exception": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) @api_view(["POST"]) diff --git a/computing/layer_dependency/layer_generation_in_order.py b/computing/layer_dependency/layer_generation_in_order.py index d3097346..7a47f70d 100644 --- a/computing/layer_dependency/layer_generation_in_order.py +++ b/computing/layer_dependency/layer_generation_in_order.py @@ -83,6 +83,14 @@ from computing.misc.facilities_proximity_local_compute import ( generate_facilities_proximity_local, ) +from computing.misc.antyodaya_local_compute import generate_antyodaya_data_local +from computing.misc.canal_local_compute import canal_vector as canal_vector_local +from computing.misc.digital_elevation_model_local import ( + generate_febdem_raster_vector_clip, +) +from computing.misc.drainage_density_local_compute import drainage_density +from computing.misc.livestocks_local_compute import generate_livestocks_data_local +from computing.misc.river_local_compute import river_vector as river_vector_local from computing.misc.factory_csr_local_compute import generate_factory_csr_data_local from computing.misc.green_credit_local_compute import generate_green_credit_data_local from computing.misc.lcw_conflict_local_compute import generate_lcw_conflict_data_local @@ -106,16 +114,22 @@ from computing.terrain_descriptor.terrain_clusters_local import ( generate_terrain_clusters as generate_terrain_clusters_local, ) +from computing.terrain_descriptor.terrain_compute_all_local import ( + generate_terrain_compute_all, +) from computing.terrain_descriptor.terrain_raster_fabdem_local import ( generate_terrain_raster_clip as terrain_raster_local, ) from stats_generator.utils import generate_stats_excel_file from utilities.gee_utils import valid_gee_text import os +import logging from nrm_app.celery import app from computing.models import Layer import json +logger = logging.getLogger(__name__) + VALID_COMPUTE_TYPES = {"gee", "local"} CONFIG_DIR = os.path.dirname(__file__) @@ -131,8 +145,6 @@ "local": "local_end_year_rules.json", } -status = {} - GEE_TASK_REGISTRY = { "generate_tehsil_shape_file_data": generate_tehsil_shape_file_data, @@ -237,6 +249,7 @@ "generate_terrain_raster": terrain_raster_local, "generate_terrain_clusters": generate_terrain_clusters_local, "generate_terrain_descriptor": generate_terrain_clusters_local, + "generate_terrain_compute_all": generate_terrain_compute_all, "lulc_on_plain_cluster": lulc_on_plain_cluster_local, "terrain_lulc_plain_cluster": lulc_on_plain_cluster_local, "lulc_on_slope_cluster": lulc_on_slope_cluster_local, @@ -268,6 +281,12 @@ "generate_mws_connectivity": mws_connectivity_vector, "generate_facilities_proximity_task": generate_facilities_proximity_local, "generate_facilities_proximity": generate_facilities_proximity_local, + "generate_livestocks": generate_livestocks_data_local, + "generate_antyodaya": generate_antyodaya_data_local, + "generate_density_vector": drainage_density, + "generate_river_data": river_vector_local, + "generate_canal_vector": canal_vector_local, + "generate_dem_raster_vector": generate_febdem_raster_vector_clip, "generate_mws_centroid_data": generate_mws_centroid_data_local, "generate_mws_centroid": generate_mws_centroid_data_local, } @@ -292,9 +311,13 @@ def layer_generate_map( ): """ This function take state, district,block and map_order(map to trigger, it can be map_1, map_2_1, map_2_2, map_3, map_4). One map trigger more numbers of pipeline. + + Sibling nodes (nodes that don't depend on each other) keep running even if one of + them fails; only nodes that declare a dependency on a failed node are skipped. """ compute = normalize_compute(compute) - status.clear() + log_ctx = f"state={state}, district={district}, block={block}, map={map_order}, compute={compute}" + status = {} # checking:- is mws layer generated? try: @@ -307,8 +330,10 @@ def layer_generate_map( .first() ) if not layer: + logger.error(f"MWS layer missing, cannot proceed ({log_ctx})") return f"check mws layer for {district}_{block}" except Exception as e: + logger.exception(f"Exception while checking mws layer ({log_ctx})") return f"exception occur while checking mws for {district}_{block} as: {e}" global_args = {} @@ -321,15 +346,20 @@ def layer_generate_map( map_config = load_map_config(map_order, compute=compute) if not map_config: + logger.error(f"Map configuration not found ({log_ctx})") return f"Map configuration not found for {map_order} using compute={compute}" validation_errors = validate_map_config(map_config, compute=compute) if validation_errors: + logger.error( + f"Invalid map configuration ({log_ctx}): {'; '.join(validation_errors)}" + ) return ( f"Invalid {compute} map configuration for {map_order}: " f"{'; '.join(validation_errors)}" ) + logger.info(f"Starting layer generation ({log_ctx})") task_registry = TASK_REGISTRIES[compute] for func in map_config: run_node_tree( @@ -341,6 +371,20 @@ def layer_generate_map( block=block, global_args=global_args, gee_account_id=gee_account_id, + status=status, + ) + + failed_nodes = sorted(name for name, ok in status.items() if not ok) + succeeded_nodes = sorted(name for name, ok in status.items() if ok) + if failed_nodes: + logger.error( + f"Layer generation completed with failures ({log_ctx}): " + f"failed={failed_nodes}, succeeded={succeeded_nodes}" + ) + else: + logger.info( + f"Layer generation completed successfully ({log_ctx}): " + f"succeeded={succeeded_nodes}" ) return f"{status = }" @@ -555,6 +599,7 @@ def run_node_tree( block, global_args, gee_account_id, + status, ): node_func_name = node["name"] node_func_obj = task_registry[node_func_name] @@ -571,9 +616,11 @@ def run_node_tree( district=district, block=block, args=args, + status=status, ) if not status.get(node_func_name, False): + # Only nodes depending on this one are affected; siblings keep running. return for child in node.get("children", []): @@ -586,23 +633,25 @@ def run_node_tree( block=block, global_args=global_args, gee_account_id=gee_account_id, + status=status, ) def run_layer_with_dependency( - deps, node_func_name, node_func_obj, compute, state, district, block, args + deps, node_func_name, node_func_obj, compute, state, district, block, args, status ): """ This function checks dependency of layer if it is generated or not and call the pipeline functions and maintain status of each function, """ + log_ctx = f"node={node_func_name}, state={state}, district={district}, block={block}" for dep in deps: if status.get(dep, False): continue checker = DependencyValidator.get_checker(dep, compute) status[dep] = checker(district, block) if checker else False if not status[dep]: - print( - f"Skipping {node_func_name} because dependency {dep} failed or not executed." + logger.warning( + f"Skipping {node_func_name} because dependency {dep} failed or was not executed ({log_ctx})" ) status[node_func_name] = False break @@ -613,8 +662,8 @@ def run_layer_with_dependency( args["end_year"] = end_year_rules[node_func_name] if node_func_name == "site_suitability": args["project_id"] = None - print( - f"{node_func_name} is running... with args={args, state, district, block}, depends_on={deps}" + logger.info( + f"Running {node_func_name} with args={args}, depends_on={deps} ({log_ctx})" ) if node_func_name == "generate_stats_excel_file": result = node_func_obj(state, district, block) @@ -625,15 +674,16 @@ def run_layer_with_dependency( else node_func_obj(state, district, block) ) if result: - print(f"{node_func_name} is completed...") status[node_func_name] = True + logger.info(f"Completed {node_func_name} ({log_ctx}): result={result}") else: - print(f"check the {node_func_name}") status[node_func_name] = False - print(f"{result = }") - except Exception as e: - print(f"{node_func_name} raised an error: {e}") + logger.warning( + f"{node_func_name} returned a falsy result, treating as failed ({log_ctx}): result={result}" + ) + except Exception: status[node_func_name] = False + logger.exception(f"{node_func_name} raised an error ({log_ctx})") def get_args(iterator_name, global_args, gee_account_id): diff --git a/computing/layer_dependency/local_layer_map.json b/computing/layer_dependency/local_layer_map.json index 2ad0ecfb..6aaf8b34 100644 --- a/computing/layer_dependency/local_layer_map.json +++ b/computing/layer_dependency/local_layer_map.json @@ -1,12 +1,5 @@ { - "map_2_1": [ - { - "name": "generate_terrain_raster" - }, - { - "name": "generate_terrain_descriptor", - "depends_on": ["generate_terrain_raster"] - }, + "dynamic_layers": [ { "name": "lulc_v3", "use_global_args": true @@ -17,17 +10,31 @@ "use_global_args": true }, { - "name": "terrain_lulc_slope_cluster", - "depends_on": ["generate_terrain_descriptor", "lulc_v3"], + "name": "change_detection", "use_global_args": true }, + { + "name": "change_detection_vector", + "depends_on": ["change_detection"], + "use_global_args": true + }, + { + "name": "generate_terrain_raster" + }, + { + "name": "generate_terrain_descriptor", + "depends_on": ["generate_terrain_raster"] + }, { "name": "terrain_lulc_plain_cluster", - "depends_on": ["generate_terrain_descriptor", "lulc_v3"], + "depends_on": ["generate_terrain_raster", "lulc_v3"], "use_global_args": true - } - ], - "map_2_2": [ + }, + { + "name": "terrain_lulc_slope_cluster", + "depends_on": ["generate_terrain_raster", "lulc_v3"], + "use_global_args": true + }, { "name": "generate_ci_layer", "use_global_args": true @@ -35,31 +42,44 @@ { "name": "generate_swb", "use_global_args": true + }, + { + "name": "generate_nrega_layer" } ], - "map_3": [ + "static_layers": [ { - "name": "change_detection", - "use_global_args": true + "name": "aquifer_vector" }, { - "name": "change_detection_vector", - "depends_on": ["change_detection"], - "use_global_args": true - } - ], - "map_static": [ + "name": "generate_livestocks" + }, { - "name": "generate_drainage_layer" + "name": "generate_antyodaya" }, { - "name": "generate_slope_percentage" + "name": "generate_density_vector" }, { - "name": "generate_catchment_area_singleflow" + "name": "generate_river_data" }, { - "name": "generate_natural_depression" + "name": "generate_canal_vector" + }, + { + "name": "generate_dem_raster_vector" + }, + { + "name": "generate_facilities_proximity" + }, + { + "name": "generate_drainage_layer" + }, + { + "name": "restoration_opportunity" + }, + { + "name": "soge_vector" }, { "name": "generate_lcw" @@ -77,25 +97,22 @@ "name": "generate_mining" }, { - "name": "soge_vector" - }, - { - "name": "aquifer_vector" + "name": "generate_natural_depression" }, { - "name": "restoration_opportunity" + "name": "generate_distance_nearest_DL" }, { - "name": "generate_mws_connectivity" + "name": "generate_catchment_area_singleflow" }, { - "name": "generate_mws_centroid" + "name": "generate_slope_percentage" }, { - "name": "generate_facilities_proximity" + "name": "generate_mws_connectivity_data" }, { - "name": "generate_nrega_layer" + "name": "generate_mws_centroid" } ] } diff --git a/docs/guide/api/api.md b/docs/guide/api/api.md index 95d766d7..2d98561d 100644 --- a/docs/guide/api/api.md +++ b/docs/guide/api/api.md @@ -2,6 +2,9 @@ This document provides a comprehensive overview of the Core Stack Backend API endpoints, their functionality, and usage. +For local layer-generation endpoints, payloads, and curl examples, see +[Local Compute Pipeline APIs](local_compute_pipeline.md). + ## Authentication Endpoints ### Authentication Flow From 669c7ad5628b7956c83cfcfbdd674c17157e327a Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Mon, 20 Jul 2026 16:42:09 +0530 Subject: [PATCH 050/120] config refactoring --- computing/base_layer_setup.py | 13 +- computing/config.yaml | 728 ++++++++++++++++++++++++++++------ computing/config_loader.py | 195 ++++----- computing/config_new.yaml | 601 ---------------------------- 4 files changed, 695 insertions(+), 842 deletions(-) delete mode 100644 computing/config_new.yaml diff --git a/computing/base_layer_setup.py b/computing/base_layer_setup.py index 45ebba9c..63d864f1 100644 --- a/computing/base_layer_setup.py +++ b/computing/base_layer_setup.py @@ -10,6 +10,7 @@ from computing.config_loader import ( ADMIN_BOUNDARY_INPUT_DIR, ADMIN_BOUNDARY_OUTPUT_DIR, + DATA_DIR, MICROWATERSHED_PATH, PROJECT_ROOT, SOI_TEHSIL_PATH, @@ -32,7 +33,7 @@ ) logger = logging.getLogger(__name__) -CONFIG_NEW_PATH = Path(__file__).resolve().parent / "config_new.yaml" +CONFIG_NEW_PATH = Path(__file__).resolve().parent / "config.yaml" _SOI_WFS_PARAMS = { "service": "WFS", @@ -56,6 +57,10 @@ def _load_new_config() -> dict: return yaml.safe_load(f) or {} +def _local_path(rel_path: str) -> Path: + return Path(rel_path.replace("{DATA_DIR}", str(DATA_DIR))) + + def _format_periodic_value(template: str, year: int) -> str: return template.replace("{year+1}", str(year + 1)).replace("{year}", str(year)) @@ -229,7 +234,7 @@ def ensure_manifest_base_layers(*layers): "Base layer %s has no source in %s; create it manually at %s.", layer["name"], CONFIG_NEW_PATH, - PROJECT_ROOT / layer["local_path"], + _local_path(layer["local_path"]), ) continue @@ -239,7 +244,7 @@ def ensure_manifest_base_layers(*layers): f"{layer.get('type')}" ) - local_path = PROJECT_ROOT / layer["local_path"] + local_path = _local_path(layer["local_path"]) if local_path.exists(): logger.info( "Base layer %s already exists at %s, skipping.", @@ -315,7 +320,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{DATA_DIR / 'admin-boundary'}"], check=True, ) except (subprocess.CalledProcessError, FileNotFoundError) as e: diff --git a/computing/config.yaml b/computing/config.yaml index 633d3814..4c7634cd 100644 --- a/computing/config.yaml +++ b/computing/config.yaml @@ -1,131 +1,603 @@ -# Local compute dependency manifest. +# Local compute 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/base_layers/pan_india_waterbodies.geojson - source: manual - note: Pan-India surface water bodies vector layer used for local clipping - - - path: data/admin-boundary/input/ - source: google_drive + static_layers: + - name: terrain + local_path: "{DATA_DIR}/base_layers/terrain_raster_fabdam_pan_india.tif" + source: s3://corestack-datasets/base_layers/static_layers/terrain/terrain_raster_fabdam_pan_india.tif + type: file + + - name: slope percentage + local_path: "{DATA_DIR}/base_layers/slope_percentage/slope_percentage.tif" + source: s3://corestack-datasets/base_layers/static_layers/slope_percentage/slope_percentage.tif + type: file + + - name: Aquifer Layer + aliases: + - aquifer + local_path: "{DATA_DIR}/base_layers/aquifer/aquifer.geojson" + source: s3://corestack-datasets/base_layers/static_layers/aquifer/aquifer.geojson + type: file + + - name: restoration opportunity + local_path: "{DATA_DIR}/base_layers/restoration_opportunity/restoration_opportunity.geojson" + source: s3://corestack-datasets/base_layers/static_layers/restoration_opportunity/restoration_opportunity.geojson + type: file + + - name: soge + local_path: "{DATA_DIR}/base_layers/soge/soge.geojson" + source: s3://corestack-datasets/base_layers/static_layers/soge/soge.geojson + type: file + + - name: lcw + local_path: "{DATA_DIR}/base_layers/lcw/lcw.tif" + source: s3://corestack-datasets/base_layers/static_layers/lcw/lcw.tif + type: file + + - name: factory csr + local_path: "{DATA_DIR}/base_layers/factory_csr/factory_csr.geojson" + source: s3://corestack-datasets/base_layers/static_layers/factory_csr/factory_csr.geojson + type: file + + - name: mining + local_path: "{DATA_DIR}/base_layers/mining/mining.geojson" + source: s3://corestack-datasets/base_layers/static_layers/mining/mining.geojson + type: file + + - name: facilities + local_path: "{DATA_DIR}/base_layers/facilities/facilities.geojson" + source: s3://corestack-datasets/base_layers/static_layers/facilities/facilities.geojson + type: file + + - name: dem + local_path: "{DATA_DIR}/base_layers/dem/dem.tif" + source: s3://corestack-datasets/base_layers/static_layers/dem/dem.tif + type: file + + - name: river + local_path: "{DATA_DIR}/base_layers/river/river.geojson" + source: s3://corestack-datasets/base_layers/static_layers/river/river.geojson + type: file + + - name: canal + local_path: "{DATA_DIR}/base_layers/canal/canal.geojson" + source: s3://corestack-datasets/base_layers/static_layers/canal/canal.geojson + type: file + + - name: hydrological soil group + local_path: "{DATA_DIR}/base_layers/hydrological_soil_group/hydrological_soil_group.geojson" + source: s3://corestack-datasets/base_layers/static_layers/hydrological_soil_group/hydrological_soil_group.geojson + type: file + + - name: mission antyodaya + local_path: "{DATA_DIR}/base_layers/mission_antyodaya/mission_antyodaya.gpkg" + source: "" + type: file + + - name: ceew climate data + local_path: "{DATA_DIR}/base_layers/ceew_climate_data/ceew_climate_data.tif" + source: "" + type: file + + - name: water quality aikosh + local_path: "{DATA_DIR}/base_layers/water_quality_aikosh/water_quality_aikosh.geojson" + source: "" + type: file + + - name: groundwater quality + local_path: "{DATA_DIR}/base_layers/groundwater_quality/groundwater_quality.geojson" + source: "" + type: file + + - name: admin boundaries + local_path: "{DATA_DIR}/admin-boundary/input/soi_tehsil.geojson" + source: "" + # Full admin-boundary archive (~8 GB, 7z) containing this file; extracted in place. 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_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}/ - pattern: "{district}_{block}_lulc_slope.gpkg" - geoserver_workspace: terrain_lulc - - # lulc_on_plain_cluster_local.py - - path: data/lulc_X_terrain/lulc_plain_clusters_local/{state}/{district}/{block}/ - pattern: "{district}_{block}_lulc_plain.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 - - surface_water_bodies: - # swb_local.py - - path: data/surface_water_bodies/swb_local/{state}/{district}/{block}/ - pattern: "surface_waterbodies_{district}_{block}.gpkg" - geoserver_workspace: swb + type: file + + - name: MWS Boundaries + aliases: + - mws + local_path: "{DATA_DIR}/base_layers/static_layers/mws/Microwatershed_v2_with_details.geojson" + source: s3://corestack-datasets/base_layers/static_layers/mws/Microwatershed_v2_with_details.geojson + type: file + + - name: stream order + local_path: "{DATA_DIR}/base_layers/stream_order/stream_order.tif" + source: "" + type: file + + - name: drainage lines + local_path: "{DATA_DIR}/base_layers/drainage_lines_pan_india.gpkg" + source: "" + type: file + + - name: natural depression + local_path: "{DATA_DIR}/base_layers/natural_depression/natural_depression.tif" + source: "" + type: file + + - name: catchment area + local_path: "{DATA_DIR}/base_layers/catchment_area/catchment_area.tif" + source: "" + type: file + + - name: soil health + local_path: "{DATA_DIR}/base_layers/soil_health/soil_health.tif" + source: "" + type: file + + - name: Nrega Layer (Scrapping) + aliases: + - nrega layer + local_path: "{DATA_DIR}/base_layers/nrega/nrega.gpkg" + source: "" + type: file + + - name: tree health + local_path: "{DATA_DIR}/base_layers/tree_health/tree_health.tif" + source: "" + type: file + + - name: green credit + local_path: "{DATA_DIR}/base_layers/green_credit/green_credit.geojson" + source: "" + type: file + + - name: Agroecological Natural Farming + aliases: + - aez + local_path: "{DATA_DIR}/base_layers/AEZs/Agro_Ecological_Regions.shp" + source: s3://corestack-datasets/base_layers/AEZs/Agro_Ecological_Regions.shp + type: file + + periodic_layers: + - name: lulc_v3 + filename: lulc_v3_{year}_{year+1}.tif + local_path: "{DATA_DIR}/base_layers/lulc/{filename}" + periodicity: annual + start_year: 2017 + end_year: 2024 + source: s3://corestack-datasets/base_layers/periodic_layers/lulc/lulc_v3/{filename} + type: file + + - name: lulc_v4 + filename: lulc_v4_{year}_{year+1}.tif + local_path: "{DATA_DIR}/base_layers/lulc/{filename}" + periodicity: annual + start_year: 2017 + end_year: 2024 + source: s3://corestack-datasets/base_layers/periodic_layers/lulc/lulc_v4/{filename} + type: file + + - name: lulc_v4_local_compute_cold_start + local_path: "{DATA_DIR}/base_layers/periodic/lulc_v4_local_compute_cold_start/" + source: "" + type: file + + - name: lulc_v4_sampling_cold_start + local_path: "{DATA_DIR}/base_layers/periodic/lulc_v4_sampling_cold_start/" + source: "" + type: file + + - name: lulc_v2_river_basin + local_path: "{DATA_DIR}/base_layers/periodic/lulc_v2_river_basin/" + source: "" + type: file + + - name: lulc_v3_river_basin + local_path: "{DATA_DIR}/base_layers/periodic/lulc_v3_river_basin/" + source: "" + type: file + + - name: lulc_for_tehsil + local_path: "{DATA_DIR}/base_layers/periodic/lulc_for_tehsil/" + source: "" + type: file + + - name: lulc_v4_aez_cold_start + local_path: "{DATA_DIR}/base_layers/periodic/lulc_v4_aez_cold_start/" + source: "" + type: file + + - name: lulc_v4_temporal_correction_cold_start + local_path: "{DATA_DIR}/base_layers/periodic/lulc_v4_temporal_correction_cold_start/" + source: "" + type: file + + - name: lulc_v4_aez_after_6_years + local_path: "{DATA_DIR}/base_layers/periodic/lulc_v4_aez_after_6_years/" + source: "" + type: file + + - name: lulc_v4_temporal_correction_after_6_years + local_path: "{DATA_DIR}/base_layers/periodic/lulc_v4_temporal_correction_after_6_years/" + source: "" + type: file + + - name: lulc_v4_local_compute_adding_new_grids + local_path: "{DATA_DIR}/base_layers/periodic/lulc_v4_local_compute_adding_new_grids/" + source: "" + type: file + + - name: lulc_v4_sampling_adding_new_grids + local_path: "{DATA_DIR}/base_layers/periodic/lulc_v4_sampling_adding_new_grids/" + source: "" + type: file + + - name: lulc_v4_aez_adding_new_grids + local_path: "{DATA_DIR}/base_layers/periodic/lulc_v4_aez_adding_new_grids/" + source: "" + type: file + + - name: lulc_v4_temporal_correction_adding_new_grids + local_path: "{DATA_DIR}/base_layers/periodic/lulc_v4_temporal_correction_adding_new_grids/" + source: "" + type: file + + - name: lulc_v4_temporal_correction_adding_new_grids_after_6_years + local_path: "{DATA_DIR}/base_layers/periodic/lulc_v4_temporal_correction_adding_new_grids_after_6_years/" + source: "" + type: file + + - name: rainfall jaxa + local_path: "{DATA_DIR}/base_layers/periodic/rainfall_jaxa/" + source: "" + type: file + + - name: rainfall chirps + local_path: "{DATA_DIR}/base_layers/periodic/rainfall_chirps/" + source: "" + type: file + + - name: et fldas + local_path: "{DATA_DIR}/base_layers/periodic/et_fldas/" + source: "" + type: file + + - name: et gldas + local_path: "{DATA_DIR}/base_layers/periodic/et_gldas/" + source: "" + type: file + + - name: pet + local_path: "{DATA_DIR}/base_layers/periodic/pet/" + source: "" + type: file + + - name: dw lulc + local_path: "{DATA_DIR}/base_layers/periodic/dw_lulc/" + source: "" + type: file + + - name: runoff + local_path: "{DATA_DIR}/base_layers/periodic/runoff/" + source: "" + type: file + + - name: swb + local_path: "{DATA_DIR}/base_layers/periodic/swb/" + source: "" + type: file + + - name: drought spei + local_path: "{DATA_DIR}/base_layers/periodic/drought_spei/" + source: "" + type: file + + - name: forest resistance and resilience to drought + local_path: "{DATA_DIR}/base_layers/periodic/forest_resistance_resilience_drought/" + source: "" + type: file + + - name: extreme rainfall + local_path: "{DATA_DIR}/base_layers/periodic/extreme_rainfall/" + source: "" + type: file + + - name: forest resistance and resilience to extreme rainfall + local_path: "{DATA_DIR}/base_layers/periodic/forest_resistance_resilience_extreme_rainfall/" + source: "" + type: file + + tehsil_level: + on_demand_layers: + - name: lulc_farm_boundary + local_path: "{DATA_DIR}/base_layers/on_demand/lulc_farm_boundary/" + source: "" + type: file + + - name: generate_ponds + local_path: "{DATA_DIR}/base_layers/on_demand/ponds/" + source: "" + type: file + + - name: generate_wells + local_path: "{DATA_DIR}/base_layers/on_demand/wells/" + source: "" + type: file + + - name: forest additonality + aliases: + - forest additionality + local_path: "{DATA_DIR}/base_layers/on_demand/forest_additionality/" + source: "" + type: file + gee_only: + - name: fes_clart_layer + local_path: "{DATA_DIR}/base_layers/on_demand/fes_clart_layer/" + source: "" + type: file + + - name: generate_clart + local_path: "{DATA_DIR}/base_layers/on_demand/clart/" + source: "" + type: file + + - name: generate_ndvi_timeseries + local_path: "{DATA_DIR}/base_layers/on_demand/ndvi_timeseries/" + source: "" + type: file + + - name: downscaling et + local_path: "{DATA_DIR}/base_layers/on_demand/downscaling_et/" + source: "" + type: file + + - name: forest fire + local_path: "{DATA_DIR}/base_layers/on_demand/forest_fire/" + source: "" + type: file + + - name: grassland degradation + local_path: "{DATA_DIR}/base_layers/on_demand/grassland_degradation/" + source: "" + type: file + + - name: deforestation on forest fringes core + local_path: "{DATA_DIR}/base_layers/on_demand/deforestation_forest_fringes_core/" + source: "" + type: file + + - name: plantation_site_suitability + local_path: "{DATA_DIR}/base_layers/on_demand/plantation_site_suitability/" + source: "" + type: file + + - name: temperature and humidity fortnightly timeseries + local_path: "{DATA_DIR}/base_layers/on_demand/temperature_humidity_fortnightly/" + source: "" + type: file + + - name: pollution through satellite measured aerosol density + local_path: "{DATA_DIR}/base_layers/on_demand/aerosol_density/" + source: "" + type: file +derived_layers: + - name: generate_mws_layer + + - name: lulc v3 + filename: "{district}_{block}_{start_year}-07-01_{end_year}-06-30_LULCmap_10m.tif" + local_path: "{DATA_DIR}/lulc/lulc_v3_local/{state}/{district}/{block}/{filename}" + geoserver_workspace: LULC_v3 + layer_type: raster + + - name: lulc v4 + + - name: generate_ci_layer + + - name: generate_terrain_raster + + - name: generate_terrain_descriptor + + - name: terrain_lulc_slope_cluster + aliases: + - lulc slope clusters + filename: "{district}_{block}_lulc_slope.gpkg" + local_path: "{DATA_DIR}/lulc_X_terrain/lulc_slope_clusters_local/{state}/{district}/{block}/{filename}" + geoserver_workspace: terrain_lulc + layer_type: vector + + - name: terrain_lulc_plain_cluster + aliases: + - lulc plain clusters + filename: "{district}_{block}_lulc_plain.gpkg" + local_path: "{DATA_DIR}/lulc_X_terrain/lulc_plain_clusters_local/{state}/{district}/{block}/{filename}" + geoserver_workspace: terrain_lulc + layer_type: vector + + - name: generate_terrain_compute_all + + - name: lulc vector + filename: lulc_vector_{district}_{block}.gpkg + local_path: "{DATA_DIR}/lulc/lulc_vector_local/{state}/{district}/{block}/{filename}" + geoserver_workspace: lulc_vector + layer_type: vector + + - name: change detection + params: + [ + Afforestation, + Deforestation, + Degradation, + Urbanization, + CropIntensity, + ] + filename: change_{district}_{block}_{param_name}_{start_year}_{end_year}.tif + local_path: "{DATA_DIR}/change_detection/change_detection_local/{state}/{district}/{block}/{filename}" + geoserver_workspace: change_detection + layer_type: raster + + - name: change detection vector + params: + [ + Afforestation, + Deforestation, + Degradation, + Urbanization, + CropIntensity, + ] + filename: change_vector_{district}_{block}_{param_name}_{start_year}_{end_year}.gpkg + local_path: "{DATA_DIR}/change_detection/change_detection_vector_local/{state}/{district}/{block}/{filename}" + geoserver_workspace: change_detection + layer_type: vector + + - name: aquifer vector + local_path: "{DATA_DIR}/layers/aquifer_vector/" + geoserver_workspace: aquifer_vector + layer_type: vector + + - name: generate_swb + local_path: "{DATA_DIR}/surface_water_bodies/" + geoserver_workspace: surface_water_bodies + layer_type: vector + + - name: crop_grid + + - name: dem_raster + local_path: "{DATA_DIR}/fabdem/fabdem_local/" + geoserver_workspace: dem + layer_type: raster + + - name: dem_vector + + - name: drainage_density_vector + local_path: "{DATA_DIR}/drainage_density/" + geoserver_workspace: drainage_density + layer_type: vector + + - name: generate_canal_vector + local_path: "{DATA_DIR}/canal/canal_local/" + geoserver_workspace: canal + layer_type: vector + + - name: generate_river_data + local_path: "{DATA_DIR}/river/river_local/" + geoserver_workspace: river + layer_type: vector + + - name: run_off_daily_and_fortnightly + + - name: clip_soil_health + + - name: soil_health_vector + + - name: tree_health_raster + + - name: tree_health_vector + + - name: stream_order + + - name: hydrology_fortnightly + + - name: hydrology_annual + + - name: generate_block_layer + + - name: generate_drainage_layer + local_path: "{DATA_DIR}/layers/drainage_lines/drainage_lines_local/" + geoserver_workspace: drainage_lines + layer_type: vector + + - name: restoration_opportunity + local_path: "{DATA_DIR}/layers/restoration_opportunity/" + geoserver_workspace: restoration + layer_type: raster + + - name: soge_vector + local_path: "{DATA_DIR}/layers/SOGE_vector/" + geoserver_workspace: soge + layer_type: vector + + - name: generate_lcw + local_path: "{DATA_DIR}/layers/lcw_conflict/" + geoserver_workspace: lcw + layer_type: raster + + - name: generate_agroecological + local_path: "{DATA_DIR}/layers/agroecological/" + geoserver_workspace: agroecological + layer_type: vector + + - name: generate_factory_csr + local_path: "{DATA_DIR}/layers/factory_csr/" + geoserver_workspace: factory_csr + layer_type: vector + + - name: generate_green_credit + local_path: "{DATA_DIR}/layers/green_credit/" + geoserver_workspace: green_credit + layer_type: vector + + - name: generate_mining + local_path: "{DATA_DIR}/layers/mining/" + geoserver_workspace: mining + layer_type: vector + + - name: generate_natural_depression + local_path: "{DATA_DIR}/layers/natural_depression/" + geoserver_workspace: natural_depression + layer_type: raster + + - name: generate_distance_nearest_DL + local_path: "{DATA_DIR}/layers/distance_nearest_upstream_DL/" + geoserver_workspace: distance_nearest_upstream_DL + layer_type: raster + + - name: generate_catchment_area_singleflow + local_path: "{DATA_DIR}/layers/catchment_area_singleflow/" + geoserver_workspace: catchment_area_singleflow + layer_type: raster + + - name: generate_slope_percentage + local_path: "{DATA_DIR}/layers/slope_percentage/" + geoserver_workspace: slope_percentage + layer_type: raster + + - name: generate_mws_connectivity_data + local_path: "{DATA_DIR}/layers/mws_connectivity/mws_connectivity_local/" + geoserver_workspace: mws_connectivity + layer_type: vector + + - name: generate_mws_centroid + local_path: "{DATA_DIR}/layers/mws_centroid/" + geoserver_workspace: mws_centroid + layer_type: vector + + - name: generate_facilities_proximity + local_path: "{DATA_DIR}/layers/facilities/" + geoserver_workspace: facilities + layer_type: vector + + - name: generate_nrega_layer + local_path: "{DATA_DIR}/layers/nrega_assets/" + geoserver_workspace: nrega_assets + layer_type: vector + + - name: merge_swb_ponds + + - name: generate_drought_layer + + - name: mws_drought_causality + + - name: clip_drought_spei + + - name: forest resistance and resilience to drought + + - name: extreme rainfall + + - name: forest resistance and resilience to extreme rainfall + + - name: drought sensitivity + + - name: grassland health + + - name: generate_zoi_data + + - name: generate_antyodaya + local_path: "{DATA_DIR}/antyodaya/output/antyodaya_local/" + geoserver_workspace: antyodaya + layer_type: vector + + - name: plantation_site_suitability diff --git a/computing/config_loader.py b/computing/config_loader.py index 6ee89a41..a42ce011 100644 --- a/computing/config_loader.py +++ b/computing/config_loader.py @@ -1,11 +1,18 @@ +import os from pathlib import Path import yaml -_CONFIG_PATH = Path(__file__).resolve().parent / "config_new.yaml" -_LEGACY_CONFIG_PATH = Path(__file__).resolve().parent / "config.yaml" +_CONFIG_PATH = Path(__file__).resolve().parent / "config.yaml" PROJECT_ROOT = Path(__file__).resolve().parents[1] +# Root directory for all downloaded/generated local compute data. Defaults to +# `/data` for local development, but should be set to a directory +# mounted from the host (e.g. a Docker volume) in containerized deployments. +DATA_DIR: Path = Path( + os.environ.get("DATA_DIR", str(PROJECT_ROOT / "data")) +).expanduser().resolve() + def _load(path: Path) -> dict: with open(path) as f: @@ -13,12 +20,12 @@ def _load(path: Path) -> dict: _cfg = _load(_CONFIG_PATH) -_legacy_cfg = _load(_LEGACY_CONFIG_PATH) def _abs(rel_path: str) -> Path: - base = rel_path.split("{")[0].rstrip("/") - return PROJECT_ROOT / base + resolved = rel_path.replace("{DATA_DIR}", str(DATA_DIR)) + base = resolved.split("{")[0].rstrip("/") + return Path(base) def _layer_key(name: str) -> str: @@ -104,21 +111,14 @@ def _base_layer_path( ) -> Path: layer = _base_layer(name, required=False) if layer and layer.get("local_path"): - local_path = Path(layer["local_path"]) + local_path = _abs(layer["local_path"]) if not allowed_suffixes or local_path.suffix.lower() in allowed_suffixes: - return PROJECT_ROOT / local_path + return local_path if fallback: - return PROJECT_ROOT / fallback + return _abs(fallback) raise KeyError(f"No local_path found in {_CONFIG_PATH.name} for base layer: {name}") -def _find_legacy_input(path_suffix: str) -> dict: - for item in _legacy_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 _derived_layer(name: str) -> dict | None: key = _layer_key(name) for layer in _cfg.get("derived_layers", []): @@ -127,19 +127,11 @@ def _derived_layer(name: str) -> dict | None: return None -def _derived_output_dir( - name: str, - legacy_module: str, - legacy_index: int = 0, -) -> Path: +def _derived_output_dir(name: str) -> Path: layer = _derived_layer(name) - if layer: - return _abs(layer["local_path"]) - return _abs(_legacy_output_entry(legacy_module, legacy_index)["path"]) - - -def _legacy_output_entry(module: str, index: int = 0) -> dict: - return _legacy_cfg["local_compute_outputs"][module][index] + if not layer: + raise KeyError(f"No derived layer found in {_CONFIG_PATH.name} for name: {name}") + return _abs(layer["local_path"]) # --------------------------------------------------------------------------- @@ -150,7 +142,7 @@ def _legacy_output_entry(module: str, index: int = 0) -> dict: next( layer["local_path"] for layer in _manifest_base_layers() - if layer["local_path"].startswith("data/base_layers/lulc/") + if layer["local_path"].startswith("{DATA_DIR}/base_layers/lulc/") ) ).parent @@ -158,156 +150,141 @@ def _legacy_output_entry(module: str, index: int = 0) -> dict: AEZ_VECTOR_PATH: Path = _base_layer_path("aez") -PRECOMPUTED_TEHSIL_WATERSHED_DIR: Path = _abs( - _find_legacy_input("data/base_layers/tehsil_watersheds/")["path"] -) +PRECOMPUTED_TEHSIL_WATERSHED_DIR: Path = DATA_DIR / "base_layers/tehsil_watersheds" MICROWATERSHED_PATH: Path = _base_layer_path("mws") AQUIFER_VECTOR_PATH: Path = _base_layer_path( "aquifer", - fallback="data/base_layers/Aquifer_vector.geojson", + fallback="{DATA_DIR}/base_layers/Aquifer_vector.geojson", allowed_suffixes=(".geojson", ".gpkg", ".shp"), ) SWB_VECTOR_PATH: Path = _base_layer_path( "surface water bodies", - fallback="data/base_layers/pan_india_waterbodies.geojson", + fallback="{DATA_DIR}/base_layers/pan_india_waterbodies.geojson", ) -SOI_TEHSIL_PATH: Path = PROJECT_ROOT / _find_legacy_input( - "data/admin-boundary/input/soi_tehsil.geojson" -)["path"] +SOI_TEHSIL_PATH: Path = _base_layer_path("admin boundaries") -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" +ADMIN_BOUNDARY_INPUT_DIR: Path = DATA_DIR / "admin-boundary/input" +ADMIN_BOUNDARY_OUTPUT_DIR: Path = DATA_DIR / "admin-boundary/output" +VILLAGE_BOUNDARIES_DIR: Path = DATA_DIR / "base_layers/village_boundaries" # --------------------------------------------------------------------------- # Google Drive IDs # --------------------------------------------------------------------------- -GDRIVE_ADMIN_BOUNDARY_FILE_ID: str = _find_legacy_input( - "data/admin-boundary/input/" -)["gdrive_id"] -GDRIVE_MICROWATERSHED_FILE_ID: str = _find_legacy_input( - "data/base_layers/Microwatershed_v2_with_details.geojson" -)["gdrive_id"] +GDRIVE_ADMIN_BOUNDARY_FILE_ID: str = _base_layer("admin boundaries").get("gdrive_id", "") + +# MWS boundaries are now sourced from S3 (see config.yaml); no Google Drive +# file ID is configured for it anymore. +_mws_layer = _base_layer("mws", required=False) or {} +GDRIVE_MICROWATERSHED_FILE_ID: str = _mws_layer.get("gdrive_id", "") -LULC_GDRIVE_FILES: list[tuple[str, str]] = [ - (Path(item["path"]).name, item["gdrive_id"]) - for item in _legacy_cfg["base_layers"]["inputs"] - if item["path"].startswith("data/base_layers/lulc/") - and item.get("source") == "google_drive" -] +# LULC rasters are now sourced from S3 as periodic base layers (see +# config.yaml); no per-year Google Drive file IDs are configured anymore. +LULC_GDRIVE_FILES: list[tuple[str, str]] = [] # --------------------------------------------------------------------------- # Output base directories # --------------------------------------------------------------------------- -CHANGE_DETECTION_RASTER_OUTPUT_DIR: Path = _derived_output_dir( - "change detection", "change_detection", 0 -) +CHANGE_DETECTION_RASTER_OUTPUT_DIR: Path = _derived_output_dir("change detection") CHANGE_DETECTION_VECTOR_OUTPUT_DIR: Path = _derived_output_dir( - "change detection vector", "change_detection", 1 -) -LULC_VECTOR_OUTPUT_DIR: Path = _derived_output_dir("lulc vector", "lulc", 0) -LULC_V3_OUTPUT_DIR: Path = _derived_output_dir("lulc v3", "lulc", 1) -LULC_SLOPE_CLUSTER_OUTPUT_DIR: Path = _derived_output_dir( - "lulc slope clusters", "lulc_x_terrain", 0 -) -LULC_PLAIN_CLUSTER_OUTPUT_DIR: Path = _derived_output_dir( - "lulc plain clusters", "lulc_x_terrain", 1 -) -AQUIFER_VECTOR_OUTPUT_DIR: Path = _derived_output_dir("aquifer vector", "misc", 0) -SWB_VECTOR_OUTPUT_DIR: Path = _abs( - _legacy_output_entry("surface_water_bodies", 0)["path"] + "change detection vector" ) +LULC_VECTOR_OUTPUT_DIR: Path = _derived_output_dir("lulc vector") +LULC_V3_OUTPUT_DIR: Path = _derived_output_dir("lulc v3") +LULC_SLOPE_CLUSTER_OUTPUT_DIR: Path = _derived_output_dir("lulc slope clusters") +LULC_PLAIN_CLUSTER_OUTPUT_DIR: Path = _derived_output_dir("lulc plain clusters") +AQUIFER_VECTOR_OUTPUT_DIR: Path = _derived_output_dir("aquifer vector") +SWB_VECTOR_OUTPUT_DIR: Path = _derived_output_dir("generate_swb") PAN_INDIA_DRAINAGE_LINES_GPKG_PATH = ( - PROJECT_ROOT / "data/base_layers/drainage_lines_pan_india.gpkg" + DATA_DIR / "base_layers/drainage_lines_pan_india.gpkg" ) -PAN_INDIA_DRAINAGE_LINES_PATH = PROJECT_ROOT / "data/layers/drainage_lines/Pan_India_drainage_lines.gpkg" -LOCAL_DRAINAGE_LINES_OUTPUT = PROJECT_ROOT / "data/layers/drainage_lines/drainage_lines_local" +PAN_INDIA_DRAINAGE_LINES_PATH = DATA_DIR / "layers/drainage_lines/Pan_India_drainage_lines.gpkg" +LOCAL_DRAINAGE_LINES_OUTPUT = DATA_DIR / "layers/drainage_lines/drainage_lines_local" -LOCAL_DRAINAGE_DENSITY_OUTPUT = PROJECT_ROOT / "data/drainage_density" +LOCAL_DRAINAGE_DENSITY_OUTPUT = DATA_DIR / "drainage_density" PAN_INDIA_CANAL_PATH = _base_layer_path( - "canal", fallback="data/canal/Canal_pan_india.geojson" + "canal", fallback="{DATA_DIR}/canal/Canal_pan_india.geojson" ) -LOCAL_CANAL_OUTPUT = PROJECT_ROOT / "data/canal/canal_local" +LOCAL_CANAL_OUTPUT = DATA_DIR / "canal/canal_local" PAN_INDIA_AGROECOLOGICAL_PATH = _base_layer_path( "aez", - fallback="data/base_layers/Pan_India_agroecological_farming.geojson", + fallback="{DATA_DIR}/base_layers/Pan_India_agroecological_farming.geojson", ) -LOCAL_AGROECOLOGICAL_OUTPUT = PROJECT_ROOT / "data/layers/agroecological" +LOCAL_AGROECOLOGICAL_OUTPUT = DATA_DIR / "layers/agroecological" PAN_INDIA_LCW_PATH = _base_layer_path( - "lcw", fallback="data/base_layers/Pan_India_lcw_conflict.geojson" + "lcw", fallback="{DATA_DIR}/base_layers/Pan_India_lcw_conflict.geojson" ) -LOCAL_LCW_OUTPUT = PROJECT_ROOT / "data/layers/lcw_conflict" +LOCAL_LCW_OUTPUT = DATA_DIR / "layers/lcw_conflict" PAN_INDIA_SOGE_PATH = _base_layer_path( - "soge", fallback="data/base_layers/Pan_India_SOGE_2020.geojson" + "soge", fallback="{DATA_DIR}/base_layers/Pan_India_SOGE_2020.geojson" ) -LOCAL_SOGE_OUTPUT = PROJECT_ROOT / "data/layers/SOGE_vector" +LOCAL_SOGE_OUTPUT = DATA_DIR / "layers/SOGE_vector" PAN_INDIA_FACTORY_CSR_PATH = _base_layer_path( - "factory csr", fallback="data/base_layers/Pan_India_factory_csr.geojson" + "factory csr", fallback="{DATA_DIR}/base_layers/Pan_India_factory_csr.geojson" ) -LOCAL_FACTORY_CSR_OUTPUT = PROJECT_ROOT / "data/layers/factory_csr" +LOCAL_FACTORY_CSR_OUTPUT = DATA_DIR / "layers/factory_csr" -PAN_INDIA_GREEN_CREDIT_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_green_credit.geojson" -LOCAL_GREEN_CREDIT_OUTPUT = PROJECT_ROOT / "data/layers/green_credit" +PAN_INDIA_GREEN_CREDIT_PATH = DATA_DIR / "base_layers/Pan_India_green_credit.geojson" +LOCAL_GREEN_CREDIT_OUTPUT = DATA_DIR / "layers/green_credit" PAN_INDIA_MINING_PATH = _base_layer_path( - "mining", fallback="data/base_layers/Pan_India_mining.geojson" + "mining", fallback="{DATA_DIR}/base_layers/Pan_India_mining.geojson" ) -LOCAL_MINING_OUTPUT = PROJECT_ROOT / "data/layers/mining" +LOCAL_MINING_OUTPUT = DATA_DIR / "layers/mining" -PAN_INDIA_NATURALDEPRESSION_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_natural_depression.tif" -LOCAL_NATURALDEPRESSION_OUTPUT = PROJECT_ROOT / "data/layers/natural_depression" +PAN_INDIA_NATURALDEPRESSION_PATH = DATA_DIR / "base_layers/Pan_India_natural_depression.tif" +LOCAL_NATURALDEPRESSION_OUTPUT = DATA_DIR / "layers/natural_depression" -PAN_INDIA_DISTANCETONEARESTDRAINAGE_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_distance_to_nearest_drainage.tif" -LOCAL_DISTANCETONEARESTDRAINAGE_OUTPUT = PROJECT_ROOT / "data/layers/distance_nearest_upstream_DL" +PAN_INDIA_DISTANCETONEARESTDRAINAGE_PATH = DATA_DIR / "base_layers/Pan_India_distance_to_nearest_drainage.tif" +LOCAL_DISTANCETONEARESTDRAINAGE_OUTPUT = DATA_DIR / "layers/distance_nearest_upstream_DL" PAN_INDIA_FACILITIES_PATH = _base_layer_path( - "facilities", fallback="data/base_layers/Pan_India_facilities_polygon.geojson" + "facilities", fallback="{DATA_DIR}/base_layers/Pan_India_facilities_polygon.geojson" ) -LOCAL_FACILITIES_OUTPUT = PROJECT_ROOT / "data/layers/facilities" -PAN_INDIA_CATCHMENT_AREA_PATH = PROJECT_ROOT / "data/base_layers/Pan_India_catchment_area.tif" -LOCAL_CATCHMENT_AREA_OUTPUT = PROJECT_ROOT / "data/layers/catchment_area_singleflow" +LOCAL_FACILITIES_OUTPUT = DATA_DIR / "layers/facilities" +PAN_INDIA_CATCHMENT_AREA_PATH = DATA_DIR / "base_layers/Pan_India_catchment_area.tif" +LOCAL_CATCHMENT_AREA_OUTPUT = DATA_DIR / "layers/catchment_area_singleflow" PAN_INDIA_SLOPE_PERCENTAGE_PATH = _base_layer_path( - "slope percentage", fallback="data/base_layers/Pan_India_slope_percentage.tif" + "slope percentage", fallback="{DATA_DIR}/base_layers/Pan_India_slope_percentage.tif" ) -LOCAL_SLOPE_PERCENTAGE_OUTPUT = PROJECT_ROOT / "data/layers/slope_percentage" +LOCAL_SLOPE_PERCENTAGE_OUTPUT = DATA_DIR / "layers/slope_percentage" -PAN_INDIA_MWS_CONNECTIVITY_PATH = PROJECT_ROOT / "data/layers/mws_connectivity/Pan_India_mws_connectivity.geojson" -LOCAL_MWS_CONNECTIVITY_OUTPUT = PROJECT_ROOT / "data/layers/mws_connectivity/mws_connectivity_local" +PAN_INDIA_MWS_CONNECTIVITY_PATH = DATA_DIR / "layers/mws_connectivity/Pan_India_mws_connectivity.geojson" +LOCAL_MWS_CONNECTIVITY_OUTPUT = DATA_DIR / "layers/mws_connectivity/mws_connectivity_local" -LOCAL_MWS_CENTROID_OUTPUT = PROJECT_ROOT / "data/layers/mws_centroid" +LOCAL_MWS_CENTROID_OUTPUT = DATA_DIR / "layers/mws_centroid" -NREGA_LOCAL_OUTPUT = PROJECT_ROOT / "data/layers/nrega_assets" +NREGA_LOCAL_OUTPUT = DATA_DIR / "layers/nrega_assets" PAN_INDIA_RESTORATION_PATH = _base_layer_path( "restoration opportunity", - fallback="data/base_layers/Pan_India_WRI_Restoration.tif", + fallback="{DATA_DIR}/base_layers/Pan_India_WRI_Restoration.tif", ) -LOCAL_RESTORATION_OUTPUT = PROJECT_ROOT / "data/layers/restoration_opportunity" +LOCAL_RESTORATION_OUTPUT = DATA_DIR / "layers/restoration_opportunity" PAN_INDIA_RIVER_PATH = _base_layer_path( - "river", fallback="data/river/River_pan_india.geojson" + "river", fallback="{DATA_DIR}/river/River_pan_india.geojson" ) -LOCAL_RIVER_OUTPUT = PROJECT_ROOT / "data/river/river_local" +LOCAL_RIVER_OUTPUT = DATA_DIR / "river/river_local" -PAN_INDIA_FABDEM_PATH = _base_layer_path("dem", fallback="data/fabdem/fabdem_pan_india.tif") -LOCAL_FABDEM_OUTPUT = PROJECT_ROOT / "data/fabdem/fabdem_local" +PAN_INDIA_FABDEM_PATH = _base_layer_path("dem", fallback="{DATA_DIR}/fabdem/fabdem_pan_india.tif") +LOCAL_FABDEM_OUTPUT = DATA_DIR / "fabdem/fabdem_local" -PAN_INDIA_ANTYODAYA_2020 = PROJECT_ROOT / "data/base_layers/pan_india_antyodaya_2020.gpkg" -LOCAL_ANTYODAYA_2020_OUTPUT = PROJECT_ROOT / "data/antyodaya/output/antyodaya_local" +PAN_INDIA_ANTYODAYA_2020 = DATA_DIR / "base_layers/pan_india_antyodaya_2020.gpkg" +LOCAL_ANTYODAYA_2020_OUTPUT = DATA_DIR / "antyodaya/output/antyodaya_local" -PAN_INDIA_LIVESTOCKS = PROJECT_ROOT / "data/base_layers/pan_india_livestock.gpkg" -LOCAL_LIVESTOCKS_OUTPUT = PROJECT_ROOT / "data/livestock/output/livestock_local" +PAN_INDIA_LIVESTOCKS = DATA_DIR / "base_layers/pan_india_livestock.gpkg" +LOCAL_LIVESTOCKS_OUTPUT = DATA_DIR / "livestock/output/livestock_local" diff --git a/computing/config_new.yaml b/computing/config_new.yaml deleted file mode 100644 index cb8c9bf1..00000000 --- a/computing/config_new.yaml +++ /dev/null @@ -1,601 +0,0 @@ -# Local compute manifest. - -base_layers: - static_layers: - - name: terrain - local_path: data/base_layers/terrain_raster_fabdam_pan_india.tif - source: s3://corestack-datasets/base_layers/static_layers/terrain/terrain_raster_fabdam_pan_india.tif - type: file - - - name: slope percentage - local_path: data/base_layers/slope_percentage/slope_percentage.tif - source: s3://corestack-datasets/base_layers/static_layers/slope_percentage/slope_percentage.tif - type: file - - - name: Aquifer Layer - aliases: - - aquifer - local_path: data/base_layers/aquifer/aquifer.tif - source: s3://corestack-datasets/base_layers/static_layers/aquifer/aquifer.tif - type: file - - - name: restoration opportunity - local_path: data/base_layers/restoration_opportunity/restoration_opportunity.geojson - source: s3://corestack-datasets/base_layers/static_layers/restoration_opportunity/restoration_opportunity.geojson - type: file - - - name: soge - local_path: data/base_layers/soge/soge.geojson - source: s3://corestack-datasets/base_layers/static_layers/soge/soge.geojson - type: file - - - name: lcw - local_path: data/base_layers/lcw/lcw.tif - source: s3://corestack-datasets/base_layers/static_layers/lcw/lcw.tif - type: file - - - name: factory csr - local_path: data/base_layers/factory_csr/factory_csr.geojson - source: s3://corestack-datasets/base_layers/static_layers/factory_csr/factory_csr.geojson - type: file - - - name: mining - local_path: data/base_layers/mining/mining.geojson - source: s3://corestack-datasets/base_layers/static_layers/mining/mining.geojson - type: file - - - name: facilities - local_path: data/base_layers/facilities/facilities.geojson - source: s3://corestack-datasets/base_layers/static_layers/facilities/facilities.geojson - type: file - - - name: dem - local_path: data/base_layers/dem/dem.tif - source: s3://corestack-datasets/base_layers/static_layers/dem/dem.tif - type: file - - - name: river - local_path: data/base_layers/river/river.geojson - source: s3://corestack-datasets/base_layers/static_layers/river/river.geojson - type: file - - - name: canal - local_path: data/base_layers/canal/canal.geojson - source: s3://corestack-datasets/base_layers/static_layers/canal/canal.geojson - type: file - - - name: hydrological soil group - local_path: data/base_layers/hydrological_soil_group/hydrological_soil_group.geojson - source: s3://corestack-datasets/base_layers/static_layers/hydrological_soil_group/hydrological_soil_group.geojson - type: file - - - name: mission antyodaya - local_path: data/base_layers/mission_antyodaya/mission_antyodaya.gpkg - source: "" - type: file - - - name: ceew climate data - local_path: data/base_layers/ceew_climate_data/ceew_climate_data.tif - source: "" - type: file - - - name: water quality aikosh - local_path: data/base_layers/water_quality_aikosh/water_quality_aikosh.geojson - source: "" - type: file - - - name: groundwater quality - local_path: data/base_layers/groundwater_quality/groundwater_quality.geojson - source: "" - type: file - - - name: admin boundaries - local_path: data/admin-boundary/input/soi_tehsil.geojson - source: "" - type: file - - - name: MWS Boundaries - aliases: - - mws - local_path: data/base_layers/static_layers/mws/Microwatershed_v2_with_details.geojson - source: s3://corestack-datasets/base_layers/static_layers/mws/Microwatershed_v2_with_details.geojson - type: file - - - name: stream order - local_path: data/base_layers/stream_order/stream_order.tif - source: "" - type: file - - - name: drainage lines - local_path: data/base_layers/drainage_lines_pan_india.gpkg - source: "" - type: file - - - name: natural depression - local_path: data/base_layers/natural_depression/natural_depression.tif - source: "" - type: file - - - name: catchment area - local_path: data/base_layers/catchment_area/catchment_area.tif - source: "" - type: file - - - name: soil health - local_path: data/base_layers/soil_health/soil_health.tif - source: "" - type: file - - - name: Nrega Layer (Scrapping) - aliases: - - nrega layer - local_path: data/base_layers/nrega/nrega.gpkg - source: "" - type: file - - - name: tree health - local_path: data/base_layers/tree_health/tree_health.tif - source: "" - type: file - - - name: green credit - local_path: data/base_layers/green_credit/green_credit.geojson - source: "" - type: file - - - name: Agroecological Natural Farming - aliases: - - aez - local_path: data/base_layers/AEZs/Agro_Ecological_Regions.shp - source: s3://corestack-datasets/base_layers/AEZs/Agro_Ecological_Regions.shp - type: file - - periodic_layers: - - name: lulc_v3 - filename: lulc_v3_{year}_{year+1}.tif - local_path: data/base_layers/lulc/{filename} - periodicity: annual - start_year: 2017 - end_year: 2024 - source: s3://corestack-datasets/base_layers/periodic_layers/lulc/lulc_v3/{filename} - type: file - - - name: lulc_v4 - filename: lulc_v4_{year}_{year+1}.tif - local_path: data/base_layers/lulc/{filename} - periodicity: annual - start_year: 2017 - end_year: 2024 - source: s3://corestack-datasets/base_layers/periodic_layers/lulc/lulc_v4/{filename} - type: file - - - name: lulc_v4_local_compute_cold_start - local_path: data/base_layers/periodic/lulc_v4_local_compute_cold_start/ - source: "" - type: file - - - name: lulc_v4_sampling_cold_start - local_path: data/base_layers/periodic/lulc_v4_sampling_cold_start/ - source: "" - type: file - - - name: lulc_v2_river_basin - local_path: data/base_layers/periodic/lulc_v2_river_basin/ - source: "" - type: file - - - name: lulc_v3_river_basin - local_path: data/base_layers/periodic/lulc_v3_river_basin/ - source: "" - type: file - - - name: lulc_for_tehsil - local_path: data/base_layers/periodic/lulc_for_tehsil/ - source: "" - type: file - - - name: lulc_v4_aez_cold_start - local_path: data/base_layers/periodic/lulc_v4_aez_cold_start/ - source: "" - type: file - - - name: lulc_v4_temporal_correction_cold_start - local_path: data/base_layers/periodic/lulc_v4_temporal_correction_cold_start/ - source: "" - type: file - - - name: lulc_v4_aez_after_6_years - local_path: data/base_layers/periodic/lulc_v4_aez_after_6_years/ - source: "" - type: file - - - name: lulc_v4_temporal_correction_after_6_years - local_path: data/base_layers/periodic/lulc_v4_temporal_correction_after_6_years/ - source: "" - type: file - - - name: lulc_v4_local_compute_adding_new_grids - local_path: data/base_layers/periodic/lulc_v4_local_compute_adding_new_grids/ - source: "" - type: file - - - name: lulc_v4_sampling_adding_new_grids - local_path: data/base_layers/periodic/lulc_v4_sampling_adding_new_grids/ - source: "" - type: file - - - name: lulc_v4_aez_adding_new_grids - local_path: data/base_layers/periodic/lulc_v4_aez_adding_new_grids/ - source: "" - type: file - - - name: lulc_v4_temporal_correction_adding_new_grids - local_path: data/base_layers/periodic/lulc_v4_temporal_correction_adding_new_grids/ - source: "" - type: file - - - name: lulc_v4_temporal_correction_adding_new_grids_after_6_years - local_path: data/base_layers/periodic/lulc_v4_temporal_correction_adding_new_grids_after_6_years/ - source: "" - type: file - - - name: rainfall jaxa - local_path: data/base_layers/periodic/rainfall_jaxa/ - source: "" - type: file - - - name: rainfall chirps - local_path: data/base_layers/periodic/rainfall_chirps/ - source: "" - type: file - - - name: et fldas - local_path: data/base_layers/periodic/et_fldas/ - source: "" - type: file - - - name: et gldas - local_path: data/base_layers/periodic/et_gldas/ - source: "" - type: file - - - name: pet - local_path: data/base_layers/periodic/pet/ - source: "" - type: file - - - name: dw lulc - local_path: data/base_layers/periodic/dw_lulc/ - source: "" - type: file - - - name: runoff - local_path: data/base_layers/periodic/runoff/ - source: "" - type: file - - - name: swb - local_path: data/base_layers/periodic/swb/ - source: "" - type: file - - - name: drought spei - local_path: data/base_layers/periodic/drought_spei/ - source: "" - type: file - - - name: forest resistance and resilience to drought - local_path: data/base_layers/periodic/forest_resistance_resilience_drought/ - source: "" - type: file - - - name: extreme rainfall - local_path: data/base_layers/periodic/extreme_rainfall/ - source: "" - type: file - - - name: forest resistance and resilience to extreme rainfall - local_path: data/base_layers/periodic/forest_resistance_resilience_extreme_rainfall/ - source: "" - type: file - - tehsil_level: - on_demand_layers: - - name: lulc_farm_boundary - local_path: data/base_layers/on_demand/lulc_farm_boundary/ - source: "" - type: file - - - name: generate_ponds - local_path: data/base_layers/on_demand/ponds/ - source: "" - type: file - - - name: generate_wells - local_path: data/base_layers/on_demand/wells/ - source: "" - type: file - - - name: forest additonality - aliases: - - forest additionality - local_path: data/base_layers/on_demand/forest_additionality/ - source: "" - type: file - gee_only: - - name: fes_clart_layer - local_path: data/base_layers/on_demand/fes_clart_layer/ - source: "" - type: file - - - name: generate_clart - local_path: data/base_layers/on_demand/clart/ - source: "" - type: file - - - name: generate_ndvi_timeseries - local_path: data/base_layers/on_demand/ndvi_timeseries/ - source: "" - type: file - - - name: downscaling et - local_path: data/base_layers/on_demand/downscaling_et/ - source: "" - type: file - - - name: forest fire - local_path: data/base_layers/on_demand/forest_fire/ - source: "" - type: file - - - name: grassland degradation - local_path: data/base_layers/on_demand/grassland_degradation/ - source: "" - type: file - - - name: deforestation on forest fringes core - local_path: data/base_layers/on_demand/deforestation_forest_fringes_core/ - source: "" - type: file - - - name: plantation_site_suitability - local_path: data/base_layers/on_demand/plantation_site_suitability/ - source: "" - type: file - - - name: temperature and humidity fortnightly timeseries - local_path: data/base_layers/on_demand/temperature_humidity_fortnightly/ - source: "" - type: file - - - name: pollution through satellite measured aerosol density - local_path: data/base_layers/on_demand/aerosol_density/ - source: "" - type: file -derived_layers: - - name: generate_mws_layer - - - name: lulc v3 - filename: "{district}_{block}_{start_year}-07-01_{end_year}-06-30_LULCmap_10m.tif" - local_path: data/lulc/lulc_v3_local/{state}/{district}/{block}/{filename} - geoserver_workspace: LULC_v3 - layer_type: raster - - - name: lulc v4 - - - name: generate_ci_layer - - - name: generate_terrain_raster - - - name: generate_terrain_descriptor - - - name: terrain_lulc_slope_cluster - aliases: - - lulc slope clusters - filename: "{district}_{block}_lulc_slope.gpkg" - local_path: data/lulc_X_terrain/lulc_slope_clusters_local/{state}/{district}/{block}/{filename} - geoserver_workspace: terrain_lulc - layer_type: vector - - - name: terrain_lulc_plain_cluster - aliases: - - lulc plain clusters - filename: "{district}_{block}_lulc_plain.gpkg" - local_path: data/lulc_X_terrain/lulc_plain_clusters_local/{state}/{district}/{block}/{filename} - geoserver_workspace: terrain_lulc - layer_type: vector - - - name: generate_terrain_compute_all - - - name: lulc vector - filename: lulc_vector_{district}_{block}.gpkg - local_path: data/lulc/lulc_vector_local/{state}/{district}/{block}/{filename} - geoserver_workspace: lulc_vector - layer_type: vector - - - name: change detection - params: - [ - Afforestation, - Deforestation, - Degradation, - Urbanization, - CropIntensity, - ] - filename: change_{district}_{block}_{param_name}_{start_year}_{end_year}.tif - local_path: data/change_detection/change_detection_local/{state}/{district}/{block}/{filename} - geoserver_workspace: change_detection - layer_type: raster - - - name: change detection vector - params: - [ - Afforestation, - Deforestation, - Degradation, - Urbanization, - CropIntensity, - ] - filename: change_vector_{district}_{block}_{param_name}_{start_year}_{end_year}.gpkg - local_path: data/change_detection/change_detection_vector_local/{state}/{district}/{block}/{filename} - geoserver_workspace: change_detection - layer_type: vector - - - name: aquifer vector - local_path: data/layers/aquifer_vector/ - geoserver_workspace: aquifer_vector - layer_type: vector - - - name: generate_swb - local_path: data/surface_water_bodies/ - geoserver_workspace: surface_water_bodies - layer_type: vector - - - name: crop_grid - - - name: dem_raster - local_path: data/fabdem/fabdem_local/ - geoserver_workspace: dem - layer_type: raster - - - name: dem_vector - - - name: drainage_density_vector - local_path: data/drainage_density/ - geoserver_workspace: drainage_density - layer_type: vector - - - name: generate_canal_vector - local_path: data/canal/canal_local/ - geoserver_workspace: canal - layer_type: vector - - - name: generate_river_data - local_path: data/river/river_local/ - geoserver_workspace: river - layer_type: vector - - - name: run_off_daily_and_fortnightly - - - name: clip_soil_health - - - name: soil_health_vector - - - name: tree_health_raster - - - name: tree_health_vector - - - name: stream_order - - - name: hydrology_fortnightly - - - name: hydrology_annual - - - name: generate_block_layer - - - name: generate_drainage_layer - local_path: data/layers/drainage_lines/drainage_lines_local/ - geoserver_workspace: drainage_lines - layer_type: vector - - - name: restoration_opportunity - local_path: data/layers/restoration_opportunity/ - geoserver_workspace: restoration - layer_type: raster - - - name: soge_vector - local_path: data/layers/SOGE_vector/ - geoserver_workspace: soge - layer_type: vector - - - name: generate_lcw - local_path: data/layers/lcw_conflict/ - geoserver_workspace: lcw - layer_type: raster - - - name: generate_agroecological - local_path: data/layers/agroecological/ - geoserver_workspace: agroecological - layer_type: vector - - - name: generate_factory_csr - local_path: data/layers/factory_csr/ - geoserver_workspace: factory_csr - layer_type: vector - - - name: generate_green_credit - local_path: data/layers/green_credit/ - geoserver_workspace: green_credit - layer_type: vector - - - name: generate_mining - local_path: data/layers/mining/ - geoserver_workspace: mining - layer_type: vector - - - name: generate_natural_depression - local_path: data/layers/natural_depression/ - geoserver_workspace: natural_depression - layer_type: raster - - - name: generate_distance_nearest_DL - local_path: data/layers/distance_nearest_upstream_DL/ - geoserver_workspace: distance_nearest_upstream_DL - layer_type: raster - - - name: generate_catchment_area_singleflow - local_path: data/layers/catchment_area_singleflow/ - geoserver_workspace: catchment_area_singleflow - layer_type: raster - - - name: generate_slope_percentage - local_path: data/layers/slope_percentage/ - geoserver_workspace: slope_percentage - layer_type: raster - - - name: generate_mws_connectivity_data - local_path: data/layers/mws_connectivity/mws_connectivity_local/ - geoserver_workspace: mws_connectivity - layer_type: vector - - - name: generate_mws_centroid - local_path: data/layers/mws_centroid/ - geoserver_workspace: mws_centroid - layer_type: vector - - - name: generate_facilities_proximity - local_path: data/layers/facilities/ - geoserver_workspace: facilities - layer_type: vector - - - name: generate_nrega_layer - local_path: data/layers/nrega_assets/ - geoserver_workspace: nrega_assets - layer_type: vector - - - name: merge_swb_ponds - - - name: generate_drought_layer - - - name: mws_drought_causality - - - name: clip_drought_spei - - - name: forest resistance and resilience to drought - - - name: extreme rainfall - - - name: forest resistance and resilience to extreme rainfall - - - name: drought sensitivity - - - name: grassland health - - - name: generate_zoi_data - - - name: generate_antyodaya - local_path: data/antyodaya/output/antyodaya_local/ - geoserver_workspace: antyodaya - layer_type: vector - - - name: plantation_site_suitability From 1762b4a7b1bb1cde7fcf13321bb2cbad29280318 Mon Sep 17 00:00:00 2001 From: amit-spatial Date: Mon, 20 Jul 2026 11:26:47 +0000 Subject: [PATCH 051/120] added graceful error handling for tehsils without any village with village id --- utilities/pipelines/tabular.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/utilities/pipelines/tabular.py b/utilities/pipelines/tabular.py index 29fb3976..f3aad2c4 100644 --- a/utilities/pipelines/tabular.py +++ b/utilities/pipelines/tabular.py @@ -135,7 +135,12 @@ def fetch_by_values( values = [value for value in values if value is not None and value == value] if not values: - return pd.DataFrame(columns=list(columns or [])) + empty_columns = ( + columns + if columns is not None + else (*self.key_columns, *(self.source_columns or ())) + ) + return pd.DataFrame(columns=list(dict.fromkeys(empty_columns))) if not self.is_fresh(): self.materialize() select_columns = "*" if columns is None else ", ".join(quote_identifier(col) for col in columns) From db96f08d69811a6b5f7ab7c0dea4300543bf9543 Mon Sep 17 00:00:00 2001 From: amit-spatial Date: Mon, 20 Jul 2026 11:28:16 +0000 Subject: [PATCH 052/120] resolved add to db, corrected db schema to match with the schema accepted by save_layer_info_to_db, update_layer_sync_status functions --- utilities/pipelines/publish.py | 82 ++++++++++++++++++++++------------ 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/utilities/pipelines/publish.py b/utilities/pipelines/publish.py index ea8f6a6b..03dcaa9e 100644 --- a/utilities/pipelines/publish.py +++ b/utilities/pipelines/publish.py @@ -421,13 +421,9 @@ def register_layer( ) -> dict[str, Any]: """Register a published layer in the Core Stack layer database. - Uses the same `save_layer_info_to_db` path as the GEE-backed pipelines, so - local pipeline layers appear alongside every other layer. The `Layer` model - is keyed on state/district/block, so only tehsil-scoped runs can register; + The `Layer` model is keyed on state/district/block, so only tehsil-scoped runs can register; other scopes are reported as skipped rather than failing the run. - Never raises: the run has already succeeded by the time this is called, so - a database problem is reported in the result instead of losing the outputs. """ level = str(getattr(scope, "level", "") or "").lower() @@ -437,32 +433,60 @@ def register_layer( if not (state and district and block): return {"ok": False, "status": "skipped", "reason": "scope is missing state, district, or tehsil name"} - try: - from computing.models import Dataset, LayerType - from computing.utils import save_layer_info_to_db, update_layer_sync_status + from django.db import transaction + + from computing.models import Dataset, Layer, LayerType + from geoadmin.models import DistrictSOI, StateSOI, TehsilSOI - Dataset.objects.get_or_create( + with transaction.atomic(): + dataset, _ = Dataset.objects.get_or_create( name=dataset_name, - defaults={"layer_type": LayerType.VECTOR, "workspace": workspace}, + defaults={ + "layer_type": LayerType.VECTOR, + "workspace": workspace, + "is_active": True, + }, + ) + dataset.layer_type = LayerType.VECTOR + dataset.workspace = workspace + dataset.is_active = True + dataset.save(update_fields=["layer_type", "workspace", "is_active", "updated_at"]) + + state_obj = StateSOI.objects.get(state_name__iexact=state) + district_obj = DistrictSOI.objects.get( + district_name__iexact=district, + state=state_obj, ) - layer_id = save_layer_info_to_db( - state=state, - district=district, - block=block, + block_obj = TehsilSOI.objects.get( + tehsil_name__iexact=block, + district=district_obj, + ) + layer, _ = Layer.objects.update_or_create( + dataset=dataset, layer_name=layer_name, - asset_id=geoserver_url, - dataset_name=dataset_name, - algorithm=algorithm, - algorithm_version=algorithm_version, - misc={"is_generated_locally": True, "geoserver_workspace": workspace, **(misc or {})}, - is_override=overwrite, - is_gee_asset=False, + state=state_obj, + district=district_obj, + block=block_obj, + layer_version="1.0", + defaults={ + "algorithm": algorithm, + "algorithm_version": algorithm_version, + "is_sync_to_geoserver": True, + "is_override": overwrite, + "gee_asset_path": "not applicable: local compute GeoServer layer", + "is_public_gee_asset": False, + "misc": { + "is_generated_locally": True, + "source_type": "local_compute", + "geoserver_workspace": workspace, + "geoserver_url": geoserver_url, + **(misc or {}), + }, + }, ) - if not layer_id: - return {"ok": False, "status": "not_registered", "dataset": dataset_name, - "reason": "save_layer_info_to_db returned no layer id (check state/district/block exist in the SOI tables)"} - update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) - return {"ok": True, "status": "registered", "dataset": dataset_name, "layer_id": layer_id} - except Exception as exc: - return {"ok": False, "status": "registration_failed", "dataset": dataset_name, - "error_type": exc.__class__.__name__, "error": str(exc)[:500]} + return { + "ok": True, + "status": "registered", + "dataset": dataset_name, + "layer_id": layer.id, + } From 593372967074075942e6b086d01def6939469a3a Mon Sep 17 00:00:00 2001 From: amit-spatial Date: Mon, 20 Jul 2026 11:29:37 +0000 Subject: [PATCH 053/120] revised save to db usage --- computing/misc/livestocks/pipeline.py | 46 +++++++++++++-------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/computing/misc/livestocks/pipeline.py b/computing/misc/livestocks/pipeline.py index d8de86a7..97ad3459 100644 --- a/computing/misc/livestocks/pipeline.py +++ b/computing/misc/livestocks/pipeline.py @@ -486,29 +486,6 @@ def run_livestocks_pipeline( geoserver = asdict(geoserver_result) geoserver["ok"] = True geoserver["status"] = "published" - if request.publish.register_layers: - result["layer_registration"] = register_layer( - dataset_name=output_config.get("dataset_name", "Livestock Census"), - layer_name=layer_name, - scope=request.scope, - workspace=geoserver_workspace, - geoserver_url=geoserver.get("wfs_url"), - algorithm=ALGORITHM, - algorithm_version=ALGORITHM_VERSION, - misc={ - "source_csv": config["sources"]["csv"], - "gpkg_path": result.get("gpkg_path"), - "links_path": result.get("links_path"), - "output_dir": bundle.path.as_posix(), - "geoserver_layer_name": layer_name, - "geoserver_url": geoserver.get("wfs_url"), - "rows": result.get("rows"), - "matched_rows": result.get("matched_rows"), - "join_coverage": result.get("join_coverage"), - }, - overwrite=request.publish.overwrite, - ) - result["layer_id"] = (result["layer_registration"] or {}).get("layer_id") except Exception as exc: geoserver = { "ok": False, @@ -520,6 +497,29 @@ def run_livestocks_pipeline( } timings["publish_geoserver_seconds"] = round(time.perf_counter() - t0, 3) result["geoserver"] = geoserver + if request.publish.register_layers and geoserver and geoserver.get("ok"): + result["layer_registration"] = register_layer( + dataset_name=output_config.get("dataset_name", "Livestock Census"), + layer_name=layer_name, + scope=request.scope, + workspace=geoserver_workspace, + geoserver_url=geoserver.get("wfs_url"), + algorithm=ALGORITHM, + algorithm_version=ALGORITHM_VERSION, + misc={ + "source_csv": config["sources"]["csv"], + "gpkg_path": result.get("gpkg_path"), + "links_path": result.get("links_path"), + "output_dir": bundle.path.as_posix(), + "geoserver_layer_name": layer_name, + "geoserver_url": geoserver.get("wfs_url"), + "rows": result.get("rows"), + "matched_rows": result.get("matched_rows"), + "join_coverage": result.get("join_coverage"), + }, + overwrite=request.publish.overwrite, + ) + result["layer_id"] = result["layer_registration"]["layer_id"] if outputs.readme: result["readme_path"] = bundle.write_readme( _readme_lines( From 7aa32d94de5b4750d602afee6e16a384505f38ff Mon Sep 17 00:00:00 2001 From: amit-spatial Date: Mon, 20 Jul 2026 11:30:10 +0000 Subject: [PATCH 054/120] Use canonical DB helpers for local pipeline registration --- computing/misc/antyodaya/pipeline.py | 62 +++++++++++-------- computing/misc/facilities/pipeline.py | 59 ++++++++++++------- computing/misc/livestocks/pipeline.py | 52 ++++++++++------ utilities/pipelines/__init__.py | 2 - utilities/pipelines/publish.py | 85 --------------------------- 5 files changed, 108 insertions(+), 152 deletions(-) diff --git a/computing/misc/antyodaya/pipeline.py b/computing/misc/antyodaya/pipeline.py index baeace53..3e5aa107 100644 --- a/computing/misc/antyodaya/pipeline.py +++ b/computing/misc/antyodaya/pipeline.py @@ -13,6 +13,7 @@ import pandas as pd from django.conf import settings +from computing.utils import save_layer_info_to_db, update_layer_sync_status from utilities.pipelines import AdminScope, CSAdminSource, StandardRequest, load_config from utilities.pipelines.admin import ( ADMIN_COLUMN_DESCRIPTIONS, @@ -28,7 +29,7 @@ stable_hash, utc_now_text, ) -from utilities.pipelines.publish import publish_gpkg_layer, register_layer +from utilities.pipelines.publish import publish_gpkg_layer from utilities.pipelines.schema import ( STATUS_MATCHED, STATUS_NO_DATA, @@ -597,29 +598,6 @@ def run_antyodaya_pipeline( geoserver = asdict(geoserver_result) geoserver["ok"] = True geoserver["status"] = "published" - if request.publish.register_layers: - result["layer_registration"] = register_layer( - dataset_name=output_config.get("dataset_name", "Antyodaya 2020"), - layer_name=result_name, - scope=request.scope, - workspace=geoserver_workspace, - geoserver_url=geoserver.get("wfs_url"), - algorithm=ALGORITHM, - algorithm_version=ALGORITHM_VERSION, - misc={ - "source_csv": config["sources"]["csv"], - "gpkg_path": result.get("gpkg_path"), - "links_path": result.get("links_path"), - "output_dir": bundle.path.as_posix(), - "geoserver_layer_name": result_name, - "geoserver_url": geoserver.get("wfs_url"), - "rows": result.get("rows"), - "matched_rows": result.get("matched_rows"), - "join_coverage": result.get("join_coverage"), - }, - overwrite=request.publish.overwrite, - ) - result["layer_id"] = (result["layer_registration"] or {}).get("layer_id") except Exception as exc: geoserver = { "ok": False, @@ -631,6 +609,42 @@ def run_antyodaya_pipeline( } timings["publish_geoserver_seconds"] = round(time.perf_counter() - t0, 3) result["geoserver"] = geoserver + if request.publish.register_layers and geoserver and geoserver.get("ok"): + state = request.scope.state_name + district = request.scope.district_name + block = request.scope.tehsil_name + if not (state and district and block): + raise ValueError( + "Layer registration requires state, district, and tehsil names." + ) + dataset_name = output_config.get("dataset_name", "Antyodaya 2020") + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=result_name, + asset_id="not applicable: local compute GeoServer layer", + dataset_name=dataset_name, + algorithm=ALGORITHM, + algorithm_version=ALGORITHM_VERSION, + misc={"is_generated_locally": True}, + is_override=request.publish.overwrite, + ) + if layer_id is None: + raise RuntimeError(f"Database registration failed for layer {result_name!r}.") + if update_layer_sync_status( + layer_id=layer_id, sync_to_geoserver=True + ) is None: + raise RuntimeError( + f"GeoServer sync status update failed for layer ID {layer_id}." + ) + result["layer_id"] = layer_id + result["layer_registration"] = { + "ok": True, + "status": "registered", + "dataset": dataset_name, + "layer_id": layer_id, + } if outputs.readme: result["readme_path"] = bundle.write_readme( _readme_lines( diff --git a/computing/misc/facilities/pipeline.py b/computing/misc/facilities/pipeline.py index 413b3fec..7e847551 100644 --- a/computing/misc/facilities/pipeline.py +++ b/computing/misc/facilities/pipeline.py @@ -16,6 +16,7 @@ from django.conf import settings from scipy.spatial import cKDTree +from computing.utils import save_layer_info_to_db, update_layer_sync_status from utilities.pipelines import AdminScope, CSAdminSource, StandardRequest, load_config from utilities.pipelines.admin import ( ADMIN_COLUMN_DESCRIPTIONS, @@ -53,7 +54,6 @@ from utilities.pipelines.publish import ( publish_gpkg_layer, publish_gpkg_layers, - register_layer, ) from utilities.pipelines.unicode import normalize_unicode_frame from nrm_app.celery import app @@ -1134,33 +1134,48 @@ def run_facilities_pipeline( } ).as_posix() if request.publish.register_layers and published_layers: - common_misc = { - "source_facilities_gpkg": config["sources"]["facilities_gpkg"], - "gpkg_path": result.get("gpkg_path"), - "facility_points_gpkg_path": result.get("facility_points_gpkg_path"), - "links_path": result.get("links_path"), - "output_dir": bundle.path.as_posix(), - "village_rows": result.get("village_rows"), - "nearest_rows": result.get("nearest_rows"), - "inventory_rows": result.get("inventory_rows"), - } + state = request.scope.state_name + district = request.scope.district_name + block = request.scope.tehsil_name + if not (state and district and block): + raise ValueError( + "Layer registration requires state, district, and tehsil names." + ) registrations: dict[str, dict[str, Any]] = {} for role, published in published_layers.items(): - registrations[role] = register_layer( - dataset_name=( - output_config.get("dataset_name", "Facilities Proximity") - if role == "village_properties" - else output_config.get("points_dataset_name", "Facilities Points") - ), + dataset_name = ( + output_config.get("dataset_name", "Facilities Proximity") + if role == "village_properties" + else output_config.get("points_dataset_name", "Facilities Points") + ) + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, layer_name=published["layer_name"], - scope=request.scope, - workspace=published["workspace"], - geoserver_url=published["wfs_url"], + asset_id="not applicable: local compute GeoServer layer", + dataset_name=dataset_name, algorithm=ALGORITHM, algorithm_version=ALGORITHM_VERSION, - misc={**common_misc, "output_role": role}, - overwrite=request.publish.overwrite, + misc={"is_generated_locally": True}, + is_override=request.publish.overwrite, ) + if layer_id is None: + raise RuntimeError( + f"Database registration failed for layer {published['layer_name']!r}." + ) + if update_layer_sync_status( + layer_id=layer_id, sync_to_geoserver=True + ) is None: + raise RuntimeError( + f"GeoServer sync status update failed for layer ID {layer_id}." + ) + registrations[role] = { + "ok": True, + "status": "registered", + "dataset": dataset_name, + "layer_id": layer_id, + } result["layer_registrations"] = registrations result["layer_registration"] = registrations.get("village_properties") result["layer_id"] = (result.get("layer_registration") or {}).get("layer_id") diff --git a/computing/misc/livestocks/pipeline.py b/computing/misc/livestocks/pipeline.py index 97ad3459..bd2b2d71 100644 --- a/computing/misc/livestocks/pipeline.py +++ b/computing/misc/livestocks/pipeline.py @@ -12,6 +12,7 @@ import pandas as pd from django.conf import settings +from computing.utils import save_layer_info_to_db, update_layer_sync_status from utilities.pipelines import AdminScope, CSAdminSource, StandardRequest, load_config from utilities.pipelines.admin import ( ADMIN_COLUMN_DESCRIPTIONS, @@ -27,7 +28,7 @@ stable_hash, utc_now_text, ) -from utilities.pipelines.publish import publish_gpkg_layer, register_layer +from utilities.pipelines.publish import publish_gpkg_layer from utilities.pipelines.schema import ( STATUS_MATCHED, STATUS_NO_DATA, @@ -498,28 +499,41 @@ def run_livestocks_pipeline( timings["publish_geoserver_seconds"] = round(time.perf_counter() - t0, 3) result["geoserver"] = geoserver if request.publish.register_layers and geoserver and geoserver.get("ok"): - result["layer_registration"] = register_layer( - dataset_name=output_config.get("dataset_name", "Livestock Census"), + state = request.scope.state_name + district = request.scope.district_name + block = request.scope.tehsil_name + if not (state and district and block): + raise ValueError( + "Layer registration requires state, district, and tehsil names." + ) + dataset_name = output_config.get("dataset_name", "Livestock Census") + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, layer_name=layer_name, - scope=request.scope, - workspace=geoserver_workspace, - geoserver_url=geoserver.get("wfs_url"), + asset_id="not applicable: local compute GeoServer layer", + dataset_name=dataset_name, algorithm=ALGORITHM, algorithm_version=ALGORITHM_VERSION, - misc={ - "source_csv": config["sources"]["csv"], - "gpkg_path": result.get("gpkg_path"), - "links_path": result.get("links_path"), - "output_dir": bundle.path.as_posix(), - "geoserver_layer_name": layer_name, - "geoserver_url": geoserver.get("wfs_url"), - "rows": result.get("rows"), - "matched_rows": result.get("matched_rows"), - "join_coverage": result.get("join_coverage"), - }, - overwrite=request.publish.overwrite, + misc={"is_generated_locally": True}, + is_override=request.publish.overwrite, ) - result["layer_id"] = result["layer_registration"]["layer_id"] + if layer_id is None: + raise RuntimeError(f"Database registration failed for layer {layer_name!r}.") + if update_layer_sync_status( + layer_id=layer_id, sync_to_geoserver=True + ) is None: + raise RuntimeError( + f"GeoServer sync status update failed for layer ID {layer_id}." + ) + result["layer_id"] = layer_id + result["layer_registration"] = { + "ok": True, + "status": "registered", + "dataset": dataset_name, + "layer_id": layer_id, + } if outputs.readme: result["readme_path"] = bundle.write_readme( _readme_lines( diff --git a/utilities/pipelines/__init__.py b/utilities/pipelines/__init__.py index a0960d1e..1bc80a6a 100644 --- a/utilities/pipelines/__init__.py +++ b/utilities/pipelines/__init__.py @@ -7,7 +7,6 @@ """ from .admin import AdminScope, CSAdminSource -from .publish import register_layer from .schema import StandardRequest, api_request_payload, load_config __all__ = [ @@ -15,6 +14,5 @@ "CSAdminSource", "StandardRequest", "api_request_payload", - "register_layer", "load_config", ] diff --git a/utilities/pipelines/publish.py b/utilities/pipelines/publish.py index 03dcaa9e..f697a5af 100644 --- a/utilities/pipelines/publish.py +++ b/utilities/pipelines/publish.py @@ -405,88 +405,3 @@ def publish_gpkg_layers( property_count=len(verification["properties"]), ) return results - - -def register_layer( - *, - dataset_name: str, - layer_name: str, - scope: Any, - workspace: str, - geoserver_url: str, - algorithm: str, - algorithm_version: str, - misc: dict[str, Any] | None = None, - overwrite: bool = False, -) -> dict[str, Any]: - """Register a published layer in the Core Stack layer database. - - The `Layer` model is keyed on state/district/block, so only tehsil-scoped runs can register; - other scopes are reported as skipped rather than failing the run. - - """ - - level = str(getattr(scope, "level", "") or "").lower() - if level not in {"tehsil", "block"}: - return {"ok": False, "status": "skipped", "reason": f"layer registration needs a tehsil scope, got {level!r}"} - state, district, block = scope.state_name, scope.district_name, scope.tehsil_name - if not (state and district and block): - return {"ok": False, "status": "skipped", "reason": "scope is missing state, district, or tehsil name"} - - from django.db import transaction - - from computing.models import Dataset, Layer, LayerType - from geoadmin.models import DistrictSOI, StateSOI, TehsilSOI - - with transaction.atomic(): - dataset, _ = Dataset.objects.get_or_create( - name=dataset_name, - defaults={ - "layer_type": LayerType.VECTOR, - "workspace": workspace, - "is_active": True, - }, - ) - dataset.layer_type = LayerType.VECTOR - dataset.workspace = workspace - dataset.is_active = True - dataset.save(update_fields=["layer_type", "workspace", "is_active", "updated_at"]) - - 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, - ) - layer, _ = Layer.objects.update_or_create( - dataset=dataset, - layer_name=layer_name, - state=state_obj, - district=district_obj, - block=block_obj, - layer_version="1.0", - defaults={ - "algorithm": algorithm, - "algorithm_version": algorithm_version, - "is_sync_to_geoserver": True, - "is_override": overwrite, - "gee_asset_path": "not applicable: local compute GeoServer layer", - "is_public_gee_asset": False, - "misc": { - "is_generated_locally": True, - "source_type": "local_compute", - "geoserver_workspace": workspace, - "geoserver_url": geoserver_url, - **(misc or {}), - }, - }, - ) - return { - "ok": True, - "status": "registered", - "dataset": dataset_name, - "layer_id": layer.id, - } From d88258aab997da57030a89351a7ea2dabd3b0858 Mon Sep 17 00:00:00 2001 From: pawangramvaani Date: Tue, 21 Jul 2026 08:28:29 -0700 Subject: [PATCH 055/120] update cols --- stats_generator/utils.py | 432 +++++++++++++++++++++------------------ 1 file changed, 236 insertions(+), 196 deletions(-) diff --git a/stats_generator/utils.py b/stats_generator/utils.py index 52361f75..9d482c94 100644 --- a/stats_generator/utils.py +++ b/stats_generator/utils.py @@ -263,16 +263,22 @@ def create_excel_for_livestock(data, writer): df = pd.DataFrame(df_data) # Columns to exclude - exclude_cols = ["state_name", "district_name", "TEHSIL"] + exclude_cols = ["cattle_female", "cattle_male", "buffalo_female", "buffalo_male", "sheep_female", "sheep_male", "goat_female", "goat_male", "pig_female", "pig_male"] df = df.drop(columns=exclude_cols, errors="ignore") - if "pc11_village_id" in df.columns: - df = df[df["pc11_village_id"].notna() &(df["pc11_village_id"].astype(str).str.strip() != "") &(df["pc11_village_id"] != 0)] - - # Keep important columns first if they exist - first_cols = [c for c in ["pc11_village_id", "NAME"] if c in df.columns] - other_cols = [c for c in df.columns if c not in first_cols] - df = df[first_cols + other_cols] + # if "pc11_village_id" in df.columns: + # df = df[df["pc11_village_id"].notna() &(df["pc11_village_id"].astype(str).str.strip() != "") &(df["pc11_village_id"] != 0)] + # + # # Keep important columns first if they exist + # first_cols = [c for c in ["pc11_village_id", "NAME"] if c in df.columns] + # other_cols = [c for c in df.columns if c not in first_cols] + # df = df[first_cols + other_cols] + # Rename remaining columns + + rename_cols = { + "livestock_status": "data_availability_status", + } + df = df.rename(columns=rename_cols) df.to_excel(writer, sheet_name="livestock", index=False) print("Excel file created for livestock") @@ -286,173 +292,78 @@ def create_excel_for_antyodaya_20(data, writer): df_data = [feature.get("properties", {}) for feature in features] df = pd.DataFrame(df_data) - required_columns = [ - "state_name", "district_name", "TEHSIL", "village_id", "village_name", - "pc11_state_id", "pc11_district_id", "pc11_subdistrict_id", - "institutionalization_cat_cluster", "institutionalization_cat_value", - "total_hhd", "total_hhd_mobilized_into_shg", - "total_no_of_shg_promoted", "total_shg", - "total_hhd_mobilized_into_pg", "availability_of_fpos_pacs", - "social_protection_cat_cluster", "social_protection_cat_value", - "gp_total_hhd_eligible_under_nfsa", - "gp_total_hhd_receiving_food_grains_from_fps", - "total_hhd_having_bpl_cards", - "total_hhd_availing_pension_under_nsap", - "civic_infrastructure_cat_cluster", - "civic_infrastructure_cat_value", - "availability_of_panchayat_bhawan", - "is_post_office_available", - "total_no_of_elect_rep_undergone_training_under_rgsa", - "total_no_of_elected_representatives", - "total_no_of_elect_rep_oriented_under_rgsa", - "availability_of_public_information_board", - "availability_of_public_library", - "financial_inclusion_cat_cluster", - "financial_inclusion_cat_value", - "is_bank_available", - "is_atm_available", - "is_bank_buss_correspondent_with_internet", - "total_shg_accessed_bank_loans", - "total_hhd_availing_pmjdy_bank_ac", - "energy_access_cat_cluster", - "energy_access_cat_value", - "availablility_hours_of_domestic_electricity", - "availability_of_elect_supply_to_msme", - "total_hhd_with_clean_energy", - "road_connectivity_cat_cluster", - "road_connectivity_cat_value", - "is_village_connected_to_all_weather_road", - "availability_of_internal_pucca_road", - "availability_of_public_transport", - "availability_of_railway_station", - "housing_quality_cat_cluster", - "housing_quality_cat_value", - "total_hhd_with_kuccha_wall_kuccha_roof", - "total_hhd_got_benefit_under_state_housing_scheme", - "total_hhd_have_got_pmay_house", - "total_hhd_in_pmay_permanent_wait_list", - "total_hhd_availing_pmuy_benefits", - "maternal_child_health_cat_cluster", - "maternal_child_health_cat_value", - "availability_of_mother_child_health_facilities", - "is_aanganwadi_centre_available", - "is_early_childhood_edu_provided_in_anganwadi", - "total_childs_aged_0_to_3_years", - "total_childs_aged_0_to_3_years_reg_under_aanganwadi", - "total_childs_aged_3_to_6_years_reg_under_aanganwadi", - "total_female_child_age_bw_0_6", - "total_male_child_age_bw_0_6", - "total_no_of_children_in_icds_cas", - "total_no_of_registered_children_in_anganwadi", - "total_anemic_pregnant_women", - "total_no_of_lactating_mothers", - "total_no_of_lactating_mothers_receiving_services_under_icds", - "total_no_of_pregnant_women", - "total_no_of_pregnant_women_receiving_services_under_icds", - "total_no_of_women_delivered_babies_at_hospitals_registered_asha", - "total_childs_aged_0_to_3_years_immunized", - "total_childs_categorized_non_stunted_as_per_icds", - "total_no_of_young_anemic_children_6_59_months_in_icds_cas", - "total_underweight_child_age_under_6_years", - "total_no_of_newly_born_children", - "total_no_of_newly_born_underweight_children", - "gp_total_no_of_beneficiaries_receiving_benefits_under_pmjay", - "gp_total_no_of_eligible_beneficiaries_under_pmjay", - "total_hhd_registered_under_pmjay", - "total_no_of_beneficiaries_receiving_benefits_under_pmmvy", - "total_no_of_eligible_beneficiaries_under_pmmvy", - "water_sanitation_cat_cluster", - "water_sanitation_cat_value", - "availability_of_piped_tap_water", - "total_hhd_having_piped_water_connection", - "total_hhd_not_having_sanitary_latrines", - "availability_of_drainage_system", - "is_community_waste_disposal_system", - "is_community_biogas_waste_recycle_for_production", - "livelihoods_cottage_traditional_industry_cat_cluster", - "livelihoods_cottage_traditional_industry_cat_value", - "availability_of_cottage_small_scale_units", - "total_hhd_engaged_cottage_small_scale_units", - "is_handloom", - "is_handicrafts", - "livelihoods_employment_cat_cluster", - "livelihoods_employment_cat_value", - "total_hhd_engaged_in_farm_activities", - "livelihoods_forest_resources_cat_cluster", - "livelihoods_forest_resources_cat_value", - "availability_of_community_forest", - "availability_of_minor_forest_production", - "total_hhd_source_of_minor_forest_production", - "livelihoods_common_resources_cat_cluster", - "livelihoods_common_resources_cat_value", - "is_common_pastures_available", - "livelihoods_alternative_farming_cat_cluster", - "livelihoods_alternative_farming_cat_value", - "is_bee_farming", - "is_sericulture", - "livelihoods_fisheries_cat_cluster", - "livelihoods_fisheries_cat_value", - "availability_of_aquaculture_ext_facility", - "availability_of_fish_community_ponds", - "availability_of_fish_farming", - "livestock_veterinary_cat_cluster", - "livestock_veterinary_cat_value", - "availability_of_livestock_extension_services", - "is_veterinary_hospital_available", - "availability_of_goatary_dev_project", - "availability_of_pigery_development", - "availability_of_poultry_dev_project", - "availability_of_milk_routes", - "agriculture_land_cultivation_cat_cluster", - "agriculture_land_cultivation_cat_value", - "area_irrigated_in_hac", - "net_sown_area_in_hac", - "net_sown_area_kharif_in_hac", - "net_sown_area_other_in_hac", - "net_sown_area_rabi_in_hac", - "total_cultivable_area_in_hac", - "agriculture_irrigation_watershed_cat_cluster", - "agriculture_irrigation_watershed_cat_value", - "availability_of_major_source_of_irrigation", - "availability_of_rain_harvest_system", - "availability_of_watershed_dev_project", - "total_approved_labour_budget_for_year", - "total_expenditure_approved_under_nrm_labour_budget_during_yr", - "no_of_farmers_using_drip_sprinkler", - "total_no_of_farmers", - "agriculture_organic_farming_cat_cluster", - "agriculture_organic_farming_cat_value", - "total_no_farmers_adopted_organic_farming", - "agriculture_support_services_cat_cluster", - "agriculture_support_services_cat_value", - "is_fertilizer_shop_available", - "is_govt_seed_centre_available", - "is_soil_testing_centre_available", - "total_no_of_farmers_received_benefit_under_pmfby", - "total_no_of_farmers_registered_under_pmkpy", - "total_no_of_farmers_add_fert_in_soil_as_per_report", - "agricultural_markets_cat_cluster", - "agricultural_markets_cat_value", - "availability_of_market", - "availability_of_food_storage_warehouse" + exclude_cols = [ + "shg_pen_feat_value", + "shg_fed_feat_value", + "pg_pen_feat_value", + "fpo_feat_value", + "pds_util_feat_value", + "bpl_cov_feat_value", + "nfsa_cov_feat_value", + "pension_cov_feat_value", + "panchayat_bhawan_feat_value", + "post_office_feat_value", + "rep_training_feat_value", + "rep_orientation_feat_value", + "info_board_feat_value", + "public_library_feat_value", + "bank_feat_value", + "atm_feat_value", + "bank_correspondent_feat_value", + "shg_credit_feat_value", + "jan_dhan_pen_feat_value", + "electrification_rate_feat_value", + "electricity_supply_to_msme_feat_value", + "clean_energy_penetration_feat_value", + "all_weather_road_feat_value", + "internal_pucca_road_feat_value", + "public_transport_feat_value", + "railway_station_feat_value", + "pucca_housing_rate_feat_value", + "housing_scheme_coverage_feat_value", + "pmay_demand_met_feat_value", + "ujjwala_coverage_feat_value", + "awc_infra_enrollment_coverage_feat_value", + "maternal_health_care_access_feat_value", + "child_nutrition_development_feat_value", + "newborn_health_outcomes_feat_value", + "health_schemes_utilization_feat_value", + "piped_water_coverage_feat_value", + "sanitation_coverage_feat_value", + "drainage_quality_feat_value", + "waste_disposal_feat_value", + "biogas_waste_recycling_feat_value", + "cottage_units_available_feat_value", + "cottage_industry_participation_feat_value", + "handloom_feat_value", + "handicrafts_feat_value", + "farm_employment_feat_value", + "community_forest_feat_value", + "minor_forest_production_feat_value", + "forest_dependence_feat_value", + "common_pastures_feat_value", + "alternative_farming_feat_value", + "fisheries_aquaculture_feat_value", + "veterinary_services_feat_value", + "development_projects_feat_value", + "milk_routes_feat_value", + "land_utilization_feat_value", + "irrigation_infra_watershed_dev_feat_value", + "nrega_nrm_exp_feat_value", + "modern_irrigation_feat_value", + "organic_farming_feat_value", + "agri_inputs_availability_feat_value", + "agri_risk_support_feat_value", + "soil_testing_adoption_feat_value", + "market_access_feat_value", + "food_storage_feat_value" ] + df = df.drop(columns=exclude_cols, errors="ignore") + rename_cols = { + "antyodaya_status": "data_availability_status", + } + df = df.rename(columns=rename_cols) - # Keep only available columns from the required list - df = df[[col for col in required_columns if col in df.columns]] - - df = df.rename(columns={"TEHSIL": "tehsil_name"}) - - if "village_id" in df.columns: - df = df[df["village_id"].notna() &(df["village_id"].astype(str).str.strip() != "") &(df["village_id"] != 0)] - - # Keep important columns first if they exist - first_cols = [c for c in ["village_id", "village_name"] if c in df.columns] - other_cols = [c for c in df.columns if c not in first_cols] - df = df[first_cols + other_cols] - - # Round numeric columns - numeric_cols = df.select_dtypes(include=["number"]).columns - df[numeric_cols] = df[numeric_cols].round(2) df.to_excel(writer, sheet_name="antyodaya", index=False) print("Excel file created for antyodaya") except Exception as e: @@ -679,33 +590,162 @@ def create_excel_for_facilities(data, writer): df_data = [feature["properties"] for feature in features] df = pd.DataFrame(df_data) - if "censuscode2011" in df.columns: - df = df[df["censuscode2011"].notna() &(df["censuscode2011"].astype(str).str.strip() != "") &(df["censuscode2011"] != 0)] - - first_cols = ["censuscode2011", "censusname"] - other_cols = [c for c in df.columns if c not in first_cols] - df = df[first_cols + other_cols] - - numeric_cols = df.select_dtypes(include=["int64", "float64"]).columns - df[numeric_cols] = df[numeric_cols].round(2) - exclude_cols = [ - "censuscode2011", - "censusname", - "district", - "core_admin_uid", - "shrid2", - "state", - "tehsil", + "l2_essential_education_selected_l3", + "l2_essential_education_facility_uid", + "l3_school_primary_facility_uid", + "l3_school_primary_inside_scope", + "l3_school_upper_primary_facility_uid", + "l3_school_upper_primary_inside_scope", + "l3_school_secondary_facility_uid", + "l3_school_secondary_inside_scope", + "l2_higher_education_selected_l3", + "l2_higher_education_facility_uid", + "l3_school_higher_secondary_facility_uid", + "l3_school_higher_secondary_inside_scope", + "l3_college_facility_uid", + "l3_college_inside_scope", + "l3_universities_facility_uid", + "l3_universities_inside_scope", + "l2_essential_health_selected_l3", + "l2_essential_health_facility_uid", + "l3_health_sub_cen_facility_uid", + "l3_health_sub_cen_inside_scope", + "l3_health_phc_facility_uid", + "l3_health_phc_inside_scope", + "l2_advanced_health_selected_l3", + "l2_advanced_health_facility_uid", + "l3_health_chc_facility_uid", + "l3_health_chc_inside_scope", + "l3_health_dis_h_facility_uid", + "l3_health_dis_h_inside_scope", + "l3_health_s_t_h_facility_uid", + "l3_health_s_t_h_inside_scope", + "l2_essential_services_selected_l3", + "l2_essential_services_facility_uid", + "l3_pds_facility_uid", + "l3_pds_inside_scope", + "l2_financial_inclusion_selected_l3", + "l2_financial_inclusion_facility_uid", + "l3_csc_facility_uid", + "l3_csc_inside_scope", + "l3_bank_mitra_facility_uid", + "l3_bank_mitra_inside_scope", + "l3_bank_branch_facility_uid", + "l3_bank_branch_inside_scope", + "l3_bank_atm_facility_uid", + "l3_bank_atm_inside_scope", + "l2_apmc_access_selected_l3", + "l2_apmc_access_facility_uid", + "l3_apmc_facility_uid", + "l3_apmc_inside_scope", + "l3_agri_industry_markets_trading_facility_uid", + "l3_agri_industry_markets_trading_inside_scope", + "l2_post_harvest_selected_l3", + "l2_post_harvest_facility_uid", + "l3_agri_industry_storage_warehousing_facility_uid", + "l3_agri_industry_storage_warehousing_inside_scope", + "l3_agri_industry_distribution_utilities_facility_uid", + "l3_agri_industry_distribution_utilities_inside_scope", + "l3_agri_industry_agri_processing_facility_uid", + "l3_agri_industry_agri_processing_inside_scope", + "l3_agri_industry_industrial_manufacturing_facility_uid", + "l3_agri_industry_industrial_manufacturing_inside_scope", + "l2_cooperative_selected_l3", + "l2_cooperative_facility_uid", + "l3_agri_industry_co_operatives_societies_facility_uid", + "l3_agri_industry_co_operatives_societies_inside_scope", + "l2_livestock_selected_l3", + "l2_livestock_facility_uid", + "l3_agri_industry_dairy_animal_husbandry_facility_uid", + "l3_agri_industry_dairy_animal_husbandry_inside_scope", + "l2_agri_support_infra_selected_l3", + "l2_agri_support_infra_facility_uid", + "l3_agri_industry_agri_support_infrastructure_facility_uid", + "l3_agri_industry_agri_support_infrastructure_inside_scope", + "facilities_layer_kind", + "title", ] - df.rename( - columns={ - col: f"{col}_in_km" for col in df.columns if col not in exclude_cols - }, - inplace=True, - ) + + df = df.drop(columns=exclude_cols, errors="ignore") + # if "censuscode2011" in df.columns: + # df = df[df["censuscode2011"].notna() &(df["censuscode2011"].astype(str).str.strip() != "") &(df["censuscode2011"] != 0)] + # + # first_cols = ["censuscode2011", "censusname"] + # other_cols = [c for c in df.columns if c not in first_cols] + # df = df[first_cols + other_cols] + # + # numeric_cols = df.select_dtypes(include=["int64", "float64"]).columns + # df[numeric_cols] = df[numeric_cols].round(2) + # + # exclude_cols = [ + # "censuscode2011", + # "censusname", + # "district", + # "core_admin_uid", + # "shrid2", + # "state", + # "tehsil", + # ] + # df.rename( + # columns={ + # col: f"{col}_in_km" for col in df.columns if col not in exclude_cols + # }, + # inplace=True, + # ) # Write to Excel + rename_cols = { + "facilities_status": "data_availability_status", + "l2_essential_education_distance_km": "essential_education_cat_distance_km", + "l2_essential_education_selected_l3_label": "essential_education_facility_label", + "l3_school_primary_distance_km": "school_primary_distance_km", + "l3_school_upper_primary_distance_km": "school_upper_primary_distance_km", + "l3_school_secondary_distance_km": "school_secondary_distance_km", + "l2_higher_education_distance_km": "higher_education_cat_distance_km", + "l2_higher_education_selected_l3_label": "higher_education_facility_label", + "l3_school_higher_secondary_distance_km": "school_higher_secondary_distance_km", + "l3_college_distance_km": "college_distance_km", + "l3_universities_distance_km": "universities_distance_km", + "l2_essential_health_distance_km": "essential_health_cat_distance_km", + "l2_essential_health_selected_l3_label": "essential_health_facility_label", + "l3_health_sub_cen_distance_km": "health_sub_cen_distance_km", + "l3_health_phc_distance_km": "health_phc_distance_km", + "l2_advanced_health_distance_km": "advanced_health_cat_distance_km", + "l2_advanced_health_selected_l3_label": "advanced_health_facility_label", + "l3_health_chc_distance_km": "health_chc_distance_km", + "l3_health_dis_h_distance_km": "health_dis_h_distance_km", + "l3_health_s_t_h_distance_km": "health_s_t_h_distance_km", + "l2_essential_services_distance_km": "essential_services_cat_distance_km", + "l2_essential_services_selected_l3_label": "essential_services_facility_label", + "l3_pds_distance_km": "pds_distance_km", + "l2_financial_inclusion_distance_km": "financial_inclusion_cat_distance_km", + "l2_financial_inclusion_selected_l3_label": "financial_inclusion_facility_label", + "l3_csc_distance_km": "csc_distance_km", + "l3_bank_mitra_distance_km": "bank_mitra_distance_km", + "l3_bank_branch_distance_km": "bank_branch_distance_km", + "l3_bank_atm_distance_km": "bank_atm_distance_km", + "l2_apmc_access_distance_km": "apmc_markets_cat_distance_km", + "l2_apmc_access_selected_l3_label": "apmc_markets_facility_label", + "l3_apmc_distance_km": "apmc_markets_distance_km", + "l3_agri_industry_markets_trading_distance_km": "agri_industry_markets_trading_distance_km", + "l2_post_harvest_distance_km": "post_harvest_cat_distance_km", + "l2_post_harvest_selected_l3_label": "post_harvest_facility_label", + "l3_agri_industry_storage_warehousing_distance_km": "agri_industry_storage_warehousing_distance_km", + "l3_agri_industry_distribution_utilities_distance_km": "agri_industry_distribution_utilities_distance_km", + "l3_agri_industry_agri_processing_distance_km": "agri_industry_agri_processing_distance_km", + "l3_agri_industry_industrial_manufacturing_distance_km": "agri_industry_industrial_manufacturing_distance_km", + "l2_cooperative_distance_km": "cooperative_cat_distance_km", + "l2_cooperative_selected_l3_label": "cooperative_facility_label", + "l3_agri_industry_co_operatives_societies_distance_km": "agri_industry_co_operatives_societies_distance_km", + "l2_livestock_distance_km": "livestock_cat_distance_km", + "l2_livestock_selected_l3_label": "livestock_facility_label", + "l3_agri_industry_dairy_animal_husbandry_distance_km": "agri_industry_dairy_animal_husbandry_distance_km", + "l2_agri_support_infra_distance_km": "agri_support_infra_cat_distance_km", + "l2_agri_support_infra_selected_l3_label": "agri_support_infra_facility_label", + "l3_agri_industry_agri_support_infrastructure_distance_km": "agri_industry_agri_support_infrastructure_distance_km", + } + df = df.rename(columns=rename_cols) df.to_excel(writer, sheet_name="facilities_proximity", index=False) print("Excel file created for facilities_proximity") From 3f931b0b2b7831e572f4d2554d6bbf0348382270 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Wed, 22 Jul 2026 03:58:35 +0530 Subject: [PATCH 056/120] swb to geoserver --- computing/config.yaml | 8 +++ computing/surface_water_bodies/swb_local.py | 64 +++++++++++++++------ 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/computing/config.yaml b/computing/config.yaml index 4c7634cd..382f7323 100644 --- a/computing/config.yaml +++ b/computing/config.yaml @@ -19,6 +19,14 @@ base_layers: source: s3://corestack-datasets/base_layers/static_layers/aquifer/aquifer.geojson type: file + - name: surface water bodies + aliases: + - swb + - pan india waterbodies + local_path: "{DATA_DIR}/base_layers/pan_india_waterbodies.fgb" + source: "" + type: file + - name: restoration opportunity local_path: "{DATA_DIR}/base_layers/restoration_opportunity/restoration_opportunity.geojson" source: s3://corestack-datasets/base_layers/static_layers/restoration_opportunity/restoration_opportunity.geojson diff --git a/computing/surface_water_bodies/swb_local.py b/computing/surface_water_bodies/swb_local.py index 69ae6090..41979df6 100644 --- a/computing/surface_water_bodies/swb_local.py +++ b/computing/surface_water_bodies/swb_local.py @@ -16,7 +16,11 @@ write_vector_output, ) from computing.surface_water_bodies.clip_swb_local import _clip_gdf, _to_geom -from computing.utils import save_layer_info_to_db +from computing.utils import ( + push_shape_to_geoserver, + save_layer_info_to_db, + update_layer_sync_status, +) from nrm_app.celery import app from utilities.constants import GEE_PATHS from utilities.gee_utils import ( @@ -34,6 +38,7 @@ LOCAL_ALGORITHM = "local_surface_water_bodies_clip" LOCAL_ALGORITHM_VERSION = "local-1.0" DATASET_NAME = "Surface Water Bodies" +GEOSERVER_WORKSPACE = "swb" logger = logging.getLogger(__name__) SQM_PER_HECTARE = 10000.0 GEE_EXPORT_CHUNK_SIZE = 1000 @@ -256,6 +261,17 @@ def _export_gdf_to_gee_in_chunks(gdf, base_description, base_asset_id): ) +def _push_local_swb_to_geoserver(output_path, layer_name): + geoserver_response = push_shape_to_geoserver( + str(Path(output_path).with_suffix("")), + workspace=GEOSERVER_WORKSPACE, + layer_name=layer_name, + file_type="gpkg", + ) + logger.info("GeoServer response for local SWB layer %s: %s", layer_name, geoserver_response) + return bool(geoserver_response) and geoserver_response.get("status_code") in (200, 201) + + def run_swb_local( state=None, district=None, @@ -264,7 +280,7 @@ def run_swb_local( roi_path=None, asset_suffix=None, swb_path=SWB_VECTOR_PATH, - push_to_geoserver=False, + push_to_geoserver=True, sync_layer_metadata=True, gee_account_id=None, app_type="MWS", @@ -310,10 +326,22 @@ def run_swb_local( asset_folder_list, ) + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + custom_subdir=asset_suffix, + block_fallback="unknown_block", + ) + logger.info("Local SWB output path: %s", output_path) + if is_gee_asset_exists(gee_asset_id): logger.info("GEE asset already exists, reusing: %s", gee_asset_id) + layer_id = None if sync_layer_metadata: - save_layer_info_to_db( + layer_id = save_layer_info_to_db( state=state, district=district, block=block, @@ -325,18 +353,15 @@ def run_swb_local( algorithm_version=LOCAL_ALGORITHM_VERSION, ) make_asset_public(gee_asset_id) - return True - output_path = build_output_vector_path( - layer_name=layer_name, - state=state, - district=district, - block=block, - output_base_dir=LOCAL_OUTPUT_BASE_DIR, - custom_subdir=asset_suffix, - block_fallback="unknown_block", - ) - logger.info("Local SWB output path: %s", output_path) + if push_to_geoserver and output_path.exists(): + layer_at_geoserver = _push_local_swb_to_geoserver( + output_path=output_path, layer_name=layer_name + ) + if layer_at_geoserver and layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + logger.info("Sync to GeoServer flag updated for existing local SWB layer") + return True clipped_gdf = _clip_gdf(_resolve_source_path(swb_path), roi_geometry) if clipped_gdf.empty: @@ -359,7 +384,6 @@ def run_swb_local( ) logger.info("Saved local SWB vector to disk: %s", local_asset_path) - _ = push_to_geoserver ee_initialize(gee_account_id) logger.info( "Initialized Earth Engine for local SWB export: gee_account_id=%s", @@ -442,6 +466,14 @@ def run_swb_local( layer_name, ) + if push_to_geoserver: + layer_at_geoserver = _push_local_swb_to_geoserver( + output_path=local_asset_path, layer_name=layer_name + ) + if layer_at_geoserver and layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + logger.info("Sync to GeoServer flag updated for local SWB layer: %s", layer_name) + return True @@ -465,7 +497,7 @@ def _generate_swb_local_task( roi=roi, roi_path=roi_path, asset_suffix=asset_suffix, - push_to_geoserver=False, + push_to_geoserver=True, sync_layer_metadata=True, gee_account_id=gee_account_id, app_type=app_type, From b5535381a899ac6314559cadc9a846c6e506258a Mon Sep 17 00:00:00 2001 From: aman verma Date: Wed, 22 Jul 2026 10:08:37 +0000 Subject: [PATCH 057/120] ltp-stp acz level --- .../local/generate_ltp_stp_local.py | 390 +++++++++++++----- 1 file changed, 292 insertions(+), 98 deletions(-) diff --git a/computing/tree_health/local/generate_ltp_stp_local.py b/computing/tree_health/local/generate_ltp_stp_local.py index 4af69c18..56cad66f 100644 --- a/computing/tree_health/local/generate_ltp_stp_local.py +++ b/computing/tree_health/local/generate_ltp_stp_local.py @@ -1,17 +1,3 @@ -""" -This is written to closely follow the original GEE notebook: - Year - -> ACZ - -> District - -> Clip LULC - -> Tree mask - -> Polygonize - -> Compute patch area - -> Classify LTP/STP - -> Rasterize - -> Save GeoTIFF -""" - import os import re @@ -23,14 +9,50 @@ from rasterio.features import shapes, rasterize from shapely.geometry import shape from rasterio.enums import Resampling -from rasterio.warp import calculate_default_transform, reproject +from rasterio.warp import reproject from computing.config_loader import LULC_BASE_DIR, PROJECT_ROOT from rasterio.merge import merge from glob import glob +from math import floor +from rasterio.transform import Affine + +""" +Generate Long-Term Tree Patches (LTP) and Short-Term Tree Patches (STP) rasters. + +This script processes satellite imagery and land use data to identify and classify +tree patches based on size thresholds. It follows this workflow: + Year + -> ACZ (Agroclimatic Zone) + -> District + -> Clip LULC (Land Use Land Cover) data + -> Extract tree mask + -> Polygonize tree patches + -> Compute patch area in hectares + -> Classify as LTP (Large Tree Patches) or STP (Small Tree Patches) + -> Rasterize classified patches + -> Save GeoTIFF output +""" + +# LULC classification value for tree/forest cover TREE_CLASS = 6 + +# Minimum area threshold (in hectares) to classify a patch as Large Tree Patch (LTP) +# Patches below this are classified as Short-term Tree Patches (STP) AREA_THRESHOLD_HA = 1.0 -LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/tree_health/ltp_stp" + +# Source LULC data resolution in meters +SOURCE_RESOLUTION_M = 10 + +# Target output resolution in meters (1:2.5 resampling ratio) +TARGET_RESOLUTION_M = 25 + +# Base output directory for LTP/STP raster files +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/base_layers/tree_health/ltp_stp" + + +# Dictionary mapping Agroclimatic Zones (ACZ) to their acronyms +# Other ACZs are commented out but available for use ACZS = { "Eastern Plateau & Hills Region": "EPAHR", @@ -47,17 +69,19 @@ } -def generate_ltp_stp_local(year): - """ - Generate LTP/STP rasters for each year, ACZ, and district. +def generate_ltp_stp_local(start_year, end_year): """ + Main function to generate LTP/STP classification rasters. - lulc_file = f"data/base_layers/lulc/lulc_v3_{year}_{year+1}.tif" - print(f"\nYear: {year} -> LULC file: {lulc_file}") - if not os.path.exists(lulc_file): - print("ERROR: LULC file does not exist:", lulc_file) - return + Processes LULC data for a range of years across multiple ACZs and districts. + Creates both starting year and ending year datasets using modal LULC values + from a 3-year window (current year and +/- 1 year for robustness). + Args: + start_year: Beginning year for analysis period + end_year: End year for analysis period + """ + # Load district boundary geometries from GeoJSON district_boundaries = gpd.read_file( "data/base_layers/india_district_boundaries.geojson" ) @@ -67,65 +91,220 @@ def generate_ltp_stp_local(year): "rows from data/base_layers/india_district_boundaries.geojson", ) - for acz, acronym in ACZS.items(): + # Create 3-year windows for modal LULC calculation (current year ± 1 year) + # This provides temporal stability in the LULC classification + start_years = [start_year, start_year + 1, start_year + 2] + end_years = [end_year, end_year - 1, end_year - 2] - print(f"\nProcessing {acz}") + # Process both start and end year datasets + for lulc_years in [start_years, end_years]: + year = lulc_years[0] - district_csv = f"data/base_layers/tree_health/Agroclimatic_regions/{acz}.csv" - district_names = pd.read_csv(district_csv)["Name"].tolist() + # Iterate through each Agroclimatic Zone + for acz, acronym in ACZS.items(): - output_dir = os.path.join( - LOCAL_OUTPUT_BASE_DIR, - f"ltp_{year}", - acronym, - ) - os.makedirs(output_dir, exist_ok=True) + print(f"\nProcessing {acz}") + + # Load district names that belong to this ACZ + district_csv = ( + f"data/base_layers/tree_health/Agroclimatic_regions/{acz}.csv" + ) + district_names = pd.read_csv(district_csv)["Name"].tolist() + + # Create output directory for this ACZ and year + output_dir = os.path.join( + LOCAL_OUTPUT_BASE_DIR, + f"ltp_{year}", + acronym, + ) + os.makedirs(output_dir, exist_ok=True) + + # Open LULC source files for the 3-year window + lulc_sources = [] + + for y in lulc_years: + lulc_file = f"data/base_layers/lulc/lulc_v3_{y}_{y+1}.tif" + lulc_sources.append(rasterio.open(lulc_file)) + + # Generate LTP/STP rasters for each district in the ACZ + generate_district_tiff( + acronym, + district_boundaries, + district_names, + lulc_sources, + output_dir, + year, + ) + + # Merge individual district rasters into single ACZ mosaic + merge_district_tiffs(acz, output_dir, year, acronym) + + # Close all LULC source files + for src in lulc_sources: + src.close() + + +def get_lulc_mode(lulc_sources, district): + """ + Extract and process LULC data for a district. - generate_district_tiff( - acronym, district_boundaries, district_names, lulc_file, output_dir, year + Computes the modal (most common) LULC class from 3-year LULC stack, + resamples to 25m resolution, and extracts tree class pixels. + + Args: + lulc_sources: List of opened rasterio LULC source files (3 files) + district: GeoDataFrame containing the district geometry + + Returns: + tree: Binary array (1 = tree, 0 = non-tree) + transform: Affine transform of the output raster + profile: Rasterio profile of the source data + """ + lulc_arrays = [] + + profile = None + transform = None + nodata = None + crs = None + source_transform = None + + # Clip each LULC source to district boundaries and collect arrays + for src in lulc_sources: + roi = district + # Reproject district to match source CRS if needed + if roi.crs != src.crs: + roi = roi.to_crs(src.crs) + + # Clip LULC raster to district boundaries + clipped, transform = mask( + src, + roi.geometry, + crop=True, + filled=False, + indexes=1, ) - merge_district_tiffs(acz, output_dir, year, acronym) + # Extract metadata from first source + if profile is None: + profile = src.profile.copy() + nodata = src.nodata if src.nodata is not None else 255 + crs = src.crs + source_transform = src.transform + + # Fill masked pixels with nodata value + arr = clipped.filled(nodata) + lulc_arrays.append(arr) + + # Stack the three LULC arrays + stack = np.stack(lulc_arrays) + + # Compute modal LULC using the three-year stack + # Returns the most frequent class value; uses tie-breaking logic + a = stack[0] + b = stack[1] + c = stack[2] + + # Nested where conditions to find the mode: + # If a==b, use a; else if a==c, use a; else if b==c, use b; else use b as tie-breaker + modal = np.where( + a == b, + a, + np.where( + a == c, + a, + np.where( + b == c, + b, + b, # tie breaker - default to middle year + ), + ), + ).astype(np.uint8) + + # Resample modal LULC from 10m to 25m resolution + modal, transform = resample_to_25m( + modal, + transform, + source_transform, + crs, + nodata, + ) + + # Extract only tree class pixels as binary mask + tree = (modal == TREE_CLASS).astype(np.uint8) + return tree, transform, profile def resample_to_25m( clipped, transform, + source_transform, src_crs, nodata, ): """ - Resample clipped raster from its native resolution - to approximately 25 m using MODE resampling. - """ + Resample raster from 10m to 25m resolution using MODE resampling. + + Snaps the output to the original LULC grid to ensure alignment with + other datasets. This maintains consistency across multiple processing runs. - # Approximate 25 m in degrees - target_res = 25.0 / 111320.0 + Args: + clipped: Input raster array at 10m resolution + transform: Current Affine transform of clipped data + source_transform: Original Affine transform of LULC source + src_crs: Coordinate reference system + nodata: Nodata value + Returns: + dst: Resampled raster array at 25m resolution + dst_transform: Affine transform of output raster + """ + # Extract coordinates from current transform left = transform.c top = transform.f + # Calculate boundaries of the clipped area right = left + clipped.shape[1] * transform.a bottom = top + clipped.shape[0] * transform.e - dst_transform, dst_width, dst_height = calculate_default_transform( - src_crs, - src_crs, - clipped.shape[1], - clipped.shape[0], - left, - bottom, - right, - top, - resolution=target_res, + # Extract coordinates from original LULC grid + # (ensures alignment with source resolution) + src_left = source_transform.c + src_top = source_transform.f + + # Calculate resampling scale factor (2.5x for 10m -> 25m) + scale = TARGET_RESOLUTION_M / SOURCE_RESOLUTION_M + + # Calculate target pixel resolution + target_res_x = abs(source_transform.a) * scale + target_res_y = abs(source_transform.e) * scale + + # Snap to the original LULC grid - align new pixels to source grid + new_left = src_left + floor((left - src_left) / target_res_x) * target_res_x + new_top = src_top - floor((src_top - top) / target_res_y) * target_res_y + + # Calculate output raster dimensions + dst_width = int(np.ceil((right - new_left) / target_res_x)) + dst_height = int(np.ceil((new_top - bottom) / target_res_y)) + + # Create output Affine transform + dst_transform = Affine( + target_res_x, + 0, + new_left, + 0, + -target_res_y, + new_top, ) + # Initialize output array filled with nodata value dst = np.full( (dst_height, dst_width), nodata, dtype=clipped.dtype, ) + # Reproject and resample using MODE resampling + # MODE resampling preserves the most common value in each output pixel reproject( source=clipped, destination=dst, @@ -137,92 +316,81 @@ def resample_to_25m( dst_nodata=nodata, resampling=Resampling.mode, ) - return dst, dst_transform def generate_district_tiff( - acronym, district_boundaries, district_names, lulc_file, output_dir, year + acronym, district_boundaries, district_names, lulc_sources, output_dir, year ): - + """ + Generate LTP/STP classification rasters for each district. + + For each district, extracts tree pixels, polygonizes them, calculates + patch areas, and classifies based on size threshold into LTP (Large) + or STP (Small) tree patches. + + Args: + acronym: ACZ acronym for file naming + district_boundaries: GeoDataFrame of all district boundaries + district_names: List of district names to process + lulc_sources: List of opened LULC rasterio objects + output_dir: Directory to save output GeoTIFFs + year: Year for file naming + """ for district_name in district_names: - print(" Processing district:", district_name) + + # Extract the specific district from boundary data district = district_boundaries[district_boundaries["Name"] == district_name] if district.empty: print(" ERROR: District not found in boundary file.") continue - with rasterio.open(lulc_file) as src: - # print("Opened LULC raster. CRS:", src.crs) - # print("District CRS:", district.crs) - - if district.crs != src.crs: - district = district.to_crs(src.crs) - # print("Reprojected district to raster CRS.") - - clipped, transform = mask( - src, - district.geometry, - crop=True, - filled=False, - indexes=1, - ) - # print("Clipped raster shape:", clipped.shape) - # print("Masked pixels count:", np.count_nonzero(~clipped.mask)) - - profile = src.profile.copy() - - nodata = src.nodata - if nodata is None: - nodata = 255 - - # print("Original shape:", clipped.shape) - # print("Original resolution:", src.res) - - resampled, transform = resample_to_25m( - clipped.filled(nodata), - transform, - src.crs, - nodata, + # Get modal LULC tree mask for the district + tree, transform, profile = get_lulc_mode( + lulc_sources, + district, ) - tree = (resampled == TREE_CLASS).astype(np.uint8) - - # print("Resampled shape:", tree.shape) - # print("Tree pixels:", np.count_nonzero(tree)) - # print("Tree pixels count:", np.count_nonzero(tree == 1)) - + # Polygonize tree patches - convert raster to vector polygons + # shapes() returns (geometry, value) tuples for connected regions polygons = [] for geom, value in shapes( tree, - mask=tree == 1, + mask=tree == 1, # Only polygonize tree pixels transform=transform, - connectivity=8, + connectivity=8, # Use 8-connectivity (diagonal neighbors included) ): if value == 1: polygons.append(shape(geom)) - # print("Polygonized tree patches:", len(polygons)) if len(polygons) == 0: print("No tree patches found for this district.") continue + # Create GeoDataFrame from polygons gdf = gpd.GeoDataFrame( geometry=polygons, crs=profile["crs"], ) - projected = gdf.to_crs(gdf.estimate_utm_crs()) + # Convert to UTM for accurate area calculation + utm = gdf.estimate_utm_crs() + projected = gdf.to_crs(utm) + # Calculate patch area in hectares gdf["area_ha"] = projected.area / 10000.0 + # Classify patches as Large Tree Patch (1) or Small Tree Patch (0) + # based on area threshold gdf["large_tree_patch"] = (gdf["area_ha"] >= AREA_THRESHOLD_HA).astype(np.uint8) + # Convert back to original CRS gdf = gdf.to_crs(profile["crs"]) + # Rasterize the LTP/STP classification back to raster format ltp = rasterize( ( (geom, value) @@ -237,9 +405,12 @@ def generate_district_tiff( dtype=np.uint8, ) + # Create output raster: + # 255 = nodata, 1 = large tree patch, 0 = small tree patch/non-tree output = np.full(tree.shape, 255, dtype=np.uint8) output[tree == 1] = ltp[tree == 1] + # Update raster profile with output specifications profile.update( driver="GTiff", height=output.shape[0], @@ -251,11 +422,13 @@ def generate_district_tiff( compress="lzw", ) + # Generate output filename with district name outfile = os.path.join( output_dir, f"ltp_{year}_{acronym}_{re.sub('[^A-Za-z0-9]', '', district_name)}.tif", ) + # Write raster to GeoTIFF file with rasterio.open(outfile, "w", **profile) as dst: dst.write(output, 1) @@ -263,23 +436,41 @@ def generate_district_tiff( def merge_district_tiffs(acz, output_dir, year, acronym): + """ + Merge individual district LTP/STP rasters into a single ACZ mosaic. + + Combines all district GeoTIFFs for an ACZ and year into a single + seamless raster using the 'first' method (no priority/overlap handling). + + Args: + acz: Full name of Agroclimatic Zone (for logging) + output_dir: Directory containing district rasters + year: Year for file naming + acronym: ACZ acronym for output filename + """ print(f"\nMerging district rasters for {acz}...") + # Find all district raster files in the output directory district_tiffs = sorted(glob(os.path.join(output_dir, "*.tif"))) if len(district_tiffs) == 0: print("No district rasters found.") return + # Open all district rasters src_files = [rasterio.open(fp) for fp in district_tiffs] + # Merge rasters using the 'first' method + # (uses first valid pixel from overlapping areas) mosaic, out_transform = merge( src_files, method="first", ) + # Copy metadata from first source file out_meta = src_files[0].meta.copy() + # Update metadata for merged output out_meta.update( { "height": mosaic.shape[1], @@ -289,14 +480,17 @@ def merge_district_tiffs(acz, output_dir, year, acronym): } ) + # Generate output filename for ACZ-level mosaic acz_output = os.path.join( output_dir, f"ltp_{year}_{acronym}.tif", ) + # Write merged raster to GeoTIFF with rasterio.open(acz_output, "w", **out_meta) as dst: dst.write(mosaic) + # Close all source files for src in src_files: src.close() From 2ca6399e6698eb4a0844be8f592e79049d6581e3 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 23 Jul 2026 00:16:04 +0530 Subject: [PATCH 058/120] config update --- computing/config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/computing/config.yaml b/computing/config.yaml index 382f7323..0a8c5cf0 100644 --- a/computing/config.yaml +++ b/computing/config.yaml @@ -28,8 +28,8 @@ base_layers: type: file - name: restoration opportunity - local_path: "{DATA_DIR}/base_layers/restoration_opportunity/restoration_opportunity.geojson" - source: s3://corestack-datasets/base_layers/static_layers/restoration_opportunity/restoration_opportunity.geojson + local_path: "{DATA_DIR}/base_layers/restoration_opportunity/restoration_opportunity.tif" + source: s3://corestack-datasets/base_layers/static_layers/restoration_opportunity/restoration_opportunity.tif type: file - name: soge @@ -38,8 +38,8 @@ base_layers: type: file - name: lcw - local_path: "{DATA_DIR}/base_layers/lcw/lcw.tif" - source: s3://corestack-datasets/base_layers/static_layers/lcw/lcw.tif + local_path: "{DATA_DIR}/base_layers/lcw/lcw.geojson" + source: s3://corestack-datasets/base_layers/static_layers/lcw/lcw.geojson type: file - name: factory csr From 0e09e99d5cff3a29ef9c33cc82a00c01519f0f71 Mon Sep 17 00:00:00 2001 From: pawangramvaani Date: Wed, 22 Jul 2026 12:23:45 -0700 Subject: [PATCH 059/120] remove blank village id --- stats_generator/utils.py | 198 ++++++------ stats_generator/village_indicators.py | 420 ++++++++++++++++++-------- 2 files changed, 393 insertions(+), 225 deletions(-) diff --git a/stats_generator/utils.py b/stats_generator/utils.py index 9d482c94..049541f1 100644 --- a/stats_generator/utils.py +++ b/stats_generator/utils.py @@ -49,10 +49,10 @@ def get_vector_layer_geoserver(state, district, block, specific_sheets=None): # Use append mode with if_sheet_exists='replace' with pd.ExcelWriter( - xlsx_file, - engine="openpyxl", - mode=mode, - if_sheet_exists="replace" if mode == "a" else None, + xlsx_file, + engine="openpyxl", + mode=mode, + if_sheet_exists="replace" if mode == "a" else None, ) as writer: for layer in fetch_layers_for_excel_generation(): workspace = layer["workspace"] @@ -88,13 +88,13 @@ def get_vector_layer_geoserver(state, district, block, specific_sheets=None): if workspace == "terrain": create_excel_for_terrain(geojson_data, xlsx_file, writer) elif ( - workspace == "terrain_lulc" - and layer_name == f"{district}_{block}_lulc_slope" + workspace == "terrain_lulc" + and layer_name == f"{district}_{block}_lulc_slope" ): create_excel_for_terrain_lulc_slope(geojson_data, xlsx_file, writer) elif ( - workspace == "terrain_lulc" - and layer_name == f"{district}_{block}_lulc_plain" + workspace == "terrain_lulc" + and layer_name == f"{district}_{block}_lulc_plain" ): create_excel_for_terrain_lulc_plain(geojson_data, xlsx_file, writer) elif workspace == "swb": @@ -140,8 +140,8 @@ def get_vector_layer_geoserver(state, district, block, specific_sheets=None): geojson_data, xlsx_file, writer, start_year, end_year ) elif ( - workspace == "mws_layers" - and layer_name == f"deltaG_well_depth_{district}_{block}" + workspace == "mws_layers" + and layer_name == f"deltaG_well_depth_{district}_{block}" ): parsed_data_annual_mws = parse_geojson_annual_mws(geojson_data) create_excel_annual_mws(parsed_data_annual_mws, xlsx_file, writer) @@ -152,8 +152,8 @@ def get_vector_layer_geoserver(state, district, block, specific_sheets=None): except Exception as e: print("Exception", str(e)) elif ( - workspace == "mws_layers" - and layer_name == f"deltaG_fortnight_{district}_{block}" + workspace == "mws_layers" + and layer_name == f"deltaG_fortnight_{district}_{block}" ): processed_data = [ process_feature(feature) for feature in geojson_data["features"] @@ -178,34 +178,34 @@ def get_vector_layer_geoserver(state, district, block, specific_sheets=None): elif workspace == "tree_overall_vector": create_excel_for_overall_tree_change(geojson_data, xlsx_file, writer) elif ( - workspace == "change_detection" - and layer_name == f"change_vector_{district}_{block}_Afforestation" + workspace == "change_detection" + and layer_name == f"change_vector_{district}_{block}_Afforestation" ): create_excel_chan_detection_afforestation( geojson_data, xlsx_file, writer ) elif ( - workspace == "change_detection" - and layer_name == f"change_vector_{district}_{block}_CropIntensity" + workspace == "change_detection" + and layer_name == f"change_vector_{district}_{block}_CropIntensity" ): create_excel_chan_detection_cropintensity( geojson_data, xlsx_file, writer ) elif ( - workspace == "change_detection" - and layer_name == f"change_vector_{district}_{block}_Deforestation" + workspace == "change_detection" + and layer_name == f"change_vector_{district}_{block}_Deforestation" ): create_excel_chan_detection_deforestation( geojson_data, xlsx_file, writer ) elif ( - workspace == "change_detection" - and layer_name == f"change_vector_{district}_{block}_Degradation" + workspace == "change_detection" + and layer_name == f"change_vector_{district}_{block}_Degradation" ): create_excel_chan_detection_degradation(geojson_data, xlsx_file, writer) elif ( - workspace == "change_detection" - and layer_name == f"change_vector_{district}_{block}_Urbanization" + workspace == "change_detection" + and layer_name == f"change_vector_{district}_{block}_Urbanization" ): create_excel_chan_detection_urbanization( geojson_data, xlsx_file, writer @@ -263,18 +263,26 @@ def create_excel_for_livestock(data, writer): df = pd.DataFrame(df_data) # Columns to exclude - exclude_cols = ["cattle_female", "cattle_male", "buffalo_female", "buffalo_male", "sheep_female", "sheep_male", "goat_female", "goat_male", "pig_female", "pig_male"] + exclude_cols = [ + "cattle_female", + "cattle_male", + "buffalo_female", + "buffalo_male", + "sheep_female", + "sheep_male", + "goat_female", + "goat_male", + "pig_female", + "pig_male", + ] df = df.drop(columns=exclude_cols, errors="ignore") - # if "pc11_village_id" in df.columns: - # df = df[df["pc11_village_id"].notna() &(df["pc11_village_id"].astype(str).str.strip() != "") &(df["pc11_village_id"] != 0)] - # - # # Keep important columns first if they exist - # first_cols = [c for c in ["pc11_village_id", "NAME"] if c in df.columns] - # other_cols = [c for c in df.columns if c not in first_cols] - # df = df[first_cols + other_cols] - # Rename remaining columns - + if "village_id" in df.columns: + df = df[ + df["village_id"].notna() + & (df["village_id"].astype(str).str.strip() != "") + & (df["village_id"] != 0) + ] rename_cols = { "livestock_status": "data_availability_status", } @@ -292,6 +300,13 @@ def create_excel_for_antyodaya_20(data, writer): df_data = [feature.get("properties", {}) for feature in features] df = pd.DataFrame(df_data) + if "village_id" in df.columns: + df = df[ + df["village_id"].notna() + & (df["village_id"].astype(str).str.strip() != "") + & (df["village_id"] != 0) + ] + exclude_cols = [ "shg_pen_feat_value", "shg_fed_feat_value", @@ -356,13 +371,15 @@ def create_excel_for_antyodaya_20(data, writer): "agri_risk_support_feat_value", "soil_testing_adoption_feat_value", "market_access_feat_value", - "food_storage_feat_value" + "food_storage_feat_value", ] df = df.drop(columns=exclude_cols, errors="ignore") rename_cols = { "antyodaya_status": "data_availability_status", } df = df.rename(columns=rename_cols) + numeric_cols = df.select_dtypes(include=["number"]).columns + df[numeric_cols] = df[numeric_cols].round(2) df.to_excel(writer, sheet_name="antyodaya", index=False) print("Excel file created for antyodaya") @@ -590,6 +607,12 @@ def create_excel_for_facilities(data, writer): df_data = [feature["properties"] for feature in features] df = pd.DataFrame(df_data) + if "village_id" in df.columns: + df = df[ + df["village_id"].notna() + & (df["village_id"].astype(str).str.strip() != "") + & (df["village_id"] != 0) + ] exclude_cols = [ "l2_essential_education_selected_l3", "l2_essential_education_facility_uid", @@ -668,84 +691,59 @@ def create_excel_for_facilities(data, writer): ] df = df.drop(columns=exclude_cols, errors="ignore") - # if "censuscode2011" in df.columns: - # df = df[df["censuscode2011"].notna() &(df["censuscode2011"].astype(str).str.strip() != "") &(df["censuscode2011"] != 0)] - # - # first_cols = ["censuscode2011", "censusname"] - # other_cols = [c for c in df.columns if c not in first_cols] - # df = df[first_cols + other_cols] - # - # numeric_cols = df.select_dtypes(include=["int64", "float64"]).columns - # df[numeric_cols] = df[numeric_cols].round(2) - # - # exclude_cols = [ - # "censuscode2011", - # "censusname", - # "district", - # "core_admin_uid", - # "shrid2", - # "state", - # "tehsil", - # ] - # df.rename( - # columns={ - # col: f"{col}_in_km" for col in df.columns if col not in exclude_cols - # }, - # inplace=True, - # ) - - # Write to Excel rename_cols = { "facilities_status": "data_availability_status", - "l2_essential_education_distance_km": "essential_education_cat_distance_km", + "l2_essential_education_distance_km": "essential_education_cat_distance_in_km", "l2_essential_education_selected_l3_label": "essential_education_facility_label", - "l3_school_primary_distance_km": "school_primary_distance_km", - "l3_school_upper_primary_distance_km": "school_upper_primary_distance_km", - "l3_school_secondary_distance_km": "school_secondary_distance_km", - "l2_higher_education_distance_km": "higher_education_cat_distance_km", + "l3_school_primary_distance_km": "school_primary_distance_in_km", + "l3_school_upper_primary_distance_km": "school_upper_primary_distance_in_km", + "l3_school_secondary_distance_km": "school_secondary_distance_in_km", + "l2_higher_education_distance_km": "higher_education_cat_distance_in_km", "l2_higher_education_selected_l3_label": "higher_education_facility_label", - "l3_school_higher_secondary_distance_km": "school_higher_secondary_distance_km", - "l3_college_distance_km": "college_distance_km", - "l3_universities_distance_km": "universities_distance_km", - "l2_essential_health_distance_km": "essential_health_cat_distance_km", + "l3_school_higher_secondary_distance_km": "school_higher_secondary_distance_in_km", + "l3_college_distance_km": "college_distance_in_km", + "l3_universities_distance_km": "universities_distance_in_km", + "l2_essential_health_distance_km": "essential_health_cat_distance_in_km", "l2_essential_health_selected_l3_label": "essential_health_facility_label", - "l3_health_sub_cen_distance_km": "health_sub_cen_distance_km", - "l3_health_phc_distance_km": "health_phc_distance_km", - "l2_advanced_health_distance_km": "advanced_health_cat_distance_km", + "l3_health_sub_cen_distance_km": "health_sub_cen_distance_in_km", + "l3_health_phc_distance_km": "health_phc_distance_in_km", + "l2_advanced_health_distance_km": "advanced_health_cat_distance_in_km", "l2_advanced_health_selected_l3_label": "advanced_health_facility_label", - "l3_health_chc_distance_km": "health_chc_distance_km", - "l3_health_dis_h_distance_km": "health_dis_h_distance_km", - "l3_health_s_t_h_distance_km": "health_s_t_h_distance_km", - "l2_essential_services_distance_km": "essential_services_cat_distance_km", + "l3_health_chc_distance_km": "health_chc_distance_in_km", + "l3_health_dis_h_distance_km": "health_dis_h_distance_in_km", + "l3_health_s_t_h_distance_km": "health_s_t_h_distance_in_km", + "l2_essential_services_distance_km": "essential_services_cat_distance_in_km", "l2_essential_services_selected_l3_label": "essential_services_facility_label", - "l3_pds_distance_km": "pds_distance_km", - "l2_financial_inclusion_distance_km": "financial_inclusion_cat_distance_km", + "l3_pds_distance_km": "pds_distance_in_km", + "l2_financial_inclusion_distance_km": "financial_inclusion_cat_distance_in_km", "l2_financial_inclusion_selected_l3_label": "financial_inclusion_facility_label", - "l3_csc_distance_km": "csc_distance_km", - "l3_bank_mitra_distance_km": "bank_mitra_distance_km", - "l3_bank_branch_distance_km": "bank_branch_distance_km", - "l3_bank_atm_distance_km": "bank_atm_distance_km", - "l2_apmc_access_distance_km": "apmc_markets_cat_distance_km", + "l3_csc_distance_km": "csc_distance_in_km", + "l3_bank_mitra_distance_km": "bank_mitra_distance_in_km", + "l3_bank_branch_distance_km": "bank_branch_distance_in_km", + "l3_bank_atm_distance_km": "bank_atm_distance_in_km", + "l2_apmc_access_distance_km": "apmc_markets_cat_distance_in_km", "l2_apmc_access_selected_l3_label": "apmc_markets_facility_label", - "l3_apmc_distance_km": "apmc_markets_distance_km", - "l3_agri_industry_markets_trading_distance_km": "agri_industry_markets_trading_distance_km", - "l2_post_harvest_distance_km": "post_harvest_cat_distance_km", + "l3_apmc_distance_km": "apmc_markets_distance_in_km", + "l3_agri_industry_markets_trading_distance_km": "agri_industry_markets_trading_distance_in_km", + "l2_post_harvest_distance_km": "post_harvest_cat_distance_in_km", "l2_post_harvest_selected_l3_label": "post_harvest_facility_label", - "l3_agri_industry_storage_warehousing_distance_km": "agri_industry_storage_warehousing_distance_km", - "l3_agri_industry_distribution_utilities_distance_km": "agri_industry_distribution_utilities_distance_km", - "l3_agri_industry_agri_processing_distance_km": "agri_industry_agri_processing_distance_km", - "l3_agri_industry_industrial_manufacturing_distance_km": "agri_industry_industrial_manufacturing_distance_km", - "l2_cooperative_distance_km": "cooperative_cat_distance_km", + "l3_agri_industry_storage_warehousing_distance_km": "agri_industry_storage_warehousing_distance_in_km", + "l3_agri_industry_distribution_utilities_distance_km": "agri_industry_distribution_utilities_distance_in_km", + "l3_agri_industry_agri_processing_distance_km": "agri_industry_agri_processing_distance_in_km", + "l3_agri_industry_industrial_manufacturing_distance_km": "agri_industry_industrial_manufacturing_distance_in_km", + "l2_cooperative_distance_km": "cooperative_cat_distance_in_km", "l2_cooperative_selected_l3_label": "cooperative_facility_label", - "l3_agri_industry_co_operatives_societies_distance_km": "agri_industry_co_operatives_societies_distance_km", - "l2_livestock_distance_km": "livestock_cat_distance_km", + "l3_agri_industry_co_operatives_societies_distance_km": "agri_industry_co_operatives_societies_distance_in_km", + "l2_livestock_distance_km": "livestock_cat_distance_in_km", "l2_livestock_selected_l3_label": "livestock_facility_label", - "l3_agri_industry_dairy_animal_husbandry_distance_km": "agri_industry_dairy_animal_husbandry_distance_km", - "l2_agri_support_infra_distance_km": "agri_support_infra_cat_distance_km", + "l3_agri_industry_dairy_animal_husbandry_distance_km": "agri_industry_dairy_animal_husbandry_distance_in_km", + "l2_agri_support_infra_distance_km": "agri_support_infra_cat_distance_in_km", "l2_agri_support_infra_selected_l3_label": "agri_support_infra_facility_label", - "l3_agri_industry_agri_support_infrastructure_distance_km": "agri_industry_agri_support_infrastructure_distance_km", + "l3_agri_industry_agri_support_infrastructure_distance_km": "agri_industry_agri_support_infrastructure_distance_in_km", } df = df.rename(columns=rename_cols) + numeric_cols = df.select_dtypes(include=["int64", "float64"]).columns + df[numeric_cols] = df[numeric_cols].round(2) df.to_excel(writer, sheet_name="facilities_proximity", index=False) print("Excel file created for facilities_proximity") @@ -1725,7 +1723,7 @@ def calculate_area(base_area, percentage): def create_excel_for_nrega_assets( - nrega_data, mws_data, output_file, writer, start_year, end_year + nrega_data, mws_data, output_file, writer, start_year, end_year ): workCategoryMapping = { "SWC - Landscape level impact": "Soil and water conservation", @@ -1829,7 +1827,7 @@ def create_excel_for_nrega_assets( def create_excel_village_nrega_assets( - result_df, output_file, writer, all_villages_df, start_year, end_year + result_df, output_file, writer, all_villages_df, start_year, end_year ): workCategoryMapping = { "SWC - Landscape level impact": "Soil and water conservation", @@ -1874,7 +1872,7 @@ def create_excel_village_nrega_assets( continue mask = (final_df["vill_id"] == row["vill_ID"]) & ( - final_df["vill_name"] == row["vill_name"] + final_df["vill_name"] == row["vill_name"] ) col_name = f"{category}_count_{year}" final_df.loc[mask, col_name] += 1 @@ -1894,7 +1892,7 @@ def create_excel_village_nrega_assets( def fetch_village_asset_count( - state, district, block, writer, output_file, start_year, end_year + state, district, block, writer, output_file, start_year, end_year ): # 1. Read village data village_gdf = gpd.read_file(get_url("panchayat_boundaries", f"{district}_{block}"))[ diff --git a/stats_generator/village_indicators.py b/stats_generator/village_indicators.py index 18ecbea6..35b24cdc 100644 --- a/stats_generator/village_indicators.py +++ b/stats_generator/village_indicators.py @@ -42,75 +42,41 @@ def safe_val(v): if df_facilities.empty: return DEFAULT_VALUE.copy() - fac_row = df_facilities[df_facilities["censuscode2011"] == v_id] + fac_row = df_facilities[df_facilities["village_id"] == v_id] if fac_row.empty: return DEFAULT_VALUE.copy() row = fac_row.iloc[0] result = { - "essential_education_infra": get_max( - [ - row.get("school_primary_distance_in_km", -1), - row.get("school_upper_primary_distance_in_km", -1), - row.get("school_secondary_distance_in_km", -1), - ] + "essential_education_infra": row.get( + "essential_education_cat_distance_in_km", -1 ), - "higher_education_infra": get_min( - [ - row.get("school_higher_secondary_distance_in_km", -1), - row.get("college_distance_in_km", -1), - row.get("universities_distance_in_km", -1), - ] + "higher_education_infra": safe_val( + row.get("higher_education_cat_distance_in_km", -1) ), - "essential_health_services": get_max( - [ - row.get("health_sub_cen_distance_in_km", -1), - row.get("health_phc_distance_in_km", -1), - ] + "essential_health_services": safe_val( + row.get("essential_health_cat_distance_in_km", -1) ), - "advanced_health_services": get_min( - [ - row.get("health_chc_distance_in_km", -1), - row.get("health_dis_h_distance_in_km", -1), - row.get("health_s_t_h_distance_in_km", -1), - ] + "advanced_health_services": safe_val( + row.get("advanced_health_cat_distance_in_km", -1) ), - "public_distribution_system": get_max( - [ - row.get("pds_distance_in_km", -1), - ] + "public_distribution_system": safe_val( + row.get("essential_services_cat_distance_in_km", -1) ), - "financial_inclusion": get_max( - [ - row.get("csc_distance_in_km", -1), - row.get("bank_mitra_distance_in_km", -1), - row.get("bank_branch_distance_in_km", -1), - row.get("bank_atm_distance_in_km", -1), - ] - ), - "agri_market_access": get_min( - [ - row.get("apmc_distance_in_km", -1), - row.get("agri_industry_markets_trading_distance_in_km", -1), - ] - ), - "post_harvest_infra": get_min( - [ - row.get("agri_industry_storage_warehousing_distance_in_km", -1), - row.get("agri_industry_distribution_utilities_distance_in_km", -1), - row.get("agri_industry_agri_processing_distance_in_km", -1), - row.get("agri_industry_industrial_manufacturing_distance_in_km", -1), - ] + "financial_inclusion": safe_val( + row.get("financial_inclusion_cat_distance_in_km", -1) ), + "agri_market_access": safe_val(row.get("apmc_markets_cat_distance_in_km", -1)), + "post_harvest_infra": safe_val(row.get("post_harvest_cat_distance_in_km", -1)), "farmer_cooperatives_access": safe_val( - row.get("agri_industry_co_operatives_societies_distance_in_km", -1) + row.get("cooperative_cat_distance_in_km", -1) ), "livestock_management_centers": safe_val( - row.get("agri_industry_dairy_animal_husbandry_distance_in_km", -1) + row.get("livestock_cat_distance_in_km", -1) ), "agricultural_support_infrastructure": safe_val( - row.get("agri_industry_agri_support_infrastructure_distance_in_km", -1) + row.get("agri_support_infra_cat_distance_in_km", -1) ), } @@ -147,7 +113,7 @@ def extract_soc_eco(df_soc_eco_indi, v_id): def extract_livestock(df_livestock, v_id): - village_row = df_livestock[df_livestock["pc11_village_id"] == v_id] + village_row = df_livestock[df_livestock["village_id"] == v_id] livestock_cols = [ "cattle_total", "buffalo_total", @@ -158,100 +124,304 @@ def extract_livestock(df_livestock, v_id): return village_row[livestock_cols].fillna(0).sum(axis=1).iloc[0] -def extract_antyodaya(df_antyodaya, v_id): - """Extract social economic indicators for a given village ID.""" - data_map = { - "Low": 0, - "Medium": 1, - "High": 2, - } - - def get_cluster_from_score(value): - if pd.isna(value): - return None +# def extract_antyodaya(df_antyodaya, v_id): +# """Extract social economic indicators for a given village ID.""" +# data_map = { +# "Low": 0, +# "Medium": 1, +# "High": 2, +# } +# +# def get_cluster_from_score(value): +# if pd.isna(value): +# return None +# +# nearest = min([0, 0.5, 1], key=lambda x: abs(value - x)) +# +# return { +# 0: 0, # Low +# 0.5: 1, # Medium +# 1: 2, # High +# }[nearest] +# +# village_row = df_antyodaya[df_antyodaya["village_id"] == v_id] +# coverage_accross_pds_cols = [ +# "pds_util_feat_value", +# "nfsa_cov_feat_value", +# "bpl_cov_feat_value", +# "pension_cov_feat_value", +# ] +# coverage_across_PDS_NFSA_BPL_and_Pension = ( +# village_row[coverage_accross_pds_cols].fillna(0).mean(axis=1).iloc[0] +# ) +# +# coverage_across_PDS_NFSA_BPL_and_Pension = get_cluster_from_score( +# coverage_across_PDS_NFSA_BPL_and_Pension +# ) +# print("coverage cluster", coverage_across_PDS_NFSA_BPL_and_Pension) +# +# return { +# "road_connectivity": data_map.get( +# village_row["road_connectivity_cat_cluster"].iloc[0], -9999 +# ), +# "electricity_supply": data_map.get( +# village_row["electricity_supply_to_msme_feat_cluster"].iloc[0], -9999 +# ), +# "housing_quality": data_map.get( +# village_row["housing_quality_cat_cluster"].iloc[0], -9999 +# ), +# "maternal_and_child_health_service_access": data_map.get( +# village_row["maternal_child_health_cat_cluster"].iloc[0], -9999 +# ), +# "water_and_sanitation_infrastructure": data_map.get( +# village_row["water_sanitation_cat_cluster"].iloc[0], -9999 +# ), +# "access_to_formal_banking_services": data_map.get( +# village_row["bank_feat_cluster"].iloc[0], -9999 +# ), +# "coverage_across_PDS_NFSA_BPL_and_Pension": coverage_across_PDS_NFSA_BPL_and_Pension, +# "institutionalization_strength": data_map.get( +# village_row["institutionalization_cat_cluster"].iloc[0], -9999 +# ), +# "civic_infrastructure": data_map.get( +# village_row["civic_infrastructure_cat_cluster"].iloc[0], -9999 +# ), +# "farm_employment": data_map.get( +# village_row["farm_employment_feat_cluster"].iloc[0], -9999 +# ), +# "forest-based_livelihood": data_map.get( +# village_row["livelihoods_forest_resources_cat_cluster"].iloc[0], -9999 +# ), +# "alternate_farming": data_map.get( +# village_row["livelihoods_alternative_farming_cat_cluster"].iloc[0], -9999 +# ), +# "fisheries_adoption": data_map.get( +# village_row["livelihoods_fisheries_cat_cluster"].iloc[0], -9999 +# ), +# "cottage_industry": data_map.get( +# village_row["livelihoods_cottage_traditional_industry_cat_cluster"].iloc[0], +# -9999, +# ), +# "livestock_management_service_quality": data_map.get( +# village_row["electricity_supply_to_msme_feat_cluster"].iloc[0], -9999 +# ), +# "common_pasture_access": data_map.get( +# village_row["common_pastures_feat_cluster"].iloc[0], -9999 +# ), +# "watershed_infrastructure_and_modern_irrigation": data_map.get( +# village_row["irrigation_infra_watershed_dev_feat_cluster"].iloc[0], -9999 +# ), +# "organic_farming_adoption": data_map.get( +# village_row["agriculture_organic_farming_cat_cluster"].iloc[0], -9999 +# ), +# "pension_coverage_and_soil_testing_services_adoption": data_map.get( +# village_row["pension_cov_feat_cluster"].iloc[0], -9999 +# ), +# } - nearest = min([0, 0.5, 1], key=lambda x: abs(value - x)) - return { - 0: 0, # Low - 0.5: 1, # Medium - 1: 2, # High - }[nearest] - - village_row = df_antyodaya[df_antyodaya["village_id"] == v_id] - coverage_accross_pds_cols = [ - "pds_util_feat_value", - "nfsa_cov_feat_value", - "bpl_cov_feat_value", - "pension_cov_feat_value", - ] - coverage_across_PDS_NFSA_BPL_and_Pension = ( - village_row[coverage_accross_pds_cols].fillna(0).mean(axis=1).iloc[0] - ) - - coverage_across_PDS_NFSA_BPL_and_Pension = get_cluster_from_score( - coverage_across_PDS_NFSA_BPL_and_Pension - ) - print("coverage cluster", coverage_across_PDS_NFSA_BPL_and_Pension) - - return { - "road_connectivity": data_map.get( - village_row["road_connectivity_cat_cluster"].iloc[0], -9999 +def extract_antyodaya(df_antyodaya, v_id): + """Return finalized category and raw Antyodaya fields for one village. + + Category clusters, category values, and raw values are copied directly from + the Excel row. No feature-level values or derived calculations are used. + """ + category_raw_columns = { + "institutionalization": ( + "availability_of_fpos_pacs", + "total_hhd", + "total_hhd_mobilized_into_pg", + "total_hhd_mobilized_into_shg", + "total_no_of_shg_promoted", + "total_shg", + ), + "social_protection": ( + "gp_total_hhd_eligible_under_nfsa", + "gp_total_hhd_receiving_food_grains_from_fps", + "total_hhd", + "total_hhd_availing_pension_under_nsap", + "total_hhd_having_bpl_cards", ), - "electricity_supply": data_map.get( - village_row["electricity_supply_to_msme_feat_cluster"].iloc[0], -9999 + "civic_infrastructure": ( + "availability_of_panchayat_bhawan", + "availability_of_public_information_board", + "availability_of_public_library", + "is_post_office_available", + "total_no_of_elect_rep_oriented_under_rgsa", + "total_no_of_elect_rep_undergone_training_under_rgsa", + "total_no_of_elected_representatives", ), - "housing_quality": data_map.get( - village_row["housing_quality_cat_cluster"].iloc[0], -9999 + "financial_inclusion": ( + "is_atm_available", + "is_bank_available", + "is_bank_buss_correspondent_with_internet", + "total_hhd", + "total_hhd_availing_pmjdy_bank_ac", + "total_shg", + "total_shg_accessed_bank_loans", ), - "maternal_and_child_health_service_access": data_map.get( - village_row["maternal_child_health_cat_cluster"].iloc[0], -9999 + "energy_access": ( + "availability_of_elect_supply_to_msme", + "availablility_hours_of_domestic_electricity", + "total_hhd", + "total_hhd_with_clean_energy", ), - "water_and_sanitation_infrastructure": data_map.get( - village_row["water_sanitation_cat_cluster"].iloc[0], -9999 + "road_connectivity": ( + "availability_of_internal_pucca_road", + "availability_of_public_transport", + "availability_of_railway_station", + "is_village_connected_to_all_weather_road", ), - "access_to_formal_banking_services": data_map.get( - village_row["bank_feat_cluster"].iloc[0], -9999 + "housing_quality": ( + "total_hhd", + "total_hhd_availing_pmuy_benefits", + "total_hhd_got_benefit_under_state_housing_scheme", + "total_hhd_have_got_pmay_house", + "total_hhd_in_pmay_permanent_wait_list", + "total_hhd_with_kuccha_wall_kuccha_roof", ), - "coverage_across_PDS_NFSA_BPL_and_Pension": coverage_across_PDS_NFSA_BPL_and_Pension, - "institutionalization_strength": data_map.get( - village_row["institutionalization_cat_cluster"].iloc[0], -9999 + "maternal_child_health": ( + "availability_of_mother_child_health_facilities", + "gp_total_no_of_beneficiaries_receiving_benefits_under_pmjay", + "gp_total_no_of_eligible_beneficiaries_under_pmjay", + "is_aanganwadi_centre_available", + "is_early_childhood_edu_provided_in_anganwadi", + "total_anemic_pregnant_women", + "total_childs_aged_0_to_3_years", + "total_childs_aged_0_to_3_years_immunized", + "total_childs_aged_0_to_3_years_reg_under_aanganwadi", + "total_childs_aged_3_to_6_years_reg_under_aanganwadi", + "total_childs_categorized_non_stunted_as_per_icds", + "total_female_child_age_bw_0_6", + "total_hhd", + "total_hhd_registered_under_pmjay", + "total_male_child_age_bw_0_6", + "total_no_of_beneficiaries_receiving_benefits_under_pmmvy", + "total_no_of_children_in_icds_cas", + "total_no_of_eligible_beneficiaries_under_pmmvy", + "total_no_of_lactating_mothers", + "total_no_of_lactating_mothers_receiving_services_under_icds", + "total_no_of_newly_born_children", + "total_no_of_newly_born_underweight_children", + "total_no_of_pregnant_women", + "total_no_of_pregnant_women_receiving_services_under_icds", + "total_no_of_registered_children_in_anganwadi", + "total_no_of_women_delivered_babies_at_hospitals_registered_asha", + "total_no_of_young_anemic_children_6_59_months_in_icds_cas", + "total_underweight_child_age_under_6_years", ), - "civic_infrastructure": data_map.get( - village_row["civic_infrastructure_cat_cluster"].iloc[0], -9999 + "water_sanitation": ( + "availability_of_drainage_system", + "availability_of_piped_tap_water", + "is_community_biogas_waste_recycle_for_production", + "is_community_waste_disposal_system", + "total_hhd", + "total_hhd_having_piped_water_connection", + "total_hhd_not_having_sanitary_latrines", ), - "farm_employment": data_map.get( - village_row["farm_employment_feat_cluster"].iloc[0], -9999 + "livelihoods_cottage_traditional_industry": ( + "availability_of_cottage_small_scale_units", + "is_handicrafts", + "is_handloom", + "total_hhd", + "total_hhd_engaged_cottage_small_scale_units", ), - "forest-based_livelihood": data_map.get( - village_row["livelihoods_forest_resources_cat_cluster"].iloc[0], -9999 + "livelihoods_employment": ( + "total_hhd", + "total_hhd_engaged_in_farm_activities", ), - "alternate_farming": data_map.get( - village_row["livelihoods_alternative_farming_cat_cluster"].iloc[0], -9999 + "livelihoods_forest_resources": ( + "availability_of_community_forest", + "availability_of_minor_forest_production", + "total_hhd", + "total_hhd_source_of_minor_forest_production", ), - "fisheries_adoption": data_map.get( - village_row["livelihoods_fisheries_cat_cluster"].iloc[0], -9999 + "livelihoods_common_resources": ("is_common_pastures_available",), + "livelihoods_alternative_farming": ( + "is_bee_farming", + "is_sericulture", ), - "cottage_industry": data_map.get( - village_row["livelihoods_cottage_traditional_industry_cat_cluster"].iloc[0], -9999 + "livelihoods_fisheries": ( + "availability_of_aquaculture_ext_facility", + "availability_of_fish_community_ponds", + "availability_of_fish_farming", ), - "livestock_management_service_quality": data_map.get( - village_row["electricity_supply_to_msme_feat_cluster"].iloc[0], -9999 + "livestock_veterinary": ( + "availability_of_goatary_dev_project", + "availability_of_livestock_extension_services", + "availability_of_milk_routes", + "availability_of_pigery_development", + "availability_of_poultry_dev_project", + "is_veterinary_hospital_available", ), - "common_pasture_access": data_map.get( - village_row["common_pastures_feat_cluster"].iloc[0], -9999 + "agriculture_land_cultivation": ( + "area_irrigated_in_hac", + "net_sown_area_in_hac", + "net_sown_area_kharif_in_hac", + "net_sown_area_other_in_hac", + "net_sown_area_rabi_in_hac", + "total_cultivable_area_in_hac", ), - "watershed_infrastructure_and_modern_irrigation": data_map.get( - village_row["irrigation_infra_watershed_dev_feat_cluster"].iloc[0], -9999 + "agriculture_irrigation_watershed": ( + "availability_of_major_source_of_irrigation", + "availability_of_rain_harvest_system", + "availability_of_watershed_dev_project", + "no_of_farmers_using_drip_sprinkler", + "total_approved_labour_budget_for_year", + "total_expenditure_approved_under_nrm_labour_budget_during_yr", + "total_no_of_farmers", ), - "organic_farming_adoption": data_map.get( - village_row["agriculture_organic_farming_cat_cluster"].iloc[0], -9999 + "agriculture_organic_farming": ( + "total_no_farmers_adopted_organic_farming", + "total_no_of_farmers", ), - "pension_coverage_and_soil_testing_services_adoption": data_map.get( - village_row["pension_cov_feat_cluster"].iloc[0], -9999 + "agriculture_support_services": ( + "is_fertilizer_shop_available", + "is_govt_seed_centre_available", + "is_soil_testing_centre_available", + "total_no_of_farmers", + "total_no_of_farmers_add_fert_in_soil_as_per_report", + "total_no_of_farmers_received_benefit_under_pmfby", + "total_no_of_farmers_registered_under_pmkpy", + ), + "agricultural_markets": ( + "availability_of_food_storage_warehouse", + "availability_of_market", ), } + if df_antyodaya.empty or "village_id" not in df_antyodaya.columns: + return {} + + village_rows = df_antyodaya[df_antyodaya["village_id"] == v_id] + if village_rows.empty: + return {} + + required_columns = [] + for category, raw_columns in category_raw_columns.items(): + required_columns.extend( + (f"{category}_cat_cluster", f"{category}_cat_value", *raw_columns) + ) + # Raw inputs shared by categories remain a single field in the flat KYL row. + required_columns = list(dict.fromkeys(required_columns)) + + missing_columns = [ + column for column in required_columns if column not in df_antyodaya.columns + ] + if missing_columns: + raise ValueError( + "Antyodaya sheet is missing required columns: " + ", ".join(missing_columns) + ) + + row = village_rows.iloc[0] + + def excel_value(value): + if pd.isna(value): + return None + return value.item() if hasattr(value, "item") else value + + return {column: excel_value(row[column]) for column in required_columns} + def get_generate_filter_data_village(state, district, block, regenerate=0): From 51c207bc67b72f07069e7c6075cf75dead541f9f Mon Sep 17 00:00:00 2001 From: aman verma Date: Thu, 23 Jul 2026 10:23:17 +0000 Subject: [PATCH 060/120] fixed baseline years --- computing/api.py | 58 +++- .../drought_resistance_resilience.py | 214 ++++++++++----- .../export_fire_index.py | 166 +++++++++++ .../forest_fire_resistance_resilience.py | 253 +++++++++++++++++ .../export_max_wind_index.py | 146 ++++++++++ .../highwind_resistance_resilience.py | 259 ++++++++++++++++++ computing/spei/hybrid_tree_mask.py | 13 +- .../export_rainfall_index.py | 180 +++++++----- .../rainfall_resistance_resilience.py | 231 +++++++++++----- computing/spei/spei.py | 80 +++++- computing/urls.py | 10 + 11 files changed, 1374 insertions(+), 236 deletions(-) create mode 100644 computing/spei/forestfire_sensitivity/export_fire_index.py create mode 100644 computing/spei/forestfire_sensitivity/forest_fire_resistance_resilience.py create mode 100644 computing/spei/high_wind_sensitivity/export_max_wind_index.py create mode 100644 computing/spei/high_wind_sensitivity/highwind_resistance_resilience.py diff --git a/computing/api.py b/computing/api.py index 97087bff..71548082 100644 --- a/computing/api.py +++ b/computing/api.py @@ -57,6 +57,8 @@ generate_spei_pipeline, run_drought_resistance_resilience, run_rainfall_resistance_resilience, + run_forest_fire_resistance_resilience, + run_high_wind_resistance_resilience, ) from .drought.drought import calculate_drought from .drought.drought_causality import drought_causality @@ -2424,9 +2426,7 @@ def generate_canal_vector(request): {"Success": "Successfully initiated"}, status=status.HTTP_200_OK ) except Exception as e: - print( - f"Exception in generate canal vector layer for {district} - {block}:: ", e - ) + print(f"Exception in generate canal vector layer: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -2444,9 +2444,7 @@ def drought_resilience_resistance(request): args=[aez, start_year, end_year, gee_account_id], queue="nrm" ) return Response( - { - "Success": "Successfully drought_resilience_resistance generate_spei task" - }, + {"Success": "Successfully drought_resilience_resistance task"}, status=status.HTTP_200_OK, ) except Exception as e: @@ -2468,9 +2466,7 @@ def rainfall_resilience_resistance(request): args=[aez, start_year, end_year, gee_account_id], queue="nrm" ) return Response( - { - "Success": "Successfully rainfall_resilience_resistance generate_spei task" - }, + {"Success": "Successfully rainfall_resilience_resistance task"}, status=status.HTTP_200_OK, ) except Exception as e: @@ -2478,6 +2474,50 @@ def rainfall_resilience_resistance(request): return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) +@api_view(["POST"]) +@schema(None) +def forest_fire_resilience_resistance(request): + print("Inside forest_fire_resilience_resistance API.") + try: + aez = request.data.get("aez") + start_year = request.data.get("start_year") + end_year = request.data.get("end_year") + gee_account_id = request.data.get("gee_account_id") + + run_forest_fire_resistance_resilience.apply_async( + args=[aez, start_year, end_year, gee_account_id], queue="nrm" + ) + return Response( + {"Success": "Successfully forest_fire_resilience_resistance task"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + print("Exception in forest_fire_resilience_resistance api :: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@schema(None) +def high_wind_resilience_resistance(request): + print("Inside run_high_wind_resistance_resilience API.") + try: + aez = request.data.get("aez") + start_year = request.data.get("start_year") + end_year = request.data.get("end_year") + gee_account_id = request.data.get("gee_account_id") + + run_high_wind_resistance_resilience.apply_async( + args=[aez, start_year, end_year, gee_account_id], queue="nrm" + ) + return Response( + {"Success": "Successfully run_high_wind_resistance_resilience task"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + print("Exception in run_high_wind_resistance_resilience api :: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @api_view(["POST"]) @schema(None) def generate_fabdem_raster_vector(request): diff --git a/computing/spei/drought_sensitivity/drought_resistance_resilience.py b/computing/spei/drought_sensitivity/drought_resistance_resilience.py index fef4ca95..2e990a7e 100644 --- a/computing/spei/drought_sensitivity/drought_resistance_resilience.py +++ b/computing/spei/drought_sensitivity/drought_resistance_resilience.py @@ -1,39 +1,39 @@ import ee -from utilities.constants import AEZ from utilities.gee_utils import ( - export_raster_asset_to_gee, ee_initialize, is_gee_asset_exists, + export_raster_asset_to_gee, ) def generate_drought_resistance( - aez, start_year=2004, end_year=2022, gee_account_id=None + aez, start_year=2004, end_year=None, gee_account_id=None ): """ - Forest Sensitivity Analysis Pipeline — Script 2 - Drought Resistance & Resilience - - For each forest pixel, computes mean resistance and resilience - across all drought years (SPEI-12 < threshold). - - Resistance = Yn_bar / |Ye - Yn_bar| - Resilience = |Ye - Yn_bar| / |Y(e+1) - Yn_bar| - - Where: - Yn_bar = mean NDVI across non-drought years (baseline) - Ye = NDVI during drought year - Y(e+1) = NDVI the year after drought - - Requires: - - Forest mask asset from Script 1 - - SPEI-12 assets from spei-drought-analysis-pipeline + * Forest Sensitivity Analysis Pipeline — Script 2 + * Drought Resistance & Resilience (Harmonized kNDVI + Signed Formulas) + * + * For each forest pixel, computes mean resistance and resilience + * across all drought years (SPEI-12 < threshold). + * + * Harmonization: Transforms Landsat 8/9 (OLI) to Landsat 5/7 (ETM+) + * equivalent before computing kNDVI to eliminate sensor-shift bias. + * + * Signed resistance (both +ve and -ve events): + * Resistance = Yn_bar / |Ye - Yn_bar| × sign(Ye - Yn_bar) + * + * Resilience computed ONLY when Ye < Yn_bar (negative effect years): + * Resilience = |Ye - Yn_bar| / |Ye+1 - Yn_bar| × sign(Ye+1 - Yn_bar) + * + * Requires: + * - Forest mask asset from Script 1 + * - SPEI-12 assets from spei-drought-analysis-pipeline """ ee_initialize(gee_account_id) - TREE_COVER_ASSET = f"projects/corestack-datasets-alpha/assets/datasets/SPEI/Hybrid_Tree_AEZ_{aez}_Period_{str(2003)}_{str(end_year)}" + TREE_COVER_ASSET = f"projects/corestack-datasets-alpha/assets/datasets/SPEI/Hybrid_Tree_AEZ_{aez}_{str(2003)}_{str(end_year)}" OUTPUT_DESC = f"Drought_Metrics_AEZ_{aez}" OUTPUT_ASSET_ID = ( f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" @@ -44,43 +44,94 @@ def generate_drought_resistance( DROUGHT_THRESHOLD = -1.0 # SPEI-12 below this = drought year - aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() - + # Fixed baseline window. This is independent of analysis START_YEAR/END_YEAR. + # Please don't EVER change this once results are published, or old outputs will change when the pipeline timeline is extended. + # This helps in fixing the Yn_bar (the average of the non-drought year NDVI) to a constant value. + BASELINE_START_YEAR = 2004 # SPEI has no data before 2004 + BASELINE_END_YEAR = 2024 + + # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO + aoi = ( + ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + .filter(ee.Filter.eq("Name", "Odisha")) + .geometry() + ) # Loading the assets := treeMeta = ee.Image(TREE_COVER_ASSET) + startYear = treeMeta.select("start_year") endYear = treeMeta.select("end_year") # Load SPEI-12 collection from single multiband asset - SPEI12_ASSET = ( - f"projects/corestack-datasets-alpha/assets/datasets/SPEI/SPEI12_{str(aez)}" - ) + SPEI12_ASSET = f"projects/corestack-datasets-alpha/assets/datasets/SPEI/SPEI12" spei12_raw = ee.Image(SPEI12_ASSET) spei12_bandnames = [] - for yn in range(2004, 2024): + for yn in range(2004, end_year + 1): spei12_bandnames.append("y" + str(yn)) - spei12_named = spei12_raw.rename(spei12_bandnames) - # Build per-year SPEI collection - speiImages = [] + # Building the per-year SPEI collection here. + speiMinYear = min(start_year, BASELINE_START_YEAR) + speiMaxYear = max(end_year, BASELINE_END_YEAR) - for y in range(start_year, end_year + 1): + speiImages = [] + for y in range(speiMinYear, speiMaxYear + 1): speiImages.append( spei12_named.select("y" + str(y)).rename("spei").set("year", y) ) - speiCol = ee.ImageCollection(speiImages) - # LANDSAT NDVI := - def maskClouds(image): + # LANDSAT HARMONIZATION & kNDVI := + + # Chastain et al. coefficients (OLI to ETM+) + chastainBandNames = ["BLUE", "GREEN", "RED", "NIR", "SWIR1", "SWIR2"] + oliETMSlopes = ee.Image.constant( + [1.03501, 1.00921, 1.01991, 1.14061, 1.04351, 1.05271] + ) + oliETMIntercepts = ee.Image.constant( + [-0.0055, -0.0008, -0.0021, -0.0163, -0.0045, 0.00261] + ) + + # Pre-process Landsat 5/7 (Baseline) + def prepL57(image): qa = image.select("QA_PIXEL") mask = qa.bitwiseAnd(1 << 3).eq(0).And(qa.bitwiseAnd(1 << 4).eq(0)) - return image.updateMask(mask) - def get_annual_ndvi(year): + # Apply mask, select optical bands, and apply Collection 2 scale factors + scaled = ( + image.updateMask(mask) + .select(["SR_B1", "SR_B2", "SR_B3", "SR_B4", "SR_B5", "SR_B7"]) + .multiply(0.0000275) + .add(-0.2) + ) + + return scaled.rename(chastainBandNames).copyProperties( + image, ["system:time_start"] + ) + + # Pre-process Landsat 8/9 and Harmonize to ETM+ + def prepL89(image): + qa = image.select("QA_PIXEL") + mask = qa.bitwiseAnd(1 << 3).eq(0).And(qa.bitwiseAnd(1 << 4).eq(0)) + + # Apply mask, select optical bands, and apply Collection 2 scale factors + scaled = ( + image.updateMask(mask) + .select(["SR_B2", "SR_B3", "SR_B4", "SR_B5", "SR_B6", "SR_B7"]) + .multiply(0.0000275) + .add(-0.2) + .rename(chastainBandNames) + ) + + # Apply Chastain regression model (OLI -> ETM+) + harmonized = scaled.multiply(oliETMSlopes).add(oliETMIntercepts) + + return harmonized.copyProperties(image, ["system:time_start"]) + + # Calculate annual median kNDVI + def getAnnualKNDVI(year): start = ee.Date.fromYMD(year, 1, 1) end = ee.Date.fromYMD(year, 12, 31) @@ -89,9 +140,12 @@ def get_annual_ndvi(year): .merge(ee.ImageCollection("LANDSAT/LC09/C02/T1_L2")) .filterDate(start, end) .filterBounds(aoi) - .map(maskClouds) + .map(prepL89) .map( - lambda img: img.normalizedDifference(["SR_B5", "SR_B4"]).rename("ndvi") + lambda img: img.normalizedDifference(["NIR", "RED"]) + .pow(2) + .tanh() + .rename("kndvi") ) ) @@ -100,69 +154,97 @@ def get_annual_ndvi(year): .merge(ee.ImageCollection("LANDSAT/LE07/C02/T1_L2")) .filterDate(start, end) .filterBounds(aoi) - .map(maskClouds) + .map(prepL57) .map( - lambda img: img.normalizedDifference(["SR_B4", "SR_B3"]).rename("ndvi") + lambda img: img.normalizedDifference(["NIR", "RED"]) + .pow(2) + .tanh() + .rename("kndvi") ) ) - return l89.merge(l57).median().set("year", year).rename("ndvi") + return l89.merge(l57).median().set("year", year).rename("kndvi") + + # Load kNDVI for START_YEAR to END_YEAR+1 (need next year for resilience) + # kndviYears = ee.List.sequence(start_year, end_year + 1) + kndviMinYear = min(start_year, BASELINE_START_YEAR) + kndviMaxYear = max(end_year + 1, BASELINE_END_YEAR) - # Load NDVI for START_YEAR to END_YEAR+1 (need next year for resilience) - ndviYears = ee.List.sequence(start_year, end_year + 1) - ndviCol = ee.ImageCollection(ndviYears.map(get_annual_ndvi)) + kndviYears = ee.List.sequence(kndviMinYear, kndviMaxYear) + kndviCol = ee.ImageCollection(kndviYears.map(getAnnualKNDVI)) - # BASELINE NDVI (Yn_bar) := - # Mean NDVI across non-drought years only - # Uses simple masked ImageCollection mean — we are trying to avoid GEE array scalign issues here + # BASELINE kNDVI (Yn_bar) := + # Mean kNDVI across non-drought years only + # The analysis years will still remain the same , so if some internal year width is given like 2010-2018 for example + # then too the baseline yn_bar would be same of the bigger normalization. But the analysis will be resulting only of the analysis years. analysisYears = ee.List.sequence(start_year, end_year) - def ndvi_non_drought_func(y): + baselineYears = ee.List.sequence(BASELINE_START_YEAR, BASELINE_END_YEAR) + + def calc_kndviNonDrought(y): year = ee.Number(y) - ndvi = ndviCol.filter(ee.Filter.eq("year", year)).first() + kndvi = kndviCol.filter(ee.Filter.eq("year", year)).first() spei = ( speiCol.filter(ee.Filter.eq("year", year)) .first() .resample("bilinear") - .reproject(crs=ndvi.projection(), scale=30) + .reproject(crs=kndvi.projection(), scale=30) ) - isNonDrought = spei.gte(DROUGHT_THRESHOLD) - return ndvi.updateMask(isNonDrought).set("year", year) + return kndvi.updateMask(isNonDrought).set("year", year) - ndviNonDrought = ee.ImageCollection(analysisYears.map(ndvi_non_drought_func)) + kndviNonDrought = ee.ImageCollection(baselineYears.map(calc_kndviNonDrought)) - Yn_bar = ndviNonDrought.mean().rename("ndvi_baseline") + Yn_bar = kndviNonDrought.mean().rename("kndvi_baseline") - # RESISTANCE & RESILIENCE := + # SIGNED RESISTANCE & RESILIENCE := - def metrics_col_func(y): + def calc_metrics_col(y): year = ee.Number(y) - ndviYe = ndviCol.filter(ee.Filter.eq("year", year)).first() + kndviYe = kndviCol.filter(ee.Filter.eq("year", year)).first() speiYe = ( speiCol.filter(ee.Filter.eq("year", year)) .first() .resample("bilinear") - .reproject(crs=ndviYe.projection(), scale=30) + .reproject(crs=kndviYe.projection(), scale=30) ) # Only compute on forest pixels during drought years isForest = startYear.lte(year).And(endYear.gte(year)) isDrought = speiYe.lt(DROUGHT_THRESHOLD) - mask = isForest.And(isDrought) + eventMask = isForest.And(isDrought) - diff = ndviYe.subtract(Yn_bar).abs().max(1e-6) - resistance = Yn_bar.divide(diff).rename("resistance") + diffRaw = kndviYe.subtract(Yn_bar) + diffAbs = diffRaw.abs().max(1e-6) - ndviNext = ndviCol.filter(ee.Filter.eq("year", year.add(1))).first() - diffNext = ndviNext.subtract(Yn_bar).abs().max(1e-6) - resilience = diff.divide(diffNext).rename("resilience") + # Resistance: signed, computed for ALL drought years + resistance = ( + Yn_bar.divide(diffAbs) + .multiply(diffRaw.signum()) + .rename("resistance") + .updateMask(eventMask) + ) + + # Resilience: ONLY computed when Ye < Yn_bar (negative effect years) + isNegativeEffect = kndviYe.lt(Yn_bar) + resilMask = eventMask.And(isNegativeEffect) + + kndviNext = kndviCol.filter(ee.Filter.eq("year", year.add(1))).first() + diffNext = kndviNext.subtract(Yn_bar) + diffNextAbs = diffNext.abs().max(1e-6) + + resilience = ( + diffAbs.divide(diffNextAbs) + .multiply(diffNext.signum()) + .rename("resilience") + .updateMask(resilMask) + ) - return ee.Image.cat([resistance, resilience]).updateMask(mask).set("year", year) + return ee.Image.cat([resistance, resilience]).set("year", year) - metricsCol = ee.ImageCollection(analysisYears.map(metrics_col_func)) + metricsCol = ee.ImageCollection(analysisYears.map(calc_metrics_col)) # AGGREGATE & EXPORT := diff --git a/computing/spei/forestfire_sensitivity/export_fire_index.py b/computing/spei/forestfire_sensitivity/export_fire_index.py new file mode 100644 index 00000000..9a4008b9 --- /dev/null +++ b/computing/spei/forestfire_sensitivity/export_fire_index.py @@ -0,0 +1,166 @@ +import ee + +from utilities.constants import AEZ +from utilities.gee_utils import ( + export_raster_asset_to_gee, + ee_initialize, + is_gee_asset_exists, +) + + +def fire_index(aez, start_year=2004, end_year=2022, gee_account_id=None): + """ + * Forest Sensitivity Analysis Pipeline — Fire Script + * Fire Radiative Power (FRP) Index Export (Threshold > 30) + * + * Computes 5 quantities per pixel per year and exports as a single + * multiband asset — one band per year for each quantity: + * + * FRP_{year} = annual sum of FRP on fire days + * zScore_{year} = z-score of FRP relative to the 2004-2022 period + * fireDays_{year} = number of days in the year with FRP > 30 + * fireAvg_{year} = average daily FRP on fire days + * maxFRP_{year} = maximum daily FRP recorded in that year + * + * Fire day definition: daily MaxFRP > 30 (MODIS Terra Thermal Anomalies) + * + * Requires: nothing — only public datasets (MODIS MOD14A1) + */ + """ + ee_initialize(gee_account_id) + OUTPUT_DESC = f"fire_index_FRP30_AEZ_{aez}" + OUTPUT_ASSET_ID = ( + f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" + ) + + if is_gee_asset_exists(OUTPUT_ASSET_ID): + return None + + # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + aoi = ( + ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + .filter(ee.Filter.eq("Name", "Odisha")) + .geometry() + ) + FRP_THRESHOLD = 30 + """ + Fixed baseline window for zScore normalization — independent of START_YEAR/END_YEAR. + This is matching the same 2004-2024 window locked in for rain and drought, so all z-score-based indices + are normalized against the same reference period. + Do NOT change this once results are published, or old zScore bands will drift when we extend the analysis timeline later. + """ + BASELINE_START_YEAR = 2004 + BASELINE_END_YEAR = 2024 + + # Terra Thermal Anomalies & Fire Daily 1km + modisFire = ( + ee.ImageCollection("MODIS/061/MOD14A1").filterBounds(aoi).select("MaxFRP") + ) + + proj = modisFire.first().projection() + + # 1. ANNUAL METRICS CALCULATION + # We need annual metrics computed for every year covering BOTH the analysis window and the baseline window, + # whichever stretches further. + metricsMinYear = min(start_year, BASELINE_START_YEAR) + metricsMaxYear = max(end_year, BASELINE_END_YEAR) + metricsYears = ee.List.sequence(metricsMinYear, metricsMaxYear) + + def annual_collection(y): + start = ee.Date.fromYMD(y, 1, 1) + end = ee.Date.fromYMD(y, 12, 31) + + yearCollection = modisFire.filterDate(start, end) + + # Absolute maximum daily FRP for this year + maxFRP = ( + yearCollection.max().unmask(0).setDefaultProjection(proj).rename("maxFRP") + ) + + # Binary mask: isolate days where FRP > 30 + fireDaysCollection = yearCollection.map( + lambda img: img.updateMask(img.gt(FRP_THRESHOLD)) + ) + + # 1. Annual sum of FRP on fire days + sumFRP = ( + fireDaysCollection.map(lambda img: img.unmask(0)) + .sum() + .setDefaultProjection(proj) + .rename("FRP") + ) + + # 2. Number of fire days + fireDays = ( + fireDaysCollection.map(lambda img: img.mask()) + .sum() + .unmask(0) + .setDefaultProjection(proj) + .rename("fireDays") + ) + + # 3. Average FRP of fire days + fireAvg = ( + fireDaysCollection.mean() + .unmask(0) + .setDefaultProjection(proj) + .rename("fireAvg") + ) + + # Combine metrics into a single image per year containing all 4 base properties + return ( + sumFRP.addBands(fireDays).addBands(fireAvg).addBands(maxFRP).set("year", y) + ) + + annualMetrics = ee.ImageCollection(metricsYears.map(annual_collection)) + + # 2. FIXED Z-SCORE CALCULATION + # Baseline stats only from the frozen baseline years, not from whatever analysis window I happen to be running right now. + baselineYears = ee.List.sequence(BASELINE_START_YEAR, BASELINE_END_YEAR) + + baselineMetrics = annualMetrics.filter(ee.Filter.inList("year", baselineYears)) + + frpMean = baselineMetrics.select("FRP").mean() + frpStdDev = baselineMetrics.select("FRP").reduce(ee.Reducer.stdDev()) + + # Server-side map architecture over the annual image collection + def completed_annual_collection_func(img): + frp = img.select("FRP") + z = frp.subtract(frpMean).divide(frpStdDev).rename("zScore") + return img.addBands(z) + + completedAnnualCollection = annualMetrics.map(completed_annual_collection_func) + + # 3. SERVER-SIDE STACK INTO SINGLE MULTIBAND IMAGE & EXPORT + analysisYears = ee.List.sequence(start_year, end_year) + + def add_bands_for_year(y, acc): + yearStr = ee.String(ee.Number(y).toInt()) + yearImg = completedAnnualCollection.filter(ee.Filter.eq("year", y)).first() + + frpBand = yearImg.select("FRP").rename(ee.String("FRP_").cat(yearStr)) + zBand = yearImg.select("zScore").rename(ee.String("zScore_").cat(yearStr)) + fireDaysBand = yearImg.select("fireDays").rename( + ee.String("fireDays_").cat(yearStr) + ) + fireAvgBand = yearImg.select("fireAvg").rename( + ee.String("fireAvg_").cat(yearStr) + ) + maxFRPBand = yearImg.select("maxFRP").rename(ee.String("maxFRP_").cat(yearStr)) + + return ee.Image(acc).addBands( + [frpBand, zBand, fireDaysBand, fireAvgBand, maxFRPBand] + ) + + empty_image = ee.Image().mask(ee.Image(0)) + output_image = ee.Image(analysisYears.iterate(add_bands_for_year, empty_image)) + + # Export execution block + task_id = export_raster_asset_to_gee( + output_image.clip(aoi), OUTPUT_DESC, OUTPUT_ASSET_ID, scale=1000, region=aoi + ) + + print("✅ Clean pipeline compilation verified.") + print("Ready to execute in the tasks tab. Total structured bands: 95.") + + return task_id diff --git a/computing/spei/forestfire_sensitivity/forest_fire_resistance_resilience.py b/computing/spei/forestfire_sensitivity/forest_fire_resistance_resilience.py new file mode 100644 index 00000000..e604ee2d --- /dev/null +++ b/computing/spei/forestfire_sensitivity/forest_fire_resistance_resilience.py @@ -0,0 +1,253 @@ +import ee + +from utilities.constants import AEZ +from utilities.gee_utils import export_raster_asset_to_gee, is_gee_asset_exists + +""" + * Forest Sensitivity Analysis Pipeline — Fire Resistance & Resilience + * Fire Shock Resistance & Resilience (Harmonized kNDVI + Signed Formulas) + * + * Signed resistance (both +ve and -ve events): + * Resistance = Yn_bar / |Ye - Yn_bar| × sign(Ye - Yn_bar) + * + * Resilience computed ONLY when Ye < Yn_bar (negative effect years): + * Resilience = |Ye - Yn_bar| / |Ye+1 - Yn_bar| × sign(Ye+1 - Yn_bar) + * + * Harmonization: Transforms Landsat 8/9 (OLI) to Landsat 5/7 (ETM+) + * equivalent before computing kNDVI to eliminate sensor-shift bias. + * + * Requires: + * - Forest mask asset (Script 1) + * - Fire index asset (FRP > 30) +""" + + +def forest_fire_sensitivity(aez, start_year=2004, end_year=2022, gee_account_id=None): + TREE_COVER_ASSET = f"projects/corestack-datasets-alpha/assets/datasets/SPEI/Hybrid_Tree_AEZ_{aez}_{str(2003)}_{str(end_year)}" + + FIRE_INDEX_ASSET = f"projects/corestack-datasets-alpha/assets/datasets/SPEI/fire_index_FRP30_AEZ_{aez}" + + OUTPUT_DESC = f"fire_metrics_harmonized_kNDVI_AEZ_{aez}" + OUTPUT_ASSET_ID = ( + f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" + ) + + if is_gee_asset_exists(OUTPUT_ASSET_ID): + return None + + Z_THRESHOLD = 1.0 + + # I'm using 2004-2024 to match the same baseline window I already locked + # in for drought (Script 2), rain (Script 3b), and the fire z-score + # itself (fire index script) — keeping all my baselines consistent with + # each other. + BASELINE_START_YEAR = 2004 + BASELINE_END_YEAR = 2024 + + # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO + aoi = ( + ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + .filter(ee.Filter.eq("Name", "Odisha")) + .geometry() + ) + # Loading the assets := + treeMeta = ee.Image(TREE_COVER_ASSET) + startYearTree = treeMeta.select("start_year") + endYearTree = treeMeta.select("end_year") + + fireIndex = ee.Image(FIRE_INDEX_ASSET) + + # I need zScore bands covering BOTH my analysis window and my baseline + # window — whichever stretches further in either direction. Right now + # they're the same range (2004-2024), so this doesn't change anything + # today, but it protects me for later when I extend END_YEAR and the two + # windows stop lining up. + zMinYear = min(start_year, BASELINE_START_YEAR) + zMaxYear = max(end_year, BASELINE_END_YEAR) + + zScoreCol_list = [] + + for y in range(zMinYear, zMaxYear + 1): + zScoreCol_list.append( + fireIndex.select("zScore_" + str(y)).rename("zScore").set("year", y) + ) + + zScoreCol = ee.ImageCollection(zScoreCol_list) + + # LANDSAT HARMONIZATION & kNDVI := + chastainBandNames = ["BLUE", "GREEN", "RED", "NIR", "SWIR1", "SWIR2"] + oliETMSlopes = ee.Image.constant( + [1.03501, 1.00921, 1.01991, 1.14061, 1.04351, 1.05271] + ) + oliETMIntercepts = ee.Image.constant( + [-0.0055, -0.0008, -0.0021, -0.0163, -0.0045, 0.00261] + ) + + # Pre-process Landsat 5/7 (Baseline) + def prepL57(image): + qa = image.select("QA_PIXEL") + mask = qa.bitwiseAnd(1 << 3).eq(0).And(qa.bitwiseAnd(1 << 4).eq(0)) + + scaled = ( + image.updateMask(mask) + .select(["SR_B1", "SR_B2", "SR_B3", "SR_B4", "SR_B5", "SR_B7"]) + .multiply(0.0000275) + .add(-0.2) + ) + + return scaled.rename(chastainBandNames).copyProperties( + image, ["system:time_start"] + ) + + # Pre-process Landsat 8/9 and Harmonize to ETM+ + def prepL89(image): + qa = image.select("QA_PIXEL") + mask = qa.bitwiseAnd(1 << 3).eq(0).And(qa.bitwiseAnd(1 << 4).eq(0)) + + scaled = ( + image.updateMask(mask) + .select(["SR_B2", "SR_B3", "SR_B4", "SR_B5", "SR_B6", "SR_B7"]) + .multiply(0.0000275) + .add(-0.2) + .rename(chastainBandNames) + ) + + harmonized = scaled.multiply(oliETMSlopes).add(oliETMIntercepts) + return harmonized.copyProperties(image, ["system:time_start"]) + + # Calculate annual median kNDVI + def getAnnualKNDVI(year): + start = ee.Date.fromYMD(year, 1, 1) + end = ee.Date.fromYMD(year, 12, 31) + + l89 = ( + ee.ImageCollection("LANDSAT/LC08/C02/T1_L2") + .merge(ee.ImageCollection("LANDSAT/LC09/C02/T1_L2")) + .filterDate(start, end) + .filterBounds(aoi) + .map(prepL89) + .map( + lambda img: img.normalizedDifference(["NIR", "RED"]) + .pow(2) + .tanh() + .rename("kndvi") + ) + ) + + l57 = ( + ee.ImageCollection("LANDSAT/LT05/C02/T1_L2") + .merge(ee.ImageCollection("LANDSAT/LE07/C02/T1_L2")) + .filterDate(start, end) + .filterBounds(aoi) + .map(prepL57) + .map( + lambda img: img.normalizedDifference(["NIR", "RED"]) + .pow(2) + .tanh() + .rename("kndvi") + ) + ) + + return l89.merge(l57).median().set("year", year).rename("kndvi") + + # Same idea as zScoreCol above — I need kNDVI images covering whichever + # is bigger: my baseline window, or my analysis window PLUS ONE year + # (because resilience for my very last analysis year needs next-year + # kNDVI to compare against). + kndviMinYear = min(start_year, BASELINE_START_YEAR) + kndviMaxYear = max(end_year + 1, BASELINE_END_YEAR) + kndviYears = ee.List.sequence(kndviMinYear, kndviMaxYear) + kndviCol = ee.ImageCollection(kndviYears.map(getAnnualKNDVI)) + + # BASELINE kNDVI (Yn_bar):= + # Mean kNDVI across non-anomalous years only + + analysisYears = ee.List.sequence(start_year, end_year) + + # This is my frozen baseline window — the years I use to WORK OUT what + # Yn_bar (my "normal" kNDVI reference) is. On purpose, kept separate from + # analysisYears so extending my results later never shifts this. + baselineYears = ee.List.sequence(BASELINE_START_YEAR, BASELINE_END_YEAR) + + def calc_kndvi(y): + year = ee.Number(y) + kndvi = ee.Image(kndviCol.filter(ee.Filter.eq("year", year)).first()) + zScore = ( + ee.Image(zScoreCol.filter(ee.Filter.eq("year", year)).first()) + .resample("bilinear") + .reproject(crs=kndvi.projection(), scale=30) + ) + + isNormal = zScore.select("zScore").abs().lt(Z_THRESHOLD) + isForest = startYearTree.lte(year).And(endYearTree.gte(year)) + + return kndvi.updateMask(isNormal.And(isForest)).set("year", year) + + Yn_bar = ( + ee.ImageCollection(baselineYears.map(calc_kndvi)) + .mean() + .rename("kndvi_baseline") + ) + + # SIGNED RESISTANCE & RESILIENCE := + def calc_kndvi_ye(y): + + year = ee.Number(y) + + kndviYe = ee.Image(kndviCol.filter(ee.Filter.eq("year", year)).first()) + zScore = ( + ee.Image(zScoreCol.filter(ee.Filter.eq("year", year)).first()) + .resample("bilinear") + .reproject(crs=kndviYe.projection(), scale=30) + ) + + # Only compute on forest pixels during anomalous fire years + isAnomalous = zScore.select("zScore").gt(Z_THRESHOLD) + isForest = startYearTree.lte(year).And(endYearTree.gte(year)) + eventMask = isAnomalous.And(isForest) + + diffRaw = kndviYe.subtract(Yn_bar) + diffAbs = diffRaw.abs().max(1e-6) + + # Resistance: signed, computed for ALL anomalous years (both +ve and -ve) + resistance = ( + Yn_bar.divide(diffAbs) + .multiply(diffRaw.signum()) + .rename("resistance") + .updateMask(eventMask) + ) + + # Resilience: ONLY computed when Ye < Yn_bar (negative effect years) + isNegativeEffect = kndviYe.lt(Yn_bar) + resil_mask = eventMask.And(isNegativeEffect) + + kndvi_next = ee.Image( + kndviCol.filter(ee.Filter.eq("year", year.add(1))).first() + ) + diffNext = kndvi_next.subtract(Yn_bar) + diffNextAbs = diffNext.abs().max(1e-6) + + resilience = ( + diffAbs.divide(diffNextAbs) + .multiply(diffNext.signum()) + .rename("resilience") + .updateMask(resil_mask) + ) + + return ee.Image.cat([resistance, resilience]).set("year", year) + + metricsCol = ee.ImageCollection(analysisYears.map(calc_kndvi_ye)) + + # AGGREGATE & EXPORT := + mean_resist = metricsCol.select("resistance").mean().clip(aoi) + mean_resil = metricsCol.select("resilience").mean().clip(aoi) + + finalOutput = mean_resist.rename("resistance").addBands( + mean_resil.rename("resilience") + ) + + task_id = export_raster_asset_to_gee( + finalOutput, OUTPUT_DESC, OUTPUT_ASSET_ID, scale=30, region=aoi + ) + + return task_id diff --git a/computing/spei/high_wind_sensitivity/export_max_wind_index.py b/computing/spei/high_wind_sensitivity/export_max_wind_index.py new file mode 100644 index 00000000..16c95dff --- /dev/null +++ b/computing/spei/high_wind_sensitivity/export_max_wind_index.py @@ -0,0 +1,146 @@ +import ee + +from utilities.constants import AEZ +from utilities.gee_utils import ( + ee_initialize, + is_gee_asset_exists, + export_raster_asset_to_gee, +) + + +def max_wind_index(aez, start_year=2004, end_year=2022, gee_account_id=None): + """ + * Forest Sensitivity Analysis Pipeline — Script 5a + * High Windspeed Index Export (Annual Max Hourly Windspeed, Hours > Threshold, Mean > Threshold) + * + * Computes three quantities per pixel per year and exports as a single + * multiband asset — three bands per year: + * + * WSmax_{year} = maximum hourly windspeed within the year (ERA5-Land) + * WShoursGT_{year} = total hours where windspeed > WIND_THRESHOLD + * WSmeanGT_{year} = mean windspeed during the hours it exceeded WIND_THRESHOLD + * + * Windspeed computed from ERA5-Land hourly u/v 10m wind components: + * windspeed = sqrt(u_component_of_wind_10m^2 + v_component_of_wind_10m^2) + * + * Output asset bands (e.g., for 2004-2022 = 19 years * 3 = 57 bands): + * WSmax_2004, WShoursGT_2004, WSmeanGT_2004, ... + * + * Requires: nothing — only public datasets (ERA5-Land Hourly) + """ + + ee_initialize(gee_account_id) + OUTPUT_DESC = f"wind_index_AEZ_{aez}" + OUTPUT_ASSET_ID = ( + f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" + ) + + if is_gee_asset_exists(OUTPUT_ASSET_ID): + return None + + # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO + aoi = ( + ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + .filter(ee.Filter.eq("Name", "Odisha")) + .geometry() + ) + + # Set your wind speed threshold here (in m/s) + WIND_THRESHOLD = 10.0 + + # =========================================================================== + # 3. HOURLY WINDSPEED FROM U/V COMPONENTS + # =========================================================================== + + era5Hourly = ( + ee.ImageCollection("ECMWF/ERA5_LAND/HOURLY") + .filterBounds(aoi) + .filterDate("2000-01-01", f"{end_year}-12-31") + .select(["u_component_of_wind_10m", "v_component_of_wind_10m"]) + ) + + proj = era5Hourly.first().projection() + + def toWindSpeed(img): + ws = ( + img.select("u_component_of_wind_10m") + .pow(2) + .add(img.select("v_component_of_wind_10m").pow(2)) + .sqrt() + .rename("windspeed") + ) + return ws.copyProperties(img, ["system:time_start"]) + + windSpeedCol = era5Hourly.map(toWindSpeed) + + # =========================================================================== + # 4. ANNUAL METRICS (Max, Hours > Thresh, Mean > Thresh) + # =========================================================================== + + years = ee.List.sequence(start_year, end_year) + + def annual_metrics(y): + start = ee.Date.fromYMD(y, 1, 1) + # Note: filterDate is exclusive on the end date. Using y+1 ensures Dec 31 is included. + end = ee.Date.fromYMD(ee.Number(y).add(1), 1, 1) + + yearCol = windSpeedCol.filterDate(start, end) + + # 1. Max Wind Speed + wsMax = yearCol.max().rename("WSmax") + + # 2. Number of hours wind speed > threshold + wsHoursGT = yearCol.map( + lambda img: img.gt(WIND_THRESHOLD).rename("WShoursGT") + ).sum() + + # 3. Mean wind speed when > threshold + wsMeanGT = ( + yearCol.map(lambda img: img.updateMask(img.gt(WIND_THRESHOLD))) + .mean() + .rename("WSmeanGT") + ) + + # Combine all three into a single image for the year + return ( + ee.Image([wsMax, wsHoursGT, wsMeanGT]) + .setDefaultProjection(proj) + .set("year", y) + ) + + annual_ws_metrics = ee.ImageCollection(years.map(annual_metrics)) + + # =========================================================================== + # 5. STACK INTO SINGLE MULTIBAND IMAGE + # =========================================================================== + def add_year_bands(year, image): + year = ee.Number(year) + + year_img = annual_ws_metrics.filter(ee.Filter.eq("year", year)).first() + + year_str = ee.String(ee.Number(year).toInt()) + + ws_max = year_img.select("WSmax").rename(ee.String("WSmax_").cat(year_str)) + + ws_hours = year_img.select("WShoursGT").rename( + ee.String("WShoursGT_").cat(year_str) + ) + + ws_mean = year_img.select("WSmeanGT").rename( + ee.String("WSmeanGT_").cat(year_str) + ) + + return ee.Image(image).addBands(ee.Image([ws_max, ws_hours, ws_mean])) + + empty_image = ee.Image().mask(ee.Image(0)) + output_image = ee.Image(years.iterate(add_year_bands, empty_image)) + + # Export execution block + task_id = export_raster_asset_to_gee( + output_image.clip(aoi), OUTPUT_DESC, OUTPUT_ASSET_ID, scale=1000, region=aoi + ) + + print("✅ Clean pipeline compilation verified.") + print("Ready to execute in the tasks tab. Total structured bands: 95.") + + return task_id diff --git a/computing/spei/high_wind_sensitivity/highwind_resistance_resilience.py b/computing/spei/high_wind_sensitivity/highwind_resistance_resilience.py new file mode 100644 index 00000000..b321aaa1 --- /dev/null +++ b/computing/spei/high_wind_sensitivity/highwind_resistance_resilience.py @@ -0,0 +1,259 @@ +import ee + +from utilities.constants import AEZ +from utilities.gee_utils import ( + is_gee_asset_exists, + export_raster_asset_to_gee, + ee_initialize, +) + + +def high_wind_sensitivity(aez, start_year=2004, end_year=None, gee_account_id=None): + """ + * Forest Sensitivity Analysis Pipeline — Script 5b + * High Windspeed Resistance & Resilience (Harmonized kNDVI + Signed Formulas) + * + * For each forest pixel, computes mean resistance and resilience + * across all high-windspeed years (WSmax > threshold). + * + * Harmonization: Transforms Landsat 8/9 (OLI) to Landsat 5/7 (ETM+) + * equivalent before computing kNDVI to eliminate sensor-shift bias. + * + * Signed resistance (both +ve and -ve events): + * Resistance = Yn_bar / |Ye - Yn_bar| × sign(Ye - Yn_bar) + * + * Resilience computed ONLY when Ye < Yn_bar (negative effect years): + * Resilience = |Ye - Yn_bar| / |Ye+1 - Yn_bar| × sign(Ye+1 - Yn_bar) + * + * Requires: + * - Forest mask asset from Script 1 + * - Windspeed index asset from Script 5a + """ + ee_initialize(gee_account_id) + + TREE_COVER_ASSET = f"projects/corestack-datasets-alpha/assets/datasets/SPEI/Hybrid_Tree_AEZ_{aez}_{str(2003)}_{str(end_year)}" + WIND_INDEX_ASSET = ( + f"projects/corestack-datasets-alpha/assets/datasets/SPEI/wind_index_AEZ_{aez}" + ) + + OUTPUT_DESC = f"wind_metrics_harmonized_kNDVI_AEZ_{aez}" + OUTPUT_ASSET_ID = ( + f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" + ) + + if is_gee_asset_exists(OUTPUT_ASSET_ID): + return None + + WIND_THRESHOLD = 15 + + BASELINE_START_YEAR = 2004 + BASELINE_END_YEAR = 2024 + + # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO + aoi = ( + ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + .filter(ee.Filter.eq("Name", "Odisha")) + .geometry() + ) + # Loading the assets := + + treeMeta = ee.Image(TREE_COVER_ASSET) + startYear = treeMeta.select("start_year") + endYear = treeMeta.select("end_year") + + # Load windspeed index from single multiband asset (Script 5a output) + windIndex_raw = ee.Image(WIND_INDEX_ASSET) + + # I need WSmax bands covering BOTH my analysis window and my baseline + # window — whichever stretches further. Right now they're the same range + # (2004-2022), so this doesn't change anything today, but it protects me + # once the two windows stop lining up in the future. + wsMinYear = min(start_year, BASELINE_START_YEAR) + wsMaxYear = max(end_year, BASELINE_END_YEAR) + + wsImages = [] + for y in range(wsMinYear, wsMaxYear + 1): + wsImages.append( + windIndex_raw.select("WSmax_").cat(y).rename("windspeed").set("year", y) + ) + wsCol = ee.ImageCollection(wsImages) + + # LANDSAT HARMONIZATION & kNDVI := + + # Chastain et al. coefficients (OLI to ETM+) + chastainBandNames = ["BLUE", "GREEN", "RED", "NIR", "SWIR1", "SWIR2"] + oliETMSlopes = ee.Image.constant( + [1.03501, 1.00921, 1.01991, 1.14061, 1.04351, 1.05271] + ) + oliETMIntercepts = ee.Image.constant( + [-0.0055, -0.0008, -0.0021, -0.0163, -0.0045, 0.00261] + ) + + # Pre-process Landsat 5/7 (Baseline) + def prepL57(image): + qa = image.select("QA_PIXEL") + mask = qa.bitwiseAnd(1 << 3).eq(0).And(qa.bitwiseAnd(1 << 4).eq(0)) + + # Apply mask, select optical bands, and apply Collection 2 scale factors + scaled = ( + image.updateMask(mask) + .select(["SR_B1", "SR_B2", "SR_B3", "SR_B4", "SR_B5", "SR_B7"]) + .multiply(0.0000275) + .add(-0.2) + ) + + return scaled.rename(chastainBandNames).copyProperties( + image, ["system:time_start"] + ) + + # Pre-process Landsat 8/9 and Harmonize to ETM+ + def prepL89(image): + qa = image.select("QA_PIXEL") + mask = qa.bitwiseAnd(1 << 3).eq(0).And(qa.bitwiseAnd(1 << 4).eq(0)) + + # Apply mask, select optical bands, and apply Collection 2 scale factors + scaled = ( + image.updateMask(mask) + .select(["SR_B2", "SR_B3", "SR_B4", "SR_B5", "SR_B6", "SR_B7"]) + .multiply(0.0000275) + .add(-0.2) + .rename(chastainBandNames) + ) + + # Apply Chastain regression model (OLI -> ETM+) + harmonized = scaled.multiply(oliETMSlopes).add(oliETMIntercepts) + + return harmonized.copyProperties(image, ["system:time_start"]) + + # Calculate annual median kNDVI + def getAnnualKNDVI(year): + start = ee.Date.fromYMD(year, 1, 1) + end = ee.Date.fromYMD(year, 12, 31) + + l89 = ( + ee.ImageCollection("LANDSAT/LC08/C02/T1_L2") + .merge(ee.ImageCollection("LANDSAT/LC09/C02/T1_L2")) + .filterDate(start, end) + .filterBounds(aoi) + .map(prepL89) + .map( + lambda img: img.normalizedDifference(["NIR", "RED"]) + .pow(2) + .tanh() + .rename("kndvi") + ) + ) + + l57 = ( + ee.ImageCollection("LANDSAT/LT05/C02/T1_L2") + .merge(ee.ImageCollection("LANDSAT/LE07/C02/T1_L2")) + .filterDate(start, end) + .filterBounds(aoi) + .map(prepL57) + .map( + lambda img: img.normalizedDifference(["NIR", "RED"]) + .pow(2) + .tanh() + .rename("kndvi") + ) + ) + + return l89.merge(l57).median().set("year", year).rename("kndvi") + + # Load kNDVI for START_YEAR to END_YEAR+1 (need next year for resilience) + # I need kNDVI images covering whichever is bigger: my baseline window, or + # my analysis window PLUS ONE year (since resilience for my last analysis + # year needs next-year kNDVI to compare against). + + kndviMinYear = min(start_year, BASELINE_START_YEAR) + kndviMaxYear = max(end_year + 1, BASELINE_END_YEAR) + kndviYears = ee.List.sequence(kndviMinYear, kndviMaxYear) + kndviCol = ee.ImageCollection(kndviYears.map(getAnnualKNDVI)) + + # BASELINE kNDVI (Yn_bar):= + # Mean kNDVI across non-high-wind years only + + analysisYears = ee.List.sequence(start_year, end_year) + + # This is my frozen baseline window — the years I use to WORK OUT what + # Yn_bar (my "normal" kNDVI reference) is, kept separate from + # analysisYears on purpose. + baselineYears = ee.List.sequence(BASELINE_START_YEAR, BASELINE_END_YEAR) + + def calc_kndvi_non_event(y): + year = ee.Number(y) + kndvi = kndviCol.filter(ee.Filter.eq("year", year)).first() + ws = ( + wsCol.filter(ee.Filter.eq("year", year)) + .first() + .resample("bilinear") + .reproject(crs=kndvi.projection(), scale=30) + ) + isNonEvent = ws.lte(WIND_THRESHOLD) + return kndvi.updateMask(isNonEvent).set("year", year) + + kndviNonEvent = ee.ImageCollection(baselineYears.map(calc_kndvi_non_event)) + + Yn_bar = kndviNonEvent.mean().rename("kndvi_baseline") + + # SIGNED RESISTANCE & RESILIENCE := + def calc_metrics_col(y): + year = ee.Number(y) + + kndviYe = kndviCol.filter(ee.Filter.eq("year", year)).first() + wsYe = ( + wsCol.filter(ee.Filter.eq("year", year)) + .first() + .resample("bilinear") + .reproject(crs=kndviYe.projection(), scale=30) + ) + + # Only compute on forest pixels during high-windspeed years + # Flag ANY year where WSmax crosses the threshold, however briefly + isForest = startYear.lte(year).And(endYear.gte(year)) + isHighWind = wsYe.gt(WIND_THRESHOLD) + eventMask = isForest.And(isHighWind) + + diffRaw = kndviYe.subtract(Yn_bar) + diffAbs = diffRaw.abs().max(1e-6) + + # Resistance: signed, computed for ALL high-wind years + resistance = ( + Yn_bar.divide(diffAbs) + .multiply(diffRaw.signum()) + .rename("resistance") + .updateMask(eventMask) + ) + + # Resilience: ONLY computed when Ye < Yn_bar (negative effect years) + isNegativeEffect = kndviYe.lt(Yn_bar) + resilMask = eventMask.And(isNegativeEffect) + + kndviNext = kndviCol.filter(ee.Filter.eq("year", year.add(1))).first() + diffNext = kndviNext.subtract(Yn_bar) + diffNextAbs = diffNext.abs().max(1e-6) + + resilience = ( + diffAbs.divide(diffNextAbs) + .multiply(diffNext.signum()) + .rename("resilience") + .updateMask(resilMask) + ) + + return ee.Image.cat([resistance, resilience]).set("year", year) + + metricsCol = ee.ImageCollection(analysisYears.map(calc_metrics_col)) + + # AGGREGATE & EXPORT + meanResistance = metricsCol.select("resistance").mean().clip(aoi) + meanResilience = metricsCol.select("resilience").mean().clip(aoi) + + finalOutput = meanResistance.rename("resistance").addBands( + meanResilience.rename("resilience") + ) + + task_id = export_raster_asset_to_gee( + finalOutput, OUTPUT_DESC, OUTPUT_ASSET_ID, scale=30, region=aoi + ) + + return task_id diff --git a/computing/spei/hybrid_tree_mask.py b/computing/spei/hybrid_tree_mask.py index 4b350294..36f04694 100644 --- a/computing/spei/hybrid_tree_mask.py +++ b/computing/spei/hybrid_tree_mask.py @@ -7,7 +7,7 @@ ) -def generate_hybrid_tree_mask(aez, start_year=2003, end_year=2024, gee_account_id=None): +def generate_hybrid_tree_mask(aez, start_year=2003, end_year=None, gee_account_id=None): """ Forest Sensitivity Analysis Pipeline — Script 1 Hybrid 30m Annual Tree Cover Mask + Contiguous Forest Period @@ -35,9 +35,7 @@ def generate_hybrid_tree_mask(aez, start_year=2003, end_year=2024, gee_account_i start_year = 2003 LULC_START_YEAR = 2017 - aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() - - OUTPUT_DESC = f"Hybrid_Tree_AEZ_{aez}_Period_{str(start_year)}_{str(end_year)}" + OUTPUT_DESC = f"Hybrid_Tree_AEZ_{aez}_{str(start_year)}_{str(end_year)}" OUTPUT_ASSET_ID = ( f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" ) @@ -45,6 +43,13 @@ def generate_hybrid_tree_mask(aez, start_year=2003, end_year=2024, gee_account_i if is_gee_asset_exists(OUTPUT_ASSET_ID): return None, OUTPUT_ASSET_ID + # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO + aoi = ( + ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + .filter(ee.Filter.eq("Name", "Odisha")) + .geometry() + ) + # DATASET PREPARATION := # --- GLC-FCS30D --- glcMosaic = ee.ImageCollection( diff --git a/computing/spei/rainfall_sensitivity/export_rainfall_index.py b/computing/spei/rainfall_sensitivity/export_rainfall_index.py index 7f9a85ec..7759cb2a 100644 --- a/computing/spei/rainfall_sensitivity/export_rainfall_index.py +++ b/computing/spei/rainfall_sensitivity/export_rainfall_index.py @@ -2,39 +2,37 @@ from utilities.constants import AEZ from utilities.gee_utils import ( - export_raster_asset_to_gee, ee_initialize, is_gee_asset_exists, + export_raster_asset_to_gee, ) -def rainfall_index(aez, start_year=2004, end_year=2022, gee_account_id=None): +def rainfall_index(aez, start_year=2004, end_year=None, gee_account_id=None): """ - Forest Sensitivity Analysis Pipeline — Script 3a - Heavy Rainfall Index Export - - Computes two quantities per pixel per year and exports as a single - multiband asset — one band per year for each quantity: - - Hm_{year} = annual sum of precipitation on heavy days - zScore_{year} = z-score of Hm relative to the full period mean/stddev - - Heavy day definition: daily precipitation > long-term 95th percentile (CHIRPS) - Z-score computed across all years in the period. - - Output asset bands: - Hm_2004, Hm_2005, ..., Hm_2023 (19 bands) - zScore_2004, ..., zScore_2023 (19 bands) - Total: 38 bands - - This asset is the direct input to Script 3b, analogous to how - SPEI assets are the input to Script 2 (drought). - - Requires: nothing — only public datasets (CHIRPS) + * Forest Sensitivity Analysis Pipeline — Script 3a + * Heavy Rainfall Index Export (Wet-Days Only Threshold + Extended Metrics) + * + * Computes 5 quantities per pixel per year and exports as a single + * multiband asset — one band per year for each quantity: + * + * Hm_{year} = annual sum of precipitation on heavy days + * zScore_{year} = z-score of Hm relative to the 2004-2022 period + * heavyDays_{year} = number of days in the year with heavy rainfall + * heavyAvg_{year} = average daily precipitation on heavy rainfall days + * maxDay_{year} = maximum daily precipitation recorded in that year + * + * Heavy day definition: daily precipitation > long-term 95th percentile + * of WET DAYS ONLY (> 1mm) (CHIRPS) + * + * This asset is the direct input to Script 3b, analogous to how + * SPEI assets are the input to Script 2 (drought). + * + * Requires: nothing — only public datasets (CHIRPS) """ ee_initialize(gee_account_id) - OUTPUT_DESC = f"Rain_Index_AEZ_{aez}" + OUTPUT_DESC = f"rain_index_{aez}" # f"rain_index_AEZ_{aez}" OUTPUT_ASSET_ID = ( f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" ) @@ -42,85 +40,131 @@ def rainfall_index(aez, start_year=2004, end_year=2022, gee_account_id=None): if is_gee_asset_exists(OUTPUT_ASSET_ID): return None - aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() - # =========================================================================== - # HEAVY RAINFALL INDEX (Hm) - # =========================================================================== + # Fixed baseline window for zScore normalization — independent of START_YEAR/END_YEAR. + # Do NOT change this once results are published, or old zScore bands will drift + # when the pipeline timeline is extended. + BASELINE_START_YEAR = 2004 + BASELINE_END_YEAR = 2024 + + # 1. AOI + # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO + aoi = ( + ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + .filter(ee.Filter.eq("Name", "Odisha")) + .geometry() + ) + # 2. BASELINE HEAVY RAINFALL THRESHOLD chirps = ( ee.ImageCollection("UCSB-CHG/CHIRPS/DAILY") .filterBounds(aoi) - .filterDate("2000-01-01", "2023-12-31") + .filterDate("2000-01-01", ee.Date.fromYMD(BASELINE_END_YEAR, 12, 31)) .select("precipitation") ) proj = chirps.first().projection() - # Long-term 95th percentile — defines what counts as a heavy day + # Long-term 95th percentile of WET DAYS ONLY (> 1mm) p95 = ( - chirps.reduce(ee.Reducer.percentile([95])) + chirps.map(lambda img: img.updateMask(img.gt(1))) + .reduce(ee.Reducer.percentile([95])) .setDefaultProjection(proj) .rename("p95") ) - # Annual heavy rain sum per year - years = ee.List.sequence(start_year, end_year) + # 3. ANNUAL METRICS CALCULATION - def annualHmFunc(y): + metricsMinYear = min(start_year, BASELINE_START_YEAR) + metricsMaxYear = max(end_year, BASELINE_END_YEAR) + metricsYears = ee.List.sequence(metricsMinYear, metricsMaxYear) + + def calc_annual_metrics(y): start = ee.Date.fromYMD(y, 1, 1) end = ee.Date.fromYMD(y, 12, 31) - heavySum = ( - chirps.filterDate(start, end) - .map(lambda img: img.multiply(img.gt(p95))) + yearCollection = chirps.filterDate(start, end) + + # Absolute maximum daily rainfall for this year + maxDay = ( + yearCollection.max().unmask(0).setDefaultProjection(proj).rename("maxDay") + ) + + # Binary mask: isolated heavy rain days + heavyRainCollection = yearCollection.map( + lambda img: img.updateMask(img.gt(p95)) + ) + + # 1. Annual sum of heavy rainfall + hm = ( + heavyRainCollection.map(lambda img: img.unmask(0)) .sum() .setDefaultProjection(proj) .rename("Hm") - .set("year", y) ) - return heavySum - annualHm = ee.ImageCollection(years.map(annualHmFunc)) + # 2. Number of heavy days + heavyDays = ( + heavyRainCollection.map(lambda img: img.mask()) + .sum() + .unmask(0) + .setDefaultProjection(proj) + .rename("heavyDays") + ) + + # 3. Average intensity of heavy days + heavyAvg = ( + heavyRainCollection.mean() + .unmask(0) + .setDefaultProjection(proj) + .rename("heavyAvg") + ) - # =========================================================================== - # Z-SCORE ACROSS ALL YEARS - # =========================================================================== + # Combine metrics into a single image per year containing all 4 base properties + return hm.addBands(heavyDays).addBands(heavyAvg).addBands(maxDay).set("year", y) - hmMean = annualHm.mean().rename("Hm_mean") - hmStdDev = annualHm.reduce(ee.Reducer.stdDev()).rename("Hm_stdDev") + annualMetrics = ee.ImageCollection(metricsYears.map(calc_annual_metrics)) - def annualZScoreFunc(y): - year = ee.Number(y) - hm = annualHm.filter(ee.Filter.eq("year", year)).first() + # 4. FIXED Z-SCORE CALCULATION + + baselineYears = ee.List.sequence(BASELINE_START_YEAR, BASELINE_END_YEAR) + + baselineMetrics = annualMetrics.filter(ee.Filter.inList("year", baselineYears)) + + hmMean = baselineMetrics.select("Hm").mean() + hmStdDev = baselineMetrics.select("Hm").reduce(ee.Reducer.stdDev()) + + # Changed to server-side map architecture instead of the annual image collection, done for GEE optimisation.. as it's a better practice. + def calc_annual_metrics(img): + hm = img.select("Hm") z = hm.subtract(hmMean).divide(hmStdDev).rename("zScore") - return z.set("year", year) + return img.addBands(z) - annualZScore = ee.ImageCollection(years.map(annualZScoreFunc)) + completedAnnualCollection = annualMetrics.map(calc_annual_metrics) - # =========================================================================== - # STACK INTO SINGLE MULTIBAND IMAGE - # =========================================================================== + # 5. SERVER-SIDE STACK INTO SINGLE MULTIBAND IMAGE & EXPORT + analysisYears = ee.List.sequence(start_year, end_year) - # Build one image with 38 named bands: - # Hm_2004 ... Hm_2022, zScore_2004 ... zScore_2022 + # Changed server-side iteration style to stack and correctly rename bands + def add_bands_for_year(y, acc): + yearStr = ee.String(ee.Number(y).toInt()) + yearImg = completedAnnualCollection.filter(ee.Filter.eq("year", y)).first() - def add_bands_for_year(y, img): - y = ee.Number(y).toInt() - hm_band = ( - annualHm.filter(ee.Filter.eq("year", y)) - .first() - .rename(ee.String("Hm_").cat(ee.Number(y).format())) + hmBand = yearImg.select("Hm").rename(ee.String("Hm_").cat(yearStr)) + zBand = yearImg.select("zScore").rename(ee.String("zScore_").cat(yearStr)) + heavyDaysBand = yearImg.select("heavyDays").rename( + ee.String("heavyDays_").cat(yearStr) + ) + heavyAvgBand = yearImg.select("heavyAvg").rename( + ee.String("heavyAvg_").cat(yearStr) ) - z_band = ( - annualZScore.filter(ee.Filter.eq("year", y)) - .first() - .rename(ee.String("zScore_").cat(ee.Number(y).format())) + maxDayBand = yearImg.select("maxDay").rename(ee.String("maxDay_").cat(yearStr)) + + return ee.Image(acc).addBands( + [hmBand, zBand, heavyDaysBand, heavyAvgBand, maxDayBand] ) - return ee.Image(img).addBands(hm_band).addBands(z_band) empty_image = ee.Image().mask(ee.Image(0)) - output_image = ee.Image(years.iterate(add_bands_for_year, empty_image)) - output_image = output_image.select(output_image.bandNames().remove("constant")) + output_image = ee.Image(analysisYears.iterate(add_bands_for_year, empty_image)) task_id = export_raster_asset_to_gee( output_image.clip(aoi), OUTPUT_DESC, OUTPUT_ASSET_ID, scale=5566, region=aoi diff --git a/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py b/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py index 0991677b..6835b619 100644 --- a/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py +++ b/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py @@ -2,36 +2,40 @@ from utilities.constants import AEZ from utilities.gee_utils import ( - export_raster_asset_to_gee, ee_initialize, is_gee_asset_exists, + export_raster_asset_to_gee, ) def generate_rainfall_resilience( - aez, start_year=2004, end_year=2022, gee_account_id=None + aez, start_year=2004, end_year=None, gee_account_id=None ): """ - Forest Sensitivity Analysis Pipeline — Script 3b - Heavy Rainfall Resistance & Resilience - - Signed resistance (both +ve and -ve events): - Resistance = Yn_bar / |Ye - Yn_bar| × sign(Ye - Yn_bar) - - Resilience computed ONLY when Ye < Yn_bar (negative effect years): - Resilience = |Ye - Yn_bar| / |Ye+1 - Yn_bar| × sign(Ye+1 - Yn_bar) - - Requires: - - Forest mask asset (Script 1) - - Rainfall index asset (Script 3a) + * Forest Sensitivity Analysis Pipeline — Script 3b + * Heavy Rainfall Resistance & Resilience (Harmonized kNDVI + Signed Formulas) + * + * Signed resistance (both +ve and -ve events): + * Resistance = Yn_bar / |Ye - Yn_bar| × sign(Ye - Yn_bar) + * + * Resilience computed ONLY when Ye < Yn_bar (negative effect years): + * Resilience = |Ye - Yn_bar| / |Ye+1 - Yn_bar| × sign(Ye+1 - Yn_bar) + * + * Harmonization: Transforms Landsat 8/9 (OLI) to Landsat 5/7 (ETM+) + * equivalent before computing kNDVI to eliminate sensor-shift bias. + * + * Requires: + * - Forest mask asset (Script 1) + * - Rainfall index asset (Script 3a) """ + ee_initialize(gee_account_id) - TREE_COVER_ASSET = f"projects/corestack-datasets-alpha/assets/datasets/SPEI/Hybrid_Tree_AEZ_{aez}_Period_{str(2003)}_{str(end_year)}" + TREE_COVER_ASSET = f"projects/corestack-datasets-alpha/assets/datasets/SPEI/Hybrid_Tree_AEZ_{aez}_{str(2003)}_{str(end_year)}" RAIN_INDEX_ASSET = ( - f"projects/corestack-datasets-alpha/assets/datasets/SPEI/Rain_Index_AEZ_{aez}" + f"projects/corestack-datasets-alpha/assets/datasets/SPEI/rain_index_AEZ_{aez}" ) - OUTPUT_DESC = f"Rain_Metrics_AEZ_{aez}" + OUTPUT_DESC = f"Rain_Metrics_{aez}" # f"Rain_Metrics_AEZ_{aez}" OUTPUT_ASSET_ID = ( f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" ) @@ -41,41 +45,103 @@ def generate_rainfall_resilience( Z_THRESHOLD = 1.0 - # AOI := - aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + # This is the same fix I did for the drought script (Script 2). Yn_bar is + # my baseline — "what kNDVI should normally look like on a healthy, + # non-anomalous year." If I let Yn_bar be computed only from + # START_YEAR..END_YEAR, then every time I extend my analysis window in + # the future, Yn_bar shifts a little, and that quietly changes ALL my + # past resistance/resilience numbers too — even for years I already + # published. That's the exact problem I don't want. + # + # So I'm freezing the baseline window here, separately from my analysis + # window. Once I publish results, I'm never touching these two numbers + # again — if I do, my old outputs will drift. + # + # I picked 2004-2024 to match what I already locked in for the drought + # baseline (Script 2) and for the rain z-score baseline (Script 3a) — + # keeping all three consistent. 2004 is my floor because that's as far + # back as my source data goes; 2024 is what I've already confirmed is + # available and exported (3a's END_YEAR is now 2024, so the zScore bands + # I need actually exist in the rain index asset). + BASELINE_START_YEAR = 2004 + BASELINE_END_YEAR = 2024 + + # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO + aoi = ( + ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + .filter(ee.Filter.eq("Name", "Odisha")) + .geometry() + ) - # Loading the assets := treeMeta = ee.Image(TREE_COVER_ASSET) startYearTree = treeMeta.select("start_year") endYearTree = treeMeta.select("end_year") - rainIndex_raw = ee.Image(RAIN_INDEX_ASSET) - rainBandNames = [] - - for yr in range(start_year, end_year + 1): - rainBandNames.append("Hm_" + str(yr)) - rainBandNames.append("zScore_" + str(yr)) + rainIndex = ee.Image(RAIN_INDEX_ASSET) - rainIndex = rainIndex_raw.rename(rainBandNames) + # I need zScore bands for every year covering BOTH my baseline window + # and my analysis window — whichever stretches further in each + # direction. Before, this loop only went START_YEAR..END_YEAR, which + # meant if I ever widened my baseline beyond my analysis window, I'd be + # trying to .select() a band like zScore_2024 that this loop never even + # asked for. So I'm building the year range from the min/max of both + # windows, to be safe. + zMinYear = min(start_year, BASELINE_START_YEAR) + zMaxYear = max(end_year, BASELINE_END_YEAR) - hmCol_list = [] zScoreCol_list = [] - for y in range(start_year, end_year + 1): - hmCol_list.append(rainIndex.select("Hm_" + str(y)).rename("Hm").set("year", y)) + for y in range(zMinYear, zMaxYear + 1): zScoreCol_list.append( rainIndex.select("zScore_" + str(y)).rename("zScore").set("year", y) ) - hmCol = ee.ImageCollection(hmCol_list) zScoreCol = ee.ImageCollection(zScoreCol_list) - # LANDSAT NDVI := - def mask_clouds(image): + # LANDSAT HARMONIZATION & kNDVI := + + chastainBandNames = ["BLUE", "GREEN", "RED", "NIR", "SWIR1", "SWIR2"] + oliETMSlopes = ee.Image.constant( + [1.03501, 1.00921, 1.01991, 1.14061, 1.04351, 1.05271] + ) + oliETMIntercepts = ee.Image.constant( + [-0.0055, -0.0008, -0.0021, -0.0163, -0.0045, 0.00261] + ) + + # Pre-process Landsat 5/7 (Baseline) + def prepL57(image): + qa = image.select("QA_PIXEL") + mask = qa.bitwiseAnd(1 << 3).eq(0).And(qa.bitwiseAnd(1 << 4).eq(0)) + + scaled = ( + image.updateMask(mask) + .select(["SR_B1", "SR_B2", "SR_B3", "SR_B4", "SR_B5", "SR_B7"]) + .multiply(0.0000275) + .add(-0.2) + ) + + return scaled.rename(chastainBandNames).copyProperties( + image, ["system:time_start"] + ) + + # Pre-process Landsat 8/9 and Harmonize to ETM+ + def prepL89(image): qa = image.select("QA_PIXEL") mask = qa.bitwiseAnd(1 << 3).eq(0).And(qa.bitwiseAnd(1 << 4).eq(0)) - return image.updateMask(mask) - def get_annual_ndvi(year): + scaled = ( + image.updateMask(mask) + .select(["SR_B2", "SR_B3", "SR_B4", "SR_B5", "SR_B6", "SR_B7"]) + .multiply(0.0000275) + .add(-0.2) + .rename(chastainBandNames) + ) + + harmonized = scaled.multiply(oliETMSlopes).add(oliETMIntercepts) + + return harmonized.copyProperties(image, ["system:time_start"]) + + # Calculate annual median kNDVI + def getAnnualKNDVI(year): start = ee.Date.fromYMD(year, 1, 1) end = ee.Date.fromYMD(year, 12, 31) @@ -84,9 +150,12 @@ def get_annual_ndvi(year): .merge(ee.ImageCollection("LANDSAT/LC09/C02/T1_L2")) .filterDate(start, end) .filterBounds(aoi) - .map(mask_clouds) + .map(prepL89) .map( - lambda img: img.normalizedDifference(["SR_B5", "SR_B4"]).rename("ndvi") + lambda img: img.normalizedDifference(["NIR", "RED"]) + .pow(2) + .tanh() + .rename("kndvi") ) ) @@ -95,60 +164,77 @@ def get_annual_ndvi(year): .merge(ee.ImageCollection("LANDSAT/LE07/C02/T1_L2")) .filterDate(start, end) .filterBounds(aoi) - .map(mask_clouds) + .map(prepL57) .map( - lambda img: img.normalizedDifference(["SR_B4", "SR_B3"]).rename("ndvi") + lambda img: img.normalizedDifference(["NIR", "RED"]) + .pow(2) + .tanh() + .rename("kndvi") ) ) - return l89.merge(l57).median().set("year", year).rename("ndvi") + return l89.merge(l57).median().set("year", year).rename("kndvi") - ndviCol = ee.ImageCollection( - ee.List.sequence(start_year, end_year + 1).map(get_annual_ndvi) - ) + # Same idea as zScoreCol above — I need kNDVI images covering whichever + # is bigger: my baseline window, or my analysis window (+1 year, because + # resilience for my LAST analysis year needs next-year kNDVI to compare + # against). So I'm taking the min of the two start years and the max of + # (END_YEAR + 1) vs BASELINE_END_YEAR. + kndviMinYear = min(start_year, BASELINE_START_YEAR) + kndviMaxYear = max(end_year + 1, BASELINE_END_YEAR) + + kndviYears = ee.List.sequence(kndviMinYear, kndviMaxYear) + kndviCol = ee.ImageCollection(kndviYears.map(getAnnualKNDVI)) - # BASELINE NDVI (Yn_bar) := - # Mean NDVI across non-anomalous years only + # BASELINE kNDVI (Yn_bar) := + # Mean kNDVI across non-anomalous years only + + # This is my actual analysis window — the years I want resistance and + # resilience results FOR. This stays exactly as before, untouched. analysisYears = ee.List.sequence(start_year, end_year) - # Yn_bar = ee.ImageCollection(analysisYears.map(function(y) { - # year = ee.Number(y) - # ndvi = ee.Image(ndviCol.filter(ee.Filter.eq('year', year)).first()) - # zScore = ee.Image(zScoreCol.filter(ee.Filter.eq('year', year)).first()) - # .resample('bilinear') - # .reproject({crs: ndvi.projection(), scale: 30}) - # isNormal = zScore.select('zScore').abs().lt(Z_THRESHOLD) - # isForest = startYearTree.lte(year).and(endYearTree.gte(year)) - # return ndvi.updateMask(isNormal.and(isForest)).set('year', year) - # })).mean().rename('ndvi_baseline') - - def ndvi_forest(y): + # This is my frozen baseline window — the years I use to WORK OUT what + # Yn_bar (my "normal" kNDVI reference) is. This is separate from + # analysisYears on purpose, so extending my analysis later doesn't shift + # Yn_bar and quietly rewrite results I've already published. + baselineYears = ee.List.sequence(BASELINE_START_YEAR, BASELINE_END_YEAR) + + def calc_Yn_bar(y): year = ee.Number(y) - ndvi = ee.Image(ndviCol.filter(ee.Filter.eq("year", year)).first()) + kndvi = ee.Image(kndviCol.filter(ee.Filter.eq("year", year)).first()) zScore = ( ee.Image(zScoreCol.filter(ee.Filter.eq("year", year)).first()) .resample("bilinear") - .reproject(crs=ndvi.projection(), scale=30) + .reproject(crs=kndvi.projection(), scale=30) ) isNormal = zScore.select("zScore").abs().lt(Z_THRESHOLD) isForest = startYearTree.lte(year).And(endYearTree.gte(year)) - return ndvi.updateMask(isNormal.And(isForest)).set("year", year) - ndviNonDrought = ee.ImageCollection(analysisYears.map(ndvi_forest)) + return kndvi.updateMask(isNormal.And(isForest)).set("year", year) + + Yn_bar = ( + ee.ImageCollection(baselineYears.map(calc_Yn_bar)) + .mean() + .rename("kndvi_baseline") + ) - Yn_bar = ndviNonDrought.mean().rename("ndvi_baseline") # SIGNED RESISTANCE & RESILIENCE := - def metrics_col_func(y): + # This part is completely unchanged from before — it still only runs + # over analysisYears (my actual START_YEAR..END_YEAR), it just now uses + # the frozen Yn_bar computed above instead of a Yn_bar that would've + # silently moved every time I touch START_YEAR/END_YEAR. + + def calc_metrics_cols(y): year = ee.Number(y) - ndviYe = ee.Image(ndviCol.filter(ee.Filter.eq("year", year)).first()) + kndviYe = ee.Image(kndviCol.filter(ee.Filter.eq("year", year)).first()) zScore = ( ee.Image(zScoreCol.filter(ee.Filter.eq("year", year)).first()) .resample("bilinear") - .reproject(crs=ndviYe.projection(), scale=30) + .reproject(crs=kndviYe.projection(), scale=30) ) # Only compute on forest pixels during anomalous rainfall years @@ -156,7 +242,7 @@ def metrics_col_func(y): isForest = startYearTree.lte(year).And(endYearTree.gte(year)) eventMask = isAnomalous.And(isForest) - diffRaw = ndviYe.subtract(Yn_bar) + diffRaw = kndviYe.subtract(Yn_bar) diffAbs = diffRaw.abs().max(1e-6) # Resistance: signed, computed for ALL anomalous years (both +ve and -ve) @@ -168,16 +254,11 @@ def metrics_col_func(y): ) # Resilience: ONLY computed when Ye < Yn_bar (negative effect years) - # This avoids the 2D interpretation problem when Ye > Yn_bar - # and also thinking about it, resilience only makes sense, - # when Ye < Yn_bar , as if NDVI has increased from baseline, no point in calculating the recovering - # as the nae sugegsts. ALthough we are missing out on cases , if increase happened, and due to some lasting effect of rainfall, - # ndvi decreased in further years. But we're ignoring that case here, just for simplicity of understanding in 2-D. - isNegativeEffect = ndviYe.lt(Yn_bar) + isNegativeEffect = kndviYe.lt(Yn_bar) resilMask = eventMask.And(isNegativeEffect) - ndviNext = ee.Image(ndviCol.filter(ee.Filter.eq("year", year.add(1))).first()) - diffNext = ndviNext.subtract(Yn_bar) + kndviNext = ee.Image(kndviCol.filter(ee.Filter.eq("year", year.add(1))).first()) + diffNext = kndviNext.subtract(Yn_bar) diffNextAbs = diffNext.abs().max(1e-6) resilience = ( @@ -189,7 +270,7 @@ def metrics_col_func(y): return ee.Image.cat([resistance, resilience]).set("year", year) - metricsCol = ee.ImageCollection(analysisYears.map(metrics_col_func)) + metricsCol = ee.ImageCollection(analysisYears.map(calc_metrics_cols)) # AGGREGATE & EXPORT := diff --git a/computing/spei/spei.py b/computing/spei/spei.py index b3d0030b..3559019c 100644 --- a/computing/spei/spei.py +++ b/computing/spei/spei.py @@ -1,9 +1,17 @@ from computing.spei.drought_sensitivity.drought_resistance_resilience import ( generate_drought_resistance, ) +from computing.spei.forestfire_sensitivity.export_fire_index import fire_index +from computing.spei.forestfire_sensitivity.forest_fire_resistance_resilience import ( + forest_fire_sensitivity, +) from computing.spei.generate_spei.download_base_datasets import download_data_locally from computing.spei.generate_spei.generate_ppet_multiband import ppet_multiband from computing.spei.generate_spei.spei_runner import run_spei +from computing.spei.high_wind_sensitivity.export_max_wind_index import max_wind_index +from computing.spei.high_wind_sensitivity.highwind_resistance_resilience import ( + high_wind_sensitivity, +) from computing.spei.hybrid_tree_mask import generate_hybrid_tree_mask from computing.spei.rainfall_sensitivity.export_rainfall_index import rainfall_index from computing.spei.rainfall_sensitivity.rainfall_resistance_resilience import ( @@ -26,20 +34,20 @@ def generate_spei_pipeline( start_date = f"{str(start_year)}-01-01" end_date = f"{str(end_year)}-12-31" - # download_data_locally( - # aez=aez, - # start_date=start_date, - # end_date=end_date, - # frequency="monthly", - # datasets=None, - # overwrite=overwrite, - # ) - - # ppet_multiband( - # aez=aez, - # start=start_year, - # end=end_year, - # ) + download_data_locally( + aez=aez, + start_date=start_date, + end_date=end_date, + frequency="monthly", + datasets=None, + overwrite=overwrite, + ) + + ppet_multiband( + aez=aez, + start=start_year, + end=end_year, + ) run_spei(aez, start_year, end_year) @@ -80,3 +88,47 @@ def run_rainfall_resistance_resilience( generate_rainfall_resilience( aez, start_year=2004, end_year=end_year, gee_account_id=gee_account_id ) + + +@app.task(bind=True) +def run_forest_fire_resistance_resilience( + self, aez, start_year=None, end_year=None, gee_account_id=None +): + task_id = fire_index( + aez, start_year=start_year, end_year=end_year, gee_account_id=gee_account_id + ) + if task_id: + check_task_status([task_id]) + + task_id, asset_id = generate_hybrid_tree_mask( + aez, start_year=start_year, end_year=end_year, gee_account_id=gee_account_id + ) + if task_id: + check_task_status([task_id]) + + if is_gee_asset_exists(asset_id): + forest_fire_sensitivity( + aez, start_year=2004, end_year=end_year, gee_account_id=gee_account_id + ) + + +@app.task(bind=True) +def run_high_wind_resistance_resilience( + self, aez, start_year=None, end_year=None, gee_account_id=None +): + task_id = max_wind_index( + aez, start_year=start_year, end_year=end_year, gee_account_id=gee_account_id + ) + if task_id: + check_task_status([task_id]) + + task_id, asset_id = generate_hybrid_tree_mask( + aez, start_year=start_year, end_year=end_year, gee_account_id=gee_account_id + ) + if task_id: + check_task_status([task_id]) + + if is_gee_asset_exists(asset_id): + high_wind_sensitivity( + aez, start_year=2004, end_year=end_year, gee_account_id=gee_account_id + ) diff --git a/computing/urls.py b/computing/urls.py index 8f3892ab..db2557d2 100644 --- a/computing/urls.py +++ b/computing/urls.py @@ -258,6 +258,16 @@ api.rainfall_resilience_resistance, name="rainfall_resilience_resistance", ), + path( + "forest_fire_resilience_resistance/", + api.forest_fire_resilience_resistance, + name="forest_fire_resilience_resistance", + ), + path( + "high_wind_resilience_resistance/", + api.high_wind_resilience_resistance, + name="high_wind_resilience_resistance", + ), path( "generate_dem_raster_vector/", api.generate_fabdem_raster_vector, From 7f1fb94017860495e2a9b1ae0b9908eb3b5e1e08 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 23 Jul 2026 16:23:06 +0530 Subject: [PATCH 061/120] json map path updates --- computing/layer_dependency/local_layer_map.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/computing/layer_dependency/local_layer_map.json b/computing/layer_dependency/local_layer_map.json index 6aaf8b34..573cc0fc 100644 --- a/computing/layer_dependency/local_layer_map.json +++ b/computing/layer_dependency/local_layer_map.json @@ -1,5 +1,8 @@ { "dynamic_layers": [ + { + "name": "generate_nrega_layer" + }, { "name": "lulc_v3", "use_global_args": true @@ -42,9 +45,6 @@ { "name": "generate_swb", "use_global_args": true - }, - { - "name": "generate_nrega_layer" } ], "static_layers": [ From 4f7132739b0f7de4b4461848467edbe149f73444 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 23 Jul 2026 16:23:06 +0530 Subject: [PATCH 062/120] json map path updates --- computing/layer_dependency/local_layer_map.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/computing/layer_dependency/local_layer_map.json b/computing/layer_dependency/local_layer_map.json index 6aaf8b34..573cc0fc 100644 --- a/computing/layer_dependency/local_layer_map.json +++ b/computing/layer_dependency/local_layer_map.json @@ -1,5 +1,8 @@ { "dynamic_layers": [ + { + "name": "generate_nrega_layer" + }, { "name": "lulc_v3", "use_global_args": true @@ -42,9 +45,6 @@ { "name": "generate_swb", "use_global_args": true - }, - { - "name": "generate_nrega_layer" } ], "static_layers": [ From d875afc03ad4d40957318f09e5dc6fccfac73a90 Mon Sep 17 00:00:00 2001 From: aman verma Date: Thu, 23 Jul 2026 11:23:46 +0000 Subject: [PATCH 063/120] ltp-stp change apis --- computing/api.py | 42 +++++++ .../ltp_stp/generate_ltp_stp_change_local.py | 107 ++++++++++++++++++ .../generate_ltp_stp_local.py | 5 +- computing/urls.py | 10 ++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 computing/tree_health/ltp_stp/generate_ltp_stp_change_local.py rename computing/tree_health/{local => ltp_stp}/generate_ltp_stp_local.py (96%) diff --git a/computing/api.py b/computing/api.py index 97087bff..56c251a3 100644 --- a/computing/api.py +++ b/computing/api.py @@ -150,6 +150,10 @@ from .tree_health.local.overall_change_vector_local import ( tree_health_overall_change_vector_local, ) +from .tree_health.ltp_stp.generate_ltp_stp_change_local import ( + generate_ltp_stp_change_local, +) +from .tree_health.ltp_stp.generate_ltp_stp_local import generate_ltp_stp_local from .utils import ( Geoserver, @@ -2676,3 +2680,41 @@ def generate_soil_health(request): except Exception as e: print("Exception in generate_soil_health api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@schema(None) +def generate_ltp_stp(request): + print("Inside generate_ltp_stp API.") + try: + start_year = request.data.get("start_year") + end_year = request.data.get("end_year") + + generate_ltp_stp_local.apply_async(args=[start_year, end_year], queue="nrm") + return Response( + {"Success": f"Successfully initiated generate_ltp_stp task"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + print("Exception in generate_ltp_stp api :: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@schema(None) +def generate_ltp_stp_change(request): + print("Inside generate_ltp_stp API.") + try: + start_year = request.data.get("start_year") + end_year = request.data.get("end_year") + + generate_ltp_stp_change_local.apply_async( + args=[start_year, end_year], queue="nrm" + ) + return Response( + {"Success": f"Successfully initiated generate_ltp_stp task"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + print("Exception in generate_ltp_stp api :: ", e) + return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/computing/tree_health/ltp_stp/generate_ltp_stp_change_local.py b/computing/tree_health/ltp_stp/generate_ltp_stp_change_local.py new file mode 100644 index 00000000..4ef522db --- /dev/null +++ b/computing/tree_health/ltp_stp/generate_ltp_stp_change_local.py @@ -0,0 +1,107 @@ +import os +import numpy as np +import rasterio +from computing.config_loader import PROJECT_ROOT +from nrm_app.celery import app + +# Local base directory for input and output raster files. +LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/base_layers/tree_health/ltp_stp" + +# Mapping of agro-climatic zones (ACZs) to their output acronyms. +ACZS = { + "Eastern Plateau & Hills Region": "EPAHR", + "Southern Plateau and Hills Region": "SPAHR", + "East Coast Plains & Hills Region": "ECPHR", + "Western Plateau and Hills Region": "WPAHR", + "Central Plateau & Hills Region": "CPAHR", + "Lower Gangetic Plain Region": "LGPR", + "Middle Gangetic Plain Region": "MGPR", + "Upper Gangetic Plain Region": "UGPR", + "Trans Gangetic Plain Region": "TGPR", + "Eastern Himalayan Region": "EHR", + "Western Himalayan Region": "WHR", +} + + +@app.task(bind=True) +def generate_ltp_stp_change_local(self, start_year, end_year): + """Generate LTP-STP change rasters for the given year pair. + + For each ACZ, this function loads the LTP raster for year_1 and year_2, + computes the change category, and writes a compact uint8 output raster. + """ + + for acz, acronym in ACZS.items(): + print(f"Processing {acz}") + + # Input file paths for both years. + ltp_file_1 = os.path.join( + LOCAL_OUTPUT_BASE_DIR, + f"ltp_{start_year}", + acronym, + f"ltp_{start_year}_{acronym}.tif", + ) + ltp_file_2 = os.path.join( + LOCAL_OUTPUT_BASE_DIR, + f"ltp_{end_year}", + acronym, + f"ltp_{end_year}_{acronym}.tif", + ) + + if not (os.path.exists(ltp_file_1) and os.path.exists(ltp_file_2)): + print("Missing input(s)") + continue + + # Read first year raster and preserve profile metadata for output. + with rasterio.open(ltp_file_1) as src1: + ltp1 = src1.read(1) + profile = src1.profile.copy() + nodata = src1.nodata if src1.nodata is not None else -9999 + + # Read second year raster. + with rasterio.open(ltp_file_2) as src2: + ltp2 = src2.read(1) + + # Standardize nodata values for comparison. + ltp1 = np.where(ltp1 == nodata, -9999, ltp1) + ltp2 = np.where(ltp2 == nodata, -9999, ltp2) + + # Initialize output change raster with nodata default. + change = np.full(ltp1.shape, -9999, dtype=np.int16) + + # Define change categories: + # 0 -> no change, absent in both years + # 1 -> no change, present in both years + # 2 -> new presence in year_2 + # 3 -> loss from year_1 to year_2 + # 4 -> year_1 nodata, year_2 absent + # 5 -> year_1 nodata, year_2 present + # 6 -> year_1 absent, year_2 nodata + # 7 -> year_1 present, year_2 nodata + change[(ltp1 == 0) & (ltp2 == 0)] = 0 + change[(ltp1 == 1) & (ltp2 == 1)] = 1 + change[(ltp1 == 0) & (ltp2 == 1)] = 2 + change[(ltp1 == 1) & (ltp2 == 0)] = 3 + change[(ltp1 == -9999) & (ltp2 == 0)] = 4 + change[(ltp1 == -9999) & (ltp2 == 1)] = 5 + change[(ltp1 == 0) & (ltp2 == -9999)] = 6 + change[(ltp1 == 1) & (ltp2 == -9999)] = 7 + + # Convert back to original nodata and write as uint8. + change = np.where(change == -9999, nodata, change).astype(np.uint8) + profile.update( + driver="GTiff", dtype="uint8", count=1, compress="lzw", nodata=nodata + ) + + outdir = os.path.join( + LOCAL_OUTPUT_BASE_DIR, f"ltp_change_{start_year}_{end_year}", acronym + ) + os.makedirs(outdir, exist_ok=True) + outfile = os.path.join( + outdir, f"ltp_change_{start_year}_{end_year}_{acronym}.tif" + ) + + with rasterio.open(outfile, "w", **profile) as dst: + dst.write(change, 1) + + print("Saved:", outfile) diff --git a/computing/tree_health/local/generate_ltp_stp_local.py b/computing/tree_health/ltp_stp/generate_ltp_stp_local.py similarity index 96% rename from computing/tree_health/local/generate_ltp_stp_local.py rename to computing/tree_health/ltp_stp/generate_ltp_stp_local.py index 56cad66f..a9155e07 100644 --- a/computing/tree_health/local/generate_ltp_stp_local.py +++ b/computing/tree_health/ltp_stp/generate_ltp_stp_local.py @@ -16,6 +16,8 @@ from math import floor from rasterio.transform import Affine +from nrm_app.celery import app + """ Generate Long-Term Tree Patches (LTP) and Short-Term Tree Patches (STP) rasters. @@ -69,7 +71,8 @@ } -def generate_ltp_stp_local(start_year, end_year): +@app.task(bind=True) +def generate_ltp_stp_local(self, start_year, end_year): """ Main function to generate LTP/STP classification rasters. diff --git a/computing/urls.py b/computing/urls.py index 8f3892ab..165700cc 100644 --- a/computing/urls.py +++ b/computing/urls.py @@ -293,4 +293,14 @@ api.generate_soil_health, name="generate_soil_health", ), + path( + "generate_ltp_stp/", + api.generate_ltp_stp, + name="generate_ltp_stp", + ), + path( + "generate_ltp_stp_change/", + api.generate_ltp_stp_change, + name="generate_ltp_stp_change", + ), ] From 9d6596bb5a916135e8506f523e542007607ee49f Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 23 Jul 2026 16:59:00 +0530 Subject: [PATCH 064/120] added tree health and soil health --- computing/api.py | 3 -- .../layer_generation_in_order.py | 32 ++++++++++++++--- .../layer_dependency/local_layer_map.json | 35 +++++++++++++++++++ computing/soil_health/soil_health.py | 25 +++++++++++++ 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/computing/api.py b/computing/api.py index a15d4aca..be38a69c 100644 --- a/computing/api.py +++ b/computing/api.py @@ -1094,7 +1094,6 @@ def tree_health_raster(request): tree_health_ccd_raster, tree_health_ccd_raster_local, ) - print("What is task? ", ccd_task) ccd_task.apply_async( kwargs=task_kwargs, @@ -1106,7 +1105,6 @@ def tree_health_raster(request): tree_health_ch_raster, tree_health_ch_raster_local, ) - print("What is task? ", ch_task) ch_task.apply_async( kwargs=task_kwargs, queue="nrm", @@ -1116,7 +1114,6 @@ def tree_health_raster(request): tree_health_overall_change_raster, tree_health_overall_change_raster_local, ) - print("What is task? ", overall_task) overall_task.apply_async( kwargs=task_kwargs, queue="nrm", diff --git a/computing/layer_dependency/layer_generation_in_order.py b/computing/layer_dependency/layer_generation_in_order.py index 7a47f70d..20684455 100644 --- a/computing/layer_dependency/layer_generation_in_order.py +++ b/computing/layer_dependency/layer_generation_in_order.py @@ -32,6 +32,19 @@ from computing.tree_health.gee.overall_change_vector import ( tree_health_overall_change_vector, ) +from computing.tree_health.local.canopy_height_local import tree_health_ch_raster_local +from computing.tree_health.local.canopy_height_vector_local import ( + tree_health_ch_vector_local, +) +from computing.tree_health.local.ccd_local import tree_health_ccd_raster_local +from computing.tree_health.local.ccd_vector_local import tree_health_ccd_vector_local +from computing.tree_health.local.overall_change_local import ( + tree_health_overall_change_raster_local, +) +from computing.tree_health.local.overall_change_vector_local import ( + tree_health_overall_change_vector_local, +) +from computing.soil_health.soil_health import generate_soil_health_local from computing.misc.naturaldepression import generate_natural_depression_data from computing.misc.distancetonearestdrainage import ( generate_distance_to_nearest_drainage_line, @@ -289,6 +302,13 @@ "generate_dem_raster_vector": generate_febdem_raster_vector_clip, "generate_mws_centroid_data": generate_mws_centroid_data_local, "generate_mws_centroid": generate_mws_centroid_data_local, + "tree_health_ch_raster": tree_health_ch_raster_local, + "tree_health_ch_vector": tree_health_ch_vector_local, + "tree_health_ccd_raster": tree_health_ccd_raster_local, + "tree_health_ccd_vector": tree_health_ccd_vector_local, + "tree_health_overall_change_raster": tree_health_overall_change_raster_local, + "tree_health_overall_change_vector": tree_health_overall_change_vector_local, + "soil_health": generate_soil_health_local, } TASK_REGISTRIES = { @@ -604,7 +624,10 @@ def run_node_tree( node_func_name = node["name"] node_func_obj = task_registry[node_func_name] args = get_args( - iterator_name=node, global_args=global_args, gee_account_id=gee_account_id + iterator_name=node, + global_args=global_args, + gee_account_id=gee_account_id, + compute=compute, ) deps = node.get("depends_on", []) run_layer_with_dependency( @@ -686,16 +709,17 @@ def run_layer_with_dependency( logger.exception(f"{node_func_name} raised an error ({log_ctx})") -def get_args(iterator_name, global_args, gee_account_id): +def get_args(iterator_name, global_args, gee_account_id, compute="gee"): """ This function merge the global agrs and local args(define in json maps) return combination of both. """ arg = iterator_name.get("args", {}) - args = {"gee_account_id": gee_account_id, **arg} + args = dict(arg) + if normalize_compute(compute) == "gee": + args["gee_account_id"] = gee_account_id if iterator_name.get("use_global_args", False): args = { **global_args, - "gee_account_id": gee_account_id, **args, } return args diff --git a/computing/layer_dependency/local_layer_map.json b/computing/layer_dependency/local_layer_map.json index 573cc0fc..ea43fcbe 100644 --- a/computing/layer_dependency/local_layer_map.json +++ b/computing/layer_dependency/local_layer_map.json @@ -45,6 +45,41 @@ { "name": "generate_swb", "use_global_args": true + }, + { + "name": "tree_health_ch_raster", + "depends_on": ["lulc_v3"], + "use_global_args": true, + "children": [ + { + "name": "tree_health_ch_vector", + "use_global_args": true + } + ] + }, + { + "name": "tree_health_ccd_raster", + "depends_on": ["lulc_v3"], + "use_global_args": true, + "children": [ + { + "name": "tree_health_ccd_vector", + "use_global_args": true + } + ] + }, + { + "name": "tree_health_overall_change_raster", + "depends_on": ["change_detection"], + "use_global_args": true, + "children": [ + { + "name": "tree_health_overall_change_vector" + } + ] + }, + { + "name": "soil_health" } ], "static_layers": [ diff --git a/computing/soil_health/soil_health.py b/computing/soil_health/soil_health.py index c11cb120..6412d1b6 100644 --- a/computing/soil_health/soil_health.py +++ b/computing/soil_health/soil_health.py @@ -2,6 +2,7 @@ import geopandas as gpd from computing.soil_health.soil_health_helper import nutrient_stats_for_geometries +from nrm_app.celery import app from utilities.gee_utils import valid_gee_text from computing.local_compute_helper import ( PROJECT_ROOT, @@ -102,6 +103,7 @@ def vectorize_soil_health( asset_suffix, roi_gdf = get_roi(asset_suffix, block, district, roi, state) layer_name = f"{asset_suffix}_soil_health" + geoserver_statuses = [] for nutrient in NUTRIENTS: # This produces one output feature per ROI geometry with Nitrogen summary columns. @@ -142,5 +144,28 @@ def vectorize_soil_health( file_type="gpkg", ) print(f"GeoServer response: {geoserver_response}") + geoserver_statuses.append( + isinstance(geoserver_response, dict) + and geoserver_response.get("status_code") in (200, 201) + ) # TODO Add Stac specs + + return all(geoserver_statuses) if push_to_geoserver else True + + +@app.task(bind=True) +def generate_soil_health_local(self, state=None, district=None, block=None): + raster_generated = clip_soil_health_raster( + state=state, + district=district, + block=block, + ) + if not raster_generated: + return False + + return vectorize_soil_health( + state=state, + district=district, + block=block, + ) From f89bcaaffc0e514a6d8784e7f186fb5c1091faa3 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Fri, 24 Jul 2026 09:22:02 +0530 Subject: [PATCH 065/120] soil type pipeline --- computing/config.yaml | 11 + computing/config_loader.py | 19 ++ .../layer_generation_in_order.py | 3 + .../layer_dependency/local_layer_map.json | 3 + computing/soil_type/__init__.py | 1 + computing/soil_type/soil_type_local.py | 295 ++++++++++++++++++ computing/soil_type/tests.py | 112 +++++++ 7 files changed, 444 insertions(+) create mode 100644 computing/soil_type/__init__.py create mode 100644 computing/soil_type/soil_type_local.py create mode 100644 computing/soil_type/tests.py diff --git a/computing/config.yaml b/computing/config.yaml index 0a8c5cf0..75a9bdc6 100644 --- a/computing/config.yaml +++ b/computing/config.yaml @@ -136,6 +136,11 @@ base_layers: source: "" type: file + - name: soil type + local_path: "{DATA_DIR}/base_layers/soil_type/" + source: "" + type: directory + - name: Nrega Layer (Scrapping) aliases: - nrega layer @@ -456,6 +461,12 @@ derived_layers: geoserver_workspace: aquifer_vector layer_type: vector + - name: soil type + filename: soil_type_{district}_{block}.gpkg + local_path: "{DATA_DIR}/layers/soil_type/{state}/{district}/{block}/{filename}" + geoserver_workspace: soil_type + layer_type: vector + - name: generate_swb local_path: "{DATA_DIR}/surface_water_bodies/" geoserver_workspace: surface_water_bodies diff --git a/computing/config_loader.py b/computing/config_loader.py index a42ce011..0c35461b 100644 --- a/computing/config_loader.py +++ b/computing/config_loader.py @@ -167,6 +167,24 @@ def _derived_output_dir(name: str) -> Path: SOI_TEHSIL_PATH: Path = _base_layer_path("admin boundaries") +SOIL_TYPE_BASE_DIR: Path = _base_layer_path("soil type") +SOIL_TYPE_RASTER_PATHS: dict[str, Path] = { + "available_water_capacity": SOIL_TYPE_BASE_DIR / "available_water_capacity.tif", + "soil_drainage_classes": SOIL_TYPE_BASE_DIR / "soil_drainage_classes.tif", + "subsoil_bulk_density": SOIL_TYPE_BASE_DIR / "subsoil_bulk_density.tif", + "subsoil_exchange_capacity": SOIL_TYPE_BASE_DIR + / "subsoil_exchange_capacity.tif", + "subsoil_organic_carbon": SOIL_TYPE_BASE_DIR / "subsoil_organic_carbon.tif", + "subsoil_ph": SOIL_TYPE_BASE_DIR / "subsoil_pH.tif", + "subsoil_texture": SOIL_TYPE_BASE_DIR / "subsoil_texture.tif", + "topsoil_bulk_density": SOIL_TYPE_BASE_DIR / "topsoil_bulk_density.tif", + "topsoil_exchange_capacity": SOIL_TYPE_BASE_DIR + / "topsoil_exchange_capacity.tif", + "topsoil_organic_carbon": SOIL_TYPE_BASE_DIR / "topsoil_organic_carbon.tif", + "topsoil_ph": SOIL_TYPE_BASE_DIR / "topsoil_pH.tif", + "topsoil_texture": SOIL_TYPE_BASE_DIR / "topsoil_texture.tif", +} + ADMIN_BOUNDARY_INPUT_DIR: Path = DATA_DIR / "admin-boundary/input" ADMIN_BOUNDARY_OUTPUT_DIR: Path = DATA_DIR / "admin-boundary/output" VILLAGE_BOUNDARIES_DIR: Path = DATA_DIR / "base_layers/village_boundaries" @@ -200,6 +218,7 @@ def _derived_output_dir(name: str) -> Path: LULC_PLAIN_CLUSTER_OUTPUT_DIR: Path = _derived_output_dir("lulc plain clusters") AQUIFER_VECTOR_OUTPUT_DIR: Path = _derived_output_dir("aquifer vector") SWB_VECTOR_OUTPUT_DIR: Path = _derived_output_dir("generate_swb") +SOIL_TYPE_OUTPUT_DIR: Path = _derived_output_dir("soil type") PAN_INDIA_DRAINAGE_LINES_GPKG_PATH = ( diff --git a/computing/layer_dependency/layer_generation_in_order.py b/computing/layer_dependency/layer_generation_in_order.py index 20684455..c95079b2 100644 --- a/computing/layer_dependency/layer_generation_in_order.py +++ b/computing/layer_dependency/layer_generation_in_order.py @@ -45,6 +45,7 @@ tree_health_overall_change_vector_local, ) from computing.soil_health.soil_health import generate_soil_health_local +from computing.soil_type.soil_type_local import generate_soil_type_local from computing.misc.naturaldepression import generate_natural_depression_data from computing.misc.distancetonearestdrainage import ( generate_distance_to_nearest_drainage_line, @@ -309,6 +310,8 @@ "tree_health_overall_change_raster": tree_health_overall_change_raster_local, "tree_health_overall_change_vector": tree_health_overall_change_vector_local, "soil_health": generate_soil_health_local, + "generate_soil_type": generate_soil_type_local, + "soil_type": generate_soil_type_local, } TASK_REGISTRIES = { diff --git a/computing/layer_dependency/local_layer_map.json b/computing/layer_dependency/local_layer_map.json index ea43fcbe..3a7c2f82 100644 --- a/computing/layer_dependency/local_layer_map.json +++ b/computing/layer_dependency/local_layer_map.json @@ -148,6 +148,9 @@ }, { "name": "generate_mws_centroid" + }, + { + "name": "soil_type" } ] } diff --git a/computing/soil_type/__init__.py b/computing/soil_type/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/computing/soil_type/__init__.py @@ -0,0 +1 @@ + diff --git a/computing/soil_type/soil_type_local.py b/computing/soil_type/soil_type_local.py new file mode 100644 index 00000000..50bb5006 --- /dev/null +++ b/computing/soil_type/soil_type_local.py @@ -0,0 +1,295 @@ +import logging +import os +from pathlib import Path + +import numpy as np +import pandas as pd +import rasterio +from rasterio.mask import mask +from shapely.geometry import mapping + +from computing.config_loader import ( + PRECOMPUTED_TEHSIL_WATERSHED_DIR, + SOIL_TYPE_OUTPUT_DIR, + SOIL_TYPE_RASTER_PATHS, +) +from computing.local_compute_helper import ( + build_output_vector_path, + ensure_file_exists, + load_precomputed_watersheds, + push_local_vector_to_geoserver, + read_validated_vector_file, + write_vector_output, +) +from computing.utils import save_layer_info_to_db, update_layer_sync_status +from nrm_app.celery import app +from utilities.gee_utils import valid_gee_text + +logger = logging.getLogger(__name__) + +GEOSERVER_WORKSPACE = "soil_type" +LOCAL_ALGORITHM = "local_soil_type_zonal_summary" +LOCAL_ALGORITHM_VERSION = "local-1.0" +DATASET_NAME = "Soil Type" + +AVAILABLE_WATER_CAPACITY_CLASSES = { + 1: 150, + 2: 125, + 3: 100, + 4: 75, + 5: 50, + 6: 15, + 7: 0, +} +SOIL_DRAINAGE_CLASSES = { + 0: "Excessively drained", + 1: "Somewhat excessively drained", + 2: "Well drained", + 3: "Moderately well drained", + 4: "Imperfectly drained", + 5: "Poorly drained", + 6: "Very poorly drained", +} +TOPSOIL_TEXTURE_CLASSES = { + 1: "Coarse", + 2: "Medium", + 3: "Fine", +} +SUBSOIL_TEXTURE_CLASSES = { + 1: "Clay (heavy)", + 2: "Silty clay", + 3: "Clay", + 4: "Silty clay loam", + 5: "Clay loam", + 6: "Silt", + 7: "Silt loam", + 8: "Sandy clay", + 9: "Loam", + 10: "Sandy clay loam", + 11: "Sandy loam", + 12: "Loamy sand", + 13: "Sand", +} + +SOIL_PROPERTY_SPECS = ( + { + "column": "available_water_capacity", + "aggregation": "mode", + "mapping": AVAILABLE_WATER_CAPACITY_CLASSES, + "zero_is_nodata": True, + }, + { + "column": "soil_drainage_classes", + "aggregation": "mode", + "mapping": SOIL_DRAINAGE_CLASSES, + "zero_is_nodata": False, + }, + {"column": "subsoil_bulk_density", "aggregation": "mean"}, + {"column": "subsoil_exchange_capacity", "aggregation": "mean"}, + {"column": "subsoil_organic_carbon", "aggregation": "mean"}, + {"column": "subsoil_ph", "aggregation": "mean"}, + { + "column": "subsoil_texture", + "aggregation": "mode", + "mapping": SUBSOIL_TEXTURE_CLASSES, + "zero_is_nodata": True, + }, + {"column": "topsoil_bulk_density", "aggregation": "mean"}, + {"column": "topsoil_exchange_capacity", "aggregation": "mean"}, + {"column": "topsoil_organic_carbon", "aggregation": "mean"}, + {"column": "topsoil_ph", "aggregation": "mean"}, + { + "column": "topsoil_texture", + "aggregation": "mode", + "mapping": TOPSOIL_TEXTURE_CLASSES, + "zero_is_nodata": True, + }, +) + + +def _slug(value, fallback): + return valid_gee_text(str(value).strip().lower()) or fallback + + +def _layer_name(district=None, block=None, asset_suffix=None): + if asset_suffix: + return f"{_slug(asset_suffix, 'custom')}_soil_type" + return ( + f"soil_type_{_slug(district, 'unknown_district')}_" + f"{_slug(block, 'unknown_block')}" + ) + + +def _aggregate_values(values, spec): + values = np.asarray(values, dtype=np.float64) + values = values[np.isfinite(values)] + if spec.get("zero_is_nodata", True): + values = values[values != 0] + if values.size == 0: + return None + + if spec["aggregation"] == "mean": + return round(float(values.mean()), 4) + + class_values = np.rint(values).astype(np.int32) + unique_values, counts = np.unique(class_values, return_counts=True) + mode = int(unique_values[np.argmax(counts)]) + return spec["mapping"].get(mode) + + +def compute_soil_properties_for_geometries( + geometries_gdf, + raster_paths=SOIL_TYPE_RASTER_PATHS, +): + if geometries_gdf.crs is None: + raise ValueError("Input geometry CRS is missing.") + + result = geometries_gdf.copy() + for spec in SOIL_PROPERTY_SPECS: + column = spec["column"] + raster_path = Path(raster_paths[column]) + ensure_file_exists(raster_path, f"Soil property raster '{column}'") + + with rasterio.open(raster_path) as src: + working_gdf = ( + geometries_gdf + if not src.crs or geometries_gdf.crs == src.crs + else geometries_gdf.to_crs(src.crs) + ) + values = [] + for geom in working_gdf.geometry: + if geom is None or geom.is_empty: + values.append(None) + continue + try: + clipped, _ = mask( + src, + [mapping(geom)], + crop=True, + filled=False, + ) + except ValueError: + values.append(None) + continue + + band = clipped[0] + valid = ~np.ma.getmaskarray(band) + data = np.asarray(band, dtype=np.float64) + if src.nodata is not None and np.isfinite(src.nodata): + valid &= data != src.nodata + values.append(_aggregate_values(data[valid], spec)) + + result[column] = pd.Series(values, index=result.index) + logger.info("Computed soil property column '%s'.", column) + + return result + + +def run_soil_type_local( + state=None, + district=None, + block=None, + asset_suffix=None, + roi_path=None, + precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, + raster_paths=SOIL_TYPE_RASTER_PATHS, + output_base_dir=SOIL_TYPE_OUTPUT_DIR, + push_to_geoserver=True, + sync_layer_metadata=True, +): + is_tehsil_run = bool(state and district and block) + if is_tehsil_run: + state = str(state).strip().lower() + district = str(district).strip().lower() + block = str(block).strip().lower() + geometries_gdf, geometry_source = load_precomputed_watersheds( + state=state, + district=district, + block=block, + precomputed_roi_dir=precomputed_roi_dir, + ) + else: + if not roi_path or not asset_suffix: + raise ValueError( + "Custom runs require both `roi_path` and `asset_suffix`." + ) + geometries_gdf = read_validated_vector_file( + roi_path, + f"Custom ROI file has no valid geometries: {roi_path}", + ) + geometry_source = str(roi_path) + + logger.info("Soil type geometry source: %s", geometry_source) + layer_name = _layer_name(district, block, asset_suffix) + result_gdf = compute_soil_properties_for_geometries( + geometries_gdf=geometries_gdf, + raster_paths=raster_paths, + ) + output_path = build_output_vector_path( + layer_name=layer_name, + state=state, + district=district, + block=block, + output_base_dir=output_base_dir, + ) + asset_id = write_vector_output(result_gdf, output_path, layer_name) + logger.info("Saved local soil type vector: %s", asset_id) + + geoserver_ok = False + geoserver_response = None + if push_to_geoserver: + geoserver_response = push_local_vector_to_geoserver( + path=os.path.splitext(asset_id)[0], + workspace=GEOSERVER_WORKSPACE, + layer_name=layer_name, + file_type="gpkg", + ) + geoserver_ok = ( + isinstance(geoserver_response, dict) + and geoserver_response.get("status_code") in (200, 201) + ) + if not geoserver_ok: + logger.error( + "GeoServer upload failed for %s: %s", + layer_name, + geoserver_response, + ) + return False + + if sync_layer_metadata and is_tehsil_run: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name=DATASET_NAME, + misc={ + "is_generated_locally": True, + "geoserver_available": geoserver_ok, + "geoserver_sync_response": geoserver_response, + "source_rasters": [ + str(raster_paths[spec["column"]]) + for spec in SOIL_PROPERTY_SPECS + ], + }, + 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 geoserver_ok if push_to_geoserver else True + + +@app.task(bind=True) +def generate_soil_type_local(self, state=None, district=None, block=None): + _ = self + return run_soil_type_local( + state=state, + district=district, + block=block, + ) diff --git a/computing/soil_type/tests.py b/computing/soil_type/tests.py new file mode 100644 index 00000000..87fda718 --- /dev/null +++ b/computing/soil_type/tests.py @@ -0,0 +1,112 @@ +from unittest import TestCase +from unittest.mock import MagicMock, patch + +import numpy as np + +from computing.soil_type.soil_type_local import ( + _aggregate_values, + run_soil_type_local, +) + + +class SoilTypeAggregationTests(TestCase): + def test_mean_ignores_nan_and_zero_nodata(self): + spec = {"aggregation": "mean"} + self.assertEqual(_aggregate_values([0, 1.0, 2.0, np.nan], spec), 1.5) + + def test_mode_is_decoded(self): + spec = { + "aggregation": "mode", + "mapping": {1: "Coarse", 2: "Medium"}, + "zero_is_nodata": True, + } + self.assertEqual(_aggregate_values([0, 1, 2, 2, np.nan], spec), "Medium") + + def test_drainage_zero_is_a_valid_class(self): + spec = { + "aggregation": "mode", + "mapping": {0: "Excessively drained", 1: "Well drained"}, + "zero_is_nodata": False, + } + self.assertEqual( + _aggregate_values([0, 0, 1, np.nan], spec), + "Excessively drained", + ) + + +class SoilTypePublicationTests(TestCase): + def _run_with_mocks(self, geoserver_status): + geometries = MagicMock() + result = MagicMock() + patchers = ( + patch( + "computing.soil_type.soil_type_local.load_precomputed_watersheds", + return_value=(geometries, "/tmp/watersheds.gpkg"), + ), + patch( + "computing.soil_type.soil_type_local." + "compute_soil_properties_for_geometries", + return_value=result, + ), + patch( + "computing.soil_type.soil_type_local.build_output_vector_path", + return_value="/tmp/soil_type_test.gpkg", + ), + patch( + "computing.soil_type.soil_type_local.write_vector_output", + return_value="/tmp/soil_type_test.gpkg", + ), + patch( + "computing.soil_type.soil_type_local.push_local_vector_to_geoserver", + return_value={"status_code": geoserver_status}, + ), + patch( + "computing.soil_type.soil_type_local.save_layer_info_to_db", + return_value=42, + ), + patch( + "computing.soil_type.soil_type_local.update_layer_sync_status" + ), + ) + mocks = [patcher.start() for patcher in patchers] + self.addCleanup(lambda: [patcher.stop() for patcher in reversed(patchers)]) + + success = run_soil_type_local( + state="Puducherry", + district="Puducherry", + block="Bahur", + ) + return success, mocks + + def test_success_creates_workspace_and_syncs_cloud_metadata(self): + success, mocks = self._run_with_mocks(201) + geoserver_mock = mocks[4] + save_layer_mock = mocks[5] + update_sync_mock = mocks[6] + + self.assertTrue(success) + geoserver_mock.assert_called_once_with( + path="/tmp/soil_type_test", + workspace="soil_type", + layer_name="soil_type_puducherry_bahur", + file_type="gpkg", + ) + self.assertEqual( + save_layer_mock.call_args.kwargs["dataset_name"], + "Soil Type", + ) + self.assertEqual( + save_layer_mock.call_args.kwargs["layer_name"], + "soil_type_puducherry_bahur", + ) + update_sync_mock.assert_called_once_with( + layer_id=42, + sync_to_geoserver=True, + ) + + def test_failed_publication_does_not_update_cloud_metadata(self): + success, mocks = self._run_with_mocks(500) + + self.assertFalse(success) + mocks[5].assert_not_called() + mocks[6].assert_not_called() From 2c8209460ab36cab3800d4815d94c3f623618644 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Fri, 24 Jul 2026 10:46:22 +0530 Subject: [PATCH 066/120] todo soil type on water availability --- computing/soil_type/soil_type_local.py | 1 + 1 file changed, 1 insertion(+) diff --git a/computing/soil_type/soil_type_local.py b/computing/soil_type/soil_type_local.py index 50bb5006..13bf0e9c 100644 --- a/computing/soil_type/soil_type_local.py +++ b/computing/soil_type/soil_type_local.py @@ -32,6 +32,7 @@ LOCAL_ALGORITHM_VERSION = "local-1.0" DATASET_NAME = "Soil Type" +# TODO: water capacity could be a mean instead of dominant AVAILABLE_WATER_CAPACITY_CLASSES = { 1: 150, 2: 125, From 93ef5b0792341673bf53c7752b1b356ab89e2730 Mon Sep 17 00:00:00 2001 From: aman verma Date: Fri, 24 Jul 2026 08:18:27 +0000 Subject: [PATCH 067/120] udpate --- .../spei/high_wind_sensitivity/export_max_wind_index.py | 9 +++------ .../spei/rainfall_sensitivity/export_rainfall_index.py | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/computing/spei/high_wind_sensitivity/export_max_wind_index.py b/computing/spei/high_wind_sensitivity/export_max_wind_index.py index 16c95dff..eacaabbb 100644 --- a/computing/spei/high_wind_sensitivity/export_max_wind_index.py +++ b/computing/spei/high_wind_sensitivity/export_max_wind_index.py @@ -49,7 +49,7 @@ def max_wind_index(aez, start_year=2004, end_year=2022, gee_account_id=None): WIND_THRESHOLD = 10.0 # =========================================================================== - # 3. HOURLY WINDSPEED FROM U/V COMPONENTS + # 1. HOURLY WINDSPEED FROM U/V COMPONENTS # =========================================================================== era5Hourly = ( @@ -74,7 +74,7 @@ def toWindSpeed(img): windSpeedCol = era5Hourly.map(toWindSpeed) # =========================================================================== - # 4. ANNUAL METRICS (Max, Hours > Thresh, Mean > Thresh) + # 2. ANNUAL METRICS (Max, Hours > Thresh, Mean > Thresh) # =========================================================================== years = ee.List.sequence(start_year, end_year) @@ -111,7 +111,7 @@ def annual_metrics(y): annual_ws_metrics = ee.ImageCollection(years.map(annual_metrics)) # =========================================================================== - # 5. STACK INTO SINGLE MULTIBAND IMAGE + # 3. STACK INTO SINGLE MULTIBAND IMAGE # =========================================================================== def add_year_bands(year, image): year = ee.Number(year) @@ -140,7 +140,4 @@ def add_year_bands(year, image): output_image.clip(aoi), OUTPUT_DESC, OUTPUT_ASSET_ID, scale=1000, region=aoi ) - print("✅ Clean pipeline compilation verified.") - print("Ready to execute in the tasks tab. Total structured bands: 95.") - return task_id diff --git a/computing/spei/rainfall_sensitivity/export_rainfall_index.py b/computing/spei/rainfall_sensitivity/export_rainfall_index.py index 7759cb2a..44b533ae 100644 --- a/computing/spei/rainfall_sensitivity/export_rainfall_index.py +++ b/computing/spei/rainfall_sensitivity/export_rainfall_index.py @@ -32,7 +32,7 @@ def rainfall_index(aez, start_year=2004, end_year=None, gee_account_id=None): """ ee_initialize(gee_account_id) - OUTPUT_DESC = f"rain_index_{aez}" # f"rain_index_AEZ_{aez}" + OUTPUT_DESC = f"rain_index_AEZ_{aez}" OUTPUT_ASSET_ID = ( f"projects/corestack-datasets-alpha/assets/datasets/SPEI/{OUTPUT_DESC}" ) From 98be250a8ba563e5366eb98ef429c624752e46df Mon Sep 17 00:00:00 2001 From: amit-spatial Date: Fri, 24 Jul 2026 10:15:23 +0000 Subject: [PATCH 068/120] Match local pipeline admin names --- computing/misc/antyodaya/pipeline.py | 12 +- computing/misc/facilities/pipeline.py | 12 +- computing/misc/livestocks/pipeline.py | 12 +- utilities/pipelines/admin.py | 204 +++++++++++++++++++++++++- 4 files changed, 214 insertions(+), 26 deletions(-) diff --git a/computing/misc/antyodaya/pipeline.py b/computing/misc/antyodaya/pipeline.py index 3e5aa107..243cd7e6 100644 --- a/computing/misc/antyodaya/pipeline.py +++ b/computing/misc/antyodaya/pipeline.py @@ -19,6 +19,7 @@ ADMIN_COLUMN_DESCRIPTIONS, admin_output_frame, admin_presentation_frame, + resolve_registration_scope, ) from utilities.pipelines.outputs import ( OutputBundle, @@ -610,13 +611,10 @@ def run_antyodaya_pipeline( timings["publish_geoserver_seconds"] = round(time.perf_counter() - t0, 3) result["geoserver"] = geoserver if request.publish.register_layers and geoserver and geoserver.get("ok"): - state = request.scope.state_name - district = request.scope.district_name - block = request.scope.tehsil_name - if not (state and district and block): - raise ValueError( - "Layer registration requires state, district, and tehsil names." - ) + registration_scope = resolve_registration_scope(request.scope) + state = registration_scope.state_name + district = registration_scope.district_name + block = registration_scope.tehsil_name dataset_name = output_config.get("dataset_name", "Antyodaya 2020") layer_id = save_layer_info_to_db( state=state, diff --git a/computing/misc/facilities/pipeline.py b/computing/misc/facilities/pipeline.py index 7e847551..6cb1aecd 100644 --- a/computing/misc/facilities/pipeline.py +++ b/computing/misc/facilities/pipeline.py @@ -23,6 +23,7 @@ ADMIN_PRESENTATION_COLUMNS, admin_output_frame, admin_presentation_frame, + resolve_registration_scope, ) from utilities.pipelines.schema import ( STATUS_COMPUTED, @@ -1134,13 +1135,10 @@ def run_facilities_pipeline( } ).as_posix() if request.publish.register_layers and published_layers: - state = request.scope.state_name - district = request.scope.district_name - block = request.scope.tehsil_name - if not (state and district and block): - raise ValueError( - "Layer registration requires state, district, and tehsil names." - ) + registration_scope = resolve_registration_scope(request.scope) + state = registration_scope.state_name + district = registration_scope.district_name + block = registration_scope.tehsil_name registrations: dict[str, dict[str, Any]] = {} for role, published in published_layers.items(): dataset_name = ( diff --git a/computing/misc/livestocks/pipeline.py b/computing/misc/livestocks/pipeline.py index bd2b2d71..731a442a 100644 --- a/computing/misc/livestocks/pipeline.py +++ b/computing/misc/livestocks/pipeline.py @@ -18,6 +18,7 @@ ADMIN_COLUMN_DESCRIPTIONS, admin_output_frame, admin_presentation_frame, + resolve_registration_scope, ) from utilities.pipelines.outputs import ( OutputBundle, @@ -499,13 +500,10 @@ def run_livestocks_pipeline( timings["publish_geoserver_seconds"] = round(time.perf_counter() - t0, 3) result["geoserver"] = geoserver if request.publish.register_layers and geoserver and geoserver.get("ok"): - state = request.scope.state_name - district = request.scope.district_name - block = request.scope.tehsil_name - if not (state and district and block): - raise ValueError( - "Layer registration requires state, district, and tehsil names." - ) + registration_scope = resolve_registration_scope(request.scope) + state = registration_scope.state_name + district = registration_scope.district_name + block = registration_scope.tehsil_name dataset_name = output_config.get("dataset_name", "Livestock Census") layer_id = save_layer_info_to_db( state=state, diff --git a/utilities/pipelines/admin.py b/utilities/pipelines/admin.py index bb51fff6..3001486c 100644 --- a/utilities/pipelines/admin.py +++ b/utilities/pipelines/admin.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import sqlite3 from dataclasses import dataclass, field from pathlib import Path from typing import Any, Sequence @@ -84,10 +85,113 @@ def is_internal_admin_column(column: Any) -> bool: return str(column) in INTERNAL_ADMIN_COLUMNS +def normalize_scope_name(value: Any) -> str | None: + """Normalize API-safe separators without changing an admin name.""" + + if value is None: + return None + text = re.sub(r"_+", " ", str(value).strip()) + return " ".join(text.split()) or None + + def normalize_key(value: Any) -> str: """Normalize an admin name for consistent lowercase lookup.""" - return " ".join(str(value or "").strip().lower().split()) + return (normalize_scope_name(value) or "").lower() + + +def admin_name_match_keys(value: Any) -> frozenset[str]: + """Return conservative comparison keys for common admin-name spellings.""" + + text = normalize_scope_name(value) or "" + variants = [text] + variants.extend(re.findall(r"\(([^()]*)\)", text)) + variants.append(re.sub(r"\([^()]*\)", " ", text)) + keys: set[str] = set() + for variant in variants: + words = re.sub(r"[^0-9a-z]+", " ", variant.lower()).split() + if words: + keys.add(" ".join(words)) + keys.add("".join(words)) + return frozenset(keys) + + +def _unique_admin_name_match( + candidates: Sequence[str], requested: str, label: str +) -> str: + """Resolve one name without fuzzy spelling or word-order matching.""" + + exact = [ + candidate + for candidate in candidates + if normalize_key(candidate) == normalize_key(requested) + ] + if len(exact) == 1: + return exact[0] + requested_keys = admin_name_match_keys(requested) + matches = [ + candidate + for candidate in candidates + if requested_keys & admin_name_match_keys(candidate) + ] + if len(matches) != 1: + raise ValueError( + f"Could not uniquely resolve {label} {requested!r}." + ) + return matches[0] + + +def resolve_registration_scope(scope: "AdminScope") -> "AdminScope": + """Resolve a tehsil scope to the exact names stored in Django.""" + + if not (scope.state_name and scope.district_name and scope.tehsil_name): + raise ValueError( + "Layer registration requires state, district, and tehsil names." + ) + + from geoadmin.models import DistrictSOI, StateSOI, TehsilSOI + + def unique_match(queryset, field: str, requested: str, label: str): + exact = list(queryset.filter(**{f"{field}__iexact": requested})[:2]) + if len(exact) == 1: + return exact[0] + matches = [ + item + for item in queryset + if admin_name_match_keys(getattr(item, field)) + & admin_name_match_keys(requested) + ] + if len(matches) != 1: + raise ValueError( + f"Could not uniquely resolve {label} {requested!r} for layer registration." + ) + return matches[0] + + state = unique_match( + StateSOI.objects.all(), + "state_name", + scope.state_name, + "state", + ) + district = unique_match( + DistrictSOI.objects.filter(state=state), + "district_name", + scope.district_name, + "district", + ) + tehsil = unique_match( + TehsilSOI.objects.filter(district=district), + "tehsil_name", + scope.tehsil_name, + "tehsil", + ) + return AdminScope( + level=scope.level, + state_name=state.state_name, + district_name=district.district_name, + tehsil_name=tehsil.tehsil_name, + village_ids=scope.village_ids, + ) def format_admin_name(value: Any) -> str | None: @@ -206,9 +310,13 @@ def from_mapping(cls, data: dict[str, Any]) -> "AdminScope": return cls( level=str(data.get("level") or data.get("scope_level") or "tehsil").lower(), - state_name=data.get("state_name") or data.get("state"), - district_name=data.get("district_name") or data.get("district"), - tehsil_name=data.get("tehsil_name") or data.get("block_name") or data.get("block"), + state_name=normalize_scope_name(data.get("state_name") or data.get("state")), + district_name=normalize_scope_name( + data.get("district_name") or data.get("district") + ), + tehsil_name=normalize_scope_name( + data.get("tehsil_name") or data.get("block_name") or data.get("block") + ), village_ids=_as_tuple(data.get("village_ids") or data.get("village_id")), ) @@ -287,6 +395,63 @@ def ensure_indexes(self) -> list[str]: return ensure_indexes(self.path, self.required_indexes) + def _resolve_scope_aliases(self, scope: AdminScope) -> AdminScope: + """Resolve conservative punctuation/spacing aliases in the GPKG.""" + + level = scope.level.lower() + if level == "village": + return scope + with sqlite3.connect(self.path) as connection: + table = quote_identifier(self.table_name) + state_names = [ + row[0] + for row in connection.execute( + f"SELECT DISTINCT {quote_identifier('state_name')} " + f"FROM {table}" + ) + if row[0] + ] + state = _unique_admin_name_match( + state_names, scope.state_name or "", "state" + ) + district = scope.district_name + tehsil = scope.tehsil_name + if level in {"district", "tehsil", "block"}: + district_names = [ + row[0] + for row in connection.execute( + f"SELECT DISTINCT {quote_identifier('district_name')} " + f"FROM {table} WHERE {lower_key_expression('state_name')} = ?", + (normalize_key(state),), + ) + if row[0] + ] + district = _unique_admin_name_match( + district_names, district or "", "district" + ) + if level in {"tehsil", "block"}: + tehsil_names = [ + row[0] + for row in connection.execute( + f"SELECT DISTINCT {quote_identifier('TEHSIL')} " + f"FROM {table} " + f"WHERE {lower_key_expression('state_name')} = ? " + f"AND {lower_key_expression('district_name')} = ?", + (normalize_key(state), normalize_key(district)), + ) + if row[0] + ] + tehsil = _unique_admin_name_match( + tehsil_names, tehsil or "", "tehsil" + ) + return AdminScope( + level=scope.level, + state_name=state, + district_name=district, + tehsil_name=tehsil, + village_ids=scope.village_ids, + ) + def _where_for_scope(self, scope: AdminScope) -> tuple[str, tuple[Any, ...]]: level = scope.level.lower() clauses: list[str] = [] @@ -360,5 +525,34 @@ def read_scope( params=params, ) if rows.empty: - raise ValueError(f"No admin rows found for scope: {scope}") + resolved_scope = self._resolve_scope_aliases(scope) + resolved_where, resolved_params = self._where_for_scope( + resolved_scope + ) + if include_geometry: + rows = read_features( + self.path, + self.table_name, + columns=read_columns, + where=resolved_where, + params=resolved_params, + geometry_column_name=ADMIN_GEOMETRY_COLUMN, + ) + else: + rows = read_table( + self.path, + self.table_name, + columns=read_columns, + where=resolved_where, + params=resolved_params, + ) + if rows.empty: + raise ValueError(f"No admin rows found for scope: {scope}") + return AdminSelection( + resolved_scope, + rows, + created_indexes, + resolved_where, + resolved_params, + ) return AdminSelection(scope, rows, created_indexes, where, params) From 70ac674125a44d04a3ceb208e740261b3791a5db Mon Sep 17 00:00:00 2001 From: amit-spatial Date: Sat, 25 Jul 2026 20:39:50 +0000 Subject: [PATCH 069/120] Fix local pipeline layer names --- computing/misc/antyodaya/pipeline.py | 29 +++++++++-------- computing/misc/facilities/pipeline.py | 24 +++++++------- computing/misc/livestocks/pipeline.py | 28 ++++++++-------- utilities/pipelines/outputs.py | 46 ++++++++++++++++++++++++--- 4 files changed, 85 insertions(+), 42 deletions(-) diff --git a/computing/misc/antyodaya/pipeline.py b/computing/misc/antyodaya/pipeline.py index 243cd7e6..20caff4d 100644 --- a/computing/misc/antyodaya/pipeline.py +++ b/computing/misc/antyodaya/pipeline.py @@ -26,6 +26,7 @@ column_dictionary, frame_profile, input_signatures, + resolved_scope_output_identity, slug, stable_hash, utc_now_text, @@ -55,7 +56,7 @@ CONFIG_PATH = Path(__file__).with_name("antyodaya_pipeline.yaml") ALGORITHM = "local-antyodaya-csv-admin-join" -ALGORITHM_VERSION = "2.0" +ALGORITHM_VERSION = "2.1" SOURCE_DEFAULTS = { "admin_gpkg": ADMIN_BOUNDARY_GPKG, "csv": ANTYODAYA_2020_CSV, @@ -84,10 +85,6 @@ def _apply_source_defaults(config: Mapping[str, Any]) -> dict[str, Any]: return resolved -def _layer_name(prefix: str, district: str | None, tehsil: str | None) -> str: - return f"{prefix}_{slug(district)}_{slug(tehsil)}".strip("_") - - def _cli_request(state: str, district: str, tehsil: str, sync_to_geoserver: bool = True) -> StandardRequest: return StandardRequest.from_mapping( { @@ -474,9 +471,19 @@ def run_antyodaya_pipeline( outputs = resolve_output_options(request, config) columns = _source_columns(config) output_config = config["output"] - layer_name = _layer_name(output_config["layer_prefix"], request.scope.district_name, request.scope.tehsil_name) - result_name = layer_name or f"{output_config['layer_prefix']}_{slug(request.scope.level)}" - output_root = _repo_path(output_config["root"]) / slug(request.scope.state_name) / slug(request.scope.district_name) / slug(request.scope.tehsil_name) + + t0 = time.perf_counter() + admin_source = CSAdminSource(_repo_path(config["sources"]["admin_gpkg"]), table_name=config["sources"]["admin_layer"]) + include_geometry = outputs.gpkg or request.publish.sync_to_geoserver + admin_selection, output_parts, result_name = resolved_scope_output_identity( + admin_source, + output_config["layer_prefix"], + AdminScope.from_mapping(asdict(request.scope)), + include_geometry=include_geometry, + ) + timings["read_admin_seconds"] = round(time.perf_counter() - t0, 3) + + output_root = _repo_path(output_config["root"]).joinpath(*output_parts) bundle = OutputBundle( output_root, result_name, @@ -495,12 +502,6 @@ def run_antyodaya_pipeline( cached["cache_hit"] = True return cached - t0 = time.perf_counter() - admin_source = CSAdminSource(_repo_path(config["sources"]["admin_gpkg"]), table_name=config["sources"]["admin_layer"]) - include_geometry = outputs.gpkg or request.publish.sync_to_geoserver - admin_selection = admin_source.read_scope(AdminScope.from_mapping(asdict(request.scope)), include_geometry=include_geometry) - timings["read_admin_seconds"] = round(time.perf_counter() - t0, 3) - t0 = time.perf_counter() sidecar = _sidecar(config, columns) sidecar_status = sidecar.materialize() diff --git a/computing/misc/facilities/pipeline.py b/computing/misc/facilities/pipeline.py index 6cb1aecd..a8977d6c 100644 --- a/computing/misc/facilities/pipeline.py +++ b/computing/misc/facilities/pipeline.py @@ -47,7 +47,7 @@ frame_profile, input_signatures, mark_cached_result, - scope_output_identity, + resolved_scope_output_identity, slug, stable_hash, utc_now_text, @@ -67,7 +67,7 @@ CONFIG_PATH = Path(__file__).with_name("facilities_pipeline.yaml") ALGORITHM = "local-facilities-live-proximity" -ALGORITHM_VERSION = "2.0" +ALGORITHM_VERSION = "2.1" SOURCE_DEFAULTS = { "admin_gpkg": ADMIN_BOUNDARY_GPKG, "facilities_gpkg": FACILITIES_GPKG, @@ -93,10 +93,6 @@ def _apply_source_defaults(config: Mapping[str, Any]) -> dict[str, Any]: return resolved -def _layer_name(prefix: str, district: str | None, tehsil: str | None) -> str: - return f"{prefix}_{slug(district)}_{slug(tehsil)}".strip("_") - - def _cli_request(state: str, district: str, tehsil: str, sync_to_geoserver: bool = True) -> StandardRequest: return StandardRequest.from_mapping( { @@ -902,7 +898,17 @@ def run_facilities_pipeline( config = _apply_source_defaults(load_config(config_path)) outputs = resolve_output_options(request, config) output_config = config["output"] - output_parts, layer_name = scope_output_identity(output_config["layer_prefix"], request.scope) + + t0 = time.perf_counter() + admin_source = CSAdminSource(_repo_path(config["sources"]["admin_gpkg"]), table_name=config["sources"]["admin_layer"]) + admin_selection, output_parts, layer_name = resolved_scope_output_identity( + admin_source, + output_config["layer_prefix"], + AdminScope.from_mapping(asdict(request.scope)), + include_geometry=True, + ) + timings["read_admin_seconds"] = round(time.perf_counter() - t0, 3) + output_root = _repo_path(output_config["root"]).joinpath(*output_parts) bundle = OutputBundle( output_root, @@ -925,13 +931,9 @@ def run_facilities_pipeline( created_facility_indexes = _ensure_facility_indexes(config) timings["ensure_facility_indexes_seconds"] = round(time.perf_counter() - t0, 3) - t0 = time.perf_counter() - admin_source = CSAdminSource(_repo_path(config["sources"]["admin_gpkg"]), table_name=config["sources"]["admin_layer"]) - admin_selection = admin_source.read_scope(AdminScope.from_mapping(asdict(request.scope)), include_geometry=True) admin_rows = admin_selection.rows bounds = _bbox(admin_rows) village_points = _village_points(admin_rows) - timings["read_admin_seconds"] = round(time.perf_counter() - t0, 3) t0 = time.perf_counter() classification = _classification(config) diff --git a/computing/misc/livestocks/pipeline.py b/computing/misc/livestocks/pipeline.py index 731a442a..a7d370c6 100644 --- a/computing/misc/livestocks/pipeline.py +++ b/computing/misc/livestocks/pipeline.py @@ -25,6 +25,7 @@ column_dictionary, frame_profile, input_signatures, + resolved_scope_output_identity, slug, stable_hash, utc_now_text, @@ -52,7 +53,7 @@ CONFIG_PATH = Path(__file__).with_name("livestocks_pipeline.yaml") ALGORITHM = "local-livestock-csv-admin-join" -ALGORITHM_VERSION = "2.0" +ALGORITHM_VERSION = "2.1" SOURCE_DEFAULTS = { "admin_gpkg": ADMIN_BOUNDARY_GPKG, "csv": LIVESTOCK_CENSUS_20_CSV, @@ -81,10 +82,6 @@ def _apply_source_defaults(config: Mapping[str, Any]) -> dict[str, Any]: return resolved -def _layer_name(prefix: str, district: str | None, tehsil: str | None) -> str: - return f"{prefix}_{slug(district)}_{slug(tehsil)}".strip("_") - - def _cli_request(state: str, district: str, tehsil: str, sync_to_geoserver: bool = True) -> StandardRequest: return StandardRequest.from_mapping( { @@ -375,8 +372,19 @@ def run_livestocks_pipeline( schema = _schema(config) columns = _source_columns(config) output_config = config["output"] - layer_name = _layer_name(output_config["layer_prefix"], request.scope.district_name, request.scope.tehsil_name) - output_root = _repo_path(output_config["root"]) / slug(request.scope.state_name) / slug(request.scope.district_name) / slug(request.scope.tehsil_name) + + t0 = time.perf_counter() + admin_source = CSAdminSource(_repo_path(config["sources"]["admin_gpkg"]), table_name=config["sources"]["admin_layer"]) + include_geometry = outputs.gpkg or request.publish.sync_to_geoserver + admin_selection, output_parts, layer_name = resolved_scope_output_identity( + admin_source, + output_config["layer_prefix"], + AdminScope.from_mapping(asdict(request.scope)), + include_geometry=include_geometry, + ) + timings["read_admin_seconds"] = round(time.perf_counter() - t0, 3) + + output_root = _repo_path(output_config["root"]).joinpath(*output_parts) bundle = OutputBundle( output_root, layer_name, @@ -395,12 +403,6 @@ def run_livestocks_pipeline( cached["cache_hit"] = True return cached - t0 = time.perf_counter() - admin_source = CSAdminSource(_repo_path(config["sources"]["admin_gpkg"]), table_name=config["sources"]["admin_layer"]) - include_geometry = outputs.gpkg or request.publish.sync_to_geoserver - admin_selection = admin_source.read_scope(AdminScope.from_mapping(asdict(request.scope)), include_geometry=include_geometry) - timings["read_admin_seconds"] = round(time.perf_counter() - t0, 3) - t0 = time.perf_counter() sidecar = _sidecar(config) sidecar_status = sidecar.materialize() diff --git a/utilities/pipelines/outputs.py b/utilities/pipelines/outputs.py index 3381d42a..ae298f41 100644 --- a/utilities/pipelines/outputs.py +++ b/utilities/pipelines/outputs.py @@ -23,6 +23,15 @@ def slug(value: Any) -> str: return re.sub(r"[^a-z0-9]+", "_", str(value or "").lower()).strip("_") +def layer_slug(value: Any) -> str: + """Return the established Core Stack spelling for a layer-name part.""" + + import re + + text = re.sub(r"[^a-z0-9 ,:;_-]", "", str(value or "").lower()) + return text.replace(" ", "_") + + def utc_now_text() -> str: """Return an ISO UTC timestamp without microseconds.""" @@ -43,18 +52,47 @@ def scope_output_identity(prefix: str, scope: Any) -> tuple[tuple[str, ...], str state = slug(getattr(scope, "state_name", None)) district = slug(getattr(scope, "district_name", None)) tehsil = slug(getattr(scope, "tehsil_name", None)) + layer_state = layer_slug(getattr(scope, "state_name", None)) + layer_district = layer_slug(getattr(scope, "district_name", None)) + layer_tehsil = layer_slug(getattr(scope, "tehsil_name", None)) + layer_prefix = layer_slug(prefix) if level == "state": parts = tuple(part for part in (state,) if part) - return parts, "_".join(part for part in (prefix, state) if part) + return parts, "_".join( + part for part in (layer_prefix, layer_state) if part + ) if level == "district": parts = tuple(part for part in (state, district) if part) - return parts, "_".join(part for part in (prefix, district) if part) + return parts, "_".join( + part for part in (layer_prefix, layer_district) if part + ) if level == "village": village_ids = tuple(str(value) for value in (getattr(scope, "village_ids", None) or ())) digest = stable_hash({"village_ids": village_ids})[:10] if village_ids else "unknown" - return ("village", digest), f"{prefix}_village_{digest}" + return ("village", digest), f"{layer_prefix}_village_{digest}" parts = tuple(part for part in (state, district, tehsil) if part) - return parts, "_".join(part for part in (prefix, district, tehsil) if part) + return parts, "_".join( + part + for part in (layer_prefix, layer_district, layer_tehsil) + if part + ) + + +def resolved_scope_output_identity( + admin_source: Any, + prefix: str, + scope: Any, + *, + include_geometry: bool, +) -> tuple[Any, tuple[str, ...], str]: + """Read a scope, then derive output identity from its canonical names.""" + + selection = admin_source.read_scope( + scope, + include_geometry=include_geometry, + ) + output_parts, layer_name = scope_output_identity(prefix, selection.scope) + return selection, output_parts, layer_name def mark_cached_result(result: Mapping[str, Any], started: float) -> dict[str, Any]: From cc6f4fac4a2791b2c858a6beaf5fb4c26d10cf60 Mon Sep 17 00:00:00 2001 From: amit-spatial Date: Sun, 26 Jul 2026 09:53:36 +0000 Subject: [PATCH 070/120] Match local layer names to registration scope --- computing/misc/antyodaya/pipeline.py | 9 ++++++--- computing/misc/facilities/pipeline.py | 9 ++++++--- computing/misc/livestocks/pipeline.py | 9 ++++++--- utilities/pipelines/outputs.py | 21 ++++++++++++++------- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/computing/misc/antyodaya/pipeline.py b/computing/misc/antyodaya/pipeline.py index 20caff4d..b3daf1c6 100644 --- a/computing/misc/antyodaya/pipeline.py +++ b/computing/misc/antyodaya/pipeline.py @@ -19,7 +19,6 @@ ADMIN_COLUMN_DESCRIPTIONS, admin_output_frame, admin_presentation_frame, - resolve_registration_scope, ) from utilities.pipelines.outputs import ( OutputBundle, @@ -475,7 +474,12 @@ def run_antyodaya_pipeline( t0 = time.perf_counter() admin_source = CSAdminSource(_repo_path(config["sources"]["admin_gpkg"]), table_name=config["sources"]["admin_layer"]) include_geometry = outputs.gpkg or request.publish.sync_to_geoserver - admin_selection, output_parts, result_name = resolved_scope_output_identity( + ( + admin_selection, + registration_scope, + output_parts, + result_name, + ) = resolved_scope_output_identity( admin_source, output_config["layer_prefix"], AdminScope.from_mapping(asdict(request.scope)), @@ -612,7 +616,6 @@ def run_antyodaya_pipeline( timings["publish_geoserver_seconds"] = round(time.perf_counter() - t0, 3) result["geoserver"] = geoserver if request.publish.register_layers and geoserver and geoserver.get("ok"): - registration_scope = resolve_registration_scope(request.scope) state = registration_scope.state_name district = registration_scope.district_name block = registration_scope.tehsil_name diff --git a/computing/misc/facilities/pipeline.py b/computing/misc/facilities/pipeline.py index a8977d6c..8dd86f81 100644 --- a/computing/misc/facilities/pipeline.py +++ b/computing/misc/facilities/pipeline.py @@ -23,7 +23,6 @@ ADMIN_PRESENTATION_COLUMNS, admin_output_frame, admin_presentation_frame, - resolve_registration_scope, ) from utilities.pipelines.schema import ( STATUS_COMPUTED, @@ -901,7 +900,12 @@ def run_facilities_pipeline( t0 = time.perf_counter() admin_source = CSAdminSource(_repo_path(config["sources"]["admin_gpkg"]), table_name=config["sources"]["admin_layer"]) - admin_selection, output_parts, layer_name = resolved_scope_output_identity( + ( + admin_selection, + registration_scope, + output_parts, + layer_name, + ) = resolved_scope_output_identity( admin_source, output_config["layer_prefix"], AdminScope.from_mapping(asdict(request.scope)), @@ -1137,7 +1141,6 @@ def run_facilities_pipeline( } ).as_posix() if request.publish.register_layers and published_layers: - registration_scope = resolve_registration_scope(request.scope) state = registration_scope.state_name district = registration_scope.district_name block = registration_scope.tehsil_name diff --git a/computing/misc/livestocks/pipeline.py b/computing/misc/livestocks/pipeline.py index a7d370c6..a62bae05 100644 --- a/computing/misc/livestocks/pipeline.py +++ b/computing/misc/livestocks/pipeline.py @@ -18,7 +18,6 @@ ADMIN_COLUMN_DESCRIPTIONS, admin_output_frame, admin_presentation_frame, - resolve_registration_scope, ) from utilities.pipelines.outputs import ( OutputBundle, @@ -376,7 +375,12 @@ def run_livestocks_pipeline( t0 = time.perf_counter() admin_source = CSAdminSource(_repo_path(config["sources"]["admin_gpkg"]), table_name=config["sources"]["admin_layer"]) include_geometry = outputs.gpkg or request.publish.sync_to_geoserver - admin_selection, output_parts, layer_name = resolved_scope_output_identity( + ( + admin_selection, + registration_scope, + output_parts, + layer_name, + ) = resolved_scope_output_identity( admin_source, output_config["layer_prefix"], AdminScope.from_mapping(asdict(request.scope)), @@ -502,7 +506,6 @@ def run_livestocks_pipeline( timings["publish_geoserver_seconds"] = round(time.perf_counter() - t0, 3) result["geoserver"] = geoserver if request.publish.register_layers and geoserver and geoserver.get("ok"): - registration_scope = resolve_registration_scope(request.scope) state = registration_scope.state_name district = registration_scope.district_name block = registration_scope.tehsil_name diff --git a/utilities/pipelines/outputs.py b/utilities/pipelines/outputs.py index ae298f41..8068521e 100644 --- a/utilities/pipelines/outputs.py +++ b/utilities/pipelines/outputs.py @@ -26,10 +26,9 @@ def slug(value: Any) -> str: def layer_slug(value: Any) -> str: """Return the established Core Stack spelling for a layer-name part.""" - import re + from utilities.gee_utils import valid_gee_text - text = re.sub(r"[^a-z0-9 ,:;_-]", "", str(value or "").lower()) - return text.replace(" ", "_") + return valid_gee_text(str(value or "").lower()) def utc_now_text() -> str: @@ -84,15 +83,23 @@ def resolved_scope_output_identity( scope: Any, *, include_geometry: bool, -) -> tuple[Any, tuple[str, ...], str]: - """Read a scope, then derive output identity from its canonical names.""" +) -> tuple[Any, Any, tuple[str, ...], str]: + """Read a scope and derive its output and DB-compatible layer identity.""" + + from .admin import resolve_registration_scope selection = admin_source.read_scope( scope, include_geometry=include_geometry, ) - output_parts, layer_name = scope_output_identity(prefix, selection.scope) - return selection, output_parts, layer_name + output_parts, _ = scope_output_identity(prefix, selection.scope) + naming_scope = ( + resolve_registration_scope(scope) + if str(getattr(scope, "level", "") or "").lower() == "tehsil" + else selection.scope + ) + _, layer_name = scope_output_identity(prefix, naming_scope) + return selection, naming_scope, output_parts, layer_name def mark_cached_result(result: Mapping[str, Any], started: float) -> dict[str, Any]: From d80566748965c150f04376a10c50b03346b64627 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Mon, 27 Jul 2026 16:09:12 +0530 Subject: [PATCH 071/120] config update --- computing/config.yaml | 6 +++--- computing/layer_dependency/local_end_year_rules.json | 9 ++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/computing/config.yaml b/computing/config.yaml index 75a9bdc6..d1d1b46d 100644 --- a/computing/config.yaml +++ b/computing/config.yaml @@ -131,12 +131,12 @@ base_layers: source: "" type: file - - name: soil health + - name: soil_health local_path: "{DATA_DIR}/base_layers/soil_health/soil_health.tif" source: "" type: file - - name: soil type + - name: soil_type local_path: "{DATA_DIR}/base_layers/soil_type/" source: "" type: directory @@ -161,7 +161,7 @@ base_layers: - name: Agroecological Natural Farming aliases: - aez - local_path: "{DATA_DIR}/base_layers/AEZs/Agro_Ecological_Regions.shp" + local_path: "{DATA_DIR}/base_layers/AEZ_GeoJSON.geojson" source: s3://corestack-datasets/base_layers/AEZs/Agro_Ecological_Regions.shp type: file diff --git a/computing/layer_dependency/local_end_year_rules.json b/computing/layer_dependency/local_end_year_rules.json index 0967ef42..9185d5c4 100644 --- a/computing/layer_dependency/local_end_year_rules.json +++ b/computing/layer_dependency/local_end_year_rules.json @@ -1 +1,8 @@ -{} +{ + "tree_health_ch_raster": 2023, + "tree_health_ch_vector": 2023, + "tree_health_ccd_raster": 2023, + "tree_health_ccd_vector": 2023, + "tree_health_overall_change_raster": 2023, + "tree_health_overall_change_vector": 2023 +} From e6deebf837ef7bbd399e4f5b29e0d7b3e4686a87 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Tue, 28 Jul 2026 16:22:16 +0530 Subject: [PATCH 072/120] mws connectivity --- computing/mws/mws_connectivity_local_compute.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/computing/mws/mws_connectivity_local_compute.py b/computing/mws/mws_connectivity_local_compute.py index 79dbfc49..f589b010 100644 --- a/computing/mws/mws_connectivity_local_compute.py +++ b/computing/mws/mws_connectivity_local_compute.py @@ -55,9 +55,6 @@ def _compute_mws_connectivity_for_watersheds(watersheds_gdf, mws_gdf): @app.task(bind=True) def mws_connectivity_vector( self, - asset_folder_list=None, - app_type=None, - gee_account_id=None, state=None, district=None, block=None, @@ -139,4 +136,4 @@ def mws_connectivity_vector( update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) print("Sync to GeoServer flag updated for MWS connectivity vector") - return True \ No newline at end of file + return True From 5a3328acc84df9b8b8718dd56007a659699929bc Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Tue, 28 Jul 2026 16:37:19 +0530 Subject: [PATCH 073/120] misc updated --- computing/cropping_intensity/cropping_intesity_local.py | 1 + computing/terrain_descriptor/terrain_clusters_local.py | 1 + computing/terrain_descriptor/terrain_raster_fabdem_local.py | 1 + 3 files changed, 3 insertions(+) diff --git a/computing/cropping_intensity/cropping_intesity_local.py b/computing/cropping_intensity/cropping_intesity_local.py index fddd5f62..5b53b587 100644 --- a/computing/cropping_intensity/cropping_intesity_local.py +++ b/computing/cropping_intensity/cropping_intesity_local.py @@ -242,6 +242,7 @@ def run_cropping_intensity_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/terrain_descriptor/terrain_clusters_local.py b/computing/terrain_descriptor/terrain_clusters_local.py index bc788982..ae335477 100644 --- a/computing/terrain_descriptor/terrain_clusters_local.py +++ b/computing/terrain_descriptor/terrain_clusters_local.py @@ -119,6 +119,7 @@ def run_terrain_clusters_local( layer_name=layer_name, asset_id=local_vector_path, dataset_name="Terrain Vector", + misc={"is_generated_locally": True}, algorithm="FABDEM", algorithm_version="2.0", ) diff --git a/computing/terrain_descriptor/terrain_raster_fabdem_local.py b/computing/terrain_descriptor/terrain_raster_fabdem_local.py index 2d4d253e..f8f9bd1f 100644 --- a/computing/terrain_descriptor/terrain_raster_fabdem_local.py +++ b/computing/terrain_descriptor/terrain_raster_fabdem_local.py @@ -90,6 +90,7 @@ def run_terrain_raster_fabdem_local( layer_name=layer_name, asset_id=clipped_raster_path, dataset_name="Terrain Raster", + misc={"is_generated_locally": True}, algorithm="FABDEM", algorithm_version="2.0", ) From c5ada95b5628b2f981bc614c9475db0b93f663a4 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Tue, 28 Jul 2026 21:30:29 +0530 Subject: [PATCH 074/120] swb3 and swb4 then swb layer to geoserver --- .../layer_generation_in_order.py | 4 +- .../layer_dependency/local_layer_map.json | 3 +- computing/surface_water_bodies/swb3.py | 13 +- computing/surface_water_bodies/swb_local.py | 173 +++++++++++++++++- computing/tests.py | 94 +++++++++- 5 files changed, 276 insertions(+), 11 deletions(-) diff --git a/computing/layer_dependency/layer_generation_in_order.py b/computing/layer_dependency/layer_generation_in_order.py index a64a66c6..72ae32fd 100644 --- a/computing/layer_dependency/layer_generation_in_order.py +++ b/computing/layer_dependency/layer_generation_in_order.py @@ -720,7 +720,9 @@ def get_args(iterator_name, global_args, gee_account_id, compute="gee"): """ arg = iterator_name.get("args", {}) args = dict(arg) - if normalize_compute(compute) == "gee": + if normalize_compute(compute) == "gee" or iterator_name.get( + "pass_gee_account_id", False + ): args["gee_account_id"] = gee_account_id if iterator_name.get("use_global_args", False): args = { diff --git a/computing/layer_dependency/local_layer_map.json b/computing/layer_dependency/local_layer_map.json index 3a7c2f82..92a4fab5 100644 --- a/computing/layer_dependency/local_layer_map.json +++ b/computing/layer_dependency/local_layer_map.json @@ -44,7 +44,8 @@ }, { "name": "generate_swb", - "use_global_args": true + "use_global_args": true, + "pass_gee_account_id": true }, { "name": "tree_health_ch_raster", diff --git a/computing/surface_water_bodies/swb3.py b/computing/surface_water_bodies/swb3.py index 2f3ea7d8..c20af38e 100644 --- a/computing/surface_water_bodies/swb3.py +++ b/computing/surface_water_bodies/swb3.py @@ -232,6 +232,8 @@ def waterbody_catchment_streamorder_properties( river_asset_id=DEFAULT_PAN_INDIA_RIVER_ASSET, canal_asset_id=DEFAULT_PAN_INDIA_CANAL_ASSET, waterbody_type_buffer_m=DEFAULT_WATERBODY_TYPE_BUFFER_M, + supporting_asset_suffix=None, + swb2_asset_suffix=None, ): print(f"asset suffix swb3: {asset_suffix}") print(f"[SWB4] river_asset_id: {river_asset_id}") @@ -245,12 +247,17 @@ def waterbody_catchment_streamorder_properties( ) + description ) + if is_gee_asset_exists(asset_id): + return None, asset_id + + supporting_asset_suffix = supporting_asset_suffix or asset_suffix + swb2_asset_suffix = swb2_asset_suffix or asset_suffix swb2_asset = ( get_gee_dir_path( asset_folder_list, asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"] ) + "swb2_" - + asset_suffix + + swb2_asset_suffix ) # As requested: SWB3 always uses SWB2 as input. water_bodies = ee.FeatureCollection(swb2_asset) @@ -258,7 +265,7 @@ def waterbody_catchment_streamorder_properties( print(f"asset_i{water_bodies}") swb4_fs = generate_swb_layer_with_max_so_catchment( roi=water_bodies, - asset_suffix=asset_suffix, + asset_suffix=supporting_asset_suffix, asset_folder=asset_folder_list, app_type=app_type, gee_account_id=gee_account_id, @@ -268,7 +275,7 @@ def waterbody_catchment_streamorder_properties( asset_folder_list, asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"] ) + "drainage_lines_" - + asset_suffix + + supporting_asset_suffix ) swb4_fs_on_drainage = add_on_drainage_flag(swb4_fs, asset_id_dl) swb4_fc_with_waterbody_type = add_waterbody_type_flag( diff --git a/computing/surface_water_bodies/swb_local.py b/computing/surface_water_bodies/swb_local.py index 41979df6..be327a30 100644 --- a/computing/surface_water_bodies/swb_local.py +++ b/computing/surface_water_bodies/swb_local.py @@ -16,9 +16,14 @@ write_vector_output, ) from computing.surface_water_bodies.clip_swb_local import _clip_gdf, _to_geom +from computing.surface_water_bodies.swb3 import ( + waterbody_catchment_streamorder_properties, +) +from computing.surface_water_bodies.swb4 import waterbody_wbc_intersection from computing.utils import ( push_shape_to_geoserver, save_layer_info_to_db, + sync_fc_to_geoserver, update_layer_sync_status, ) from nrm_app.celery import app @@ -106,6 +111,10 @@ def _layer_name(asset_suffix): return f"surface_waterbodies_{asset_suffix}_local" +def _final_layer_name(asset_suffix): + return f"surface_waterbodies_{asset_suffix}" + + def _gee_description(asset_suffix): return f"swb2_{asset_suffix}_local" @@ -272,6 +281,131 @@ def _push_local_swb_to_geoserver(output_path, layer_name): return bool(geoserver_response) and geoserver_response.get("status_code") in (200, 201) +def _continue_swb_in_gee( + state, + asset_suffix, + asset_folder_list, + app_type, + gee_account_id, + roi, +): + swb2_asset_suffix = f"{asset_suffix}_local" + swb3_task_id, swb3_asset_id = waterbody_catchment_streamorder_properties( + roi=roi, + asset_suffix=asset_suffix, + asset_folder_list=asset_folder_list, + app_type=app_type, + gee_account_id=gee_account_id, + supporting_asset_suffix=asset_suffix, + swb2_asset_suffix=swb2_asset_suffix, + ) + if swb3_task_id: + check_task_status([swb3_task_id]) + if not is_gee_asset_exists(swb3_asset_id): + raise RuntimeError(f"SWB3 GEE asset was not created: {swb3_asset_id}") + + swb4_task_id, swb4_asset_id = waterbody_wbc_intersection( + roi=roi, + state=state, + asset_suffix=asset_suffix, + asset_folder_list=asset_folder_list, + app_type=app_type, + ) + if swb4_task_id: + check_task_status([swb4_task_id]) + if not is_gee_asset_exists(swb4_asset_id): + raise RuntimeError(f"SWB4 GEE asset was not created: {swb4_asset_id}") + + make_asset_public(swb3_asset_id) + make_asset_public(swb4_asset_id) + return asset_suffix, swb3_asset_id + + +def _sync_final_swb( + state, + district, + block, + layer_name, + asset_suffix, + asset_id, + start_year, + end_year, + push_to_geoserver, + sync_layer_metadata, +): + layer_id = None + if sync_layer_metadata: + misc = { + "is_generated_locally": True, + "source_stage": "swb3_gee_from_swb2_local", + } + if start_year is not None: + misc["start_year"] = start_year + if end_year is not None: + misc["end_year"] = end_year + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name=DATASET_NAME, + misc=misc, + algorithm=LOCAL_ALGORITHM, + algorithm_version=LOCAL_ALGORITHM_VERSION, + ) + + if not push_to_geoserver: + return True + + response = sync_fc_to_geoserver( + ee.FeatureCollection(asset_id), + asset_suffix, + layer_name, + workspace=GEOSERVER_WORKSPACE, + ) + synced = bool(response) and response.get("status_code") in (200, 201) + if synced and layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + return synced + + +def _complete_swb_pipeline( + state, + district, + block, + asset_suffix, + asset_folder_list, + app_type, + gee_account_id, + roi, + start_year, + end_year, + push_to_geoserver, + sync_layer_metadata, +): + final_asset_suffix, final_asset_id = _continue_swb_in_gee( + state=state, + asset_suffix=asset_suffix, + asset_folder_list=asset_folder_list, + app_type=app_type, + gee_account_id=gee_account_id, + roi=roi, + ) + return _sync_final_swb( + state=state, + district=district, + block=block, + layer_name=_final_layer_name(asset_suffix), + asset_suffix=final_asset_suffix, + asset_id=final_asset_id, + start_year=start_year, + end_year=end_year, + push_to_geoserver=push_to_geoserver, + sync_layer_metadata=sync_layer_metadata, + ) + + def run_swb_local( state=None, district=None, @@ -284,6 +418,8 @@ def run_swb_local( sync_layer_metadata=True, gee_account_id=None, app_type="MWS", + start_year=None, + end_year=None, ): state = str(state).strip().lower() if state else None district = str(district).strip().lower() if district else None @@ -337,6 +473,9 @@ def run_swb_local( ) logger.info("Local SWB output path: %s", output_path) + ee_initialize(gee_account_id) + gee_roi = gdf_to_ee_fc(_prepare_gdf_for_gee(roi_gdf[["geometry"]])) + if is_gee_asset_exists(gee_asset_id): logger.info("GEE asset already exists, reusing: %s", gee_asset_id) layer_id = None @@ -361,7 +500,20 @@ def run_swb_local( if layer_at_geoserver and layer_id: update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) logger.info("Sync to GeoServer flag updated for existing local SWB layer") - return True + return _complete_swb_pipeline( + state=state, + district=district, + block=block, + asset_suffix=asset_suffix, + asset_folder_list=asset_folder_list, + app_type=app_type, + gee_account_id=gee_account_id, + roi=gee_roi, + start_year=start_year, + end_year=end_year, + push_to_geoserver=push_to_geoserver, + sync_layer_metadata=sync_layer_metadata, + ) clipped_gdf = _clip_gdf(_resolve_source_path(swb_path), roi_geometry) if clipped_gdf.empty: @@ -384,7 +536,6 @@ def run_swb_local( ) logger.info("Saved local SWB vector to disk: %s", local_asset_path) - ee_initialize(gee_account_id) logger.info( "Initialized Earth Engine for local SWB export: gee_account_id=%s", gee_account_id, @@ -474,7 +625,20 @@ def run_swb_local( update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) logger.info("Sync to GeoServer flag updated for local SWB layer: %s", layer_name) - return True + return _complete_swb_pipeline( + state=state, + district=district, + block=block, + asset_suffix=asset_suffix, + asset_folder_list=asset_folder_list, + app_type=app_type, + gee_account_id=gee_account_id, + roi=gee_roi, + start_year=start_year, + end_year=end_year, + push_to_geoserver=push_to_geoserver, + sync_layer_metadata=sync_layer_metadata, + ) def _generate_swb_local_task( @@ -489,7 +653,6 @@ def _generate_swb_local_task( gee_account_id=None, app_type="MWS", ): - _ = start_year, end_year return run_swb_local( state=state, district=district, @@ -501,6 +664,8 @@ def _generate_swb_local_task( sync_layer_metadata=True, gee_account_id=gee_account_id, app_type=app_type, + start_year=start_year, + end_year=end_year, ) diff --git a/computing/tests.py b/computing/tests.py index 7ce503c2..6aba34e3 100644 --- a/computing/tests.py +++ b/computing/tests.py @@ -1,3 +1,93 @@ -from django.test import TestCase +from unittest.mock import call, patch -# Create your tests here. +from django.test import SimpleTestCase + +from computing.layer_dependency.layer_generation_in_order import get_args +from computing.surface_water_bodies.swb_local import ( + _continue_swb_in_gee, + _final_layer_name, +) + + +class LocalSwbContinuationTests(SimpleTestCase): + def test_final_geoserver_layer_has_no_local_suffix(self): + self.assertEqual( + _final_layer_name("dumka_jarmundi"), + "surface_waterbodies_dumka_jarmundi", + ) + + def test_local_map_passes_gee_account_to_swb(self): + args = get_args( + iterator_name={ + "name": "generate_swb", + "use_global_args": True, + "pass_gee_account_id": True, + }, + global_args={"start_year": 2017, "end_year": 2024}, + gee_account_id="22", + compute="local", + ) + + self.assertEqual( + args, + { + "start_year": 2017, + "end_year": 2024, + "gee_account_id": "22", + }, + ) + + @patch("computing.surface_water_bodies.swb_local.make_asset_public") + @patch("computing.surface_water_bodies.swb_local.is_gee_asset_exists") + @patch("computing.surface_water_bodies.swb_local.check_task_status") + @patch("computing.surface_water_bodies.swb_local.waterbody_wbc_intersection") + @patch( + "computing.surface_water_bodies.swb_local.waterbody_catchment_streamorder_properties" + ) + def test_runs_swb3_and_swb4_without_local_suffix( + self, + generate_swb3, + generate_swb4, + check_task_status, + is_gee_asset_exists, + make_asset_public, + ): + generate_swb3.return_value = ("swb3-task", "swb3-asset") + generate_swb4.return_value = ("swb4-task", "swb4-asset") + is_gee_asset_exists.return_value = True + roi = object() + + result = _continue_swb_in_gee( + state="odisha", + asset_suffix="district_block", + asset_folder_list=["odisha", "district", "block"], + app_type="MWS", + gee_account_id="account", + roi=roi, + ) + + generate_swb3.assert_called_once_with( + roi=roi, + asset_suffix="district_block", + asset_folder_list=["odisha", "district", "block"], + app_type="MWS", + gee_account_id="account", + supporting_asset_suffix="district_block", + swb2_asset_suffix="district_block_local", + ) + generate_swb4.assert_called_once_with( + roi=roi, + state="odisha", + asset_suffix="district_block", + asset_folder_list=["odisha", "district", "block"], + app_type="MWS", + ) + self.assertEqual( + check_task_status.call_args_list, + [call(["swb3-task"]), call(["swb4-task"])], + ) + self.assertEqual( + make_asset_public.call_args_list, + [call("swb3-asset"), call("swb4-asset")], + ) + self.assertEqual(result, ("district_block", "swb3-asset")) From 05ad25e862e4eddb9d3be2631f6818874708952e Mon Sep 17 00:00:00 2001 From: aman verma Date: Wed, 29 Jul 2026 14:03:10 +0000 Subject: [PATCH 075/120] hazard fixes, ltp fixes, forest fire fixes --- computing/api.py | 11 +- computing/forest_fire/forest_fire.py | 547 ++++++++---------- computing/forest_fire/forest_fire_updated.py | 263 --------- computing/forest_fire/forest_fire_utils.py | 153 +++-- .../forest_fire/forest_fire_utils_old.py | 77 +++ computing/soil_health/soil_health.py | 11 +- .../drought_resistance_resilience.py | 13 +- .../export_fire_index.py | 23 +- .../forest_fire_resistance_resilience.py | 12 +- .../export_max_wind_index.py | 20 +- .../highwind_resistance_resilience.py | 15 +- computing/spei/hybrid_tree_mask.py | 12 +- .../export_rainfall_index.py | 23 +- .../rainfall_resistance_resilience.py | 12 +- .../ltp_stp/generate_ltp_stp_change_local.py | 36 +- .../ltp_stp/generate_ltp_stp_local.py | 59 +- 16 files changed, 549 insertions(+), 738 deletions(-) delete mode 100644 computing/forest_fire/forest_fire_updated.py create mode 100644 computing/forest_fire/forest_fire_utils_old.py diff --git a/computing/api.py b/computing/api.py index f2fa1e61..2e49e70b 100644 --- a/computing/api.py +++ b/computing/api.py @@ -30,7 +30,6 @@ from computing.change_detection.change_detection_vector_local import ( vectorise_change_detection as vectorise_change_detection_local_task, ) -from computing.forest_fire.forest_fire_updated import generate_forest_fire_layer_updated from computing.layer_dependency.layer_generation_in_order import ( layer_generate_map, normalize_compute as _normalize_layer_order_compute, @@ -56,6 +55,7 @@ from .cropping_intensity.cropping_intesity_local import ( generate_cropping_intensity as generate_cropping_intensity_local_task, ) +from .forest_fire.forest_fire import generate_forest_fire_layer from .soil_health.soil_health import soil_health_local from .drought.drought import calculate_drought @@ -209,12 +209,7 @@ from .tree_health.local.canopy_height_vector_local import tree_health_ch_vector_local from .tree_health.local.ccd_local import tree_health_ccd_raster_local from .tree_health.local.ccd_vector_local import tree_health_ccd_vector_local -from .tree_health.local.overall_change_local import ( - tree_health_overall_change_raster_local, -) -from .tree_health.local.overall_change_vector_local import ( - tree_health_overall_change_vector_local, -) + from .tree_health.ltp_stp.generate_ltp_stp_change_local import ( generate_ltp_stp_change_local, ) @@ -2780,7 +2775,7 @@ def generate_forest_fire(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_forest_fire_layer_updated.apply_async( + generate_forest_fire_layer.apply_async( kwargs={ "state": state, "district": district, diff --git a/computing/forest_fire/forest_fire.py b/computing/forest_fire/forest_fire.py index 15c9199f..74d9f5f0 100644 --- a/computing/forest_fire/forest_fire.py +++ b/computing/forest_fire/forest_fire.py @@ -1,292 +1,255 @@ -""" -Forest Fire pipeline. - -Generates a vector layer with MODIS-based fire metrics per micro-watershed. -Uses MODIS Terra (MOD14A1) and Aqua (MYD14A1) active fire products to -quantify fire radiative power and fire frequency across a user-defined -time window. - -For each MWS the pipeline computes four metrics: - - fire_frp_sum_per_year – yearly-normalised total Fire Radiative Power - - fire_frp_mean – temporal mean FRP - - fire_frp_max – peak FRP observed - - fire_count_per_year – yearly-normalised fire pixel count -""" - -import ee -from computing.utils import ( - sync_fc_to_geoserver, - save_layer_info_to_db, - update_layer_sync_status, -) -from utilities.constants import GEE_PATHS -from utilities.gee_utils import ( - ee_initialize, - check_task_status, - valid_gee_text, - get_gee_dir_path, - is_gee_asset_exists, - make_asset_public, - export_vector_asset_to_gee, -) -from nrm_app.celery import app -from .forest_fire_utils import ( - SCALE, - load_fire_collections, - prepare_frp_images, -) - - -@app.task(bind=True) -def generate_forest_fire_layer( - self, - state, - district, - block, - start_year=2001, - end_year=2022, - gee_account_id=None, - app_type="MWS", -): - """ - Generate MODIS fire-risk metrics as a vector layer. - - For each micro-watershed the task computes four fire metrics from - merged MODIS Terra + Aqua active fire products, exports the result - as a vector asset to GEE, syncs to GeoServer, and saves metadata. - - Args: - state: str – state name. - district: str – district name. - block: str – block / tehsil name. - start_year: int – first year of the analysis window (default 2001). - end_year: int – last year of the analysis window (default 2022). - gee_account_id: int – GEE service-account ID for authentication. - app_type: str – application type key in GEE_PATHS (default "MWS"). - """ - - # ------------------------------------------------------------------ - # STEP 1: Initialize GEE and set up paths - # ------------------------------------------------------------------ - ee_initialize(gee_account_id) - - start_year = int(start_year) - end_year = int(end_year) - n_years = end_year - start_year + 1 - - asset_suffix = ( - valid_gee_text(district.lower()) + "_" + valid_gee_text(block.lower()) - ) - asset_folder_list = [state, district, block] - - description = f"forest_fire_{asset_suffix}_{start_year}_{end_year}" - layer_name = f"{asset_suffix}_forest_fire" - - asset_id = ( - get_gee_dir_path( - asset_folder_list, - asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"], - ) - + description - ) - - print(f"Forest Fire pipeline started: {asset_id=}") - - # ------------------------------------------------------------------ - # STEP 2: Set up ROI (MWS boundaries from GEE) - # ------------------------------------------------------------------ - roi_path = ( - get_gee_dir_path( - asset_folder_list, - asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"], - ) - + f"filtered_mws_{valid_gee_text(district.lower())}" - + f"_{valid_gee_text(block.lower())}_uid" - ) - mws_fc = ee.FeatureCollection(roi_path) - - # Add this debug block before the map to identify bad features - print("Total features:", mws_fc.size().getInfo()) - - # Check for features whose geometry area is 0 - def flag_bad_geom(f): - area = f.geometry().area(1) - return f.set("area_m2", area) - - debug_fc = mws_fc.map(flag_bad_geom) - bad = debug_fc.filter(ee.Filter.eq("area_m2", 0)) - print("Bad geometry count:", bad.size().getInfo()) - print("Bad UIDs:", bad.aggregate_array("uid").getInfo()) - - # ------------------------------------------------------------------ - # STEP 3: Compute fire metrics - # ------------------------------------------------------------------ - if not is_gee_asset_exists(asset_id): - - # Prepare temporally-aggregated fire images - frp_collection = load_fire_collections(start_year, end_year) - fire_images = prepare_frp_images(frp_collection, n_years) - - frp_sum_img = fire_images["sum"] - frp_mean_img = fire_images["mean"] - frp_max_img = fire_images["max"] - fire_count_img = fire_images["count"] - # ---- map compute over all MWS features ---- - mws_fc = mws_fc.filter(ee.Filter.notNull(["uid"])) - - # After your area filter, add geometry repair - def repair_geometry(f): - return f.setGeometry(f.geometry().buffer(0).simplify(10)) - - def validate_feature(f): - geom = f.geometry() - - return f.set({"geom_type": geom.type(), "area_m2": geom.area(1)}) - - validated = mws_fc.map(validate_feature) - - mws_fc = validated.filter(ee.Filter.gt("area_m2", 0)) - mws_fc = mws_fc.map(repair_geometry) - - fire_projection = ee.Image(frp_collection.first()).select("MaxFRP").projection() - metric_images = { - "fire_frp_sum_per_year": frp_sum_img.rename( - "fire_frp_sum_per_year" - ).setDefaultProjection(fire_projection), - "fire_frp_mean": frp_mean_img.rename("fire_frp_mean").setDefaultProjection( - fire_projection - ), - "fire_frp_max": frp_max_img.rename("fire_frp_max").setDefaultProjection( - fire_projection - ), - "fire_count_per_year": fire_count_img.rename( - "fire_count_per_year" - ).setDefaultProjection(fire_projection), - } - - fc = _reduce_fire_metric( - mws_fc, - metric_images["fire_frp_sum_per_year"], - ee.Reducer.sum(), - "fire_frp_sum_per_year", - fire_projection, - ) - fc = _reduce_fire_metric( - fc, - metric_images["fire_frp_mean"], - ee.Reducer.mean(), - "fire_frp_mean", - fire_projection, - ) - fc = _reduce_fire_metric( - fc, - metric_images["fire_frp_max"], - ee.Reducer.mean(), - "fire_frp_max", - fire_projection, - ) - fc = _reduce_fire_metric( - fc, - metric_images["fire_count_per_year"], - ee.Reducer.sum(), - "fire_count_per_year", - fire_projection, - ) - - fc = fc.select( - [ - "uid", - "fire_frp_sum_per_year", - "fire_frp_mean", - "fire_frp_max", - "fire_count_per_year", - ] - ) - - # -------------------------------------------------------------- - # STEP 4: Export to GEE - # -------------------------------------------------------------- - task_id = export_vector_asset_to_gee(fc, description, asset_id) - if task_id: - check_task_status([task_id]) - print("Forest Fire layer exported to GEE.") - - # ------------------------------------------------------------------ - # STEP 5: Publish to GeoServer and save metadata to DB - # ------------------------------------------------------------------ - layer_at_geoserver = _save_to_db_and_sync_to_geoserver( - layer_name=layer_name, - asset_id=asset_id, - start_year=start_year, - end_year=end_year, - asset_suffix=asset_suffix, - state=state, - district=district, - block=block, - ) - return layer_at_geoserver - - -def _reduce_fire_metric(fc, image, reducer, metric_name, projection): - reduced = image.reduceRegions( - collection=fc, - reducer=reducer, - scale=SCALE, - crs=projection, - tileScale=4, - ) - - def fill_null(feature): - value = feature.get(metric_name) - value = ee.Algorithms.If(ee.Algorithms.IsEqual(value, None), 0, value) - return feature.set(metric_name, ee.Number(value)) - - return reduced.map(fill_null) - - -# ------------------------------------------------------------------ -# Private helpers (publish / persist) -# ------------------------------------------------------------------ - - -def _save_to_db_and_sync_to_geoserver( - layer_name=None, - asset_id=None, - start_year=None, - end_year=None, - asset_suffix=None, - state=None, - district=None, - block=None, -): - """Publish asset to GeoServer and persist metadata to the database.""" - print("Forest Fire: save_to_db_and_sync_to_geoserver") - - layer_id = None - if state and district and block: - layer_id = save_layer_info_to_db( - state=state, - district=district, - block=block, - layer_name=layer_name, - asset_id=asset_id, - dataset_name="Forest Fire", - misc={ - "start_year": start_year, - "end_year": end_year, - }, - ) - - make_asset_public(asset_id) - - fc = ee.FeatureCollection(asset_id) - res = sync_fc_to_geoserver(fc, asset_suffix, layer_name, "forest_fire") - print(res) - - layer_at_geoserver = False - if res["status_code"] == 201 and layer_id: - update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) - print("Forest Fire: sync to geoserver flag updated") - layer_at_geoserver = True - - return layer_at_geoserver +import ee + +# import geemap + +from .forest_fire_utils import ( + SCALE, + MAXPIX, + load_fire_collections, + prepare_frp_images, +) +from utilities.constants import GEE_PATHS +from utilities.gee_utils import ( + ee_initialize, + export_vector_asset_to_gee, + get_gee_dir_path, + check_task_status, + valid_gee_text, + is_gee_asset_exists, + make_asset_public, +) +from computing.utils import ( + sync_fc_to_geoserver, + update_layer_sync_status, + save_layer_info_to_db, +) +from nrm_app.celery import app + + +@app.task(bind=True) +def generate_forest_fire_layer( + self, + state, + district, + block, + start_year=2001, + end_year=2022, + gee_account_id=None, + app_type="MWS", +): + """ + Generate MODIS fire metrics as a vector layer for each MWS feature. + """ + + ee_initialize(gee_account_id) + + 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") + + n_years = end_year - start_year + 1 + asset_suffix = ( + valid_gee_text(district.lower()) + "_" + valid_gee_text(block.lower()) + ) + asset_folder_list = [state, district, block] + + gee_base_path = get_gee_dir_path( + asset_folder_list, + asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"], + ) + description = f"forest_fire_{asset_suffix}_{start_year}_{end_year}" + layer_name = f"{asset_suffix}_forest_fire" + asset_id = gee_base_path + description + + print(f"Forest Fire updated pipeline started: {asset_id=}") + + # ------------------------------------------------------------------ + # STEP 2: Set up ROI (MWS boundaries from GEE) + # ------------------------------------------------------------------ + roi_path = ( + get_gee_dir_path( + asset_folder_list, + asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"], + ) + + f"filtered_mws_{valid_gee_text(district.lower())}" + + f"_{valid_gee_text(block.lower())}_uid" + ) + mws_fc = ee.FeatureCollection(roi_path) + + print("Total features:", mws_fc.size().getInfo()) + + def flag_bad_geom(f): + area = f.geometry().area(1) + return f.set("area_m2", area) + + debug_fc = mws_fc.map(flag_bad_geom) + bad = debug_fc.filter(ee.Filter.eq("area_m2", 0)) + + print("Bad geometry count:", bad.size().getInfo()) + print("Bad UIDs:", bad.aggregate_array("uid").getInfo()) + + # ------------------------------------------------------------------ + # STEP 3: Compute fire metrics + # ------------------------------------------------------------------ + if not is_gee_asset_exists(asset_id): + frp_collection = load_fire_collections(start_year, end_year) + fire_images = prepare_frp_images(frp_collection, n_years) + + frp_sum_img = fire_images["sum"] + frp_mean_img = fire_images["mean"] + frp_max_img = fire_images["max"] + fire_count_img = fire_images["count"] + + def compute_fire_metrics(f): + geom = f.geometry() + + def reduce(img, reducer, band): + val = img.reduceRegion( + reducer=reducer, + geometry=geom, + scale=SCALE, + maxPixels=MAXPIX, + bestEffort=True, + ).get(band) + + return ee.Number( + ee.Algorithms.If( + ee.Algorithms.IsEqual(val, None), + 0, + val, + ) + ) + + fire_frp_sum_per_year = reduce( + frp_sum_img, + ee.Reducer.sum(), + "MaxFRP", + ) + fire_frp_mean = reduce( + frp_mean_img, + ee.Reducer.mean(), + "MaxFRP", + ) + fire_frp_max = reduce( + frp_max_img, + ee.Reducer.mean(), + "MaxFRP", + ) + fire_count_per_year = reduce( + fire_count_img, + ee.Reducer.sum(), + "fire", + ) + + return ( + f.set("fire_frp_sum_per_year", fire_frp_sum_per_year) + .set("fire_frp_mean", fire_frp_mean) + .set("fire_frp_max", fire_frp_max) + .set("fire_count_per_year", fire_count_per_year) + ) + + def repair_geometry(f): + return f.setGeometry(f.geometry().buffer(0).simplify(10)) + + def validate_feature(f): + geom = f.geometry() + + return f.set( + { + "geom_type": geom.type(), + "area_m2": geom.area(1), + } + ) + + validated = mws_fc.map(validate_feature) + + mws_fc = validated.filter(ee.Filter.gt("area_m2", 0)) + + mws_fc = mws_fc.map(repair_geometry) + + fc = mws_fc.map(compute_fire_metrics) + + fc = fc.select( + [ + "uid", + "fire_frp_sum_per_year", + "fire_frp_mean", + "fire_frp_max", + "fire_count_per_year", + ] + ) + + # ------------------------------------------------------------------ + # STEP 4 + # ------------------------------------------------------------------ + + # print("Exporting locally...") + # + # geemap.ee_export_vector( + # fc, + # filename=f"{description}.geojson", + # ) + + task_id = export_vector_asset_to_gee(fc, description, asset_id) + if task_id: + check_task_status([task_id]) + print("Forest Fire layer exported to GEE.") + + # ------------------------------------------------------------------ + # STEP 5: Publish to GeoServer and save metadata to DB + # ------------------------------------------------------------------ + layer_at_geoserver = _save_to_db_and_sync_to_geoserver( + layer_name=layer_name, + asset_id=asset_id, + start_year=start_year, + end_year=end_year, + asset_suffix=asset_suffix, + state=state, + district=district, + block=block, + ) + return layer_at_geoserver + + +def _save_to_db_and_sync_to_geoserver( + layer_name=None, + asset_id=None, + start_year=None, + end_year=None, + asset_suffix=None, + state=None, + district=None, + block=None, +): + """Publish asset to GeoServer and persist metadata to the database.""" + print("Forest Fire: save_to_db_and_sync_to_geoserver") + + layer_id = None + if state and district and block: + layer_id = save_layer_info_to_db( + state=state, + district=district, + block=block, + layer_name=layer_name, + asset_id=asset_id, + dataset_name="Forest Fire", + misc={ + "start_year": start_year, + "end_year": end_year, + }, + ) + + make_asset_public(asset_id) + + fc = ee.FeatureCollection(asset_id) + res = sync_fc_to_geoserver(fc, asset_suffix, layer_name, "forest_fire") + print(res) + + layer_at_geoserver = False + if res["status_code"] == 201 and layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Forest Fire: sync to geoserver flag updated") + layer_at_geoserver = True + + return layer_at_geoserver diff --git a/computing/forest_fire/forest_fire_updated.py b/computing/forest_fire/forest_fire_updated.py deleted file mode 100644 index ae8c3c14..00000000 --- a/computing/forest_fire/forest_fire_updated.py +++ /dev/null @@ -1,263 +0,0 @@ -""" -Clean Forest Fire pipeline. - -Uses MODIS Terra and Aqua active fire products to compute fire incident -metrics for each micro-watershed. -""" - -import ee - -from computing.utils import ( - save_layer_info_to_db, - sync_fc_to_geoserver, - update_layer_sync_status, -) -from nrm_app.celery import app -from utilities.constants import GEE_PATHS -from utilities.gee_utils import ( - check_task_status, - ee_initialize, - export_vector_asset_to_gee, - get_gee_dir_path, - is_gee_asset_exists, - make_asset_public, - valid_gee_text, -) - -SCALE = 1000 -TILE_SCALE = 4 - -TERRA_FIRE_PATH = "MODIS/061/MOD14A1" -AQUA_FIRE_PATH = "MODIS/061/MYD14A1" -FIRE_BAND = "MaxFRP" - -METRIC_FIELDS = [ - "uid", - "fire_frp_sum_per_year", - "fire_frp_mean", - "fire_frp_max", - "fire_count_per_year", -] - - -@app.task(bind=True) -def generate_forest_fire_layer_updated( - self, - state, - district, - block, - start_year=2001, - end_year=2022, - gee_account_id=None, - app_type="MWS", -): - """ - Generate MODIS fire metrics as a vector layer for each MWS feature. - """ - - ee_initialize(gee_account_id) - - 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") - - n_years = end_year - start_year + 1 - asset_suffix = ( - valid_gee_text(district.lower()) + "_" + valid_gee_text(block.lower()) - ) - asset_folder_list = [state, district, block] - - gee_base_path = get_gee_dir_path( - asset_folder_list, - asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"], - ) - description = f"forest_fire_{asset_suffix}_{start_year}_{end_year}" - layer_name = f"{asset_suffix}_forest_fire" - asset_id = gee_base_path + description - - print(f"Forest Fire updated pipeline started: {asset_id=}") - - if not is_gee_asset_exists(asset_id): - roi_path = ( - gee_base_path - + f"filtered_mws_{valid_gee_text(district.lower())}" - + f"_{valid_gee_text(block.lower())}_uid" - ) - mws_fc = _prepare_mws_features(roi_path) - - fire_collection = _load_fire_collection(start_year, end_year) - fire_count = fire_collection.size().getInfo() - print("Forest Fire MODIS image count:", fire_count) - if fire_count == 0: - raise ValueError( - "No MODIS fire images found for " - f"{start_year}-01-01 to {end_year}-12-31" - ) - - projection = ee.Image(fire_collection.first()).select(FIRE_BAND).projection() - metric_images = _build_metric_images(fire_collection, n_years, projection) - - fc = _add_fire_metrics(mws_fc, metric_images, projection) - fc = fc.select(METRIC_FIELDS) - - task_id = export_vector_asset_to_gee(fc, description, asset_id) - if task_id: - check_task_status([task_id]) - print("Forest Fire updated layer exported to GEE.") - - return _save_to_db_and_sync_to_geoserver( - layer_name=layer_name, - asset_id=asset_id, - start_year=start_year, - end_year=end_year, - asset_suffix=asset_suffix, - state=state, - district=district, - block=block, - ) - - -def _load_fire_collection(start_year, end_year): - start_date = f"{start_year}-07-01" - end_date = f"{end_year}-07-01" - - terra = ee.ImageCollection(TERRA_FIRE_PATH) - aqua = ee.ImageCollection(AQUA_FIRE_PATH) - - return terra.merge(aqua).filterDate(start_date, end_date).select(FIRE_BAND) - - -def _prepare_mws_features(roi_path): - fc = ee.FeatureCollection(roi_path).filter(ee.Filter.notNull(["uid"])) - - def repair_and_measure(feature): - geom = feature.geometry().buffer(0).simplify(10) - return feature.setGeometry(geom).set("area_m2", geom.area(1)) - - return fc.map(repair_and_measure).filter(ee.Filter.gt("area_m2", 0)) - - -def _build_metric_images(fire_collection, n_years, projection): - def mask_fire_pixels(image): - image = ee.Image(image) - return image.updateMask(image.gt(0)) - - def fire_incident_pixel(image): - image = ee.Image(image) - return image.gt(0).unmask(0) - - fire_only = fire_collection.map(mask_fire_pixels) - fire_incidents = fire_collection.map(fire_incident_pixel) - - def prepare(image, metric_name): - return ee.Image(image).rename(metric_name).setDefaultProjection(projection) - - return { - "fire_frp_sum_per_year": prepare( - fire_only.sum().divide(n_years), - "fire_frp_sum_per_year", - ), - "fire_frp_mean": prepare( - fire_only.mean(), - "fire_frp_mean", - ), - "fire_frp_max": prepare( - fire_only.max(), - "fire_frp_max", - ), - "fire_count_per_year": prepare( - fire_incidents.sum().divide(n_years), - "fire_count_per_year", - ), - } - - -def _add_fire_metrics(mws_fc, metric_images, projection): - fc = _reduce_metric( - mws_fc, - metric_images["fire_frp_sum_per_year"], - ee.Reducer.sum(), - "fire_frp_sum_per_year", - projection, - ) - fc = _reduce_metric( - fc, - metric_images["fire_frp_mean"], - ee.Reducer.mean(), - "fire_frp_mean", - projection, - ) - fc = _reduce_metric( - fc, - metric_images["fire_frp_max"], - ee.Reducer.mean(), - "fire_frp_max", - projection, - ) - return _reduce_metric( - fc, - metric_images["fire_count_per_year"], - ee.Reducer.sum(), - "fire_count_per_year", - projection, - ) - - -def _reduce_metric(fc, image, reducer, metric_name, projection): - reduced = image.reduceRegions( - collection=fc, - reducer=reducer, - scale=SCALE, - crs=projection, - tileScale=TILE_SCALE, - ) - - def fill_null(feature): - value = feature.get(metric_name) - value = ee.Algorithms.If(ee.Algorithms.IsEqual(value, None), 0, value) - return feature.set(metric_name, ee.Number(value)) - - return reduced.map(fill_null) - - -def _save_to_db_and_sync_to_geoserver( - layer_name=None, - asset_id=None, - start_year=None, - end_year=None, - asset_suffix=None, - state=None, - district=None, - block=None, -): - print("Forest Fire updated: save_to_db_and_sync_to_geoserver") - - layer_id = None - if state and district and block: - layer_id = save_layer_info_to_db( - state=state, - district=district, - block=block, - layer_name=layer_name, - asset_id=asset_id, - dataset_name="Forest Fire", - misc={ - "start_year": start_year, - "end_year": end_year, - }, - ) - - make_asset_public(asset_id) - - fc = ee.FeatureCollection(asset_id) - res = sync_fc_to_geoserver(fc, asset_suffix, layer_name, "forest_fire") - print(res) - - layer_at_geoserver = False - if res["status_code"] == 201 and layer_id: - update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) - print("Forest Fire updated: sync to geoserver flag updated") - layer_at_geoserver = True - - return layer_at_geoserver diff --git a/computing/forest_fire/forest_fire_utils.py b/computing/forest_fire/forest_fire_utils.py index d54ccd68..3632c1a4 100644 --- a/computing/forest_fire/forest_fire_utils.py +++ b/computing/forest_fire/forest_fire_utils.py @@ -1,77 +1,76 @@ -""" -Utility functions and constants for the Forest Fire pipeline. - -Provides MODIS fire data loading, FRP preprocessing, and fire-binary -helpers for per-MWS fire risk analysis using Google Earth Engine. -""" - -import ee - -# ---------------------------------------- -# PARAMETERS / CONSTANTS -# ---------------------------------------- - -SCALE = 1000 -MAXPIX = 1e13 - -# MODIS Active Fire products (Terra + Aqua) -TERRA_FIRE_PATH = "MODIS/061/MOD14A1" -AQUA_FIRE_PATH = "MODIS/061/MYD14A1" - - -def load_fire_collections(start_year, end_year): - """ - Load and merge MODIS Terra + Aqua active fire collections. - - Filters the merged collection to the date range - [start_year-01-01, end_year-12-31] and selects the MaxFRP band. - - Args: - start_year: int – first year of the analysis window. - end_year: int – last year of the analysis window. - - Returns: - ee.ImageCollection – merged, date-filtered MaxFRP collection. - """ - start_date = f"{start_year}-07-01" - end_date = f"{end_year + 1}-07-01" - - terra = ee.ImageCollection(TERRA_FIRE_PATH) - aqua = ee.ImageCollection(AQUA_FIRE_PATH) - - fires = terra.merge(aqua).filterDate(start_date, end_date) - return fires.select("MaxFRP") - - -def prepare_frp_images(frp_collection, n_years): - """ - Pre-compute the four temporally aggregated fire images. - - Args: - frp_collection: ee.ImageCollection – MaxFRP collection. - n_years: int – number of years in the analysis window. - - Returns: - dict with keys 'sum', 'mean', 'max', 'count', each mapping to - an ee.Image ready for spatial reduction. - """ - - # Mask zeros for FRP statistics - def mask_fire(img): - return img.updateMask(img.gt(0)) - - frp_masked = frp_collection.map(mask_fire) - - # Binary fire occurrence for count - def fire_binary(img): - return img.gt(0).unmask(0).rename("fire") - - fire_binary_collection = frp_collection.map(fire_binary) - - return { - "sum": frp_masked.sum().divide(n_years), # yearly-normalised total FRP - "mean": frp_masked.mean(), # temporal mean FRP - # "max": frp_masked.max(), # peak FRP - "max": frp_masked.max(), - "count": fire_binary_collection.sum().divide(n_years), # yearly fire frequency - } +""" +Utility functions and constants for the Forest Fire pipeline. + +Provides MODIS fire data loading, FRP preprocessing, and fire-binary +helpers for per-MWS fire risk analysis using Google Earth Engine. +""" + +import ee + +# ---------------------------------------- +# PARAMETERS / CONSTANTS +# ---------------------------------------- + +SCALE = 1000 +MAXPIX = 1e13 + +# MODIS Active Fire products (Terra + Aqua) +TERRA_FIRE_PATH = "MODIS/061/MOD14A1" +AQUA_FIRE_PATH = "MODIS/061/MYD14A1" + + +def load_fire_collections(start_year, end_year): + """ + Load and merge MODIS Terra + Aqua active fire collections. + + Filters the merged collection to the date range + [start_year-01-01, end_year-12-31] and selects the MaxFRP band. + + Args: + start_year: int – first year of the analysis window. + end_year: int – last year of the analysis window. + + Returns: + ee.ImageCollection – merged, date-filtered MaxFRP collection. + """ + start_date = f"{start_year}-01-01" + end_date = f"{end_year}-12-31" + + terra = ee.ImageCollection(TERRA_FIRE_PATH) + aqua = ee.ImageCollection(AQUA_FIRE_PATH) + + fires = terra.merge(aqua).filterDate(start_date, end_date) + return fires.select("MaxFRP") + + +def prepare_frp_images(frp_collection, n_years): + """ + Pre-compute the four temporally aggregated fire images. + + Args: + frp_collection: ee.ImageCollection – MaxFRP collection. + n_years: int – number of years in the analysis window. + + Returns: + dict with keys 'sum', 'mean', 'max', 'count', each mapping to + an ee.Image ready for spatial reduction. + """ + + # Mask zeros for FRP statistics + def mask_fire(img): + return img.updateMask(img.gt(0)) + + frp_masked = frp_collection.map(mask_fire) + + # Binary fire occurrence for count + def fire_binary(img): + return img.gt(0).unmask(0).rename("fire") + + fire_binary_collection = frp_collection.map(fire_binary) + + return { + "sum": frp_masked.sum().divide(n_years), # yearly-normalised total FRP + "mean": frp_masked.mean(), # temporal mean FRP + "max": frp_masked.max(), + "count": fire_binary_collection.sum().divide(n_years), # yearly fire frequency + } diff --git a/computing/forest_fire/forest_fire_utils_old.py b/computing/forest_fire/forest_fire_utils_old.py new file mode 100644 index 00000000..d54ccd68 --- /dev/null +++ b/computing/forest_fire/forest_fire_utils_old.py @@ -0,0 +1,77 @@ +""" +Utility functions and constants for the Forest Fire pipeline. + +Provides MODIS fire data loading, FRP preprocessing, and fire-binary +helpers for per-MWS fire risk analysis using Google Earth Engine. +""" + +import ee + +# ---------------------------------------- +# PARAMETERS / CONSTANTS +# ---------------------------------------- + +SCALE = 1000 +MAXPIX = 1e13 + +# MODIS Active Fire products (Terra + Aqua) +TERRA_FIRE_PATH = "MODIS/061/MOD14A1" +AQUA_FIRE_PATH = "MODIS/061/MYD14A1" + + +def load_fire_collections(start_year, end_year): + """ + Load and merge MODIS Terra + Aqua active fire collections. + + Filters the merged collection to the date range + [start_year-01-01, end_year-12-31] and selects the MaxFRP band. + + Args: + start_year: int – first year of the analysis window. + end_year: int – last year of the analysis window. + + Returns: + ee.ImageCollection – merged, date-filtered MaxFRP collection. + """ + start_date = f"{start_year}-07-01" + end_date = f"{end_year + 1}-07-01" + + terra = ee.ImageCollection(TERRA_FIRE_PATH) + aqua = ee.ImageCollection(AQUA_FIRE_PATH) + + fires = terra.merge(aqua).filterDate(start_date, end_date) + return fires.select("MaxFRP") + + +def prepare_frp_images(frp_collection, n_years): + """ + Pre-compute the four temporally aggregated fire images. + + Args: + frp_collection: ee.ImageCollection – MaxFRP collection. + n_years: int – number of years in the analysis window. + + Returns: + dict with keys 'sum', 'mean', 'max', 'count', each mapping to + an ee.Image ready for spatial reduction. + """ + + # Mask zeros for FRP statistics + def mask_fire(img): + return img.updateMask(img.gt(0)) + + frp_masked = frp_collection.map(mask_fire) + + # Binary fire occurrence for count + def fire_binary(img): + return img.gt(0).unmask(0).rename("fire") + + fire_binary_collection = frp_collection.map(fire_binary) + + return { + "sum": frp_masked.sum().divide(n_years), # yearly-normalised total FRP + "mean": frp_masked.mean(), # temporal mean FRP + # "max": frp_masked.max(), # peak FRP + "max": frp_masked.max(), + "count": fire_binary_collection.sum().divide(n_years), # yearly fire frequency + } diff --git a/computing/soil_health/soil_health.py b/computing/soil_health/soil_health.py index f2c06826..9941d9d4 100644 --- a/computing/soil_health/soil_health.py +++ b/computing/soil_health/soil_health.py @@ -43,9 +43,14 @@ def _get_lulc_mask_classes(nutrient): nutrient = str(nutrient).strip().upper() - if nutrient in {"OC", "OLM"}: - return {6} - if nutrient in {"N", "P", "K"}: + if nutrient == "OLM": + return {6, 12} + if nutrient in { + "N", + "P", + "K", + "OC", + }: return {8, 9, 10, 11} raise ValueError(f"Unsupported nutrient for LULC masking: {nutrient}") diff --git a/computing/spei/drought_sensitivity/drought_resistance_resilience.py b/computing/spei/drought_sensitivity/drought_resistance_resilience.py index 2e990a7e..29849d47 100644 --- a/computing/spei/drought_sensitivity/drought_resistance_resilience.py +++ b/computing/spei/drought_sensitivity/drought_resistance_resilience.py @@ -1,5 +1,6 @@ import ee +from utilities.constants import AEZ from utilities.gee_utils import ( ee_initialize, is_gee_asset_exists, @@ -50,12 +51,12 @@ def generate_drought_resistance( BASELINE_START_YEAR = 2004 # SPEI has no data before 2004 BASELINE_END_YEAR = 2024 - # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO - aoi = ( - ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") - .filter(ee.Filter.eq("Name", "Odisha")) - .geometry() - ) + aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + # aoi = ( + # ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + # .filter(ee.Filter.eq("Name", "Odisha")) + # .geometry() + # ) # Loading the assets := treeMeta = ee.Image(TREE_COVER_ASSET) diff --git a/computing/spei/forestfire_sensitivity/export_fire_index.py b/computing/spei/forestfire_sensitivity/export_fire_index.py index 9a4008b9..2200f47b 100644 --- a/computing/spei/forestfire_sensitivity/export_fire_index.py +++ b/computing/spei/forestfire_sensitivity/export_fire_index.py @@ -36,12 +36,12 @@ def fire_index(aez, start_year=2004, end_year=2022, gee_account_id=None): if is_gee_asset_exists(OUTPUT_ASSET_ID): return None - # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() - aoi = ( - ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") - .filter(ee.Filter.eq("Name", "Odisha")) - .geometry() - ) + aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + # aoi = ( + # ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + # .filter(ee.Filter.eq("Name", "Odisha")) + # .geometry() + # ) FRP_THRESHOLD = 30 """ Fixed baseline window for zScore normalization — independent of START_YEAR/END_YEAR. @@ -148,13 +148,20 @@ def add_bands_for_year(y, acc): ) maxFRPBand = yearImg.select("maxFRP").rename(ee.String("maxFRP_").cat(yearStr)) - return ee.Image(acc).addBands( - [frpBand, zBand, fireDaysBand, fireAvgBand, maxFRPBand] + return ( + ee.Image(acc) + .addBands(frpBand) + .addBands(zBand) + .addBands(fireDaysBand) + .addBands(fireAvgBand) + .addBands(maxFRPBand) ) empty_image = ee.Image().mask(ee.Image(0)) output_image = ee.Image(analysisYears.iterate(add_bands_for_year, empty_image)) + output_image = output_image.select(output_image.bandNames().remove("constant")) + # Export execution block task_id = export_raster_asset_to_gee( output_image.clip(aoi), OUTPUT_DESC, OUTPUT_ASSET_ID, scale=1000, region=aoi diff --git a/computing/spei/forestfire_sensitivity/forest_fire_resistance_resilience.py b/computing/spei/forestfire_sensitivity/forest_fire_resistance_resilience.py index e604ee2d..186b0272 100644 --- a/computing/spei/forestfire_sensitivity/forest_fire_resistance_resilience.py +++ b/computing/spei/forestfire_sensitivity/forest_fire_resistance_resilience.py @@ -44,12 +44,12 @@ def forest_fire_sensitivity(aez, start_year=2004, end_year=2022, gee_account_id= BASELINE_START_YEAR = 2004 BASELINE_END_YEAR = 2024 - # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO - aoi = ( - ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") - .filter(ee.Filter.eq("Name", "Odisha")) - .geometry() - ) + aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + # aoi = ( + # ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + # .filter(ee.Filter.eq("Name", "Odisha")) + # .geometry() + # ) # Loading the assets := treeMeta = ee.Image(TREE_COVER_ASSET) startYearTree = treeMeta.select("start_year") diff --git a/computing/spei/high_wind_sensitivity/export_max_wind_index.py b/computing/spei/high_wind_sensitivity/export_max_wind_index.py index eacaabbb..f4a72ba0 100644 --- a/computing/spei/high_wind_sensitivity/export_max_wind_index.py +++ b/computing/spei/high_wind_sensitivity/export_max_wind_index.py @@ -38,12 +38,12 @@ def max_wind_index(aez, start_year=2004, end_year=2022, gee_account_id=None): if is_gee_asset_exists(OUTPUT_ASSET_ID): return None - # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO - aoi = ( - ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") - .filter(ee.Filter.eq("Name", "Odisha")) - .geometry() - ) + aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + # aoi = ( + # ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + # .filter(ee.Filter.eq("Name", "Odisha")) + # .geometry() + # ) # Set your wind speed threshold here (in m/s) WIND_THRESHOLD = 10.0 @@ -55,7 +55,7 @@ def max_wind_index(aez, start_year=2004, end_year=2022, gee_account_id=None): era5Hourly = ( ee.ImageCollection("ECMWF/ERA5_LAND/HOURLY") .filterBounds(aoi) - .filterDate("2000-01-01", f"{end_year}-12-31") + .filterDate("2000-01-01", ee.Date.fromYMD(end_year, 12, 31)) .select(["u_component_of_wind_10m", "v_component_of_wind_10m"]) ) @@ -130,14 +130,16 @@ def add_year_bands(year, image): ee.String("WSmeanGT_").cat(year_str) ) - return ee.Image(image).addBands(ee.Image([ws_max, ws_hours, ws_mean])) + return ee.Image(image).addBands(ws_max).addBands(ws_hours).addBands(ws_mean) empty_image = ee.Image().mask(ee.Image(0)) output_image = ee.Image(years.iterate(add_year_bands, empty_image)) + output_image = output_image.select(output_image.bandNames().remove("constant")) + # Export execution block task_id = export_raster_asset_to_gee( - output_image.clip(aoi), OUTPUT_DESC, OUTPUT_ASSET_ID, scale=1000, region=aoi + output_image.clip(aoi), OUTPUT_DESC, OUTPUT_ASSET_ID, scale=11132, region=aoi ) return task_id diff --git a/computing/spei/high_wind_sensitivity/highwind_resistance_resilience.py b/computing/spei/high_wind_sensitivity/highwind_resistance_resilience.py index b321aaa1..0fa5b5f0 100644 --- a/computing/spei/high_wind_sensitivity/highwind_resistance_resilience.py +++ b/computing/spei/high_wind_sensitivity/highwind_resistance_resilience.py @@ -49,12 +49,12 @@ def high_wind_sensitivity(aez, start_year=2004, end_year=None, gee_account_id=No BASELINE_START_YEAR = 2004 BASELINE_END_YEAR = 2024 - # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO - aoi = ( - ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") - .filter(ee.Filter.eq("Name", "Odisha")) - .geometry() - ) + aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + # aoi = ( + # ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + # .filter(ee.Filter.eq("Name", "Odisha")) + # .geometry() + # ) # Loading the assets := treeMeta = ee.Image(TREE_COVER_ASSET) @@ -74,7 +74,7 @@ def high_wind_sensitivity(aez, start_year=2004, end_year=None, gee_account_id=No wsImages = [] for y in range(wsMinYear, wsMaxYear + 1): wsImages.append( - windIndex_raw.select("WSmax_").cat(y).rename("windspeed").set("year", y) + windIndex_raw.select(f"WSmax_{y}").rename("windspeed").set("year", y) ) wsCol = ee.ImageCollection(wsImages) @@ -167,6 +167,7 @@ def getAnnualKNDVI(year): kndviMinYear = min(start_year, BASELINE_START_YEAR) kndviMaxYear = max(end_year + 1, BASELINE_END_YEAR) + kndviYears = ee.List.sequence(kndviMinYear, kndviMaxYear) kndviCol = ee.ImageCollection(kndviYears.map(getAnnualKNDVI)) diff --git a/computing/spei/hybrid_tree_mask.py b/computing/spei/hybrid_tree_mask.py index 36f04694..8192938f 100644 --- a/computing/spei/hybrid_tree_mask.py +++ b/computing/spei/hybrid_tree_mask.py @@ -43,12 +43,12 @@ def generate_hybrid_tree_mask(aez, start_year=2003, end_year=None, gee_account_i if is_gee_asset_exists(OUTPUT_ASSET_ID): return None, OUTPUT_ASSET_ID - # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO - aoi = ( - ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") - .filter(ee.Filter.eq("Name", "Odisha")) - .geometry() - ) + aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + # aoi = ( + # ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + # .filter(ee.Filter.eq("Name", "Odisha")) + # .geometry() + # ) # DATASET PREPARATION := # --- GLC-FCS30D --- diff --git a/computing/spei/rainfall_sensitivity/export_rainfall_index.py b/computing/spei/rainfall_sensitivity/export_rainfall_index.py index 44b533ae..57bf0946 100644 --- a/computing/spei/rainfall_sensitivity/export_rainfall_index.py +++ b/computing/spei/rainfall_sensitivity/export_rainfall_index.py @@ -47,12 +47,12 @@ def rainfall_index(aez, start_year=2004, end_year=None, gee_account_id=None): BASELINE_END_YEAR = 2024 # 1. AOI - # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO - aoi = ( - ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") - .filter(ee.Filter.eq("Name", "Odisha")) - .geometry() - ) + aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + # aoi = ( + # ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + # .filter(ee.Filter.eq("Name", "Odisha")) + # .geometry() + # ) # 2. BASELINE HEAVY RAINFALL THRESHOLD chirps = ( @@ -159,13 +159,20 @@ def add_bands_for_year(y, acc): ) maxDayBand = yearImg.select("maxDay").rename(ee.String("maxDay_").cat(yearStr)) - return ee.Image(acc).addBands( - [hmBand, zBand, heavyDaysBand, heavyAvgBand, maxDayBand] + return ( + ee.Image(acc) + .addBands(hmBand) + .addBands(zBand) + .addBands(heavyDaysBand) + .addBands(heavyAvgBand) + .addBands(maxDayBand) ) empty_image = ee.Image().mask(ee.Image(0)) output_image = ee.Image(analysisYears.iterate(add_bands_for_year, empty_image)) + output_image = output_image.select(output_image.bandNames().remove("constant")) + task_id = export_raster_asset_to_gee( output_image.clip(aoi), OUTPUT_DESC, OUTPUT_ASSET_ID, scale=5566, region=aoi ) diff --git a/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py b/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py index 6835b619..29711a4a 100644 --- a/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py +++ b/computing/spei/rainfall_sensitivity/rainfall_resistance_resilience.py @@ -66,12 +66,12 @@ def generate_rainfall_resilience( BASELINE_START_YEAR = 2004 BASELINE_END_YEAR = 2024 - # aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() # TODO - aoi = ( - ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") - .filter(ee.Filter.eq("Name", "Odisha")) - .geometry() - ) + aoi = ee.FeatureCollection(AEZ).filter(ee.Filter.eq("ae_regcode", aez)).geometry() + # aoi = ( + # ee.FeatureCollection("projects/ext-datasets/assets/datasets/State_pan_india") + # .filter(ee.Filter.eq("Name", "Odisha")) + # .geometry() + # ) treeMeta = ee.Image(TREE_COVER_ASSET) startYearTree = treeMeta.select("start_year") diff --git a/computing/tree_health/ltp_stp/generate_ltp_stp_change_local.py b/computing/tree_health/ltp_stp/generate_ltp_stp_change_local.py index 4ef522db..df8bf639 100644 --- a/computing/tree_health/ltp_stp/generate_ltp_stp_change_local.py +++ b/computing/tree_health/ltp_stp/generate_ltp_stp_change_local.py @@ -20,11 +20,14 @@ "Trans Gangetic Plain Region": "TGPR", "Eastern Himalayan Region": "EHR", "Western Himalayan Region": "WHR", + "West Coast Plains & Ghat Region": "WCPGR", + "Gujarat Plains & Hills Region": "GPHR", + "Western Dry Region": "WDR", } @app.task(bind=True) -def generate_ltp_stp_change_local(self, start_year, end_year): +def generate_ltp_stp_change_local(self, year_1: int, year_2: int, scale=25): """Generate LTP-STP change rasters for the given year pair. For each ACZ, this function loads the LTP raster for year_1 and year_2, @@ -37,15 +40,17 @@ def generate_ltp_stp_change_local(self, start_year, end_year): # Input file paths for both years. ltp_file_1 = os.path.join( LOCAL_OUTPUT_BASE_DIR, - f"ltp_{start_year}", + f"{scale}", + f"ltp_{year_1}", acronym, - f"ltp_{start_year}_{acronym}.tif", + f"ltp_{year_1}_{acronym}.tif", ) ltp_file_2 = os.path.join( LOCAL_OUTPUT_BASE_DIR, - f"ltp_{end_year}", + f"{scale}", + f"ltp_{year_2}", acronym, - f"ltp_{end_year}_{acronym}.tif", + f"ltp_{year_2}_{acronym}.tif", ) if not (os.path.exists(ltp_file_1) and os.path.exists(ltp_file_2)): @@ -56,7 +61,7 @@ def generate_ltp_stp_change_local(self, start_year, end_year): with rasterio.open(ltp_file_1) as src1: ltp1 = src1.read(1) profile = src1.profile.copy() - nodata = src1.nodata if src1.nodata is not None else -9999 + nodata = src1.nodata if src1.nodata is not None else 255 # Read second year raster. with rasterio.open(ltp_file_2) as src2: @@ -93,15 +98,22 @@ def generate_ltp_stp_change_local(self, start_year, end_year): driver="GTiff", dtype="uint8", count=1, compress="lzw", nodata=nodata ) - outdir = os.path.join( - LOCAL_OUTPUT_BASE_DIR, f"ltp_change_{start_year}_{end_year}", acronym - ) + outdir = os.path.join(LOCAL_OUTPUT_BASE_DIR, f"ltp_change_{year_1}_{year_2}") os.makedirs(outdir, exist_ok=True) - outfile = os.path.join( - outdir, f"ltp_change_{start_year}_{end_year}_{acronym}.tif" - ) + outfile = os.path.join(outdir, f"ltp_change_{year_1}_{year_2}_{acronym}.tif") with rasterio.open(outfile, "w", **profile) as dst: dst.write(change, 1) print("Saved:", outfile) + + +# To merge the files in the output directory and run the following command in terminal: +# gdal_merge.py \ +# -o ltp_stp_change_2017_2024.tif \ +# -co COMPRESS=LZW \ +# -co TILED=YES \ +# -co BIGTIFF=YES \ +# -n 255 \ +# -a_nodata 255 \ +# *.tif diff --git a/computing/tree_health/ltp_stp/generate_ltp_stp_local.py b/computing/tree_health/ltp_stp/generate_ltp_stp_local.py index a9155e07..8825f986 100644 --- a/computing/tree_health/ltp_stp/generate_ltp_stp_local.py +++ b/computing/tree_health/ltp_stp/generate_ltp_stp_local.py @@ -43,12 +43,6 @@ # Patches below this are classified as Short-term Tree Patches (STP) AREA_THRESHOLD_HA = 1.0 -# Source LULC data resolution in meters -SOURCE_RESOLUTION_M = 10 - -# Target output resolution in meters (1:2.5 resampling ratio) -TARGET_RESOLUTION_M = 25 - # Base output directory for LTP/STP raster files LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/base_layers/tree_health/ltp_stp" @@ -68,11 +62,14 @@ "Western Himalayan Region": "WHR", "Upper Gangetic Plain Region": "UGPR", "Trans Gangetic Plain Region": "TGPR", + "West Coast Plains & Ghat Region": "WCPGR", + "Gujarat Plains & Hills Region": "GPHR", + "Western Dry Region": "WDR", } @app.task(bind=True) -def generate_ltp_stp_local(self, start_year, end_year): +def generate_ltp_stp_local(self, start_year, end_year, scale=25): """ Main function to generate LTP/STP classification rasters. @@ -83,6 +80,7 @@ def generate_ltp_stp_local(self, start_year, end_year): Args: start_year: Beginning year for analysis period end_year: End year for analysis period + scale: Scaling factor for LTP/STP """ # Load district boundary geometries from GeoJSON district_boundaries = gpd.read_file( @@ -117,6 +115,7 @@ def generate_ltp_stp_local(self, start_year, end_year): # Create output directory for this ACZ and year output_dir = os.path.join( LOCAL_OUTPUT_BASE_DIR, + f"{scale}", f"ltp_{year}", acronym, ) @@ -137,6 +136,7 @@ def generate_ltp_stp_local(self, start_year, end_year): lulc_sources, output_dir, year, + scale, ) # Merge individual district rasters into single ACZ mosaic @@ -147,7 +147,7 @@ def generate_ltp_stp_local(self, start_year, end_year): src.close() -def get_lulc_mode(lulc_sources, district): +def get_lulc_mode(lulc_sources, district, scale): """ Extract and process LULC data for a district. @@ -157,6 +157,7 @@ def get_lulc_mode(lulc_sources, district): Args: lulc_sources: List of opened rasterio LULC source files (3 files) district: GeoDataFrame containing the district geometry + scale: Scaling factor for LTP/STP Returns: tree: Binary array (1 = tree, 0 = non-tree) @@ -223,42 +224,48 @@ def get_lulc_mode(lulc_sources, district): ), ).astype(np.uint8) - # Resample modal LULC from 10m to 25m resolution - modal, transform = resample_to_25m( - modal, - transform, - source_transform, - crs, - nodata, - ) + if scale == 25: + # Resample modal LULC from 10m to 25m resolution + modal, transform = resample_tiff( + modal, + transform, + source_transform, + crs, + nodata, + source_resolution_m=10, + target_resolution_m=25, + ) # Extract only tree class pixels as binary mask tree = (modal == TREE_CLASS).astype(np.uint8) return tree, transform, profile -def resample_to_25m( +def resample_tiff( clipped, transform, source_transform, src_crs, nodata, + source_resolution_m, + target_resolution_m, ): """ - Resample raster from 10m to 25m resolution using MODE resampling. + Resample raster using MODE resampling. Snaps the output to the original LULC grid to ensure alignment with other datasets. This maintains consistency across multiple processing runs. Args: - clipped: Input raster array at 10m resolution + clipped: Input raster array transform: Current Affine transform of clipped data source_transform: Original Affine transform of LULC source src_crs: Coordinate reference system nodata: Nodata value - + source_resolution_m: Source resolution in m + target_resolution_m: Target resolution in m Returns: - dst: Resampled raster array at 25m resolution + dst: Resampled raster array dst_transform: Affine transform of output raster """ # Extract coordinates from current transform @@ -275,7 +282,7 @@ def resample_to_25m( src_top = source_transform.f # Calculate resampling scale factor (2.5x for 10m -> 25m) - scale = TARGET_RESOLUTION_M / SOURCE_RESOLUTION_M + scale = target_resolution_m / source_resolution_m # Calculate target pixel resolution target_res_x = abs(source_transform.a) * scale @@ -323,7 +330,7 @@ def resample_to_25m( def generate_district_tiff( - acronym, district_boundaries, district_names, lulc_sources, output_dir, year + acronym, district_boundaries, district_names, lulc_sources, output_dir, year, scale ): """ Generate LTP/STP classification rasters for each district. @@ -339,6 +346,7 @@ def generate_district_tiff( lulc_sources: List of opened LULC rasterio objects output_dir: Directory to save output GeoTIFFs year: Year for file naming + scale: Scale factor for patch size """ for district_name in district_names: print(" Processing district:", district_name) @@ -351,10 +359,7 @@ def generate_district_tiff( continue # Get modal LULC tree mask for the district - tree, transform, profile = get_lulc_mode( - lulc_sources, - district, - ) + tree, transform, profile = get_lulc_mode(lulc_sources, district, scale) # Polygonize tree patches - convert raster to vector polygons # shapes() returns (geometry, value) tuples for connected regions From affaf890acec34ddbbd4848aa0971f04f0db7ddf Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 30 Jul 2026 01:49:36 +0530 Subject: [PATCH 076/120] layer generation in bulk --- computing/bulk_layer_generation.py | 228 ++++++++++++++++ .../commands/bulk_generate_layers.py | 128 +++++++++ computing/tasks.py | 39 ++- computing/tests.py | 248 +++++++++++++++++- 4 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 computing/bulk_layer_generation.py create mode 100644 computing/management/commands/bulk_generate_layers.py diff --git a/computing/bulk_layer_generation.py b/computing/bulk_layer_generation.py new file mode 100644 index 00000000..3d45dd5a --- /dev/null +++ b/computing/bulk_layer_generation.py @@ -0,0 +1,228 @@ +import inspect +from dataclasses import asdict, dataclass +from typing import Any, Callable, Mapping + +from django.utils.module_loading import import_string + +from geoadmin.models import TehsilSOI +from utilities.pipelines import api_request_payload + + +@dataclass(frozen=True) +class Location: + state: str + district: str + block: str + + def asdict(self) -> dict[str, str]: + return asdict(self) + + +@dataclass(frozen=True) +class PipelineSpec: + runner_path: str + payload_builder: Callable[[Mapping[str, str], bool], dict[str, Any]] + + def run(self, location: Mapping[str, str], overwrite: bool) -> Any: + runner = import_string(self.runner_path) + return runner(self.payload_builder(location, overwrite)) + + +def _standard_payload( + location: Mapping[str, str], overwrite: bool +) -> dict[str, Any]: + return api_request_payload( + { + "state": location["state"], + "district": location["district"], + "block": location["block"], + "overwrite": overwrite, + }, + overwrite=overwrite, + ) + + +STANDARD_PIPELINES = { + "antyodaya": PipelineSpec( + "computing.misc.antyodaya.run_antyodaya_request", + _standard_payload, + ), + "facilities_proximity": PipelineSpec( + "computing.misc.facilities.run_facilities_request", + _standard_payload, + ), + "livestocks": PipelineSpec( + "computing.misc.livestocks.run_livestocks_request", + _standard_payload, + ), +} + + +def _task_registry(compute: str): + from computing.layer_dependency.layer_generation_in_order import TASK_REGISTRIES + + return TASK_REGISTRIES[compute] + + +def _normalize_compute(compute: str) -> str: + value = str(compute or "local").strip().lower() + if value not in {"gee", "local"}: + raise ValueError("compute must be either 'gee' or 'local'") + return value + + +def pipeline_names(compute: str = "local") -> tuple[str, ...]: + compute = _normalize_compute(compute) + names = set(_task_registry(compute)) + if compute == "local": + names.update(STANDARD_PIPELINES) + return tuple(sorted(names)) + + +def get_pipeline(name: str, compute: str = "local"): + compute = _normalize_compute(compute) + if compute == "local" and name in STANDARD_PIPELINES: + return STANDARD_PIPELINES[name] + + try: + return _task_registry(compute)[name] + except KeyError as exc: + raise ValueError( + f"Unknown {compute} pipeline '{name}'. Use --list-pipelines " + "to see available pipelines." + ) from exc + + +def _legacy_runner_kwargs( + runner, + location: Mapping[str, str], + *, + start_year: int | None, + end_year: int | None, + gee_account_id: str | None, + overwrite: bool, +) -> dict[str, Any]: + target = getattr(runner, "run", runner) + parameters = inspect.signature(target).parameters + accepts_kwargs = any( + parameter.kind == inspect.Parameter.VAR_KEYWORD + for parameter in parameters.values() + ) + available = { + "state": location["state"], + "district": location["district"], + "block": location["block"], + "start_year": start_year, + "end_year": end_year, + "gee_account_id": gee_account_id, + } + kwargs = { + name: value + for name, value in available.items() + if value is not None and (accepts_kwargs or name in parameters) + } + if accepts_kwargs or "overwrite" in parameters: + kwargs["overwrite"] = overwrite + if "is_override" in parameters: + kwargs["is_override"] = overwrite + + missing = [ + name + for name, parameter in parameters.items() + if name != "self" + and parameter.default is inspect.Parameter.empty + and parameter.kind + in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + and name not in kwargs + ] + if missing: + raise ValueError( + f"Pipeline requires unsupported or missing arguments: {', '.join(missing)}" + ) + return kwargs + + +def validate_pipeline( + name: str, + *, + compute: str = "local", + start_year: int | None = None, + end_year: int | None = None, + gee_account_id: str | None = None, + overwrite: bool = True, +) -> None: + runner = get_pipeline(name, compute) + if isinstance(runner, PipelineSpec): + return + _legacy_runner_kwargs( + runner, + {"state": "state", "district": "district", "block": "block"}, + start_year=start_year, + end_year=end_year, + gee_account_id=gee_account_id, + overwrite=overwrite, + ) + + +def run_pipeline( + name: str, + location: Mapping[str, str], + overwrite: bool = True, + *, + compute: str = "local", + start_year: int | None = None, + end_year: int | None = None, + gee_account_id: str | None = None, +) -> Any: + runner = get_pipeline(name, compute) + if isinstance(runner, PipelineSpec): + return runner.run(location, overwrite) + kwargs = _legacy_runner_kwargs( + runner, + location, + start_year=start_year, + end_year=end_year, + gee_account_id=gee_account_id, + overwrite=overwrite, + ) + return runner(**kwargs) + + +def get_active_locations( + *, + state: str | None = None, + district: str | None = None, + block: str | None = None, + limit: int | None = None, +) -> list[Location]: + queryset = ( + TehsilSOI.objects.filter( + active_status=True, + district__active_status=True, + district__state__active_status=True, + ) + .select_related("district__state") + .order_by( + "district__state__state_name", + "district__district_name", + "tehsil_name", + "pk", + ) + ) + if state: + queryset = queryset.filter(district__state__state_name__iexact=state) + if district: + queryset = queryset.filter(district__district_name__iexact=district) + if block: + queryset = queryset.filter(tehsil_name__iexact=block) + if limit is not None: + queryset = queryset[:limit] + + return [ + Location( + state=tehsil.district.state.state_name, + district=tehsil.district.district_name, + block=tehsil.tehsil_name, + ) + for tehsil in queryset + ] diff --git a/computing/management/commands/bulk_generate_layers.py b/computing/management/commands/bulk_generate_layers.py new file mode 100644 index 00000000..8272eedd --- /dev/null +++ b/computing/management/commands/bulk_generate_layers.py @@ -0,0 +1,128 @@ +from django.core.management.base import BaseCommand, CommandError + +from computing.bulk_layer_generation import ( + get_active_locations, + pipeline_names, + validate_pipeline, +) +from computing.tasks import bulk_generate_layer + + +DEFAULT_QUEUE = "layer_bulk" + + +class Command(BaseCommand): + help = "Queue a registered layer pipeline for active locations." + + def add_arguments(self, parser): + parser.add_argument("pipeline", nargs="?") + parser.add_argument("--all-active", action="store_true") + parser.add_argument("--state") + parser.add_argument("--district") + parser.add_argument("--block") + parser.add_argument("--limit", type=int) + parser.add_argument("--queue", default=DEFAULT_QUEUE) + parser.add_argument("--compute", choices=("local", "gee"), default="local") + parser.add_argument("--start-year", type=int) + parser.add_argument("--end-year", type=int) + parser.add_argument("--gee-account-id") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--list-pipelines", action="store_true") + + overwrite_group = parser.add_mutually_exclusive_group() + overwrite_group.add_argument( + "--overwrite", dest="overwrite", action="store_true" + ) + overwrite_group.add_argument( + "--no-overwrite", dest="overwrite", action="store_false" + ) + parser.set_defaults(overwrite=True) + + def handle(self, *args, **options): + if options["list_pipelines"]: + self._list_pipelines(options["compute"]) + return + + pipeline = options["pipeline"] + if not pipeline: + raise CommandError("A pipeline name is required.") + try: + validate_pipeline( + pipeline, + compute=options["compute"], + start_year=options["start_year"], + end_year=options["end_year"], + gee_account_id=options["gee_account_id"], + overwrite=options["overwrite"], + ) + except ValueError as exc: + raise CommandError(str(exc)) from exc + + filters = { + name: options[name] + for name in ("state", "district", "block") + if options[name] + } + if not options["all_active"] and not filters: + raise CommandError( + "Specify --all-active or at least one of " + "--state, --district, or --block." + ) + if options["all_active"] and filters: + raise CommandError( + "--all-active cannot be combined with location filters." + ) + if options["limit"] is not None and options["limit"] < 1: + raise CommandError("--limit must be greater than zero.") + queue = options["queue"].strip() + if not queue: + raise CommandError("--queue cannot be empty.") + + locations = get_active_locations( + **filters, + limit=options["limit"], + ) + if not locations: + raise CommandError("No active locations matched the requested scope.") + + action = "Would queue" if options["dry_run"] else "Queueing" + self.stdout.write( + f"{action} {options['compute']} pipeline '{pipeline}' for " + f"{len(locations)} active " + f"location(s) on queue '{queue}'." + ) + + for location in locations: + location_data = location.asdict() + label = ( + f"{location.state}/{location.district}/{location.block}" + ) + if options["dry_run"]: + self.stdout.write(f" {label}") + continue + + result = bulk_generate_layer.apply_async( + kwargs={ + "pipeline": pipeline, + "location": location_data, + "overwrite": options["overwrite"], + "compute": options["compute"], + "start_year": options["start_year"], + "end_year": options["end_year"], + "gee_account_id": options["gee_account_id"], + }, + queue=queue, + ) + self.stdout.write(f" {label}: {result.id}") + + if options["dry_run"]: + self.stdout.write(self.style.SUCCESS("Dry run complete; no tasks queued.")) + else: + self.stdout.write( + self.style.SUCCESS(f"Queued {len(locations)} task(s).") + ) + + def _list_pipelines(self, compute): + self.stdout.write(f"Registered {compute} pipelines:") + for name in pipeline_names(compute): + self.stdout.write(f" - {name}") diff --git a/computing/tasks.py b/computing/tasks.py index 910ae270..71f0c21a 100644 --- a/computing/tasks.py +++ b/computing/tasks.py @@ -1,3 +1,40 @@ +import logging + +from nrm_app.celery import app + from computing.STAC_specs.stac_collection import generate_stac_collection_task +from computing.bulk_layer_generation import run_pipeline + + +logger = logging.getLogger(__name__) + + +@app.task(name="computing.tasks.bulk_generate_layer") +def bulk_generate_layer( + pipeline, + location, + overwrite=True, + compute="local", + start_year=None, + end_year=None, + gee_account_id=None, +): + logger.info( + "Running bulk pipeline %s for %s/%s/%s", + pipeline, + location["state"], + location["district"], + location["block"], + ) + return run_pipeline( + pipeline, + location, + overwrite=overwrite, + compute=compute, + start_year=start_year, + end_year=end_year, + gee_account_id=gee_account_id, + ) + -__all__ = ["generate_stac_collection_task"] +__all__ = ["bulk_generate_layer", "generate_stac_collection_task"] diff --git a/computing/tests.py b/computing/tests.py index 6aba34e3..1107828e 100644 --- a/computing/tests.py +++ b/computing/tests.py @@ -1,12 +1,18 @@ +from io import StringIO from unittest.mock import call, patch -from django.test import SimpleTestCase +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import SimpleTestCase, TestCase +from computing.bulk_layer_generation import Location, get_active_locations, run_pipeline from computing.layer_dependency.layer_generation_in_order import get_args from computing.surface_water_bodies.swb_local import ( _continue_swb_in_gee, _final_layer_name, ) +from computing.tasks import bulk_generate_layer +from geoadmin.models import DistrictSOI, StateSOI, TehsilSOI class LocalSwbContinuationTests(SimpleTestCase): @@ -91,3 +97,243 @@ def test_runs_swb3_and_swb4_without_local_suffix( [call("swb3-asset"), call("swb4-asset")], ) self.assertEqual(result, ("district_block", "swb3-asset")) + + +class BulkPipelineRegistryTests(SimpleTestCase): + @patch("computing.bulk_layer_generation.import_string") + def test_registered_pipeline_builds_standard_payload(self, import_string): + runner = import_string.return_value + location = { + "state": "Jharkhand", + "district": "Dumka", + "block": "Masalia", + } + + run_pipeline("antyodaya", location, overwrite=False) + + runner.assert_called_once_with( + { + "scope": { + "level": "tehsil", + "state_name": "Jharkhand", + "district_name": "Dumka", + "tehsil_name": "Masalia", + }, + "outputs": {}, + "publish": { + "sync_to_geoserver": True, + "overwrite": False, + "register_layers": True, + "use_pregenerated": False, + }, + } + ) + + @patch("computing.tasks.run_pipeline") + def test_bulk_task_runs_registered_pipeline(self, run_registered_pipeline): + location = { + "state": "Jharkhand", + "district": "Dumka", + "block": "Masalia", + } + + bulk_generate_layer.run("livestocks", location, overwrite=False) + + run_registered_pipeline.assert_called_once_with( + "livestocks", + location, + overwrite=False, + compute="local", + start_year=None, + end_year=None, + gee_account_id=None, + ) + + @patch("computing.bulk_layer_generation._task_registry") + def test_existing_registry_pipeline_receives_supported_arguments( + self, task_registry + ): + def lulc( + state, + district, + block, + start_year, + end_year, + gee_account_id=None, + ): + return ( + state, + district, + block, + start_year, + end_year, + gee_account_id, + ) + + task_registry.return_value = {"lulc_v3": lulc} + + result = run_pipeline( + "lulc_v3", + { + "state": "Jharkhand", + "district": "Dumka", + "block": "Masalia", + }, + compute="local", + start_year=2018, + end_year=2024, + ) + + self.assertEqual( + result, + ("Jharkhand", "Dumka", "Masalia", 2018, 2024, None), + ) + + +class BulkLayerCommandTests(SimpleTestCase): + def test_command_requires_explicit_scope(self): + with self.assertRaisesMessage(CommandError, "Specify --all-active"): + call_command("bulk_generate_layers", "antyodaya") + + @patch("computing.bulk_layer_generation._task_registry", return_value={}) + def test_command_rejects_unknown_pipeline(self, task_registry): + with self.assertRaisesMessage(CommandError, "Unknown local pipeline"): + call_command("bulk_generate_layers", "missing", "--all-active") + + @patch( + "computing.management.commands.bulk_generate_layers." + "bulk_generate_layer.apply_async" + ) + @patch( + "computing.management.commands.bulk_generate_layers.get_active_locations" + ) + def test_command_dry_run_does_not_enqueue( + self, get_locations, apply_async + ): + get_locations.return_value = [ + Location("Jharkhand", "Dumka", "Jarmundi") + ] + output = StringIO() + + call_command( + "bulk_generate_layers", + "antyodaya", + "--all-active", + "--limit=1", + "--dry-run", + stdout=output, + ) + + apply_async.assert_not_called() + self.assertIn("Dry run complete", output.getvalue()) + + @patch( + "computing.management.commands.bulk_generate_layers." + "bulk_generate_layer.apply_async" + ) + @patch( + "computing.management.commands.bulk_generate_layers.get_active_locations" + ) + def test_command_routes_tasks_to_bulk_queue( + self, get_locations, apply_async + ): + get_locations.return_value = [ + Location("Jharkhand", "Dumka", "Masalia") + ] + apply_async.return_value.id = "task-id" + + call_command( + "bulk_generate_layers", + "livestocks", + "--block=Masalia", + "--no-overwrite", + stdout=StringIO(), + ) + + apply_async.assert_called_once_with( + kwargs={ + "pipeline": "livestocks", + "location": { + "state": "Jharkhand", + "district": "Dumka", + "block": "Masalia", + }, + "overwrite": False, + "compute": "local", + "start_year": None, + "end_year": None, + "gee_account_id": None, + }, + queue="layer_bulk", + ) + + +class BulkLayerGenerationTests(TestCase): + @classmethod + def setUpTestData(cls): + active_state = StateSOI.objects.create( + state_name="Jharkhand", active_status=True + ) + inactive_state = StateSOI.objects.create( + state_name="Odisha", active_status=False + ) + dumka = DistrictSOI.objects.create( + state=active_state, + district_name="Dumka", + active_status=True, + ) + inactive_district = DistrictSOI.objects.create( + state=active_state, + district_name="Inactive District", + active_status=False, + ) + inactive_state_district = DistrictSOI.objects.create( + state=inactive_state, + district_name="Mayurbhanj", + active_status=True, + ) + TehsilSOI.objects.create( + district=dumka, tehsil_name="Masalia", active_status=True + ) + TehsilSOI.objects.create( + district=dumka, tehsil_name="Jarmundi", active_status=True + ) + TehsilSOI.objects.create( + district=dumka, tehsil_name="Inactive Block", active_status=False + ) + TehsilSOI.objects.create( + district=inactive_district, + tehsil_name="Active Block", + active_status=True, + ) + TehsilSOI.objects.create( + district=inactive_state_district, + tehsil_name="Baripada", + active_status=True, + ) + + def test_active_locations_require_active_hierarchy_and_are_ordered(self): + locations = get_active_locations() + + self.assertEqual( + [location.block for location in locations], + ["Jarmundi", "Masalia"], + ) + + def test_active_locations_apply_case_insensitive_filters_and_limit(self): + locations = get_active_locations( + state="jharkhand", + district="dumka", + block="jarmundi", + limit=1, + ) + + self.assertEqual(len(locations), 1) + self.assertEqual( + locations[0].asdict(), + { + "state": "Jharkhand", + "district": "Dumka", + "block": "Jarmundi", + }, + ) From 908cacea4a19613dc013045f79a0c2e43b371c95 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 30 Jul 2026 12:10:29 +0530 Subject: [PATCH 077/120] lulc layer --- computing/lulc/lulc_v3_local.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/computing/lulc/lulc_v3_local.py b/computing/lulc/lulc_v3_local.py index 99c1a7d2..bf9d1726 100644 --- a/computing/lulc/lulc_v3_local.py +++ b/computing/lulc/lulc_v3_local.py @@ -15,7 +15,7 @@ from nrm_app.celery import app from utilities.gee_utils import valid_gee_text -GEOSERVER_WORKSPACE = "LULC_v3" +GEOSERVER_WORKSPACE = "LULC_level_3" GEOSERVER_STYLE = "lulc_level_3_style" LOCAL_ALGORITHM = "local_lulc_v3_clip" LOCAL_ALGORITHM_VERSION = "local-1.0" From 824cc6dfd64b7a6749a1c086e6b0cb3c9115ced1 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 30 Jul 2026 13:04:27 +0530 Subject: [PATCH 078/120] single api for soil type --- computing/api.py | 40 ++++++++++++++++ computing/soil_type/soil_type_local.py | 6 --- computing/soil_type/tests.py | 65 ++++++++++++++++++++++++++ computing/urls.py | 5 ++ 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/computing/api.py b/computing/api.py index 2e49e70b..72e2bb75 100644 --- a/computing/api.py +++ b/computing/api.py @@ -57,6 +57,7 @@ ) from .forest_fire.forest_fire import generate_forest_fire_layer from .soil_health.soil_health import soil_health_local +from .soil_type.soil_type_local import generate_soil_type_local from .drought.drought import calculate_drought from .drought.drought_causality import drought_causality @@ -2825,6 +2826,45 @@ def generate_soil_health(request): return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) +@api_view(["POST"]) +@schema(None) +def generate_soil_type(request): + try: + location = { + field: request.data.get(field) + for field in ("state", "district", "block") + } + compute = request.data.get("compute") + missing_fields = [field for field, value in location.items() if not value] + if not compute: + missing_fields.append("compute") + if missing_fields: + return Response( + {"Exception": f"Missing required fields: {', '.join(missing_fields)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if compute.lower() != "local": + return Response( + {"Exception": "Soil type only supports compute=local"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + generate_soil_type_local.apply_async( + kwargs={field: value.lower() for field, value in location.items()}, + queue="nrm", + ) + return Response( + {"Success": "Successfully initiated generate_soil_type task"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + logger.exception("Exception in generate_soil_type api") + return Response( + {"Exception": str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + @api_view(["POST"]) @schema(None) def generate_ltp_stp(request): diff --git a/computing/soil_type/soil_type_local.py b/computing/soil_type/soil_type_local.py index 13bf0e9c..813ebd45 100644 --- a/computing/soil_type/soil_type_local.py +++ b/computing/soil_type/soil_type_local.py @@ -267,12 +267,6 @@ def run_soil_type_local( dataset_name=DATASET_NAME, misc={ "is_generated_locally": True, - "geoserver_available": geoserver_ok, - "geoserver_sync_response": geoserver_response, - "source_rasters": [ - str(raster_paths[spec["column"]]) - for spec in SOIL_PROPERTY_SPECS - ], }, algorithm=LOCAL_ALGORITHM, algorithm_version=LOCAL_ALGORITHM_VERSION, diff --git a/computing/soil_type/tests.py b/computing/soil_type/tests.py index 87fda718..56351cb3 100644 --- a/computing/soil_type/tests.py +++ b/computing/soil_type/tests.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock, patch import numpy as np +from django.urls import reverse +from rest_framework.test import APISimpleTestCase from computing.soil_type.soil_type_local import ( _aggregate_values, @@ -110,3 +112,66 @@ def test_failed_publication_does_not_update_cloud_metadata(self): self.assertFalse(success) mocks[5].assert_not_called() mocks[6].assert_not_called() + + +class SoilTypeAPITests(APISimpleTestCase): + def setUp(self): + self.client.force_authenticate(user=MagicMock(is_authenticated=True)) + + @patch("computing.api.generate_soil_type_local.apply_async") + def test_generate_soil_type_queues_local_task(self, apply_async): + response = self.client.post( + reverse("generate_soil_type"), + { + "state": "Puducherry", + "district": "Puducherry", + "block": "Bahur", + "compute": "local", + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + apply_async.assert_called_once_with( + kwargs={ + "state": "puducherry", + "district": "puducherry", + "block": "bahur", + }, + queue="nrm", + ) + + @patch("computing.api.generate_soil_type_local.apply_async") + def test_generate_soil_type_rejects_missing_location(self, apply_async): + response = self.client.post( + reverse("generate_soil_type"), + {"state": "Puducherry", "compute": "local"}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + {"Exception": "Missing required fields: district, block"}, + ) + apply_async.assert_not_called() + + @patch("computing.api.generate_soil_type_local.apply_async") + def test_generate_soil_type_rejects_non_local_compute(self, apply_async): + response = self.client.post( + reverse("generate_soil_type"), + { + "state": "Puducherry", + "district": "Puducherry", + "block": "Bahur", + "compute": "gee", + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + {"Exception": "Soil type only supports compute=local"}, + ) + apply_async.assert_not_called() diff --git a/computing/urls.py b/computing/urls.py index 3b11c340..b5bdfe23 100644 --- a/computing/urls.py +++ b/computing/urls.py @@ -308,6 +308,11 @@ api.generate_soil_health, name="generate_soil_health", ), + path( + "generate_soil_type/", + api.generate_soil_type, + name="generate_soil_type", + ), path( "generate_ltp_stp/", api.generate_ltp_stp, From 571c579c62bce98e4f0320093622f48ffbaafa05 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 30 Jul 2026 16:11:03 +0530 Subject: [PATCH 079/120] bulk layer update --- computing/bulk_layer_generation.py | 17 ++++++- .../commands/bulk_generate_layers.py | 11 ++++- computing/tests.py | 48 +++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/computing/bulk_layer_generation.py b/computing/bulk_layer_generation.py index 3d45dd5a..483a9d98 100644 --- a/computing/bulk_layer_generation.py +++ b/computing/bulk_layer_generation.py @@ -1,7 +1,10 @@ import inspect from dataclasses import asdict, dataclass +from functools import reduce +from operator import or_ from typing import Any, Callable, Mapping +from django.db.models import Q from django.utils.module_loading import import_string from geoadmin.models import TehsilSOI @@ -193,8 +196,12 @@ def get_active_locations( state: str | None = None, district: str | None = None, block: str | None = None, + blocks: list[str] | None = None, limit: int | None = None, ) -> list[Location]: + if block and blocks: + raise ValueError("Use either block or blocks, not both.") + queryset = ( TehsilSOI.objects.filter( active_status=True, @@ -213,8 +220,14 @@ def get_active_locations( queryset = queryset.filter(district__state__state_name__iexact=state) if district: queryset = queryset.filter(district__district_name__iexact=district) - if block: - queryset = queryset.filter(tehsil_name__iexact=block) + selected_blocks = [block] if block else list(dict.fromkeys(blocks or [])) + if selected_blocks: + queryset = queryset.filter( + reduce( + or_, + (Q(tehsil_name__iexact=name) for name in selected_blocks), + ) + ) if limit is not None: queryset = queryset[:limit] diff --git a/computing/management/commands/bulk_generate_layers.py b/computing/management/commands/bulk_generate_layers.py index 8272eedd..9445a92a 100644 --- a/computing/management/commands/bulk_generate_layers.py +++ b/computing/management/commands/bulk_generate_layers.py @@ -19,7 +19,12 @@ def add_arguments(self, parser): parser.add_argument("--all-active", action="store_true") parser.add_argument("--state") parser.add_argument("--district") - parser.add_argument("--block") + parser.add_argument( + "--block", + dest="blocks", + action="append", + help="Tehsil/block name. Repeat to select multiple blocks.", + ) parser.add_argument("--limit", type=int) parser.add_argument("--queue", default=DEFAULT_QUEUE) parser.add_argument("--compute", choices=("local", "gee"), default="local") @@ -60,9 +65,11 @@ def handle(self, *args, **options): filters = { name: options[name] - for name in ("state", "district", "block") + for name in ("state", "district") if options[name] } + if options["blocks"]: + filters["blocks"] = options["blocks"] if not options["all_active"] and not filters: raise CommandError( "Specify --all-active or at least one of " diff --git a/computing/tests.py b/computing/tests.py index 1107828e..6a7d2688 100644 --- a/computing/tests.py +++ b/computing/tests.py @@ -266,6 +266,42 @@ def test_command_routes_tasks_to_bulk_queue( }, queue="layer_bulk", ) + get_locations.assert_called_once_with( + blocks=["Masalia"], + limit=None, + ) + + @patch( + "computing.management.commands.bulk_generate_layers." + "bulk_generate_layer.apply_async" + ) + @patch( + "computing.management.commands.bulk_generate_layers.get_active_locations" + ) + def test_command_accepts_multiple_blocks(self, get_locations, apply_async): + get_locations.return_value = [ + Location("Jharkhand", "Dumka", "Jarmundi"), + Location("Jharkhand", "Dumka", "Masalia"), + ] + apply_async.return_value.id = "task-id" + + call_command( + "bulk_generate_layers", + "lulc_v3", + "--district=Dumka", + "--block=Jarmundi", + "--block=Masalia", + "--start-year=2018", + "--end-year=2024", + stdout=StringIO(), + ) + + get_locations.assert_called_once_with( + district="Dumka", + blocks=["Jarmundi", "Masalia"], + limit=None, + ) + self.assertEqual(apply_async.call_count, 2) class BulkLayerGenerationTests(TestCase): @@ -337,3 +373,15 @@ def test_active_locations_apply_case_insensitive_filters_and_limit(self): "block": "Jarmundi", }, ) + + def test_active_locations_accept_multiple_blocks(self): + locations = get_active_locations( + state="jharkhand", + district="dumka", + blocks=["masalia", "JARMUNDI"], + ) + + self.assertEqual( + [location.block for location in locations], + ["Jarmundi", "Masalia"], + ) From 5d7a99b12c241a3d1dbe8e8cbc4f4adab8c52bf6 Mon Sep 17 00:00:00 2001 From: aman verma Date: Thu, 30 Jul 2026 11:29:38 +0000 Subject: [PATCH 080/120] soil health single vector --- computing/soil_health/soil_health.py | 157 +++++++++--------- .../generate_spei/download_base_datasets.py | 23 +-- .../generate_spei/generate_ppet_multiband.py | 2 +- 3 files changed, 96 insertions(+), 86 deletions(-) diff --git a/computing/soil_health/soil_health.py b/computing/soil_health/soil_health.py index 9941d9d4..715b578f 100644 --- a/computing/soil_health/soil_health.py +++ b/computing/soil_health/soil_health.py @@ -246,19 +246,19 @@ def clip_soil_health_raster( update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) print("Sync to GeoServer flag updated for Soil health raster") - try: - layer_STAC_generated = generate_STAC_layerwise.generate_raster_stac( - state=state, - district=district, - block=block, - layer_name=layer_name, - ) - update_layer_sync_status( - layer_id=layer_id, is_stac_specs_generated=layer_STAC_generated - ) - print("STAC metadata updated for Soil health raster") - except Exception as e: - print(f"Error generating STAC: {e}") + # try: + # layer_STAC_generated = generate_STAC_layerwise.generate_raster_stac( + # state=state, + # district=district, + # block=block, + # layer_name=layer_name, + # ) + # update_layer_sync_status( + # layer_id=layer_id, is_stac_specs_generated=layer_STAC_generated + # ) + # print("STAC metadata updated for Soil health raster") + # except Exception as e: + # print(f"Error generating STAC: {e}") return all(geoserver_statuses) if push_to_geoserver else True @@ -304,86 +304,95 @@ def vectorize_soil_health( state, precomputed_roi_dir=PRECOMPUTED_TEHSIL_WATERSHED_DIR, ) - layer_name = f"{asset_suffix}_soil_health" - geoserver_statuses = [] + base_layer_name = f"{asset_suffix}_soil_health" + output_layer_name = f"{base_layer_name}_vector" + result_gdf = roi_gdf.copy() + 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}", + layer_name=f"{base_layer_name}_raster_{nutrient}", output_base_dir=LOCAL_OUTPUT_BASE_DIR, state=state, district=district, block=block, ) - result_gdf = nutrient_stats_for_geometries( + nutrient_gdf = nutrient_stats_for_geometries( roi_gdf=roi_gdf, raster_path=raster_path, percentiles=tuple(NUTRIENT_PERCENTILES), nutrient=nutrient, ) - output_layer_name = f"{layer_name}_vector_{nutrient}" - output_path = build_output_vector_path( + nutrient_columns = [ + column + for column in nutrient_gdf.columns + if column.startswith(f"{nutrient}_") + ] + for column in nutrient_columns: + result_gdf[column] = nutrient_gdf[column] + + output_path = build_output_vector_path( + layer_name=output_layer_name, + state=state, + district=district, + block=block, + output_base_dir=LOCAL_OUTPUT_BASE_DIR, + ) + asset_id = write_vector_output( + gdf=result_gdf, + output_path=output_path, + layer_name=output_layer_name, + ) + print(f"Saved soil health vector: {output_path}") + + geoserver_status = False + if push_to_geoserver: + geoserver_response = push_local_vector_to_geoserver( + path=os.path.splitext(output_path)[0], layer_name=output_layer_name, + workspace=GEOSERVER_VECTOR_WORKSPACE, + 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 + geoserver_status = True + + if sync_layer_metadata: + layer_id = save_layer_info_to_db( state=state, district=district, block=block, - output_base_dir=LOCAL_OUTPUT_BASE_DIR, - ) - asset_id = write_vector_output( - gdf=result_gdf, - output_path=output_path, layer_name=output_layer_name, + asset_id=asset_id, + dataset_name="Soil Health Vector", + misc={"is_generated_locally": True}, + algorithm=LOCAL_ALGORITHM, + algorithm_version=LOCAL_ALGORITHM_VERSION, ) - 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=output_layer_name, - workspace=GEOSERVER_VECTOR_WORKSPACE, - file_type="gpkg", - ) - print(f"GeoServer response for {nutrient}: {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=output_layer_name, - asset_id=asset_id, - dataset_name="Soil Health Vector", - misc={"is_generated_locally": True}, - algorithm=LOCAL_ALGORITHM, - algorithm_version=LOCAL_ALGORITHM_VERSION, - ) - logger.info("Saved layer metadata to DB: layer_id=%s", layer_id) - if layer_id and push_to_geoserver: - update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) - print("Sync to GeoServer flag updated for Soil health vector") - - try: - layer_STAC_generated = generate_STAC_layerwise.generate_vector_stac( - state=state, - district=district, - block=block, - layer_name="soil_health_vector", - ) - update_layer_sync_status( - layer_id=layer_id, is_stac_specs_generated=layer_STAC_generated - ) - print("STAC metadata updated for Soil health vector") - except Exception as e: - print(f"Error generating STAC: {e}") - - return all(geoserver_statuses) if push_to_geoserver else True + logger.info("Saved layer metadata to DB: layer_id=%s", layer_id) + if layer_id and push_to_geoserver: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Sync to GeoServer flag updated for Soil health vector") + + # try: + # layer_STAC_generated = generate_STAC_layerwise.generate_vector_stac( + # state=state, + # district=district, + # block=block, + # layer_name=output_layer_name, + # ) + # update_layer_sync_status( + # layer_id=layer_id, is_stac_specs_generated=layer_STAC_generated + # ) + # print("STAC metadata updated for Soil health vector") + # except Exception as e: + # print(f"Error generating STAC: {e}") + + return geoserver_status if push_to_geoserver else True @app.task(bind=True) diff --git a/computing/spei/generate_spei/download_base_datasets.py b/computing/spei/generate_spei/download_base_datasets.py index d21c8747..6b1383fc 100644 --- a/computing/spei/generate_spei/download_base_datasets.py +++ b/computing/spei/generate_spei/download_base_datasets.py @@ -27,7 +27,6 @@ CHIRPS_COLLECTION = "UCSB-CHG/CHIRPS/DAILY" MODIS_PET_COLLECTION = "MODIS/061/MOD16A2GF" -DEFAULT_PROJECT = "ee-corestackdev" DATASET_CHOICES = ("chirps", "modis_pet", "both") @@ -172,16 +171,16 @@ def get_last_downloaded_date( Returns the latest date as a string (YYYYMMDD or YYYYMM format). """ dataset_output_dir = output_dir / str(aez) / frequency / dataset - + if not dataset_output_dir.exists(): return None - + # Get all .tif files in the directory tif_files = list(dataset_output_dir.glob("*.tif")) - + if not tif_files: return None - + # Extract dates from filenames (e.g., "CHIRPS_20240101.tif" or "CHIRPS_202401.tif") dates = [] for file_path in tif_files: @@ -196,10 +195,10 @@ def get_last_downloaded_date( dates.append(date_str) except (ValueError, IndexError): continue - + if not dates: return None - + # Sort and return the latest date return sorted(dates)[-1] @@ -312,17 +311,19 @@ def download_dataset_images( name = dataset_label(dataset) print(output_dir, aez, frequency, dataset) dataset_output_dir = Path(output_dir) / str(aez) / frequency / dataset - print("start_date",start_date) + print("start_date", start_date) # Check if files already exist and find the last downloaded date - last_downloaded = get_last_downloaded_date(Path(output_dir), aez, frequency, dataset) - + last_downloaded = get_last_downloaded_date( + Path(output_dir), aez, frequency, dataset + ) + if last_downloaded and not overwrite: # Convert last downloaded date to next date next_date_str = get_next_date(last_downloaded, frequency) print(f"Last downloaded date: {last_downloaded}") print(f"Resuming downloads from: {next_date_str}") start_date = next_date_str - + collection, region, _, labeled_dates = build_dataset_image( aez=aez, dataset=dataset, diff --git a/computing/spei/generate_spei/generate_ppet_multiband.py b/computing/spei/generate_spei/generate_ppet_multiband.py index a05cd155..a7ff807c 100644 --- a/computing/spei/generate_spei/generate_ppet_multiband.py +++ b/computing/spei/generate_spei/generate_ppet_multiband.py @@ -84,7 +84,7 @@ def reproject_modis_to_chirps_grid( def ppet_multiband( aez=None, start: int = 2004, - end: int = 2024, + end: int = 2024, # TODO remove hardcoding ) -> Path: """ SPEI Pipeline - Step 1 (Local P-PET) From db963802e41d3ed6eb04cc1501cd58cec48038aa Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Thu, 30 Jul 2026 17:03:08 +0530 Subject: [PATCH 081/120] overall change map param fix --- computing/layer_dependency/local_layer_map.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/computing/layer_dependency/local_layer_map.json b/computing/layer_dependency/local_layer_map.json index 92a4fab5..749e236d 100644 --- a/computing/layer_dependency/local_layer_map.json +++ b/computing/layer_dependency/local_layer_map.json @@ -75,7 +75,8 @@ "use_global_args": true, "children": [ { - "name": "tree_health_overall_change_vector" + "name": "tree_health_overall_change_vector", + "use_global_args": false } ] }, From 28c00207d40aed2d17f1c3f6f788cee4e1a6fa1a Mon Sep 17 00:00:00 2001 From: pawangramvaani Date: Thu, 30 Jul 2026 23:45:31 -0700 Subject: [PATCH 082/120] return boolean value --- computing/soil_health/soil_health.py | 9 +++++++-- computing/tree_health/local/ccd_local.py | 10 ++++------ .../tree_health/local/overall_change_vector_local.py | 9 +++++---- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/computing/soil_health/soil_health.py b/computing/soil_health/soil_health.py index 715b578f..82004965 100644 --- a/computing/soil_health/soil_health.py +++ b/computing/soil_health/soil_health.py @@ -407,7 +407,7 @@ def soil_health_local( push_to_geoserver=True, sync_layer_metadata=True, ): - clip_soil_health_raster( + soil_health_raster_on_geoserver = clip_soil_health_raster( state, district, block, @@ -418,7 +418,7 @@ def soil_health_local( sync_layer_metadata, ) - vectorize_soil_health( + soil_health_vector_on_geoserver = vectorize_soil_health( state, district, block, @@ -427,3 +427,8 @@ def soil_health_local( push_to_geoserver, sync_layer_metadata, ) + return ( + True + if soil_health_vector_on_geoserver and soil_health_raster_on_geoserver + else False + ) diff --git a/computing/tree_health/local/ccd_local.py b/computing/tree_health/local/ccd_local.py index 53381d3b..2fe4e7cc 100644 --- a/computing/tree_health/local/ccd_local.py +++ b/computing/tree_health/local/ccd_local.py @@ -21,7 +21,6 @@ from nrm_app.celery import app from utilities.gee_utils import valid_gee_text - LOCAL_CCD_BASE_DIR = PROJECT_ROOT / "data/base_layers/tree_health/ccd" LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/tree_health" GEOSERVER_WORKSPACE = "tree_ccd_raster" @@ -47,9 +46,7 @@ def _resolve_ccd_raster(year, ccd_dir=LOCAL_CCD_BASE_DIR): if path.exists(): return str(path) - raise FileNotFoundError( - f"Local CCD raster for {year} not found in {ccd_dir}. " - ) + raise FileNotFoundError(f"Local CCD raster for {year} not found in {ccd_dir}. ") def _pick_output_nodata(dtype, source_nodata): @@ -153,6 +150,7 @@ def _clip_and_mask_ccd(ccd_path, lulc_path, roi_gdf, output_path): return str(output_path) + @app.task(bind=True) def tree_health_ccd_raster_local( self, @@ -181,8 +179,7 @@ def tree_health_ccd_raster_local( # Admin runs use the precomputed watershed boundary. Custom runs use the ROI path. if state and district and block: asset_suffix = ( - f"{_slug(district, 'unknown_district')}_" - f"{_slug(block, 'unknown_block')}" + f"{_slug(district, 'unknown_district')}_" f"{_slug(block, 'unknown_block')}" ) roi_gdf = load_precomputed_roi( state=state, @@ -264,6 +261,7 @@ def tree_health_ccd_raster_local( ) print(f"GeoServer upload response for {layer_name}: {upload_res}") print(f"GeoServer style response for {layer_name}: {style_res}") + layer_at_geoserver = True except Exception as error: print(f"Failed to sync local CCD raster {layer_name}: {error}") layer_at_geoserver = False diff --git a/computing/tree_health/local/overall_change_vector_local.py b/computing/tree_health/local/overall_change_vector_local.py index 37d9faf0..64152164 100644 --- a/computing/tree_health/local/overall_change_vector_local.py +++ b/computing/tree_health/local/overall_change_vector_local.py @@ -18,7 +18,6 @@ LOCAL_OUTPUT_BASE_DIR as OVERALL_CHANGE_RASTER_DIR, ) - LOCAL_OUTPUT_BASE_DIR = PROJECT_ROOT / "data/tree_health" GEOSERVER_WORKSPACE = "tree_overall_vector" @@ -60,6 +59,7 @@ def _resolve_overall_change_raster(asset_suffix, state=None, district=None, bloc "Run overall_change_local.py first." ) + @app.task(bind=True) def tree_health_overall_change_vector_local( self, @@ -79,8 +79,7 @@ def tree_health_overall_change_vector_local( # Vector outputs are generated over watershed polygons, same as reduceRegions in GEE. if state and district and block: asset_suffix = ( - f"{_slug(district, 'unknown_district')}_" - f"{_slug(block, 'unknown_block')}" + f"{_slug(district, 'unknown_district')}_" f"{_slug(block, 'unknown_block')}" ) result_gdf, _ = load_precomputed_watersheds( state=state, @@ -140,6 +139,7 @@ def tree_health_overall_change_vector_local( if not isinstance(res, dict) or res.get("status_code") not in (200, 201): return False + layer_at_geoserver = False if sync_layer_metadata and state and district and block: layer_id = save_layer_info_to_db( state=state, @@ -154,5 +154,6 @@ def tree_health_overall_change_vector_local( ) if layer_id and push_to_geoserver: update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + layer_at_geoserver = True - return True + return layer_at_geoserver From 29500d5f6c5536432c7abddeb397a47ae7c4ca33 Mon Sep 17 00:00:00 2001 From: aman verma Date: Fri, 31 Jul 2026 13:14:20 +0000 Subject: [PATCH 083/120] spei hybrid mask start_year update --- computing/spei/hybrid_tree_mask.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/computing/spei/hybrid_tree_mask.py b/computing/spei/hybrid_tree_mask.py index 8192938f..8e0520a5 100644 --- a/computing/spei/hybrid_tree_mask.py +++ b/computing/spei/hybrid_tree_mask.py @@ -7,7 +7,7 @@ ) -def generate_hybrid_tree_mask(aez, start_year=2003, end_year=None, gee_account_id=None): +def generate_hybrid_tree_mask(aez, start_year=2004, end_year=None, gee_account_id=None): """ Forest Sensitivity Analysis Pipeline — Script 1 Hybrid 30m Annual Tree Cover Mask + Contiguous Forest Period @@ -32,7 +32,7 @@ def generate_hybrid_tree_mask(aez, start_year=2003, end_year=None, gee_account_i TEMPORAL_WINDOW = 2 - start_year = 2003 + start_year = 2004 LULC_START_YEAR = 2017 OUTPUT_DESC = f"Hybrid_Tree_AEZ_{aez}_{str(start_year)}_{str(end_year)}" From 059c5a3b9bef5dfcad637b810ff754a31455b3aa Mon Sep 17 00:00:00 2001 From: aman verma Date: Sat, 1 Aug 2026 15:31:58 +0000 Subject: [PATCH 084/120] soil health OLM OC rename --- computing/soil_health/soil_health.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/computing/soil_health/soil_health.py b/computing/soil_health/soil_health.py index 82004965..422c174c 100644 --- a/computing/soil_health/soil_health.py +++ b/computing/soil_health/soil_health.py @@ -35,7 +35,7 @@ GEOSERVER_STYLE = "" GEOSERVER_RASTER_WORKSPACE = "soil_health_raster" GEOSERVER_VECTOR_WORKSPACE = "soil_health_vector" -NUTRIENTS = ["N", "K", "P", "OC", "OLM"] +NUTRIENTS = ["N", "K", "P", "OC", "OLM_OC"] NUTRIENT_PERCENTILES = (5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95) LOCAL_ALGORITHM = "local_soil_health" LOCAL_ALGORITHM_VERSION = "local-1.0" @@ -43,7 +43,7 @@ def _get_lulc_mask_classes(nutrient): nutrient = str(nutrient).strip().upper() - if nutrient == "OLM": + if nutrient == "OLM_OC": return {6, 12} if nutrient in { "N", From abd63bdfc7e1918e3caf5547cb3dd31b37dd3a06 Mon Sep 17 00:00:00 2001 From: aman verma Date: Sat, 1 Aug 2026 20:08:36 +0000 Subject: [PATCH 085/120] shrub change detection --- .../change_detection/change_detection.py | 42 ++++++++++++++ .../change_detection_local.py | 56 ++++++++++++++++--- .../change_detection_vector.py | 25 ++++++--- .../change_detection_vector_local.py | 17 +++++- 4 files changed, 122 insertions(+), 18 deletions(-) diff --git a/computing/change_detection/change_detection.py b/computing/change_detection/change_detection.py index c272e8d0..dcc2b8ba 100644 --- a/computing/change_detection/change_detection.py +++ b/computing/change_detection/change_detection.py @@ -30,6 +30,7 @@ def get_change_detection( "Deforestation": change_deforestation, "Afforestation": change_afforestation, "CropIntensity": change_cropping_intensity, + "ShrubChange": change_shrub, } description = ( f"change_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" @@ -333,6 +334,47 @@ def remap_values(image): return change_far +def change_shrub(roi_boundary, l1_asset): + lulc_projection = l1_asset[0].projection() + # shrub -> shrub, shrub -> crops, shrub -> tree, shrub -> built-up, shrub -> water + + # Remap values function + def remap_values(image): + return image.remap( + [1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12], + [1, 2, 2, 2, 3, 4, 5, 5, 5, 5, 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_sh_sh = then.eq(6).And(now.eq(6)) + trans_sh_fa = then.eq(6).And(now.eq(5)).multiply(2) + trans_sh_tr = then.eq(6).And(now.eq(3)).multiply(3) + trans_sh_bu = then.eq(6).And(now.eq(1)).multiply(4) + trans_sh_wa = then.eq(6).And(now.eq(2)).multiply(5) + + # Create a zero image and add transitions + change_shr = ( + ee.Image.constant(0) + .setDefaultProjection(lulc_projection) + .clip(roi_boundary.geometry()) + ) + change_shr = ( + change_shr.add(trans_sh_sh) + .add(trans_sh_fa) + .add(trans_sh_tr) + .add(trans_sh_bu) + .add(trans_sh_wa) + ) + return change_shr + + def sync_to_gcs_geoserver( state, district, block, description, param_list, layer_ids, start_year, end_year ): diff --git a/computing/change_detection/change_detection_local.py b/computing/change_detection/change_detection_local.py index c74b046f..333dfa10 100644 --- a/computing/change_detection/change_detection_local.py +++ b/computing/change_detection/change_detection_local.py @@ -11,7 +11,9 @@ from nrm_app.celery import app -from computing.config_loader import CHANGE_DETECTION_RASTER_OUTPUT_DIR as LOCAL_OUTPUT_BASE_DIR +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, build_output_raster_path, @@ -22,6 +24,7 @@ validate_geometry, ) from computing.utils import save_layer_info_to_db, update_layer_sync_status + GEOSERVER_WORKSPACE = "change_detection" CHANGE_STAC_LAYER_NAMES = { @@ -88,6 +91,20 @@ 12: 8, } +SHRUB_REMAP = { + 1: 1, + 2: 2, + 3: 2, + 4: 2, + 6: 3, + 7: 4, + 8: 5, + 9: 5, + 10: 5, + 11: 5, + 12: 6, +} + def _build_lookup_table(mapping, size=13): lookup = np.zeros(size, dtype=np.int16) @@ -102,6 +119,7 @@ def _build_lookup_table(mapping, size=13): DEFORESTATION_AFFORESTATION_REMAP ) CROP_INTENSITY_LOOKUP = _build_lookup_table(CROP_INTENSITY_REMAP) +SHRUB_LOOKUP = _build_lookup_table(SHRUB_REMAP) CHANGE_PARAM_FUNCTIONS = { "Urbanization": "_compute_built_up_change", @@ -109,6 +127,7 @@ def _build_lookup_table(mapping, size=13): "Deforestation": "_compute_deforestation_change", "Afforestation": "_compute_afforestation_change", "CropIntensity": "_compute_crop_intensity_change", + "ShrubChange": "_compute_shrub_change", } ZERO_NODATA = 0 @@ -147,7 +166,9 @@ def _combine_transitions(shape, transitions): def _base_description(district, block): - return f"change_{_slug(district, 'unknown_district')}_{_slug(block, 'unknown_block')}" + return ( + f"change_{_slug(district, 'unknown_district')}_{_slug(block, 'unknown_block')}" + ) def _published_layer_name(district, block, param_name): @@ -155,7 +176,9 @@ def _published_layer_name(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}" + return ( + f"{_published_layer_name(district, block, param_name)}_{start_year}_{end_year}" + ) def _select_change_detection_raster_paths(start_year, end_year): @@ -182,7 +205,9 @@ def _build_roi_shapes_by_crs(roi_gdf, raster_paths): 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.") + 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 @@ -354,10 +379,24 @@ def _compute_built_up_change(lulc_arrays): ) +def _compute_shrub_change(lulc_arrays): + remapped_arrays = [_remap_array(array, SHRUB_LOOKUP) for array in lulc_arrays] + now, then = _compute_then_now_modes(remapped_arrays) + + return _combine_transitions( + then.shape, + [ + (1, (then == 6) & (now == 6)), + (2, (then == 6) & (now == 5)), + (3, (then == 6) & (now == 3)), + (4, (then == 6) & (now == 1)), + (5, (then == 6) & (now == 2)), + ], + ) + + def _compute_degradation_change(lulc_arrays): - remapped_arrays = [ - _remap_array(array, DEGRADATION_LOOKUP) for array in 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, @@ -372,8 +411,7 @@ def _compute_degradation_change(lulc_arrays): def _compute_deforestation_afforestation_modes(lulc_arrays): remapped_arrays = [ - _remap_array(array, DEFORESTATION_AFFORESTATION_LOOKUP) - for array in lulc_arrays + _remap_array(array, DEFORESTATION_AFFORESTATION_LOOKUP) for array in lulc_arrays ] return _compute_then_now_modes(remapped_arrays) diff --git a/computing/change_detection/change_detection_vector.py b/computing/change_detection/change_detection_vector.py index 95e88837..6c0c9c1f 100644 --- a/computing/change_detection/change_detection_vector.py +++ b/computing/change_detection/change_detection_vector.py @@ -40,6 +40,7 @@ def vectorise_change_detection( degradation_vector(roi, state, district, block, start_year, end_year), urbanization_vector(roi, state, district, block, start_year, end_year), crop_intensity_vector(roi, state, district, block, start_year, end_year), + shrub_change_vector(roi, state, district, block, start_year, end_year), ] print(task_list) @@ -52,6 +53,7 @@ def vectorise_change_detection( "Deforestation", "Afforestation", "CropIntensity", + "ShrubChange", ] layer_at_geoserver = False for param in param_list: @@ -137,6 +139,22 @@ def urbanization_vector(roi, state, district, block, start_year, end_year): ) +# Shrub Change +def shrub_change_vector(roi, state, district, block, start_year, end_year): + args = [ + {"value": 1, "label": "sh_sh"}, + {"value": 2, "label": "sh_fa"}, + {"value": 3, "label": "sh_tr"}, + {"value": 4, "label": "sh_bu"}, + {"value": 5, "label": "sh_wa"}, + {"value": [2, 3, 4, 5], "label": "total_change"}, + ] # Classes in shrub change raster layer + + return generate_vector( + roi, args, state, district, block, "ShrubChange", start_year, end_year + ) + + # CropnIntensity def crop_intensity_vector(roi, state, district, block, start_year, end_year): @@ -211,13 +229,6 @@ def process_feature(feature): def sync_change_to_geoserver(block, district, state, asset_id, param, layer_id): - # stac_spec_layer_name_dict = { - # "Urbanization": "change_urbanization_vector", - # "Degradation": "change_cropping_reduction_vector", - # "Deforestation": "change_tree_cover_loss_vector", - # "Afforestation": "change_tree_cover_gain_vector", - # "CropIntensity": "change_cropping_intensity_vector", - # } fc = ee.FeatureCollection(asset_id).getInfo() fc = {"features": fc["features"], "type": fc["type"]} res = sync_layer_to_geoserver( diff --git a/computing/change_detection/change_detection_vector_local.py b/computing/change_detection/change_detection_vector_local.py index 669b8c11..a078c7a8 100644 --- a/computing/change_detection/change_detection_vector_local.py +++ b/computing/change_detection/change_detection_vector_local.py @@ -21,6 +21,7 @@ save_layer_info_to_db, update_layer_sync_status, ) + GEOSERVER_WORKSPACE = "change_detection" CHANGE_VECTOR_CLASS_DEFINITIONS = { @@ -66,6 +67,14 @@ {"value": 9, "label": "tr_tr"}, {"value": [1, 2, 3, 4, 5, 6], "label": "total_change"}, ], + "ShrubChange": [ + {"value": 1, "label": "sh_sh"}, + {"value": 2, "label": "sh_fa"}, + {"value": 3, "label": "sh_tr"}, + {"value": 4, "label": "sh_bu"}, + {"value": 5, "label": "sh_wa"}, + {"value": [2, 3, 4, 5], "label": "total_change"}, + ], } @@ -81,10 +90,14 @@ def _published_layer_name(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}" + 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): +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')}_" From f810a6e27604bb906fb13dcea41bb4407c146eb8 Mon Sep 17 00:00:00 2001 From: aman verma Date: Sun, 2 Aug 2026 14:27:59 +0000 Subject: [PATCH 086/120] shrub change detection- added sh_ba class --- computing/change_detection/change_detection.py | 2 ++ computing/change_detection/change_detection_local.py | 1 + computing/change_detection/change_detection_vector.py | 3 ++- computing/change_detection/change_detection_vector_local.py | 3 ++- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/computing/change_detection/change_detection.py b/computing/change_detection/change_detection.py index dcc2b8ba..3803b0b7 100644 --- a/computing/change_detection/change_detection.py +++ b/computing/change_detection/change_detection.py @@ -358,6 +358,7 @@ def remap_values(image): trans_sh_tr = then.eq(6).And(now.eq(3)).multiply(3) trans_sh_bu = then.eq(6).And(now.eq(1)).multiply(4) trans_sh_wa = then.eq(6).And(now.eq(2)).multiply(5) + trans_sh_ba = then.eq(6).And(now.eq(4)).multiply(6) # Create a zero image and add transitions change_shr = ( @@ -371,6 +372,7 @@ def remap_values(image): .add(trans_sh_tr) .add(trans_sh_bu) .add(trans_sh_wa) + .add(trans_sh_ba) ) return change_shr diff --git a/computing/change_detection/change_detection_local.py b/computing/change_detection/change_detection_local.py index 333dfa10..c9f6f9c0 100644 --- a/computing/change_detection/change_detection_local.py +++ b/computing/change_detection/change_detection_local.py @@ -391,6 +391,7 @@ def _compute_shrub_change(lulc_arrays): (3, (then == 6) & (now == 3)), (4, (then == 6) & (now == 1)), (5, (then == 6) & (now == 2)), + (6, (then == 6) & (now == 4)), ], ) diff --git a/computing/change_detection/change_detection_vector.py b/computing/change_detection/change_detection_vector.py index 6c0c9c1f..7e8ceb75 100644 --- a/computing/change_detection/change_detection_vector.py +++ b/computing/change_detection/change_detection_vector.py @@ -147,7 +147,8 @@ def shrub_change_vector(roi, state, district, block, start_year, end_year): {"value": 3, "label": "sh_tr"}, {"value": 4, "label": "sh_bu"}, {"value": 5, "label": "sh_wa"}, - {"value": [2, 3, 4, 5], "label": "total_change"}, + {"value": 6, "label": "sh_ba"}, + {"value": [2, 3, 4, 5, 6], "label": "total_change"}, ] # Classes in shrub change raster layer return generate_vector( diff --git a/computing/change_detection/change_detection_vector_local.py b/computing/change_detection/change_detection_vector_local.py index a078c7a8..8c84c912 100644 --- a/computing/change_detection/change_detection_vector_local.py +++ b/computing/change_detection/change_detection_vector_local.py @@ -73,7 +73,8 @@ {"value": 3, "label": "sh_tr"}, {"value": 4, "label": "sh_bu"}, {"value": 5, "label": "sh_wa"}, - {"value": [2, 3, 4, 5], "label": "total_change"}, + {"value": 6, "label": "sh_ba"}, + {"value": [2, 3, 4, 5, 6], "label": "total_change"}, ], } From 7aa5631224d976537da8ed889bbd71e35bd54878 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Mon, 3 Aug 2026 12:34:02 +0530 Subject: [PATCH 087/120] bulk layer update --- computing/bulk_layer_generation.py | 68 +++++++++++++++ .../commands/bulk_generate_layers.py | 23 +++++- computing/tests.py | 82 ++++++++++++++++++- 3 files changed, 168 insertions(+), 5 deletions(-) diff --git a/computing/bulk_layer_generation.py b/computing/bulk_layer_generation.py index 483a9d98..4f309c20 100644 --- a/computing/bulk_layer_generation.py +++ b/computing/bulk_layer_generation.py @@ -4,6 +4,8 @@ from operator import or_ from typing import Any, Callable, Mapping +import requests +from django.conf import settings from django.db.models import Q from django.utils.module_loading import import_string @@ -239,3 +241,69 @@ def get_active_locations( ) for tehsil in queryset ] + + +def get_active_locations_from_api( + *, + state: str | None = None, + district: str | None = None, + block: str | None = None, + blocks: list[str] | None = None, + limit: int | None = None, +) -> list[Location]: + if block and blocks: + raise ValueError("Use either block or blocks, not both.") + + backend_url = getattr(settings, "PROD_BACKEND_URL", "").rstrip("/") + if not backend_url: + raise ValueError("PROD_BACKEND_URL is not configured.") + + endpoint = f"{backend_url}/api/v1/proposed_blocks/" + try: + response = requests.get(endpoint, timeout=30) + response.raise_for_status() + payload = response.json() + except requests.RequestException as exc: + raise ValueError( + f"Failed to fetch active locations from {endpoint}: {exc}" + ) from exc + if not isinstance(payload, list): + raise ValueError("Active locations API returned an invalid response.") + + state_filter = state.casefold() if state else None + district_filter = district.casefold() if district else None + selected_blocks = { + name.casefold() for name in ([block] if block else blocks or []) + } + locations = [] + + try: + for state_data in payload: + state_name = state_data["label"] + if state_filter and state_name.casefold() != state_filter: + continue + for district_data in state_data["district"]: + district_name = district_data["label"] + if ( + district_filter + and district_name.casefold() != district_filter + ): + continue + for block_data in district_data["blocks"]: + block_name = block_data["label"] + if ( + selected_blocks + and block_name.casefold() not in selected_blocks + ): + continue + locations.append( + Location(state_name, district_name, block_name) + ) + if limit is not None and len(locations) >= limit: + return locations + except (KeyError, TypeError, AttributeError) as exc: + raise ValueError( + "Active locations API returned an invalid response." + ) from exc + + return locations diff --git a/computing/management/commands/bulk_generate_layers.py b/computing/management/commands/bulk_generate_layers.py index 9445a92a..4bfedb48 100644 --- a/computing/management/commands/bulk_generate_layers.py +++ b/computing/management/commands/bulk_generate_layers.py @@ -2,6 +2,7 @@ from computing.bulk_layer_generation import ( get_active_locations, + get_active_locations_from_api, pipeline_names, validate_pipeline, ) @@ -17,6 +18,14 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("pipeline", nargs="?") parser.add_argument("--all-active", action="store_true") + parser.add_argument( + "--from-prod-api", + action="store_true", + help=( + "Load active locations from PROD_BACKEND_URL instead of " + "the local database." + ), + ) parser.add_argument("--state") parser.add_argument("--district") parser.add_argument( @@ -85,10 +94,18 @@ def handle(self, *args, **options): if not queue: raise CommandError("--queue cannot be empty.") - locations = get_active_locations( - **filters, - limit=options["limit"], + location_loader = ( + get_active_locations_from_api + if options["from_prod_api"] + else get_active_locations ) + try: + locations = location_loader( + **filters, + limit=options["limit"], + ) + except ValueError as exc: + raise CommandError(str(exc)) from exc if not locations: raise CommandError("No active locations matched the requested scope.") diff --git a/computing/tests.py b/computing/tests.py index 6a7d2688..a0ff9c11 100644 --- a/computing/tests.py +++ b/computing/tests.py @@ -3,9 +3,14 @@ from django.core.management import call_command from django.core.management.base import CommandError -from django.test import SimpleTestCase, TestCase +from django.test import SimpleTestCase, TestCase, override_settings -from computing.bulk_layer_generation import Location, get_active_locations, run_pipeline +from computing.bulk_layer_generation import ( + Location, + get_active_locations, + get_active_locations_from_api, + run_pipeline, +) from computing.layer_dependency.layer_generation_in_order import get_args from computing.surface_water_bodies.swb_local import ( _continue_swb_in_gee, @@ -303,6 +308,79 @@ def test_command_accepts_multiple_blocks(self, get_locations, apply_async): ) self.assertEqual(apply_async.call_count, 2) + @patch( + "computing.management.commands.bulk_generate_layers." + "get_active_locations_from_api" + ) + def test_command_loads_locations_from_prod_api(self, get_locations): + get_locations.return_value = [ + Location("Jharkhand", "Dumka", "Masalia") + ] + + call_command( + "bulk_generate_layers", + "livestocks", + "--all-active", + "--from-prod-api", + "--dry-run", + stdout=StringIO(), + ) + + get_locations.assert_called_once_with(limit=None) + + +@override_settings(PROD_BACKEND_URL="https://geoserver.core-stack.org/") +class ActiveLocationsApiTests(SimpleTestCase): + @patch("computing.bulk_layer_generation.requests.get") + def test_filters_and_limits_api_locations(self, get): + get.return_value.json.return_value = [ + { + "label": "Jharkhand", + "district": [ + { + "label": "Dumka", + "blocks": [ + {"label": "Jarmundi"}, + {"label": "Masalia"}, + ], + } + ], + }, + { + "label": "Odisha", + "district": [ + { + "label": "Mayurbhanj", + "blocks": [{"label": "Baripada"}], + } + ], + }, + ] + + locations = get_active_locations_from_api( + state="jharkhand", + district="dumka", + blocks=["MASALIA", "jarmundi"], + limit=1, + ) + + self.assertEqual( + locations, + [Location("Jharkhand", "Dumka", "Jarmundi")], + ) + get.assert_called_once_with( + "https://geoserver.core-stack.org/api/v1/proposed_blocks/", + timeout=30, + ) + get.return_value.raise_for_status.assert_called_once_with() + + @override_settings(PROD_BACKEND_URL="") + def test_requires_prod_backend_url(self): + with self.assertRaisesMessage( + ValueError, "PROD_BACKEND_URL is not configured" + ): + get_active_locations_from_api() + class BulkLayerGenerationTests(TestCase): @classmethod From f4be54299d0a6525ff75529121e0fa27d885b0e4 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Mon, 3 Aug 2026 12:53:22 +0530 Subject: [PATCH 088/120] soil health OLM OC rename --- computing/soil_health/soil_health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/computing/soil_health/soil_health.py b/computing/soil_health/soil_health.py index 422c174c..c3277e3f 100644 --- a/computing/soil_health/soil_health.py +++ b/computing/soil_health/soil_health.py @@ -35,7 +35,7 @@ GEOSERVER_STYLE = "" GEOSERVER_RASTER_WORKSPACE = "soil_health_raster" GEOSERVER_VECTOR_WORKSPACE = "soil_health_vector" -NUTRIENTS = ["N", "K", "P", "OC", "OLM_OC"] +NUTRIENTS = ["N", "K", "P", "OC", "OC_OLM"] NUTRIENT_PERCENTILES = (5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95) LOCAL_ALGORITHM = "local_soil_health" LOCAL_ALGORITHM_VERSION = "local-1.0" From 5beff5316076609ad9816a73fd09673f27328148 Mon Sep 17 00:00:00 2001 From: aman verma Date: Mon, 3 Aug 2026 07:29:03 +0000 Subject: [PATCH 089/120] OLM rename --- computing/soil_health/soil_health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/computing/soil_health/soil_health.py b/computing/soil_health/soil_health.py index c3277e3f..f6df42d3 100644 --- a/computing/soil_health/soil_health.py +++ b/computing/soil_health/soil_health.py @@ -43,7 +43,7 @@ def _get_lulc_mask_classes(nutrient): nutrient = str(nutrient).strip().upper() - if nutrient == "OLM_OC": + if nutrient == "OC_OLM": return {6, 12} if nutrient in { "N", From a1e049d6110eef331609fe7edd1944c46114c2cf Mon Sep 17 00:00:00 2001 From: aman verma Date: Mon, 3 Aug 2026 08:58:18 +0000 Subject: [PATCH 090/120] change detection shrub --- computing/change_detection/change_detection_local.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/computing/change_detection/change_detection_local.py b/computing/change_detection/change_detection_local.py index c9f6f9c0..85728ed5 100644 --- a/computing/change_detection/change_detection_local.py +++ b/computing/change_detection/change_detection_local.py @@ -33,6 +33,7 @@ "Deforestation": "change_tree_cover_loss_raster", "Afforestation": "change_tree_cover_gain_raster", "CropIntensity": "change_cropping_intensity_raster", + "ShrubChange": "change_shrub_change_raster", } BUILT_UP_REMAP = { @@ -495,7 +496,12 @@ def _compute_change_outputs(lulc_arrays): 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") + for param_name in ( + "Urbanization", + "Degradation", + "CropIntensity", + "ShrubChange", + ) ] futures.append(executor.submit(_compute_forest_change_outputs, lulc_arrays)) From f9d6e7015be4a619ecb311cfd9ed016007e4a68f Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Tue, 4 Aug 2026 03:06:56 +0530 Subject: [PATCH 091/120] local swb flow changes --- .../layer_generation_in_order.py | 2 + .../layer_dependency/local_layer_map.json | 12 +++-- computing/surface_water_bodies/swb_local.py | 51 +++++++++++++++++++ computing/tests.py | 48 ++++++++++++++++- 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/computing/layer_dependency/layer_generation_in_order.py b/computing/layer_dependency/layer_generation_in_order.py index 72ae32fd..1993be1f 100644 --- a/computing/layer_dependency/layer_generation_in_order.py +++ b/computing/layer_dependency/layer_generation_in_order.py @@ -123,6 +123,7 @@ from computing.mws.mws_centroid_local_compute import generate_mws_centroid_data_local from computing.mws.mws_connectivity_local_compute import mws_connectivity_vector from computing.surface_water_bodies.swb_local import ( + ensure_swb_gee_dependencies, generate_swb_layer as generate_swb_layer_local, ) from computing.terrain_descriptor.terrain_clusters_local import ( @@ -251,6 +252,7 @@ "lulc_vector": vectorise_lulc_local, "generate_cropping_intensity": generate_cropping_intensity_local, "generate_ci_layer": generate_cropping_intensity_local, + "ensure_swb_gee_dependencies": ensure_swb_gee_dependencies, "generate_swb_layer": generate_swb_layer_local, "generate_swb": generate_swb_layer_local, "get_change_detection": get_change_detection_local, diff --git a/computing/layer_dependency/local_layer_map.json b/computing/layer_dependency/local_layer_map.json index 749e236d..b0afef65 100644 --- a/computing/layer_dependency/local_layer_map.json +++ b/computing/layer_dependency/local_layer_map.json @@ -43,9 +43,15 @@ "use_global_args": true }, { - "name": "generate_swb", - "use_global_args": true, - "pass_gee_account_id": true + "name": "ensure_swb_gee_dependencies", + "pass_gee_account_id": true, + "children": [ + { + "name": "generate_swb", + "use_global_args": true, + "pass_gee_account_id": true + } + ] }, { "name": "tree_health_ch_raster", diff --git a/computing/surface_water_bodies/swb_local.py b/computing/surface_water_bodies/swb_local.py index be327a30..714ac27b 100644 --- a/computing/surface_water_bodies/swb_local.py +++ b/computing/surface_water_bodies/swb_local.py @@ -15,6 +15,13 @@ read_validated_vector_file, write_vector_output, ) +from computing.misc.catchment_area import ( + generate_catchment_area_singleflow as generate_catchment_area_singleflow_gee, +) +from computing.misc.drainage_lines import ( + clip_drainage_lines as clip_drainage_lines_gee, +) +from computing.misc.stream_order import generate_stream_order as generate_stream_order_gee from computing.surface_water_bodies.clip_swb_local import _clip_gdf, _to_geom from computing.surface_water_bodies.swb3 import ( waterbody_catchment_streamorder_properties, @@ -148,6 +155,50 @@ def _resolve_gee_asset_id(state, district, block, asset_suffix, app_type): return description, asset_id, asset_folder_list +def ensure_swb_gee_dependencies( + state, + district, + block, + gee_account_id=None, +): + state = str(state).strip().lower() + district = str(district).strip().lower() + block = str(block).strip().lower() + asset_suffix = f"{_slug(district, 'unknown_district')}_{_slug(block, 'unknown_block')}" + asset_base = get_gee_dir_path( + [state, district, block], + asset_path=GEE_PATHS["MWS"]["GEE_ASSET_PATH"], + ) + dependencies = [ + ( + f"{asset_base}stream_order_{asset_suffix}_raster", + generate_stream_order_gee, + ), + ( + f"{asset_base}catchment_area_{asset_suffix}_raster", + generate_catchment_area_singleflow_gee, + ), + ( + f"{asset_base}drainage_lines_{asset_suffix}", + clip_drainage_lines_gee, + ), + ] + + ee_initialize(gee_account_id) + for asset_id, task in dependencies: + if is_gee_asset_exists(asset_id): + continue + task.run( + state=state, + district=district, + block=block, + gee_account_id=gee_account_id, + ) + if not is_gee_asset_exists(asset_id): + raise RuntimeError(f"SWB GEE dependency was not created: {asset_id}") + return True + + def _prepare_gdf_for_gee(gdf): prepared = gdf.copy() if prepared.crs is None: diff --git a/computing/tests.py b/computing/tests.py index a0ff9c11..5329c8b2 100644 --- a/computing/tests.py +++ b/computing/tests.py @@ -11,10 +11,11 @@ get_active_locations_from_api, run_pipeline, ) -from computing.layer_dependency.layer_generation_in_order import get_args +from computing.layer_dependency.layer_generation_in_order import get_args, load_map_config from computing.surface_water_bodies.swb_local import ( _continue_swb_in_gee, _final_layer_name, + ensure_swb_gee_dependencies, ) from computing.tasks import bulk_generate_layer from geoadmin.models import DistrictSOI, StateSOI, TehsilSOI @@ -48,6 +49,51 @@ def test_local_map_passes_gee_account_to_swb(self): }, ) + def test_local_map_runs_gee_dependencies_before_swb(self): + swb_node = next( + node + for node in load_map_config("dynamic_layers", compute="local") + if node["name"] == "ensure_swb_gee_dependencies" + ) + + self.assertEqual(swb_node["children"][0]["name"], "generate_swb") + + @patch("computing.surface_water_bodies.swb_local.clip_drainage_lines_gee") + @patch( + "computing.surface_water_bodies.swb_local.generate_catchment_area_singleflow_gee" + ) + @patch("computing.surface_water_bodies.swb_local.generate_stream_order_gee") + @patch("computing.surface_water_bodies.swb_local.is_gee_asset_exists") + @patch("computing.surface_water_bodies.swb_local.ee_initialize") + def test_generates_missing_swb_gee_dependencies( + self, + ee_initialize, + is_gee_asset_exists, + generate_stream_order, + generate_catchment_area, + generate_drainage_lines, + ): + is_gee_asset_exists.side_effect = [False, True, False, True, False, True] + + result = ensure_swb_gee_dependencies( + state="Madhya Pradesh", + district="Dhar", + block="Kukshi", + gee_account_id="account", + ) + + expected_kwargs = { + "state": "madhya pradesh", + "district": "dhar", + "block": "kukshi", + "gee_account_id": "account", + } + ee_initialize.assert_called_once_with("account") + generate_stream_order.run.assert_called_once_with(**expected_kwargs) + generate_catchment_area.run.assert_called_once_with(**expected_kwargs) + generate_drainage_lines.run.assert_called_once_with(**expected_kwargs) + self.assertTrue(result) + @patch("computing.surface_water_bodies.swb_local.make_asset_public") @patch("computing.surface_water_bodies.swb_local.is_gee_asset_exists") @patch("computing.surface_water_bodies.swb_local.check_task_status") From 3f151d0d28d7629a38d4acea3fc0f029101bbd71 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Tue, 4 Aug 2026 03:06:56 +0530 Subject: [PATCH 092/120] local swb flow changes --- .../layer_generation_in_order.py | 2 + .../layer_dependency/local_layer_map.json | 12 +++-- computing/surface_water_bodies/swb_local.py | 51 +++++++++++++++++++ computing/tests.py | 48 ++++++++++++++++- 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/computing/layer_dependency/layer_generation_in_order.py b/computing/layer_dependency/layer_generation_in_order.py index 72ae32fd..1993be1f 100644 --- a/computing/layer_dependency/layer_generation_in_order.py +++ b/computing/layer_dependency/layer_generation_in_order.py @@ -123,6 +123,7 @@ from computing.mws.mws_centroid_local_compute import generate_mws_centroid_data_local from computing.mws.mws_connectivity_local_compute import mws_connectivity_vector from computing.surface_water_bodies.swb_local import ( + ensure_swb_gee_dependencies, generate_swb_layer as generate_swb_layer_local, ) from computing.terrain_descriptor.terrain_clusters_local import ( @@ -251,6 +252,7 @@ "lulc_vector": vectorise_lulc_local, "generate_cropping_intensity": generate_cropping_intensity_local, "generate_ci_layer": generate_cropping_intensity_local, + "ensure_swb_gee_dependencies": ensure_swb_gee_dependencies, "generate_swb_layer": generate_swb_layer_local, "generate_swb": generate_swb_layer_local, "get_change_detection": get_change_detection_local, diff --git a/computing/layer_dependency/local_layer_map.json b/computing/layer_dependency/local_layer_map.json index 749e236d..b0afef65 100644 --- a/computing/layer_dependency/local_layer_map.json +++ b/computing/layer_dependency/local_layer_map.json @@ -43,9 +43,15 @@ "use_global_args": true }, { - "name": "generate_swb", - "use_global_args": true, - "pass_gee_account_id": true + "name": "ensure_swb_gee_dependencies", + "pass_gee_account_id": true, + "children": [ + { + "name": "generate_swb", + "use_global_args": true, + "pass_gee_account_id": true + } + ] }, { "name": "tree_health_ch_raster", diff --git a/computing/surface_water_bodies/swb_local.py b/computing/surface_water_bodies/swb_local.py index be327a30..714ac27b 100644 --- a/computing/surface_water_bodies/swb_local.py +++ b/computing/surface_water_bodies/swb_local.py @@ -15,6 +15,13 @@ read_validated_vector_file, write_vector_output, ) +from computing.misc.catchment_area import ( + generate_catchment_area_singleflow as generate_catchment_area_singleflow_gee, +) +from computing.misc.drainage_lines import ( + clip_drainage_lines as clip_drainage_lines_gee, +) +from computing.misc.stream_order import generate_stream_order as generate_stream_order_gee from computing.surface_water_bodies.clip_swb_local import _clip_gdf, _to_geom from computing.surface_water_bodies.swb3 import ( waterbody_catchment_streamorder_properties, @@ -148,6 +155,50 @@ def _resolve_gee_asset_id(state, district, block, asset_suffix, app_type): return description, asset_id, asset_folder_list +def ensure_swb_gee_dependencies( + state, + district, + block, + gee_account_id=None, +): + state = str(state).strip().lower() + district = str(district).strip().lower() + block = str(block).strip().lower() + asset_suffix = f"{_slug(district, 'unknown_district')}_{_slug(block, 'unknown_block')}" + asset_base = get_gee_dir_path( + [state, district, block], + asset_path=GEE_PATHS["MWS"]["GEE_ASSET_PATH"], + ) + dependencies = [ + ( + f"{asset_base}stream_order_{asset_suffix}_raster", + generate_stream_order_gee, + ), + ( + f"{asset_base}catchment_area_{asset_suffix}_raster", + generate_catchment_area_singleflow_gee, + ), + ( + f"{asset_base}drainage_lines_{asset_suffix}", + clip_drainage_lines_gee, + ), + ] + + ee_initialize(gee_account_id) + for asset_id, task in dependencies: + if is_gee_asset_exists(asset_id): + continue + task.run( + state=state, + district=district, + block=block, + gee_account_id=gee_account_id, + ) + if not is_gee_asset_exists(asset_id): + raise RuntimeError(f"SWB GEE dependency was not created: {asset_id}") + return True + + def _prepare_gdf_for_gee(gdf): prepared = gdf.copy() if prepared.crs is None: diff --git a/computing/tests.py b/computing/tests.py index a0ff9c11..5329c8b2 100644 --- a/computing/tests.py +++ b/computing/tests.py @@ -11,10 +11,11 @@ get_active_locations_from_api, run_pipeline, ) -from computing.layer_dependency.layer_generation_in_order import get_args +from computing.layer_dependency.layer_generation_in_order import get_args, load_map_config from computing.surface_water_bodies.swb_local import ( _continue_swb_in_gee, _final_layer_name, + ensure_swb_gee_dependencies, ) from computing.tasks import bulk_generate_layer from geoadmin.models import DistrictSOI, StateSOI, TehsilSOI @@ -48,6 +49,51 @@ def test_local_map_passes_gee_account_to_swb(self): }, ) + def test_local_map_runs_gee_dependencies_before_swb(self): + swb_node = next( + node + for node in load_map_config("dynamic_layers", compute="local") + if node["name"] == "ensure_swb_gee_dependencies" + ) + + self.assertEqual(swb_node["children"][0]["name"], "generate_swb") + + @patch("computing.surface_water_bodies.swb_local.clip_drainage_lines_gee") + @patch( + "computing.surface_water_bodies.swb_local.generate_catchment_area_singleflow_gee" + ) + @patch("computing.surface_water_bodies.swb_local.generate_stream_order_gee") + @patch("computing.surface_water_bodies.swb_local.is_gee_asset_exists") + @patch("computing.surface_water_bodies.swb_local.ee_initialize") + def test_generates_missing_swb_gee_dependencies( + self, + ee_initialize, + is_gee_asset_exists, + generate_stream_order, + generate_catchment_area, + generate_drainage_lines, + ): + is_gee_asset_exists.side_effect = [False, True, False, True, False, True] + + result = ensure_swb_gee_dependencies( + state="Madhya Pradesh", + district="Dhar", + block="Kukshi", + gee_account_id="account", + ) + + expected_kwargs = { + "state": "madhya pradesh", + "district": "dhar", + "block": "kukshi", + "gee_account_id": "account", + } + ee_initialize.assert_called_once_with("account") + generate_stream_order.run.assert_called_once_with(**expected_kwargs) + generate_catchment_area.run.assert_called_once_with(**expected_kwargs) + generate_drainage_lines.run.assert_called_once_with(**expected_kwargs) + self.assertTrue(result) + @patch("computing.surface_water_bodies.swb_local.make_asset_public") @patch("computing.surface_water_bodies.swb_local.is_gee_asset_exists") @patch("computing.surface_water_bodies.swb_local.check_task_status") From d687e5e1017ac99b40b9e4196ac87c0635a74be1 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Tue, 4 Aug 2026 17:16:57 +0530 Subject: [PATCH 093/120] tehsil watersheds from geoserver --- computing/base_layer_setup.py | 174 +++++++++++++++++- .../layer_generation_in_order.py | 2 + computing/local_compute_helper.py | 13 +- .../commands/local_compute_layer_setup.py | 28 ++- computing/test_base_layer_setup.py | 108 +++++++++++ 5 files changed, 315 insertions(+), 10 deletions(-) create mode 100644 computing/test_base_layer_setup.py diff --git a/computing/base_layer_setup.py b/computing/base_layer_setup.py index 63d864f1..e15c6f9e 100644 --- a/computing/base_layer_setup.py +++ b/computing/base_layer_setup.py @@ -1,6 +1,7 @@ import logging import subprocess from functools import wraps +from inspect import signature from pathlib import Path from urllib.parse import urlparse @@ -410,14 +411,172 @@ def ensure_microwatershed(): raise -def ensure_tehsil_watersheds(): +def _active_tehsil_locations(): + from geoadmin.models import TehsilSOI + + return TehsilSOI.objects.filter( + active_status=True, + district__active_status=True, + district__state__active_status=True, + ).values_list( + "district__state__state_name", + "district__district_name", + "tehsil_name", + ).order_by( + "district__state__state_name", + "district__district_name", + "tehsil_name", + ) + + +def _tehsil_watershed_details(state, district, tehsil): + from utilities.gee_utils import valid_gee_text + + state_slug = valid_gee_text(state.strip().lower()) + district_slug = valid_gee_text(district.strip().lower()) + tehsil_slug = valid_gee_text(tehsil.strip().lower()) + destination = ( + TEHSIL_WATERSHEDS_DIR + / state_slug + / district_slug + / f"{tehsil_slug}.gpkg" + ) + layer_name = f"mws:mws_{district_slug}_{tehsil_slug}" + return destination, layer_name + + +def ensure_tehsil_watershed(state, district, tehsil, force=False): + import geopandas as gpd + + from utilities.constants import GEOSERVER_BASE + + destination, layer_name = _tehsil_watershed_details(state, district, tehsil) + if destination.exists() and not force: + return destination + + wfs_url = f"{GEOSERVER_BASE}mws/ows" + params = { + "service": "WFS", + "version": "1.0.0", + "request": "GetFeature", + "typeName": layer_name, + "outputFormat": "application/json", + "srsName": "EPSG:4326", + } + temp_destination = destination.with_suffix(".tmp.gpkg") + + try: + response = requests.get(wfs_url, params=params, timeout=600) + response.raise_for_status() + payload = response.json() + if ( + not isinstance(payload, dict) + or payload.get("type") != "FeatureCollection" + ): + raise ValueError("GeoServer did not return a FeatureCollection") + + watersheds = gpd.GeoDataFrame.from_features( + payload.get("features", []), + crs="EPSG:4326", + ) + if watersheds.empty: + raise ValueError("GeoServer layer is empty") + + destination.parent.mkdir(parents=True, exist_ok=True) + if temp_destination.exists(): + temp_destination.unlink() + watersheds.to_file( + temp_destination, + layer="watersheds", + driver="GPKG", + ) + temp_destination.replace(destination) + except Exception: + if temp_destination.exists(): + temp_destination.unlink() + raise + + logger.info("Saved %s to %s", layer_name, destination) + return destination + + +def _download_active_tehsil_watersheds(force=False): + locations = list(_active_tehsil_locations()) + if not locations: + logger.warning("No active tehsils found; no watershed files downloaded.") + return + + written = 0 + skipped = 0 + failures = [] + + for state, district, tehsil in locations: + destination, layer_name = _tehsil_watershed_details( + state, + district, + tehsil, + ) + if destination.exists() and not force: + skipped += 1 + continue + + try: + ensure_tehsil_watershed( + state=state, + district=district, + tehsil=tehsil, + force=force, + ) + written += 1 + except Exception as exc: + failures.append(f"{layer_name}: {exc}") + logger.error("Failed to download %s: %s", layer_name, exc) + + logger.info( + "GeoServer tehsil watersheds complete: %d written, %d skipped, %d failed.", + written, + skipped, + len(failures), + ) + if failures: + raise RuntimeError( + "Failed to download watershed layers for active tehsils: " + + "; ".join(failures) + ) + + +def with_tehsil_watershed(func): + func_signature = signature(func) + + @wraps(func) + def wrapper(*args, **kwargs): + call = func_signature.bind_partial(*args, **kwargs) + call.apply_defaults() + compute = str(call.arguments.get("compute", "local")).strip().lower() + if compute == "local": + ensure_tehsil_watershed( + state=call.arguments["state"], + district=call.arguments["district"], + tehsil=call.arguments.get("block") or call.arguments.get("tehsil"), + ) + return func(*args, **kwargs) + + return wrapper + + +def ensure_tehsil_watersheds(geoserver=False, force=False): """ 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. + Existing files are skipped unless force is true. When geoserver is true, + only active tehsils are downloaded from the mws workspace. Both source files (SOI tehsil + microwatershed) must exist first. """ - if _is_dir_populated(TEHSIL_WATERSHEDS_DIR): + if geoserver: + _download_active_tehsil_watersheds(force=force) + return + + if _is_dir_populated(TEHSIL_WATERSHEDS_DIR) and not force: logger.info( "Tehsil watershed files already present at %s, skipping.", TEHSIL_WATERSHEDS_DIR, @@ -450,7 +609,7 @@ def ensure_tehsil_watersheds(): tehsil_path=str(SOI_TEHSIL_PATH), output_dir=str(TEHSIL_WATERSHEDS_DIR), output_format="gpkg", - overwrite=False, + overwrite=force, clip_to_tehsil=False, ) logger.info("Tehsil watershed files ready at %s", TEHSIL_WATERSHEDS_DIR) @@ -479,13 +638,16 @@ def ensure_village_boundaries_dir(): ) -def setup_base_layers(*layers): +def setup_base_layers(*layers, geoserver=False, force=False): selected_layers = layers or DEFAULT_BASE_LAYERS manifest_index = _manifest_layer_index() for layer in selected_layers: if layer in _BASE_LAYER_ENSURERS: - _BASE_LAYER_ENSURERS[layer]() + if layer == "tehsil_watersheds": + ensure_tehsil_watersheds(geoserver=geoserver, force=force) + else: + _BASE_LAYER_ENSURERS[layer]() continue if _layer_key(layer) in manifest_index: diff --git a/computing/layer_dependency/layer_generation_in_order.py b/computing/layer_dependency/layer_generation_in_order.py index 1993be1f..4bf8cf34 100644 --- a/computing/layer_dependency/layer_generation_in_order.py +++ b/computing/layer_dependency/layer_generation_in_order.py @@ -1,4 +1,5 @@ from nrm_app.celery import app +from computing.base_layer_setup import with_tehsil_watershed from computing.misc.admin_boundary import generate_tehsil_shape_file_data from computing.misc.nrega import clip_nrega_district_block from computing.mws.mws import mws_layer @@ -323,6 +324,7 @@ @app.task(bind=True) +@with_tehsil_watershed def layer_generate_map( self, state, diff --git a/computing/local_compute_helper.py b/computing/local_compute_helper.py index 7353f472..4c3d6bb2 100644 --- a/computing/local_compute_helper.py +++ b/computing/local_compute_helper.py @@ -21,6 +21,7 @@ PROJECT_ROOT, TERRAIN_RASTER_PATH, ) +from computing.base_layer_setup import ensure_tehsil_watershed from utilities.download_gpkg_from_geoserver import generate_gpkg PRECOMPUTED_PANCHAYAT_DIR = PROJECT_ROOT / "data/base_layers/village_boundaries" @@ -147,7 +148,11 @@ def load_precomputed_watersheds( except FileNotFoundError: print(f"Precomputed watershed not found for " f"{state}/{district}/{block}") - generate_gpkg(state=state, district=district, block=block, workspace="mws") + ensure_tehsil_watershed( + state=state, + district=district, + tehsil=block, + ) watershed_path = resolve_precomputed_vector_file( state=state, district=district, @@ -221,7 +226,11 @@ def load_precomputed_roi( ) except FileNotFoundError: print(f"Precomputed ROI not found for {state}/{district}/{block}. Downloading...") - generate_gpkg(state=state, district=district, block=block, workspace="mws") + ensure_tehsil_watershed( + state=state, + district=district, + tehsil=block, + ) roi_path = resolve_precomputed_vector_file( state=state, district=district, diff --git a/computing/management/commands/local_compute_layer_setup.py b/computing/management/commands/local_compute_layer_setup.py index 1c156766..38fa31e5 100644 --- a/computing/management/commands/local_compute_layer_setup.py +++ b/computing/management/commands/local_compute_layer_setup.py @@ -50,6 +50,19 @@ def add_arguments(self, parser): action="store_true", help="Generate per-tehsil watershed files if they are missing.", ) + parser.add_argument( + "--geoserver", + action="store_true", + help=( + "Download watershed GPKGs from the mws GeoServer workspace for " + "active tehsils only." + ), + ) + parser.add_argument( + "--force", + action="store_true", + help="Replace existing tehsil watershed GPKGs.", + ) parser.add_argument( "--ensure-village-boundaries", action="store_true", @@ -68,12 +81,23 @@ def handle(self, *args, **options): self._print_available_layers() return + if options["geoserver"] and not options["ensure_tehsil_watersheds"]: + raise CommandError( + "--geoserver requires --ensure-tehsil-watersheds." + ) + if options["force"] and not options["ensure_tehsil_watersheds"]: + raise CommandError("--force requires --ensure-tehsil-watersheds.") + layers = self._selected_layers(options) self.stdout.write(f"Setting up local compute layers: {', '.join(layers)}") try: - setup_base_layers(*layers) - except ValueError as exc: + setup_base_layers( + *layers, + geoserver=options["geoserver"], + force=options["force"], + ) + except (RuntimeError, ValueError) as exc: raise CommandError(str(exc)) from exc self.stdout.write(self.style.SUCCESS("Local compute layer setup complete.")) diff --git a/computing/test_base_layer_setup.py b/computing/test_base_layer_setup.py new file mode 100644 index 00000000..0d315c3a --- /dev/null +++ b/computing/test_base_layer_setup.py @@ -0,0 +1,108 @@ +import tempfile +from pathlib import Path +from unittest.mock import Mock, patch + +from django.test import SimpleTestCase + +from computing.base_layer_setup import ( + _download_active_tehsil_watersheds, + with_tehsil_watershed, +) + + +class GeoServerTehsilWatershedSetupTests(SimpleTestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.output_dir = Path(self.temp_dir.name) + self.location = [("Bihar", "Banka", "Banka")] + + def tearDown(self): + self.temp_dir.cleanup() + + @patch("computing.base_layer_setup.requests.get") + @patch("computing.base_layer_setup._active_tehsil_locations") + def test_existing_active_tehsil_is_skipped(self, active_locations, get): + active_locations.return_value = self.location + destination = self.output_dir / "bihar" / "banka" / "banka.gpkg" + destination.parent.mkdir(parents=True) + destination.write_bytes(b"existing") + + with patch( + "computing.base_layer_setup.TEHSIL_WATERSHEDS_DIR", + self.output_dir, + ): + _download_active_tehsil_watersheds() + + get.assert_not_called() + self.assertEqual(destination.read_bytes(), b"existing") + + @patch("geopandas.GeoDataFrame.from_features") + @patch("computing.base_layer_setup.requests.get") + @patch("computing.base_layer_setup._active_tehsil_locations") + def test_force_replaces_active_tehsil_gpkg( + self, + active_locations, + get, + from_features, + ): + active_locations.return_value = self.location + response = Mock() + response.json.return_value = { + "type": "FeatureCollection", + "features": [{"type": "Feature", "properties": {}, "geometry": None}], + } + get.return_value = response + + watersheds = Mock() + watersheds.empty = False + watersheds.to_file.side_effect = lambda path, **kwargs: Path(path).write_bytes( + b"replacement" + ) + from_features.return_value = watersheds + + destination = self.output_dir / "bihar" / "banka" / "banka.gpkg" + destination.parent.mkdir(parents=True) + destination.write_bytes(b"existing") + + with patch( + "computing.base_layer_setup.TEHSIL_WATERSHEDS_DIR", + self.output_dir, + ): + _download_active_tehsil_watersheds(force=True) + + self.assertEqual(destination.read_bytes(), b"replacement") + get.assert_called_once() + self.assertEqual( + get.call_args.kwargs["params"]["typeName"], + "mws:mws_banka_banka", + ) + watersheds.to_file.assert_called_once_with( + destination.with_suffix(".tmp.gpkg"), + layer="watersheds", + driver="GPKG", + ) + + @patch("computing.base_layer_setup.ensure_tehsil_watershed") + def test_local_compute_ensures_requested_tehsil(self, ensure): + @with_tehsil_watershed + def generate(state, district, block, compute="gee"): + return "generated" + + result = generate("Bihar", "Banka", "Banka", compute="local") + + self.assertEqual(result, "generated") + ensure.assert_called_once_with( + state="Bihar", + district="Banka", + tehsil="Banka", + ) + + @patch("computing.base_layer_setup.ensure_tehsil_watershed") + def test_gee_compute_does_not_ensure_local_tehsil(self, ensure): + @with_tehsil_watershed + def generate(state, district, block, compute="gee"): + return "generated" + + generate("Bihar", "Banka", "Banka") + + ensure.assert_not_called() From 22afe33725b48dcbc880130808cddb3c31525b58 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Tue, 4 Aug 2026 19:04:32 +0530 Subject: [PATCH 094/120] swb3 and swb4 layers uses pan india stream order, catchment area and drainage lines --- .../layer_generation_in_order.py | 2 - .../layer_dependency/local_layer_map.json | 12 +-- computing/surface_water_bodies/swb3.py | 26 +++---- computing/surface_water_bodies/swb_local.py | 52 ------------- computing/tests.py | 75 +++++++++---------- computing/utils.py | 24 ++---- waterrejuvenation/utils.py | 2 +- 7 files changed, 59 insertions(+), 134 deletions(-) diff --git a/computing/layer_dependency/layer_generation_in_order.py b/computing/layer_dependency/layer_generation_in_order.py index 4bf8cf34..45ddcff3 100644 --- a/computing/layer_dependency/layer_generation_in_order.py +++ b/computing/layer_dependency/layer_generation_in_order.py @@ -124,7 +124,6 @@ from computing.mws.mws_centroid_local_compute import generate_mws_centroid_data_local from computing.mws.mws_connectivity_local_compute import mws_connectivity_vector from computing.surface_water_bodies.swb_local import ( - ensure_swb_gee_dependencies, generate_swb_layer as generate_swb_layer_local, ) from computing.terrain_descriptor.terrain_clusters_local import ( @@ -253,7 +252,6 @@ "lulc_vector": vectorise_lulc_local, "generate_cropping_intensity": generate_cropping_intensity_local, "generate_ci_layer": generate_cropping_intensity_local, - "ensure_swb_gee_dependencies": ensure_swb_gee_dependencies, "generate_swb_layer": generate_swb_layer_local, "generate_swb": generate_swb_layer_local, "get_change_detection": get_change_detection_local, diff --git a/computing/layer_dependency/local_layer_map.json b/computing/layer_dependency/local_layer_map.json index b0afef65..749e236d 100644 --- a/computing/layer_dependency/local_layer_map.json +++ b/computing/layer_dependency/local_layer_map.json @@ -43,15 +43,9 @@ "use_global_args": true }, { - "name": "ensure_swb_gee_dependencies", - "pass_gee_account_id": true, - "children": [ - { - "name": "generate_swb", - "use_global_args": true, - "pass_gee_account_id": true - } - ] + "name": "generate_swb", + "use_global_args": true, + "pass_gee_account_id": true }, { "name": "tree_health_ch_raster", diff --git a/computing/surface_water_bodies/swb3.py b/computing/surface_water_bodies/swb3.py index c20af38e..d6f4e338 100644 --- a/computing/surface_water_bodies/swb3.py +++ b/computing/surface_water_bodies/swb3.py @@ -1,5 +1,10 @@ from computing.utils import generate_swb_layer_with_max_so_catchment -from utilities.constants import GEE_PATHS +from utilities.constants import ( + CATCHMENT_AREA, + GEE_PATHS, + PAN_INDIA_DRAINAGE_LINES_DATASET, + STREAM_ORDER_ASSET, +) from utilities.gee_utils import ( valid_gee_text, get_gee_dir_path, @@ -232,8 +237,10 @@ def waterbody_catchment_streamorder_properties( river_asset_id=DEFAULT_PAN_INDIA_RIVER_ASSET, canal_asset_id=DEFAULT_PAN_INDIA_CANAL_ASSET, waterbody_type_buffer_m=DEFAULT_WATERBODY_TYPE_BUFFER_M, - supporting_asset_suffix=None, swb2_asset_suffix=None, + stream_order_asset_id=STREAM_ORDER_ASSET, + catchment_area_asset_id=CATCHMENT_AREA, + drainage_lines_asset_id=PAN_INDIA_DRAINAGE_LINES_DATASET, ): print(f"asset suffix swb3: {asset_suffix}") print(f"[SWB4] river_asset_id: {river_asset_id}") @@ -250,7 +257,6 @@ def waterbody_catchment_streamorder_properties( if is_gee_asset_exists(asset_id): return None, asset_id - supporting_asset_suffix = supporting_asset_suffix or asset_suffix swb2_asset_suffix = swb2_asset_suffix or asset_suffix swb2_asset = ( get_gee_dir_path( @@ -265,19 +271,13 @@ def waterbody_catchment_streamorder_properties( print(f"asset_i{water_bodies}") swb4_fs = generate_swb_layer_with_max_so_catchment( roi=water_bodies, - asset_suffix=supporting_asset_suffix, - asset_folder=asset_folder_list, - app_type=app_type, gee_account_id=gee_account_id, + stream_order_asset_id=stream_order_asset_id, + catchment_area_asset_id=catchment_area_asset_id, ) - asset_id_dl = ( - get_gee_dir_path( - asset_folder_list, asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"] - ) - + "drainage_lines_" - + supporting_asset_suffix + swb4_fs_on_drainage = add_on_drainage_flag( + swb4_fs, drainage_lines_asset_id ) - swb4_fs_on_drainage = add_on_drainage_flag(swb4_fs, asset_id_dl) swb4_fc_with_waterbody_type = add_waterbody_type_flag( swb4_fs_on_drainage, river_asset_id=river_asset_id, diff --git a/computing/surface_water_bodies/swb_local.py b/computing/surface_water_bodies/swb_local.py index 714ac27b..ff67287d 100644 --- a/computing/surface_water_bodies/swb_local.py +++ b/computing/surface_water_bodies/swb_local.py @@ -15,13 +15,6 @@ read_validated_vector_file, write_vector_output, ) -from computing.misc.catchment_area import ( - generate_catchment_area_singleflow as generate_catchment_area_singleflow_gee, -) -from computing.misc.drainage_lines import ( - clip_drainage_lines as clip_drainage_lines_gee, -) -from computing.misc.stream_order import generate_stream_order as generate_stream_order_gee from computing.surface_water_bodies.clip_swb_local import _clip_gdf, _to_geom from computing.surface_water_bodies.swb3 import ( waterbody_catchment_streamorder_properties, @@ -155,50 +148,6 @@ def _resolve_gee_asset_id(state, district, block, asset_suffix, app_type): return description, asset_id, asset_folder_list -def ensure_swb_gee_dependencies( - state, - district, - block, - gee_account_id=None, -): - state = str(state).strip().lower() - district = str(district).strip().lower() - block = str(block).strip().lower() - asset_suffix = f"{_slug(district, 'unknown_district')}_{_slug(block, 'unknown_block')}" - asset_base = get_gee_dir_path( - [state, district, block], - asset_path=GEE_PATHS["MWS"]["GEE_ASSET_PATH"], - ) - dependencies = [ - ( - f"{asset_base}stream_order_{asset_suffix}_raster", - generate_stream_order_gee, - ), - ( - f"{asset_base}catchment_area_{asset_suffix}_raster", - generate_catchment_area_singleflow_gee, - ), - ( - f"{asset_base}drainage_lines_{asset_suffix}", - clip_drainage_lines_gee, - ), - ] - - ee_initialize(gee_account_id) - for asset_id, task in dependencies: - if is_gee_asset_exists(asset_id): - continue - task.run( - state=state, - district=district, - block=block, - gee_account_id=gee_account_id, - ) - if not is_gee_asset_exists(asset_id): - raise RuntimeError(f"SWB GEE dependency was not created: {asset_id}") - return True - - def _prepare_gdf_for_gee(gdf): prepared = gdf.copy() if prepared.crs is None: @@ -347,7 +296,6 @@ def _continue_swb_in_gee( asset_folder_list=asset_folder_list, app_type=app_type, gee_account_id=gee_account_id, - supporting_asset_suffix=asset_suffix, swb2_asset_suffix=swb2_asset_suffix, ) if swb3_task_id: diff --git a/computing/tests.py b/computing/tests.py index 5329c8b2..7abf502f 100644 --- a/computing/tests.py +++ b/computing/tests.py @@ -1,4 +1,5 @@ from io import StringIO +from inspect import signature from unittest.mock import call, patch from django.core.management import call_command @@ -15,13 +16,42 @@ from computing.surface_water_bodies.swb_local import ( _continue_swb_in_gee, _final_layer_name, - ensure_swb_gee_dependencies, ) +from computing.surface_water_bodies.swb3 import ( + waterbody_catchment_streamorder_properties, +) +from computing.utils import generate_swb_layer_with_max_so_catchment from computing.tasks import bulk_generate_layer from geoadmin.models import DistrictSOI, StateSOI, TehsilSOI +from utilities.constants import ( + CATCHMENT_AREA, + PAN_INDIA_DRAINAGE_LINES_DATASET, + STREAM_ORDER_ASSET, +) class LocalSwbContinuationTests(SimpleTestCase): + def test_swb_enrichment_defaults_to_pan_india_assets(self): + raster_parameters = signature( + generate_swb_layer_with_max_so_catchment + ).parameters + swb3_parameters = signature( + waterbody_catchment_streamorder_properties + ).parameters + + self.assertEqual( + raster_parameters["stream_order_asset_id"].default, + STREAM_ORDER_ASSET, + ) + self.assertEqual( + raster_parameters["catchment_area_asset_id"].default, + CATCHMENT_AREA, + ) + self.assertEqual( + swb3_parameters["drainage_lines_asset_id"].default, + PAN_INDIA_DRAINAGE_LINES_DATASET, + ) + def test_final_geoserver_layer_has_no_local_suffix(self): self.assertEqual( _final_layer_name("dumka_jarmundi"), @@ -49,50 +79,14 @@ def test_local_map_passes_gee_account_to_swb(self): }, ) - def test_local_map_runs_gee_dependencies_before_swb(self): + def test_local_map_runs_swb_without_generated_gee_dependencies(self): swb_node = next( node for node in load_map_config("dynamic_layers", compute="local") - if node["name"] == "ensure_swb_gee_dependencies" - ) - - self.assertEqual(swb_node["children"][0]["name"], "generate_swb") - - @patch("computing.surface_water_bodies.swb_local.clip_drainage_lines_gee") - @patch( - "computing.surface_water_bodies.swb_local.generate_catchment_area_singleflow_gee" - ) - @patch("computing.surface_water_bodies.swb_local.generate_stream_order_gee") - @patch("computing.surface_water_bodies.swb_local.is_gee_asset_exists") - @patch("computing.surface_water_bodies.swb_local.ee_initialize") - def test_generates_missing_swb_gee_dependencies( - self, - ee_initialize, - is_gee_asset_exists, - generate_stream_order, - generate_catchment_area, - generate_drainage_lines, - ): - is_gee_asset_exists.side_effect = [False, True, False, True, False, True] - - result = ensure_swb_gee_dependencies( - state="Madhya Pradesh", - district="Dhar", - block="Kukshi", - gee_account_id="account", + if node["name"] == "generate_swb" ) - expected_kwargs = { - "state": "madhya pradesh", - "district": "dhar", - "block": "kukshi", - "gee_account_id": "account", - } - ee_initialize.assert_called_once_with("account") - generate_stream_order.run.assert_called_once_with(**expected_kwargs) - generate_catchment_area.run.assert_called_once_with(**expected_kwargs) - generate_drainage_lines.run.assert_called_once_with(**expected_kwargs) - self.assertTrue(result) + self.assertTrue(swb_node["pass_gee_account_id"]) @patch("computing.surface_water_bodies.swb_local.make_asset_public") @patch("computing.surface_water_bodies.swb_local.is_gee_asset_exists") @@ -129,7 +123,6 @@ def test_runs_swb3_and_swb4_without_local_suffix( asset_folder_list=["odisha", "district", "block"], app_type="MWS", gee_account_id="account", - supporting_asset_suffix="district_block", swb2_asset_suffix="district_block_local", ) generate_swb4.assert_called_once_with( diff --git a/computing/utils.py b/computing/utils.py index 884e1124..06f63277 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -27,10 +27,12 @@ from projects.models import Project from utilities.constants import ( ADMIN_BOUNDARY_OUTPUT_DIR, + CATCHMENT_AREA, GEE_ASSET_PATH, GEE_HELPER_PATH, GEE_PATHS, SHAPEFILE_DIR, + STREAM_ORDER_ASSET, ) from utilities.gee_utils import ( check_task_status, @@ -860,25 +862,15 @@ def safe_reduce_max(image, geom, scale=30): # MAIN FUNCTION TO PROCESS SWB LAYER # ------------------------------------------------------ def generate_swb_layer_with_max_so_catchment( - roi=None, - app_type="MWS", - asset_suffix=None, - asset_folder=None, - gee_account_id=None, + roi=None, + gee_account_id=None, + stream_order_asset_id=STREAM_ORDER_ASSET, + catchment_area_asset_id=CATCHMENT_AREA, ): ee_initialize(gee_account_id) - # Build asset paths - base_path = get_gee_dir_path( - asset_folder, asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"] - ) - - so_asset = f"{base_path}stream_order_{asset_suffix}_raster" - ca_asset = f"{base_path}catchment_area_{asset_suffix}_raster" - - # Load rasters - stream_order_band = ee.Image(so_asset).select("b1") - catchment_band = ee.Image(ca_asset).select("b1") + stream_order_band = ee.Image(stream_order_asset_id).select([0], ["b1"]) + catchment_band = ee.Image(catchment_area_asset_id).select([0], ["b1"]) # Processing per waterbody def compute_for_feature(feature): diff --git a/waterrejuvenation/utils.py b/waterrejuvenation/utils.py index b61fb7b7..ed129647 100644 --- a/waterrejuvenation/utils.py +++ b/waterrejuvenation/utils.py @@ -692,7 +692,7 @@ def add_on_drainage_flag(swb_fc, dl_asset_id): ee.FeatureCollection: SWB FC with added property 'on_drainage_line' """ - dl_fc = ee.FeatureCollection(dl_asset_id) + dl_fc = ee.FeatureCollection(dl_asset_id).filterBounds(swb_fc.geometry()) # Map over each SWB feature def set_flag(feature): From bdec7fad25bad30cc7250faba47042fcc3388b9d Mon Sep 17 00:00:00 2001 From: aman verma Date: Tue, 4 Aug 2026 15:39:04 +0000 Subject: [PATCH 095/120] SPEI on agri-years --- computing/spei/generate_spei/compute_spei.R | 80 +++++++++++++++---- .../generate_spei/download_base_datasets.py | 7 +- .../generate_spei/generate_ppet_multiband.py | 11 ++- computing/spei/spei.py | 6 +- 4 files changed, 80 insertions(+), 24 deletions(-) diff --git a/computing/spei/generate_spei/compute_spei.R b/computing/spei/generate_spei/compute_spei.R index c80cf8e9..b6ced39f 100644 --- a/computing/spei/generate_spei/compute_spei.R +++ b/computing/spei/generate_spei/compute_spei.R @@ -14,6 +14,7 @@ library(SPEI) library(raster) +library(terra) run_spei_pipeline <- function(aez, start_year, end_year) { @@ -91,11 +92,37 @@ run_spei_pipeline <- function(aez, start_year, end_year) { cat(paste("Loaded", nlayers(p_pet_brick), "bands (expected", n_monthly, ")\n")) # --- Band names --- - spei1_names <- paste0('y', rep(start_year:end_year, each = 12), - '_m', sprintf('%02d', rep(1:12, n_years))) - spei3_names <- paste0('y', rep(start_year:end_year, each = 4), - '_m', sprintf('%02d', rep(c(3,6,9,12), n_years))) - spei12_names <- paste0('y', start_year:end_year) + agri_months <- c(7:12, 1:6) + + years <- unlist(lapply(start_year:end_year, function(y) { + c(rep(y, 6), rep(y + 1, 6)) + })) + + spei1_names <- paste0( + "y", + years, + "_m", + sprintf("%02d", rep(agri_months, n_years)) + ) + + agri_quarter_months <- c("07_09", "10_12", "01_03", "04_06") + + years3 <- unlist(lapply(start_year:end_year, function(y) { + c(y, y, y + 1, y + 1) + })) + + spei3_names <- paste0( + "y", + years3, + "_m", + sprintf("%s", rep(agri_quarter_months, n_years)) + ) + + spei12_names <- paste0( + start_year:end_year, + "_", + (start_year + 1):(end_year + 1) + ) # --- Compute block by block --- cat("Running SPEI computation...\n") @@ -136,17 +163,38 @@ run_spei_pipeline <- function(aez, start_year, end_year) { if (file.exists(spei3_file)) file.remove(spei3_file) if (file.exists(spei12_file)) file.remove(spei12_file) - writeRaster(spei1_b, - spei1_file, - format = "GTiff", overwrite = TRUE, NAflag = -9999) - - writeRaster(spei3_b, - spei3_file, - format = "GTiff", overwrite = TRUE, NAflag = -9999) - - writeRaster(spei12_b, - spei12_file, - format = "GTiff", overwrite = TRUE, NAflag = -9999) + spei1_t <- rast(spei1_b) + names(spei1_t) <- spei1_names + + terra::writeRaster( + spei1_t, + spei1_file, + overwrite = TRUE, + NAflag = -9999, + gdal = c("COMPRESS=LZW") + ) + + r <- terra::rast(spei1_file) + + spei3_t <- rast(spei3_b) + names(spei3_t) <- spei3_names + + terra::writeRaster( + spei3_t, + spei3_file, + overwrite = TRUE, + NAflag = -9999 + ) + + spei12_t <- rast(spei12_b) + names(spei12_t) <- spei12_names + + terra::writeRaster( + spei12_t, + spei12_file, + overwrite = TRUE, + NAflag = -9999 + ) file.remove(temp_file) diff --git a/computing/spei/generate_spei/download_base_datasets.py b/computing/spei/generate_spei/download_base_datasets.py index 6b1383fc..0225816f 100644 --- a/computing/spei/generate_spei/download_base_datasets.py +++ b/computing/spei/generate_spei/download_base_datasets.py @@ -407,8 +407,8 @@ def download_one(job: tuple[int, ee.Date, str, Path]) -> tuple[int, str]: def download_data_locally( aez: int, datasets: list[str] | str | None = None, - start_date: str = "2004-01-01", - end_date: str = "2025-12-31", + start_year: str = None, + end_year: str = None, frequency: str = "monthly", output_dir: str = "data/base_layers/spei/inputs", sleep: float = 0.2, @@ -420,6 +420,9 @@ def download_data_locally( selected_datasets = datasets or ["both"] # validate_inputs(aez, selected_datasets, frequency) + start_date = f"{str(start_year)}-07-01" + end_date = f"{str(end_year+1)}-06-30" + expanded_datasets = expand_datasets(selected_datasets) for dataset in expanded_datasets: scale = 5500 diff --git a/computing/spei/generate_spei/generate_ppet_multiband.py b/computing/spei/generate_spei/generate_ppet_multiband.py index a7ff807c..c523e354 100644 --- a/computing/spei/generate_spei/generate_ppet_multiband.py +++ b/computing/spei/generate_spei/generate_ppet_multiband.py @@ -84,7 +84,7 @@ def reproject_modis_to_chirps_grid( def ppet_multiband( aez=None, start: int = 2004, - end: int = 2024, # TODO remove hardcoding + end: int = 2024, ) -> Path: """ SPEI Pipeline - Step 1 (Local P-PET) @@ -113,7 +113,14 @@ def ppet_multiband( ) 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)] + agri_months = list(range(7, 13)) + list(range(1, 7)) + months = [ + (year if month >= 7 else year + 1, month) + for year in range(start, end + 1) + for month in agri_months + ] + + # 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: diff --git a/computing/spei/spei.py b/computing/spei/spei.py index 3559019c..0df7a220 100644 --- a/computing/spei/spei.py +++ b/computing/spei/spei.py @@ -31,13 +31,11 @@ def generate_spei_pipeline( overwrite=False, ): ee_initialize(gee_account_id) - start_date = f"{str(start_year)}-01-01" - end_date = f"{str(end_year)}-12-31" download_data_locally( aez=aez, - start_date=start_date, - end_date=end_date, + start_year=start_year, + end_year=end_year, frequency="monthly", datasets=None, overwrite=overwrite, From 18d24e9ddf25a98e7c00692ffd330c5385a2af9e Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Tue, 4 Aug 2026 22:24:15 +0530 Subject: [PATCH 096/120] swb3 and swb4 layers uses pan india stream order, catchment area and drainage lines --- computing/surface_water_bodies/swb_local.py | 4 ++-- computing/tests.py | 10 ++++++++-- utilities/constants.py | 18 +++++++++--------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/computing/surface_water_bodies/swb_local.py b/computing/surface_water_bodies/swb_local.py index ff67287d..3ca34c22 100644 --- a/computing/surface_water_bodies/swb_local.py +++ b/computing/surface_water_bodies/swb_local.py @@ -108,11 +108,11 @@ def _resolve_asset_suffix(state, district, block, asset_suffix): def _layer_name(asset_suffix): - return f"surface_waterbodies_{asset_suffix}_local" + return f"surface_waterbodies_{asset_suffix}" def _final_layer_name(asset_suffix): - return f"surface_waterbodies_{asset_suffix}" + return _layer_name(asset_suffix) def _gee_description(asset_suffix): diff --git a/computing/tests.py b/computing/tests.py index 7abf502f..3d7e9558 100644 --- a/computing/tests.py +++ b/computing/tests.py @@ -16,6 +16,7 @@ from computing.surface_water_bodies.swb_local import ( _continue_swb_in_gee, _final_layer_name, + _layer_name, ) from computing.surface_water_bodies.swb3 import ( waterbody_catchment_streamorder_properties, @@ -52,10 +53,15 @@ def test_swb_enrichment_defaults_to_pan_india_assets(self): PAN_INDIA_DRAINAGE_LINES_DATASET, ) - def test_final_geoserver_layer_has_no_local_suffix(self): + def test_geoserver_layers_have_no_local_suffix(self): + expected_layer_name = "surface_waterbodies_dumka_jarmundi" + self.assertEqual( + _layer_name("dumka_jarmundi"), + expected_layer_name, + ) self.assertEqual( _final_layer_name("dumka_jarmundi"), - "surface_waterbodies_dumka_jarmundi", + expected_layer_name, ) def test_local_map_passes_gee_account_to_swb(self): diff --git a/utilities/constants.py b/utilities/constants.py index 9e702be8..b67c0a76 100644 --- a/utilities/constants.py +++ b/utilities/constants.py @@ -5,14 +5,14 @@ ADMIN_BOUNDARY_INPUT_DIR = "data/admin-boundary/input" ADMIN_BOUNDARY_OUTPUT_DIR = "data/admin-boundary/output" -# Canonical local pipeline resources. Runtime pipelines read these defaults -# directly; their YAML `sources` entries are optional test overrides. -BASE_RESOURCES_DIR = "data/base_resources" -ADMIN_BOUNDARY_GPKG = f"{BASE_RESOURCES_DIR}/cs_admin_standard.gpkg" -FACILITIES_GPKG = f"{BASE_RESOURCES_DIR}/cs_pan_india_facilities.gpkg" -ANTYODAYA_2020_CSV = f"{BASE_RESOURCES_DIR}/cs_antyodaya_2020_cluster_analysis.csv" -LIVESTOCK_CENSUS_20_CSV = f"{BASE_RESOURCES_DIR}/cs_livestock_census_20.csv" - +# Canonical local pipeline resources. Runtime pipelines read these defaults +# directly; their YAML `sources` entries are optional test overrides. +BASE_RESOURCES_DIR = "data/base_resources" +ADMIN_BOUNDARY_GPKG = f"{BASE_RESOURCES_DIR}/cs_admin_standard.gpkg" +FACILITIES_GPKG = f"{BASE_RESOURCES_DIR}/cs_pan_india_facilities.gpkg" +ANTYODAYA_2020_CSV = f"{BASE_RESOURCES_DIR}/cs_antyodaya_2020_cluster_analysis.csv" +LIVESTOCK_CENSUS_20_CSV = f"{BASE_RESOURCES_DIR}/cs_livestock_census_20.csv" + NREGA_ASSETS_INPUT_DIR = "data/nrega_assets/input" NREGA_ASSETS_OUTPUT_DIR = "data/nrega_assets/output" @@ -344,7 +344,7 @@ # other FIRST_COMPUTING_API_PATH = "/api/v1/generate_block_layer/" -WBC = "projects/ext-datasets/assets/datasets/WBC_" +WBC = "projects/ext-datasets/assets/datasets/WBC/WBC_" WATERREJUVENATION_PROJECT = GEE_STORAGE_PROJECT # Plantation From 53657b36cab7197ef31d64b589c1b520ab591f9f Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Wed, 5 Aug 2026 00:06:19 +0530 Subject: [PATCH 097/120] local bulk generation commands for regeneration --- computing/bulk_layer_generation.py | 54 ++++++++++++++ .../commands/bulk_generate_layers.py | 46 +++++++++--- computing/tests.py | 70 +++++++++++++++++++ 3 files changed, 159 insertions(+), 11 deletions(-) diff --git a/computing/bulk_layer_generation.py b/computing/bulk_layer_generation.py index 4f309c20..ed2069c7 100644 --- a/computing/bulk_layer_generation.py +++ b/computing/bulk_layer_generation.py @@ -9,6 +9,7 @@ from django.db.models import Q from django.utils.module_loading import import_string +from computing.models import Layer from geoadmin.models import TehsilSOI from utilities.pipelines import api_request_payload @@ -243,6 +244,59 @@ def get_active_locations( ] +def get_locally_generated_locations( + *, + state: str | None = None, + district: str | None = None, + block: str | None = None, + blocks: list[str] | None = None, + limit: int | None = None, +) -> list[Location]: + if block and blocks: + raise ValueError("Use either block or blocks, not both.") + + queryset = Layer.objects.filter( + misc__is_generated_locally=True, + ) + if state: + queryset = queryset.filter(state__state_name__iexact=state) + if district: + queryset = queryset.filter(district__district_name__iexact=district) + selected_blocks = [block] if block else list(dict.fromkeys(blocks or [])) + if selected_blocks: + queryset = queryset.filter( + reduce( + or_, + (Q(block__tehsil_name__iexact=name) for name in selected_blocks), + ) + ) + + locations = ( + queryset.values( + "state__state_name", + "district__district_name", + "block__tehsil_name", + ) + .order_by( + "state__state_name", + "district__district_name", + "block__tehsil_name", + ) + .distinct() + ) + if limit is not None: + locations = locations[:limit] + + return [ + Location( + state=location["state__state_name"], + district=location["district__district_name"], + block=location["block__tehsil_name"], + ) + for location in locations + ] + + def get_active_locations_from_api( *, state: str | None = None, diff --git a/computing/management/commands/bulk_generate_layers.py b/computing/management/commands/bulk_generate_layers.py index 4bfedb48..b733b697 100644 --- a/computing/management/commands/bulk_generate_layers.py +++ b/computing/management/commands/bulk_generate_layers.py @@ -3,6 +3,7 @@ from computing.bulk_layer_generation import ( get_active_locations, get_active_locations_from_api, + get_locally_generated_locations, pipeline_names, validate_pipeline, ) @@ -18,6 +19,14 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("pipeline", nargs="?") parser.add_argument("--all-active", action="store_true") + parser.add_argument( + "--regenerate-local", + action="store_true", + help=( + "Queue the pipeline for locations with at least one Layer row " + "where misc.is_generated_locally is true." + ), + ) parser.add_argument( "--from-prod-api", action="store_true", @@ -79,14 +88,25 @@ def handle(self, *args, **options): } if options["blocks"]: filters["blocks"] = options["blocks"] - if not options["all_active"] and not filters: + if ( + not options["all_active"] + and not options["regenerate_local"] + and not filters + ): raise CommandError( - "Specify --all-active or at least one of " + "Specify --all-active, --regenerate-local, or at least one of " "--state, --district, or --block." ) - if options["all_active"] and filters: + if options["all_active"] and (options["regenerate_local"] or filters): raise CommandError( - "--all-active cannot be combined with location filters." + "--all-active cannot be combined with --regenerate-local or " + "location filters." + ) + if options["regenerate_local"] and options["compute"] != "local": + raise CommandError("--regenerate-local requires --compute=local.") + if options["regenerate_local"] and options["from_prod_api"]: + raise CommandError( + "--regenerate-local cannot be combined with --from-prod-api." ) if options["limit"] is not None and options["limit"] < 1: raise CommandError("--limit must be greater than zero.") @@ -94,11 +114,12 @@ def handle(self, *args, **options): if not queue: raise CommandError("--queue cannot be empty.") - location_loader = ( - get_active_locations_from_api - if options["from_prod_api"] - else get_active_locations - ) + if options["regenerate_local"]: + location_loader = get_locally_generated_locations + elif options["from_prod_api"]: + location_loader = get_active_locations_from_api + else: + location_loader = get_active_locations try: locations = location_loader( **filters, @@ -107,12 +128,15 @@ def handle(self, *args, **options): except ValueError as exc: raise CommandError(str(exc)) from exc if not locations: - raise CommandError("No active locations matched the requested scope.") + raise CommandError("No locations matched the requested scope.") action = "Would queue" if options["dry_run"] else "Queueing" + location_source = ( + "locally generated" if options["regenerate_local"] else "active" + ) self.stdout.write( f"{action} {options['compute']} pipeline '{pipeline}' for " - f"{len(locations)} active " + f"{len(locations)} {location_source} " f"location(s) on queue '{queue}'." ) diff --git a/computing/tests.py b/computing/tests.py index 3d7e9558..b577d410 100644 --- a/computing/tests.py +++ b/computing/tests.py @@ -10,9 +10,11 @@ Location, get_active_locations, get_active_locations_from_api, + get_locally_generated_locations, run_pipeline, ) from computing.layer_dependency.layer_generation_in_order import get_args, load_map_config +from computing.models import Dataset, Layer from computing.surface_water_bodies.swb_local import ( _continue_swb_in_gee, _final_layer_name, @@ -373,6 +375,36 @@ def test_command_loads_locations_from_prod_api(self, get_locations): get_locations.assert_called_once_with(limit=None) + @patch( + "computing.management.commands.bulk_generate_layers." + "bulk_generate_layer.apply_async" + ) + @patch( + "computing.management.commands.bulk_generate_layers." + "get_locally_generated_locations" + ) + def test_command_regenerates_locally_generated_locations( + self, get_locations, apply_async + ): + get_locations.return_value = [ + Location("Jharkhand", "Dumka", "Masalia") + ] + apply_async.return_value.id = "task-id" + + call_command( + "bulk_generate_layers", + "livestocks", + "--regenerate-local", + "--district=Dumka", + stdout=StringIO(), + ) + + get_locations.assert_called_once_with( + district="Dumka", + limit=None, + ) + apply_async.assert_called_once() + @override_settings(PROD_BACKEND_URL="https://geoserver.core-stack.org/") class ActiveLocationsApiTests(SimpleTestCase): @@ -508,3 +540,41 @@ def test_active_locations_accept_multiple_blocks(self): [location.block for location in locations], ["Jarmundi", "Masalia"], ) + + def test_locally_generated_locations_are_distinct_and_filterable(self): + dataset = Dataset.objects.create(name="Local Dataset") + jarmundi = TehsilSOI.objects.get(tehsil_name="Jarmundi") + masalia = TehsilSOI.objects.get(tehsil_name="Masalia") + for layer_name in ("local_one", "local_two"): + Layer.objects.create( + dataset=dataset, + layer_name=layer_name, + state=jarmundi.district.state, + district=jarmundi.district, + block=jarmundi, + misc={"is_generated_locally": True}, + ) + Layer.objects.create( + dataset=dataset, + layer_name="gee_layer", + state=masalia.district.state, + district=masalia.district, + block=masalia, + misc={"is_generated_locally": False}, + ) + + locations = get_locally_generated_locations( + district="dumka", + blocks=["JARMUNDI", "Masalia"], + ) + + self.assertEqual( + [location.asdict() for location in locations], + [ + { + "state": "Jharkhand", + "district": "Dumka", + "block": "Jarmundi", + } + ], + ) From 929714254a90287515830a23a2551bd4cb7e1bac Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Tue, 4 Aug 2026 22:24:15 +0530 Subject: [PATCH 098/120] swb3 and swb4 layers uses pan india stream order, catchment area and drainage lines --- computing/surface_water_bodies/swb_local.py | 4 ++-- computing/tests.py | 10 ++++++++-- utilities/constants.py | 18 +++++++++--------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/computing/surface_water_bodies/swb_local.py b/computing/surface_water_bodies/swb_local.py index ff67287d..3ca34c22 100644 --- a/computing/surface_water_bodies/swb_local.py +++ b/computing/surface_water_bodies/swb_local.py @@ -108,11 +108,11 @@ def _resolve_asset_suffix(state, district, block, asset_suffix): def _layer_name(asset_suffix): - return f"surface_waterbodies_{asset_suffix}_local" + return f"surface_waterbodies_{asset_suffix}" def _final_layer_name(asset_suffix): - return f"surface_waterbodies_{asset_suffix}" + return _layer_name(asset_suffix) def _gee_description(asset_suffix): diff --git a/computing/tests.py b/computing/tests.py index 7abf502f..3d7e9558 100644 --- a/computing/tests.py +++ b/computing/tests.py @@ -16,6 +16,7 @@ from computing.surface_water_bodies.swb_local import ( _continue_swb_in_gee, _final_layer_name, + _layer_name, ) from computing.surface_water_bodies.swb3 import ( waterbody_catchment_streamorder_properties, @@ -52,10 +53,15 @@ def test_swb_enrichment_defaults_to_pan_india_assets(self): PAN_INDIA_DRAINAGE_LINES_DATASET, ) - def test_final_geoserver_layer_has_no_local_suffix(self): + def test_geoserver_layers_have_no_local_suffix(self): + expected_layer_name = "surface_waterbodies_dumka_jarmundi" + self.assertEqual( + _layer_name("dumka_jarmundi"), + expected_layer_name, + ) self.assertEqual( _final_layer_name("dumka_jarmundi"), - "surface_waterbodies_dumka_jarmundi", + expected_layer_name, ) def test_local_map_passes_gee_account_to_swb(self): diff --git a/utilities/constants.py b/utilities/constants.py index 9e702be8..b67c0a76 100644 --- a/utilities/constants.py +++ b/utilities/constants.py @@ -5,14 +5,14 @@ ADMIN_BOUNDARY_INPUT_DIR = "data/admin-boundary/input" ADMIN_BOUNDARY_OUTPUT_DIR = "data/admin-boundary/output" -# Canonical local pipeline resources. Runtime pipelines read these defaults -# directly; their YAML `sources` entries are optional test overrides. -BASE_RESOURCES_DIR = "data/base_resources" -ADMIN_BOUNDARY_GPKG = f"{BASE_RESOURCES_DIR}/cs_admin_standard.gpkg" -FACILITIES_GPKG = f"{BASE_RESOURCES_DIR}/cs_pan_india_facilities.gpkg" -ANTYODAYA_2020_CSV = f"{BASE_RESOURCES_DIR}/cs_antyodaya_2020_cluster_analysis.csv" -LIVESTOCK_CENSUS_20_CSV = f"{BASE_RESOURCES_DIR}/cs_livestock_census_20.csv" - +# Canonical local pipeline resources. Runtime pipelines read these defaults +# directly; their YAML `sources` entries are optional test overrides. +BASE_RESOURCES_DIR = "data/base_resources" +ADMIN_BOUNDARY_GPKG = f"{BASE_RESOURCES_DIR}/cs_admin_standard.gpkg" +FACILITIES_GPKG = f"{BASE_RESOURCES_DIR}/cs_pan_india_facilities.gpkg" +ANTYODAYA_2020_CSV = f"{BASE_RESOURCES_DIR}/cs_antyodaya_2020_cluster_analysis.csv" +LIVESTOCK_CENSUS_20_CSV = f"{BASE_RESOURCES_DIR}/cs_livestock_census_20.csv" + NREGA_ASSETS_INPUT_DIR = "data/nrega_assets/input" NREGA_ASSETS_OUTPUT_DIR = "data/nrega_assets/output" @@ -344,7 +344,7 @@ # other FIRST_COMPUTING_API_PATH = "/api/v1/generate_block_layer/" -WBC = "projects/ext-datasets/assets/datasets/WBC_" +WBC = "projects/ext-datasets/assets/datasets/WBC/WBC_" WATERREJUVENATION_PROJECT = GEE_STORAGE_PROJECT # Plantation From c7a8db841f21c408ebb16aa828928840862dab81 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Wed, 5 Aug 2026 00:06:19 +0530 Subject: [PATCH 099/120] local bulk generation commands for regeneration --- computing/bulk_layer_generation.py | 54 ++++++++++++++ .../commands/bulk_generate_layers.py | 46 +++++++++--- computing/tests.py | 70 +++++++++++++++++++ 3 files changed, 159 insertions(+), 11 deletions(-) diff --git a/computing/bulk_layer_generation.py b/computing/bulk_layer_generation.py index 4f309c20..ed2069c7 100644 --- a/computing/bulk_layer_generation.py +++ b/computing/bulk_layer_generation.py @@ -9,6 +9,7 @@ from django.db.models import Q from django.utils.module_loading import import_string +from computing.models import Layer from geoadmin.models import TehsilSOI from utilities.pipelines import api_request_payload @@ -243,6 +244,59 @@ def get_active_locations( ] +def get_locally_generated_locations( + *, + state: str | None = None, + district: str | None = None, + block: str | None = None, + blocks: list[str] | None = None, + limit: int | None = None, +) -> list[Location]: + if block and blocks: + raise ValueError("Use either block or blocks, not both.") + + queryset = Layer.objects.filter( + misc__is_generated_locally=True, + ) + if state: + queryset = queryset.filter(state__state_name__iexact=state) + if district: + queryset = queryset.filter(district__district_name__iexact=district) + selected_blocks = [block] if block else list(dict.fromkeys(blocks or [])) + if selected_blocks: + queryset = queryset.filter( + reduce( + or_, + (Q(block__tehsil_name__iexact=name) for name in selected_blocks), + ) + ) + + locations = ( + queryset.values( + "state__state_name", + "district__district_name", + "block__tehsil_name", + ) + .order_by( + "state__state_name", + "district__district_name", + "block__tehsil_name", + ) + .distinct() + ) + if limit is not None: + locations = locations[:limit] + + return [ + Location( + state=location["state__state_name"], + district=location["district__district_name"], + block=location["block__tehsil_name"], + ) + for location in locations + ] + + def get_active_locations_from_api( *, state: str | None = None, diff --git a/computing/management/commands/bulk_generate_layers.py b/computing/management/commands/bulk_generate_layers.py index 4bfedb48..b733b697 100644 --- a/computing/management/commands/bulk_generate_layers.py +++ b/computing/management/commands/bulk_generate_layers.py @@ -3,6 +3,7 @@ from computing.bulk_layer_generation import ( get_active_locations, get_active_locations_from_api, + get_locally_generated_locations, pipeline_names, validate_pipeline, ) @@ -18,6 +19,14 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("pipeline", nargs="?") parser.add_argument("--all-active", action="store_true") + parser.add_argument( + "--regenerate-local", + action="store_true", + help=( + "Queue the pipeline for locations with at least one Layer row " + "where misc.is_generated_locally is true." + ), + ) parser.add_argument( "--from-prod-api", action="store_true", @@ -79,14 +88,25 @@ def handle(self, *args, **options): } if options["blocks"]: filters["blocks"] = options["blocks"] - if not options["all_active"] and not filters: + if ( + not options["all_active"] + and not options["regenerate_local"] + and not filters + ): raise CommandError( - "Specify --all-active or at least one of " + "Specify --all-active, --regenerate-local, or at least one of " "--state, --district, or --block." ) - if options["all_active"] and filters: + if options["all_active"] and (options["regenerate_local"] or filters): raise CommandError( - "--all-active cannot be combined with location filters." + "--all-active cannot be combined with --regenerate-local or " + "location filters." + ) + if options["regenerate_local"] and options["compute"] != "local": + raise CommandError("--regenerate-local requires --compute=local.") + if options["regenerate_local"] and options["from_prod_api"]: + raise CommandError( + "--regenerate-local cannot be combined with --from-prod-api." ) if options["limit"] is not None and options["limit"] < 1: raise CommandError("--limit must be greater than zero.") @@ -94,11 +114,12 @@ def handle(self, *args, **options): if not queue: raise CommandError("--queue cannot be empty.") - location_loader = ( - get_active_locations_from_api - if options["from_prod_api"] - else get_active_locations - ) + if options["regenerate_local"]: + location_loader = get_locally_generated_locations + elif options["from_prod_api"]: + location_loader = get_active_locations_from_api + else: + location_loader = get_active_locations try: locations = location_loader( **filters, @@ -107,12 +128,15 @@ def handle(self, *args, **options): except ValueError as exc: raise CommandError(str(exc)) from exc if not locations: - raise CommandError("No active locations matched the requested scope.") + raise CommandError("No locations matched the requested scope.") action = "Would queue" if options["dry_run"] else "Queueing" + location_source = ( + "locally generated" if options["regenerate_local"] else "active" + ) self.stdout.write( f"{action} {options['compute']} pipeline '{pipeline}' for " - f"{len(locations)} active " + f"{len(locations)} {location_source} " f"location(s) on queue '{queue}'." ) diff --git a/computing/tests.py b/computing/tests.py index 3d7e9558..b577d410 100644 --- a/computing/tests.py +++ b/computing/tests.py @@ -10,9 +10,11 @@ Location, get_active_locations, get_active_locations_from_api, + get_locally_generated_locations, run_pipeline, ) from computing.layer_dependency.layer_generation_in_order import get_args, load_map_config +from computing.models import Dataset, Layer from computing.surface_water_bodies.swb_local import ( _continue_swb_in_gee, _final_layer_name, @@ -373,6 +375,36 @@ def test_command_loads_locations_from_prod_api(self, get_locations): get_locations.assert_called_once_with(limit=None) + @patch( + "computing.management.commands.bulk_generate_layers." + "bulk_generate_layer.apply_async" + ) + @patch( + "computing.management.commands.bulk_generate_layers." + "get_locally_generated_locations" + ) + def test_command_regenerates_locally_generated_locations( + self, get_locations, apply_async + ): + get_locations.return_value = [ + Location("Jharkhand", "Dumka", "Masalia") + ] + apply_async.return_value.id = "task-id" + + call_command( + "bulk_generate_layers", + "livestocks", + "--regenerate-local", + "--district=Dumka", + stdout=StringIO(), + ) + + get_locations.assert_called_once_with( + district="Dumka", + limit=None, + ) + apply_async.assert_called_once() + @override_settings(PROD_BACKEND_URL="https://geoserver.core-stack.org/") class ActiveLocationsApiTests(SimpleTestCase): @@ -508,3 +540,41 @@ def test_active_locations_accept_multiple_blocks(self): [location.block for location in locations], ["Jarmundi", "Masalia"], ) + + def test_locally_generated_locations_are_distinct_and_filterable(self): + dataset = Dataset.objects.create(name="Local Dataset") + jarmundi = TehsilSOI.objects.get(tehsil_name="Jarmundi") + masalia = TehsilSOI.objects.get(tehsil_name="Masalia") + for layer_name in ("local_one", "local_two"): + Layer.objects.create( + dataset=dataset, + layer_name=layer_name, + state=jarmundi.district.state, + district=jarmundi.district, + block=jarmundi, + misc={"is_generated_locally": True}, + ) + Layer.objects.create( + dataset=dataset, + layer_name="gee_layer", + state=masalia.district.state, + district=masalia.district, + block=masalia, + misc={"is_generated_locally": False}, + ) + + locations = get_locally_generated_locations( + district="dumka", + blocks=["JARMUNDI", "Masalia"], + ) + + self.assertEqual( + [location.asdict() for location in locations], + [ + { + "state": "Jharkhand", + "district": "Dumka", + "block": "Jarmundi", + } + ], + ) From 5ea3f46a646e9296c5c78732ed7c01f1ca3e6978 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Wed, 5 Aug 2026 02:31:08 +0530 Subject: [PATCH 100/120] swb fixes and layer sync --- computing/surface_water_bodies/area_utils.py | 76 +++++++++++++++ computing/surface_water_bodies/swb3.py | 3 +- computing/surface_water_bodies/swb4.py | 15 ++- computing/surface_water_bodies/swb_local.py | 92 +++++++++++++----- computing/tests.py | 98 +++++++++++++++++++- 5 files changed, 255 insertions(+), 29 deletions(-) create mode 100644 computing/surface_water_bodies/area_utils.py diff --git a/computing/surface_water_bodies/area_utils.py b/computing/surface_water_bodies/area_utils.py new file mode 100644 index 00000000..a5d66ea5 --- /dev/null +++ b/computing/surface_water_bodies/area_utils.py @@ -0,0 +1,76 @@ +import ee +import pandas as pd + + +SQM_PER_HECTARE = 10000.0 +AREA_YEARS = tuple(range(2018, 2026)) + + +def add_area_ored_to_gdf(gdf): + result = gdf.copy() + area_ored = ( + pd.to_numeric(result["area_ored"], errors="coerce") + if "area_ored" in result + else pd.Series(float("nan"), index=result.index, dtype=float) + ) + + for year in reversed(AREA_YEARS): + area_column = f"area_{year}" + k_column = f"k_{year}" + if area_column not in result or k_column not in result: + continue + + area = pd.to_numeric(result[area_column], errors="coerce") + k = pd.to_numeric(result[k_column], errors="coerce") + candidate = area.multiply(100).divide(k).divide(SQM_PER_HECTARE) + area_ored = area_ored.fillna(candidate.where(k.notna() & k.ne(0))) + + result["area_ored"] = area_ored + return result + + +def _gee_number_and_presence(feature, property_name): + value = feature.get(property_name) + missing = ee.Algorithms.IsEqual(value, None) + number = ee.Number(ee.Algorithms.If(missing, 0, value)) + present = ee.Number(ee.Algorithms.If(missing, 0, 1)).eq(1) + return number, present + + +def gee_area_ored(feature): + feature = ee.Feature(feature) + geometry_area = feature.geometry().area(maxError=1).divide(SQM_PER_HECTARE) + + total_area_m2, has_total_area_m2 = _gee_number_and_presence( + feature, "total_area_m2" + ) + total_area, has_total_area = _gee_number_and_presence(feature, "total_area") + annual_area_divisor = ee.Number( + ee.Algorithms.If(has_total_area_m2, SQM_PER_HECTARE, 1) + ) + derived = ee.Number( + ee.Algorithms.If( + has_total_area, + total_area, + ee.Algorithms.If( + has_total_area_m2, + total_area_m2.divide(SQM_PER_HECTARE), + geometry_area, + ), + ) + ) + + for year in AREA_YEARS: + area, has_area = _gee_number_and_presence(feature, f"area_{year}") + k, has_k = _gee_number_and_presence(feature, f"k_{year}") + valid = has_area.And(has_k).And(k.neq(0)) + candidate = area.multiply(100).divide(k).divide(annual_area_divisor) + derived = ee.Number(ee.Algorithms.If(valid, candidate, derived)) + + existing, has_existing = _gee_number_and_presence(feature, "area_ored") + return ee.Number(ee.Algorithms.If(has_existing, existing, derived)) + + +def ensure_gee_area_ored(feature): + feature = ee.Feature(feature) + return feature.set("area_ored", gee_area_ored(feature)) diff --git a/computing/surface_water_bodies/swb3.py b/computing/surface_water_bodies/swb3.py index d6f4e338..7b1281f7 100644 --- a/computing/surface_water_bodies/swb3.py +++ b/computing/surface_water_bodies/swb3.py @@ -1,4 +1,5 @@ from computing.utils import generate_swb_layer_with_max_so_catchment +from computing.surface_water_bodies.area_utils import ensure_gee_area_ored from utilities.constants import ( CATCHMENT_AREA, GEE_PATHS, @@ -266,7 +267,7 @@ def waterbody_catchment_streamorder_properties( + swb2_asset_suffix ) # As requested: SWB3 always uses SWB2 as input. - water_bodies = ee.FeatureCollection(swb2_asset) + water_bodies = ee.FeatureCollection(swb2_asset).map(ensure_gee_area_ored) print(f"asset_i{water_bodies}") swb4_fs = generate_swb_layer_with_max_so_catchment( diff --git a/computing/surface_water_bodies/swb4.py b/computing/surface_water_bodies/swb4.py index afb64ebb..dcb05eb7 100644 --- a/computing/surface_water_bodies/swb4.py +++ b/computing/surface_water_bodies/swb4.py @@ -1,5 +1,6 @@ import ee +from computing.surface_water_bodies.area_utils import ensure_gee_area_ored from utilities.constants import GEE_PATHS, WBC from utilities.gee_utils import ( get_gee_dir_path, @@ -40,7 +41,18 @@ def waterbody_wbc_intersection( ) + "swb3_" + asset_suffix - ) + ).map(ensure_gee_area_ored) + + def ensure_uid(feature): + feature = ee.Feature(feature) + uid = ee.Algorithms.If( + ee.Algorithms.IsEqual(feature.get("UID"), None), + feature.get("wb_id"), + feature.get("UID"), + ) + return feature.set("UID", uid) + + water_bodies = water_bodies.map(ensure_uid) # Filter points and polygons within the area of interest (aoi) points = census_state.filterBounds(roi) @@ -92,7 +104,6 @@ def replace_null_area(feature): def select_closest_polygon(feature): # Calculate the spread area of the water body spread = ee.Number(feature.get("water_spread_area_of_water_body")) - spread = spread.multiply(10000) intersections = ee.List(feature.get("intersections")) # Calculate the difference between intersection areas diff --git a/computing/surface_water_bodies/swb_local.py b/computing/surface_water_bodies/swb_local.py index 3ca34c22..3e5cb8f6 100644 --- a/computing/surface_water_bodies/swb_local.py +++ b/computing/surface_water_bodies/swb_local.py @@ -15,6 +15,10 @@ read_validated_vector_file, write_vector_output, ) +from computing.surface_water_bodies.area_utils import ( + SQM_PER_HECTARE, + add_area_ored_to_gdf, +) from computing.surface_water_bodies.clip_swb_local import _clip_gdf, _to_geom from computing.surface_water_bodies.swb3 import ( waterbody_catchment_streamorder_properties, @@ -45,7 +49,6 @@ DATASET_NAME = "Surface Water Bodies" GEOSERVER_WORKSPACE = "swb" logger = logging.getLogger(__name__) -SQM_PER_HECTARE = 10000.0 GEE_EXPORT_CHUNK_SIZE = 1000 @@ -163,7 +166,7 @@ def _prepare_gdf_for_gee(gdf): def _convert_area_columns_to_hectares(gdf): - converted = gdf.copy() + converted = add_area_ored_to_gdf(gdf) converted_columns = {} for column in list(converted.columns): @@ -173,7 +176,7 @@ def _convert_area_columns_to_hectares(gdf): target_column = None if column == "total_area_m2": target_column = "total_area" - elif column.startswith("area_"): + elif column.startswith("area_") and column != "area_ored": target_column = column if target_column is None: @@ -187,6 +190,33 @@ def _convert_area_columns_to_hectares(gdf): return converted, converted_columns +def _create_local_swb_output(swb_path, roi_geometry, output_path, layer_name): + clipped_gdf = _clip_gdf(_resolve_source_path(swb_path), roi_geometry) + if clipped_gdf.empty: + raise ValueError("No surface water body features intersect the provided ROI.") + + clipped_gdf, converted_area_columns = _convert_area_columns_to_hectares( + clipped_gdf + ) + logger.info( + "Clipped SWB features: count=%s columns=%s", + len(clipped_gdf), + list(clipped_gdf.columns), + ) + logger.info( + "Converted SWB area columns from square meters to hectares: %s", + converted_area_columns, + ) + + local_asset_path = write_vector_output( + gdf=clipped_gdf, + output_path=output_path, + layer_name=layer_name, + ) + logger.info("Saved local SWB vector to disk: %s", local_asset_path) + return clipped_gdf, local_asset_path + + def _union_geometry(gdf): try: return gdf.union_all() @@ -278,7 +308,11 @@ def _push_local_swb_to_geoserver(output_path, layer_name): file_type="gpkg", ) logger.info("GeoServer response for local SWB layer %s: %s", layer_name, geoserver_response) - return bool(geoserver_response) and geoserver_response.get("status_code") in (200, 201) + return bool(geoserver_response) and geoserver_response.get("status_code") in ( + 200, + 201, + 202, + ) def _continue_swb_in_gee( @@ -363,7 +397,9 @@ def _sync_final_swb( layer_name, workspace=GEOSERVER_WORKSPACE, ) - synced = bool(response) and response.get("status_code") in (200, 201) + synced = bool(response) and response.get("status_code") in (200, 201, 202) + if not synced: + raise RuntimeError(f"Failed to sync final SWB layer to GeoServer: {layer_name}") if synced and layer_id: update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) return synced @@ -477,6 +513,16 @@ def run_swb_local( if is_gee_asset_exists(gee_asset_id): logger.info("GEE asset already exists, reusing: %s", gee_asset_id) + if output_path.exists(): + local_asset_path = str(output_path) + else: + _, local_asset_path = _create_local_swb_output( + swb_path=swb_path, + roi_geometry=roi_geometry, + output_path=output_path, + layer_name=layer_name, + ) + layer_id = None if sync_layer_metadata: layer_id = save_layer_info_to_db( @@ -486,16 +532,24 @@ def run_swb_local( layer_name=layer_name, asset_id=gee_asset_id, dataset_name=DATASET_NAME, - misc={"is_generated_locally": True, "source_stage": "swb2_local"}, + misc={ + "is_generated_locally": True, + "local_vector_path": local_asset_path, + "source_stage": "swb2_local", + }, algorithm=LOCAL_ALGORITHM, algorithm_version=LOCAL_ALGORITHM_VERSION, ) make_asset_public(gee_asset_id) - if push_to_geoserver and output_path.exists(): + if push_to_geoserver: layer_at_geoserver = _push_local_swb_to_geoserver( - output_path=output_path, layer_name=layer_name + output_path=local_asset_path, layer_name=layer_name ) + if not layer_at_geoserver: + raise RuntimeError( + f"Failed to sync SWB2 layer to GeoServer: {layer_name}" + ) if layer_at_geoserver and layer_id: update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) logger.info("Sync to GeoServer flag updated for existing local SWB layer") @@ -514,26 +568,12 @@ def run_swb_local( sync_layer_metadata=sync_layer_metadata, ) - clipped_gdf = _clip_gdf(_resolve_source_path(swb_path), roi_geometry) - if clipped_gdf.empty: - raise ValueError("No surface water body features intersect the provided ROI.") - clipped_gdf, converted_area_columns = _convert_area_columns_to_hectares(clipped_gdf) - logger.info( - "Clipped SWB features: count=%s columns=%s", - len(clipped_gdf), - list(clipped_gdf.columns), - ) - logger.info( - "Converted SWB area columns from square meters to hectares: %s", - converted_area_columns, - ) - - local_asset_path = write_vector_output( - gdf=clipped_gdf, + clipped_gdf, local_asset_path = _create_local_swb_output( + swb_path=swb_path, + roi_geometry=roi_geometry, output_path=output_path, layer_name=layer_name, ) - logger.info("Saved local SWB vector to disk: %s", local_asset_path) logger.info( "Initialized Earth Engine for local SWB export: gee_account_id=%s", @@ -620,6 +660,8 @@ def run_swb_local( layer_at_geoserver = _push_local_swb_to_geoserver( output_path=local_asset_path, layer_name=layer_name ) + if not layer_at_geoserver: + raise RuntimeError(f"Failed to sync SWB2 layer to GeoServer: {layer_name}") if layer_at_geoserver and layer_id: update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) logger.info("Sync to GeoServer flag updated for local SWB layer: %s", layer_name) diff --git a/computing/tests.py b/computing/tests.py index b577d410..1cb3eca5 100644 --- a/computing/tests.py +++ b/computing/tests.py @@ -1,7 +1,9 @@ from io import StringIO from inspect import signature -from unittest.mock import call, patch +from pathlib import Path +from unittest.mock import MagicMock, call, patch +import pandas as pd from django.core.management import call_command from django.core.management.base import CommandError from django.test import SimpleTestCase, TestCase, override_settings @@ -17,8 +19,10 @@ from computing.models import Dataset, Layer from computing.surface_water_bodies.swb_local import ( _continue_swb_in_gee, + _convert_area_columns_to_hectares, _final_layer_name, _layer_name, + run_swb_local, ) from computing.surface_water_bodies.swb3 import ( waterbody_catchment_streamorder_properties, @@ -34,6 +38,98 @@ class LocalSwbContinuationTests(SimpleTestCase): + @patch("computing.surface_water_bodies.swb_local._complete_swb_pipeline") + @patch("computing.surface_water_bodies.swb_local._push_local_swb_to_geoserver") + @patch("computing.surface_water_bodies.swb_local.make_asset_public") + @patch("computing.surface_water_bodies.swb_local.is_gee_asset_exists") + @patch("computing.surface_water_bodies.swb_local._create_local_swb_output") + @patch("computing.surface_water_bodies.swb_local.build_output_vector_path") + @patch("computing.surface_water_bodies.swb_local._resolve_gee_asset_id") + @patch("computing.surface_water_bodies.swb_local.gdf_to_ee_fc") + @patch("computing.surface_water_bodies.swb_local.ee_initialize") + @patch("computing.surface_water_bodies.swb_local._union_geometry") + @patch("computing.surface_water_bodies.swb_local._resolve_roi_gdf") + def test_existing_swb2_asset_recreates_and_syncs_missing_local_output( + self, + resolve_roi, + union_geometry, + ee_initialize, + gdf_to_ee_fc, + resolve_asset, + build_output_path, + create_local_output, + asset_exists, + make_public, + push_swb2, + complete_pipeline, + ): + roi_gdf = MagicMock() + roi_gdf.__len__.return_value = 1 + resolve_roi.return_value = roi_gdf + union_geometry.return_value.is_empty = False + gdf_to_ee_fc.return_value = MagicMock() + resolve_asset.return_value = ( + "swb2_bid_bid_local", + "projects/example/swb2_bid_bid_local", + ["maharashtra", "bid", "bid"], + ) + output_path = Path("/tmp/nonexistent-swb2-output.gpkg") + build_output_path.return_value = output_path + create_local_output.return_value = (MagicMock(), str(output_path)) + asset_exists.return_value = True + events = [] + push_swb2.side_effect = lambda **kwargs: events.append("swb2") or True + complete_pipeline.side_effect = ( + lambda **kwargs: events.append("complete") or True + ) + + result = run_swb_local( + state="Maharashtra", + district="Bid", + block="Bid", + sync_layer_metadata=False, + ) + + create_local_output.assert_called_once() + push_swb2.assert_called_once_with( + output_path=str(output_path), + layer_name="surface_waterbodies_bid_bid", + ) + complete_pipeline.assert_called_once() + self.assertEqual(events, ["swb2", "complete"]) + self.assertTrue(result) + + def test_reconstructs_area_ored_before_converting_annual_areas(self): + source = pd.DataFrame( + { + "area_2020": [500, 0], + "k_2020": [83.3333, 0], + "area_2021": [600, 240], + "k_2021": [100, 20], + "total_area_m2": [550, 1200], + } + ) + + converted, _ = _convert_area_columns_to_hectares(source) + + self.assertAlmostEqual(converted.loc[0, "area_ored"], 0.06, places=6) + self.assertAlmostEqual(converted.loc[1, "area_ored"], 0.12, places=6) + self.assertEqual(converted["area_2020"].tolist(), [0.05, 0]) + self.assertEqual(converted["total_area"].tolist(), [0.055, 0.12]) + + def test_preserves_existing_area_ored(self): + source = pd.DataFrame( + { + "area_ored": [0.25], + "area_2025": [500], + "k_2025": [100], + } + ) + + converted, _ = _convert_area_columns_to_hectares(source) + + self.assertEqual(converted.loc[0, "area_ored"], 0.25) + def test_swb_enrichment_defaults_to_pan_india_assets(self): raster_parameters = signature( generate_swb_layer_with_max_so_catchment From a6f3b166f0d436d46c2723177b63f99c4cbc2d4a Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Wed, 5 Aug 2026 14:34:07 +0530 Subject: [PATCH 101/120] local layer generation --- computing/bulk_layer_generation.py | 132 ++++++++++++++++++ .../commands/bulk_generate_layers.py | 3 + computing/misc/nrega_local_compute.py | 9 +- computing/tests.py | 52 ++++++- 4 files changed, 189 insertions(+), 7 deletions(-) diff --git a/computing/bulk_layer_generation.py b/computing/bulk_layer_generation.py index ed2069c7..75770fc1 100644 --- a/computing/bulk_layer_generation.py +++ b/computing/bulk_layer_generation.py @@ -28,6 +28,7 @@ def asdict(self) -> dict[str, str]: class PipelineSpec: runner_path: str payload_builder: Callable[[Mapping[str, str], bool], dict[str, Any]] + dataset_names: tuple[str, ...] def run(self, location: Mapping[str, str], overwrite: bool) -> Any: runner = import_string(self.runner_path) @@ -52,17 +53,133 @@ def _standard_payload( "antyodaya": PipelineSpec( "computing.misc.antyodaya.run_antyodaya_request", _standard_payload, + ("Antyodaya 2020",), ), "facilities_proximity": PipelineSpec( "computing.misc.facilities.run_facilities_request", _standard_payload, + ("Facilities Points", "Facilities Proximity"), ), "livestocks": PipelineSpec( "computing.misc.livestocks.run_livestocks_request", _standard_payload, + ("Livestock Census 2019",), ), } +LOCAL_TASK_DATASETS = { + "computing.misc.nrega_local_compute.generate_nrega_data_local": ("NREGA Assets",), + "computing.lulc.lulc_v3_local.clip_lulc_v3": ("LULC_v3", "LULC_level_3"), + "computing.lulc.lulc_vector_local.vectorise_lulc": ("LULC",), + "computing.cropping_intensity.cropping_intesity_local.generate_cropping_intensity": ( + "Cropping Intensity", + ), + "computing.surface_water_bodies.swb_local.generate_swb_layer": ( + "Surface Water Bodies", + ), + "computing.change_detection.change_detection_local.get_change_detection": ( + "Change Detection Raster", + ), + "computing.change_detection.change_detection_vector_local.vectorise_change_detection": ( + "Change Detection Vector", + ), + "computing.misc.aquifer_vector_local.generate_aquifer_vector": ("Aquifer",), + "computing.terrain_descriptor.terrain_raster_fabdem_local.generate_terrain_raster_clip": ( + "Terrain Raster", + ), + "computing.terrain_descriptor.terrain_clusters_local.generate_terrain_clusters": ( + "Terrain Vector", + ), + "computing.terrain_descriptor.terrain_compute_all_local.generate_terrain_compute_all": ( + "Terrain Raster", + "Terrain Vector", + ), + "computing.lulc_X_terrain.lulc_on_plain_cluster_local.lulc_on_plain_cluster_local": ( + "Terrain LULC", + ), + "computing.lulc_X_terrain.lulc_on_slope_cluster_local.lulc_on_slope_cluster_local": ( + "Terrain LULC", + ), + "computing.misc.soge_vector_local_compute.generate_soge_vector_local": ("SOGE",), + "computing.misc.drainage_lines_local_compute.clip_drainage_lines": ("Drainage",), + "computing.misc.naturaldepression_local_compute.generate_natural_depression_data_local": ( + "Natural Depression", + ), + "computing.misc.distancetonearestdrainage_local_compute.generate_distance_to_nearest_drainage_line_local": ( + "Distance to Drainage Line", + ), + "computing.misc.catchment_area_local_compute.generate_catchment_area_singleflow_local": ( + "Catchment Area", + ), + "computing.misc.slope_percentage_local_compute.generate_slope_percentage_data_local": ( + "Slope Percentage", + ), + "computing.misc.lcw_conflict_local_compute.generate_lcw_conflict_data_local": ( + "LCW Conflict", + ), + "computing.misc.agroecological_space_local_compute.generate_agroecological_data_local": ( + "Agroecological", + ), + "computing.misc.factory_csr_local_compute.generate_factory_csr_data_local": ( + "Factory CSR", + ), + "computing.misc.green_credit_local_compute.generate_green_credit_data_local": ( + "Green Credit", + ), + "computing.misc.mining_data_local_compute.generate_mining_data_local": ("Mining",), + "computing.misc.restoration_opportunity_local_compute.generate_restoration_opportunity_local": ( + "Restoration Raster", + "Restoration Vector", + ), + "computing.mws.mws_connectivity_local_compute.mws_connectivity_vector": ( + "Mws Connectivity", + ), + "computing.misc.facilities_proximity_local_compute.generate_facilities_proximity_local": ( + "Facilities Proximity", + ), + "computing.misc.livestocks_local_compute.generate_livestocks_data_local": ( + "Livestock Census 2019", + ), + "computing.misc.antyodaya_local_compute.generate_antyodaya_data_local": ( + "Antyodaya 2020", + ), + "computing.misc.drainage_density_local_compute.drainage_density": ( + "Drainage Density Vector", + ), + "computing.misc.river_local_compute.river_vector": ("River Vector",), + "computing.misc.canal_local_compute.canal_vector": ("Canal Vector",), + "computing.misc.digital_elevation_model_local.generate_febdem_raster_vector_clip": ( + "DEM Raster", + "DEM Vector", + ), + "computing.mws.mws_centroid_local_compute.generate_mws_centroid_data_local": ( + "Mws Centroid", + ), + "computing.tree_health.local.canopy_height_local.tree_health_ch_raster_local": ( + "Canopy Height Raster", + ), + "computing.tree_health.local.canopy_height_vector_local.tree_health_ch_vector_local": ( + "Canopy Height Vector", + ), + "computing.tree_health.local.ccd_local.tree_health_ccd_raster_local": ( + "Ccd Raster", + ), + "computing.tree_health.local.ccd_vector_local.tree_health_ccd_vector_local": ( + "Ccd Vector", + ), + "computing.tree_health.local.overall_change_local.tree_health_overall_change_raster_local": ( + "Tree Overall Change Raster", + ), + "computing.tree_health.local.overall_change_vector_local.tree_health_overall_change_vector_local": ( + "Tree Overall Change Vector", + ), + "computing.soil_health.soil_health.soil_health_local": ( + "Soil Health Raster", + "Soil Health Vector", + ), + "computing.soil_type.soil_type_local.generate_soil_type_local": ("Soil Type",), +} + def _task_registry(compute: str): from computing.layer_dependency.layer_generation_in_order import TASK_REGISTRIES @@ -99,6 +216,19 @@ def get_pipeline(name: str, compute: str = "local"): ) from exc +def get_regeneration_dataset_names(name: str) -> tuple[str, ...]: + pipeline = get_pipeline(name, compute="local") + if isinstance(pipeline, PipelineSpec): + return pipeline.dataset_names + + try: + return LOCAL_TASK_DATASETS[pipeline.name] + except KeyError as exc: + raise ValueError( + f"Local regeneration datasets are not configured for pipeline '{name}'." + ) from exc + + def _legacy_runner_kwargs( runner, location: Mapping[str, str], @@ -246,6 +376,7 @@ def get_active_locations( def get_locally_generated_locations( *, + dataset_names: tuple[str, ...], state: str | None = None, district: str | None = None, block: str | None = None, @@ -256,6 +387,7 @@ def get_locally_generated_locations( raise ValueError("Use either block or blocks, not both.") queryset = Layer.objects.filter( + dataset__name__in=dataset_names, misc__is_generated_locally=True, ) if state: diff --git a/computing/management/commands/bulk_generate_layers.py b/computing/management/commands/bulk_generate_layers.py index b733b697..b711c57d 100644 --- a/computing/management/commands/bulk_generate_layers.py +++ b/computing/management/commands/bulk_generate_layers.py @@ -4,6 +4,7 @@ get_active_locations, get_active_locations_from_api, get_locally_generated_locations, + get_regeneration_dataset_names, pipeline_names, validate_pipeline, ) @@ -121,6 +122,8 @@ def handle(self, *args, **options): else: location_loader = get_active_locations try: + if options["regenerate_local"]: + filters["dataset_names"] = get_regeneration_dataset_names(pipeline) locations = location_loader( **filters, limit=options["limit"], diff --git a/computing/misc/nrega_local_compute.py b/computing/misc/nrega_local_compute.py index ede5398b..d587dcff 100644 --- a/computing/misc/nrega_local_compute.py +++ b/computing/misc/nrega_local_compute.py @@ -49,14 +49,15 @@ def _compute_nrega_for_watersheds(watersheds_gdf, nrega_gdf): cleaned_columns.append(cleaned) nrega_in_roi.columns = cleaned_columns - # Replace NaN nrega_in_roi = nrega_in_roi.replace({np.nan: None}) - # Convert datetime columns for col in nrega_in_roi.columns: if col != "geometry": - if pd.api.types.is_datetime64_any_dtype(nrega_in_roi[col]): - nrega_in_roi[col] = nrega_in_roi[col].astype(str).replace("NaT", None) + nrega_in_roi[col] = nrega_in_roi[col].map( + lambda value: value.isoformat() + if isinstance(value, pd.Timestamp) + else value + ) return nrega_in_roi diff --git a/computing/tests.py b/computing/tests.py index 1cb3eca5..cd08ff26 100644 --- a/computing/tests.py +++ b/computing/tests.py @@ -1,21 +1,28 @@ from io import StringIO from inspect import signature from pathlib import Path +from tempfile import TemporaryDirectory from unittest.mock import MagicMock, call, patch +import geopandas as gpd import pandas as pd from django.core.management import call_command from django.core.management.base import CommandError from django.test import SimpleTestCase, TestCase, override_settings +from shapely.geometry import Point from computing.bulk_layer_generation import ( Location, get_active_locations, get_active_locations_from_api, get_locally_generated_locations, + get_regeneration_dataset_names, + pipeline_names, run_pipeline, ) from computing.layer_dependency.layer_generation_in_order import get_args, load_map_config +from computing.local_compute_helper import write_vector_output +from computing.misc.nrega_local_compute import _compute_nrega_for_watersheds from computing.models import Dataset, Layer from computing.surface_water_bodies.swb_local import ( _continue_swb_in_gee, @@ -37,6 +44,29 @@ ) +class LocalNregaTests(SimpleTestCase): + def test_timestamp_attributes_can_be_written_to_geopackage(self): + watersheds = gpd.GeoDataFrame( + geometry=[Point(0, 0)], + crs="EPSG:4326", + ) + nrega = gpd.GeoDataFrame( + { + "work_start": pd.to_datetime(["2026-08-05"]), + "geometry": [Point(0, 0)], + }, + crs="EPSG:4326", + ) + + result = _compute_nrega_for_watersheds(watersheds, nrega) + + self.assertEqual(result.loc[0, "work_start"], "2026-08-05T00:00:00") + with TemporaryDirectory() as output_dir: + output_path = Path(output_dir) / "nrega.gpkg" + write_vector_output(result, output_path, "nrega") + self.assertTrue(output_path.exists()) + + class LocalSwbContinuationTests(SimpleTestCase): @patch("computing.surface_water_bodies.swb_local._complete_swb_pipeline") @patch("computing.surface_water_bodies.swb_local._push_local_swb_to_geoserver") @@ -248,6 +278,13 @@ def test_runs_swb3_and_swb4_without_local_suffix( class BulkPipelineRegistryTests(SimpleTestCase): + def test_all_local_pipelines_define_regeneration_datasets(self): + for pipeline in pipeline_names("local"): + self.assertTrue( + get_regeneration_dataset_names(pipeline), + pipeline, + ) + @patch("computing.bulk_layer_generation.import_string") def test_registered_pipeline_builds_standard_payload(self, import_string): runner = import_string.return_value @@ -496,11 +533,18 @@ def test_command_regenerates_locally_generated_locations( ) get_locations.assert_called_once_with( + dataset_names=("Livestock Census 2019",), district="Dumka", limit=None, ) apply_async.assert_called_once() + def test_change_detection_regeneration_uses_vector_dataset(self): + self.assertEqual( + get_regeneration_dataset_names("change_detection_vector"), + ("Change Detection Vector",), + ) + @override_settings(PROD_BACKEND_URL="https://geoserver.core-stack.org/") class ActiveLocationsApiTests(SimpleTestCase): @@ -639,6 +683,7 @@ def test_active_locations_accept_multiple_blocks(self): def test_locally_generated_locations_are_distinct_and_filterable(self): dataset = Dataset.objects.create(name="Local Dataset") + other_dataset = Dataset.objects.create(name="Other Local Dataset") jarmundi = TehsilSOI.objects.get(tehsil_name="Jarmundi") masalia = TehsilSOI.objects.get(tehsil_name="Masalia") for layer_name in ("local_one", "local_two"): @@ -651,15 +696,16 @@ def test_locally_generated_locations_are_distinct_and_filterable(self): misc={"is_generated_locally": True}, ) Layer.objects.create( - dataset=dataset, - layer_name="gee_layer", + dataset=other_dataset, + layer_name="other_local_layer", state=masalia.district.state, district=masalia.district, block=masalia, - misc={"is_generated_locally": False}, + misc={"is_generated_locally": True}, ) locations = get_locally_generated_locations( + dataset_names=("Local Dataset",), district="dumka", blocks=["JARMUNDI", "Masalia"], ) From 51e33b60d5f207e9dd01d0627274694f52a8ab7e Mon Sep 17 00:00:00 2001 From: aman verma Date: Wed, 5 Aug 2026 13:06:48 +0000 Subject: [PATCH 102/120] merge SPEI with correct band names --- computing/spei/generate_spei/merge_spei.py | 67 ++++++++++++++++++++++ computing/spei/spei.py | 18 ++++-- 2 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 computing/spei/generate_spei/merge_spei.py diff --git a/computing/spei/generate_spei/merge_spei.py b/computing/spei/generate_spei/merge_spei.py new file mode 100644 index 00000000..e63bfda1 --- /dev/null +++ b/computing/spei/generate_spei/merge_spei.py @@ -0,0 +1,67 @@ +import glob +import rasterio +from rasterio.merge import merge + + +def merge_SPEI_rasters(spei_type): + # Input rasters + input_dir = f"data/base_layers/spei/outputs/SPEI_{spei_type}" + input_files = sorted(glob.glob(f"{input_dir}/SPEI{spei_type}_*.tif")) + + if not input_files: + raise RuntimeError("No input files found.") + + srcs = [rasterio.open(f) for f in input_files] + + # Validate all rasters have same number of bands + band_count = srcs[0].count + for src in srcs: + if src.count != band_count: + raise RuntimeError( + f"{src.name} has {src.count} bands, expected {band_count}" + ) + + # Output metadata + meta = srcs[0].meta.copy() + + # Merge first band to determine output size/transform + first_band, out_transform = merge(srcs, indexes=1, nodata=-9999, method="first") + + meta.update( + driver="GTiff", + height=first_band.shape[1], + width=first_band.shape[2], + transform=out_transform, + count=band_count, + compress="LZW", + tiled=True, + BIGTIFF="YES", + nodata=-9999, + ) + + output_file = f"{input_dir}/SPEI{spei_type}.tif" + + with rasterio.open(output_file, "w", **meta) as dst: + + # Copy band descriptions + dst.descriptions = srcs[0].descriptions + + for band in range(1, band_count + 1): + print(f"Merging band {band}/{band_count}") + + mosaic, _ = merge(srcs, indexes=[band], nodata=-9999, method="first") + + dst.write(mosaic[0], band) + + # Close inputs + for src in srcs: + src.close() + + print(f"Finished: {output_file}") + + +""" + After uploading the merged SPEI files on GEE, the band names gets renamed to b1, b2,..,etc. + To rename them back to relevant names, use the below GEE script: + https://code.earthengine.google.co.in/370dfca36f228fbd09892708949cf381 +""" diff --git a/computing/spei/spei.py b/computing/spei/spei.py index 0df7a220..e1ab1bf6 100644 --- a/computing/spei/spei.py +++ b/computing/spei/spei.py @@ -7,6 +7,7 @@ ) from computing.spei.generate_spei.download_base_datasets import download_data_locally from computing.spei.generate_spei.generate_ppet_multiband import ppet_multiband +from computing.spei.generate_spei.merge_spei import merge_SPEI_rasters from computing.spei.generate_spei.spei_runner import run_spei from computing.spei.high_wind_sensitivity.export_max_wind_index import max_wind_index from computing.spei.high_wind_sensitivity.highwind_resistance_resilience import ( @@ -24,14 +25,23 @@ @app.task(bind=True) def generate_spei_pipeline( self, - aez, - start_year, - end_year, + aez=None, + start_year=2004, + end_year=2024, gee_account_id=None, overwrite=False, ): ee_initialize(gee_account_id) + if aez is None: + for aez in range(20): + compute_spei(aez, end_year, overwrite, start_year) + for spei_type in [1, 3, 12]: + merge_SPEI_rasters(spei_type) + else: + compute_spei(aez, end_year, overwrite, start_year) + +def compute_spei(aez, end_year, overwrite, start_year): download_data_locally( aez=aez, start_year=start_year, @@ -40,13 +50,11 @@ def generate_spei_pipeline( datasets=None, overwrite=overwrite, ) - ppet_multiband( aez=aez, start=start_year, end=end_year, ) - run_spei(aez, start_year, end_year) From 9eae3aacebdc8d4b62aaf39c6edf9eef2e4e6afb Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Wed, 5 Aug 2026 19:00:20 +0530 Subject: [PATCH 103/120] nrega name change --- computing/misc/nrega_local_compute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/computing/misc/nrega_local_compute.py b/computing/misc/nrega_local_compute.py index d587dcff..41547fff 100644 --- a/computing/misc/nrega_local_compute.py +++ b/computing/misc/nrega_local_compute.py @@ -76,7 +76,7 @@ def generate_nrega_data_local( sync_layer_metadata=True, ): if state and district and block: - layer_name = f"nrega_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" watersheds_gdf, watershed_source = load_precomputed_watersheds( state=state, district=district, From 23e25f5c73293e859bf0a736aeee04b9ba01fd22 Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Wed, 5 Aug 2026 19:00:20 +0530 Subject: [PATCH 104/120] nrega name change --- computing/misc/nrega_local_compute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/computing/misc/nrega_local_compute.py b/computing/misc/nrega_local_compute.py index d587dcff..41547fff 100644 --- a/computing/misc/nrega_local_compute.py +++ b/computing/misc/nrega_local_compute.py @@ -76,7 +76,7 @@ def generate_nrega_data_local( sync_layer_metadata=True, ): if state and district and block: - layer_name = f"nrega_{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" + layer_name = f"{valid_gee_text(district.lower())}_{valid_gee_text(block.lower())}" watersheds_gdf, watershed_source = load_precomputed_watersheds( state=state, district=district, From fdfd3fa7ac05472e0c2ee42389dcecd90e705f1d Mon Sep 17 00:00:00 2001 From: aman verma Date: Wed, 5 Aug 2026 18:31:28 +0000 Subject: [PATCH 105/120] soil vector with 900m2 pixel --- computing/soil_health/soil_health.py | 207 ++++++++++++++------ computing/soil_health/soil_health_helper.py | 38 ++++ 2 files changed, 180 insertions(+), 65 deletions(-) diff --git a/computing/soil_health/soil_health.py b/computing/soil_health/soil_health.py index f6df42d3..a16339ad 100644 --- a/computing/soil_health/soil_health.py +++ b/computing/soil_health/soil_health.py @@ -8,9 +8,11 @@ from rasterio.warp import Resampling, reproject from shapely.geometry import mapping -from computing.STAC_specs import generate_STAC_layerwise from computing.config_loader import LULC_BASE_DIR -from computing.soil_health.soil_health_helper import nutrient_stats_for_geometries +from computing.soil_health.soil_health_helper import ( + nutrient_stats_for_geometries, + lulc_area_stats_for_geometries, +) from computing.utils import save_layer_info_to_db, update_layer_sync_status from utilities.gee_utils import valid_gee_text from computing.local_compute_helper import ( @@ -83,14 +85,11 @@ def _resolve_latest_lulc_raster_paths(count=3, lulc_dir=LULC_BASE_DIR): return [str(path) for path in selected] -def _clip_and_mask_soil_health_raster( +def _clip_raster_to_roi( roi_gdf, - soil_raster_path, - output_path, - nutrient, - lulc_paths=None, + raster_path, ): - with rasterio.open(soil_raster_path) as soil_src: + with rasterio.open(raster_path) as soil_src: roi_gdf = validate_geometry(roi_gdf) if roi_gdf.empty: raise ValueError( @@ -143,37 +142,79 @@ def _clip_and_mask_soil_health_raster( } ) - if lulc_paths: - reprojected_arrays = [] - for lulc_path in lulc_paths: - lulc_array = np.zeros( - (clipped_meta["height"], clipped_meta["width"]), - dtype=np.float32, + return clipped_data, clipped_meta + + +def _prepare_mode_lulc( + reference_meta, + lulc_paths, +): + """ + Reprojects the latest LULC rasters to the reference grid and + returns the 3-year mode LULC. + """ + + reprojected_arrays = [] + + for lulc_path in lulc_paths: + + lulc_array = np.zeros( + ( + reference_meta["height"], + reference_meta["width"], + ), + dtype=np.float32, + ) + + with rasterio.open(lulc_path) as src: + + reproject( + source=rasterio.band(src, 1), + destination=lulc_array, + src_transform=src.transform, + src_crs=src.crs, + src_nodata=src.nodata, + dst_transform=reference_meta["transform"], + dst_crs=reference_meta["crs"], + dst_nodata=0, + resampling=Resampling.mode, ) - with rasterio.open(lulc_path) as lulc_src: - reproject( - source=rasterio.band(lulc_src, 1), - destination=lulc_array, - src_transform=lulc_src.transform, - src_crs=lulc_src.crs, - src_nodata=lulc_src.nodata, - dst_transform=clipped_meta["transform"], - dst_crs=clipped_meta["crs"], - dst_nodata=0, - resampling=Resampling.mode, - ) - reprojected_arrays.append(lulc_array) - - lulc_mode_array = compute_mode_lulc_array(reprojected_arrays) - allowed_mask_classes = _get_lulc_mask_classes(nutrient) - valid_pixels = np.isin(lulc_mode_array, list(allowed_mask_classes)) - valid_soil_pixels = clipped_data != nodata - output_array = np.where(valid_pixels & valid_soil_pixels, clipped_data, nodata) - else: - output_array = clipped_data + + reprojected_arrays.append(lulc_array) + + return compute_mode_lulc_array(reprojected_arrays) + + +def _apply_lulc_mask_and_write( + clipped_data, + clipped_meta, + output_path, + lulc_mode_array=None, + allowed_mask_classes=None, +): + nodata = clipped_meta["nodata"] + + if clipped_data.shape != lulc_mode_array.shape: + raise ValueError( + f"LULC shape {lulc_mode_array.shape} " + f"does not match soil raster {clipped_data.shape}" + ) + + valid_pixels = np.isin( + lulc_mode_array, + list(allowed_mask_classes), + ) + + valid_soil_pixels = clipped_data != nodata + + output_array = np.where( + valid_pixels & valid_soil_pixels, + clipped_data, + nodata, + ) with rasterio.open(output_path, "w", **clipped_meta) as dst: - dst.write(output_array.astype(clipped_meta["dtype"], copy=False), 1) + dst.write(output_array.astype(clipped_meta["dtype"]), 1) return str(output_path) @@ -198,7 +239,27 @@ def clip_soil_health_raster( precomputed_roi_dir=precomputed_roi_dir, ) layer_name = f"{asset_suffix}_soil_health_raster" + lulc_paths = _resolve_latest_lulc_raster_paths() + + if not lulc_paths: + raise ValueError("No LULC rasters found to prepare 3-year mode.") + + reference_data, reference_meta = _clip_raster_to_roi( + roi_gdf=roi_gdf, + raster_path=str( + PROJECT_ROOT / "data/base_layers/soil_health/soil_health_N.tif" + ), + ) + + lulc_mode_array = _prepare_mode_lulc( + reference_meta=reference_meta, + lulc_paths=lulc_paths, + ) + + if lulc_mode_array.shape != reference_data.shape: + raise ValueError("Prepared LULC mode does not match reference raster grid.") + geoserver_statuses = [] for nutrient in NUTRIENTS: SOIL_MAP_PATH = str( @@ -212,12 +273,22 @@ def clip_soil_health_raster( block=block, ) - asset_id = _clip_and_mask_soil_health_raster( - roi_gdf=roi_gdf, - soil_raster_path=SOIL_MAP_PATH, + if nutrient == "N": + clipped_data = reference_data + clipped_meta = reference_meta + else: + clipped_data, clipped_meta = _clip_raster_to_roi( + roi_gdf, + SOIL_MAP_PATH, + ) + + allowed_mask_classes = _get_lulc_mask_classes(nutrient) + asset_id = _apply_lulc_mask_and_write( + clipped_data=clipped_data, + clipped_meta=clipped_meta, output_path=output_raster_path, - nutrient=nutrient, - lulc_paths=lulc_paths, + lulc_mode_array=lulc_mode_array, + allowed_mask_classes=allowed_mask_classes, ) if push_to_geoserver: @@ -260,7 +331,11 @@ def clip_soil_health_raster( # except Exception as e: # print(f"Error generating STAC: {e}") - return all(geoserver_statuses) if push_to_geoserver else True + return ( + all(geoserver_statuses) if push_to_geoserver else True, + lulc_mode_array, + reference_meta, + ) def get_roi(asset_suffix, block, district, roi, state, precomputed_roi_dir): @@ -294,6 +369,8 @@ def vectorize_soil_health( roi=None, push_to_geoserver=True, sync_layer_metadata=True, + lulc_mode=None, + lulc_meta=None, ): asset_suffix, roi_gdf = get_roi( @@ -331,6 +408,16 @@ def vectorize_soil_health( for column in nutrient_columns: result_gdf[column] = nutrient_gdf[column] + lulc_area_gdf = lulc_area_stats_for_geometries( + roi_gdf=result_gdf, + lulc_mode=lulc_mode, + lulc_meta=lulc_meta, + ) + + result_gdf["crop_cover_area"] = lulc_area_gdf["crop_cover_area"] + + result_gdf["tree_shrub_area"] = lulc_area_gdf["tree_shrub_area"] + output_path = build_output_vector_path( layer_name=output_layer_name, state=state, @@ -378,20 +465,6 @@ def vectorize_soil_health( update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) print("Sync to GeoServer flag updated for Soil health vector") - # try: - # layer_STAC_generated = generate_STAC_layerwise.generate_vector_stac( - # state=state, - # district=district, - # block=block, - # layer_name=output_layer_name, - # ) - # update_layer_sync_status( - # layer_id=layer_id, is_stac_specs_generated=layer_STAC_generated - # ) - # print("STAC metadata updated for Soil health vector") - # except Exception as e: - # print(f"Error generating STAC: {e}") - return geoserver_status if push_to_geoserver else True @@ -407,15 +480,17 @@ def soil_health_local( push_to_geoserver=True, sync_layer_metadata=True, ): - soil_health_raster_on_geoserver = clip_soil_health_raster( - state, - district, - block, - asset_suffix, - roi, - precomputed_roi_dir, - push_to_geoserver, - sync_layer_metadata, + soil_health_raster_on_geoserver, cached_lulc_mode, cached_lulc_meta = ( + clip_soil_health_raster( + state, + district, + block, + asset_suffix, + roi, + precomputed_roi_dir, + push_to_geoserver, + sync_layer_metadata, + ) ) soil_health_vector_on_geoserver = vectorize_soil_health( @@ -426,6 +501,8 @@ def soil_health_local( roi, push_to_geoserver, sync_layer_metadata, + lulc_mode=cached_lulc_mode, + lulc_meta=cached_lulc_meta, ) return ( True diff --git a/computing/soil_health/soil_health_helper.py b/computing/soil_health/soil_health_helper.py index c360bf4e..164ee87d 100644 --- a/computing/soil_health/soil_health_helper.py +++ b/computing/soil_health/soil_health_helper.py @@ -75,3 +75,41 @@ def nutrient_stats_for_geometries(roi_gdf, raster_path, percentiles, nutrient): for column in rows[0] if rows else []: result[column] = [row[column] for row in rows] return result + + +def lulc_area_stats_for_geometries( + roi_gdf, + lulc_mode, + lulc_meta, +): + rows = [] + pixel_area = 0.09 + transform = lulc_meta["transform"] + + for geom in roi_gdf.geometry: + + mask_arr = rasterio.features.geometry_mask( + [mapping(geom)], + out_shape=lulc_mode.shape, + transform=transform, + invert=True, + ) + + crop_pixels = np.count_nonzero(mask_arr & np.isin(lulc_mode, [8, 9, 10, 11])) + + tree_pixels = np.count_nonzero(mask_arr & np.isin(lulc_mode, [6, 12])) + + rows.append( + { + "crop_cover_area": crop_pixels * pixel_area, + "tree_shrub_area": tree_pixels * pixel_area, + } + ) + + result = roi_gdf.copy() + + result["crop_cover_area"] = [r["crop_cover_area"] for r in rows] + + result["tree_shrub_area"] = [r["tree_shrub_area"] for r in rows] + + return result From 20b6aab4b0a999558d40eb26eb016a295aae54e9 Mon Sep 17 00:00:00 2001 From: aman verma Date: Wed, 5 Aug 2026 18:33:00 +0000 Subject: [PATCH 106/120] soil vector with dynamic pixel area --- computing/soil_health/soil_health.py | 14 ---- computing/soil_health/soil_health_helper.py | 81 +++++++++++++++++++-- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/computing/soil_health/soil_health.py b/computing/soil_health/soil_health.py index a16339ad..4c92858c 100644 --- a/computing/soil_health/soil_health.py +++ b/computing/soil_health/soil_health.py @@ -317,20 +317,6 @@ def clip_soil_health_raster( update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) print("Sync to GeoServer flag updated for Soil health raster") - # try: - # layer_STAC_generated = generate_STAC_layerwise.generate_raster_stac( - # state=state, - # district=district, - # block=block, - # layer_name=layer_name, - # ) - # update_layer_sync_status( - # layer_id=layer_id, is_stac_specs_generated=layer_STAC_generated - # ) - # print("STAC metadata updated for Soil health raster") - # except Exception as e: - # print(f"Error generating STAC: {e}") - return ( all(geoserver_statuses) if push_to_geoserver else True, lulc_mode_array, diff --git a/computing/soil_health/soil_health_helper.py b/computing/soil_health/soil_health_helper.py index 164ee87d..664447ef 100644 --- a/computing/soil_health/soil_health_helper.py +++ b/computing/soil_health/soil_health_helper.py @@ -3,8 +3,54 @@ from rasterio.mask import mask from shapely.geometry import mapping +from affine import Affine +from pyproj import Geod +from shapely.geometry import Polygon + from computing.local_compute_helper import ensure_file_exists +from pyproj import Geod +import numpy as np + + +def _compute_row_area(transform, height): + """ + Compute the geodesic area (m²) of one pixel for every raster row. + + Since the raster is north-up in EPSG:4326, every pixel in the same + row has identical area. Only latitude changes between rows. + + Returns + ------- + np.ndarray + Shape = (height,) + Area (m²) of one pixel for each row. + """ + + geod = Geod(ellps="WGS84") + + row_area = np.zeros(height, dtype=np.float64) + + pixel_width = transform.a # degrees + pixel_height = abs(transform.e) # degrees + + west = transform.c + east = west + pixel_width + + for row in range(height): + + north = transform.f + row * transform.e + south = north + transform.e + + area, _ = geod.polygon_area_perimeter( + [west, east, east, west], + [north, north, south, south], + ) + + row_area[row] = abs(area) + + return row_area + def nutrient_stats_for_geometries(roi_gdf, raster_path, percentiles, nutrient): ensure_file_exists(raster_path, "Clipped soil health raster") @@ -83,7 +129,10 @@ def lulc_area_stats_for_geometries( lulc_meta, ): rows = [] - pixel_area = 0.09 + row_area = _compute_row_area( + lulc_meta["transform"], + lulc_mode.shape[0], + ) transform = lulc_meta["transform"] for geom in roi_gdf.geometry: @@ -95,14 +144,36 @@ def lulc_area_stats_for_geometries( invert=True, ) - crop_pixels = np.count_nonzero(mask_arr & np.isin(lulc_mode, [8, 9, 10, 11])) + # crop_pixels = np.count_nonzero(mask_arr & np.isin(lulc_mode, [8, 9, 10, 11])) + + # tree_pixels = np.count_nonzero(mask_arr & np.isin(lulc_mode, [6, 12])) + + crop_mask = mask_arr & np.isin(lulc_mode, [8, 9, 10, 11]) + + tree_mask = mask_arr & np.isin(lulc_mode, [6, 12]) + + crop_area = 0.0 + tree_area = 0.0 + + height = lulc_mode.shape[0] + + for row in range(height): + + crop_pixels = np.count_nonzero(crop_mask[row]) + + tree_pixels = np.count_nonzero(tree_mask[row]) + + crop_area += crop_pixels * row_area[row] + + tree_area += tree_pixels * row_area[row] - tree_pixels = np.count_nonzero(mask_arr & np.isin(lulc_mode, [6, 12])) + crop_area /= 10000.0 + tree_area /= 10000.0 rows.append( { - "crop_cover_area": crop_pixels * pixel_area, - "tree_shrub_area": tree_pixels * pixel_area, + "crop_cover_area": crop_area, + "tree_shrub_area": tree_area, } ) From 2e3482f023700d7ac0e4de2f8e78f98a6afb6b43 Mon Sep 17 00:00:00 2001 From: aman verma Date: Thu, 6 Aug 2026 08:11:44 +0000 Subject: [PATCH 107/120] lulc level 1 and 2 code removed --- computing/lulc/lulc_v3.py | 53 ++++++++++----------- computing/soil_health/soil_health_helper.py | 4 -- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/computing/lulc/lulc_v3.py b/computing/lulc/lulc_v3.py index 5be3bcd8..bc5c9292 100644 --- a/computing/lulc/lulc_v3.py +++ b/computing/lulc/lulc_v3.py @@ -235,39 +235,36 @@ def sync_lulc_to_geoserver( asset_suffix=None, ): print("Syncing lulc to geoserver") - lulc_workspaces = ["LULC_level_1", "LULC_level_2", "LULC_level_3"] + # lulc_workspaces = ["LULC_level_1", "LULC_level_2", "LULC_level_3"] layer_at_geoserver = False for i in range(0, len(final_output_filename_array_new)): - name_arr = final_output_filename_array_new[i].split( - "_20" - ) # TODO: better logic than this + name_arr = final_output_filename_array_new[i].split("_20") s_year = name_arr[1][:2] e_year = name_arr[2][:2] gcs_file_name = "LULC_" + s_year + "_" + e_year + "_" + name_arr[0] print("Syncing " + gcs_file_name + " to geoserver") - for workspace in lulc_workspaces: - suff = workspace.replace("LULC", "") - style = workspace.lower() + "_style" - if block_name: - layer_name = ( - "LULC_" - + s_year - + "_" - + e_year - + "_" - + valid_gee_text(district_name.lower()) - + "_" - + valid_gee_text(block_name.lower()) - + suff - ) - else: - layer_name = f"LULC_{s_year}_{e_year}_{asset_suffix}_{suff}" - - res = sync_raster_gcs_to_geoserver( - workspace, gcs_file_name, layer_name, style + # for workspace in lulc_workspaces: + workspace = "LULC_level_3" + suff = workspace.replace("LULC", "") + style = workspace.lower() + "_style" + if block_name: + layer_name = ( + "LULC_" + + s_year + + "_" + + e_year + + "_" + + valid_gee_text(district_name.lower()) + + "_" + + valid_gee_text(block_name.lower()) + + suff ) - if res and layer_ids: - update_layer_sync_status(layer_id=layer_ids[i], sync_to_geoserver=True) - print("geoserver flag is updated") - layer_at_geoserver = True + else: + layer_name = f"LULC_{s_year}_{e_year}_{asset_suffix}_{suff}" + + res = sync_raster_gcs_to_geoserver(workspace, gcs_file_name, layer_name, style) + if res and layer_ids: + update_layer_sync_status(layer_id=layer_ids[i], sync_to_geoserver=True) + print("geoserver flag is updated") + layer_at_geoserver = True return layer_at_geoserver diff --git a/computing/soil_health/soil_health_helper.py b/computing/soil_health/soil_health_helper.py index 664447ef..6cfbd321 100644 --- a/computing/soil_health/soil_health_helper.py +++ b/computing/soil_health/soil_health_helper.py @@ -1,11 +1,7 @@ -import numpy as np import rasterio from rasterio.mask import mask from shapely.geometry import mapping -from affine import Affine -from pyproj import Geod -from shapely.geometry import Polygon from computing.local_compute_helper import ensure_file_exists From 068f29fd769b68f52b1605edf16a3c17e5731235 Mon Sep 17 00:00:00 2001 From: aman verma Date: Thu, 6 Aug 2026 09:01:46 +0000 Subject: [PATCH 108/120] forest fringes final --- .../forest_fringe/forest_fringe_utils.py | 20 +++++++++---------- utilities/constants.py | 5 ++++- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/computing/forest_fringe/forest_fringe_utils.py b/computing/forest_fringe/forest_fringe_utils.py index 04454b0c..99d352ad 100644 --- a/computing/forest_fringe/forest_fringe_utils.py +++ b/computing/forest_fringe/forest_fringe_utils.py @@ -6,6 +6,11 @@ """ import ee +from utilities.constants import ( + TREE_OVERALL_CHANGE, + PAN_INDIA_LULC_V3_DATASET, + LTP_STP_CHANGE, +) # ---------------------------------------- # PARAMETERS / CONSTANTS @@ -17,13 +22,6 @@ SCALE = 30 MAXPIX = 1e12 -PAN_INDIA_LULC_PATH = "projects/corestack-datasets/assets/datasets/LULC_v3_river_basin" - -LTP_CHANGE_PATH = "projects/corestack-datasets/assets/datasets/tree_health/final_ltp_stp_change_2017_2021" - -OVERALL_CHANGE_PATH = ( - "projects/corestack-trees/assets/tree_characteristics/overall_change_2017_2023" -) # LULC years used by the forest fringe pipeline LULC_YEARS = [2017, 2018, 2019] @@ -41,7 +39,7 @@ def load_tree_mode(): """ lulc_imgs = ee.ImageCollection( [ - ee.Image(f"{PAN_INDIA_LULC_PATH}/pan_india_lulc_v3_{year}_{year + 1}") + ee.Image(f"{PAN_INDIA_LULC_V3_DATASET}{year}_{year + 1}") .select("predicted_label") .eq(TREE_CLASS) for year in LULC_YEARS @@ -52,12 +50,12 @@ def load_tree_mode(): def load_ltp_change(): """ - Load the LTP/STP change product (2017-2021). + Load the LTP/STP change product Returns: ee.Image – mean of the ltp_stp_change image collection. """ - return ee.ImageCollection(LTP_CHANGE_PATH).mean() + return ee.ImageCollection(LTP_STP_CHANGE).mean() def load_overall_change(): @@ -67,7 +65,7 @@ def load_overall_change(): Returns: ee.Image – mean of the overall_change image collection. """ - return ee.ImageCollection(OVERALL_CHANGE_PATH).mean() + return ee.ImageCollection(TREE_OVERALL_CHANGE).mean() def make_fringe(patch, fringe_width=FRINGE_WIDTH): diff --git a/utilities/constants.py b/utilities/constants.py index b67c0a76..fd15762e 100644 --- a/utilities/constants.py +++ b/utilities/constants.py @@ -370,7 +370,10 @@ CCD_RASTER = "projects/corestack-trees/assets/tree_characteristics/modal_ccd_" CH_RASTER = "projects/corestack-trees/assets/tree_characteristics/modal_ch_" TREE_OVERALL_CHANGE = ( - "projects/corestack-trees/assets/tree_characteristics/overall_change_2017_2022" + "projects/corestack-trees/assets/tree_characteristics/overall_change_2017_2023" +) +LTP_STP_CHANGE = ( + "projects/corestack-trees/assets/tree_characteristics/ltp_stp_change_2017_2024" ) CANAL_PAN_INDIA_ASSET = "projects/ext-datasets/assets/datasets/Canal_pan_india" From d9c840bf3665bf4ea268a805a29f1a9468e856a1 Mon Sep 17 00:00:00 2001 From: aman verma Date: Fri, 7 Aug 2026 08:59:43 +0000 Subject: [PATCH 109/120] forest fringes and grassland updates --- computing/forest_fringe/forest_fringe.py | 76 ++++++++-------- .../tree_in_grassland/tree_in_grassland.py | 86 +++++++++---------- .../tree_in_grassland_utils.py | 6 +- 3 files changed, 84 insertions(+), 84 deletions(-) diff --git a/computing/forest_fringe/forest_fringe.py b/computing/forest_fringe/forest_fringe.py index 08537681..6fda135e 100644 --- a/computing/forest_fringe/forest_fringe.py +++ b/computing/forest_fringe/forest_fringe.py @@ -47,12 +47,15 @@ @app.task(bind=True) def generate_forest_fringe_degradation( - self, - state, - district, - block, - gee_account_id=None, - app_type="MWS", + self, + state, + district, + block, + roi=None, + asset_suffix=None, + asset_folder_list=None, + gee_account_id=None, + app_type="MWS", ): """ Generate forest-fringe metrics as a vector layer. @@ -78,43 +81,40 @@ def generate_forest_fringe_degradation( """ # ------------------------------------------------------------------ - # STEP 1: Initialize GEE and set up paths + # Initialize GEE and set up paths # ------------------------------------------------------------------ ee_initialize(gee_account_id) - asset_suffix = ( + if state and district and block: + asset_suffix = ( valid_gee_text(district.lower()) + "_" + valid_gee_text(block.lower()) - ) - asset_folder_list = [state, district, block] - - description = f"forest_fringe_{asset_suffix}" - layer_name = f"{asset_suffix}_forest_fringe" - - asset_id = ( - get_gee_dir_path( - asset_folder_list, - asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"], - ) - + description - ) - - print(f"Forest Fringe pipeline started: {asset_id=}") + ) + asset_folder_list = [state, district, block] - # ------------------------------------------------------------------ - # STEP 2: Set up ROI (MWS boundaries from GEE) - # ------------------------------------------------------------------ - roi_path = ( + roi = ee.FeatureCollection( get_gee_dir_path( asset_folder_list, asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"], ) + f"filtered_mws_{valid_gee_text(district.lower())}" + f"_{valid_gee_text(block.lower())}_uid" + ) + + description = f"forest_fringe_{asset_suffix}" + layer_name = f"{asset_suffix}_forest_fringe" + + asset_id = ( + get_gee_dir_path( + asset_folder_list, + asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"], + ) + + description ) - mws_fc = ee.FeatureCollection(roi_path) + + print(f"Forest Fringe pipeline started: {asset_id=}") # ------------------------------------------------------------------ - # STEP 3: Compute forest-fringe metrics + # Compute forest-fringe metrics # ------------------------------------------------------------------ if not is_gee_asset_exists(asset_id): @@ -253,7 +253,7 @@ def compute_metrics_per_mws(f): ) # ---- map compute over all MWS features ---- - results_fc = mws_fc.map(compute_metrics_per_mws) + results_fc = roi.map(compute_metrics_per_mws) fc = results_fc.select( [ @@ -271,7 +271,7 @@ def compute_metrics_per_mws(f): ) # -------------------------------------------------------------- - # STEP 4: Export to GEE + # Export to GEE # -------------------------------------------------------------- task_id = export_vector_asset_to_gee(fc, description, asset_id) @@ -280,7 +280,7 @@ def compute_metrics_per_mws(f): print("Forest Fringe layer exported to GEE.") # ------------------------------------------------------------------ - # STEP 5: Publish to GeoServer and save metadata to DB + # Publish to GeoServer and save metadata to DB # ------------------------------------------------------------------ layer_at_geoserver = _save_to_db_and_sync_to_geoserver( layer_name=layer_name, @@ -299,12 +299,12 @@ def compute_metrics_per_mws(f): def _save_to_db_and_sync_to_geoserver( - layer_name=None, - asset_id=None, - asset_suffix=None, - state=None, - district=None, - block=None, + layer_name=None, + asset_id=None, + asset_suffix=None, + state=None, + district=None, + block=None, ): """Publish asset to GeoServer and persist metadata to the database.""" print("Forest Fringe: save_to_db_and_sync_to_geoserver") diff --git a/computing/tree_in_grassland/tree_in_grassland.py b/computing/tree_in_grassland/tree_in_grassland.py index 89fbec9c..3fae28b9 100644 --- a/computing/tree_in_grassland/tree_in_grassland.py +++ b/computing/tree_in_grassland/tree_in_grassland.py @@ -34,14 +34,17 @@ @app.task(bind=True) def generate_tree_in_grassland_layer( - self, - state, - district, - block, - start_year, - end_year, - gee_account_id=None, - app_type="MWS", + self, + state, + district, + block, + roi=None, + asset_suffix=None, + asset_folder_list=None, + start_year=None, + end_year=None, + gee_account_id=None, + app_type="MWS", ): """ Generate tree-in-grassland context metrics as a vector layer. @@ -75,45 +78,42 @@ def generate_tree_in_grassland_layer( app_type: str – application type key in GEE_PATHS (default "MWS"). """ # ------------------------------------------------------------------ - # STEP 1: Initialize GEE and set up paths + # Initialize GEE and set up paths # ------------------------------------------------------------------ ee_initialize(gee_account_id) start_year = int(start_year) end_year = int(end_year) - asset_suffix = ( + if state and district and block: + asset_suffix = ( valid_gee_text(district.lower()) + "_" + valid_gee_text(block.lower()) - ) - asset_folder_list = [state, district, block] - - description = f"tree_in_grassland_{asset_suffix}_{start_year}_{end_year}" - layer_name = f"{asset_suffix}_tree_in_grassland" - - asset_id = ( - get_gee_dir_path( - asset_folder_list, - asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"], - ) - + description - ) - print(f"Tree in Grassland pipeline started: {asset_id=}") + ) + asset_folder_list = [state, district, block] - # ------------------------------------------------------------------ - # STEP 2: Set up ROI (MWS boundaries from GEE) - # ------------------------------------------------------------------ - roi_path = ( + roi = ee.FeatureCollection( get_gee_dir_path( asset_folder_list, asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"], ) + f"filtered_mws_{valid_gee_text(district.lower())}" + f"_{valid_gee_text(block.lower())}_uid" + ) + + description = f"tree_in_grassland_{asset_suffix}_{start_year}_{end_year}" + layer_name = f"{asset_suffix}_tree_in_grassland" + + asset_id = ( + get_gee_dir_path( + asset_folder_list, + asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"], + ) + + description ) - mws_fc = ee.FeatureCollection(roi_path) + print(f"Tree in Grassland pipeline started: {asset_id=}") # ------------------------------------------------------------------ - # STEP 3: Compute tree-in-grassland metrics + # Compute tree-in-grassland metrics # ------------------------------------------------------------------ if not is_gee_asset_exists(asset_id): @@ -211,10 +211,10 @@ def area(mask): } ) - fc = mws_fc.map(compute_stats) + fc = roi.map(compute_stats) # -------------------------------------------------------------- - # STEP 4: Export to GEE + # Export to GEE # -------------------------------------------------------------- task_id = export_vector_asset_to_gee(fc, description, asset_id) if task_id: @@ -222,7 +222,7 @@ def area(mask): print("Tree in Grassland layer exported to GEE.") # ------------------------------------------------------------------ - # STEP 5: Publish to GeoServer and save metadata to DB + # Publish to GeoServer and save metadata to DB # ------------------------------------------------------------------ layer_at_geoserver = _save_to_db_and_sync_to_geoserver( layer_name=layer_name, @@ -243,14 +243,14 @@ def area(mask): def _save_to_db_and_sync_to_geoserver( - layer_name=None, - asset_id=None, - start_year=None, - end_year=None, - asset_suffix=None, - state=None, - district=None, - block=None, + layer_name=None, + asset_id=None, + start_year=None, + end_year=None, + asset_suffix=None, + state=None, + district=None, + block=None, ): """Publish asset to GeoServer and persist metadata to the database.""" print("Tree in Grassland: save_to_db_and_sync_to_geoserver") @@ -272,11 +272,11 @@ def _save_to_db_and_sync_to_geoserver( make_asset_public(asset_id) + layer_at_geoserver = False fc = ee.FeatureCollection(asset_id) - res = sync_fc_to_geoserver(fc, state, layer_name, "tree_in_grassland") + res = sync_fc_to_geoserver(fc, asset_suffix, layer_name, "tree_in_grassland") print(res) - layer_at_geoserver = False if res["status_code"] == 201 and layer_id: update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) print("Tree in Grassland: sync to geoserver flag updated") diff --git a/computing/tree_in_grassland/tree_in_grassland_utils.py b/computing/tree_in_grassland/tree_in_grassland_utils.py index 2c9ef22d..8b07999e 100644 --- a/computing/tree_in_grassland/tree_in_grassland_utils.py +++ b/computing/tree_in_grassland/tree_in_grassland_utils.py @@ -7,6 +7,8 @@ import ee +from utilities.constants import PAN_INDIA_LULC_V3_DATASET + # ---------------------------------------- # PARAMETERS / CONSTANTS # ---------------------------------------- @@ -37,8 +39,6 @@ NEIGHBOR_CLASSES = [k for k in LULC_CLASSES.keys() if k != TREE_CLASS] THRESHOLD = 0.5 # strictly > 50% -PAN_INDIA_LULC_PATH = "projects/corestack-datasets/assets/datasets/LULC_v3_river_basin" - def load_pan_india_lulc(year): """ @@ -54,7 +54,7 @@ def load_pan_india_lulc(year): ee.Image with the 'predicted_label' band, unmasked and cast to Int. """ return ( - ee.Image(f"{PAN_INDIA_LULC_PATH}/pan_india_lulc_v3_{year}_{year + 1}") + ee.Image(f"{PAN_INDIA_LULC_V3_DATASET}{year}_{year + 1}") .select("predicted_label") .unmask(0) .toInt() From 3483adbd661cf89dcf521c1303a2831476c9d817 Mon Sep 17 00:00:00 2001 From: pawangramvaani Date: Sun, 9 Aug 2026 23:08:21 -0700 Subject: [PATCH 110/120] remove end year --- computing/layer_dependency/local_end_year_rules.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/computing/layer_dependency/local_end_year_rules.json b/computing/layer_dependency/local_end_year_rules.json index 9185d5c4..13c0c615 100644 --- a/computing/layer_dependency/local_end_year_rules.json +++ b/computing/layer_dependency/local_end_year_rules.json @@ -3,6 +3,5 @@ "tree_health_ch_vector": 2023, "tree_health_ccd_raster": 2023, "tree_health_ccd_vector": 2023, - "tree_health_overall_change_raster": 2023, - "tree_health_overall_change_vector": 2023 + "tree_health_overall_change_raster": 2023 } From 9829e00f234a01f1407ba11471aca5b9cf418837 Mon Sep 17 00:00:00 2001 From: pawangramvaani Date: Mon, 10 Aug 2026 02:23:12 -0700 Subject: [PATCH 111/120] fix imports --- .../layer_dependency/layer_generation_in_order.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/computing/layer_dependency/layer_generation_in_order.py b/computing/layer_dependency/layer_generation_in_order.py index 45ddcff3..f48fc744 100644 --- a/computing/layer_dependency/layer_generation_in_order.py +++ b/computing/layer_dependency/layer_generation_in_order.py @@ -95,16 +95,14 @@ from computing.misc.drainage_lines_local_compute import ( clip_drainage_lines as clip_drainage_lines_local, ) -from computing.misc.facilities_proximity_local_compute import ( - generate_facilities_proximity_local, -) -from computing.misc.antyodaya_local_compute import generate_antyodaya_data_local +from computing.misc.facilities.pipeline import generate_facilities_proximity_task +from computing.misc.antyodaya.pipeline import generate_antyodaya_layer_task from computing.misc.canal_local_compute import canal_vector as canal_vector_local from computing.misc.digital_elevation_model_local import ( generate_febdem_raster_vector_clip, ) from computing.misc.drainage_density_local_compute import drainage_density -from computing.misc.livestocks_local_compute import generate_livestocks_data_local +from computing.misc.livestocks.pipeline import generate_livestocks_layer_task from computing.misc.river_local_compute import river_vector as river_vector_local from computing.misc.factory_csr_local_compute import generate_factory_csr_data_local from computing.misc.green_credit_local_compute import generate_green_credit_data_local @@ -294,10 +292,9 @@ "restoration_opportunity": generate_restoration_opportunity_local, "generate_mws_connectivity_data": mws_connectivity_vector, "generate_mws_connectivity": mws_connectivity_vector, - "generate_facilities_proximity_task": generate_facilities_proximity_local, - "generate_facilities_proximity": generate_facilities_proximity_local, - "generate_livestocks": generate_livestocks_data_local, - "generate_antyodaya": generate_antyodaya_data_local, + "generate_facilities_proximity": generate_facilities_proximity_task, + "generate_livestocks": generate_livestocks_layer_task, + "generate_antyodaya": generate_antyodaya_layer_task, "generate_density_vector": drainage_density, "generate_river_data": river_vector_local, "generate_canal_vector": canal_vector_local, From 293be727d0ce3bfb9334c04423d8936c8a893cae Mon Sep 17 00:00:00 2001 From: aman verma Date: Tue, 11 Aug 2026 11:33:06 +0000 Subject: [PATCH 112/120] forest fringes and tree in grassland on AEZ --- computing/forest_fringe/forest_fringe.py | 79 +++++++++++++------ .../forest_fringe/forest_fringe_utils.py | 2 +- .../tree_in_grassland/tree_in_grassland.py | 71 ++++++++++++----- 3 files changed, 104 insertions(+), 48 deletions(-) diff --git a/computing/forest_fringe/forest_fringe.py b/computing/forest_fringe/forest_fringe.py index 6fda135e..377578a6 100644 --- a/computing/forest_fringe/forest_fringe.py +++ b/computing/forest_fringe/forest_fringe.py @@ -22,7 +22,8 @@ save_layer_info_to_db, update_layer_sync_status, ) -from utilities.constants import GEE_PATHS +from gee_computing.models import GEEAccount +from utilities.constants import GEE_PATHS, AEZ, MWS_DATASET from utilities.gee_utils import ( ee_initialize, check_task_status, @@ -45,17 +46,40 @@ ) +def forest_fringes_on_AEZ(aez_no, gee_account_id=7): + ee_initialize(gee_account_id) + aez = ee.FeatureCollection(AEZ) + mwses = ee.FeatureCollection(MWS_DATASET) + + filter_aez = aez.filter(ee.Filter.eq("ae_regcode", aez_no)) + roi = mwses.filterBounds(filter_aez.geometry()) + + asset_suffix = f"AEZ_{aez_no}" + asset_folder_list = ["forest_fringes"] + generate_forest_fringe_degradation( + roi=roi, + asset_suffix=asset_suffix, + asset_folder_list=asset_folder_list, + gee_account_id=gee_account_id, + app_type="forest_fringes", + sync_to_db=False, + sync_to_geoserver=False, + ) + + @app.task(bind=True) def generate_forest_fringe_degradation( self, - state, - district, - block, + state=None, + district=None, + block=None, roi=None, asset_suffix=None, asset_folder_list=None, gee_account_id=None, app_type="MWS", + sync_to_db=True, + sync_to_geoserver=True, ): """ Generate forest-fringe metrics as a vector layer. @@ -101,15 +125,13 @@ def generate_forest_fringe_degradation( ) description = f"forest_fringe_{asset_suffix}" - layer_name = f"{asset_suffix}_forest_fringe" + if app_type in GEE_PATHS: + asset_path = GEE_PATHS[app_type]["GEE_ASSET_PATH"] + else: + gee_obj = GEEAccount.objects.get(pk=gee_account_id) + asset_path = f"projects/{gee_obj.name}/assets/" - asset_id = ( - get_gee_dir_path( - asset_folder_list, - asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"], - ) - + description - ) + asset_id = get_gee_dir_path(asset_folder_list, asset_path=asset_path) + description print(f"Forest Fringe pipeline started: {asset_id=}") @@ -283,12 +305,14 @@ def compute_metrics_per_mws(f): # Publish to GeoServer and save metadata to DB # ------------------------------------------------------------------ layer_at_geoserver = _save_to_db_and_sync_to_geoserver( - layer_name=layer_name, + layer_name=description, asset_id=asset_id, asset_suffix=asset_suffix, state=state, district=district, block=block, + sync_to_db=sync_to_db, + sync_to_geoserver=sync_to_geoserver, ) return layer_at_geoserver @@ -305,12 +329,14 @@ def _save_to_db_and_sync_to_geoserver( state=None, district=None, block=None, + sync_to_db=True, + sync_to_geoserver=True, ): """Publish asset to GeoServer and persist metadata to the database.""" print("Forest Fringe: save_to_db_and_sync_to_geoserver") layer_id = None - if state and district and block: + if sync_to_db and state and district and block: layer_id = save_layer_info_to_db( state=state, district=district, @@ -321,15 +347,16 @@ def _save_to_db_and_sync_to_geoserver( ) make_asset_public(asset_id) - - fc = ee.FeatureCollection(asset_id) - res = sync_fc_to_geoserver(fc, asset_suffix, layer_name, "forest_fringes") - print(res) - - layer_at_geoserver = False - if res["status_code"] == 201 and layer_id: - update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) - print("Forest Fringe: sync to geoserver flag updated") - layer_at_geoserver = True - - return layer_at_geoserver + if sync_to_geoserver: + fc = ee.FeatureCollection(asset_id) + res = sync_fc_to_geoserver(fc, asset_suffix, layer_name, "forest_fringes") + print(res) + + layer_at_geoserver = False + if res["status_code"] == 201 and layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Forest Fringe: sync to geoserver flag updated") + layer_at_geoserver = True + + return layer_at_geoserver + return False diff --git a/computing/forest_fringe/forest_fringe_utils.py b/computing/forest_fringe/forest_fringe_utils.py index 99d352ad..e32be959 100644 --- a/computing/forest_fringe/forest_fringe_utils.py +++ b/computing/forest_fringe/forest_fringe_utils.py @@ -55,7 +55,7 @@ def load_ltp_change(): Returns: ee.Image – mean of the ltp_stp_change image collection. """ - return ee.ImageCollection(LTP_STP_CHANGE).mean() + return ee.Image(LTP_STP_CHANGE) def load_overall_change(): diff --git a/computing/tree_in_grassland/tree_in_grassland.py b/computing/tree_in_grassland/tree_in_grassland.py index 3fae28b9..fecdd40d 100644 --- a/computing/tree_in_grassland/tree_in_grassland.py +++ b/computing/tree_in_grassland/tree_in_grassland.py @@ -13,7 +13,8 @@ save_layer_info_to_db, update_layer_sync_status, ) -from utilities.constants import GEE_PATHS +from gee_computing.models import GEEAccount +from utilities.constants import GEE_PATHS, AEZ, MWS_DATASET from utilities.gee_utils import ( ee_initialize, check_task_status, @@ -32,6 +33,27 @@ ) +def tree_in_grassland_for_AEZ(aez_no, gee_account_id=7): + ee_initialize(gee_account_id) + aez = ee.FeatureCollection(AEZ) + mwses = ee.FeatureCollection(MWS_DATASET) + + filter_aez = aez.filter(ee.Filter.eq("ae_regcode", aez_no)) + roi = mwses.filterBounds(filter_aez.geometry()) + + asset_suffix = f"AEZ_{aez_no}" + asset_folder_list = ["tree_in_grassland"] + generate_tree_in_grassland_layer( + roi=roi, + asset_suffix=asset_suffix, + asset_folder_list=asset_folder_list, + gee_account_id=gee_account_id, + app_type="tree_in_grassland", + sync_to_db=False, + sync_to_geoserver=False, + ) + + @app.task(bind=True) def generate_tree_in_grassland_layer( self, @@ -45,6 +67,8 @@ def generate_tree_in_grassland_layer( end_year=None, gee_account_id=None, app_type="MWS", + sync_to_db=True, + sync_to_geoserver=True, ): """ Generate tree-in-grassland context metrics as a vector layer. @@ -103,13 +127,13 @@ def generate_tree_in_grassland_layer( description = f"tree_in_grassland_{asset_suffix}_{start_year}_{end_year}" layer_name = f"{asset_suffix}_tree_in_grassland" - asset_id = ( - get_gee_dir_path( - asset_folder_list, - asset_path=GEE_PATHS[app_type]["GEE_ASSET_PATH"], - ) - + description - ) + if app_type in GEE_PATHS: + asset_path = GEE_PATHS[app_type]["GEE_ASSET_PATH"] + else: + gee_obj = GEEAccount.objects.get(pk=gee_account_id) + asset_path = f"projects/{gee_obj.name}/assets/" + + asset_id = get_gee_dir_path(asset_folder_list, asset_path=asset_path) + description print(f"Tree in Grassland pipeline started: {asset_id=}") # ------------------------------------------------------------------ @@ -233,6 +257,8 @@ def area(mask): state=state, district=district, block=block, + sync_to_db=sync_to_db, + sync_to_geoserver=sync_to_geoserver, ) return layer_at_geoserver @@ -251,12 +277,14 @@ def _save_to_db_and_sync_to_geoserver( state=None, district=None, block=None, + sync_to_db=True, + sync_to_geoserver=True, ): """Publish asset to GeoServer and persist metadata to the database.""" print("Tree in Grassland: save_to_db_and_sync_to_geoserver") layer_id = None - if state and district and block: + if sync_to_db and state and district and block: layer_id = save_layer_info_to_db( state=state, district=district, @@ -271,15 +299,16 @@ def _save_to_db_and_sync_to_geoserver( ) make_asset_public(asset_id) - - layer_at_geoserver = False - fc = ee.FeatureCollection(asset_id) - res = sync_fc_to_geoserver(fc, asset_suffix, layer_name, "tree_in_grassland") - print(res) - - if res["status_code"] == 201 and layer_id: - update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) - print("Tree in Grassland: sync to geoserver flag updated") - layer_at_geoserver = True - - return layer_at_geoserver + if sync_to_geoserver: + layer_at_geoserver = False + fc = ee.FeatureCollection(asset_id) + res = sync_fc_to_geoserver(fc, asset_suffix, layer_name, "tree_in_grassland") + print(res) + + if res["status_code"] == 201 and layer_id: + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + print("Tree in Grassland: sync to geoserver flag updated") + layer_at_geoserver = True + + return layer_at_geoserver + return False From b91e593418ee6914ad7cd2a0ceb8ed86c32a9c41 Mon Sep 17 00:00:00 2001 From: pawangramvaani Date: Tue, 11 Aug 2026 06:11:04 -0700 Subject: [PATCH 113/120] add parameter --- computing/misc/antyodaya/pipeline.py | 182 +++++++++++---- computing/misc/facilities/pipeline.py | 306 ++++++++++++++++++++------ computing/misc/livestocks/pipeline.py | 173 +++++++++++---- 3 files changed, 510 insertions(+), 151 deletions(-) diff --git a/computing/misc/antyodaya/pipeline.py b/computing/misc/antyodaya/pipeline.py index b3daf1c6..bd07aa1b 100644 --- a/computing/misc/antyodaya/pipeline.py +++ b/computing/misc/antyodaya/pipeline.py @@ -51,7 +51,7 @@ ANTYODAYA_2020_CSV, ANTYODAYA_GEOSERVER_WORKSPACE, ) - +from utilities.pipelines import api_request_payload CONFIG_PATH = Path(__file__).with_name("antyodaya_pipeline.yaml") ALGORITHM = "local-antyodaya-csv-admin-join" @@ -84,7 +84,9 @@ def _apply_source_defaults(config: Mapping[str, Any]) -> dict[str, Any]: return resolved -def _cli_request(state: str, district: str, tehsil: str, sync_to_geoserver: bool = True) -> StandardRequest: +def _cli_request( + state: str, district: str, tehsil: str, sync_to_geoserver: bool = True +) -> StandardRequest: return StandardRequest.from_mapping( { "scope": { @@ -102,11 +104,19 @@ def _cli_request(state: str, district: str, tehsil: str, sync_to_geoserver: bool def _source_columns(config: Mapping[str, Any]) -> dict[str, list[str]]: header = csv_header(_repo_path(config["sources"]["csv"])) validation = config["validation"] - category_cluster = [col for col in header if col.endswith(validation["category_cluster_suffix"])] - category_value = [col for col in header if col.endswith(validation["category_value_suffix"])] - feature_value = [col for col in header if col.endswith(validation["feature_value_suffix"])] + category_cluster = [ + col for col in header if col.endswith(validation["category_cluster_suffix"]) + ] + category_value = [ + col for col in header if col.endswith(validation["category_value_suffix"]) + ] + feature_value = [ + col for col in header if col.endswith(validation["feature_value_suffix"]) + ] metric_columns = set(category_cluster + category_value + feature_value) - location_columns = [col for col in config["source_location_columns"] if col in header] + location_columns = [ + col for col in config["source_location_columns"] if col in header + ] source_identity_columns = { config["keys"]["source_join_key"], config["keys"]["source_unique_key"], @@ -123,7 +133,9 @@ def _source_columns(config: Mapping[str, Any]) -> dict[str, list[str]]: } -def _sidecar(config: Mapping[str, Any], columns: Mapping[str, list[str]]) -> CSVSQLiteSidecar: +def _sidecar( + config: Mapping[str, Any], columns: Mapping[str, list[str]] +) -> CSVSQLiteSidecar: source_columns = [ config["keys"]["source_join_key"], config["keys"]["source_unique_key"], @@ -144,10 +156,14 @@ def _sidecar(config: Mapping[str, Any], columns: Mapping[str, list[str]]) -> CSV def _normalize_category_clusters(frame: pd.DataFrame, columns: list[str]) -> None: for column in columns: if column in frame.columns: - frame[column] = frame[column].where(frame[column].isna(), frame[column].astype(str).str.upper()) + frame[column] = frame[column].where( + frame[column].isna(), frame[column].astype(str).str.upper() + ) -def _validate_antyodaya(frame: pd.DataFrame, config: Mapping[str, Any]) -> list[ValidationIssue]: +def _validate_antyodaya( + frame: pd.DataFrame, config: Mapping[str, Any] +) -> list[ValidationIssue]: validation = config["validation"] category_columns = columns_ending_with(frame, validation["category_cluster_suffix"]) value_columns = columns_ending_with(frame, validation["category_value_suffix"]) @@ -173,7 +189,9 @@ def _validate_antyodaya(frame: pd.DataFrame, config: Mapping[str, Any]) -> list[ return issues -def _merge_admin_antyodaya(admin_rows, source_rows: pd.DataFrame, config: Mapping[str, Any]): +def _merge_admin_antyodaya( + admin_rows, source_rows: pd.DataFrame, config: Mapping[str, Any] +): admin_key = config["keys"]["admin_join_key"] source_key = config["keys"]["source_join_key"] admin = admin_rows.copy() @@ -183,17 +201,36 @@ def _merge_admin_antyodaya(admin_rows, source_rows: pd.DataFrame, config: Mappin "district_name", "sub_district_name", } - attrs = attrs.drop(columns=[col for col in source_duplicate_columns if col in attrs.columns], errors="ignore") - return admin.merge(attrs, left_on=admin_key, right_on=source_key, how="left", suffixes=("", "_antyodaya")) + attrs = attrs.drop( + columns=[col for col in source_duplicate_columns if col in attrs.columns], + errors="ignore", + ) + return admin.merge( + attrs, + left_on=admin_key, + right_on=source_key, + how="left", + suffixes=("", "_antyodaya"), + ) -def _ordered_tabular_columns(frame: pd.DataFrame, columns: Mapping[str, list[str]]) -> list[str]: +def _ordered_tabular_columns( + frame: pd.DataFrame, columns: Mapping[str, list[str]] +) -> list[str]: ordered = [] - ordered.extend([col for col in columns["location"] if col not in ordered and col in frame.columns]) + ordered.extend( + [ + col + for col in columns["location"] + if col not in ordered and col in frame.columns + ] + ) ordered.extend([col for col in columns["category_cluster"] if col in frame.columns]) ordered.extend([col for col in columns["category_value"] if col in frame.columns]) ordered.extend([col for col in columns["feature_value"] if col in frame.columns]) - ordered.extend([col for col in columns["raw"] if col in frame.columns and col not in ordered]) + ordered.extend( + [col for col in columns["raw"] if col in frame.columns and col not in ordered] + ) return [col for col in ordered if col in frame.columns] @@ -208,17 +245,27 @@ def _report_value_columns(columns: Mapping[str, list[str]]) -> list[str]: ] -def _focused_frame(frame: pd.DataFrame, columns: Mapping[str, list[str]], status_name: str | None) -> pd.DataFrame: - focused = admin_presentation_frame(frame.drop(columns=["geometry"], errors="ignore")) +def _focused_frame( + frame: pd.DataFrame, columns: Mapping[str, list[str]], status_name: str | None +) -> pd.DataFrame: + focused = admin_presentation_frame( + frame.drop(columns=["geometry"], errors="ignore") + ) source = frame.set_index("fid", drop=False) if "fid" in frame.columns else frame metric_columns = _report_value_columns(columns) output_rows: list[dict[str, Any]] = [] for _, admin_row in focused.iterrows(): row = admin_row.to_dict() admin_index = row.get("index") - values = source.loc[admin_index] if admin_index in source.index else pd.Series(dtype=object) + values = ( + source.loc[admin_index] + if admin_index in source.index + else pd.Series(dtype=object) + ) has_village_id = pd.notna(row.get("village_id")) - has_antyodaya = pd.notna(values.get("village_key")) if not values.empty else False + has_antyodaya = ( + pd.notna(values.get("village_key")) if not values.empty else False + ) status = STATUS_MATCHED if not has_village_id: status = STATUS_NO_VILLAGE_ID @@ -235,7 +282,11 @@ def _focused_frame(frame: pd.DataFrame, columns: Mapping[str, list[str]], status ordered = list(focused.columns) if status_name: ordered.append(status_name) - ordered.extend(column for column in metric_columns if column in output.columns and column not in ordered) + ordered.extend( + column + for column in metric_columns + if column in output.columns and column not in ordered + ) return output.reindex(columns=ordered) @@ -312,7 +363,9 @@ def describe(name: str) -> str | None: return describe -def _overview(frame: pd.DataFrame, group_columns: list[str], columns: Mapping[str, list[str]]) -> pd.DataFrame: +def _overview( + frame: pd.DataFrame, group_columns: list[str], columns: Mapping[str, list[str]] +) -> pd.DataFrame: available_group_columns = [col for col in group_columns if col in frame.columns] if not available_group_columns: return pd.DataFrame() @@ -322,14 +375,20 @@ def _overview(frame: pd.DataFrame, group_columns: list[str], columns: Mapping[st keys = (keys,) row = dict(zip(available_group_columns, keys)) row["admin_village_rows"] = int(len(group)) - row["matched_antyodaya_rows"] = int(group["village_key"].notna().sum()) if "village_key" in group else 0 + row["matched_antyodaya_rows"] = ( + int(group["village_key"].notna().sum()) if "village_key" in group else 0 + ) for column in columns["category_value"]: if column in group.columns: - row[f"{column}_mean"] = float(pd.to_numeric(group[column], errors="coerce").mean()) + row[f"{column}_mean"] = float( + pd.to_numeric(group[column], errors="coerce").mean() + ) for column in columns["category_cluster"]: if column not in group.columns: continue - counts = group[column].fillna("NO_DATA").astype(str).str.upper().value_counts() + counts = ( + group[column].fillna("NO_DATA").astype(str).str.upper().value_counts() + ) for label in ("HIGH", "MEDIUM", "LOW", "NO_DATA"): row[f"{column}_{label.lower()}_count"] = int(counts.get(label, 0)) rows.append(row) @@ -344,8 +403,12 @@ def _column_reference_lines(column_entries: list[Mapping[str, Any]]) -> list[str "| --- | --- | --- |", ] for entry in column_entries: - description = str(entry.get("description") or "").replace("|", "\\|").replace("\n", " ") - lines.append(f"| `{entry['column']}` | {entry.get('datatype', '')} | {description} |") + description = ( + str(entry.get("description") or "").replace("|", "\\|").replace("\n", " ") + ) + lines.append( + f"| `{entry['column']}` | {entry.get('datatype', '')} | {description} |" + ) lines.append("") return lines @@ -418,7 +481,9 @@ def _readme_lines( return lines -def _cache_input_signatures(config: Mapping[str, Any], config_path: str | Path) -> dict[str, dict[str, Any]]: +def _cache_input_signatures( + config: Mapping[str, Any], config_path: str | Path +) -> dict[str, dict[str, Any]]: sources = config.get("sources", {}) paths: dict[str, str | Path] = { "pipeline_config": _repo_path(config_path), @@ -446,7 +511,9 @@ def _cache_key(request: StandardRequest, outputs: OutputOptions) -> str: ) -def _required_result_paths(outputs: OutputOptions, request: StandardRequest) -> tuple[str, ...]: +def _required_result_paths( + outputs: OutputOptions, request: StandardRequest +) -> tuple[str, ...]: required: list[str] = ["mapping_yaml_path", "links_path"] if outputs.metadata: required.append("run_metadata_path") @@ -472,7 +539,10 @@ def run_antyodaya_pipeline( output_config = config["output"] t0 = time.perf_counter() - admin_source = CSAdminSource(_repo_path(config["sources"]["admin_gpkg"]), table_name=config["sources"]["admin_layer"]) + admin_source = CSAdminSource( + _repo_path(config["sources"]["admin_gpkg"]), + table_name=config["sources"]["admin_layer"], + ) include_geometry = outputs.gpkg or request.publish.sync_to_geoserver ( admin_selection, @@ -528,17 +598,24 @@ def run_antyodaya_pipeline( status_name, status_outputs = status_column_config(config) if status_name: joined[status_name] = [ - STATUS_NO_VILLAGE_ID - if pd.isna(village_id) - else (STATUS_MATCHED if pd.notna(village_key) else STATUS_NO_DATA) + ( + STATUS_NO_VILLAGE_ID + if pd.isna(village_id) + else (STATUS_MATCHED if pd.notna(village_key) else STATUS_NO_DATA) + ) for village_id, village_key in zip(joined["village_id"], village_keys) ] ordered_columns = _ordered_tabular_columns(joined, columns) - villages_frame = admin_output_frame(joined.drop(columns=["geometry"], errors="ignore"), value_columns=ordered_columns) + villages_frame = admin_output_frame( + joined.drop(columns=["geometry"], errors="ignore"), + value_columns=ordered_columns, + ) gpkg_value_columns = list(ordered_columns) if status_name and {"gpkg", "geoserver"} & status_outputs: gpkg_value_columns = [status_name, *gpkg_value_columns] - gpkg_frame = admin_output_frame(joined, value_columns=gpkg_value_columns, include_geometry=True) + gpkg_frame = admin_output_frame( + joined, value_columns=gpkg_value_columns, include_geometry=True + ) gpkg_frame = normalize_unicode_frame(gpkg_frame) describe = _column_describer(config, columns) timings["build_outputs_seconds"] = round(time.perf_counter() - t0, 3) @@ -563,7 +640,9 @@ def run_antyodaya_pipeline( "layer_name": result_name, "rows": int(len(villages_frame)), "matched_rows": matched_rows, - "join_coverage": round(matched_rows / len(villages_frame), 6) if len(villages_frame) else 0, + "join_coverage": ( + round(matched_rows / len(villages_frame), 6) if len(villages_frame) else 0 + ), "validation_issues": [asdict(issue) for issue in validation_issues], "sidecar": sidecar_status, "admin_created_indexes": admin_selection.created_indexes, @@ -633,10 +712,10 @@ def run_antyodaya_pipeline( is_override=request.publish.overwrite, ) if layer_id is None: - raise RuntimeError(f"Database registration failed for layer {result_name!r}.") - if update_layer_sync_status( - layer_id=layer_id, sync_to_geoserver=True - ) is None: + raise RuntimeError( + f"Database registration failed for layer {result_name!r}." + ) + if update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) is None: raise RuntimeError( f"GeoServer sync status update failed for layer ID {layer_id}." ) @@ -658,7 +737,9 @@ def run_antyodaya_pipeline( issues=validation_issues, geoserver=geoserver, column_entries=column_dictionary( - pd.DataFrame(gpkg_frame.drop(columns=["geometry"], errors="ignore")), + pd.DataFrame( + gpkg_frame.drop(columns=["geometry"], errors="ignore") + ), describe, ), ) @@ -686,7 +767,9 @@ def run_antyodaya_pipeline( "config_path": str(config_path), "outputs": { "villages": frame_profile( - pd.DataFrame(gpkg_frame.drop(columns=["geometry"], errors="ignore")), + pd.DataFrame( + gpkg_frame.drop(columns=["geometry"], errors="ignore") + ), describe, ), }, @@ -710,14 +793,27 @@ def run_antyodaya_request(payload: Mapping[str, Any]) -> dict[str, Any]: @app.task(bind=True) -def generate_antyodaya_layer_task(self, payload: Mapping[str, Any]) -> dict[str, Any]: +def generate_antyodaya_layer_task( + self, + state: str | None = None, + district: str | None = None, + block: str | None = None, + payload: Mapping[str, Any] | None = None, +) -> dict[str, Any]: """Generate Mission Antyodaya outputs for a standard request payload.""" + if payload is None: + payload = api_request_payload( + {"state": state, "district": district, "block": block}, + overwrite=True, + ) return run_antyodaya_request(payload) def main() -> None: - parser = argparse.ArgumentParser(description="Run the local Mission Antyodaya pipeline.") + parser = argparse.ArgumentParser( + description="Run the local Mission Antyodaya pipeline." + ) parser.add_argument("--state") parser.add_argument("--district") parser.add_argument("--tehsil") diff --git a/computing/misc/facilities/pipeline.py b/computing/misc/facilities/pipeline.py index 8dd86f81..9b425b7e 100644 --- a/computing/misc/facilities/pipeline.py +++ b/computing/misc/facilities/pipeline.py @@ -62,7 +62,7 @@ FACILITIES_GEOSERVER_WORKSPACE, FACILITIES_GPKG, ) - +from utilities.pipelines import api_request_payload CONFIG_PATH = Path(__file__).with_name("facilities_pipeline.yaml") ALGORITHM = "local-facilities-live-proximity" @@ -92,7 +92,9 @@ def _apply_source_defaults(config: Mapping[str, Any]) -> dict[str, Any]: return resolved -def _cli_request(state: str, district: str, tehsil: str, sync_to_geoserver: bool = True) -> StandardRequest: +def _cli_request( + state: str, district: str, tehsil: str, sync_to_geoserver: bool = True +) -> StandardRequest: return StandardRequest.from_mapping( { "scope": { @@ -139,8 +141,12 @@ def _taxonomy(config: Mapping[str, Any]) -> pd.DataFrame: "class_l2_filter_group": item.get("class_l2_filter_group"), "class_l3_facility_class": item.get("key"), "class_l3_label": item.get("label"), - "configured_subtypes": ";".join(item.get("configured_subtypes", []) or []), - "filter_logic": l2_rollups.get(item.get("class_l2_filter_group"), "direct"), + "configured_subtypes": ";".join( + item.get("configured_subtypes", []) or [] + ), + "filter_logic": l2_rollups.get( + item.get("class_l2_filter_group"), "direct" + ), "sort_order": sort_order, } ) @@ -164,13 +170,25 @@ def _classification(config: Mapping[str, Any]) -> dict[str, Any]: taxonomy = _taxonomy(config) l2_groups: list[dict[str, Any]] = [] for group, rows in taxonomy.groupby("class_l2_filter_group", sort=False): - logic_values = [value for value in rows.get("filter_logic", pd.Series(dtype=str)).dropna().astype(str).unique() if value] + logic_values = [ + value + for value in rows.get("filter_logic", pd.Series(dtype=str)) + .dropna() + .astype(str) + .unique() + if value + ] l2_groups.append( { "key": group, "label": str(group).replace("_", " ").title(), "rollup": logic_values[0] if logic_values else "direct", - "class_l1_domain": rows["class_l1_domain"].dropna().iloc[0] if "class_l1_domain" in rows and not rows["class_l1_domain"].dropna().empty else None, + "class_l1_domain": ( + rows["class_l1_domain"].dropna().iloc[0] + if "class_l1_domain" in rows + and not rows["class_l1_domain"].dropna().empty + else None + ), } ) return { @@ -238,7 +256,9 @@ def _read_facilities_bbox( frame = pd.read_sql_query(sql, connection, params=params) if frame.empty: return gpd.GeoDataFrame(frame, geometry=[], crs="EPSG:4326") - geometry = gpd.points_from_xy(frame["longitude"], frame["latitude"], crs="EPSG:4326") + geometry = gpd.points_from_xy( + frame["longitude"], frame["latitude"], crs="EPSG:4326" + ) return gpd.GeoDataFrame(frame, geometry=geometry) @@ -286,7 +306,10 @@ def _haversine_km(lat1, lon1, lat2, lon2, radius_km: float) -> float: lon2 = math.radians(float(lon2)) dlat = lat2 - lat1 dlon = lon2 - lon1 - a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 + a = ( + math.sin(dlat / 2) ** 2 + + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 + ) return 2 * radius_km * math.asin(math.sqrt(a)) @@ -296,9 +319,15 @@ def _candidate_pool( taxonomy: pd.DataFrame, ) -> tuple[gpd.GeoDataFrame, dict[str, Any]]: search = config["search"] - base = _read_facilities_bbox(config, bbox, expansion_degrees=float(search["base_expansion_degrees"])) + base = _read_facilities_bbox( + config, bbox, expansion_degrees=float(search["base_expansion_degrees"]) + ) required = taxonomy["class_l3_facility_class"].dropna().astype(str).tolist() - covered = set(base["class_l3_facility_class"].dropna().astype(str)) if not base.empty else set() + covered = ( + set(base["class_l3_facility_class"].dropna().astype(str)) + if not base.empty + else set() + ) missing = [value for value in required if value not in covered] supplemental = gpd.GeoDataFrame(pd.DataFrame(), geometry=[], crs="EPSG:4326") if missing: @@ -308,17 +337,30 @@ def _candidate_pool( class_l3_values=missing, expansion_degrees=float(search["supplemental_expansion_degrees"]), ) - frames = [frame.dropna(axis=1, how="all") for frame in (base, supplemental) if not frame.empty] + frames = [ + frame.dropna(axis=1, how="all") + for frame in (base, supplemental) + if not frame.empty + ] pool = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame() if pool.empty: pool = gpd.GeoDataFrame(pool, geometry=[], crs="EPSG:4326") else: - pool = gpd.GeoDataFrame(pool, geometry="geometry", crs="EPSG:4326").drop_duplicates(subset=["facility_uid"]) + pool = gpd.GeoDataFrame( + pool, geometry="geometry", crs="EPSG:4326" + ).drop_duplicates(subset=["facility_uid"]) metadata = { "base_candidates": int(len(base)), "supplemental_candidates": int(len(supplemental)), "candidate_pool": int(len(pool)), - "missing_after_supplemental": sorted(set(required) - set(pool["class_l3_facility_class"].dropna().astype(str))) if not pool.empty else required, + "missing_after_supplemental": ( + sorted( + set(required) + - set(pool["class_l3_facility_class"].dropna().astype(str)) + ) + if not pool.empty + else required + ), } return pool, metadata @@ -332,12 +374,17 @@ def _nearest( radius_km: float, ) -> tuple[gpd.GeoDataFrame, pd.DataFrame]: if pool.empty or village_points.empty: - return gpd.GeoDataFrame(pd.DataFrame(), geometry=[], crs="EPSG:4326"), pd.DataFrame() + return ( + gpd.GeoDataFrame(pd.DataFrame(), geometry=[], crs="EPSG:4326"), + pd.DataFrame(), + ) nearest_rows: list[dict[str, Any]] = [] point_xy = village_points[["_village_lon", "_village_lat"]].to_numpy() for tax in taxonomy.itertuples(index=False): class_l3 = str(tax.class_l3_facility_class) - candidates = pool[pool["class_l3_facility_class"] == class_l3].reset_index(drop=True) + candidates = pool[pool["class_l3_facility_class"] == class_l3].reset_index( + drop=True + ) if candidates.empty: continue tree = cKDTree(candidates[["longitude", "latitude"]].to_numpy()) @@ -367,9 +414,13 @@ def _nearest( "class_l1_domain": getattr(tax, "class_l1_domain"), "class_l2_filter_group": getattr(tax, "class_l2_filter_group"), "class_l3_facility_class": class_l3, - "class_l3_label": getattr(tax, "class_l3_label", class_l3.replace("_", " ").title()), + "class_l3_label": getattr( + tax, "class_l3_label", class_l3.replace("_", " ").title() + ), "nearest_distance_km": round(distance_km, 6), - "inside_requested_scope": bool(facility["facility_uid"] in inside_facility_ids), + "inside_requested_scope": bool( + facility["facility_uid"] in inside_facility_ids + ), "facilities_layer_kind": "nearest", "title": facility.get("facility_name") or facility.get("facility_uid"), } @@ -391,7 +442,11 @@ def _nearest( return nearest, village_service -def _village_service(village_points: pd.DataFrame, nearest: pd.DataFrame, classification: Mapping[str, Any]) -> pd.DataFrame: +def _village_service( + village_points: pd.DataFrame, + nearest: pd.DataFrame, + classification: Mapping[str, Any], +) -> pd.DataFrame: base = village_points.drop(columns=["_village_lon", "_village_lat"]).copy() if nearest.empty: return base @@ -402,7 +457,9 @@ def _village_service(village_points: pd.DataFrame, nearest: pd.DataFrame, classi ("facility_uid", "facility_uid"), ("inside_requested_scope", "inside_scope"), ): - pivot = l3.pivot_table(index="_admin_key", columns="_slug", values=metric, aggfunc="first") + pivot = l3.pivot_table( + index="_admin_key", columns="_slug", values=metric, aggfunc="first" + ) pivot.columns = [f"l3_{col}_{suffix}" for col in pivot.columns] base = base.merge(pivot.reset_index(), on="_admin_key", how="left") l2_rollups = { @@ -429,7 +486,9 @@ def _village_service(village_points: pd.DataFrame, nearest: pd.DataFrame, classi ("class_l3_facility_class", "selected_l3"), ("class_l3_label", "selected_l3_label"), ): - pivot = l2.pivot_table(index="_admin_key", columns="_slug", values=metric, aggfunc="first") + pivot = l2.pivot_table( + index="_admin_key", columns="_slug", values=metric, aggfunc="first" + ) pivot.columns = [f"l2_{col}_{suffix}" for col in pivot.columns] base = base.merge(pivot.reset_index(), on="_admin_key", how="left") base["facilities_layer_kind"] = "village_service" @@ -463,8 +522,17 @@ def _facility_detail(row: pd.Series | None) -> str | None: if row is None: return None parts: list[str] = [] - for column in ("facility_name", "class_l4_facility_subtype", "class_l3_label", "facility_code"): - value = _clean_facility_text(row.get(column)) if column != "facility_code" else _useful_value(row.get(column)) + for column in ( + "facility_name", + "class_l4_facility_subtype", + "class_l3_label", + "facility_code", + ): + value = ( + _clean_facility_text(row.get(column)) + if column != "facility_code" + else _useful_value(row.get(column)) + ) if value: parts.append(value if column != "facility_code" else f"code {value}") scope_value = row.get("inside_requested_scope") @@ -485,14 +553,18 @@ def _l2_output_column(group: Mapping[str, Any], config: Mapping[str, Any]) -> st return f"{slug(key)}_cat_distance_km" -def _l3_classes_by_group(classification: Mapping[str, Any]) -> dict[str, list[Mapping[str, Any]]]: +def _l3_classes_by_group( + classification: Mapping[str, Any], +) -> dict[str, list[Mapping[str, Any]]]: grouped: dict[str, list[Mapping[str, Any]]] = {} for item in classification.get("l3_classes", []): grouped.setdefault(item["class_l2_filter_group"], []).append(item) return grouped -def _service_output_columns(classification: Mapping[str, Any], config: Mapping[str, Any]) -> list[str]: +def _service_output_columns( + classification: Mapping[str, Any], config: Mapping[str, Any] +) -> list[str]: """Report columns derived from the classification structure: for each L2 group the category distance column, then the nearest-facility detail and distance pair for each of its L3 classes.""" @@ -541,7 +613,9 @@ def _machine_output_columns(classification: Mapping[str, Any]) -> list[str]: return columns -def _column_descriptions(classification: Mapping[str, Any], config: Mapping[str, Any]) -> dict[str, str]: +def _column_descriptions( + classification: Mapping[str, Any], config: Mapping[str, Any] +) -> dict[str, str]: """Human-readable descriptions for report columns, driven by the classification YAML description templates.""" @@ -557,31 +631,59 @@ def _column_descriptions(classification: Mapping[str, Any], config: Mapping[str, label = str(group.get("label") or group["key"]) template = category_templates.get(rollup) if template: - descriptions[_l2_output_column(group, config)] = str(template).format(label=label) + descriptions[_l2_output_column(group, config)] = str(template).format( + label=label + ) for item in l3_by_l2.get(group["key"], []): l3_label = str(item.get("label") or item["key"]) l3_slug = slug(item["key"]) if templates.get("nearest_facility"): - descriptions[f"nearest_{l3_slug}"] = str(templates["nearest_facility"]).format(label=l3_label) + descriptions[f"nearest_{l3_slug}"] = str( + templates["nearest_facility"] + ).format(label=l3_label) if templates.get("nearest_distance"): - descriptions[f"nearest_{l3_slug}_distance_km"] = str(templates["nearest_distance"]).format(label=l3_label) + descriptions[f"nearest_{l3_slug}_distance_km"] = str( + templates["nearest_distance"] + ).format(label=l3_label) return descriptions -def _machine_column_describer(classification: Mapping[str, Any], config: Mapping[str, Any]): +def _machine_column_describer( + classification: Mapping[str, Any], config: Mapping[str, Any] +): """Describe both report columns and the verbose `l2_*`/`l3_*` GPKG columns.""" descriptions = _column_descriptions(classification, config) - labels = {slug(item["key"]): str(item.get("label") or item["key"]) for item in classification.get("l3_classes", [])} - labels.update({slug(group["key"]): str(group.get("label") or group["key"]) for group in classification.get("l2_groups", [])}) + labels = { + slug(item["key"]): str(item.get("label") or item["key"]) + for item in classification.get("l3_classes", []) + } + labels.update( + { + slug(group["key"]): str(group.get("label") or group["key"]) + for group in classification.get("l2_groups", []) + } + ) patterns = ( - ("l2_", "_selected_l3_label", "Label of the L3 facility class selected for the {label} group."), + ( + "l2_", + "_selected_l3_label", + "Label of the L3 facility class selected for the {label} group.", + ), ("l2_", "_selected_l3", "L3 facility class selected for the {label} group."), ("l2_", "_distance_km", "Access distance in km for the {label} group."), - ("l2_", "_facility_uid", "Identifier of the facility selected for the {label} group."), + ( + "l2_", + "_facility_uid", + "Identifier of the facility selected for the {label} group.", + ), ("l3_", "_distance_km", "Distance in km to the nearest {label}."), ("l3_", "_facility_uid", "Identifier of the nearest {label}."), - ("l3_", "_inside_scope", "Whether the nearest {label} lies inside the requested boundary."), + ( + "l3_", + "_inside_scope", + "Whether the nearest {label} lies inside the requested boundary.", + ), ) def describe(name: str) -> str | None: @@ -601,16 +703,24 @@ def describe(name: str) -> str | None: return describe -def _machine_column_renamer(classification: Mapping[str, Any], config: Mapping[str, Any]): +def _machine_column_renamer( + classification: Mapping[str, Any], config: Mapping[str, Any] +): """Return optional report-facing names without changing stored fields.""" l2_targets: dict[str, str] = {} for group in classification.get("l2_groups", []): group_slug = slug(group["key"]) l2_targets[f"l2_{group_slug}_distance_km"] = _l2_output_column(group, config) - l2_targets[f"l2_{group_slug}_facility_uid"] = f"{group_slug}_selected_facility_uid" - l2_targets[f"l2_{group_slug}_selected_l3"] = f"{group_slug}_selected_facility_class" - l2_targets[f"l2_{group_slug}_selected_l3_label"] = f"{group_slug}_selected_facility_label" + l2_targets[f"l2_{group_slug}_facility_uid"] = ( + f"{group_slug}_selected_facility_uid" + ) + l2_targets[f"l2_{group_slug}_selected_l3"] = ( + f"{group_slug}_selected_facility_class" + ) + l2_targets[f"l2_{group_slug}_selected_l3_label"] = ( + f"{group_slug}_selected_facility_label" + ) def rename(name: str) -> str | None: if name in l2_targets: @@ -643,7 +753,9 @@ def _canonical_facility_point_output(frame: gpd.GeoDataFrame) -> gpd.GeoDataFram "NAME": "village_name", } ) - admin_columns = [column for column in ADMIN_PRESENTATION_COLUMNS if column in output.columns] + admin_columns = [ + column for column in ADMIN_PRESENTATION_COLUMNS if column in output.columns + ] value_columns = [ column for column in output.columns @@ -739,14 +851,20 @@ def _focused_service_frame( nearest_by_village_l3: dict[tuple[Any, str], pd.Series] = {} if not nearest.empty: for _, row in nearest.iterrows(): - nearest_by_village_l3[(row.get("_admin_key"), row.get("class_l3_facility_class"))] = row + nearest_by_village_l3[ + (row.get("_admin_key"), row.get("class_l3_facility_class")) + ] = row rows: list[dict[str, Any]] = [] l3_by_l2 = _l3_classes_by_group(classification) for _, source in frame.iterrows(): row = source.to_dict() admin_key = row.pop("_admin_key", None) - service = service_rows.loc[admin_key] if admin_key in service_rows.index else pd.Series(dtype=object) + service = ( + service_rows.loc[admin_key] + if admin_key in service_rows.index + else pd.Series(dtype=object) + ) has_village_id = pd.notna(row.get("village_id")) if status_name: row[status_name] = _status_value(row.get("village_id"), not nearest.empty) @@ -757,13 +875,19 @@ def _focused_service_frame( for group in classification.get("l2_groups", []): group_key = group["key"] group_slug = slug(group_key) - row[_l2_output_column(group, config)] = service.get(f"l2_{group_slug}_distance_km") + row[_l2_output_column(group, config)] = service.get( + f"l2_{group_slug}_distance_km" + ) for item in l3_by_l2.get(group_key, []): l3_key = item["key"] l3_slug = slug(l3_key) nearest_row = nearest_by_village_l3.get((admin_key, l3_key)) row[f"nearest_{l3_slug}"] = _facility_detail(nearest_row) - row[f"nearest_{l3_slug}_distance_km"] = None if nearest_row is None else nearest_row.get("nearest_distance_km") + row[f"nearest_{l3_slug}_distance_km"] = ( + None + if nearest_row is None + else nearest_row.get("nearest_distance_km") + ) rows.append(row) ordered = [*admin_columns] if status_name: @@ -780,9 +904,13 @@ def _column_reference_lines(column_entries: list[Mapping[str, Any]]) -> list[str "| --- | --- | --- | --- |", ] for entry in column_entries: - description = str(entry.get("description") or "").replace("|", "\\|").replace("\n", " ") + description = ( + str(entry.get("description") or "").replace("|", "\\|").replace("\n", " ") + ) rename_to = entry.get("rename_to") or "" - lines.append(f"| `{entry['column']}` | {entry.get('datatype', '')} | {rename_to} | {description} |") + lines.append( + f"| `{entry['column']}` | {entry.get('datatype', '')} | {rename_to} | {description} |" + ) lines.append("") return lines @@ -850,7 +978,9 @@ def _readme_lines( return lines -def _cache_input_signatures(config: Mapping[str, Any], config_path: str | Path) -> dict[str, dict[str, Any]]: +def _cache_input_signatures( + config: Mapping[str, Any], config_path: str | Path +) -> dict[str, dict[str, Any]]: sources = config.get("sources", {}) paths: dict[str, str | Path] = { "pipeline_config": _repo_path(config_path), @@ -876,7 +1006,9 @@ def _cache_key(request: StandardRequest, outputs: OutputOptions) -> str: ) -def _required_result_paths(outputs: OutputOptions, request: StandardRequest) -> tuple[str, ...]: +def _required_result_paths( + outputs: OutputOptions, request: StandardRequest +) -> tuple[str, ...]: required: list[str] = ["links_path"] if outputs.metadata: required.append("run_metadata_path") @@ -899,7 +1031,10 @@ def run_facilities_pipeline( output_config = config["output"] t0 = time.perf_counter() - admin_source = CSAdminSource(_repo_path(config["sources"]["admin_gpkg"]), table_name=config["sources"]["admin_layer"]) + admin_source = CSAdminSource( + _repo_path(config["sources"]["admin_gpkg"]), + table_name=config["sources"]["admin_layer"], + ) ( admin_selection, registration_scope, @@ -961,8 +1096,12 @@ def run_facilities_pipeline( village_service_values = village_service.drop(columns=["fid"], errors="ignore") village_service_gdf = admin_rows[["fid", "geometry"]].copy() village_service_gdf["_admin_key"] = village_service_gdf["fid"] - village_service_gdf = village_service_gdf.merge(village_service_values, on="_admin_key", how="left") - village_service_gdf = gpd.GeoDataFrame(village_service_gdf, geometry="geometry", crs=admin_rows.crs) + village_service_gdf = village_service_gdf.merge( + village_service_values, on="_admin_key", how="left" + ) + village_service_gdf = gpd.GeoDataFrame( + village_service_gdf, geometry="geometry", crs=admin_rows.crs + ) status_name, status_outputs = status_column_config(config) if status_name: village_service_gdf[status_name] = [ @@ -974,11 +1113,16 @@ def run_facilities_pipeline( # Order the GeoPackage columns by the classification schema, then append any # remaining columns (title, layer kind) so nothing is silently dropped. present = set(village_service_gdf.columns) - gpkg_value_columns = [column for column in _machine_output_columns(classification) if column in present] + gpkg_value_columns = [ + column + for column in _machine_output_columns(classification) + if column in present + ] gpkg_value_columns.extend( column for column in village_service_gdf.columns - if column not in set(gpkg_value_columns) | {"geometry", "_admin_key", status_name} + if column + not in set(gpkg_value_columns) | {"geometry", "_admin_key", status_name} ) if status_name and {"gpkg", "geoserver"} & status_outputs: gpkg_value_columns.insert(0, status_name) @@ -1031,7 +1175,9 @@ def run_facilities_pipeline( if outputs.gpkg or (request.publish.sync_to_geoserver and outputs.geoserver): # The GPKG table name becomes the GeoServer feature-type name, so it # must be the scoped layer name rather than a generic table name. - paths["gpkg_path"] = bundle.write_gpkg({layer_name: village_service_output_gdf}).as_posix() + paths["gpkg_path"] = bundle.write_gpkg( + {layer_name: village_service_output_gdf} + ).as_posix() paths["facility_points_gpkg_path"] = bundle.write_gpkg( { tehsil_facilities_layer: tehsil_facilities, @@ -1115,7 +1261,11 @@ def run_facilities_pipeline( layer_name, result, column_dictionary( - pd.DataFrame(village_service_output_gdf.drop(columns=["geometry"], errors="ignore")), + pd.DataFrame( + village_service_output_gdf.drop( + columns=["geometry"], errors="ignore" + ) + ), describe_machine, rename_machine, ), @@ -1135,7 +1285,11 @@ def run_facilities_pipeline( "readme_path": result.get("readme_path"), }, "geoserver": { - "status": geoserver.get("status") if isinstance(geoserver, Mapping) else "not_requested", + "status": ( + geoserver.get("status") + if isinstance(geoserver, Mapping) + else "not_requested" + ), "layers": published_layers, }, } @@ -1167,9 +1321,10 @@ def run_facilities_pipeline( raise RuntimeError( f"Database registration failed for layer {published['layer_name']!r}." ) - if update_layer_sync_status( - layer_id=layer_id, sync_to_geoserver=True - ) is None: + if ( + update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) + is None + ): raise RuntimeError( f"GeoServer sync status update failed for layer ID {layer_id}." ) @@ -1193,16 +1348,28 @@ def run_facilities_pipeline( "config_path": str(config_path), "outputs": { "village_properties": frame_profile( - pd.DataFrame(village_service_output_gdf.drop(columns=["geometry"], errors="ignore")), + pd.DataFrame( + village_service_output_gdf.drop( + columns=["geometry"], errors="ignore" + ) + ), describe_machine, rename_machine, ), "tehsil_facility_collection": frame_profile( - pd.DataFrame(tehsil_facilities.drop(columns=["geometry"], errors="ignore")), + pd.DataFrame( + tehsil_facilities.drop( + columns=["geometry"], errors="ignore" + ) + ), _point_column_describer, ), "village_nearest_facility_collection": frame_profile( - pd.DataFrame(village_nearest_facilities.drop(columns=["geometry"], errors="ignore")), + pd.DataFrame( + village_nearest_facilities.drop( + columns=["geometry"], errors="ignore" + ) + ), _point_column_describer, ), }, @@ -1224,7 +1391,18 @@ def run_facilities_request(payload: Mapping[str, Any]) -> dict[str, Any]: @app.task(bind=True, max_retries=3, default_retry_delay=60) -def generate_facilities_proximity_task(self, payload: Mapping[str, Any]): +def generate_facilities_proximity_task( + self, + state: str | None = None, + district: str | None = None, + block: str | None = None, + payload: Mapping[str, Any] | None = None, +): + if payload is None: + payload = api_request_payload( + {"state": state, "district": district, "block": block}, + overwrite=True, + ) return run_facilities_request(payload) @@ -1237,7 +1415,9 @@ def main() -> None: args = parser.parse_args() if not (args.state and args.district and args.tehsil): parser.error("--state, --district, and --tehsil are required") - request = _cli_request(args.state, args.district, args.tehsil, not args.no_geoserver) + request = _cli_request( + args.state, args.district, args.tehsil, not args.no_geoserver + ) result = run_facilities_pipeline(request) print(json.dumps(result, indent=2, default=str)) diff --git a/computing/misc/livestocks/pipeline.py b/computing/misc/livestocks/pipeline.py index a62bae05..ed374758 100644 --- a/computing/misc/livestocks/pipeline.py +++ b/computing/misc/livestocks/pipeline.py @@ -48,7 +48,7 @@ LIVESTOCK_CENSUS_20_CSV, LIVESTOCK_GEOSERVER_WORKSPACE, ) - +from utilities.pipelines import api_request_payload CONFIG_PATH = Path(__file__).with_name("livestocks_pipeline.yaml") ALGORITHM = "local-livestock-csv-admin-join" @@ -81,7 +81,9 @@ def _apply_source_defaults(config: Mapping[str, Any]) -> dict[str, Any]: return resolved -def _cli_request(state: str, district: str, tehsil: str, sync_to_geoserver: bool = True) -> StandardRequest: +def _cli_request( + state: str, district: str, tehsil: str, sync_to_geoserver: bool = True +) -> StandardRequest: return StandardRequest.from_mapping( { "scope": { @@ -119,7 +121,9 @@ def _schema(config: Mapping[str, Any]) -> dict[str, Any]: return load_config(_repo_path(path)) if path else {} -def _derive_livestock_metrics(frame: pd.DataFrame, schema: Mapping[str, Any]) -> pd.DataFrame: +def _derive_livestock_metrics( + frame: pd.DataFrame, schema: Mapping[str, Any] +) -> pd.DataFrame: derived = frame.copy() for _, animals in schema.get("livestock", {}).items(): for fields in animals.values(): @@ -127,27 +131,37 @@ def _derive_livestock_metrics(frame: pd.DataFrame, schema: Mapping[str, Any]) -> female = fields.get("female") total = fields.get("total") if male in derived.columns and female in derived.columns and total: - derived[total] = ( - pd.to_numeric(derived[male], errors="coerce").fillna(0) - + pd.to_numeric(derived[female], errors="coerce").fillna(0) - ) + derived[total] = pd.to_numeric(derived[male], errors="coerce").fillna( + 0 + ) + pd.to_numeric(derived[female], errors="coerce").fillna(0) for metric, spec in schema.get("derived_metrics", {}).items(): sources = spec.get("sources", []) if sources and all(source in derived.columns for source in sources): - derived[metric] = sum(pd.to_numeric(derived[source], errors="coerce").fillna(0) for source in sources) + derived[metric] = sum( + pd.to_numeric(derived[source], errors="coerce").fillna(0) + for source in sources + ) return derived -def _validate_livestock(frame: pd.DataFrame, config: Mapping[str, Any]) -> list[ValidationIssue]: +def _validate_livestock( + frame: pd.DataFrame, config: Mapping[str, Any] +) -> list[ValidationIssue]: return validate_numeric_range( frame, - columns=[column for column in config["metrics"]["count_columns"] if column in frame.columns], + columns=[ + column + for column in config["metrics"]["count_columns"] + if column in frame.columns + ], minimum=0, allow_null=True, ) -def _merge_admin_livestock(admin_rows, source_rows: pd.DataFrame, config: Mapping[str, Any]): +def _merge_admin_livestock( + admin_rows, source_rows: pd.DataFrame, config: Mapping[str, Any] +): admin = admin_rows.copy() attrs = source_rows.copy() attrs = attrs.drop(columns=["state_name", "district_name"], errors="ignore") @@ -160,7 +174,9 @@ def _merge_admin_livestock(admin_rows, source_rows: pd.DataFrame, config: Mappin ) -def _ordered_columns(frame: pd.DataFrame, config: Mapping[str, Any], columns: Mapping[str, list[str]]) -> list[str]: +def _ordered_columns( + frame: pd.DataFrame, config: Mapping[str, Any], columns: Mapping[str, list[str]] +) -> list[str]: ordered = [] ordered.extend([col for col in columns["location"] if col not in ordered]) ordered.extend([col for col in columns["metrics"] if col not in ordered]) @@ -168,19 +184,29 @@ def _ordered_columns(frame: pd.DataFrame, config: Mapping[str, Any], columns: Ma return [col for col in ordered if col in frame.columns] -def _focused_frame(frame: pd.DataFrame, value_columns: list[str], status_name: str | None) -> pd.DataFrame: +def _focused_frame( + frame: pd.DataFrame, value_columns: list[str], status_name: str | None +) -> pd.DataFrame: """Return the report CSV frame: admin columns, the status column, then the configured value columns for matched villages.""" - focused = admin_presentation_frame(frame.drop(columns=["geometry"], errors="ignore")) + focused = admin_presentation_frame( + frame.drop(columns=["geometry"], errors="ignore") + ) source = frame.set_index("fid", drop=False) if "fid" in frame.columns else frame output_rows: list[dict[str, Any]] = [] for _, admin_row in focused.iterrows(): row = admin_row.to_dict() admin_index = row.get("index") - values = source.loc[admin_index] if admin_index in source.index else pd.Series(dtype=object) + values = ( + source.loc[admin_index] + if admin_index in source.index + else pd.Series(dtype=object) + ) has_village_id = pd.notna(row.get("village_id")) - has_livestock = pd.notna(values.get("village_code")) if not values.empty else False + has_livestock = ( + pd.notna(values.get("village_code")) if not values.empty else False + ) status = STATUS_MATCHED if not has_village_id: status = STATUS_NO_VILLAGE_ID @@ -220,18 +246,26 @@ def _column_describer(schema: Mapping[str, Any], config: Mapping[str, Any]): f"20th Livestock Census (2019), {group_label} group." ) if fields.get("female"): - descriptions[fields["female"]] = f"Female {animal} count for the village, 20th Livestock Census (2019)." + descriptions[fields["female"]] = ( + f"Female {animal} count for the village, 20th Livestock Census (2019)." + ) if fields.get("male"): - descriptions[fields["male"]] = f"Male {animal} count for the village, 20th Livestock Census (2019)." + descriptions[fields["male"]] = ( + f"Male {animal} count for the village, 20th Livestock Census (2019)." + ) for metric, spec in (schema.get("derived_metrics") or {}).items(): sources = ", ".join(spec.get("sources", [])) label = spec.get("label", metric.replace("_", " ").title()) descriptions[metric] = f"{label}: sum of {sources}." - descriptions["village_code"] = "Census village code used to join livestock census records." + descriptions["village_code"] = ( + "Census village code used to join livestock census records." + ) return descriptions -def _overview(frame: pd.DataFrame, group_columns: list[str], config: Mapping[str, Any]) -> pd.DataFrame: +def _overview( + frame: pd.DataFrame, group_columns: list[str], config: Mapping[str, Any] +) -> pd.DataFrame: groups = [col for col in group_columns if col in frame.columns] if not groups: return pd.DataFrame() @@ -242,10 +276,14 @@ def _overview(frame: pd.DataFrame, group_columns: list[str], config: Mapping[str keys = (keys,) row = dict(zip(groups, keys)) row["admin_village_rows"] = int(len(group)) - row["matched_livestock_rows"] = int(group["village_code"].notna().sum()) if "village_code" in group else 0 + row["matched_livestock_rows"] = ( + int(group["village_code"].notna().sum()) if "village_code" in group else 0 + ) for metric in metrics: if metric in group.columns: - row[f"{metric}_sum"] = int(pd.to_numeric(group[metric], errors="coerce").fillna(0).sum()) + row[f"{metric}_sum"] = int( + pd.to_numeric(group[metric], errors="coerce").fillna(0).sum() + ) rows.append(row) return pd.DataFrame(rows) @@ -258,8 +296,12 @@ def _column_reference_lines(column_entries: list[Mapping[str, Any]]) -> list[str "| --- | --- | --- |", ] for entry in column_entries: - description = str(entry.get("description") or "").replace("|", "\\|").replace("\n", " ") - lines.append(f"| `{entry['column']}` | {entry.get('datatype', '')} | {description} |") + description = ( + str(entry.get("description") or "").replace("|", "\\|").replace("\n", " ") + ) + lines.append( + f"| `{entry['column']}` | {entry.get('datatype', '')} | {description} |" + ) lines.append("") return lines @@ -322,7 +364,9 @@ def _readme_lines( return lines -def _cache_input_signatures(config: Mapping[str, Any], config_path: str | Path) -> dict[str, dict[str, Any]]: +def _cache_input_signatures( + config: Mapping[str, Any], config_path: str | Path +) -> dict[str, dict[str, Any]]: sources = config.get("sources", {}) paths: dict[str, str | Path] = { "pipeline_config": _repo_path(config_path), @@ -348,7 +392,9 @@ def _cache_key(request: StandardRequest, outputs: OutputOptions) -> str: ) -def _required_result_paths(outputs: OutputOptions, request: StandardRequest) -> tuple[str, ...]: +def _required_result_paths( + outputs: OutputOptions, request: StandardRequest +) -> tuple[str, ...]: required: list[str] = ["links_path"] if outputs.metadata: required.append("run_metadata_path") @@ -373,7 +419,10 @@ def run_livestocks_pipeline( output_config = config["output"] t0 = time.perf_counter() - admin_source = CSAdminSource(_repo_path(config["sources"]["admin_gpkg"]), table_name=config["sources"]["admin_layer"]) + admin_source = CSAdminSource( + _repo_path(config["sources"]["admin_gpkg"]), + table_name=config["sources"]["admin_layer"], + ) include_geometry = outputs.gpkg or request.publish.sync_to_geoserver ( admin_selection, @@ -410,7 +459,9 @@ def run_livestocks_pipeline( t0 = time.perf_counter() sidecar = _sidecar(config) sidecar_status = sidecar.materialize() - source_rows = sidecar.fetch_by_values(config["keys"]["source_join_key"], admin_selection.pc11_village_ids) + source_rows = sidecar.fetch_by_values( + config["keys"]["source_join_key"], admin_selection.pc11_village_ids + ) timings["read_livestock_seconds"] = round(time.perf_counter() - t0, 3) t0 = time.perf_counter() @@ -419,20 +470,36 @@ def run_livestocks_pipeline( joined = _derive_livestock_metrics(joined, schema) status_name, status_outputs = status_column_config(config) if status_name: - village_codes = joined["village_code"] if "village_code" in joined.columns else pd.Series([None] * len(joined), index=joined.index) + village_codes = ( + joined["village_code"] + if "village_code" in joined.columns + else pd.Series([None] * len(joined), index=joined.index) + ) joined[status_name] = [ - STATUS_NO_VILLAGE_ID - if pd.isna(village_id) - else (STATUS_MATCHED if pd.notna(village_code) else STATUS_NO_DATA) + ( + STATUS_NO_VILLAGE_ID + if pd.isna(village_id) + else (STATUS_MATCHED if pd.notna(village_code) else STATUS_NO_DATA) + ) for village_id, village_code in zip(joined["village_id"], village_codes) ] ordered = _ordered_columns(joined, config, columns) - villages_frame = admin_output_frame(joined.drop(columns=["geometry"], errors="ignore"), value_columns=ordered) - matched_rows = int(villages_frame["village_code"].notna().sum()) if "village_code" in villages_frame else 0 - gpkg_value_columns = [column for column in schema.get("gpkg_columns", []) if column in joined.columns] or ordered + villages_frame = admin_output_frame( + joined.drop(columns=["geometry"], errors="ignore"), value_columns=ordered + ) + matched_rows = ( + int(villages_frame["village_code"].notna().sum()) + if "village_code" in villages_frame + else 0 + ) + gpkg_value_columns = [ + column for column in schema.get("gpkg_columns", []) if column in joined.columns + ] or ordered if status_name and {"gpkg", "geoserver"} & status_outputs: gpkg_value_columns = [status_name, *gpkg_value_columns] - gpkg_frame = admin_output_frame(joined, value_columns=gpkg_value_columns, include_geometry=True) + gpkg_frame = admin_output_frame( + joined, value_columns=gpkg_value_columns, include_geometry=True + ) gpkg_frame = normalize_unicode_frame(gpkg_frame) describe = _column_describer(schema, config) timings["build_outputs_seconds"] = round(time.perf_counter() - t0, 3) @@ -453,7 +520,9 @@ def run_livestocks_pipeline( "layer_name": layer_name, "rows": int(len(villages_frame)), "matched_rows": matched_rows, - "join_coverage": round(matched_rows / len(villages_frame), 6) if len(villages_frame) else 0, + "join_coverage": ( + round(matched_rows / len(villages_frame), 6) if len(villages_frame) else 0 + ), "validation_issues": [asdict(issue) for issue in validation_issues], "sidecar": sidecar_status, "admin_created_indexes": admin_selection.created_indexes, @@ -523,10 +592,10 @@ def run_livestocks_pipeline( is_override=request.publish.overwrite, ) if layer_id is None: - raise RuntimeError(f"Database registration failed for layer {layer_name!r}.") - if update_layer_sync_status( - layer_id=layer_id, sync_to_geoserver=True - ) is None: + raise RuntimeError( + f"Database registration failed for layer {layer_name!r}." + ) + if update_layer_sync_status(layer_id=layer_id, sync_to_geoserver=True) is None: raise RuntimeError( f"GeoServer sync status update failed for layer ID {layer_id}." ) @@ -548,7 +617,9 @@ def run_livestocks_pipeline( issues=validation_issues, geoserver=geoserver, column_entries=column_dictionary( - pd.DataFrame(gpkg_frame.drop(columns=["geometry"], errors="ignore")), + pd.DataFrame( + gpkg_frame.drop(columns=["geometry"], errors="ignore") + ), describe, ), ) @@ -574,7 +645,9 @@ def run_livestocks_pipeline( "config_path": str(config_path), "outputs": { "villages": frame_profile( - pd.DataFrame(gpkg_frame.drop(columns=["geometry"], errors="ignore")), + pd.DataFrame( + gpkg_frame.drop(columns=["geometry"], errors="ignore") + ), describe, ), }, @@ -596,9 +669,19 @@ def run_livestocks_request(payload: Mapping[str, Any]) -> dict[str, Any]: @app.task(bind=True) -def generate_livestocks_layer_task(self, payload: Mapping[str, Any]) -> dict[str, Any]: +def generate_livestocks_layer_task( + self, + state: str | None = None, + district: str | None = None, + block: str | None = None, + payload: Mapping[str, Any] | None = None, +) -> dict[str, Any]: """Generate livestock census outputs for a standard request payload.""" - + if payload is None: + payload = api_request_payload( + {"state": state, "district": district, "block": block}, + overwrite=True, + ) return run_livestocks_request(payload) From 53a9a4fa33324d112221f67919f139a92258f44a Mon Sep 17 00:00:00 2001 From: CoRE Stack Date: Tue, 11 Aug 2026 18:55:41 +0530 Subject: [PATCH 114/120] add ruff --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5b42d133..ad161aa2 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ AGENTS.md CLAUDE.md .codex/ .cursor/ +.ruff_cache From 2cde0418978c4d9baf3923e42b5d1e0a25404429 Mon Sep 17 00:00:00 2001 From: pawangramvaani Date: Tue, 11 Aug 2026 06:52:37 -0700 Subject: [PATCH 115/120] correct dataset name --- computing/misc/facilities/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/computing/misc/facilities/pipeline.py b/computing/misc/facilities/pipeline.py index 9b425b7e..d2295be6 100644 --- a/computing/misc/facilities/pipeline.py +++ b/computing/misc/facilities/pipeline.py @@ -1303,7 +1303,7 @@ def run_facilities_pipeline( dataset_name = ( output_config.get("dataset_name", "Facilities Proximity") if role == "village_properties" - else output_config.get("points_dataset_name", "Facilities Points") + else output_config.get("points_dataset_name", "Facilities Proximity") ) layer_id = save_layer_info_to_db( state=state, From 10d1c8d5ea693215922e08f6d783c2db62860fe9 Mon Sep 17 00:00:00 2001 From: pawangramvaani Date: Tue, 11 Aug 2026 07:09:32 -0700 Subject: [PATCH 116/120] fix dataset name --- computing/misc/facilities/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/computing/misc/facilities/pipeline.py b/computing/misc/facilities/pipeline.py index d2295be6..40acc71c 100644 --- a/computing/misc/facilities/pipeline.py +++ b/computing/misc/facilities/pipeline.py @@ -1311,7 +1311,7 @@ def run_facilities_pipeline( block=block, layer_name=published["layer_name"], asset_id="not applicable: local compute GeoServer layer", - dataset_name=dataset_name, + dataset_name="Facilities Proximity", algorithm=ALGORITHM, algorithm_version=ALGORITHM_VERSION, misc={"is_generated_locally": True}, From 8f1b976b688314f9d980969ae2d513fed510788a Mon Sep 17 00:00:00 2001 From: aman verma Date: Wed, 12 Aug 2026 08:54:01 +0000 Subject: [PATCH 117/120] tree in grassland update --- computing/tree_in_grassland/tree_in_grassland.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/computing/tree_in_grassland/tree_in_grassland.py b/computing/tree_in_grassland/tree_in_grassland.py index fecdd40d..7da68ad4 100644 --- a/computing/tree_in_grassland/tree_in_grassland.py +++ b/computing/tree_in_grassland/tree_in_grassland.py @@ -33,7 +33,7 @@ ) -def tree_in_grassland_for_AEZ(aez_no, gee_account_id=7): +def tree_in_grassland_for_AEZ(aez_no, start_year=None, end_year=None, gee_account_id=7): ee_initialize(gee_account_id) aez = ee.FeatureCollection(AEZ) mwses = ee.FeatureCollection(MWS_DATASET) @@ -47,6 +47,8 @@ def tree_in_grassland_for_AEZ(aez_no, gee_account_id=7): roi=roi, asset_suffix=asset_suffix, asset_folder_list=asset_folder_list, + start_year=start_year, + end_year=end_year, gee_account_id=gee_account_id, app_type="tree_in_grassland", sync_to_db=False, @@ -57,9 +59,9 @@ def tree_in_grassland_for_AEZ(aez_no, gee_account_id=7): @app.task(bind=True) def generate_tree_in_grassland_layer( self, - state, - district, - block, + state=None, + district=None, + block=None, roi=None, asset_suffix=None, asset_folder_list=None, From 76a4d6defbecf3c6fef270dd31f81262ff9da644 Mon Sep 17 00:00:00 2001 From: Ankit Kumar Date: Thu, 13 Aug 2026 16:53:42 +0530 Subject: [PATCH 118/120] misc updated --- computing/lulc/lulc_vector_local.py | 2 -- computing/lulc_X_terrain/lulc_on_plain_cluster_local.py | 1 - computing/lulc_X_terrain/lulc_on_slope_cluster_local.py | 1 - 3 files changed, 4 deletions(-) diff --git a/computing/lulc/lulc_vector_local.py b/computing/lulc/lulc_vector_local.py index be523cd0..689f1a8a 100644 --- a/computing/lulc/lulc_vector_local.py +++ b/computing/lulc/lulc_vector_local.py @@ -156,8 +156,6 @@ def run_lulc_vector_local( "start_year": start_year, "end_year": end_year, "is_generated_locally": True, - "geoserver_available": geoserver_ok, - "geoserver_sync_response": geoserver_response, }, algorithm=LOCAL_ALGORITHM, algorithm_version=LOCAL_ALGORITHM_VERSION, diff --git a/computing/lulc_X_terrain/lulc_on_plain_cluster_local.py b/computing/lulc_X_terrain/lulc_on_plain_cluster_local.py index 5ae5dd91..f15dc51a 100644 --- a/computing/lulc_X_terrain/lulc_on_plain_cluster_local.py +++ b/computing/lulc_X_terrain/lulc_on_plain_cluster_local.py @@ -355,7 +355,6 @@ def run_lulc_on_plain_cluster_local( "start_year": start_year, "end_year": end_year, "is_generated_locally": True, - "geoserver_available": geoserver_ok, }, ) logger.info("Saved layer metadata to DB: layer_id=%s", layer_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 index 35db91e4..f306d57e 100644 --- a/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py +++ b/computing/lulc_X_terrain/lulc_on_slope_cluster_local.py @@ -349,7 +349,6 @@ def run_lulc_on_slope_cluster_local( "start_year": start_year, "end_year": end_year, "is_generated_locally": True, - "geoserver_available": geoserver_ok, }, ) logger.info("Saved layer metadata to DB: layer_id=%s", layer_id) From 408b53aef8492abccca9346467ea48f382909e1a Mon Sep 17 00:00:00 2001 From: Kapil Dadheech Date: Fri, 14 Aug 2026 11:24:11 +0530 Subject: [PATCH 119/120] Merge sync/async layer generation and STAC auto-trigger onto feature/tame. Bring SYNC_LAYER request mode, STAC response collection, LayerMapping resolution/migration, and clearer STAC skip logging without a full conflict-heavy merge of feature/merge-sync-local. --- .env.example | 5 + computing/api.py | 43 ++ computing/celery_task_logging.py | 69 +++ computing/migrations/0004_layermapping.py | 71 +++ computing/signals.py | 144 ++---- computing/stac_layer_resolution.py | 232 +++++++++ computing/utils.py | 14 + nrm_app/celery.py | 3 + nrm_app/settings.py | 9 + utilities/layer_generation_logging.py | 153 ++++++ utilities/layer_generation_mode.py | 552 ++++++++++++++++++++++ utilities/stac_spec_collector.py | 93 ++++ 12 files changed, 1287 insertions(+), 101 deletions(-) create mode 100644 computing/celery_task_logging.py create mode 100644 computing/migrations/0004_layermapping.py create mode 100644 computing/stac_layer_resolution.py create mode 100644 utilities/layer_generation_logging.py create mode 100644 utilities/layer_generation_mode.py create mode 100644 utilities/stac_spec_collector.py diff --git a/.env.example b/.env.example index d8190878..6e262998 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ DB_PASSWORD=xxxxxxx # Django settings DEBUG=xxxxxxx SECRET_KEY=xxxxxxx +LAYER_GENERATION_SYNC_MODE=False +SYNC_LAYER=False +STAC_UPLOAD_TO_S3=False USERNAME_GESDISC=xxxxxxx PASSWORD_GESDISC=xxxxxxx @@ -43,6 +46,8 @@ GEE_SERVICE_ACCOUNT_KEY_PATH=xxxxxxx GEE_HELPER_SERVICE_ACCOUNT_KEY_PATH=xxxxxxx GEE_SERVICE_ACCOUNT_KEY_NRM_WORK_PATH=xxxxxxx GEE_DATASETS_SERVICE_ACCOUNT_KEY_PATH=xxxx +GEE_STORAGE_PROJECT=ee-corestackdev +GEE_STORAGE_PROJECT_HELPER=core-stack-dev-2 FERNET_KEY=xxx # Misc diff --git a/computing/api.py b/computing/api.py index 50f0693e..6639ea8e 100644 --- a/computing/api.py +++ b/computing/api.py @@ -1,4 +1,5 @@ import json +import inspect import logging import os @@ -47,6 +48,12 @@ from utilities.constants import KML_PATH from utilities.gee_utils import check_gee_task_status, download_gee_layer from utilities.pipelines import api_request_payload +from utilities.layer_generation_mode import ( + sync_layer_generation_if_enabled, +) +from utilities.layer_generation_logging import ( + layer_generation_api_logging, +) from .STAC_specs.stac_collection import generate_stac_collection_task from .clart.clart import generate_clart_layer from .clart.fes_clart_to_geoserver import generate_fes_clart_layer @@ -2947,3 +2954,39 @@ def generate_ltp_stp_change(request): except Exception as e: print("Exception in generate_ltp_stp api :: ", e) return Response({"Exception": e}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +def _auto_discover_computing_api_views(namespace): + """Auto-wrap request handlers for sync layer generation + STAC enrichment.""" + discovered = [] + for name, fn in namespace.items(): + if name.startswith("_") or not callable(fn): + continue + if getattr(fn, "__module__", None) != __name__: + continue + if getattr(fn, "__layer_generation_sync_wrapped__", False): + continue + try: + target = inspect.unwrap(fn) + sig = inspect.signature(target) + except (OSError, TypeError, ValueError): + continue + + params = list(sig.parameters.values()) + if len(params) == 0: + continue + first_param = params[0] + if first_param.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) and first_param.name == "request": + discovered.append(name) + return discovered + + +for _view_name in _auto_discover_computing_api_views(globals()): + wrapped = sync_layer_generation_if_enabled( + layer_generation_api_logging(globals()[_view_name]) + ) + wrapped.__layer_generation_sync_wrapped__ = True + globals()[_view_name] = wrapped diff --git a/computing/celery_task_logging.py b/computing/celery_task_logging.py new file mode 100644 index 00000000..cfb7db99 --- /dev/null +++ b/computing/celery_task_logging.py @@ -0,0 +1,69 @@ +""" +Celery signal handlers for layer-generation task observability. +""" + +import logging + +from celery.signals import task_failure, task_postrun, task_prerun + +logger = logging.getLogger("core_stack.layer_generation") + + +@task_prerun.connect +def log_layer_task_prerun( + sender=None, task_id=None, task=None, args=None, kwargs=None, **extra +): + logger.info( + "Celery task start | name=%s id=%s args=%s kwargs=%s", + getattr(sender, "name", sender), + task_id, + args, + kwargs, + ) + + +@task_postrun.connect +def log_layer_task_postrun( + sender=None, + task_id=None, + task=None, + args=None, + kwargs=None, + retval=None, + state=None, + **extra, +): + logger.info( + "Celery task finished | name=%s id=%s state=%s retval=%s", + getattr(sender, "name", sender), + task_id, + state, + retval, + ) + + +@task_failure.connect +def log_layer_task_failure( + sender=None, + task_id=None, + exception=None, + args=None, + kwargs=None, + traceback=None, + einfo=None, + **extra, +): + tb = None + if einfo is not None: + tb = getattr(einfo, "traceback", None) + if not tb: + tb = traceback + logger.error( + "Celery task failed | name=%s id=%s args=%s kwargs=%s exception=%s\n%s", + getattr(sender, "name", sender), + task_id, + args, + kwargs, + exception, + tb or "", + ) diff --git a/computing/migrations/0004_layermapping.py b/computing/migrations/0004_layermapping.py new file mode 100644 index 00000000..d2243d7c --- /dev/null +++ b/computing/migrations/0004_layermapping.py @@ -0,0 +1,71 @@ +# Generated for LayerMapping STAC registry + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("computing", "0003_dataset_can_be_empty_dataset_is_active"), + ] + + operations = [ + migrations.CreateModel( + name="LayerMapping", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ( + "display_name", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "layer_type", + models.CharField( + choices=[ + ("vector", "Vector"), + ("raster", "Raster"), + ("point", "Point"), + ("custom", "Custom"), + ], + max_length=16, + ), + ), + ("layer_name", models.CharField(db_index=True, max_length=255)), + ( + "spatial_resolution_in_meters", + models.FloatField(blank=True, null=True), + ), + ( + "ee_layer_name", + models.CharField(blank=True, default="", max_length=255), + ), + ("db_dataset_name", models.CharField(db_index=True, max_length=255)), + ( + "geoserver_workspace_name", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "geoserver_layer_name", + models.CharField(blank=True, default="", max_length=511), + ), + ( + "start_year", + models.CharField(blank=True, default="", max_length=16), + ), + ("end_year", models.CharField(blank=True, default="", max_length=16)), + ( + "style_file_url", + models.CharField(blank=True, default="", max_length=1024), + ), + ("theme", models.CharField(blank=True, default="", max_length=255)), + ("auto_stac", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Layer Mapping", + "verbose_name_plural": "Layer Mappings", + "unique_together": {("layer_name", "layer_type", "ee_layer_name")}, + }, + ), + ] diff --git a/computing/signals.py b/computing/signals.py index e103c579..a52bdb1f 100644 --- a/computing/signals.py +++ b/computing/signals.py @@ -1,13 +1,4 @@ -"""Auto-trigger STAC collection generation when a Layer is synced to GeoServer. - -The handler resolves the saved `Layer` (which stores a *templated* GeoServer -layer name) back to the canonical STAC `layer_name` / `layer_type` via the -`LayerMapping` registry (sourced from `layer_mapping.csv`), then dispatches the -`generate_stac_collection_task` Celery task asynchronously. - -This removes the need for every layer-generation task to hardcode the STAC -parameters and to call STAC generation synchronously. -""" +"""Auto-trigger STAC collection generation when a Layer is synced to GeoServer.""" from __future__ import annotations @@ -16,117 +7,68 @@ from django.db.models.signals import post_save from django.dispatch import receiver -from computing.models import Layer, LayerMapping -from utilities.gee_utils import valid_gee_text - -log = logging.getLogger(__name__) +from computing.models import Layer +from computing.stac_layer_resolution import stac_task_kwargs_for_layer +from utilities.layer_generation_mode import ( + is_sync_layer_generation_context_active, + record_sync_stac_layer, +) +# Same logger as layer task steps so Apache/WSGI error logs show STAC skips. +log = logging.getLogger("core_stack.layer_generation") _STAC_QUEUE = "nrm" -def _format_geoserver_name(template: str, layer: Layer) -> str: - if not template: - return "" - misc = layer.misc or {} - try: - return template.format( - district=valid_gee_text(layer.district.district_name.lower()), - block=valid_gee_text(layer.block.tehsil_name.lower()), - state=valid_gee_text(layer.state.state_name.lower()), - start_year=str(misc.get("start_year", "") or ""), - end_year=str(misc.get("end_year", "") or ""), - ) - except (KeyError, IndexError, AttributeError): - return "" - - -def _resolve_mapping(layer: Layer) -> LayerMapping | None: - """Return the best matching LayerMapping for a saved Layer. - - Resolution is keyed on `dataset.name -> LayerMapping.db_dataset_name`. - For datasets that fan out into multiple STAC layers (e.g. Change Detection - has 5 sub-layers per dataset row), we disambiguate by formatting each - candidate's `geoserver_layer_name` template against the Layer's location - and year metadata and matching the result against `layer.layer_name`. - """ - if not layer.dataset_id: - return None - - dataset_name = (layer.dataset.name or "").strip() - if not dataset_name: - return None - - candidates = list( - LayerMapping.objects.filter(db_dataset_name=dataset_name, auto_stac=True) - ) - if not candidates: - return None - if len(candidates) == 1: - return candidates[0] +@receiver(post_save, sender=Layer, dispatch_uid="computing.signals.trigger_stac_on_geoserver_sync") +def trigger_stac_on_geoserver_sync(sender, instance: Layer, created, **kwargs): + if not instance.is_sync_to_geoserver: + return - layer_name = (layer.layer_name or "").strip() - if not layer_name: - return None + if instance.is_stac_specs_generated: + log.info( + "STAC auto-trigger: skip layer id=%s (is_stac_specs_generated already True)", + instance.id, + ) + return - matches = [ - c - for c in candidates - if _format_geoserver_name(c.geoserver_layer_name, layer) == layer_name - ] - if len(matches) == 1: - return matches[0] - if not matches: + task_kwargs = stac_task_kwargs_for_layer(instance) + if task_kwargs is None: + dataset_name = instance.dataset.name if instance.dataset_id else None log.warning( - "STAC auto-trigger: no LayerMapping match for layer id=%s dataset=%s name=%s", - layer.id, + "STAC auto-trigger: skip layer id=%s name=%s dataset=%s " + "(no LayerMapping / CSV match; run: python manage.py load_layer_mappings)", + instance.id, + instance.layer_name, dataset_name, - layer_name, ) - return None - - log.info( - "STAC auto-trigger: %d ambiguous LayerMapping matches for layer id=%s; picking first", - len(matches), - layer.id, - ) - return matches[0] - - -@receiver(post_save, sender=Layer, dispatch_uid="computing.signals.trigger_stac_on_geoserver_sync") -def trigger_stac_on_geoserver_sync(sender, instance: Layer, created, **kwargs): - if not instance.is_sync_to_geoserver or instance.is_stac_specs_generated: return - mapping = _resolve_mapping(instance) - if mapping is None: - return - - # Lazy import: avoids importing Celery / stac_collection at app-loading time. from computing.STAC_specs.stac_collection import generate_stac_collection_task - misc = instance.misc or {} - task_kwargs = dict( - layer_type=mapping.layer_type, - state=instance.state.state_name, - district=instance.district.district_name, - block=instance.block.tehsil_name, - layer_name=mapping.layer_name, - start_year=str(misc.get("start_year", "") or ""), - end_year=str(misc.get("end_year", "") or ""), - upload_to_s3=True, - layer_id=instance.id, - ) - log.info( "STAC auto-trigger: dispatching task for layer id=%s (%s/%s)", instance.id, - mapping.layer_type, - mapping.layer_name, + task_kwargs["layer_type"], + task_kwargs["layer_name"], ) try: - generate_stac_collection_task.apply_async(kwargs=task_kwargs, queue=_STAC_QUEUE) + if is_sync_layer_generation_context_active(): + generate_stac_collection_task.apply(kwargs=task_kwargs) + record_sync_stac_layer( + state=task_kwargs["state"], + district=task_kwargs["district"], + block=task_kwargs["block"], + layer_name=task_kwargs["layer_name"], + layer_type=task_kwargs["layer_type"], + start_year=task_kwargs["start_year"], + end_year=task_kwargs["end_year"], + ) + else: + generate_stac_collection_task.apply_async( + kwargs=task_kwargs, queue=_STAC_QUEUE + ) except Exception as exc: # noqa: BLE001 log.error( "STAC auto-trigger: failed to dispatch task for layer id=%s: %s", diff --git a/computing/stac_layer_resolution.py b/computing/stac_layer_resolution.py new file mode 100644 index 00000000..a3f6e6c7 --- /dev/null +++ b/computing/stac_layer_resolution.py @@ -0,0 +1,232 @@ +"""Resolve Layer rows to STAC generation parameters via LayerMapping.""" + +from __future__ import annotations + +import logging +import os +import re +from types import SimpleNamespace + +import pandas as pd +from django.conf import settings + +from computing.models import Layer, LayerMapping +from computing.STAC_specs import constants +from computing.STAC_specs.stac_collection import STACConfig +from utilities.gee_utils import valid_gee_text + +log = logging.getLogger(__name__) + +_LULC_DATASETS = frozenset({"LULC_level_1", "LULC_level_2", "LULC_level_3"}) +_LULC_LAYER_RE = re.compile(r"^LULC_(\d{2})_(\d{2})_", re.IGNORECASE) + + +def _two_digit_hydrological_year(value) -> str: + text = str(value or "").strip() + if not text: + return "" + if len(text) == 2 and text.isdigit(): + return text + if len(text) == 4 and text.isdigit(): + return str(int(text) % 100).zfill(2) + return text + + +def lulc_years_from_layer_name(layer_name: str): + match = _LULC_LAYER_RE.match(layer_name or "") + if not match: + return None, None + return match.group(1), match.group(2) + + +def lulc_calendar_start_year(layer_name: str): + start_yy, _ = lulc_years_from_layer_name(layer_name) + if not start_yy: + return "" + return str(2000 + int(start_yy)) + + +def format_geoserver_name(template: str, layer: Layer) -> str: + if not template: + return "" + misc = layer.misc or {} + start_year = str(misc.get("start_year", "") or "") + end_year = str(misc.get("end_year", "") or "") + lulc_start, lulc_end = lulc_years_from_layer_name(layer.layer_name or "") + if lulc_start: + start_year = lulc_start + end_year = lulc_end or lulc_start + elif "{start_year}" in template: + start_year = _two_digit_hydrological_year(start_year) + end_year = _two_digit_hydrological_year(end_year or start_year) + try: + return template.format( + district=valid_gee_text(layer.district.district_name.lower()), + block=valid_gee_text(layer.block.tehsil_name.lower()), + state=valid_gee_text(layer.state.state_name.lower()), + start_year=start_year, + end_year=end_year, + ) + except (KeyError, IndexError, AttributeError): + return "" + + +def _mapping_from_csv_row(row) -> SimpleNamespace: + return SimpleNamespace( + layer_type=str(row["layer_type"]), + layer_name=str(row["layer_name"]), + ) + + +def _load_layer_mapping_csv(): + config = STACConfig() + path = config.layer_map_csv + if os.path.exists(path): + return pd.read_csv(path) + os.makedirs(os.path.dirname(path), exist_ok=True) + df = pd.read_csv(constants.LAYER_MAP_GITHUB_URL) + df.to_csv(path, index=False) + return df + + +def _resolve_mapping_from_csv(layer: Layer): + dataset_name = (layer.dataset.name or "").strip() + if not dataset_name: + return None + + try: + df = _load_layer_mapping_csv() + except Exception as exc: + log.warning("STAC mapping CSV load failed: %s", exc) + return None + + if "db_dataset_name" not in df.columns: + return None + + candidates = df[df["db_dataset_name"].astype(str).str.strip() == dataset_name] + if candidates.empty: + return None + if len(candidates) == 1: + return _mapping_from_csv_row(candidates.iloc[0]) + + layer_name = (layer.layer_name or "").strip() + if not layer_name: + return None + + matches = [] + for _, row in candidates.iterrows(): + template = str(row.get("geoserver_layer_name", "") or "") + if format_geoserver_name(template, layer) == layer_name: + matches.append(row) + if len(matches) == 1: + return _mapping_from_csv_row(matches[0]) + if not matches: + log.warning( + "STAC mapping CSV: no match for layer id=%s dataset=%s name=%s", + layer.id, + dataset_name, + layer_name, + ) + return None + return _mapping_from_csv_row(matches[0]) + + +def resolve_layer_mapping(layer: Layer): + if not layer.dataset_id: + return None + + dataset_name = (layer.dataset.name or "").strip() + if not dataset_name: + return None + + if dataset_name in _LULC_DATASETS and dataset_name != "LULC_level_3": + # Only level_3 is catalogued for STAC; level_1/level_2 share GeoServer naming. + return None + + # Only match within this dataset. Never fall back to all auto_stac rows — + # Admin Boundary / Drainage / NREGA all share `{district}_{block}` templates. + try: + candidates = list( + LayerMapping.objects.filter(db_dataset_name=dataset_name, auto_stac=True) + ) + except Exception as exc: # noqa: BLE001 — missing table / migrations + log.warning( + "STAC mapping DB unavailable (%s); falling back to CSV for dataset=%s", + exc, + dataset_name, + ) + return _resolve_mapping_from_csv(layer) + + if not candidates: + return _resolve_mapping_from_csv(layer) + + if len(candidates) == 1: + return candidates[0] + + layer_name = (layer.layer_name or "").strip() + if not layer_name: + return _resolve_mapping_from_csv(layer) + + matches = [ + c + for c in candidates + if format_geoserver_name(c.geoserver_layer_name, layer) == layer_name + ] + if len(matches) == 1: + return matches[0] + if not matches: + log.warning( + "STAC mapping DB: no match for layer id=%s dataset=%s name=%s", + layer.id, + dataset_name, + layer_name, + ) + return _resolve_mapping_from_csv(layer) + + log.info( + "STAC mapping DB: %d ambiguous matches for layer id=%s; picking first", + len(matches), + layer.id, + ) + return matches[0] + + +def stac_task_kwargs_for_layer(layer: Layer, mapping=None): + mapping = mapping or resolve_layer_mapping(layer) + if mapping is None: + return None + + misc = layer.misc or {} + start_year = str(misc.get("start_year", "") or "") + end_year = str(misc.get("end_year", "") or "") + lulc_year = lulc_calendar_start_year(layer.layer_name or "") + if lulc_year: + start_year = lulc_year + end_year = lulc_year + return { + "layer_type": mapping.layer_type, + "state": layer.state.state_name, + "district": layer.district.district_name, + "block": layer.block.tehsil_name, + "layer_name": mapping.layer_name, + "start_year": start_year, + "end_year": end_year, + "upload_to_s3": bool(getattr(settings, "STAC_UPLOAD_TO_S3", False)), + "overwrite_metadata": bool(getattr(settings, "STAC_OVERWRITE_METADATA", True)), + "layer_id": layer.id, + } + + +def stac_collect_target_for_layer(layer: Layer): + task_kwargs = stac_task_kwargs_for_layer(layer) + if task_kwargs is None: + return None + return { + "state": task_kwargs["state"], + "district": task_kwargs["district"], + "block": task_kwargs["block"], + "layer_name": task_kwargs["layer_name"], + "layer_type": task_kwargs["layer_type"], + "start_year": task_kwargs["start_year"], + "end_year": task_kwargs["end_year"], + } diff --git a/computing/utils.py b/computing/utils.py index 2cfa53dd..8ec3f274 100644 --- a/computing/utils.py +++ b/computing/utils.py @@ -1043,6 +1043,13 @@ def update_layer_sync_status( sync_to_geoserver=sync_to_geoserver, is_stac_specs_generated=is_stac_specs_generated, ) + if sync_to_geoserver: + try: + from utilities.layer_generation_mode import record_sync_layer_id + + record_sync_layer_id(layer_id) + except Exception: + pass return layer_id try: @@ -1066,6 +1073,13 @@ def update_layer_sync_status( f"Updated {update_fields} for layer ID: {layer_id} " f"(sync={sync_to_geoserver}, stac={is_stac_specs_generated})" ) + if sync_to_geoserver: + try: + from utilities.layer_generation_mode import record_sync_layer_id + + record_sync_layer_id(layer_id) + except Exception: + pass return layer_id except Exception as e: diff --git a/nrm_app/celery.py b/nrm_app/celery.py index 397563c1..e1c58af2 100755 --- a/nrm_app/celery.py +++ b/nrm_app/celery.py @@ -16,3 +16,6 @@ # Load task modules from all registered Django app configs. app.autodiscover_tasks(INSTALLED_APPS) + +# Register Celery task logging signals (prerun / postrun / failure). +import computing.celery_task_logging # noqa: E402, F401 diff --git a/nrm_app/settings.py b/nrm_app/settings.py index 3164650b..f714ca8b 100755 --- a/nrm_app/settings.py +++ b/nrm_app/settings.py @@ -282,6 +282,10 @@ def resolve_env_path(name, default="", *, trailing_sep=False): # Celery CELERY_TIMEZONE = "Asia/Kolkata" CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" +LAYER_GENERATION_SYNC_MODE = env.bool("LAYER_GENERATION_SYNC_MODE", default=False) +SYNC_LAYER = env.bool("SYNC_LAYER", default=False) +STAC_UPLOAD_TO_S3 = env.bool("STAC_UPLOAD_TO_S3", default=False) +STAC_OVERWRITE_METADATA = env.bool("STAC_OVERWRITE_METADATA", default=True) # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ @@ -342,6 +346,11 @@ def resolve_env_path(name, default="", *, trailing_sep=False): "level": "DEBUG", "propagate": False, }, + "core_stack.layer_generation": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, }, } diff --git a/utilities/layer_generation_logging.py b/utilities/layer_generation_logging.py new file mode 100644 index 00000000..979b19bf --- /dev/null +++ b/utilities/layer_generation_logging.py @@ -0,0 +1,153 @@ +""" +Structured logging for layer-generation HTTP APIs and Celery tasks. +""" + +import logging +import traceback +from functools import wraps + +logger = logging.getLogger("core_stack.layer_generation") + +LAYER_REQUEST_FIELDS = ( + "state", + "district", + "block", + "gee_account_id", + "start_date", + "end_date", + "start_year", + "end_year", + "workspace", + "layer_name", + "layer_id", + "asset_id", +) + + +def extract_request_context(request): + """Best-effort extraction of common layer-generation params from a DRF request.""" + context = {} + if request is None: + return context + try: + body = getattr(request, "data", None) or {} + for key in LAYER_REQUEST_FIELDS: + if key in body: + context[key] = body.get(key) + query_params = getattr(request, "query_params", None) + if query_params: + for key in LAYER_REQUEST_FIELDS: + if key in query_params: + context[key] = query_params.get(key) + except Exception: + pass + return context + + +def log_layer_api_start(view_name, request): + logger.info( + "Layer API start | api=%s context=%s", + view_name, + extract_request_context(request), + ) + + +def log_layer_api_failure(view_name, exc, request=None, extra=None): + logger.error( + "Layer API failed | api=%s context=%s extra=%s error=%s", + view_name, + extract_request_context(request), + extra or {}, + exc, + exc_info=exc, + ) + + +def format_api_error_payload(view_name, exc): + """Build a JSON-serializable error body for API clients and operators.""" + payload = { + "error": str(exc), + "api": view_name, + "exception_type": type(exc).__name__, + } + cause = getattr(exc, "__cause__", None) or getattr(exc, "__context__", None) + if cause: + payload["caused_by"] = f"{type(cause).__name__}: {cause}" + tb = traceback.format_exc() + if tb and tb.strip() not in ("NoneType: None", "NoneType: None\n"): + lines = [line for line in tb.strip().splitlines() if line.strip()] + if lines: + payload["traceback_tail"] = "\n".join(lines[-8:]) + return payload + + +def layer_api_error_response(view_name, exc, request=None): + """Log failure and return a DRF Response with troubleshooting fields.""" + from rest_framework import status + from rest_framework.response import Response + + log_layer_api_failure(view_name, exc, request=request) + return Response( + format_api_error_payload(view_name, exc), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +def layer_generation_api_logging(view_func): + """ + Log API entry, unhandled exceptions (with traceback), and 5xx responses + returned from inner try/except blocks. + """ + + @wraps(view_func) + def wrapper(request, *args, **kwargs): + view_name = view_func.__name__ + log_layer_api_start(view_name, request) + try: + response = view_func(request, *args, **kwargs) + except Exception as exc: + return layer_api_error_response(view_name, exc, request=request) + + status_code = getattr(response, "status_code", None) + if status_code is not None and status_code >= 500: + logger.error( + "Layer API returned %s | api=%s context=%s body=%s", + status_code, + view_name, + extract_request_context(request), + getattr(response, "data", None), + ) + return response + + return wrapper + + +def log_task_step(task_name, step, **context): + logger.info( + "Layer task step | task=%s step=%s context=%s", + task_name, + step, + context, + ) + + +def log_task_failure(task_name, exc, **context): + logger.error( + "Layer task failed | task=%s context=%s error=%s", + task_name, + context, + exc, + exc_info=exc, + ) + + +def task_location_context(state=None, district=None, block=None, **extra): + ctx = {} + if state is not None: + ctx["state"] = state + if district is not None: + ctx["district"] = district + if block is not None: + ctx["block"] = block + ctx.update(extra) + return ctx diff --git a/utilities/layer_generation_mode.py b/utilities/layer_generation_mode.py new file mode 100644 index 00000000..4588592c --- /dev/null +++ b/utilities/layer_generation_mode.py @@ -0,0 +1,552 @@ +from functools import wraps +from unittest.mock import patch +import logging +from contextvars import ContextVar + +from celery.app.task import Task +from django.conf import settings + +from utilities.layer_generation_logging import log_task_failure, log_task_step +from utilities.stac_spec_collector import collect_generated_stac_specs + +logger = logging.getLogger("core_stack.layer_generation") +_SYNC_LAYER_GENERATION_CONTEXT = ContextVar( + "sync_layer_generation_context", + default=False, +) +_SYNC_STAC_LAYERS = ContextVar("sync_stac_layers", default=None) +_SYNC_LAYER_IDS = ContextVar("sync_layer_ids", default=None) +_SYNC_LAYER_GENERATED = ContextVar("sync_layer_generated", default=None) +_SYNC_STAC_ERRORS = ContextVar("sync_stac_errors", default=None) + + +def _sync_layer_generation_enabled(): + return bool(getattr(settings, "LAYER_GENERATION_SYNC_MODE", False)) + + +def _get_request_value(data, *keys): + """Read first non-empty value from request body (supports aliases and form lists).""" + if data is None: + return None + for key in keys: + val = data.get(key) + if val is None: + continue + if isinstance(val, (list, tuple)): + val = val[0] if val else None + if val is not None and str(val).strip() != "": + return str(val).strip() + return None + + +def format_stac_for_api_response(stac_payload): + """Return STAC Feature item(s) for the API ``stac`` field, or ``{}`` when absent.""" + if not stac_payload: + return {} + if isinstance(stac_payload, dict) and stac_payload.get("type") == "Feature": + return stac_payload + if isinstance(stac_payload, list): + return stac_payload + if not isinstance(stac_payload, dict): + return {} + items = stac_payload.get("items") or [] + if len(items) == 1: + return items[0] + if len(items) > 1: + return items + return {} + + +def read_location_from_request(request): + """Resolve state/district/block from common request-body key aliases.""" + if request is None or not hasattr(request, "data"): + return None, None, None + data = request.data + state = _get_request_value(data, "state", "State", "STATE") + district = _get_request_value(data, "district", "District", "DISTRICT") + block = _get_request_value( + data, "block", "Block", "BLOCK", "tehsil", "Tehsil", "TEHSIL" + ) + return state, district, block + + +def record_sync_stac_error(message): + errors = _SYNC_STAC_ERRORS.get() + if errors is not None: + errors.append(str(message)) + + +def record_sync_layer_id(layer_id): + """Track a Layer row synced to GeoServer during the current sync request.""" + layer_ids = _SYNC_LAYER_IDS.get() + if layer_ids is None or not layer_id: + return + layer_ids.append(layer_id) + _SYNC_LAYER_GENERATED.set(True) + + +def record_sync_no_layer_generated(): + """Mark that the current sync request completed without creating a layer.""" + if _SYNC_LAYER_GENERATION_CONTEXT.get(): + _SYNC_LAYER_GENERATED.set(False) + + +def record_sync_stac_layer( + *, + state, + district, + block, + layer_name, + layer_type, + start_year="", + end_year="", +): + """Track a layer whose STAC was generated during the current sync request.""" + layers = _SYNC_STAC_LAYERS.get() + if layers is None: + return + layers.append( + { + "state": state, + "district": district, + "block": block, + "layer_name": layer_name, + "layer_type": layer_type, + "start_year": start_year, + "end_year": end_year, + } + ) + + +def _request_layer_targets(request): + """Build layer targets from explicit request fields (e.g. generate_stac_collection).""" + if request is None or not hasattr(request, "data"): + return [] + data = request.data + state = data.get("state") + district = data.get("district") + block = data.get("block") + layer_name = data.get("layer_name") + layer_type = data.get("layer_type") + if not all([state, district, block, layer_name, layer_type]): + return _lulc_stac_targets_from_request(request) + return [ + { + "state": state, + "district": district, + "block": block, + "layer_name": layer_name, + "layer_type": layer_type, + "start_year": data.get("start_year", "") or "", + "end_year": data.get("end_year", "") or "", + } + ] + + +def _lulc_stac_targets_from_request(request): + """STAC target for year-range LULC v3 clip APIs (land_use_land_cover_raster).""" + state, district, block = read_location_from_request(request) + if request is None or not hasattr(request, "data"): + return [] + data = request.data + start_year = data.get("start_year") + end_year = data.get("end_year") + if not all([state, district, block, start_year, end_year]): + return [] + return [ + { + "state": state, + "district": district, + "block": block, + "layer_name": "land_use_land_cover_raster", + "layer_type": "raster", + "start_year": str(start_year), + "end_year": str(end_year), + } + ] + + +def _merge_stac_targets(*target_lists): + merged = [] + seen = set() + for targets in target_lists: + for target in targets or []: + key = ( + target.get("state"), + target.get("district"), + target.get("block"), + target.get("layer_name"), + target.get("layer_type"), + target.get("start_year"), + target.get("end_year"), + ) + if key in seen: + continue + seen.add(key) + merged.append(target) + return merged + + +def _stac_targets_from_layer_ids(layer_ids): + from computing.models import Layer + from computing.stac_layer_resolution import stac_collect_target_for_layer + + targets = [] + seen = set() + for layer_id in layer_ids: + layer = Layer.objects.filter(id=layer_id).first() + if layer is None: + continue + target = stac_collect_target_for_layer(layer) + if target is None: + continue + key = ( + target["state"], + target["district"], + target["block"], + target["layer_name"], + target["layer_type"], + target["start_year"], + target["end_year"], + ) + if key in seen: + continue + seen.add(key) + targets.append(target) + return targets + + +def _ensure_stac_for_sync_layer_ids(layer_ids): + from computing.models import Layer + from computing.STAC_specs.stac_collection import generate_stac_collection_task + from computing.stac_layer_resolution import stac_task_kwargs_for_layer + + for layer_id in dict.fromkeys(layer_ids): + layer = Layer.objects.filter(id=layer_id).first() + if layer is None or layer.is_stac_specs_generated: + continue + task_kwargs = stac_task_kwargs_for_layer(layer) + if task_kwargs is None: + msg = ( + f"No STAC mapping for layer id={layer_id} " + f"(dataset={getattr(layer.dataset, 'name', None)}, " + f"layer_name={layer.layer_name}). " + "Run: python manage.py load_layer_mappings" + ) + logger.warning("Sync STAC skipped: %s", msg) + record_sync_stac_error(msg) + continue + result = generate_stac_collection_task.apply(kwargs=task_kwargs) + if getattr(result, "failed", lambda: False)(): + msg = f"STAC generation failed for layer id={layer_id}: {result.result}" + logger.error(msg) + record_sync_stac_error(msg) + continue + if not result.result: + msg = ( + f"STAC generation returned False for layer id={layer_id} " + f"({task_kwargs['layer_type']}/{task_kwargs['layer_name']}). " + "Check GeoServer layer exists and GEOSERVER_URL in .env." + ) + logger.error(msg) + record_sync_stac_error(msg) + continue + record_sync_stac_layer( + state=task_kwargs["state"], + district=task_kwargs["district"], + block=task_kwargs["block"], + layer_name=task_kwargs["layer_name"], + layer_type=task_kwargs["layer_type"], + start_year=task_kwargs["start_year"], + end_year=task_kwargs["end_year"], + ) + + +def _ensure_stac_for_layer_targets(layer_targets): + from computing.STAC_specs.stac_collection import generate_stac_collection_task + + for target in layer_targets: + existing = collect_generated_stac_specs(**target) + if existing.get("items"): + record_sync_stac_layer( + state=target["state"], + district=target["district"], + block=target["block"], + layer_name=target["layer_name"], + layer_type=target["layer_type"], + start_year=target.get("start_year", "") or "", + end_year=target.get("end_year", "") or "", + ) + continue + + task_kwargs = { + "layer_type": target["layer_type"], + "state": target["state"], + "district": target["district"], + "block": target["block"], + "layer_name": target["layer_name"], + "start_year": target.get("start_year", "") or "", + "end_year": target.get("end_year", "") or "", + "upload_to_s3": bool(getattr(settings, "STAC_UPLOAD_TO_S3", False)), + "overwrite_metadata": bool( + getattr(settings, "STAC_OVERWRITE_METADATA", True) + ), + } + result = generate_stac_collection_task.apply(kwargs=task_kwargs) + if getattr(result, "failed", lambda: False)(): + msg = ( + f"STAC generation failed for {target['layer_type']}/" + f"{target['layer_name']}: {result.result}" + ) + logger.error(msg) + record_sync_stac_error(msg) + continue + if not result.result: + msg = ( + f"STAC generation returned False for {target['layer_type']}/" + f"{target['layer_name']}. Check GeoServer layer exists and " + "GEOSERVER_URL in .env." + ) + logger.error(msg) + record_sync_stac_error(msg) + continue + record_sync_stac_layer( + state=target["state"], + district=target["district"], + block=target["block"], + layer_name=target["layer_name"], + layer_type=target["layer_type"], + start_year=target.get("start_year", "") or "", + end_year=target.get("end_year", "") or "", + ) + + +def _empty_stac_spec(state, district, block): + spec = collect_generated_stac_specs( + state=state, + district=district, + block=block, + layer_name="__none__", + layer_type="vector", + ) + spec["items"] = [] + spec["stac_status"] = "not_generated_yet" + return spec + + +def _collect_stac_for_request(request): + layer_targets = list(_SYNC_STAC_LAYERS.get() or []) + layer_ids = list(_SYNC_LAYER_IDS.get() or []) + + if not layer_targets and layer_ids: + _ensure_stac_for_sync_layer_ids(layer_ids) + layer_targets = _stac_targets_from_layer_ids(layer_ids) + + layer_targets = _merge_stac_targets( + layer_targets, + _request_layer_targets(request), + _lulc_stac_targets_from_request(request), + ) + + if layer_targets: + _ensure_stac_for_layer_targets(layer_targets) + + if not layer_targets: + state, district, block = read_location_from_request(request) + if all([state, district, block]): + if layer_ids: + record_sync_stac_error( + "Layer synced but STAC mapping was not resolved " + "(run python manage.py load_layer_mappings)." + ) + else: + record_sync_stac_error( + "Layer was not marked synced to GeoServer (GeoServer publish may have failed)." + ) + spec = _empty_stac_spec(state, district, block) + errors = list(_SYNC_STAC_ERRORS.get() or []) + if errors: + spec["stac_errors"] = errors + return spec + return None + + merged_items = [] + merged_spec = None + for target in layer_targets: + spec = collect_generated_stac_specs(**target) + if merged_spec is None: + merged_spec = spec + merged_items.extend(spec.get("items") or []) + + if merged_spec is None: + return None + + has_stac_data = bool(merged_items) + merged_spec["items"] = merged_items + merged_spec["stac_status"] = "available" if has_stac_data else "not_generated_yet" + if not has_stac_data and layer_targets: + record_sync_stac_error( + "STAC items not found on disk after generation. " + "Verify GEOSERVER_URL points at the GeoServer where the layer was published." + ) + errors = list(_SYNC_STAC_ERRORS.get() or []) + if errors: + merged_spec["stac_errors"] = errors + if layer_targets: + merged_spec["stac_layers"] = layer_targets + return merged_spec + + +def _read_request_mode(request): + if request is None or not hasattr(request, "data"): + return None + data = request.data + for key in ("layer_generation_mode", "layerGenerationMode", "layer_mode", "mode"): + value = data.get(key) + if value is None: + continue + if isinstance(value, (list, tuple)): + value = value[0] if value else None + if value is None: + continue + normalized = str(value).strip().lower() + if normalized: + return normalized + return None + + +def is_sync_layer_generation_request(request=None): + request_mode = _read_request_mode(request) + if request_mode is not None: + return request_mode == "sync" + return _sync_layer_generation_enabled() + + +def is_sync_layer_generation_context_active(): + return bool(_SYNC_LAYER_GENERATION_CONTEXT.get()) + + +def _apply_async_in_process( + task_self, + args=None, + kwargs=None, + task_id=None, + producer=None, + link=None, + link_error=None, + shadow=None, + **options, +): + """ + Drop-in replacement for Task.apply_async that executes the task immediately. + """ + task_args = args or () + task_kwargs = kwargs or {} + task_name = getattr(task_self, "name", "unknown_task") + log_task_step( + task_name, + "sync_execute_start", + args=task_args, + kwargs=task_kwargs, + ) + + eager_result = task_self.apply( + args=task_args, + kwargs=task_kwargs, + task_id=task_id, + link=link, + link_error=link_error, + **options, + ) + + if hasattr(eager_result, "failed") and eager_result.failed(): + err = getattr(eager_result, "result", "Unknown task failure") + tb = getattr(eager_result, "traceback", None) + log_task_failure( + task_name, + err if isinstance(err, BaseException) else RuntimeError(str(err)), + args=task_args, + kwargs=task_kwargs, + sync_mode=True, + ) + if tb: + logger.error( + "Layer task traceback (sync) | task=%s\n%s", + task_name, + tb, + ) + if isinstance(err, BaseException): + raise RuntimeError(f"{task_name} failed: {err}") from err + raise RuntimeError(f"{task_name} failed: {err}") + + if hasattr(eager_result, "result") and eager_result.result is False: + log_task_failure( + task_name, + RuntimeError("task returned False"), + args=task_args, + kwargs=task_kwargs, + sync_mode=True, + ) + raise RuntimeError(f"{task_name} returned False") + + log_task_step(task_name, "sync_execute_complete", result=eager_result.result) + return eager_result + + +def sync_layer_generation_if_enabled(view_func): + """ + Run Celery task dispatches synchronously for this view when the + LAYER_GENERATION_SYNC_MODE setting is enabled. + """ + + @wraps(view_func) + def wrapper(*args, **kwargs): + request = args[0] if args else kwargs.get("request") + if not is_sync_layer_generation_request(request): + logger.debug( + "Layer generation mode=async for view=%s", + getattr(view_func, "__name__", "unknown"), + ) + return view_func(*args, **kwargs) + logger.info( + "Layer generation mode=sync for view=%s", + getattr(view_func, "__name__", "unknown"), + ) + token = _SYNC_LAYER_GENERATION_CONTEXT.set(True) + stac_layers_token = _SYNC_STAC_LAYERS.set([]) + layer_ids_token = _SYNC_LAYER_IDS.set([]) + layer_generated_token = _SYNC_LAYER_GENERATED.set(None) + stac_errors_token = _SYNC_STAC_ERRORS.set([]) + try: + with patch.object(Task, "apply_async", _apply_async_in_process): + response = view_func(*args, **kwargs) + + try: + payload = getattr(response, "data", None) + if isinstance(payload, dict): + if _SYNC_LAYER_GENERATED.get() is False: + payload.pop("asset_id", None) + payload.pop("asset_ids", None) + payload["layer_generated"] = False + if "stac" not in payload: + stac_spec = _collect_stac_for_request(request) + payload["stac"] = format_stac_for_api_response(stac_spec) + if stac_spec is not None and stac_spec.get("stac_errors"): + payload["stac_errors"] = stac_spec["stac_errors"] + payload.pop("stac_spec", None) + if payload.get("status") == "initiated": + payload["status"] = "completed" + payload["Success"] = "Layer generation completed" + payload["message"] = "Layer generation completed" + except Exception: + logger.exception("Failed to enrich sync response with STAC specs") + finally: + _SYNC_STAC_ERRORS.reset(stac_errors_token) + _SYNC_LAYER_GENERATED.reset(layer_generated_token) + _SYNC_LAYER_IDS.reset(layer_ids_token) + _SYNC_STAC_LAYERS.reset(stac_layers_token) + _SYNC_LAYER_GENERATION_CONTEXT.reset(token) + + return response + + return wrapper diff --git a/utilities/stac_spec_collector.py b/utilities/stac_spec_collector.py new file mode 100644 index 00000000..1858b7ce --- /dev/null +++ b/utilities/stac_spec_collector.py @@ -0,0 +1,93 @@ +import json +import os + +from computing.STAC_specs.stac_collection import STACConfig, sanitize_text + + +def _read_json(path): + if not os.path.exists(path): + return None + with open(path) as f: + return json.load(f) + + +def build_stac_item_id(state, district, block, layer_name, year=""): + parts = [state, district, block, layer_name] + if year not in (None, ""): + parts.append(str(year)) + return "_".join(parts) + + +def collect_generated_stac_specs( + *, + state, + district, + block, + layer_name, + layer_type, + start_year="", + end_year="", +): + """Return STAC catalog metadata and items scoped to a single layer.""" + state = sanitize_text(str(state).lower()) + district = sanitize_text(str(district).lower()) + block = sanitize_text(str(block).lower()) + layer_name = sanitize_text(str(layer_name).lower()) + + stac_config = STACConfig() + tehsil_dirname = stac_config.tehsil_dirname + base_dir = stac_config.stac_files_dir + tehsil_dir = os.path.join(base_dir, tehsil_dirname) + state_dir = os.path.join(tehsil_dir, state) + district_dir = os.path.join(state_dir, district) + block_dir = os.path.join(district_dir, block) + + root_catalog = _read_json(os.path.join(base_dir, "catalog.json")) + tehsil_catalog = _read_json(os.path.join(tehsil_dir, "catalog.json")) + state_collection = _read_json(os.path.join(state_dir, "collection.json")) + district_collection = _read_json(os.path.join(district_dir, "collection.json")) + block_collection = _read_json(os.path.join(block_dir, "collection.json")) + + requested_item_ids = [] + if layer_type == "raster" and str(start_year).strip() and str(end_year).strip(): + requested_item_ids = [ + build_stac_item_id(state, district, block, layer_name, str(y)) + for y in range(int(start_year), int(end_year) + 1) + ] + elif str(start_year).strip(): + requested_item_ids = [ + build_stac_item_id( + state, district, block, layer_name, str(start_year).strip() + ) + ] + else: + requested_item_ids = [ + build_stac_item_id(state, district, block, layer_name, "") + ] + + item_prefix = build_stac_item_id(state, district, block, layer_name, "") + discovered_item_ids = [] + if os.path.isdir(block_dir): + for entry in os.listdir(block_dir): + if entry.startswith(item_prefix) and os.path.isdir( + os.path.join(block_dir, entry) + ): + discovered_item_ids.append(entry) + + item_ids = sorted(set(requested_item_ids + discovered_item_ids)) + + items = [] + for item_id in item_ids: + item_path = os.path.join(block_dir, item_id, f"{item_id}.json") + item_spec = _read_json(item_path) + if item_spec is not None: + items.append(item_spec) + + return { + "root_catalog": root_catalog, + "tehsil_catalog": tehsil_catalog, + "state_collection": state_collection, + "district_collection": district_collection, + "block_collection": block_collection, + "items": items, + } From df2740c7cd695b7baf96dbed6baef7f46549357d Mon Sep 17 00:00:00 2001 From: Kapil Dadheech Date: Fri, 14 Aug 2026 11:30:39 +0530 Subject: [PATCH 120/120] Fix SyntaxError in village_indicators from bad merge duplicates. --- stats_generator/village_indicators.py | 230 -------------------------- 1 file changed, 230 deletions(-) diff --git a/stats_generator/village_indicators.py b/stats_generator/village_indicators.py index 753bd1cf..01873ac8 100644 --- a/stats_generator/village_indicators.py +++ b/stats_generator/village_indicators.py @@ -42,7 +42,6 @@ def safe_val(v): if df_facilities.empty: return DEFAULT_VALUE.copy() - fac_row = df_facilities[df_facilities["village_id"] == v_id] fac_row = df_facilities[df_facilities["village_id"] == v_id] if fac_row.empty: return DEFAULT_VALUE.copy() @@ -50,51 +49,34 @@ def safe_val(v): row = fac_row.iloc[0] result = { - "essential_education_infra": row.get( - "essential_education_cat_distance_in_km", -1 "essential_education_infra": row.get( "essential_education_cat_distance_in_km", -1 ), - "higher_education_infra": safe_val( - row.get("higher_education_cat_distance_in_km", -1) "higher_education_infra": safe_val( row.get("higher_education_cat_distance_in_km", -1) ), - "essential_health_services": safe_val( - row.get("essential_health_cat_distance_in_km", -1) "essential_health_services": safe_val( row.get("essential_health_cat_distance_in_km", -1) ), - "advanced_health_services": safe_val( - row.get("advanced_health_cat_distance_in_km", -1) "advanced_health_services": safe_val( row.get("advanced_health_cat_distance_in_km", -1) ), - "public_distribution_system": safe_val( - row.get("essential_services_cat_distance_in_km", -1) "public_distribution_system": safe_val( row.get("essential_services_cat_distance_in_km", -1) ), - "financial_inclusion": safe_val( - row.get("financial_inclusion_cat_distance_in_km", -1) "financial_inclusion": safe_val( row.get("financial_inclusion_cat_distance_in_km", -1) ), "agri_market_access": safe_val(row.get("apmc_markets_cat_distance_in_km", -1)), "post_harvest_infra": safe_val(row.get("post_harvest_cat_distance_in_km", -1)), - "agri_market_access": safe_val(row.get("apmc_markets_cat_distance_in_km", -1)), - "post_harvest_infra": safe_val(row.get("post_harvest_cat_distance_in_km", -1)), "farmer_cooperatives_access": safe_val( row.get("cooperative_cat_distance_in_km", -1) - row.get("cooperative_cat_distance_in_km", -1) ), "livestock_management_centers": safe_val( row.get("livestock_cat_distance_in_km", -1) - row.get("livestock_cat_distance_in_km", -1) ), "agricultural_support_infrastructure": safe_val( row.get("agri_support_infra_cat_distance_in_km", -1) - row.get("agri_support_infra_cat_distance_in_km", -1) ), } @@ -239,155 +221,9 @@ def extract_livestock(df_livestock, v_id): # } -# def extract_antyodaya(df_antyodaya, v_id): -# """Extract social economic indicators for a given village ID.""" -# data_map = { -# "Low": 0, -# "Medium": 1, -# "High": 2, -# } -# -# def get_cluster_from_score(value): -# if pd.isna(value): -# return None -# -# nearest = min([0, 0.5, 1], key=lambda x: abs(value - x)) -# -# return { -# 0: 0, # Low -# 0.5: 1, # Medium -# 1: 2, # High -# }[nearest] -# -# village_row = df_antyodaya[df_antyodaya["village_id"] == v_id] -# coverage_accross_pds_cols = [ -# "pds_util_feat_value", -# "nfsa_cov_feat_value", -# "bpl_cov_feat_value", -# "pension_cov_feat_value", -# ] -# coverage_across_PDS_NFSA_BPL_and_Pension = ( -# village_row[coverage_accross_pds_cols].fillna(0).mean(axis=1).iloc[0] -# ) -# -# coverage_across_PDS_NFSA_BPL_and_Pension = get_cluster_from_score( -# coverage_across_PDS_NFSA_BPL_and_Pension -# ) -# print("coverage cluster", coverage_across_PDS_NFSA_BPL_and_Pension) -# -# return { -# "road_connectivity": data_map.get( -# village_row["road_connectivity_cat_cluster"].iloc[0], -9999 -# ), -# "electricity_supply": data_map.get( -# village_row["electricity_supply_to_msme_feat_cluster"].iloc[0], -9999 -# ), -# "housing_quality": data_map.get( -# village_row["housing_quality_cat_cluster"].iloc[0], -9999 -# ), -# "maternal_and_child_health_service_access": data_map.get( -# village_row["maternal_child_health_cat_cluster"].iloc[0], -9999 -# ), -# "water_and_sanitation_infrastructure": data_map.get( -# village_row["water_sanitation_cat_cluster"].iloc[0], -9999 -# ), -# "access_to_formal_banking_services": data_map.get( -# village_row["bank_feat_cluster"].iloc[0], -9999 -# ), -# "coverage_across_PDS_NFSA_BPL_and_Pension": coverage_across_PDS_NFSA_BPL_and_Pension, -# "institutionalization_strength": data_map.get( -# village_row["institutionalization_cat_cluster"].iloc[0], -9999 -# ), -# "civic_infrastructure": data_map.get( -# village_row["civic_infrastructure_cat_cluster"].iloc[0], -9999 -# ), -# "farm_employment": data_map.get( -# village_row["farm_employment_feat_cluster"].iloc[0], -9999 -# ), -# "forest-based_livelihood": data_map.get( -# village_row["livelihoods_forest_resources_cat_cluster"].iloc[0], -9999 -# ), -# "alternate_farming": data_map.get( -# village_row["livelihoods_alternative_farming_cat_cluster"].iloc[0], -9999 -# ), -# "fisheries_adoption": data_map.get( -# village_row["livelihoods_fisheries_cat_cluster"].iloc[0], -9999 -# ), -# "cottage_industry": data_map.get( -# village_row["livelihoods_cottage_traditional_industry_cat_cluster"].iloc[0], -# -9999, -# ), -# "livestock_management_service_quality": data_map.get( -# village_row["electricity_supply_to_msme_feat_cluster"].iloc[0], -9999 -# ), -# "common_pasture_access": data_map.get( -# village_row["common_pastures_feat_cluster"].iloc[0], -9999 -# ), -# "watershed_infrastructure_and_modern_irrigation": data_map.get( -# village_row["irrigation_infra_watershed_dev_feat_cluster"].iloc[0], -9999 -# ), -# "organic_farming_adoption": data_map.get( -# village_row["agriculture_organic_farming_cat_cluster"].iloc[0], -9999 -# ), -# "pension_coverage_and_soil_testing_services_adoption": data_map.get( -# village_row["pension_cov_feat_cluster"].iloc[0], -9999 -# ), -# } - - def extract_antyodaya(df_antyodaya, v_id): """Return finalized category and raw Antyodaya fields for one village. - Category clusters, category values, and raw values are copied directly from - the Excel row. No feature-level values or derived calculations are used. - """ - category_raw_columns = { - "institutionalization": ( - "availability_of_fpos_pacs", - "total_hhd", - "total_hhd_mobilized_into_pg", - "total_hhd_mobilized_into_shg", - "total_no_of_shg_promoted", - "total_shg", - ), - "social_protection": ( - "gp_total_hhd_eligible_under_nfsa", - "gp_total_hhd_receiving_food_grains_from_fps", - "total_hhd", - "total_hhd_availing_pension_under_nsap", - "total_hhd_having_bpl_cards", - ), - "civic_infrastructure": ( - "availability_of_panchayat_bhawan", - "availability_of_public_information_board", - "availability_of_public_library", - "is_post_office_available", - "total_no_of_elect_rep_oriented_under_rgsa", - "total_no_of_elect_rep_undergone_training_under_rgsa", - "total_no_of_elected_representatives", - ), - "financial_inclusion": ( - "is_atm_available", - "is_bank_available", - "is_bank_buss_correspondent_with_internet", - "total_hhd", - "total_hhd_availing_pmjdy_bank_ac", - "total_shg", - "total_shg_accessed_bank_loans", - ), - "energy_access": ( - "availability_of_elect_supply_to_msme", - "availablility_hours_of_domestic_electricity", - "total_hhd", - "total_hhd_with_clean_energy", - ), - "road_connectivity": ( - "availability_of_internal_pucca_road", - "availability_of_public_transport", - "availability_of_railway_station", - "is_village_connected_to_all_weather_road", - """Return finalized category and raw Antyodaya fields for one village. - Category clusters, category values, and raw values are copied directly from the Excel row. No feature-level values or derived calculations are used. """ @@ -437,13 +273,6 @@ def extract_antyodaya(df_antyodaya, v_id): "availability_of_railway_station", "is_village_connected_to_all_weather_road", ), - "housing_quality": ( - "total_hhd", - "total_hhd_availing_pmuy_benefits", - "total_hhd_got_benefit_under_state_housing_scheme", - "total_hhd_have_got_pmay_house", - "total_hhd_in_pmay_permanent_wait_list", - "total_hhd_with_kuccha_wall_kuccha_roof", "housing_quality": ( "total_hhd", "total_hhd_availing_pmuy_benefits", @@ -497,66 +326,7 @@ def extract_antyodaya(df_antyodaya, v_id): "is_handloom", "total_hhd", "total_hhd_engaged_cottage_small_scale_units", - "maternal_child_health": ( - "availability_of_mother_child_health_facilities", - "gp_total_no_of_beneficiaries_receiving_benefits_under_pmjay", - "gp_total_no_of_eligible_beneficiaries_under_pmjay", - "is_aanganwadi_centre_available", - "is_early_childhood_edu_provided_in_anganwadi", - "total_anemic_pregnant_women", - "total_childs_aged_0_to_3_years", - "total_childs_aged_0_to_3_years_immunized", - "total_childs_aged_0_to_3_years_reg_under_aanganwadi", - "total_childs_aged_3_to_6_years_reg_under_aanganwadi", - "total_childs_categorized_non_stunted_as_per_icds", - "total_female_child_age_bw_0_6", - "total_hhd", - "total_hhd_registered_under_pmjay", - "total_male_child_age_bw_0_6", - "total_no_of_beneficiaries_receiving_benefits_under_pmmvy", - "total_no_of_children_in_icds_cas", - "total_no_of_eligible_beneficiaries_under_pmmvy", - "total_no_of_lactating_mothers", - "total_no_of_lactating_mothers_receiving_services_under_icds", - "total_no_of_newly_born_children", - "total_no_of_newly_born_underweight_children", - "total_no_of_pregnant_women", - "total_no_of_pregnant_women_receiving_services_under_icds", - "total_no_of_registered_children_in_anganwadi", - "total_no_of_women_delivered_babies_at_hospitals_registered_asha", - "total_no_of_young_anemic_children_6_59_months_in_icds_cas", - "total_underweight_child_age_under_6_years", ), - "water_sanitation": ( - "availability_of_drainage_system", - "availability_of_piped_tap_water", - "is_community_biogas_waste_recycle_for_production", - "is_community_waste_disposal_system", - "total_hhd", - "total_hhd_having_piped_water_connection", - "total_hhd_not_having_sanitary_latrines", - ), - "livelihoods_cottage_traditional_industry": ( - "availability_of_cottage_small_scale_units", - "is_handicrafts", - "is_handloom", - "total_hhd", - "total_hhd_engaged_cottage_small_scale_units", - ), - "livelihoods_employment": ( - "total_hhd", - "total_hhd_engaged_in_farm_activities", - ), - "livelihoods_forest_resources": ( - "availability_of_community_forest", - "availability_of_minor_forest_production", - "total_hhd", - "total_hhd_source_of_minor_forest_production", - ), - "livelihoods_common_resources": ("is_common_pastures_available",), - "livelihoods_alternative_farming": ( - "is_bee_farming", - "is_sericulture", "livelihoods_employment": ( "total_hhd", "total_hhd_engaged_in_farm_activities",