diff --git a/README.md b/README.md index 95606df..cbc7ba4 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,76 @@ Makefile # Dev workflow commands pip install -r requirements.txt ``` -### 2. Run Tests +### 2. (Optional) Stand Up PostGIS via Docker + +If you want to use the PostGIS-backed analyzer, bring up a local PostGIS container: + +```sh +docker run \ + --name suntrace-postgis \ + -e POSTGRES_DB=suntrace \ + -e POSTGRES_USER=pguser \ + -e POSTGRES_PASSWORD=pgpass \ + -p 5432:5432 \ + -d postgis/postgis:15-3.4 +``` + +Wait for the database to accept connections, then ensure the PostGIS extensions exist (the analyzer can do this for you, or run once inside the container): + +```sh +docker exec -it suntrace-postgis psql -U pguser -d suntrace -c "CREATE EXTENSION IF NOT EXISTS postgis;" +docker exec -it suntrace-postgis psql -U pguser -d suntrace -c "CREATE EXTENSION IF NOT EXISTS postgis_topology;" +``` + +### 3. Load Project Data into PostGIS + +With the container running and data available under `data/`, load all vector layers: + +```sh +python scripts/load_to_postgis.py \ + --data-dir data \ + --db-uri postgresql://pguser:pgpass@localhost:5432/suntrace +``` + +Requirements for the loader: + +- `ogr2ogr` (GDAL) must be installed on the host running the script. +```sh +brew install gdal +``` +- Python deps: `geopandas`, `pandas`, `sqlalchemy`. + +> The script scans `data/`, `data/lamwo_sentinel_composites/`, `data/viz_geojsons/`, and `data/sample_region_mudu/`, writing tables such as `public.lamwo_buildings`, `public.lamwo_roads`, `public.lamwo_tile_stats_ee_biomass`, etc. Ensure filenames follow the repository defaults so table names match the analyzer’s expectations. + +If you need the joined tile view, run inside a Python shell after the load: + +```python +from utils.GeospatialAnalyzer2 import GeospatialAnalyzer2 +analyzer = GeospatialAnalyzer2() +analyzer.create_joined_tiles( + tile_stats_table='public.lamwo_tile_stats_ee_biomass', + plain_tiles_table='public.lamwo_grid' +) +``` + +### 4. Configure the App to Use PostGIS + +Set the following in `.env` (already present in this repo by default): + +``` +SUNTRACE_USE_POSTGIS=1 +SUNTRACE_DATABASE_URI=postgresql+psycopg://pguser:pgpass@localhost:5432/suntrace +``` + +Restart the app after changing the env file. The factory will log which analyzer was initialized. + +### 5. Run Tests ```sh make test ``` -### 3. Start the Application (Local) +### 6. Start the Application (Local) ```sh uvicorn main:app --reload @@ -76,7 +139,7 @@ docker logs -f suntracte docker-compose up -d --build ``` -### 5. Access Frontend +### 7. Access Frontend Open [http://localhost:8080](http://localhost:8080) in your browser. diff --git a/app/core/config.py b/app/core/config.py index a1af4b1..84948f7 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -6,6 +6,7 @@ from functools import lru_cache from pydantic_settings import BaseSettings +from typing import Optional class Settings(BaseSettings): @@ -50,6 +51,12 @@ class Settings(BaseSettings): # Building sample limit for performance BUILDING_SAMPLE_LIMIT: int = 2000 + # Database settings + SUNTRACE_USE_POSTGIS: bool = False + SUNTRACE_DATABASE_URI: Optional[str] = None + + + class Config: env_file = ".env" case_sensitive = True diff --git a/app/services/geospatial.py b/app/services/geospatial.py index 5c0d33f..e077179 100644 --- a/app/services/geospatial.py +++ b/app/services/geospatial.py @@ -2,7 +2,6 @@ Geospatial analysis service """ -import json import time from uuid import uuid4 @@ -62,7 +61,7 @@ def get_map_layers(self) -> MapLayersResponse: try: # Get bounds for the map from the plain tiles logger.info("Fetching map layers data.") - bounds = self.analyzer._plain_tiles_gdf.total_bounds.tolist() + bounds = self.analyzer.get_layer_bounds("tiles") # Calculate center coordinates center = Coordinates( @@ -78,26 +77,20 @@ def get_map_layers(self) -> MapLayersResponse: ) # Convert minigrids to GeoJSON - candidate_minigrids_geo = json.loads( - self.analyzer._candidate_minigrids_gdf.to_crs("EPSG:4326").to_json() - ) + candidate_minigrids_geo = self.analyzer.get_layer_geojson("candidate_minigrids") # Get a sample of buildings to avoid performance issues - total_buildings = len(self.analyzer._buildings_gdf) - building_sample = ( - self.analyzer._buildings_gdf.sample( - min(settings.BUILDING_SAMPLE_LIMIT, total_buildings) - ) - if total_buildings > settings.BUILDING_SAMPLE_LIMIT - else self.analyzer._buildings_gdf + total_buildings = self.analyzer.get_layer_count("buildings") + buildings_geo = self.analyzer.get_layer_geojson( + "buildings", sample=settings.BUILDING_SAMPLE_LIMIT ) - buildings_geo = json.loads(building_sample.to_crs("EPSG:4326").to_json()) + sampled_buildings = len(buildings_geo.get("features", [])) # Create metadata metadata = { "total_buildings": total_buildings, - "sampled_buildings": len(building_sample), - "total_minigrids": len(self.analyzer._candidate_minigrids_gdf), + "sampled_buildings": sampled_buildings, + "total_minigrids": self.analyzer.get_layer_count("candidate_minigrids"), "coordinate_system": self.analyzer.target_geographic_crs, } logger.info("Map layers data fetched successfully.") diff --git a/configs/paths.py b/configs/paths.py index be40ad8..9e6e783 100644 --- a/configs/paths.py +++ b/configs/paths.py @@ -6,7 +6,7 @@ # Data paths DATA_DIR = PROJECT_ROOT / "data" -TILE_STATS_PATH = DATA_DIR / "Lamwo_Tile_Stats_EE.csv" +TILE_STATS_PATH = DATA_DIR / "Lamwo_Tile_Stats_EE_biomass.csv" PLAIN_TILES_PATH = DATA_DIR / "lamwo_sentinel_composites" / "lamwo_grid.geojson" VIZUALIZATION_DIR = DATA_DIR / "viz_geojsons" diff --git a/configs/stats.py b/configs/stats.py new file mode 100644 index 0000000..18562e6 --- /dev/null +++ b/configs/stats.py @@ -0,0 +1,14 @@ +"""Shared configuration for analyzer statistic columns.""" + +DEFAULT_TILE_STAT_COLUMNS = [ + "ndvi_mean", + "ndvi_med", + "ndvi_std", + "evi_med", + "elev_mean", + "slope_mean", + "par_mean", + "rain_total_mm", + "rain_mean_mm_day", + "cloud_free_days", +] diff --git a/main.py b/main.py index 648fcfc..a6ca27f 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,11 @@ -""" -Main FastAPI application entry point -""" +"""Main FastAPI application entry point.""" +import logging import sys from pathlib import Path + import uvicorn + from app.api.deps import create_application from app.core.config import get_settings @@ -15,6 +16,9 @@ sys.path.insert(0, str(project_root)) +logging.basicConfig(level=logging.INFO) +logging.getLogger("GeospatialAnalyzer2").setLevel(logging.INFO) + settings = get_settings() app = create_application() diff --git a/notebooks/Precomputed Aggregates.ipynb b/notebooks/Precomputed Aggregates.ipynb index 9add2ca..a5b7820 100644 --- a/notebooks/Precomputed Aggregates.ipynb +++ b/notebooks/Precomputed Aggregates.ipynb @@ -825,15 +825,6 @@ "stats_table.toList(10).getInfo()" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "D4Wk_r2_4Woi" - }, - "outputs": [], - "source": [] - }, { "cell_type": "code", "execution_count": null, diff --git a/notebooks/get_lwb.ipynb b/notebooks/get_lwb.ipynb new file mode 100644 index 0000000..f89e237 --- /dev/null +++ b/notebooks/get_lwb.ipynb @@ -0,0 +1,237 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "UgTU08-JYFKu" + }, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import geopandas as gpd\n", + "from shapely import geometry\n", + "from tqdm import tqdm\n", + "import os\n", + "import tempfile\n", + "import sys" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Add the src directory to the Python path\n", + "notebook_dir = os.getcwd() # Current working directory of the notebook\n", + "parent_dir = os.path.abspath(os.path.join(notebook_dir, '..'))\n", + "if parent_dir not in sys.path:\n", + " sys.path.append(parent_dir)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from google.colab import drive\n", + "\n", + "drive.mount('/content/drive')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "J1gDo3NKY3Jl" + }, + "outputs": [], + "source": [ + "AOI_PATH = '/content/drive/Shareddrives/Sunbird AI/Projects/GIZ Mini-grid Identification/Phase II/Data/administrative areas/UGA Lamwo district.gpkg'\n", + "lamwo = gpd.read_file(AOI_PATH)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "CvWuX2JQaEpR" + }, + "outputs": [], + "source": [ + "lamwo.geometry.iloc[0]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "4GbJ1y4YYHjG" + }, + "outputs": [], + "source": [ + "# Geometry copied from drive\n", + "\"\"\"aoi_geom = {\n", + " \"coordinates\": [\n", + " [\n", + " [-122.16484503187519, 47.69090474454916],\n", + " [-122.16484503187519, 47.6217555345674],\n", + " [-122.06529607517405, 47.6217555345674],\n", + " [-122.06529607517405, 47.69090474454916],\n", + " [-122.16484503187519, 47.69090474454916],\n", + " ]\n", + " ],\n", + " \"type\": \"Polygon\",\n", + "}\"\"\"\n", + "aoi_shape = lamwo.geometry.iloc[0]\n", + "minx, miny, maxx, maxy = aoi_shape.bounds\n", + "print(aoi_shape.bounds)\n", + "\n", + "output_fn = \"lamwo_building_footprints.geojson\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "tZ2gb9DyHv4K" + }, + "outputs": [], + "source": [ + "aglwb = 'https://services2.arcgis.com/g8WusZB13b9OegfU/arcgis/rest/services/Aboveground_Live_Woody_Biomass_Density/FeatureServer/0/query?where=1%3D1&outFields=tile_id,Mg_px_1_download,Shape__Area,Shape__Length&outSR=4326&f=json'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "Wla5YGCbTJX8" + }, + "outputs": [], + "source": [ + "gdf = gpd.read_file(aglwb)\n", + "gdf.head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "wpFQQ4LWTPp9" + }, + "outputs": [], + "source": [ + "intersecting_tiles_gdf = gdf[gdf.intersects(lamwo.geometry.iloc[0])]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "9cd63329" + }, + "outputs": [], + "source": [ + "import requests\n", + "import rasterio\n", + "\n", + "# Create a directory to save the downloaded rasters\n", + "output_raster_dir = '/content/downloaded_rasters'\n", + "os.makedirs(output_raster_dir, exist_ok=True)\n", + "\n", + "# Iterate through the intersecting tiles and download the rasters\n", + "downloaded_raster_paths = []\n", + "for index, row in intersecting_tiles_gdf.iterrows():\n", + " url = row[\"Mg_px_1_download\"]\n", + " tile_id = row[\"tile_id\"]\n", + " output_path = os.path.join(output_raster_dir, f\"{tile_id}.tif\")\n", + "\n", + " print(f\"Downloading {url} to {output_path}\")\n", + " try:\n", + " # Download the raster file\n", + " response = requests.get(url, stream=True)\n", + " response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx)\n", + "\n", + " with open(output_path, 'wb') as f:\n", + " for chunk in response.iter_content(chunk_size=8192):\n", + " f.write(chunk)\n", + "\n", + " downloaded_raster_paths.append(output_path)\n", + " print(f\"Successfully downloaded {tile_id}.tif\")\n", + "\n", + " except requests.exceptions.RequestException as e:\n", + " print(f\"Error downloading {url}: {e}\")\n", + " except Exception as e:\n", + " print(f\"An unexpected error occurred: {e}\")\n", + "\n", + "print(f\"\\nDownloaded {len(downloaded_raster_paths)} raster files to {output_raster_dir}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "s_Q_R9xbWl3L" + }, + "outputs": [], + "source": [ + "from rasterio.mask import mask\n", + "\n", + "# Create a directory to save the clipped rasters\n", + "output_clipped_raster_dir = '/content/clipped_rasters'\n", + "os.makedirs(output_clipped_raster_dir, exist_ok=True)\n", + "\n", + "clipped_raster_paths = []\n", + "\n", + "# Iterate through the downloaded rasters and clip them\n", + "for raster_path in downloaded_raster_paths:\n", + " with rasterio.open(raster_path) as src:\n", + " out_image, out_transform = mask(src, [lamwo.geometry.iloc[0]], crop=True)\n", + " out_meta = src.meta.copy()\n", + "\n", + " out_meta.update({\n", + " \"driver\": \"GTiff\",\n", + " \"height\": out_image.shape[1],\n", + " \"width\": out_image.shape[2],\n", + " \"transform\": out_transform\n", + " })\n", + "\n", + " # Construct the output path for the clipped raster\n", + " clipped_output_path = os.path.join(output_clipped_raster_dir, os.path.basename(raster_path))\n", + "\n", + " with rasterio.open(clipped_output_path, \"w\", **out_meta) as dest:\n", + " dest.write(out_image)\n", + "\n", + " clipped_raster_paths.append(clipped_output_path)\n", + " print(f\"Clipped raster saved to {clipped_output_path}\")\n", + "\n", + "print(f\"\\nClipped {len(clipped_raster_paths)} raster files to {output_clipped_raster_dir}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "SdCLImX8XL_E" + }, + "outputs": [], + "source": [] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/notebooks/merge_lwb_tile_stats.ipynb b/notebooks/merge_lwb_tile_stats.ipynb new file mode 100644 index 0000000..d2521ce --- /dev/null +++ b/notebooks/merge_lwb_tile_stats.ipynb @@ -0,0 +1,2341 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "64523d0b", + "metadata": {}, + "source": [ + "# Title: Compute per‑tile total biomass from AGLWBD (30 m) raster\n", + "#\n", + "This notebook:\n", + "1. Loads the 30 m biomass raster and the plain tiles GeoJSON (1 km tiles with geometry).\n", + "2. Reprojects tiles to the raster CRS (so areas/pixel sizes are correct).\n", + "3. Computes zonal stats (sum, mean, count) per tile using rasterstats.\n", + "4. Interprets units (choose whether raster values are Mg/pixel or Mg/ha).\n", + "5. Produces quick static and interactive visualizations and writes a GeoJSON/CSV." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "669d6114", + "metadata": {}, + "outputs": [], + "source": [ + "# CODE CELL: install & imports (run once)\n", + "import sys\n", + "# Uncomment to install missing packages (run in notebook once)\n", + "# !{sys.executable} -m pip install rasterio geopandas rasterstats rioxarray folium seaborn matplotlib --quiet\n", + "\n", + "import warnings\n", + "warnings.filterwarnings(\"ignore\", message=\"Iteration over dataset of unknown size\")\n", + "import geopandas as gpd\n", + "import rasterio\n", + "from rasterstats import zonal_stats\n", + "import matplotlib.pyplot as plt\n", + "import seaborn as sns\n", + "import folium\n", + "import numpy as np\n", + "from shapely.geometry import mapping\n", + "import rioxarray as rxr\n", + "from rasterio.warp import Resampling\n", + "import pandas as pd\n" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "fbb291e5", + "metadata": {}, + "outputs": [], + "source": [ + "# CODE CELL: configure file paths\n", + "raster_path = \"../data/aglwb.tif\" # <-- change to your raster\n", + "plain_tiles_path = \"../data/lamwo_sentinel_composites/lamwo_grid.geojson\" # <-- your geojson with tile geometries (1km polygons)\n", + "tile_stats_path = \"../data/Lamwo_Tile_Stats_EE.csv\" # <-- your csv with tile stats (e.g. cloud cover)\n", + "out_geojson = \"../data/Lamwo_Tile_Stats_EE_biomass.geojson\"\n", + "out_csv = \"../data/Lamwo_Tile_Stats_EE_biomass.csv\"" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "7384947d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Tiles CRS: EPSG:4326\n", + "Tiles rows: 5657\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
geometry
0POLYGON ((32.20445 3.49577, 32.20446 3.48679, ...
1POLYGON ((32.20445 3.50482, 32.20445 3.49577, ...
2POLYGON ((32.20445 3.50482, 32.20111 3.50482, ...
3MULTIPOLYGON (((32.20443 3.52291, 32.20443 3.5...
4POLYGON ((32.20443 3.52291, 32.20350 3.52291, ...
\n", + "
" + ], + "text/plain": [ + " geometry\n", + "0 POLYGON ((32.20445 3.49577, 32.20446 3.48679, ...\n", + "1 POLYGON ((32.20445 3.50482, 32.20445 3.49577, ...\n", + "2 POLYGON ((32.20445 3.50482, 32.20111 3.50482, ...\n", + "3 MULTIPOLYGON (((32.20443 3.52291, 32.20443 3.5...\n", + "4 POLYGON ((32.20443 3.52291, 32.20350 3.52291, ..." + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Raster CRS: EPSG:4326\n", + "Raster transform: | 0.00, 0.00, 32.20|\n", + "| 0.00,-0.00, 3.89|\n", + "| 0.00, 0.00, 1.00|\n", + "Raster dtype: float32\n", + "Raster nodata: None\n", + "Pixel size (m): 0.00025 0.00025 => pixel area (m^2): 6.25e-08\n" + ] + } + ], + "source": [ + "# CODE CELL: inspect and load data\n", + "# Load tiles (GeoJSON)\n", + "tiles = gpd.read_file(plain_tiles_path)\n", + "print(\"Tiles CRS:\", tiles.crs)\n", + "print(\"Tiles rows:\", len(tiles))\n", + "display(tiles.head())\n", + "\n", + "# Open raster and show basic metadata\n", + "with rasterio.open(raster_path) as src:\n", + " print(\"Raster CRS:\", src.crs)\n", + " print(\"Raster transform:\", src.transform)\n", + " print(\"Raster dtype:\", src.dtypes[0])\n", + " print(\"Raster nodata:\", src.nodata)\n", + " pixel_width, pixel_height = src.res\n", + " pix_area_m2 = abs(pixel_width * pixel_height)\n", + " print(\"Pixel size (m):\", pixel_width, pixel_height, \"=> pixel area (m^2):\", pix_area_m2)" + ] + }, + { + "cell_type": "markdown", + "id": "80d30a42", + "metadata": {}, + "source": [ + "Reproject tiles to raster CRS for correct area/pixel alignment before zonal stats." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "6e39f603", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/Imran/miniconda3/envs/geospatial/lib/python3.9/site-packages/rioxarray/raster_writer.py:130: UserWarning: The nodata value (3.402823466e+38) has been automatically changed to (3.4028234663852886e+38) to match the dtype of the data.\n", + " warnings.warn(\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
area_m2area_ha
count5.657000e+035657.000000
mean9.615587e+0596.155866
std1.625242e+0516.252418
min5.218976e+000.000522
25%1.000000e+06100.000000
50%1.000000e+06100.000000
75%1.000000e+06100.000000
max1.000000e+06100.000000
\n", + "
" + ], + "text/plain": [ + " area_m2 area_ha\n", + "count 5.657000e+03 5657.000000\n", + "mean 9.615587e+05 96.155866\n", + "std 1.625242e+05 16.252418\n", + "min 5.218976e+00 0.000522\n", + "25% 1.000000e+06 100.000000\n", + "50% 1.000000e+06 100.000000\n", + "75% 1.000000e+06 100.000000\n", + "max 1.000000e+06 100.000000" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# CODE CELL: reproject tiles + raster to metric CRS\n", + "# Open WGS84 raster (EPSG:4326)\n", + "r = rxr.open_rasterio(raster_path)\n", + "# reproject to UTM 36N\n", + "r_utm = r.rio.reproject(\n", + " \"EPSG:32636\",\n", + " resampling=Resampling.nearest\n", + ")\n", + "r_utm.rio.to_raster(\"in_utm_32636.tif\")\n", + "#convert raster and tiles to metric CRS\n", + "tiles_r = tiles.to_crs(32636) # UTM 36N, meters\n", + "\n", + "\n", + "# compute tile area in m^2 (CRS must be projected)\n", + "tiles_r[\"area_m2\"] = tiles_r.geometry.area\n", + "tiles_r[\"area_ha\"] = tiles_r[\"area_m2\"] / 10000.0\n", + "display(tiles_r[[\"area_m2\", \"area_ha\"]].describe())" + ] + }, + { + "cell_type": "markdown", + "id": "b61b7b3f", + "metadata": {}, + "source": [ + "Compute zonal stats. We get sum, mean, and count per tile.\n", + "Note: interpretation depends on raster units:\n", + " - If raster values = Mg PER PIXEL => zonal_stats(sum) gives total Mg per tile.\n", + " - If raster values = Mg/ha (density) => tile_total_Mg = mean * tile_area_ha (or sum_pixel_values * pixel_area_ha)." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "d0384692", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
r_sumr_meanr_count
01546.6047362.962844522
12166.6877443.135583691
2439.7080692.484226177
3107.0458072.37879645
4336.1099853.73455590
\n", + "
" + ], + "text/plain": [ + " r_sum r_mean r_count\n", + "0 1546.604736 2.962844 522\n", + "1 2166.687744 3.135583 691\n", + "2 439.708069 2.484226 177\n", + "3 107.045807 2.378796 45\n", + "4 336.109985 3.734555 90" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# CODE CELL: run zonal_stats and attach results\n", + "with rasterio.open(\"in_utm_32636.tif\") as src:\n", + " nodata = src.nodata\n", + "\n", + "zs = zonal_stats(\n", + " tiles_r.geometry,\n", + " \"in_utm_32636.tif\",\n", + " stats=[\"sum\", \"mean\", \"count\"],\n", + " nodata=nodata,\n", + " all_touched=False, # set True to include any touched pixels (more inclusive)\n", + " geojson_out=False,\n", + ")\n", + "\n", + "# attach results in correct order\n", + "tiles_r[\"r_sum\"] = [z.get(\"sum\", np.nan) for z in zs]\n", + "tiles_r[\"r_mean\"] = [z.get(\"mean\", np.nan) for z in zs]\n", + "tiles_r[\"r_count\"] = [z.get(\"count\", 0) for z in zs]\n", + "\n", + "display(tiles_r[[\"r_sum\", \"r_mean\", \"r_count\"]].head())" + ] + }, + { + "cell_type": "markdown", + "id": "814f7f14", + "metadata": {}, + "source": [ + "Choose how to interpret the raster values:\n", + "Set raster_unit = \"Mg_per_pixel\" OR \"Mg_per_ha\"\n", + "If you are unsure, check dataset docs or inspect a small area manually." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "db28302f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Tiles with NaN totals: 7\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
tile_total_Mg
count5650.000000
mean3543.429800
std2078.213594
min0.000000
25%2446.498840
50%3055.315796
75%3805.678101
max23974.714844
\n", + "
" + ], + "text/plain": [ + " tile_total_Mg\n", + "count 5650.000000\n", + "mean 3543.429800\n", + "std 2078.213594\n", + "min 0.000000\n", + "25% 2446.498840\n", + "50% 3055.315796\n", + "75% 3805.678101\n", + "max 23974.714844" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# CODE CELL: convert to per-tile total biomass based on unit\n", + "raster_unit = \"Mg_per_pixel\" # <-- choose \"Mg_per_pixel\" or \"Mg_per_ha\"\n", + "\n", + "if raster_unit == \"Mg_per_pixel\":\n", + " # zonal_stats.sum is already sum of pixel values => total Mg per tile\n", + " tiles_r[\"tile_total_Mg\"] = tiles_r[\"r_sum\"]\n", + "elif raster_unit == \"Mg_per_ha\":\n", + " # pixel values are density (Mg/ha): tile_total = mean_density * tile_area_ha\n", + " tiles_r[\"tile_total_Mg\"] = tiles_r[\"r_mean\"] * tiles_r[\"area_ha\"]\n", + "else:\n", + " raise ValueError(\"Set raster_unit to 'Mg_per_pixel' or 'Mg_per_ha'\")\n", + "\n", + "# Basic QA\n", + "print(\"Tiles with NaN totals:\", tiles_r[\"tile_total_Mg\"].isna().sum())\n", + "display(tiles_r[[\"tile_total_Mg\"]].describe())" + ] + }, + { + "cell_type": "markdown", + "id": "a37c118d", + "metadata": {}, + "source": [ + "Quick static plots (matplotlib / geopandas)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "c8b14436", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAwIAAAG8CAYAAABt4xKUAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguMCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy81sbWrAAAACXBIWXMAAA9hAAAPYQGoP6dpAAC5kElEQVR4nOydd3gVVfrHv3N7egVCIPTeewdRkCIiiK4iFlTUn67Y21pWwFV3dRV1bVhB17LYuygiTZpUqaEmJBDSe3KTW2Z+f0RumPccuJObwE15P8+TB2bumXPeOVPuPTPv93wVTdM0MAzDMAzDMAzTpDAFOwCGYRiGYRiGYc49PBBgGIZhGIZhmCYIDwQYhmEYhmEYpgnCAwGGYRiGYRiGaYLwQIBhGIZhGIZhmiA8EGAYhmEYhmGYJggPBBiGYRiGYRimCcIDAYZhGIZhGIZpgvBAgGEYhmEYhmGaIDwQYJgmwJIlS6AoClJTU33rxo4di7FjxwYtprOBbD9lXH/99QgPDzdUp6IomD9/fu2Da8D8/vvvsNlsOHr06Dlr0+12IykpCa+99to5a5NhGKapwQMBhqlDTv4QPfnncDjQpUsXzJ07F1lZWWe9/aeffhpfffXVWW9HxkcffYQXX3wx4O3Ly8sxf/58rFq1qs5iYuqGRx99FFdddRXatm3rWzd27FgoioLOnTtLt1m+fLnvOvjss89q3KbVasW9996Lp556ChUVFQHHzjAMw5weHggwzFngiSeewH//+1+88sorGDFiBF5//XUMHz4c5eXlZ7Xd0w0Err32WjidTt0PubqmLgYCCxYsqHcDAafTicceeyzYYQSNHTt24JdffsGtt94qfOZwOHDo0CH8/vvvwmcffvghHA5Hrdq+4YYbkJubi48++qhW9TAMwzByeCDAMGeByZMn45prrsFNN92EJUuW4O6770ZKSgq+/vrrWtcdyGDCbDbD4XBAUZRat9/UcDgcsFgswQ4jaCxevBht2rTBsGHDhM86duyIrl274uOPP9atr6iowJdffokpU6bUqu3o6GhMmDABS5YsqVU9DMMwjBweCDDMOeCCCy4AAKSkpPjWffDBBxg4cCBCQkIQGxuLmTNnIj09Xbfd2LFj0atXL2zduhVjxoxBaGgoHnnkEWkbiqKgrKwM7733ni8l4/rrrwdgPHe+srIS8+bNQ6dOnWC325GUlIQHH3wQlZWVZ9xu7Nix+P7773H06FFf2+3atfN9np2djTlz5qBFixZwOBzo27cv3nvvPd/nqampaNasGQBgwYIFvjpO5ubv3LkT119/PTp06ACHw4GEhATceOONyMvLO2Nc/jhy5AgmTpyIsLAwJCYm4oknnoCmaboyMo3A9u3bMXnyZERGRiI8PBzjxo3Dxo0bdWVO9vlvv/2GO++8E82aNUN0dDT+7//+Dy6XC4WFhbjuuusQExODmJgYPPjgg0Lbzz33HEaMGIG4uDiEhIRg4MCB0jSb5cuXY9SoUYiOjkZ4eDi6du0qnCcvv/wyevbsidDQUMTExGDQoEGGnrR/9dVXuOCCC047iLzqqquwdOlSqKrqW/ftt9+ivLwcV1xxhXSbVatWYdCgQXA4HOjYsSPeeOMNzJ8/X9rGhRdeiN9++w35+fl+Y2UYhmFqRtN9zMUw55DDhw8DAOLi4gAATz31FP7+97/jiiuuwE033YScnBy8/PLLGDNmDLZv347o6Gjftnl5eZg8eTJmzpyJa665Bi1atJC28d///hc33XQThgwZgltuuQVA1RNbo6iqiksuuQS//fYbbrnlFnTv3h27du3CCy+8gAMHDpxRe/Doo4+iqKgIx44dwwsvvAAAPjGu0+nE2LFjcejQIcydOxft27fHp59+iuuvvx6FhYW466670KxZM7z++uu47bbbcOmll2LGjBkAgD59+gCo+qF75MgR3HDDDUhISMCePXvw5ptvYs+ePdi4cWNAbzq8Xi8mTZqEYcOG4dlnn8WyZcswb948eDwePPHEE6fdbs+ePRg9ejQiIyPx4IMPwmq14o033sDYsWOxevVqDB06VFf+jjvuQEJCAhYsWICNGzfizTffRHR0NNavX482bdrg6aefxg8//IB///vf6NWrF6677jrfti+99BIuueQSXH311XC5XPjf//6Hv/zlL/juu+98T9v37NmDiy++GH369METTzwBu92OQ4cOYd26db563nrrLdx55524/PLLcdddd6GiogI7d+7Epk2bMGvWrNPu6/Hjx5GWloYBAwactsysWbN82o6TA96PPvoI48aNQ/PmzYXy27dvx6RJk9CyZUssWLAAXq8XTzzxhG8gSBk4cCA0TcP69etx8cUXnzYOhmEYJgA0hmHqjMWLF2sAtF9++UXLycnR0tPTtf/9739aXFycFhISoh07dkxLTU3VzGaz9tRTT+m23bVrl2axWHTrzzvvPA2AtmjRIkPth4WFabNnzz5tXCkpKbq6zzvvPN/yf//7X81kMmlr167Vbbto0SINgLZu3boztj1lyhStbdu2wvoXX3xRA6B98MEHvnUul0sbPny4Fh4erhUXF2uapmk5OTkaAG3evHlCHeXl5cK6jz/+WAOgrVmz5oz7KWP27NkaAO2OO+7wrVNVVZsyZYpms9m0nJwc33oa0/Tp0zWbzaYdPnzYty4jI0OLiIjQxowZI8QyceJETVVV3/rhw4driqJot956q2+dx+PRWrdurTsesv12uVxar169tAsuuMC37oUXXtAA6GKmTJs2TevZs+cZekTOL7/8ogHQvv32W+Gz8847z1fnoEGDtDlz5miapmkFBQWazWbT3nvvPW3lypUaAO3TTz/1bTd16lQtNDRUO378uG/dwYMHNYvFosm+kjIyMjQA2jPPPFPj+BmGYZgzw6lBDHMWGD9+PJo1a4akpCTMnDkT4eHh+PLLL9GqVSt88cUXUFUVV1xxBXJzc31/CQkJ6Ny5M1auXKmry26344YbbjjrMX/66afo3r07unXrpovr5FNeGpdRfvjhByQkJOCqq67yrbNarbjzzjtRWlqK1atX+60jJCTE9/+Kigrk5ub6cta3bdsWUFwAMHfuXN//FUXB3Llz4XK58Msvv0jLe71e/Pzzz5g+fTo6dOjgW9+yZUvMmjULv/32G4qLi3XbzJkzR/fGYujQodA0DXPmzPGtM5vNGDRoEI4cOaLb9tT9LigoQFFREUaPHq3b55Nvj77++mtdes6pREdH49ixY9i8efPpukLKydSrmJiYM5abNWsWvvjiC7hcLnz22Wcwm8249NJLhXJerxe//PILpk+fjsTERN/6Tp06YfLkydK6T7adm5tbo9gZhmEY/3BqEMOcBV599VV06dIFFosFLVq0QNeuXWEyVY27Dx48CE3TTjvtotVq1S23atUKNpvNt1xUVASn0+lbttlsiI2NrXXMBw8exL59+06bopGdnR1QvUePHkXnzp19+3+S7t27+z73R35+PhYsWID//e9/QhxFRUUBxWUymXQ/5gGgS5cuAHBaLUVOTg7Ky8vRtWtX4bPu3btDVVWkp6ejZ8+evvVt2rTRlYuKigIAJCUlCesLCgp06777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Gk4eqENlRTU+eucQKnYfResXHQCGFggoioLsvHGYtzgfE3PTUDBnMjyjE6X3PNvQAu/oRNgdTP0hIqLh0draCq/Xi0X2G2FXHAOf8BV69R78qfc1tLS0wOPxhHGGsYkrAkQhcrocmFqUhalFWSFfS1VVZE/PQPb0DHzzprK+wOBQAyp2H8Xn+2qxd/cxtPwlMPgyRVEwaaoPhYUZKCzNQUHJJCQlD66wNyXNG/K8iYiIBkXXEFpq0MWd+/zzz+PHP/4x6uvrMWPGDDz77LOYPXv2xc8jSjAQIIpgqqoiO28csvPGYRn6VgyqjjXis49OoL6mCZOnpaNwVjYKiiciyRNn9XSJiIguyIrUoJdffhnr16/HCy+8gNLSUjz99NMoLy9HZWUlxo4de9FziQZMDSIiIiKiYXU+NegKXBdyatDb+P2QUoNKS0tRUlKC5557DkDfh2qZmZlYt24dHnzwwYueSzTgigARERERXRK96Ampn1gv+nbHa2017qzncrngcrlM4/1+P/bs2YMNGzYEj6mqiquuugo7d+68+IlECQYCRERERDSsnE4nfD4f3qvfGvK1EhMTkZmZaTi2ceNGPPLII6axZ86cQSAQQFqasct9WloaDh48GPJcRjoGAkREREQ0rNxuN44fPw6/P/Rmnbqum/rbyFYDaGAMBIiIiIho2Lndbrjd7kt6zzFjxsBms6GhocFwvKGhAT6f75LOJRKpVk+AiIiIiGg4OJ1OFBcXY/v27cFjmqZh+/btKCsrs3BmkYErAkREREQUtdavX4/Vq1dj1qxZmD17Np5++mm0t7fj9ttvt3pqlmMgQERERERRa+XKlWhsbMTDDz+M+vp6FBUVYdu2baYC4ljEPgJERERERDGINQJERERERDGIgQARERERUQxiIEBEREREFIMYCBARERERxSAGAkREREREMYiBABERERFRDGIgQEREREQUgxgIEBERERHFIAYCREREREQxiIEAEREREVEMYiBARERERBSDGAgQEREREcUgBgJERERERDGIgQARERERUQxiIEBEREREFIMYCBARERERxSAGAkREREREMYiBABERERFRDGIgQEREREQUg/4fq9SCBtubd0YAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAtIAAAGJCAYAAACny9QDAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguMCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy81sbWrAAAACXBIWXMAAA9hAAAPYQGoP6dpAABDUUlEQVR4nO3df3zN9f//8fvZ7JfNNj93TH4shhkitBZJ2duWpRSVkpDoLRMRUvm1fngnSaRUlxrvStL7XSppYUgx0kp+i1KEbb2bbRTD9vz+4bPX17GZeZnt4Ha9XM7l4jxfz9fr9Xid585279XzPI/DGGMEAAAA4Jx4VHQBAAAAwMWIIA0AAADYQJAGAAAAbCBIAwAAADYQpAEAAAAbCNIAAACADQRpAAAAwAaCNAAAAGADQRoAAACwgSANoEQTJ06Uw+Eol3N16tRJnTp1sp6vXLlSDodD//nPf8rl/P369VODBg3K5Vx2HT58WA8++KCcTqccDoeGDx9+zseYM2eOHA6Hfv31V6vt9Nf+UlDcdRanX79+CggIKNUxHQ6HJk6ceP7FAbgkEKSBy0hhsCh8+Pr6KjQ0VLGxsZoxY4YOHTpUJufZv3+/Jk6cqA0bNpTJ8cqSO9dWGs8995zmzJmjwYMH65133lGfPn1K7Ltw4cLyK+4U8+bN0/Tp023v//fff2vixIlauXJlmdUEAGWtUkUXAKD8JSYmKiwsTMePH1d6erpWrlyp4cOHa9q0afr000/VsmVLq+9TTz2lxx9//JyOv3//fk2aNEkNGjRQq1atSr3fkiVLzuk8dpRU25tvvqmCgoILXsP5WL58ua699lpNmDDhrH2fe+459ezZU927d3dp79Onj3r16iUfH58LVOXJIL1582Zbd8ylk0F60qRJkuRWd8qPHDmiSpX40wngJH4bAJehm2++WW3btrWejx07VsuXL9ctt9yiW2+9Vdu2bZOfn58kqVKlShc8OPz999+qXLmyvL29L+h5zsbLy6tCz18amZmZatas2Xkdw9PTU56enmVU0eXF19e3oksA4EaY2gFAknTTTTdp3Lhx+u233/Tuu+9a7cXNkV66dKk6dOig4OBgBQQEqEmTJnriiScknZzX3K5dO0lS//79rWkkc+bMkXTy7mLz5s2Vlpamjh07qnLlyta+Z5qnm5+fryeeeEJOp1P+/v669dZbtXfvXpc+DRo0UL9+/Yrse+oxz1ZbcXOk//rrL40cOVJ169aVj4+PmjRpoqlTp8oY49LP4XAoISFBCxcuVPPmzeXj46PIyEglJycX/4KfJjMzUwMGDFBISIh8fX111VVXae7cudb2wvniu3fv1ueff27Vfqb5vw6HQ3/99Zfmzp1r9S18fUo7dzgvL08TJkxQo0aN5OPjo7p162r06NHKy8srcb9OnTrp888/12+//Wad+9TX9WzX+uuvv6pmzZqSpEmTJlnHKJybvHHjRvXr109XXnmlfH195XQ69cADD+jPP/8ssa6z+eWXXxQbGyt/f3+FhoYqMTGx2HE+fY70Dz/8oJtvvlmBgYEKCAhQ586dtXbtWpc+ha/5N998o0ceeUQ1a9ZUcHCwHnroIR07dkzZ2dm6//77VbVqVVWtWlWjR48ucu6pU6fquuuuU/Xq1eXn56c2bdoU+/mBkt6fhWbOnKnIyEhVrlxZVatWVdu2bTVv3rzzePWAyxN3pAFY+vTpoyeeeEJLlizRwIEDi+2zZcsW3XLLLWrZsqUSExPl4+OjXbt2afXq1ZKkiIgIJSYmavz48Ro0aJCuv/56SdJ1111nHePPP//UzTffrF69eum+++5TSEhIiXU9++yzcjgcGjNmjDIzMzV9+nTFxMRow4YN1p3z0ihNbacyxujWW2/VihUrNGDAALVq1UpffvmlRo0apX379umll15y6f/NN9/oo48+0sMPP6wqVapoxowZ6tGjh/bs2aPq1aufsa4jR46oU6dO2rVrlxISEhQWFqYPP/xQ/fr1U3Z2toYNG6aIiAi98847evTRR3XFFVdo5MiRkmQFztO98847evDBB3XNNddo0KBBkqSGDRuW+rUqKCjQrbfeqm+++UaDBg1SRESENm3apJdeekk//fRTiXOvn3zySeXk5Oj333+3XqPCD/OV5lpr1qyp1157TYMHD9btt9+uO+64Q5KsKUdLly7VL7/8ov79+8vpdGrLli164403tGXLFq1du9bWh2Pz8/MVFxena6+9VlOmTFFycrImTJigEydOKDEx8Yz7bdmyRddff70CAwM1evRoeXl56fXXX1enTp301VdfKSoqyqX/0KFD5XQ6NWnSJK1du1ZvvPGGgoODtWbNGtWrV0/PPfecFi9erBdeeEHNmzfX/fffb+378ssv69Zbb1Xv3r117Ngx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OBwlPiZOnKiXX35Zc+bMKfPzLly4sNz2O5uJEyfK4XAoLi6uyLYXXnhBDodDnTp1KrPzNW3aVD4+PkpPTy92+4oVK3TLLbeoZs2a8vX1VcOGDXX33Xdr1apVVp+VK1e6jJWfn58iIyP1xhtvlFmdAHA6gjQA/J8DBw5Yj+nTpyswMNCl7bHHHlNQUJCCg4MrutQLrnbt2lqxYoV+//13l/a3335b9erVK7PzfPPNNzpy5Ih69uypuXPnFtn+6quvqnPnzqpevbo++OAD7dixQx9//LGuu+46Pfroo0X679ixQwcOHNDWrVv10EMPafDgwUpJSSmzegHgVARpAPg/TqfTegQFBcnhcLi0BQQEFJnacbq8vDw99thjqlOnjvz9/RUVFaWVK1eesX+DBg0kSbfffrscDof1XJJee+01NWzYUN7e3mrSpIneeeeds+73888/67bbblNISIgCAgLUrl07LVu27Jxfi1q1aqlLly4u4XbNmjX63//+p/j4eJe+J06c0COPPKLg4GBVr15dY8aMUd++fUt8nQq99dZbuvfee9WnTx+9/fbbLtv27Nmj4cOHa/jw4Zo7d65uuukm1a9fXy1bttSwYcP03XffFVu30+lUWFiYHnnkEYWFhen7778/5+sHgNIgSANAGUpISFBqaqrmz5+vjRs36s4771RcXJx27txZbP/169dLkpKSknTgwAHr+ccff6xhw4Zp5MiR2rx5sx566CH1799fK1asKHG/w4cPq2vXrkpJSdEPP/yguLg4devWTXv27Dnna3nggQdcprG8/fbb6t27t7y9vV36Pf/883rvvfeUlJSk1atXKzc3t1RTTg4dOqQPP/xQ9913n/7xj38oJydHX3/9tbX9v//9r44fP67Ro0cXu7/D4TjjsY0xSk5O1p49exQVFXXWWgDADoI0AJSRPXv2KCkpSR9++KGuv/56NWzYUI899pg6dOigpKSkYvepWbOmJCk4OFhOp9N6PnXqVPXr108PP/ywGjdurBEjRuiOO+7Q1KlTS9zvqquu0kMPPaTmzZsrPDxcTz/9tBo2bKhPP/30nK/nlltuUW5urlatWqW//vpLCxYs0AMPPFCk38yZMzV27Fjdfvvtatq0qV555ZVSTX+ZP3++wsPDFRkZKU9PT/Xq1UtvvfWWtf2nn35SYGCgnE6n1fbf//5XAQEB1mPTpk0ux7ziiisUEBAgb29vxcfHa8KECerYseM5XzsAlEalii4AAC4VmzZtUn5+vho3buzSnpeXp+rVq5/TsbZt26ZBgwa5tLVv314vv/xyifsdPnxYEydO1Oeff64DBw7oxIkTOnLkiK070l5eXrrvvvuUlJSkX375RY0bN1bLli1d+uTk5CgjI0PXXHON1ebp6ak2bdqooKCgxOO//fbbuu+++6zn9913n2644QbNnDlTVapUkVT0rnNsbKw2bNigffv2qVOnTsrPz3fZ/vXXX6tKlSrKy8vTt99+q4SEBFWrVk2DBw8+5+sHgLMhSANAGTl8+LA8PT2VlpYmT09Pl20BAQHlUsNjjz2mpUuXaurUqWrUqJH8/PzUs2dPHTt2zNbxHnjgAUVFRWnz5s3F3o22a+vWrVq7dq2+/fZbjRkzxmrPz8/X/PnzNXDgQIWHhysnJ0fp6enWXemAgAA1atRIlSoV/+crLCzMuhseGRmpdevW6dlnnyVIA7ggmNoBAGWkdevWys/PV2Zmpho1auTyOHV6wum8vLyK3FmNiIjQ6tWrXdpWr16tZs2albjf6tWr1a9fP91+++1q0aKFnE6nfv31V9vXFBkZqcjISG3evFn33ntvke1BQUEKCQmx5mhLJ8Pw2T7g99Zbb6ljx4768ccftWHDBusxYsQIa3pHz5495eXlpeeff952/Z6enjpy5Ijt/QGgJNyRBoAy0rhxY/Xu3Vv333+/XnzxRbVu3Vp//PGHUlJS1LJlyyKrXRRq0KCBUlJS1L59e/n4+Khq1aoaNWqU7rrrLrVu3VoxMTH67LPP9NFHH7mswFHcfuHh4froo4/UrVs3ORwOjRs37qxTLM5m+fLlOn78+BnnPQ8dOlSTJ09Wo0aN1LRpU82cOVMHDx4844cBjx8/rnfeeUeJiYlq3ry5y7YHH3xQ06ZN05YtWxQZGakXX3xRw4YNU1ZWlvr166ewsDBlZWXp3XfflaQid/4zMzN19OhRa2rHO++8o549e57X9QPAmXBHGgDKUFJSku6//36NHDlSTZo0Uffu3bV+/foS115+8cUXtXTpUtWtW1etW7eWJHXv3l0vv/yypk6dqsjISL3++utKSkpy+SKU4vabNm2aqlatquuuu07dunVTbGysrr766vO6Jn9//xI/PDhmzBjdc889uv/++xUdHa2AgADFxsbK19e32P6ffvqp/vzzT91+++1FtkVERCgiIsK6Kz106FAtWbJEf/zxh3r27Knw8HB17dpVu3fvVnJyslq0aOGyf5MmTVS7dm01atRIY8aM0UMPPaSZM2fav3gAKIHDGGMquggAwKWjoKBAERERuuuuu/T0009XdDkAcMEwtQMAcF5+++03LVmyRDfccIPy8vL0yiuvaPfu3cXOqQaASwlTOwAA58XDw0Nz5sxRu3bt1L59e23atEnLli1TRERERZcGABcUUzsAAAAAG7gjDQAAANhAkAYAAABsIEgDAAAANhCkAQAAABsI0gAAAIANBGkAAADABoI0AAAAYANBGgAAALDh/wE0o+jaY2YjrAAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# CODE CELL: choropleth (static)\n", + "fig, ax = plt.subplots(1, 1, figsize=(10, 8))\n", + "tiles_r.plot(column=\"tile_total_Mg\", ax=ax, cmap=\"viridis\", legend=True,\n", + " legend_kwds={\"label\": \"Tile total Mg AGB\", \"shrink\": 0.6})\n", + "ax.set_title(\"Per-tile total biomass (Mg)\")\n", + "ax.set_axis_off()\n", + "plt.show()\n", + "\n", + "# histogram\n", + "plt.figure(figsize=(8, 4))\n", + "sns.histplot(tiles_r[\"tile_total_Mg\"].dropna(), bins=60, kde=False)\n", + "plt.xlabel(\"Tile total Mg AGB\")\n", + "plt.title(\"Distribution of tile total biomass\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "6c5f2060", + "metadata": {}, + "source": [ + "Interactive map with folium (convert to EPSG:4326 for leaflet)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "1e397279", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/var/folders/8h/x4dy974d74qbv947r78v2l_m0000gn/T/ipykernel_15397/1064216052.py:3: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation.\n", + "\n", + " m = folium.Map(location=[tiles_wgs.geometry.centroid.y.mean(), tiles_wgs.geometry.centroid.x.mean()], zoom_start=10)\n" + ] + }, + { + "data": { + "text/html": [ + "
Make this Notebook Trusted to load map: File -> Trust Notebook
" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# CODE CELL: folium choropleth (interactive)\n", + "tiles_wgs = tiles_r.to_crs(\"EPSG:4326\")\n", + "m = folium.Map(location=[tiles_wgs.geometry.centroid.y.mean(), tiles_wgs.geometry.centroid.x.mean()], zoom_start=10)\n", + "# prepare geojson\n", + "geojson = tiles_wgs.to_json()\n", + "\n", + "# Use a linear colormap\n", + "import branca.colormap as cm\n", + "vmin = tiles_wgs[\"tile_total_Mg\"].min()\n", + "vmax = tiles_wgs[\"tile_total_Mg\"].quantile(0.99) # cap color at 99th pct to avoid extreme skew\n", + "colormap = cm.linear.YlGn_09.scale(vmin, vmax)\n", + "colormap.caption = \"Tile total Mg AGB\"\n", + "colormap.add_to(m)\n", + "\n", + "folium.GeoJson(\n", + " geojson,\n", + " name=\"tiles\",\n", + " style_function=lambda feat: {\n", + " \"fillColor\": colormap(feat[\"properties\"].get(\"tile_total_Mg\") or 0),\n", + " \"color\": \"#333333\",\n", + " \"weight\": 0.3,\n", + " \"fillOpacity\": 0.8,\n", + " },\n", + " tooltip=folium.GeoJsonTooltip(fields=[\"tile_total_Mg\"], labels=True),\n", + ").add_to(m)\n", + "# add plain tiles (tiles) to map\n", + "folium.GeoJson(\n", + " tiles.to_json(),\n", + " name=\"plain_tiles\",\n", + " style_function=lambda feat: {\n", + " \"fillColor\": \"#ffffff\",\n", + " \"color\": \"#333333\",\n", + " \"weight\": 0.3,\n", + " \"fillOpacity\": 0.8,\n", + " },\n", + ").add_to(m)\n", + "\n", + "#layer toggling on\n", + "folium.LayerControl().add_to(m)\n", + "m" + ] + }, + { + "cell_type": "markdown", + "id": "4e18f2e0", + "metadata": {}, + "source": [ + "Save results: GeoJSON (with geometries) and CSV (attributes only)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "d8513645", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
ndvi_meanndvi_medndvi_stdevi_medelev_meanslope_meanpar_meanrain_total_mmrain_mean_mm_daycloud_free_daysbldg_countbldg_areabldg_h_maxsystem:index
0-0.507665-0.6625790.2052911.927150661.2470283.468728189.6434179.3340603.269485290.00.00.00
1-0.517304-0.6289600.2065391.978630660.6673903.641268188.99921618.5091853.241650290.00.00.01
2-0.410299-0.5876290.1644711.775164652.7819775.080677182.8545230.000000NaN290.00.00.02
3-0.527696-0.6269400.1932251.772409653.0805183.040932182.8545230.000000NaN290.00.00.03
4-0.632031-0.7207020.1976122.258251655.3431244.595804182.8545230.000000NaN290.00.00.04
\n", + "
" + ], + "text/plain": [ + " ndvi_mean ndvi_med ndvi_std evi_med elev_mean slope_mean \\\n", + "0 -0.507665 -0.662579 0.205291 1.927150 661.247028 3.468728 \n", + "1 -0.517304 -0.628960 0.206539 1.978630 660.667390 3.641268 \n", + "2 -0.410299 -0.587629 0.164471 1.775164 652.781977 5.080677 \n", + "3 -0.527696 -0.626940 0.193225 1.772409 653.080518 3.040932 \n", + "4 -0.632031 -0.720702 0.197612 2.258251 655.343124 4.595804 \n", + "\n", + " par_mean rain_total_mm rain_mean_mm_day cloud_free_days bldg_count \\\n", + "0 189.643417 9.334060 3.269485 29 0.0 \n", + "1 188.999216 18.509185 3.241650 29 0.0 \n", + "2 182.854523 0.000000 NaN 29 0.0 \n", + "3 182.854523 0.000000 NaN 29 0.0 \n", + "4 182.854523 0.000000 NaN 29 0.0 \n", + "\n", + " bldg_area bldg_h_max system:index \n", + "0 0.0 0.0 0 \n", + "1 0.0 0.0 1 \n", + "2 0.0 0.0 2 \n", + "3 0.0 0.0 3 \n", + "4 0.0 0.0 4 " + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "#read and display tile stats csv\n", + "tile_stats = pd.read_csv(tile_stats_path)\n", + "tile_stats.head()" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "e625c4c2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
geometryarea_m2area_har_sumr_meanr_counttile_total_Mg
0POLYGON ((32.20445 3.49577, 32.20446 3.48679, ...403986.34944640.3986351546.6047362.9628445221546.604736
1POLYGON ((32.20445 3.50482, 32.20445 3.49577, ...530893.25318653.0893252166.6877443.1355836912166.687744
2POLYGON ((32.20445 3.50482, 32.20111 3.50482, ...131697.22329413.169722439.7080692.484226177439.708069
3MULTIPOLYGON (((32.20443 3.52291, 32.20443 3.5...34775.4143533.477541107.0458072.37879645107.045807
4POLYGON ((32.20443 3.52291, 32.20350 3.52291, ...68720.4939736.872049336.1099853.73455590336.109985
\n", + "
" + ], + "text/plain": [ + " geometry area_m2 \\\n", + "0 POLYGON ((32.20445 3.49577, 32.20446 3.48679, ... 403986.349446 \n", + "1 POLYGON ((32.20445 3.50482, 32.20445 3.49577, ... 530893.253186 \n", + "2 POLYGON ((32.20445 3.50482, 32.20111 3.50482, ... 131697.223294 \n", + "3 MULTIPOLYGON (((32.20443 3.52291, 32.20443 3.5... 34775.414353 \n", + "4 POLYGON ((32.20443 3.52291, 32.20350 3.52291, ... 68720.493973 \n", + "\n", + " area_ha r_sum r_mean r_count tile_total_Mg \n", + "0 40.398635 1546.604736 2.962844 522 1546.604736 \n", + "1 53.089325 2166.687744 3.135583 691 2166.687744 \n", + "2 13.169722 439.708069 2.484226 177 439.708069 \n", + "3 3.477541 107.045807 2.378796 45 107.045807 \n", + "4 6.872049 336.109985 3.734555 90 336.109985 " + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tiles_wgs.head()" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "93909c10", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
ndvi_meanndvi_medndvi_stdevi_medelev_meanslope_meanpar_meanrain_total_mmrain_mean_mm_daycloud_free_daysbldg_countbldg_areabldg_h_maxgeometryarea_m2tile_total_Mg
0-0.507665-0.6625790.2052911.927150661.2470283.468728189.6434179.3340603.269485290.00.00.0POLYGON ((32.20445 3.49577, 32.20446 3.48679, ...403986.3494461546.604736
1-0.517304-0.6289600.2065391.978630660.6673903.641268188.99921618.5091853.241650290.00.00.0POLYGON ((32.20445 3.50482, 32.20445 3.49577, ...530893.2531862166.687744
2-0.410299-0.5876290.1644711.775164652.7819775.080677182.8545230.000000NaN290.00.00.0POLYGON ((32.20445 3.50482, 32.20111 3.50482, ...131697.223294439.708069
3-0.527696-0.6269400.1932251.772409653.0805183.040932182.8545230.000000NaN290.00.00.0MULTIPOLYGON (((32.20443 3.52291, 32.20443 3.5...34775.414353107.045807
4-0.632031-0.7207020.1976122.258251655.3431244.595804182.8545230.000000NaN290.00.00.0POLYGON ((32.20443 3.52291, 32.20350 3.52291, ...68720.493973336.109985
\n", + "
" + ], + "text/plain": [ + " ndvi_mean ndvi_med ndvi_std evi_med elev_mean slope_mean \\\n", + "0 -0.507665 -0.662579 0.205291 1.927150 661.247028 3.468728 \n", + "1 -0.517304 -0.628960 0.206539 1.978630 660.667390 3.641268 \n", + "2 -0.410299 -0.587629 0.164471 1.775164 652.781977 5.080677 \n", + "3 -0.527696 -0.626940 0.193225 1.772409 653.080518 3.040932 \n", + "4 -0.632031 -0.720702 0.197612 2.258251 655.343124 4.595804 \n", + "\n", + " par_mean rain_total_mm rain_mean_mm_day cloud_free_days bldg_count \\\n", + "0 189.643417 9.334060 3.269485 29 0.0 \n", + "1 188.999216 18.509185 3.241650 29 0.0 \n", + "2 182.854523 0.000000 NaN 29 0.0 \n", + "3 182.854523 0.000000 NaN 29 0.0 \n", + "4 182.854523 0.000000 NaN 29 0.0 \n", + "\n", + " bldg_area bldg_h_max geometry \\\n", + "0 0.0 0.0 POLYGON ((32.20445 3.49577, 32.20446 3.48679, ... \n", + "1 0.0 0.0 POLYGON ((32.20445 3.50482, 32.20445 3.49577, ... \n", + "2 0.0 0.0 POLYGON ((32.20445 3.50482, 32.20111 3.50482, ... \n", + "3 0.0 0.0 MULTIPOLYGON (((32.20443 3.52291, 32.20443 3.5... \n", + "4 0.0 0.0 POLYGON ((32.20443 3.52291, 32.20350 3.52291, ... \n", + "\n", + " area_m2 tile_total_Mg \n", + "0 403986.349446 1546.604736 \n", + "1 530893.253186 2166.687744 \n", + "2 131697.223294 439.708069 \n", + "3 34775.414353 107.045807 \n", + "4 68720.493973 336.109985 " + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# merge area_m2 and tile_total_Mg into tile_stats on index\n", + "tile_stats_biomass = tile_stats.merge(tiles_wgs[[\"geometry\", \"area_m2\", \"tile_total_Mg\"]], left_index=True, right_index=True, how=\"left\").drop(columns=[\"system:index\"])\n", + "\n", + "tile_stats_biomass.head()" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "5be0e5a5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Saved: ../data/Lamwo_Tile_Stats_EE_biomass.geojson ../data/Lamwo_Tile_Stats_EE_biomass.csv\n" + ] + } + ], + "source": [ + "# CODE CELL: save outputs\n", + "#convert tile_stats_biomass to geodataframe\n", + "tile_stats_biomass_gdf = gpd.GeoDataFrame(tile_stats_biomass, geometry=\"geometry\", crs=tiles_wgs.crs)\n", + "tile_stats_biomass_gdf.to_file(out_geojson, driver=\"GeoJSON\")\n", + "tile_stats_biomass_gdf.drop(columns=\"geometry\").to_csv(out_csv, index=False)\n", + "print(\"Saved:\", out_geojson, out_csv)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d71243fb", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "geospatial", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.18" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/scripts/load_to_postgis.py b/scripts/load_to_postgis.py new file mode 100644 index 0000000..99b3195 --- /dev/null +++ b/scripts/load_to_postgis.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +Bulk load geospatial files from the repository `data/` folder into PostGIS. + +Behavior: +- Scans a data directory for files (GeoJSON, Shapefile, GPKG, CSV). +- For CSVs, tries to detect lat/lon columns or a WKT column and generates a temporary GeoJSON. +- Uses ogr2ogr to import files into PostGIS (robust for many file formats). +- After import, attempts to set SRID to 4326 (if missing) and creates a GiST spatial index. + +Usage: + python3 scripts/load_to_postgis.py --data-dir data --db-uri "postgresql://pguser:pgpass@localhost:5432/suntrace" + +Requirements: +- ogr2ogr (GDAL) must be installed and on PATH +- Python packages: geopandas, pandas, sqlalchemy + +This script is designed for development deployments where you run a local PostGIS container. +""" + +import argparse +import logging +import os +import re +import shlex +import subprocess +import tempfile +from pathlib import Path + +import pandas as pd +import geopandas as gpd +from sqlalchemy import create_engine, text + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("load_to_postgis") + +DEFAULT_DB_URI = "postgresql+psycopg://pguser:pgpass@localhost:5432/suntrace" + +CSV_LON_NAMES = {"lon", "longitude", "long", "x", "lng"} +CSV_LAT_NAMES = {"lat", "latitude", "y"} +WKT_NAMES = {"wkt", "geometry", "geom", "wkb", "wkt_geom"} + + +def safe_table_name(path: Path) -> str: + name = path.stem.lower() + # replace non-alnum with underscore + name = re.sub(r"[^0-9a-zA-Z]+", "_", name) + return name + + +def run_ogr2ogr(src: str, db_uri: str, table: str, ogr2ogr_path: str = "ogr2ogr"): + # Quote the PG: connection string for use by ogr2ogr + pg_conn = f'PG:"{db_uri}"' + cmd = f'{ogr2ogr_path} -f "PostgreSQL" {pg_conn} {shlex.quote(src)} -nln public.{table} -lco GEOMETRY_NAME=geom -t_srs EPSG:4326 -overwrite' + logger.info("Running: %s", cmd) + res = subprocess.run(cmd, shell=True, capture_output=True, text=True) + if res.returncode != 0: + logger.error("ogr2ogr failed for %s: %s", src, res.stderr) + raise RuntimeError(res.stderr) + logger.info("Loaded %s -> public.%s", src, table) + + +def ensure_index_and_srid(engine, table: str, srid: int = 4326): + # Set SRID where missing and create GIST index + logger.info("Ensuring SRID and index for %s", table) + with engine.begin() as conn: + try: + # Set SRID where unknown (st_srid = 0) + conn.execute(text(f"UPDATE public.{table} SET geom = ST_SetSRID(geom, {srid}) WHERE ST_SRID(geom) = 0 OR ST_SRID(geom) IS NULL;")) + except Exception as e: + logger.debug("Could not run SRID update for %s: %s", table, e) + try: + conn.execute(text(f"CREATE INDEX IF NOT EXISTS {table}_geom_gist ON public.{table} USING GIST(geom);")) + except Exception as e: + logger.warning("Could not create spatial index for %s: %s", table, e) + + +def csv_to_temp_geojson(csv_path: Path) -> str: + """Detect geometry columns and write a temporary GeoJSON file, return its path.""" + logger.info("Inspecting CSV %s", csv_path) + df = pd.read_csv(csv_path, nrows=100) + cols = {c.lower() for c in df.columns} + + # Check for WKT column + wkt_col = None + for c in df.columns: + if c.lower() in WKT_NAMES: + wkt_col = c + break + + if wkt_col: + logger.info("Found WKT column '%s' in %s", wkt_col, csv_path) + df_full = pd.read_csv(csv_path) + gdf = gpd.GeoDataFrame(df_full, geometry=gpd.GeoSeries.from_wkt(df_full[wkt_col]), crs="EPSG:4326") + else: + # Look for lat/lon pairs + lon_col = None + lat_col = None + for c in df.columns: + if c.lower() in CSV_LON_NAMES and lon_col is None: + lon_col = c + if c.lower() in CSV_LAT_NAMES and lat_col is None: + lat_col = c + if lon_col and lat_col: + logger.info("Found lat/lon columns %s/%s in %s", lat_col, lon_col, csv_path) + df_full = pd.read_csv(csv_path) + gdf = gpd.GeoDataFrame(df_full, geometry=gpd.points_from_xy(df_full[lon_col], df_full[lat_col]), crs="EPSG:4326") + else: + raise RuntimeError(f"Could not detect geometry in CSV {csv_path}. Provide WKT or lat/lon columns.") + + tmp = tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) + tmp_path = tmp.name + tmp.close() + gdf.to_file(tmp_path, driver="GeoJSON") + logger.info("Wrote temporary GeoJSON %s", tmp_path) + return tmp_path + + +def main(data_dir: str, db_uri: str, ogr2ogr_path: str = "ogr2ogr"): + data_path = Path(data_dir) + if not data_path.exists(): + raise RuntimeError(f"Data directory not found: {data_dir}") + + engine = create_engine(db_uri) + + # Collect candidate files in the data directory (non-recursive by default) + files = [p for p in data_path.iterdir() if p.is_file()] + + # Also include some common subfolders (e.g., lamwo_sentinel_composites) + for sub in ["lamwo_sentinel_composites", "viz_geojsons", "sample_region_mudu"]: + subp = data_path / sub + if subp.exists() and subp.is_dir(): + files.extend([p for p in subp.iterdir() if p.is_file()]) + + if not files: + logger.warning("No data files found in %s", data_dir) + return + + for f in files: + try: + if f.suffix.lower() in {".geojson", ".json", ".gpkg", ".shp"}: + table = safe_table_name(f) + run_ogr2ogr(str(f), db_uri, table, ogr2ogr_path=ogr2ogr_path) + ensure_index_and_srid(engine, table) + elif f.suffix.lower() == ".csv": + table = safe_table_name(f) + try: + tmp_geojson = csv_to_temp_geojson(f) + except Exception as e: + logger.error("Skipping CSV %s: %s", f, e) + continue + try: + run_ogr2ogr(tmp_geojson, db_uri, table, ogr2ogr_path=ogr2ogr_path) + ensure_index_and_srid(engine, table) + finally: + try: + os.remove(tmp_geojson) + except Exception: + pass + else: + logger.info("Skipping unsupported file type: %s", f) + except Exception as e: + logger.error("Failed to load %s: %s", f, e) + + logger.info("Done loading files into PostGIS.") + + +if __name__ == "__main__": + p = argparse.ArgumentParser() + p.add_argument("--data-dir", default="data", help="Path to data directory to scan and load") + p.add_argument("--db-uri", default=DEFAULT_DB_URI, help="SQLAlchemy DB URI for PostGIS") + p.add_argument("--ogr2ogr", default="ogr2ogr", help="Path to ogr2ogr binary") + args = p.parse_args() + + main(args.data_dir, args.db_uri, ogr2ogr_path=args.ogr2ogr) diff --git a/tests/test_geospatial_analyzer.py b/tests/test_geospatial_analyzer.py index cafda7a..34c7f4a 100644 --- a/tests/test_geospatial_analyzer.py +++ b/tests/test_geospatial_analyzer.py @@ -175,7 +175,7 @@ def run_tests(): # 1. Count buildings print("\n1. count_features_within (buildings):") try: - building_count = analyzer.count_buildings_within_region(test_region_polygon) + building_count = analyzer.count_features_within_region(test_region_polygon, 'buildings') print(f" Number of buildings in sample region: {building_count}") except Exception as e: print(f" Error counting buildings: {e}") diff --git a/tests/test_integration.py b/tests/test_integration.py index ca87bf0..2b86ec4 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -38,26 +38,24 @@ def test_region(self, sample_data_paths): def test_full_workflow_building_analysis(self, analyzer_with_data, test_region): """Test complete workflow for building analysis.""" # Test basic counting - building_count = analyzer_with_data.count_buildings_within_region(test_region) + building_count = analyzer_with_data.count_features_within_region( + test_region, 'buildings' + ) assert isinstance(building_count, int) assert building_count >= 0 # Test with different buffer sizes if building_count > 0: - buffered_region = analyzer_with_data.buffer_geometry( - test_region, 500 - ) # 500m buffer - buffered_count = analyzer_with_data.count_buildings_within_region( - buffered_region + buffered_region = analyzer_with_data.buffer_geometry(test_region, 500) # 500m buffer + buffered_count = analyzer_with_data.count_features_within_region( + buffered_region, 'buildings' ) assert buffered_count >= building_count # Should be at least as many def test_full_workflow_ndvi_analysis(self, analyzer_with_data, test_region): """Test complete workflow for NDVI analysis.""" - if ( - analyzer_with_data._joined_tiles_gdf.empty - or "ndvi_mean" not in analyzer_with_data._joined_tiles_gdf.columns - ): + stats_snapshot = analyzer_with_data.weighted_tile_stats_all(test_region) + if not stats_snapshot or 'ndvi_mean' not in stats_snapshot: pytest.skip("No NDVI data available") # Test average NDVI @@ -68,19 +66,11 @@ def test_full_workflow_ndvi_analysis(self, analyzer_with_data, test_region): # Test NDVI statistics ndvi_stats = analyzer_with_data.ndvi_stats(test_region) assert isinstance(ndvi_stats, dict) - assert "NDVI_mean" in ndvi_stats - - # Test high NDVI building count - if not analyzer_with_data._buildings_gdf.empty: - high_ndvi_buildings = analyzer_with_data.count_high_ndvi_buildings( - test_region, ndvi_threshold=0.3 - ) - assert isinstance(high_ndvi_buildings, int) - assert high_ndvi_buildings >= 0 - + assert 'NDVI_mean' in ndvi_stats + def test_full_workflow_minigrid_analysis(self, analyzer_with_data, test_region): """Test complete workflow for minigrid analysis.""" - if analyzer_with_data._minigrids_gdf.empty: + if not hasattr(analyzer_with_data, '_minigrids_gdf') or analyzer_with_data._minigrids_gdf.empty: pytest.skip("No minigrid data available") # Test listing minigrids @@ -115,20 +105,20 @@ def test_cross_layer_consistency(self, analyzer_with_data, test_region): def test_spatial_accuracy(self, analyzer_with_data, test_region): """Test spatial accuracy of operations.""" - if analyzer_with_data._buildings_gdf.empty: + if not hasattr(analyzer_with_data, '_buildings_gdf') or analyzer_with_data._buildings_gdf.empty: pytest.skip("No buildings data available") # Test that buildings in buffered region >= buildings in original region - original_count = analyzer_with_data.count_buildings_within_region(test_region) + original_count = analyzer_with_data.count_features_within_region( + test_region, 'buildings' + ) if original_count > 0: - buffered_region = analyzer_with_data.buffer_geometry( - test_region, 100 - ) # 100m buffer - buffered_count = analyzer_with_data.count_buildings_within_region( - buffered_region + buffered_region = analyzer_with_data.buffer_geometry(test_region, 100) # 100m buffer + buffered_count = analyzer_with_data.count_features_within_region( + buffered_region, 'buildings' ) - + assert buffered_count >= original_count def test_performance_with_real_data(self, analyzer_with_data, test_region): @@ -138,11 +128,12 @@ def test_performance_with_real_data(self, analyzer_with_data, test_region): start_time = time.time() # Run multiple operations - building_count = analyzer_with_data.count_buildings_within_region(test_region) - if not analyzer_with_data._joined_tiles_gdf.empty: - avg_ndvi = analyzer_with_data.avg_ndvi(test_region) - ndvi_stats = analyzer_with_data.ndvi_stats(test_region) - + building_count = analyzer_with_data.count_features_within_region( + test_region, 'buildings' + ) + avg_ndvi = analyzer_with_data.avg_ndvi(test_region) + ndvi_stats = analyzer_with_data.ndvi_stats(test_region) + end_time = time.time() execution_time = end_time - start_time @@ -154,7 +145,7 @@ def test_performance_with_real_data(self, analyzer_with_data, test_region): @pytest.mark.slow def test_large_region_analysis(self, analyzer_with_data): """Test analysis with large regions.""" - if analyzer_with_data._buildings_gdf.empty: + if not hasattr(analyzer_with_data, '_buildings_gdf') or analyzer_with_data._buildings_gdf.empty: pytest.skip("No buildings data available") # Create a large region covering most of the data @@ -174,27 +165,25 @@ def test_large_region_analysis(self, analyzer_with_data): ) # This should not crash and should return reasonable results - building_count = analyzer_with_data.count_buildings_within_region( - large_region - ) + building_count = analyzer_with_data.count_features_within_region(large_region, 'buildings') assert isinstance(building_count, int) assert building_count >= 0 def test_data_integrity_checks(self, analyzer_with_data): """Test data integrity across loaded datasets.""" # Check that all loaded GeoDataFrames have valid CRS - if not analyzer_with_data._buildings_gdf.empty: + if hasattr(analyzer_with_data, '_buildings_gdf') and not analyzer_with_data._buildings_gdf.empty: assert analyzer_with_data._buildings_gdf.crs is not None - - if not analyzer_with_data._minigrids_gdf.empty: + + if hasattr(analyzer_with_data, '_minigrids_gdf') and not analyzer_with_data._minigrids_gdf.empty: assert analyzer_with_data._minigrids_gdf.crs is not None - - if not analyzer_with_data._plain_tiles_gdf.empty: + + if hasattr(analyzer_with_data, '_plain_tiles_gdf') and not analyzer_with_data._plain_tiles_gdf.empty: assert analyzer_with_data._plain_tiles_gdf.crs is not None # Check that joined tiles have both geometry and stats - if not analyzer_with_data._joined_tiles_gdf.empty: - assert "geometry" in analyzer_with_data._joined_tiles_gdf.columns + if hasattr(analyzer_with_data, '_joined_tiles_gdf') and not analyzer_with_data._joined_tiles_gdf.empty: + assert 'geometry' in analyzer_with_data._joined_tiles_gdf.columns # Should have at least some statistical columns stat_cols = [ col @@ -208,14 +197,14 @@ def test_coordinate_system_consistency(self, analyzer_with_data): """Test that coordinate system handling is consistent.""" # All geographic data should be transformable to common CRS target_crs = "EPSG:4326" # WGS84 - - if not analyzer_with_data._buildings_gdf.empty: + + if hasattr(analyzer_with_data, '_buildings_gdf') and not analyzer_with_data._buildings_gdf.empty: buildings_4326 = analyzer_with_data._ensure_gdf_crs_for_calculation( analyzer_with_data._buildings_gdf.copy(), target_crs ) assert buildings_4326.crs.to_string() == target_crs - - if not analyzer_with_data._minigrids_gdf.empty: + + if hasattr(analyzer_with_data, '_minigrids_gdf') and not analyzer_with_data._minigrids_gdf.empty: minigrids_4326 = analyzer_with_data._ensure_gdf_crs_for_calculation( analyzer_with_data._minigrids_gdf.copy(), target_crs ) diff --git a/utils/GeospatialAnalyzer.py b/utils/GeospatialAnalyzer.py index b53c847..7f5b07d 100644 --- a/utils/GeospatialAnalyzer.py +++ b/utils/GeospatialAnalyzer.py @@ -1,3 +1,4 @@ +import json from typing import List, Dict, Tuple, Optional, Any import warnings import geopandas as gpd @@ -120,6 +121,20 @@ def __init__( if not self._joined_tiles_gdf.empty: print(f"Joined Tiles CRS: {self._joined_tiles_gdf.crs}") + self._layer_map: Dict[str, gpd.GeoDataFrame] = { + "buildings": self._buildings_gdf, + "tiles": self._joined_tiles_gdf, + "tile_stats": self._tile_stats_gdf, + "roads": self._roads_gdf, + "villages": self._villages_gdf, + "parishes": self._parishes_gdf, + "subcounties": self._subcounties_gdf, + "existing_grid": self._existing_grid_gdf, + "grid_extension": self._grid_extension_gdf, + "candidate_minigrids": self._candidate_minigrids_gdf, + "existing_minigrids": self._existing_minigrids_gdf, + } + def _load_and_validate_gdf( self, path: str, ensure_crs: bool = False ) -> gpd.GeoDataFrame: @@ -548,90 +563,6 @@ def count_features_within_region( print(f"Error during intersection for layer '{layer_name}': {e}") return 0 - # ----------------------------------------------------------------------------- - def count_high_ndvi_buildings( - self, region: Polygon, ndvi_threshold: float = 0.4 - ) -> int: - """ - Counts buildings whose intersected tile-based NDVI_mean > threshold. - - Args: - region: The Shapely Polygon defining the area of interest. - ndvi_threshold: The minimum average NDVI for a tile to be considered 'high'. - - Returns: - The number of buildings within high-NDVI tile areas within the region. - """ - # Use the joined tiles gdf which includes both geometry and ndvi_mean - if ( - self._joined_tiles_gdf.empty - or "ndvi_mean" not in self._joined_tiles_gdf.columns - ): - print( - "Error: Joined tiles data is empty or missing 'ndvi_mean' for count_high_ndvi_buildings." - ) - return 0 - - # Ensure consistent CRS for tile intersection with the region - tiles_for_intersect = self._check_and_reproject_gdf( - self._joined_tiles_gdf.copy(), region.crs - ) - region_for_tiles_intersect = region # Assuming region's CRS is the target - - tiles_in_region = tiles_for_intersect.loc[ - tiles_for_intersect.intersects(region_for_tiles_intersect) - ].copy() - - if tiles_in_region.empty: - return 0 - - # Keep only high-NDVI tiles - high_ndvi_tiles = tiles_in_region.loc[ - tiles_in_region["ndvi_mean"] > ndvi_threshold - ].copy() - - if high_ndvi_tiles.empty: - return 0 - - # Buffer those tiles into a unioned polygon - # Ensure metric CRS for accurate buffering and union - high_ndvi_tiles_metric = self._check_and_reproject_gdf( - high_ndvi_tiles, self.target_metric_crs - ) - - try: - highveg_area_metric = high_ndvi_tiles_metric.unary_union - except Exception as e: - print( - f"Error performing unary_union on high NDVI tiles for count_high_ndvi_buildings: {e}" - ) - return 0 - - # Intersect buildings with that highveg_area ∩ region - # Ensure buildings_gdf is in the same CRS as the highveg_area_metric for intersection - buildings_to_intersect = self._check_and_reproject_gdf( - self._buildings_gdf.copy(), highveg_area_metric.crs - ) - - # Ensure the region is also in the metric CRS for the final intersection - region_metric, _ = self._prepare_geometry_for_crs( - region, self.target_metric_crs - ) - - try: - # Intersect buildings with the high vegetation area and the region - # Note: This can be computationally intensive for large datasets - intersected_buildings = buildings_to_intersect.loc[ - buildings_to_intersect.intersects(highveg_area_metric) - & buildings_to_intersect.intersects(region_metric) - ] - return len(intersected_buildings) - except Exception as e: - print( - f"Error during building intersection with high vegetation area and region in count_high_ndvi_buildings: {e}" - ) - return 0 - # ----------------------------------------------------------------------------- # 3) NDVI & other tile‐based stats # ----------------------------------------------------------------------------- @@ -656,6 +587,10 @@ def weighted_tile_stats_all(self, region: Polygon) -> Dict[str, float]: region_m, _ = self._prepare_geometry_for_crs(region, self.target_metric_crs) region_m_geom = region_m.geometry.iloc[0] + # Compute region area in metric units (m^2) + region_area_km2 = float(region_m_geom.area) / 1e6 + + tiles = tiles_m.loc[tiles_m.intersects(region_m_geom)] if tiles.empty: @@ -663,7 +598,7 @@ def weighted_tile_stats_all(self, region: Polygon) -> Dict[str, float]: try: tiles = tiles.copy().drop( - columns=["id"], errors="ignore" + columns=["id", "tile_total_Mg", "area_m2"], errors="ignore" ) # Avoid SettingWithCopyWarning tiles["intersect_area"] = tiles.geometry.intersection(region_m_geom).area total_area = tiles["intersect_area"].sum() @@ -682,6 +617,8 @@ def weighted_tile_stats_all(self, region: Polygon) -> Dict[str, float]: weighted_stats[col] = ( weighted_sum / total_area if total_area > 0 else float("nan") ) + # include region area in the returned dictionary + weighted_stats["region_area_km2"] = region_area_km2 return weighted_stats except Exception as e: print(f"Error calculating area-weighted averages for all stats: {e}") @@ -836,7 +773,40 @@ def par_mean(self, region: Polygon) -> float: The area-weighted mean PAR, or NaN if no tiles intersect or total area is zero. """ return self.weighted_tile_stat(region, "par_mean") + def region_total_biomass(self, region: Polygon, tile_total_col: str = "tile_total_Mg") -> float: + """ + Returns total biomass (Mg) inside `region` by summing each intersecting tile's + fractional contribution: tile_total_Mg * (intersection_area / tile_area). + """ + if self._joined_tiles_gdf.empty or tile_total_col not in self._joined_tiles_gdf.columns: + print(f"Error: Joined tiles data is empty or missing '{tile_total_col}'.") + return float("nan") + + # Work on copy and ensure metric CRS for area calculations + tiles_gdf = self._check_and_reproject_gdf(self._joined_tiles_gdf.copy(), self.target_metric_crs) + region_m, _ = self._prepare_geometry_for_crs(region, self.target_metric_crs) + region_geom = region_m.geometry.iloc[0] + + tiles = tiles_gdf.loc[tiles_gdf.intersects(region_geom)].copy() + if tiles.empty: + return 0.0 + # Ensure tile area column exists (area_m2) + if "area_m2" not in tiles.columns: + tiles["area_m2"] = tiles.geometry.area + + # Compute intersection areas and fractional contributions + tiles["intersect_area"] = tiles.geometry.intersection(region_geom).area + # Avoid negative/zero division + valid = tiles["area_m2"] > 0 + if not valid.any(): + return float("nan") + + tiles.loc[valid, "fraction"] = tiles.loc[valid, "intersect_area"] / tiles.loc[valid, "area_m2"] + tiles["fraction"] = tiles["fraction"].fillna(0.0).clip(lower=0.0, upper=1.0) + + tiles["contrib_Mg"] = tiles[tile_total_col] * tiles["fraction"] + return float(tiles["contrib_Mg"].sum()) # ----------------------------------------------------------------------------- # 4) Get Layer Geoms and Nearest‐neighbor queries # ----------------------------------------------------------------------------- @@ -1107,6 +1077,8 @@ def _analyze_environmental_metrics(self, region: Polygon) -> Dict[str, Any]: env_stats["vegetation_density"] = "Sparse vegetation" else: env_stats["vegetation_density"] = "Very limited vegetation" + # add total biomass for region + env_stats["total_biomass_Mg"] = self.region_total_biomass(region) return env_stats @@ -1131,63 +1103,7 @@ def analyze_region(self, region: Polygon) -> Dict[str, Any]: # "region_summary": self._generate_region_summary(region) } - # ----------------------------------------------------------------------------- - # 5) Generic SQL‐backed primitive (PostGIS) - Uncomment if using - # ----------------------------------------------------------------------------- - # def query_postgis(self, sql: str) -> gpd.GeoDataFrame: - # """ - # Runs a raw SQL query against PostGIS and returns a GeoDataFrame. - # - # Args: - # sql: The SQL query string. - # - # Returns: - # A GeoDataFrame containing the query results. - # """ - # if self._db_engine is None: - # print("Error: PostGIS database engine not initialized.") - # return gpd.GeoDataFrame() - # try: - # return gpd.read_postgis(sql, self._db_engine, geom_col="geom") - # except Exception as e: - # print(f"Error executing PostGIS query: {e}") - # return gpd.GeoDataFrame() - - # def avg_ndvi_postgis(self, region: Polygon) -> float: - # """ - # Computes area‐weighted average NDVI via PostGIS SQL. - # - # Args: - # region: The Shapely Polygon defining the area of interest. - # - # Returns: - # The area-weighted average NDVI from PostGIS, or NaN if an error occurs. - # """ - # if self._db_engine is None: - # print("Error: PostGIS database engine not initialized.") - # return float("nan") - # try: - # # Ensure region is in a suitable CRS for PostGIS (assuming 4326 for WKT) - # region_4326, _ = self._ensure_crs_for_calculation(region, "EPSG:4326") - # wkt = region_4326.wkt - # # Assuming your tile_stats table in PostGIS has columns ndvi_mean and geom (with SRID 4326) - # sql = f""" - # SELECT SUM(t.ndvi_mean * ST_Area(ST_Intersection(t.geom, ST_GeomFromText('{wkt}', 4326)))) - # / SUM(ST_Area(ST_Intersection(t.geom, ST_GeomFromText('{wkt}', 4326)))) - # AS avg_ndvi - # FROM tile_stats t - # WHERE ST_Intersects(t.geom, ST_GeomFromText('{wkt}', 4326)); - # """ - # df = self.query_postgis(sql) - # if not df.empty and 'avg_ndvi' in df.columns: - # return float(df["avg_ndvi"].iloc[0]) - # else: - # print("Warning: PostGIS query returned no results or expected column for avg_ndvi_postgis.") - # return float("nan") - # except Exception as e: - # print(f"Error executing PostGIS average NDVI query: {e}") - # return float("nan") - + # ----------------------------------------------------------------------------- # 6) Raster‐on‐the‐fly via Earth Engine - Uncomment if using # ----------------------------------------------------------------------------- @@ -1491,3 +1407,46 @@ def visualize_layers( # display(m) # Uncomment this line if you need to explicitly display return m # Return the map object + def _get_layer_gdf(self, layer_name: str) -> gpd.GeoDataFrame: + if layer_name not in self._layer_map: + raise ValueError( + f"Unknown layer name '{layer_name}'. Available layers: {list(self._layer_map.keys())}" + ) + return self._layer_map[layer_name] + + def get_layer_bounds(self, layer_name: str) -> List[float]: + gdf = self._get_layer_gdf(layer_name) + if gdf.empty: + return [float("nan")] * 4 + return gdf.total_bounds.tolist() + + def get_layer_geojson( + self, + layer_name: str, + *, + limit: Optional[int] = None, + sample: Optional[int] = None, + target_crs: str = "EPSG:4326", + ) -> Dict[str, Any]: + gdf = self._get_layer_gdf(layer_name) + if gdf.empty: + return {"type": "FeatureCollection", "features": []} + + subset = gdf + if sample is not None and sample > 0: + subset = subset.sample(min(sample, len(subset))) + elif limit is not None and limit > 0: + subset = subset.head(limit) + + if subset.crs is not None: + crs_str = subset.crs.to_string() if hasattr(subset.crs, "to_string") else str(subset.crs) + if crs_str != target_crs: + subset = subset.to_crs(target_crs) + else: + subset = subset.set_crs(target_crs, allow_override=True) + + return json.loads(subset.to_json()) + + def get_layer_count(self, layer_name: str) -> int: + gdf = self._get_layer_gdf(layer_name) + return int(len(gdf)) diff --git a/utils/GeospatialAnalyzer2.py b/utils/GeospatialAnalyzer2.py new file mode 100644 index 0000000..577e7a0 --- /dev/null +++ b/utils/GeospatialAnalyzer2.py @@ -0,0 +1,983 @@ +""" +GeospatialAnalyzer2 +Optimized version of GeospatialAnalyzer that pushes heavy spatial operations +to PostGIS when available, uses lazy loading, caching, and efficient SQL patterns. + +Usage summary: +- Instantiate with database_uri (defaults to local container credentials). +- Use `ingest_to_postgis` to bulk-load GeoJSON/CSV into PostGIS via ogr2ogr. +- Use query methods (weighted_tile_stats_all, region_total_biomass, nearest_mini_grids, + get_gdf_info_within_region) which prefer PostGIS SQL; falls back to GeoPandas + if DB not available. + +Designed for the suntrace repo. Keep logic similar to original GeospatialAnalyzer +but optimized for scale. +""" + +from __future__ import annotations + +import json +import logging +import os +import shlex +import subprocess +import tempfile +from functools import lru_cache +from typing import Any, Dict, List, Optional, Tuple + +import geopandas as gpd +import numpy as np +import pandas as pd +from shapely import wkt, wkb +from shapely.geometry import base, Point, Polygon +from sqlalchemy import create_engine, text + +from configs.stats import DEFAULT_TILE_STAT_COLUMNS + +# Configure logging for this module +logger = logging.getLogger("GeospatialAnalyzer2") +logger.setLevel(logging.INFO) + +# Default DB URI when running the recommended local PostGIS docker +DEFAULT_DB_URI = "postgresql+psycopg://pguser:pgpass@localhost:5432/suntrace" + + +class GeospatialAnalyzer2: + """Optimized geospatial analyzer. + + Key optimizations included: + - Push spatial aggregates and spatial joins to PostGIS SQL (area-weighted, + nearest-neighbor, counts). + - Use GiST spatial indexes and SRID-correct area computations (ST_Transform + to metric CRS before ST_Area). + - Bulk-load helpers using ogr2ogr for robust ingestion. + - Lazy loading and caching for repeated small queries. + - Fallback to GeoPandas when PostGIS engine is not available. + """ + + def __init__( + self, + database_uri: Optional[str] = None, + target_metric_crs: str = "EPSG:32636", + target_geographic_crs: str = "EPSG:4326", + ogr2ogr_path: str = "ogr2ogr", + layer_table_map: Optional[Dict[str, str]] = None, + ) -> None: + self._db_uri = database_uri or DEFAULT_DB_URI + self._db_engine = None + try: + self._db_engine = create_engine(self._db_uri) + logger.info("Created DB engine for %s", self._db_uri) + except Exception as e: + logger.warning("Could not create DB engine: %s; falling back to file-based ops", e) + self._db_engine = None + + self.target_metric_crs = target_metric_crs + self.target_geographic_crs = target_geographic_crs + self._ogr2ogr = ogr2ogr_path + + default_layer_map = { + "buildings": "public.lamwo_buildings", + "tiles": "public.joined_tiles", + "roads": "public.lamwo_roads", + "villages": "public.lamwo_villages", + "parishes": "public.lamwo_parishes", + "subcounties": "public.lamwo_subcounties", + "existing_grid": "public.existing_grid", + "grid_extension": "public.grid_extension", + "candidate_minigrids": "public.candidate_minigrids", + "existing_minigrids": "public.existing_minigrids", + "tile_stats": "public.lamwo_tile_stats_ee_biomass", + } + self._layer_table_map = default_layer_map if layer_table_map is None else {**default_layer_map, **layer_table_map} + self._id_column_cache: Dict[str, str] = {} + + # ----------------------------- Ingestion helpers -------------------------- + def update_layer_table_map(self, overrides: Dict[str, str]) -> None: + """Merge additional table mappings for logical layer names.""" + self._layer_table_map.update(overrides) + + def _resolve_table(self, layer_or_table: str) -> str: + table = self._layer_table_map.get(layer_or_table, layer_or_table) + if "." not in table and layer_or_table not in self._layer_table_map: + raise ValueError(f"Unknown layer or table '{layer_or_table}'") + return table + + def _escape_wkt_literal(self, geom_wkt: str) -> str: + return geom_wkt.replace("'", "''") + + def _get_identifier_column(self, table: str) -> str: + if table in self._id_column_cache: + return self._id_column_cache[table] + + if not self._db_engine: + return "ogc_fid" + + schema, tbl = (table.split(".") + [None])[:2] + if tbl is None: + tbl = schema + schema = "public" + query = text( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = :schema AND table_name = :table + """ + ) + with self._db_engine.connect() as conn: + cols = {row[0] for row in conn.execute(query, {"schema": schema, "table": tbl})} + + preferred = ["id", "tile_id", "pt_id", "ts_id", "ogc_fid"] + for cand in preferred: + if cand in cols: + self._id_column_cache[table] = cand + return cand + + chosen = next(iter(cols)) if cols else "ogc_fid" + self._id_column_cache[table] = chosen + return chosen + + def _parse_extent(self, extent_str: Optional[str]) -> List[float]: + if not extent_str: + return [float("nan")] * 4 + try: + extent_str = extent_str.replace("BOX(", "").replace(")", "") + min_part, max_part = extent_str.split(",") + minx, miny = map(float, min_part.strip().split()) + maxx, maxy = map(float, max_part.strip().split()) + return [minx, miny, maxx, maxy] + except Exception: + return [float("nan")] * 4 + + def ingest_to_postgis( + self, + layer_map: Dict[str, str], + schema: str = "public", + overwrite: bool = True, + srid: int = 4326, + dtype_map: Optional[Dict[str, str]] = None, + ) -> None: + """Bulk load files into PostGIS using ogr2ogr. + + layer_map: mapping of target_table_name -> local_file_path + dtype_map: optional mapping of layer -> geometry type (e.g. "MULTIPOLYGON") + + This uses subprocess+ogr2ogr because it is robust for many filetypes and + scales well for large GeoJSON/GeoPackage inputs. + """ + if not self._db_engine: + raise RuntimeError("No DB engine available for ingestion") + + for table, path in layer_map.items(): + geom_type = dtype_map.get(table) if dtype_map else None + geom_flag = f"-nlt {geom_type}" if geom_type else "" + overwrite_flag = "-overwrite" if overwrite else "-append" + + cmd = ( + f"{self._ogr2ogr} -f PostgreSQL " + f"PG:\"{self._db_uri}\" " + f"{shlex.quote(path)} -nln {schema}.{table} {geom_flag} " + f"-lco GEOMETRY_NAME=geom -t_srs EPSG:{srid} {overwrite_flag}" + ) + logger.info("Running ogr2ogr for %s -> %s.%s", path, schema, table) + # Shell because PG: connection string uses colon/equals; use shell True + res = subprocess.run(cmd, shell=True, capture_output=True, text=True) + if res.returncode != 0: + logger.error("ogr2ogr failed for %s: %s", path, res.stderr) + raise RuntimeError(f"ogr2ogr failed: {res.stderr}") + logger.info("Loaded %s into %s.%s", path, schema, table) + + def ensure_postgis_extensions(self) -> None: + """Create PostGIS extensions if missing (run once after DB init).""" + if not self._db_engine: + raise RuntimeError("No DB engine available to create extensions") + with self._db_engine.connect() as conn: + conn.execute(text("CREATE EXTENSION IF NOT EXISTS postgis;")) + conn.execute(text("CREATE EXTENSION IF NOT EXISTS postgis_topology;")) + logger.info("Ensured PostGIS extensions present") + + def create_spatial_index(self, table: str, geom_col: str = "geom") -> None: + if not self._db_engine: + raise RuntimeError("No DB engine available to create index") + sql = text(f"CREATE INDEX IF NOT EXISTS {table}_geom_gist ON {table} USING GIST({geom_col});") + with self._db_engine.connect() as conn: + conn.execute(sql) + logger.info("Created GiST index on %s(%s)", table, geom_col) + + # ----------------------------- Query helpers ------------------------------ + def query_postgis(self, sql: str, geom_col: Optional[str] = "geom") -> pd.DataFrame: + """Run SQL against PostGIS and return Geo/regular DataFrame depending on geom_col.""" + if not self._db_engine: + logger.debug("No DB engine; returning empty GeoDataFrame for SQL: %s", sql) + return gpd.GeoDataFrame() + try: + if geom_col: + return gpd.read_postgis(sql, self._db_engine, geom_col=geom_col) + return pd.read_sql_query(sql, self._db_engine) + except Exception as e: + logger.error("PostGIS query failed: %s", e) + return gpd.GeoDataFrame() + + def scalar_query(self, sql: str) -> Any: + """Run scalar SQL and return single value (first column of first row).""" + if not self._db_engine: + logger.debug("No DB engine for scalar query: %s", sql) + return None + with self._db_engine.connect() as conn: + res = conn.execute(text(sql)).fetchone() + return res[0] if res is not None else None + + # ----------------------------- Utility helpers ---------------------------- + def _region_as_wkt(self, region: base.BaseGeometry) -> str: + if isinstance(region, str): + return region + if hasattr(region, "to_wkt"): + return region.to_wkt() + return wkt.dumps(region) + + def _ensure_wgs84_wkt(self, region: base.BaseGeometry) -> str: + # Store/query in 4326 for WKT readability; upstream SQL will ST_Transform as needed + try: + # if region is a GeoSeries/GeoDataFrame element, convert + if hasattr(region, "__geo_interface__"): + return wkt.dumps(region) + except Exception: + pass + return wkt.dumps(region) + + # ----------------------------- High-level optimized methods -------------- + def count_features_within_region(self, region: base.BaseGeometry, layer_or_table: str) -> int: + """Count rows intersecting the region using PostGIS (fast) or GeoPandas fallback.""" + wkt_region = self._ensure_wgs84_wkt(region) + table = self._resolve_table(layer_or_table) + if self._db_engine: + sql = f"SELECT COUNT(*) FROM {table} WHERE ST_Intersects({table}.geom, ST_GeomFromText('{wkt_region}', 4326));" + res = self.scalar_query(sql) + return int(res or 0) + + # Fallback: load table as file-based gdf (user must implement mapping outside) + logger.debug("Fallback count - no DB engine") + return 0 + + def get_tile_ids_within_region(self, region: base.BaseGeometry, table: str = "tiles") -> List[str]: + """Return identifiers for tiles intersecting region (uses `ogc_fid` fallback).""" + if not self._db_engine: + logger.debug("Fallback get_tile_ids_within_region - no DB engine") + return [] + + table = self._resolve_table(table) + id_col = self._get_identifier_column(table) + region_wkt = self._escape_wkt_literal(self._ensure_wgs84_wkt(region)) + sql = f""" + WITH region AS (SELECT ST_GeomFromText('{region_wkt}', 4326) AS geom) + SELECT CAST(t.{id_col} AS text) AS tile_id + FROM {table} t, region r + WHERE t.geom IS NOT NULL + AND ST_Intersects(t.geom, r.geom) + ORDER BY tile_id; + """ + df = self.query_postgis(sql, geom_col=None) + if df.empty: + return [] + return [str(v) for v in df["tile_id"].tolist()] + + def get_gdf_info_within_region( + self, + region: base.BaseGeometry, + layer_or_table: str, + filter_expr: Optional[str] = None, + limit: Optional[int] = None, + ) -> gpd.GeoDataFrame: + """Return features from `table` that intersect region. Prefers PostGIS. + + table should be a fully-qualified table name or public.table. + """ + wkt_region = self._ensure_wgs84_wkt(region) + table = self._resolve_table(layer_or_table) + if self._db_engine: + where_clauses = [f"ST_Intersects({table}.geom, ST_GeomFromText('{wkt_region}', 4326))"] + if filter_expr: + where_clauses.append(filter_expr) + where_sql = " AND ".join(where_clauses) + limit_sql = f"LIMIT {int(limit)}" if limit else "" + sql = f"SELECT * FROM {table} WHERE {where_sql} {limit_sql};" + gdf = self.query_postgis(sql) + return gdf + + logger.debug("Fallback get_gdf_info_within_region - no DB engine") + return gpd.GeoDataFrame() + + # Layer-specific wrappers -------------------------------------------------- + def get_layer_info_within_region( + self, + region: base.BaseGeometry, + layer_name: str, + filter_expr: Optional[str] = None, + limit: Optional[int] = None, + ) -> gpd.GeoDataFrame: + return self.get_gdf_info_within_region(region, layer_name, filter_expr, limit) + + def get_tiles_info_within_region(self, region: base.BaseGeometry) -> gpd.GeoDataFrame: + return self.get_layer_info_within_region(region, "tiles") + + def get_roads_info_within_region(self, region: base.BaseGeometry) -> gpd.GeoDataFrame: + return self.get_layer_info_within_region(region, "roads") + + def get_villages_info_within_region(self, region: base.BaseGeometry) -> gpd.GeoDataFrame: + return self.get_layer_info_within_region(region, "villages") + + def get_parishes_info_within_region(self, region: base.BaseGeometry) -> gpd.GeoDataFrame: + return self.get_layer_info_within_region(region, "parishes") + + def get_subcounties_info_within_region(self, region: base.BaseGeometry) -> gpd.GeoDataFrame: + return self.get_layer_info_within_region(region, "subcounties") + + def get_existing_grid_info_within_region(self, region: base.BaseGeometry) -> gpd.GeoDataFrame: + return self.get_layer_info_within_region(region, "existing_grid") + + def get_grid_extension_info_within_region(self, region: base.BaseGeometry) -> gpd.GeoDataFrame: + return self.get_layer_info_within_region(region, "grid_extension") + + def get_candidate_minigrids_info_within_region(self, region: base.BaseGeometry) -> gpd.GeoDataFrame: + return self.get_layer_info_within_region(region, "candidate_minigrids") + + def get_existing_minigrids_info_within_region(self, region: base.BaseGeometry) -> gpd.GeoDataFrame: + return self.get_layer_info_within_region(region, "existing_minigrids") + + def nearest_mini_grids(self, pt: Point, table: str = "candidate_minigrids", k: int = 3) -> List[Tuple[str, float]]: + """KNN nearest neighbor using PostGIS operator (<->) which uses the spatial index. + + Returns list of tuples (id, distance_meters) + """ + wkt_pt = self._ensure_wgs84_wkt(pt) + if not self._db_engine: + logger.debug("Fallback nearest - no DB engine") + return [] + + table = self._resolve_table(table) + # Compute distance in metric CRS by transforming to metric CRS in the SQL + sql = f""" + SELECT id, ST_Distance(ST_Transform({table}.geom, {self.target_metric_crs.split(':')[-1]}), ST_Transform(ST_GeomFromText('{wkt_pt}',4326), {self.target_metric_crs.split(':')[-1]})) AS distance_m + FROM {table} + WHERE {table}.geom IS NOT NULL + ORDER BY {table}.geom <-> ST_GeomFromText('{wkt_pt}',4326) + LIMIT {int(k)}; + """ + df = self.query_postgis(sql, geom_col=None) + if df.empty: + return [] + return [(row["id"], float(row["distance_m"])) for _, row in df.iterrows()] + + def weighted_tile_stats_all( + self, + region: base.BaseGeometry, + table: str = "tile_stats", + stat_columns: Optional[List[str]] = None, + ) -> Dict[str, float]: + """Compute area-weighted averages for specified stat_columns within region. + + This builds a single SQL query that computes all requested weighted stats in one pass. + """ + if stat_columns is None: + stat_columns = DEFAULT_TILE_STAT_COLUMNS + + wkt_region = self._ensure_wgs84_wkt(region) + table = self._resolve_table(table) + if not self._db_engine: + logger.debug("Fallback weighted_tile_stats_all - no DB engine") + return {col: float("nan") for col in stat_columns} + + # Build weighted expressions using metric CRS area + metric_epsg = int(self.target_metric_crs.split(":")[-1]) + weighted_selects = [] + for col in stat_columns: + safe_col = col # if needed, sanitize/validate + weighted = ( + f"SUM({safe_col} * ST_Area(ST_Transform(ST_Intersection(t.geom, region.geom), {metric_epsg})))::double precision" + ) + denom = f"NULLIF(SUM(ST_Area(ST_Transform(ST_Intersection(t.geom, region.geom), {metric_epsg}))),0)" + alias = f"{safe_col}_wavg" + weighted_selects.append(f"({weighted} / {denom}) AS {alias}") + + selects_sql = ",\n ".join(weighted_selects) + + sql = f""" + WITH region AS (SELECT ST_GeomFromText('{wkt_region}', 4326) AS geom), + t AS (SELECT * FROM {table} WHERE {table}.geom IS NOT NULL AND ST_Intersects({table}.geom, (SELECT geom FROM region))) + SELECT + {selects_sql} + FROM t, region; + """ + + df = self.query_postgis(sql, geom_col=None) + if df.empty: + return {c: float("nan") for c in stat_columns} + + result: Dict[str, float] = {} + for col in stat_columns: + alias = f"{col}_wavg" + val = df.iloc[0].get(alias) + result[col] = float(val) if val is not None else float("nan") + return result + + def weighted_tile_stat(self, region: base.BaseGeometry, stat: str, table: str = "tile_stats") -> float: + stats = self.weighted_tile_stats_all(region, table=table, stat_columns=[stat]) + return stats.get(stat, float("nan")) + + def avg_ndvi(self, region: base.BaseGeometry) -> float: + return self.weighted_tile_stat(region, "ndvi_mean") + + def ndvi_stats(self, region: base.BaseGeometry) -> Dict[str, float]: + values = self.weighted_tile_stats_all(region, stat_columns=["ndvi_mean", "ndvi_med", "ndvi_std"]) + return { + "NDVI_mean": values.get("ndvi_mean", float("nan")), + "NDVI_med": values.get("ndvi_med", float("nan")), + "NDVI_std": values.get("ndvi_std", float("nan")), + } + + def evi_med(self, region: base.BaseGeometry) -> float: + return self.weighted_tile_stat(region, "evi_med") + + def cf_days(self, region: base.BaseGeometry) -> float: + return self.weighted_tile_stat(region, "cloud_free_days") + + def elev_mean(self, region: base.BaseGeometry) -> float: + return self.weighted_tile_stat(region, "elev_mean") + + def slope_mean(self, region: base.BaseGeometry) -> float: + return self.weighted_tile_stat(region, "slope_mean") + + def par_mean(self, region: base.BaseGeometry) -> float: + return self.weighted_tile_stat(region, "par_mean") + + def region_total_biomass( + self, + region: base.BaseGeometry, + table: str = "tile_stats", + tile_total_col: str = "tile_total_Mg", + ) -> float: + """Compute total biomass for a region using PostGIS area-weighting on tile totals. + + If tile_total_col holds per-tile totals (already aggregated per tile), this + method computes the fraction of each tile within the region and multiplies. + """ + wkt_region = self._ensure_wgs84_wkt(region) + table = self._resolve_table(table) + if not self._db_engine: + logger.debug("Fallback region_total_biomass - no DB engine") + return float("nan") + + metric_epsg = int(self.target_metric_crs.split(":")[-1]) + sql = f""" + WITH region AS (SELECT ST_GeomFromText('{wkt_region}', 4326) AS geom) + SELECT SUM((ST_Area(ST_Transform(ST_Intersection(t.geom, r.geom), {metric_epsg})) / NULLIF(ST_Area(ST_Transform(t.geom, {metric_epsg})),0)) * t.{tile_total_col})::double precision AS total_biomass + FROM {table} t, region r + WHERE ST_Intersects(t.geom, r.geom); + """ + val = self.scalar_query(sql) + return float(val or 0.0) + + + def list_mini_grids(self, table: str = "existing_minigrids") -> List[str]: + if not self._db_engine: + logger.debug("Fallback list_mini_grids - no DB engine") + return [] + + table = self._resolve_table(table) + sql = f"SELECT COALESCE(name, location::text, id::text) AS label FROM {table} WHERE geom IS NOT NULL ORDER BY label;" + df = self.query_postgis(sql, geom_col=None) + if df.empty: + return [] + return [str(v) for v in df["label"].dropna().tolist()] + + def get_layer_geometry(self, layer_name: str, region: base.BaseGeometry) -> Optional[base.BaseGeometry]: + if not self._db_engine: + logger.debug("Fallback get_layer_geometry - no DB engine") + return None + + table = self._resolve_table(layer_name) + region_wkt = self._escape_wkt_literal(self._ensure_wgs84_wkt(region)) + sql = f""" + WITH region AS (SELECT ST_GeomFromText('{region_wkt}', 4326) AS geom), + clipped AS ( + SELECT ST_Intersection(t.geom, region.geom) AS geom + FROM {table} t, region + WHERE t.geom IS NOT NULL AND ST_Intersects(t.geom, region.geom) + ) + SELECT ST_AsEWKB(ST_UnaryUnion(clipped.geom)) AS geom + FROM clipped; + """ + with self._db_engine.connect() as conn: + row = conn.execute(text(sql)).fetchone() + if not row or row[0] is None: + return None + return wkb.loads(bytes(row[0])) + + def compute_distance_to_grid(self, geometry: base.BaseGeometry) -> float: + if not self._db_engine: + logger.debug("Fallback compute_distance_to_grid - no DB engine") + return float("nan") + + table = self._resolve_table("existing_grid") + geom_wkt = self._escape_wkt_literal(self._ensure_wgs84_wkt(geometry)) + metric_epsg = int(self.target_metric_crs.split(":")[-1]) + sql = f""" + WITH region AS (SELECT ST_GeomFromText('{geom_wkt}', 4326) AS geom) + SELECT MIN(ST_Distance(ST_Transform(t.geom, {metric_epsg}), ST_Transform(region.geom, {metric_epsg}))) + FROM {table} t, region + WHERE t.geom IS NOT NULL; + """ + val = self.scalar_query(sql) + return float(val) if val is not None else float("nan") + + # ----------------------------- Region analysis pipelines ------------------ + def get_layer_bounds(self, layer_name: str, target_epsg: int = 4326) -> List[float]: + if not self._db_engine: + logger.debug("Fallback get_layer_bounds - no DB engine") + return [float("nan")] * 4 + + table = self._resolve_table(layer_name) + sql = text( + f"SELECT ST_Extent(ST_Transform(t.geom, {target_epsg})) FROM {table} AS t WHERE t.geom IS NOT NULL;" + ) + with self._db_engine.connect() as conn: + row = conn.execute(sql).fetchone() + return self._parse_extent(row[0] if row else None) + + def get_layer_geojson( + self, + layer_name: str, + *, + limit: Optional[int] = None, + sample: Optional[int] = None, + target_epsg: int = 4326, + ) -> Dict[str, Any]: + if not self._db_engine: + logger.debug("Fallback get_layer_geojson - no DB engine") + return {"type": "FeatureCollection", "features": []} + + table = self._resolve_table(layer_name) + order_clause = "ORDER BY random()" if sample and sample > 0 else "" + row_limit = sample if sample and sample > 0 else limit + limit_clause = f"LIMIT {int(row_limit)}" if row_limit else "" + + sql = f""" + WITH features AS ( + SELECT jsonb_build_object( + 'type', 'Feature', + 'geometry', ST_AsGeoJSON(ST_Transform(t.geom, {target_epsg}))::jsonb, + 'properties', to_jsonb(t) - 'geom' + ) AS feature + FROM {table} AS t + WHERE t.geom IS NOT NULL + {order_clause} + {limit_clause} + ) + SELECT COALESCE( + jsonb_build_object( + 'type', 'FeatureCollection', + 'features', COALESCE(jsonb_agg(feature), '[]'::jsonb) + )::text, + '{{"type":"FeatureCollection","features":[]}}' + ) + FROM features; + """ + + with self._db_engine.connect() as conn: + row = conn.execute(text(sql)).fetchone() + if not row or row[0] is None: + return {"type": "FeatureCollection", "features": []} + payload = row[0] + if isinstance(payload, str): + return json.loads(payload) + return payload + + def get_layer_count(self, layer_name: str) -> int: + if not self._db_engine: + logger.debug("Fallback get_layer_count - no DB engine") + return 0 + table = self._resolve_table(layer_name) + sql = f"SELECT COUNT(*) FROM {table} AS t WHERE t.geom IS NOT NULL;" + return int(self.scalar_query(sql) or 0) + + def _analyze_settlements_in_region(self, region: base.BaseGeometry) -> Dict[str, Any]: + if not self._db_engine: + logger.debug("Fallback _analyze_settlements_in_region - no DB engine") + return { + "building_count": 0, + "building_categories": {}, + "intersecting_village_count": 0, + "intersecting_village_details": [], + "has_truncated_villages": False, + } + + region_wkt = self._escape_wkt_literal(self._ensure_wgs84_wkt(region)) + buildings_table = self._resolve_table("buildings") + villages_table = self._resolve_table("villages") + + building_count_sql = f""" + WITH region AS (SELECT ST_GeomFromText('{region_wkt}', 4326) AS geom) + SELECT COUNT(*) + FROM {buildings_table} b, region r + WHERE b.geom IS NOT NULL AND ST_Intersects(b.geom, r.geom); + """ + building_count = int(self.scalar_query(building_count_sql) or 0) + + categories_sql = f""" + WITH region AS (SELECT ST_GeomFromText('{region_wkt}', 4326) AS geom) + SELECT COALESCE(NULLIF(TRIM(CAST(b.category AS text)), ''), 'residential') AS category, + COUNT(*)::bigint AS feature_count + FROM {buildings_table} b, region r + WHERE b.geom IS NOT NULL AND ST_Intersects(b.geom, r.geom) + GROUP BY category + ORDER BY feature_count DESC; + """ + cat_df = self.query_postgis(categories_sql, geom_col=None) + building_categories = { + (row.get("category") or "residential"): int(row.get("feature_count", 0)) + for _, row in cat_df.iterrows() + } if not cat_df.empty else {} + + villages_total_sql = f""" + WITH region AS (SELECT ST_GeomFromText('{region_wkt}', 4326) AS geom) + SELECT COUNT(*) + FROM {villages_table} v, region r + WHERE v.geom IS NOT NULL AND ST_Intersects(v.geom, r.geom); + """ + total_villages = int(self.scalar_query(villages_total_sql) or 0) + + villages_detail_sql = f""" + WITH region AS (SELECT ST_GeomFromText('{region_wkt}', 4326) AS geom) + SELECT + COALESCE(CAST(v.addr_vname AS text), 'Unnamed') AS name, + COALESCE(CAST(v.category AS text), 'Unknown') AS electrification_category, + v.rank + FROM {villages_table} v, region r + WHERE v.geom IS NOT NULL AND ST_Intersects(v.geom, r.geom) + ORDER BY name + LIMIT 20; + """ + villages_df = self.query_postgis(villages_detail_sql, geom_col=None) + village_data: List[Dict[str, Any]] = [] + if not villages_df.empty: + for _, row in villages_df.iterrows(): + info = { + "name": row.get("name", "Unnamed"), + "electrification_category": row.get("electrification_category", "Unknown"), + } + rank_val = row.get("rank") + if rank_val is not None and not pd.isna(rank_val): + try: + info["priority_rank"] = int(rank_val) + except Exception: + pass + village_data.append(info) + + return { + "building_count": building_count, + "building_categories": building_categories, + "intersecting_village_count": total_villages, + "intersecting_village_details": village_data, + "has_truncated_villages": total_villages > len(village_data), + } + + def _analyze_infrastructure_in_region(self, region: base.BaseGeometry) -> Dict[str, Any]: + if not self._db_engine: + logger.debug("Fallback _analyze_infrastructure_in_region - no DB engine") + return { + "roads": {"total_road_segments": 0, "road_types": {}}, + "electricity": { + "existing_grid_present": False, + "distance_to_existing_grid": float("nan"), + "grid_extension_proposed": False, + "candidate_minigrids_count": 0, + "existing_minigrids_count": 0, + "capacity_distribution": {}, + "population_to_be_served": 0, + }, + } + + region_wkt = self._escape_wkt_literal(self._ensure_wgs84_wkt(region)) + roads_table = self._resolve_table("roads") + candidate_table = self._resolve_table("candidate_minigrids") + existing_table = self._resolve_table("existing_minigrids") + + roads_total_sql = f""" + WITH region AS (SELECT ST_GeomFromText('{region_wkt}', 4326) AS geom) + SELECT COUNT(*) + FROM {roads_table} r, region + WHERE r.geom IS NOT NULL AND ST_Intersects(r.geom, region.geom); + """ + total_roads = int(self.scalar_query(roads_total_sql) or 0) + + road_types_sql = f""" + WITH region AS (SELECT ST_GeomFromText('{region_wkt}', 4326) AS geom) + SELECT COALESCE(CAST(r.highway AS text), 'unknown') AS highway, + COUNT(*)::bigint AS feature_count + FROM {roads_table} r, region + WHERE r.geom IS NOT NULL AND ST_Intersects(r.geom, region.geom) + GROUP BY highway + ORDER BY feature_count DESC; + """ + road_types_df = self.query_postgis(road_types_sql, geom_col=None) + road_types = { + (row.get("highway") or "unknown"): int(row.get("feature_count", 0)) + for _, row in road_types_df.iterrows() + } if not road_types_df.empty else {} + + grid_present = self.count_features_within_region(region, "existing_grid") > 0 + grid_extension = self.count_features_within_region(region, "grid_extension") > 0 + distance_to_grid = self.compute_distance_to_grid(region) + + candidate_counts_sql = f""" + WITH region AS (SELECT ST_GeomFromText('{region_wkt}', 4326) AS geom) + SELECT COUNT(*) AS cnt, SUM(COALESCE(candidate.population, 0)) AS population_sum + FROM {candidate_table} candidate, region + WHERE candidate.geom IS NOT NULL AND ST_Intersects(candidate.geom, region.geom); + """ + candidate_row = self.query_postgis(candidate_counts_sql, geom_col=None) + candidate_count = 0 + population_sum = 0 + if not candidate_row.empty: + candidate_count = int(candidate_row.iloc[0].get("cnt") or 0) + population_sum = int(candidate_row.iloc[0].get("population_sum") or 0) + + capacity_sql = f""" + WITH region AS (SELECT ST_GeomFromText('{region_wkt}', 4326) AS geom) + SELECT COALESCE(CAST(candidate.capacity AS text), 'unknown') AS capacity, + COUNT(*)::bigint AS feature_count + FROM {candidate_table} candidate, region + WHERE candidate.geom IS NOT NULL AND ST_Intersects(candidate.geom, region.geom) + GROUP BY capacity + ORDER BY feature_count DESC; + """ + capacity_df = self.query_postgis(capacity_sql, geom_col=None) + capacity_distribution = { + (row.get("capacity") or "unknown"): int(row.get("feature_count", 0)) + for _, row in capacity_df.iterrows() + } if not capacity_df.empty else {} + + existing_minigrids_count = self.count_features_within_region(region, existing_table) + + return { + "roads": {"total_road_segments": total_roads, "road_types": road_types}, + "electricity": { + "existing_grid_present": grid_present, + "distance_to_existing_grid": distance_to_grid, + "grid_extension_proposed": grid_extension, + "candidate_minigrids_count": candidate_count, + "existing_minigrids_count": existing_minigrids_count, + "capacity_distribution": capacity_distribution, + "population_to_be_served": population_sum, + }, + } + + def _analyze_administrative_divisions(self, region: base.BaseGeometry) -> Dict[str, Any]: + if not self._db_engine: + logger.debug("Fallback _analyze_administrative_divisions - no DB engine") + return { + "parishes": {"count": 0, "details": []}, + "subcounties": {"count": 0, "names": []}, + } + + region_wkt = self._escape_wkt_literal(self._ensure_wgs84_wkt(region)) + parishes_table = self._resolve_table("parishes") + subcounties_table = self._resolve_table("subcounties") + + parishes_sql = f""" + WITH region AS (SELECT ST_GeomFromText('{region_wkt}', 4326) AS geom) + SELECT COALESCE(CAST(p.addr_pname AS text), 'Unnamed') AS name, + COALESCE(CAST(p.category AS text), 'Unknown') AS electrification_category + FROM {parishes_table} p, region + WHERE p.geom IS NOT NULL AND ST_Intersects(p.geom, region.geom); + """ + parishes_df = self.query_postgis(parishes_sql, geom_col=None) + parish_details: List[Dict[str, Any]] = [] + if not parishes_df.empty: + for _, row in parishes_df.iterrows(): + parish_details.append( + { + "name": row.get("name", "Unnamed"), + "electrification_category": row.get("electrification_category", "Unknown"), + } + ) + + subcounties_sql = f""" + WITH region AS (SELECT ST_GeomFromText('{region_wkt}', 4326) AS geom) + SELECT DISTINCT COALESCE(CAST(s.addr_sname AS text), 'Unnamed') AS name + FROM {subcounties_table} s, region + WHERE s.geom IS NOT NULL AND ST_Intersects(s.geom, region.geom) + ORDER BY name; + """ + subcounties_df = self.query_postgis(subcounties_sql, geom_col=None) + subcounty_names = ( + subcounties_df["name"].dropna().tolist() if not subcounties_df.empty else [] + ) + + return { + "parishes": {"count": len(parish_details), "details": parish_details}, + "subcounties": {"count": len(subcounty_names), "names": subcounty_names}, + } + + def _analyze_environmental_metrics(self, region: base.BaseGeometry) -> Dict[str, Any]: + stats = self.weighted_tile_stats_all(region) + if not stats: + return {} + + for key, value in list(stats.items()): + if isinstance(value, (int, float)) and not np.isnan(value): + stats[key] = round(float(value), 4) + + ndvi_value = stats.get("ndvi_mean", float("nan")) + if isinstance(ndvi_value, (int, float)) and not np.isnan(ndvi_value): + if ndvi_value > 0.5: + stats["vegetation_density"] = "Dense vegetation" + elif ndvi_value > 0.2: + stats["vegetation_density"] = "Moderate vegetation" + elif ndvi_value > 0: + stats["vegetation_density"] = "Sparse vegetation" + else: + stats["vegetation_density"] = "Very limited vegetation" + + biomass = self.region_total_biomass(region) + if isinstance(biomass, (int, float)) and not np.isnan(biomass): + stats["total_biomass_Mg"] = round(float(biomass), 4) + else: + stats["total_biomass_Mg"] = float("nan") + + try: + gseries = gpd.GeoSeries([region], crs=self.target_geographic_crs) + area_km2 = float(gseries.to_crs(self.target_metric_crs).area.iloc[0] / 1e6) + stats["region_area_km2"] = round(area_km2, 4) + except Exception: + stats["region_area_km2"] = float("nan") + return stats + + def analyze_region(self, region: base.BaseGeometry) -> Dict[str, Any]: + return { + "settlements": self._analyze_settlements_in_region(region), + "infrastructure": self._analyze_infrastructure_in_region(region), + "administrative": self._analyze_administrative_divisions(region), + "environment": self._analyze_environmental_metrics(region), + } + + def create_joined_tiles( + self, + tile_stats_table: str, + plain_tiles_table: str, + joined_table: str = "public.joined_tiles", + metric_epsg: int = 32636, + ) -> None: + """Create a DB-side joined tiles table that merges tile stats with plain tile geometries. + + The function will: + - create temporary tables with stable text IDs when needed (row number fallback), + - left-join stats -> geometries on those ids, + - ensure geometry SRID and type, + - compute area_m2 and area_ha, + - detect biomass-like columns and compute `tile_total_Mg` when missing. + + This keeps heavy work in PostGIS and returns quickly once materialized. + """ + if not self._db_engine: + raise RuntimeError("No DB engine available to create joined tiles") + + # Normalize inputs (strip public. prefix for table checks) + def short(t: str) -> str: + return t.split(".")[-1] + + stats_short = short(tile_stats_table) + tiles_short = short(plain_tiles_table) + joined_short = short(joined_table) + + stmts = [] + + # Create plain tiles with deterministic pt_id + stmts.append(f"DROP TABLE IF EXISTS public.{tiles_short}_with_id;") + stmts.append( + f"CREATE TABLE public.{tiles_short}_with_id AS SELECT *, COALESCE(CAST(id AS text), (ROW_NUMBER() OVER ())::text) AS pt_id FROM {plain_tiles_table};" + ) + + # Create tile stats with deterministic ts_id + stmts.append(f"DROP TABLE IF EXISTS public.{stats_short}_with_id;") + stmts.append( + f"CREATE TABLE public.{stats_short}_with_id AS SELECT *, COALESCE(CAST(id AS text), (ROW_NUMBER() OVER ())::text) AS ts_id FROM {tile_stats_table};" + ) + + # Create joined table by left joining stats -> tiles on id text + stmts.append(f"DROP TABLE IF EXISTS {joined_table};") + stmts.append( + f"CREATE TABLE {joined_table} AS SELECT s.*, t.geom FROM public.{stats_short}_with_id s LEFT JOIN public.{tiles_short}_with_id t ON s.ts_id = t.pt_id;" + ) + + # Ensure geometry SRID and create spatial index + stmts.append( + f"ALTER TABLE {joined_table} ALTER COLUMN geom TYPE geometry(Geometry,4326) USING ST_SetSRID(geom,4326);" + ) + stmts.append(f"CREATE INDEX IF NOT EXISTS {joined_short}_geom_gist ON {joined_table} USING GIST(geom);") + + # Compute area in metric CRS + stmts.append(f"ALTER TABLE {joined_table} DROP COLUMN IF EXISTS area_m2;") + stmts.append(f"ALTER TABLE {joined_table} ADD COLUMN area_m2 double precision;") + stmts.append( + f"UPDATE {joined_table} SET area_m2 = ST_Area(ST_Transform(geom, {metric_epsg}));" + ) + stmts.append(f"ALTER TABLE {joined_table} DROP COLUMN IF EXISTS area_ha;") + stmts.append(f"ALTER TABLE {joined_table} ADD COLUMN area_ha double precision;") + stmts.append(f"UPDATE {joined_table} SET area_ha = area_m2 / 10000.0;") + + # Detect biomass-like columns and populate tile_total_Mg if missing + # Strategy: if tile_total_Mg exists in stats, keep; else look for any column containing 'biomass' or 'Mg_ha' and compute + with self._db_engine.connect() as conn: + for s in stmts: + try: + conn.execute(text(s)) + except Exception as e: + logger.warning("Statement failed during create_joined_tiles: %s -> %s", s, e) + + # Inspect columns + res = conn.execute( + text( + "SELECT column_name FROM information_schema.columns WHERE table_name = :t" + ), + {"t": joined_short}, + ) + cols = {row[0].lower() for row in res.fetchall()} + + if "tile_total_mg" in cols: + logger.info("joined table already has tile_total_Mg; no computation needed") + else: + # find candidate biomass cols + candidates = [c for c in cols if "biomass" in c or "mg_ha" in c or "mgperha" in c] + if candidates: + col = candidates[0] + logger.info("Computing tile_total_Mg from %s * area_ha", col) + # create column and compute + try: + conn.execute(text(f"ALTER TABLE {joined_table} ADD COLUMN tile_total_mg double precision;")) + except Exception: + pass + # If candidate is per-hectare (units mg/ha or Mg/ha), multiply by area_ha + conn.execute( + text( + f"UPDATE {joined_table} SET tile_total_mg = COALESCE({col}::double precision,0) * COALESCE(area_ha,0);" + ) + ) + else: + logger.info("No biomass-like column found; leaving tile_total_Mg empty") + + # Ensure index on potential id columns + try: + conn.execute(text(f"CREATE INDEX IF NOT EXISTS {joined_short}_id_idx ON {joined_table} ((COALESCE(id::text,'')));")) + except Exception: + pass + + logger.info("Created joined tiles table: %s", joined_table) + + # ----------------------------- Convenience utilities --------------------- + @lru_cache(maxsize=256) + def cached_count(self, region_wkt: str, table: str) -> int: + return self.count_features_within_region(wkt.loads(region_wkt), table) + + +# End of GeospatialAnalyzer2 diff --git a/utils/factory.py b/utils/factory.py index c24db8e..8962f82 100644 --- a/utils/factory.py +++ b/utils/factory.py @@ -1,99 +1,88 @@ -# Create a factory function in a new file (e.g., src/utils/factory.py) +"""Factory helpers for constructing geospatial analyzers.""" + +from __future__ import annotations + +import logging import os import sys -from utils.GeospatialAnalyzer import GeospatialAnalyzer +from typing import Dict, Optional + from configs import paths +from utils.GeospatialAnalyzer import GeospatialAnalyzer +from utils.GeospatialAnalyzer2 import GeospatialAnalyzer2, DEFAULT_DB_URI + +logger = logging.getLogger(__name__) -# Add the src directory to the Python path +# Ensure repo-relative imports continue to work in legacy contexts sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) -# Add the project root (one level up) to the Python path for configs -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) +def _resolve_data_path(relative: Optional[str], default_path) -> str: + if relative: + return os.path.join(os.path.dirname(__file__), "../../data/" + relative) + return default_path def create_geospatial_analyzer( - bpath=None, - mpath=None, - tpath=None, - ppath=None, - cmpath=None, - empath=None, - egpath=None, - gepath=None, - rpath=None, - vdir=None, - vpath=None, - papath=None, - spath=None, - sr=None, + bpath: Optional[str] = None, + tpath: Optional[str] = None, + ppath: Optional[str] = None, + cmpath: Optional[str] = None, + empath: Optional[str] = None, + egpath: Optional[str] = None, + gepath: Optional[str] = None, + rpath: Optional[str] = None, + vpath: Optional[str] = None, + papath: Optional[str] = None, + spath: Optional[str] = None, + *, + use_postgis: Optional[bool] = None, + database_uri: Optional[str] = None, + layer_table_map: Optional[Dict[str, str]] = None, ): - buildings_path = ( - os.path.join(os.path.dirname(__file__), "../../data/" + bpath) - if bpath - else paths.BUILDINGS_PATH - ) - tile_stats_path = ( - os.path.join(os.path.dirname(__file__), "../../data/" + tpath) - if tpath - else paths.TILE_STATS_PATH - ) - plain_tiles_path = ( - os.path.join(os.path.dirname(__file__), "../../data/" + ppath) - if ppath - else paths.PLAIN_TILES_PATH - ) - candidate_minigrids_path = ( - os.path.join(os.path.dirname(__file__), "../../data/" + cmpath) - if cmpath - else paths.CANDIDATE_MINIGRIDS_PATH - ) - existing_minigrids_path = ( - os.path.join(os.path.dirname(__file__), "../../data/" + empath) - if empath - else paths.EXISTING_MINIGRIDS_PATH - ) - existing_grid_path = ( - os.path.join(os.path.dirname(__file__), "../../data/" + egpath) - if egpath - else paths.EXISTING_GRID_PATH - ) - grid_extension_path = ( - os.path.join(os.path.dirname(__file__), "../../data/" + gepath) - if gepath - else paths.GRID_EXTENSION_PATH - ) - roads_path = ( - os.path.join(os.path.dirname(__file__), "../../data/" + rpath) - if rpath - else paths.ROADS_PATH - ) - villages_path = ( - os.path.join(os.path.dirname(__file__), "../../data/" + vpath) - if vpath - else paths.VILLAGES_PATH - ) - parishes_path = ( - os.path.join(os.path.dirname(__file__), "../../data/" + papath) - if papath - else paths.PARISHES_PATH - ) - subcounties_path = ( - os.path.join(os.path.dirname(__file__), "../../data/" + spath) - if spath - else paths.SUBCOUNTIES_PATH - ) + """Return a geospatial analyzer tailored to the current environment. + + If PostGIS configuration is detected (either via explicit parameters or + environment variables), instantiate the PostGIS-first analyzer. Otherwise + fall back to the legacy GeoPandas implementation that reads local files. + """ + + if use_postgis is None: + env_flag = os.getenv("SUNTRACE_USE_POSTGIS") + if env_flag is not None: + use_postgis = env_flag.lower() in {"1", "true", "yes", "on"} + else: + use_postgis = bool(os.getenv("SUNTRACE_DATABASE_URI")) + + if database_uri is None: + database_uri = os.getenv("SUNTRACE_DATABASE_URI") + + if use_postgis: + uri = database_uri or DEFAULT_DB_URI + try: + analyzer = GeospatialAnalyzer2(database_uri=uri, layer_table_map=layer_table_map) + logger.info("Initialized PostGIS analyzer at %s", uri) + return analyzer + except Exception as exc: # pragma: no cover - defensive fallback + logger.warning( + "GeospatialAnalyzer2 init failed (%s); falling back to file-based analyzer", + exc, + ) - return GeospatialAnalyzer( - buildings_path=buildings_path, - tile_stats_path=tile_stats_path, - plain_tiles_path=plain_tiles_path, - candidate_minigrids_path=candidate_minigrids_path, - existing_minigrids_path=existing_minigrids_path, - existing_grid_path=existing_grid_path, - grid_extension_path=grid_extension_path, - roads_path=roads_path, - villages_path=villages_path, - parishes_path=parishes_path, - subcounties_path=subcounties_path, + # Fallback: construct GeoPandas-based analyzer from file paths + analyzer = GeospatialAnalyzer( + buildings_path=_resolve_data_path(bpath, paths.BUILDINGS_PATH), + tile_stats_path=_resolve_data_path(tpath, paths.TILE_STATS_PATH), + plain_tiles_path=_resolve_data_path(ppath, paths.PLAIN_TILES_PATH), + candidate_minigrids_path=_resolve_data_path(cmpath, paths.CANDIDATE_MINIGRIDS_PATH), + existing_minigrids_path=_resolve_data_path(empath, paths.EXISTING_MINIGRIDS_PATH), + existing_grid_path=_resolve_data_path(egpath, paths.EXISTING_GRID_PATH), + grid_extension_path=_resolve_data_path(gepath, paths.GRID_EXTENSION_PATH), + roads_path=_resolve_data_path(rpath, paths.ROADS_PATH), + villages_path=_resolve_data_path(vpath, paths.VILLAGES_PATH), + parishes_path=_resolve_data_path(papath, paths.PARISHES_PATH), + subcounties_path=_resolve_data_path(spath, paths.SUBCOUNTIES_PATH), ) + logger.info("Initialized file-based analyzer (GeoPandas)") + return analyzer diff --git a/utils/langraph_function_caller.py b/utils/langraph_function_caller.py index 048fa5c..ba46c5d 100644 --- a/utils/langraph_function_caller.py +++ b/utils/langraph_function_caller.py @@ -85,7 +85,6 @@ def analyze_region(region: str) -> Dict[str, Any]: """ Performs comprehensive analysis of a geographic region, providing structured insights about settlements, infrastructure, and environmental characteristics. - Args: region: The geographic area (as a Shapely Polygon in WKT format) to analyze diff --git a/utils/llm_function_caller.py b/utils/llm_function_caller.py index f01b95a..3964a20 100644 --- a/utils/llm_function_caller.py +++ b/utils/llm_function_caller.py @@ -314,11 +314,6 @@ def ask_with_functions(user_prompt, analyzer=None): """ - elif tool_name == "count_high_ndvi_buildings": - region = wkt_loads(parameters["region"]) - ndvi_threshold = parameters.get("ndvi_threshold", 0.4) - return geospatial_analyzer.count_high_ndvi_buildings(region, ndvi_threshold) - elif tool_name == "avg_ndvi": region = wkt_loads(parameters["region"]) return geospatial_analyzer.avg_ndvi(region) @@ -354,4 +349,4 @@ def ask_with_functions(user_prompt, analyzer=None): return geospatial_analyzer.visualize_layers( center_point, zoom_start, show_buildings, show_minigrids, show_tiles, show_tile_stats ) -""" \ No newline at end of file +"""