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",
+ " geometry | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " POLYGON ((32.20445 3.49577, 32.20446 3.48679, ... | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " POLYGON ((32.20445 3.50482, 32.20445 3.49577, ... | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " POLYGON ((32.20445 3.50482, 32.20111 3.50482, ... | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " MULTIPOLYGON (((32.20443 3.52291, 32.20443 3.5... | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " POLYGON ((32.20443 3.52291, 32.20350 3.52291, ... | \n",
+ "
\n",
+ " \n",
+ "
\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",
+ " area_m2 | \n",
+ " area_ha | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | count | \n",
+ " 5.657000e+03 | \n",
+ " 5657.000000 | \n",
+ "
\n",
+ " \n",
+ " | mean | \n",
+ " 9.615587e+05 | \n",
+ " 96.155866 | \n",
+ "
\n",
+ " \n",
+ " | std | \n",
+ " 1.625242e+05 | \n",
+ " 16.252418 | \n",
+ "
\n",
+ " \n",
+ " | min | \n",
+ " 5.218976e+00 | \n",
+ " 0.000522 | \n",
+ "
\n",
+ " \n",
+ " | 25% | \n",
+ " 1.000000e+06 | \n",
+ " 100.000000 | \n",
+ "
\n",
+ " \n",
+ " | 50% | \n",
+ " 1.000000e+06 | \n",
+ " 100.000000 | \n",
+ "
\n",
+ " \n",
+ " | 75% | \n",
+ " 1.000000e+06 | \n",
+ " 100.000000 | \n",
+ "
\n",
+ " \n",
+ " | max | \n",
+ " 1.000000e+06 | \n",
+ " 100.000000 | \n",
+ "
\n",
+ " \n",
+ "
\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",
+ " r_sum | \n",
+ " r_mean | \n",
+ " r_count | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " 1546.604736 | \n",
+ " 2.962844 | \n",
+ " 522 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " 2166.687744 | \n",
+ " 3.135583 | \n",
+ " 691 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " 439.708069 | \n",
+ " 2.484226 | \n",
+ " 177 | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " 107.045807 | \n",
+ " 2.378796 | \n",
+ " 45 | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " 336.109985 | \n",
+ " 3.734555 | \n",
+ " 90 | \n",
+ "
\n",
+ " \n",
+ "
\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",
+ " tile_total_Mg | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | count | \n",
+ " 5650.000000 | \n",
+ "
\n",
+ " \n",
+ " | mean | \n",
+ " 3543.429800 | \n",
+ "
\n",
+ " \n",
+ " | std | \n",
+ " 2078.213594 | \n",
+ "
\n",
+ " \n",
+ " | min | \n",
+ " 0.000000 | \n",
+ "
\n",
+ " \n",
+ " | 25% | \n",
+ " 2446.498840 | \n",
+ "
\n",
+ " \n",
+ " | 50% | \n",
+ " 3055.315796 | \n",
+ "
\n",
+ " \n",
+ " | 75% | \n",
+ " 3805.678101 | \n",
+ "
\n",
+ " \n",
+ " | max | \n",
+ " 23974.714844 | \n",
+ "
\n",
+ " \n",
+ "
\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",
+ " ndvi_mean | \n",
+ " ndvi_med | \n",
+ " ndvi_std | \n",
+ " evi_med | \n",
+ " elev_mean | \n",
+ " slope_mean | \n",
+ " par_mean | \n",
+ " rain_total_mm | \n",
+ " rain_mean_mm_day | \n",
+ " cloud_free_days | \n",
+ " bldg_count | \n",
+ " bldg_area | \n",
+ " bldg_h_max | \n",
+ " system:index | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " -0.507665 | \n",
+ " -0.662579 | \n",
+ " 0.205291 | \n",
+ " 1.927150 | \n",
+ " 661.247028 | \n",
+ " 3.468728 | \n",
+ " 189.643417 | \n",
+ " 9.334060 | \n",
+ " 3.269485 | \n",
+ " 29 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 0 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " -0.517304 | \n",
+ " -0.628960 | \n",
+ " 0.206539 | \n",
+ " 1.978630 | \n",
+ " 660.667390 | \n",
+ " 3.641268 | \n",
+ " 188.999216 | \n",
+ " 18.509185 | \n",
+ " 3.241650 | \n",
+ " 29 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 1 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " -0.410299 | \n",
+ " -0.587629 | \n",
+ " 0.164471 | \n",
+ " 1.775164 | \n",
+ " 652.781977 | \n",
+ " 5.080677 | \n",
+ " 182.854523 | \n",
+ " 0.000000 | \n",
+ " NaN | \n",
+ " 29 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 2 | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " -0.527696 | \n",
+ " -0.626940 | \n",
+ " 0.193225 | \n",
+ " 1.772409 | \n",
+ " 653.080518 | \n",
+ " 3.040932 | \n",
+ " 182.854523 | \n",
+ " 0.000000 | \n",
+ " NaN | \n",
+ " 29 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 3 | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " -0.632031 | \n",
+ " -0.720702 | \n",
+ " 0.197612 | \n",
+ " 2.258251 | \n",
+ " 655.343124 | \n",
+ " 4.595804 | \n",
+ " 182.854523 | \n",
+ " 0.000000 | \n",
+ " NaN | \n",
+ " 29 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 4 | \n",
+ "
\n",
+ " \n",
+ "
\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",
+ " geometry | \n",
+ " area_m2 | \n",
+ " area_ha | \n",
+ " r_sum | \n",
+ " r_mean | \n",
+ " r_count | \n",
+ " tile_total_Mg | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " POLYGON ((32.20445 3.49577, 32.20446 3.48679, ... | \n",
+ " 403986.349446 | \n",
+ " 40.398635 | \n",
+ " 1546.604736 | \n",
+ " 2.962844 | \n",
+ " 522 | \n",
+ " 1546.604736 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " POLYGON ((32.20445 3.50482, 32.20445 3.49577, ... | \n",
+ " 530893.253186 | \n",
+ " 53.089325 | \n",
+ " 2166.687744 | \n",
+ " 3.135583 | \n",
+ " 691 | \n",
+ " 2166.687744 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " POLYGON ((32.20445 3.50482, 32.20111 3.50482, ... | \n",
+ " 131697.223294 | \n",
+ " 13.169722 | \n",
+ " 439.708069 | \n",
+ " 2.484226 | \n",
+ " 177 | \n",
+ " 439.708069 | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " MULTIPOLYGON (((32.20443 3.52291, 32.20443 3.5... | \n",
+ " 34775.414353 | \n",
+ " 3.477541 | \n",
+ " 107.045807 | \n",
+ " 2.378796 | \n",
+ " 45 | \n",
+ " 107.045807 | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " POLYGON ((32.20443 3.52291, 32.20350 3.52291, ... | \n",
+ " 68720.493973 | \n",
+ " 6.872049 | \n",
+ " 336.109985 | \n",
+ " 3.734555 | \n",
+ " 90 | \n",
+ " 336.109985 | \n",
+ "
\n",
+ " \n",
+ "
\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",
+ " ndvi_mean | \n",
+ " ndvi_med | \n",
+ " ndvi_std | \n",
+ " evi_med | \n",
+ " elev_mean | \n",
+ " slope_mean | \n",
+ " par_mean | \n",
+ " rain_total_mm | \n",
+ " rain_mean_mm_day | \n",
+ " cloud_free_days | \n",
+ " bldg_count | \n",
+ " bldg_area | \n",
+ " bldg_h_max | \n",
+ " geometry | \n",
+ " area_m2 | \n",
+ " tile_total_Mg | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " -0.507665 | \n",
+ " -0.662579 | \n",
+ " 0.205291 | \n",
+ " 1.927150 | \n",
+ " 661.247028 | \n",
+ " 3.468728 | \n",
+ " 189.643417 | \n",
+ " 9.334060 | \n",
+ " 3.269485 | \n",
+ " 29 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " POLYGON ((32.20445 3.49577, 32.20446 3.48679, ... | \n",
+ " 403986.349446 | \n",
+ " 1546.604736 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " -0.517304 | \n",
+ " -0.628960 | \n",
+ " 0.206539 | \n",
+ " 1.978630 | \n",
+ " 660.667390 | \n",
+ " 3.641268 | \n",
+ " 188.999216 | \n",
+ " 18.509185 | \n",
+ " 3.241650 | \n",
+ " 29 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " POLYGON ((32.20445 3.50482, 32.20445 3.49577, ... | \n",
+ " 530893.253186 | \n",
+ " 2166.687744 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " -0.410299 | \n",
+ " -0.587629 | \n",
+ " 0.164471 | \n",
+ " 1.775164 | \n",
+ " 652.781977 | \n",
+ " 5.080677 | \n",
+ " 182.854523 | \n",
+ " 0.000000 | \n",
+ " NaN | \n",
+ " 29 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " POLYGON ((32.20445 3.50482, 32.20111 3.50482, ... | \n",
+ " 131697.223294 | \n",
+ " 439.708069 | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " -0.527696 | \n",
+ " -0.626940 | \n",
+ " 0.193225 | \n",
+ " 1.772409 | \n",
+ " 653.080518 | \n",
+ " 3.040932 | \n",
+ " 182.854523 | \n",
+ " 0.000000 | \n",
+ " NaN | \n",
+ " 29 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " MULTIPOLYGON (((32.20443 3.52291, 32.20443 3.5... | \n",
+ " 34775.414353 | \n",
+ " 107.045807 | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " -0.632031 | \n",
+ " -0.720702 | \n",
+ " 0.197612 | \n",
+ " 2.258251 | \n",
+ " 655.343124 | \n",
+ " 4.595804 | \n",
+ " 182.854523 | \n",
+ " 0.000000 | \n",
+ " NaN | \n",
+ " 29 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " POLYGON ((32.20443 3.52291, 32.20350 3.52291, ... | \n",
+ " 68720.493973 | \n",
+ " 336.109985 | \n",
+ "
\n",
+ " \n",
+ "
\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
+"""