Skip to content

Commit 6ede1f4

Browse files
authored
implement pop zonal stats fo outage areas (#512)
1 parent 380320b commit 6ede1f4

7 files changed

Lines changed: 135 additions & 17 deletions

File tree

rapida/cli/connectivity.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def parse_intervals(ctx, param, value):
9494

9595
@click.option('--popvar', required=False, multiple=True,
9696
type=str, callback=validate_variables,
97-
help=f"Open or more RAPIDA population variable to compute zonal stats for withing the connectivity zones"
97+
help=f"One or more RAPIDA population variable to compute zonal stats for withing the connectivity zones"
9898
)
9999

100100
@click.option(

rapida/cli/ntl.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Iterable
55
import click
66
import tempfile
7-
7+
from rapida.cli.assess import get_variables_by_components
88
from rapida.cli import RapidaCommandGroup
99
from rapida.ntl.nasa.const import ARCHIVE, OPERATIONAL, PROCESSING_LEVEL_NAMES, PRODUCT_NAMES, NTL_FILENAME_PATTERN, ROUTES, COLLECTIONS
1010
from rapida.ntl.nasa.search import search as nasa_search
@@ -16,10 +16,25 @@
1616
from rich.table import Table
1717
from rapida.ntl.fetch import DELIVERABLES, fetch as fetch_ntl
1818
from rapida.ntl.outage import detect_outage
19+
from rapida.cli.assess import assess
20+
from rapida.project.project import Project
21+
from tempfile import TemporaryDirectory
22+
1923

2024
logger = logging.getLogger(__name__)
2125

2226

27+
28+
def validate_variables(ctx, param, value):
29+
"""
30+
click callback function to validate polulation
31+
"""
32+
valid_vars = get_variables_by_components(['population'])['population']
33+
invalid = [v for v in value if v not in valid_vars]
34+
if invalid:
35+
raise click.BadParameter(f"Invalid variable{'s' if len(invalid) > 1 else ''}: {', '.join(invalid)} for population . Valid options: {', '.join(valid_vars)}")
36+
return value
37+
2338
class ProcessingLevelChoiceOption(click.Option):
2439
"""
2540
Custom Click option that dynamically validates choices based on the value
@@ -499,6 +514,11 @@ async def fetch(ctx, bbox:tuple[float, float, float, float]=None, nominal_date:d
499514
)
500515

501516

517+
@click.option('--popvar', required=False, multiple=True,
518+
type=str, callback=validate_variables,
519+
help=f"One or more RAPIDA population variable to compute zonal stats for outages"
520+
)
521+
502522
@click.option('-ot', "--percentage_drop",
503523
type=int,
504524
default=50,
@@ -528,12 +548,13 @@ async def fetch(ctx, bbox:tuple[float, float, float, float]=None, nominal_date:d
528548

529549
@click.pass_context
530550
async def detect(ctx, bbox:tuple[float, float, float, float]=None, nominal_date:datetime=None, deliverable:str=None,
531-
mask_clouds:bool=True, dst_dir:str=None, percentage_drop:int=None, display:bool=False):
551+
mask_clouds:bool=True, dst_dir:str=None, popvar:str|tuple[str]=None, percentage_drop:int=None, display:bool=False):
532552
progress = ctx.obj.get('progress')
533553
with progress:
534554
return await detect_outage(
535555
bbox=bbox, nominal_date=nominal_date, deliverable=deliverable, dst_dir=dst_dir,
536-
progress=progress, mask_clouds=mask_clouds, percentage_drop=percentage_drop, display=display
556+
progress=progress, mask_clouds=mask_clouds, percentage_drop=percentage_drop, display=display,
557+
pop_vars=popvar
537558
)
538559

539560

rapida/ntl/fetch.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,6 @@ async def fetch(bbox:tuple[numbers.Number]=None, nominal_date:datetime=None,
150150
# Break out of the 'routes' loop to stop searching for this processing_level
151151
break
152152

153-
print(downloaded_files)
154153
return downloaded_files
155154

156155

rapida/ntl/nasa/io.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,14 +189,15 @@ def extract_bb(image_files: list[str] = None, sds_name:str=None, return_gt=False
189189
ds = gdal.Translate(destName='', srcDS=vrt_path, **translate_options)
190190

191191
gt = ds.GetGeoTransform()
192+
srs = ds.GetSpatialRef()
192193
[gdal.Unlink(e) for e in to_unlink]
193194
band = ds.GetRasterBand(1)
194195
array = band.ReadAsArray()
195196

196197

197198
ds = None
198199
if return_gt:
199-
return array, gt
200+
return array, gt, srs
200201
return array
201202

202203
async def extract(image_files: list[str] = None, sds_name:str=None, product:str=None, dst_dir:str=None,

rapida/ntl/noaa/cmask.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def select_required_granules(sorted_granules: list, bbox: tuple, progress:Progre
101101
return combo # Exits immediately with the absolute minimum required set
102102

103103
logger.warning("Exhausted all combinations. BBOX cannot be fully covered by available data.")
104-
logger.warning(f"Selecting max of {combos}.")
104+
105105

106106
max_cov = max(combos)
107107
return combos[max_cov]

rapida/ntl/outage.py

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,23 @@
1212
import numpy as np
1313
import os
1414
from rapida.ntl import utils
15-
from rapida.admin.util import bbox_to_geojson_polygon
15+
from rapida.util.bbox_param_type import get_best_semantic_label
16+
from osgeo import gdal, gdal_array, ogr
17+
from rapida.util.geo import polygonize_raster_mask
18+
from rapida.cli.assess import assess
19+
import click
20+
from rapida.project.project import Project
21+
from tempfile import TemporaryDirectory
22+
import geopandas as gpd
23+
import datetime
1624
logger = logging.getLogger('rapida')
17-
25+
gdal.UseExceptions()
1826

1927

2028
async def detect_outage(
2129
bbox: tuple[numbers.Number] = None, nominal_date: datetime = None, deliverable: str = None,
22-
dst_dir: str = None, mask_clouds:bool = True, percentage_drop:int = None,
23-
display: bool = False, progress: Progress = None):
30+
dst_dir: str = None, mask_clouds:bool = True, percentage_drop:int = None, pop_vars:str|tuple[str]=None,
31+
display: bool = False, progress: Progress = None, year=datetime.datetime.now().year):
2432

2533
logger.info(f'Fetching best imagery for {deliverable} {bbox}-{nominal_date} ')
2634
# with open(os.path.join('/tmp', 'bbox.geojson'), "w") as ff:
@@ -44,7 +52,7 @@ async def detect_outage(
4452
monthly_timestamp, monthly_files = next(iter(monthly_results.items()))
4553
monthly_product, monthly_image_files = next(iter(monthly_files.items()))
4654

47-
monthly_data, gt = extract_bb(image_files=monthly_image_files, sds_name=nasa_const.SUB_DATASETS['A3'],
55+
monthly_data, gt, rsrs = extract_bb(image_files=monthly_image_files, sds_name=nasa_const.SUB_DATASETS['A3'],
4856
bbox=bbox, progress=progress, return_gt=True)
4957

5058
monthly_data_label = f'{monthly_product}_{monthly_timestamp}'
@@ -165,11 +173,92 @@ async def detect_outage(
165173
arrays[f'{daily_data_label}_ZSCORE'] = zscore
166174
arrays[f'{daily_data_label}_OUTAGE'] = outage
167175

168-
169-
file_name = utils.get_custom_bbox_label(bbox)
176+
# --- 5. UNIFIED DISPLAY & EXPORT ---
177+
#file_name = utils.get_custom_bbox_label(bbox)
178+
file_name = get_best_semantic_label(bbox=bbox)
170179
outage_tif_path = os.path.join(dst_dir, f'{deliverable}_{file_name}.tif')
180+
outage_gpkg_path = os.path.join(dst_dir, f'{deliverable}_{file_name}.gpkg')
171181
write_outage_tif(src_arrays=arrays, gt=gt, dst_path=outage_tif_path)
172-
# --- 5. UNIFIED DISPLAY & EXPORT ---
182+
183+
if pop_vars:
184+
# vectorize outage
185+
gtiff_driver = gdal.GetDriverByName("GTiff")
186+
187+
# Ensure parent directory exists
188+
os.makedirs(os.path.dirname(os.path.abspath(outage_gpkg_path)), exist_ok=True)
189+
190+
if not os.path.exists(outage_gpkg_path):
191+
# 1. Ask GDAL to sniff out the right driver name based on the path structure/extension
192+
v_driver = ogr.GetDriverByName('GPKG')
193+
194+
# 2. Use the driver to initialize the empty file header
195+
with v_driver.CreateDataSource(outage_gpkg_path) as init_ds:
196+
pass # Successfully generated empty stub container
197+
for k, arr in arrays.items():
198+
if 'outage' in k.lower():
199+
# Ensure data type is compatible (e.g., forcing int64/bool to int32/uint8 if needed)
200+
if arr.dtype == bool:
201+
arr = arr.astype("uint8")
202+
elif arr.dtype == "int64":
203+
arr = arr.astype("int32") # GDAL standard doesn't always love 64-bit ints
204+
gdal_dt = gdal_array.NumericTypeCodeToGDALTypeCode(arr.dtype)
205+
rows, cols = arr.shape
206+
# Generate a unique virtual memory path
207+
vsimem_path = f"/vsimem/{k}.tif"
208+
209+
210+
try:
211+
# Create, write, and safely close the dataset within the context manager
212+
with gtiff_driver.Create(vsimem_path, cols, rows, 1, gdal_dt) as ds:
213+
ds.SetGeoTransform(gt)
214+
ds.SetSpatialRef(rsrs)
215+
band = ds.GetRasterBand(1)
216+
band.WriteArray(arr)
217+
band.FlushCache()
218+
219+
# The dataset is now closed, but the file exists in /vsimem/
220+
polygonize_raster_mask(
221+
raster_ds=vsimem_path, # Passes the string path
222+
dst_dataset=outage_gpkg_path,
223+
dst_layer=k,
224+
geom_type=ogr.wkbPolygon
225+
)
226+
227+
with TemporaryDirectory(dir=dst_dir, delete=True) as project_folder:
228+
project = Project(path=project_folder, polygons=outage_gpkg_path,
229+
comment='temp project for outage zonal stats')
230+
with click.Context(assess) as ctx:
231+
ctx.ensure_object(dict)
232+
ctx.obj['progress'] = progress
233+
# 2. Use invoke. Do NOT pass 'ctx' manually here.
234+
# Click intercepts this and injects it as the first argument automatically.
235+
ctx.invoke(
236+
assess,
237+
components=('population',),
238+
variables=pop_vars,
239+
year=year,
240+
project=project.path,
241+
force=False
242+
)
243+
stat_gpkg_path = os.path.join(project_folder, 'data', f'{project.name}.gpkg')
244+
pop_stat_gdf = gpd.read_file(stat_gpkg_path, layer='stats.population')
245+
246+
pop_stat_gdf = pop_stat_gdf.to_crs('EPSG:4326')
247+
248+
pop_stat_gdf.to_file(
249+
filename=outage_gpkg_path,
250+
driver="gpkg",
251+
engine="pyogrio",
252+
mode="w",
253+
layer=k,
254+
promote_to_multi=True,
255+
index=False
256+
)
257+
258+
259+
finally:
260+
# Unlink (delete) the virtual file to free up system memory
261+
gdal.Unlink(vsimem_path)
173262
if display:
174263
from rapida.ntl import vis
175264
vis.display2(data=arrays,

rapida/util/geo.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,15 +146,23 @@ def polygonize_raster_mask(raster_ds=None, band=1, dst_dataset=None, dst_layer=N
146146

147147
with gdal.OpenEx(raster_ds, gdal.OF_RASTER|gdal.OF_READONLY) as rds:
148148
with gdal.OpenEx(dst_dataset, gdal.OF_VECTOR | gdal.OF_UPDATE) as vds:
149-
logger.info(f'Polygonizing {raster_ds} ')
149+
logger.info(f'Polygonizing {raster_ds} into {dst_dataset}:{dst_layer} ')
150150
mband = rds.GetRasterBand(band)
151-
mask_lyr = vds.CreateLayer(dst_layer, geom_type=geom_type, srs=rds.GetSpatialRef())
151+
layer_index = vds.GetLayerByName(dst_layer)
152+
rsrs = rds.GetSpatialRef()
153+
if layer_index is not None:
154+
vds.DeleteLayer(dst_layer)
155+
mask_lyr = vds.CreateLayer(dst_layer, geom_type=geom_type, srs=rsrs)
152156

153157
r = gdal.Polygonize(srcBand=mband, maskBand=mband,outLayer=mask_lyr,iPixValField=-1)
154158

155159
assert r == 0, f'Failed to polygonize {raster_ds}'
160+
156161
for feature in mask_lyr:
162+
if rsrs is None:continue
163+
if rsrs.IsGeographic():continue
157164
geom = feature.GetGeometryRef()
165+
158166
simplified_geom = geom.Simplify(constants.DEFAULT_MASK_POLYGONIZATION_SMOOTHING_BUFFER) # Use SimplifyPreserveTopology(tolerance) if needed
159167
smoothed_geom = simplified_geom.Buffer(constants.DEFAULT_MASK_POLYGONIZATION_SMOOTHING_BUFFER).Buffer(-constants.DEFAULT_MASK_POLYGONIZATION_SMOOTHING_BUFFER)
160168
feature.SetGeometry(smoothed_geom)

0 commit comments

Comments
 (0)