This section is only required when running in Google Colab and your project/data are stored in Google Drive.
Mounting Drive makes your repository and datasets accessible under /content/drive.
If you run this notebook locally (Jupyter / VS Code), skip this section.
Expected structure (Drive) After mounting, your project root should contain: - notebooks/ - src/ - config.yml (or config.yaml)
Proceed to the code cell below to mount Drive.
from google.colab import driveimport os# Check if the drive is mountedif os.path.exists('/content/drive'):# Try to unmounttry: drive.flush_and_unmount()print("Successfully unmounted")except:print("Unmount failed, the drive might not be mounted or busy")# Mount the drivedrive.mount('/content/drive')
Successfully unmounted
Mounted at /content/drive
Troubleshooting:
- If Colab becomes disconnected, Reconnect the runtime and rerun the mounting cell. - If we receive an error such as Mountpoint must not already contain files, delete all the sub-folders under “/content/drive” from the Files panel before retrying. We need to delete these one by one starting from the innermost folders, until the last “drive” folder is deleted.
2 Install packages (only if needed)
In most cases, Google Colab already includes the packages required for this workflow. The most common missing dependency is netCDF4 (NetCDF I/O support).
Check what is already installed (Colab)
Before installing anything, you can inspect the current environment by running !pip list.
If all required packages are present and only netCDF4 is missing, install only netCDF4.
If other required packages are missing from !pip list, install them together withnetCDF4 in the code cell below.
Local Jupyter note
If you are running in a local environment (Jupyter / VS Code), assume all dependencies were installed when preparing the environment following the main repository README. In that case, you can skip this section.
Proceed to the code cell below only when installation is necessary.
# In Google Colab, almost all packages already available, except netCDF4# We also need pyogrio is an alternative C-based engine for Geopandas that reads large vector files exponentially faster.!pip install netCDF4 pyogrio
Requirement already satisfied: netCDF4 in /usr/local/lib/python3.12/dist-packages (1.7.4)
Requirement already satisfied: pyogrio in /usr/local/lib/python3.12/dist-packages (0.12.1)
Requirement already satisfied: cftime in /usr/local/lib/python3.12/dist-packages (from netCDF4) (1.6.5)
Requirement already satisfied: certifi in /usr/local/lib/python3.12/dist-packages (from netCDF4) (2026.4.22)
Requirement already satisfied: numpy>=1.21.2 in /usr/local/lib/python3.12/dist-packages (from netCDF4) (2.0.2)
Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from pyogrio) (26.1)
Once this completes, you’ll be ready to load and manipulate spatial data (Sections 2.1 and beyond) and visualize your results on real-world map projections.
3 Project Folders
Next, we establish a clear folder structure on your mounted Drive so that raw inputs, downloads, intermediate masks, and final outputs each live in their own dedicated directory. The snippet below:
Declares a single main project folder
Builds subpaths for input data, downloaded files, mask outputs, and final results
Ensures each required directory exists, creating it if necessary
A well-organized directory layout makes it easy to trace where every file came from and prevents accidental overwrites—setting you up for a reproducible, scalable workflow.
# Import libraryimport os# Configurable Directory# Base folder on Google Drive for this projectmain_dir ='/content/drive/MyDrive/hybrid-bias-correction'# Define key subfoldersinput_dir =f'{main_dir}/data/input'# RAW input data goes heredownloads_dir =f'{main_dir}/data/downloads'# All downloaded filesmask_dir =f'{main_dir}/data/mask'# Generated land masksoutput_dir =f'{main_dir}/data/output'# Final outputs & results# List of directories to ensure existfor d in [input_dir, downloads_dir, mask_dir, output_dir]: os.makedirs(d, exist_ok=True)print(f"✅ Ensured directory exists: {d}")
Accurate boundary delineation is essential for robust geospatial analysis. In our workflow, the IMERG grid—with its 0.1° spatial resolution—serves as the fundamental reference for all outputs. To maintain consistency between different datasets and ensure reliable spatial analysis, it is crucial to generate a global land mask that exactly conforms to the IMERG grid. This approach guarantees that all output data layers are aligned and comparable.
There are many global country (admin0) boundary datasets used by humanitarian and international development professionals. Some popular examples include: - Natural Earth - GADM - geoBoundaries - Fieldmaps.io
For this exercise, we will use the global admin0 boundary from Fieldmaps.io to develop a global land mask at 0.1° resolution.
Furthermore, the consistent global land mask is the key starting point for subsequent processes: users can easily clip this mask to their preferred Area of Interest (AOI), which is described in detail in Section 2.2.
4.1 Creating a Global Land Mask
Now that our workspace is organized, we’ll generate a world‐wide land mask perfectly aligned to the IMERG 0.1° grid. This mask will underpin all later AOI extractions and ensures that every subsequent raster operation uses the same spatial reference.
The code below:
Downloads a zipped GeoPackage of admin-0 boundaries from Fieldmaps.io
Extracts the vector file into our downloads folder
Defines a global 0.1° raster grid spanning latitude [–90, 90] and longitude [–180, 180]
Rasterizes each country polygon to a mask where land = 1 and water = NaN
Builds an xarray DataArray and saves it as a NetCDF for downstream use
This process will download a global dataset with size around 1 GB, make sure your internet is fast.
# Download and build global land maskimport osimport zipfileimport subprocessimport timeimport geopandas as gpdimport numpy as npfrom rasterio import featuresfrom affine import Affineimport xarray as xrfrom tqdm import tqdmdef format_size(bytes_size):"""Helper to format bytes into MB/GB for readable console output."""for unit in ['B', 'KB', 'MB', 'GB']:if bytes_size <1024.0:returnf"{bytes_size:.2f}{unit}" bytes_size /=1024.0# Get the admin0 in GPKG formatdef download_and_extract_gpkg(url: str, extract_dir: str, zip_fname: str="adm0_polygons.gpkg.zip"):""" Download a zipped GPKG from `url` into `zip_fname` and extract into `extract_dir`. """try:print(f"==================================================")print(f"⬇️ STEP 1: DOWNLOADING DATA")print(f"==================================================")print(f"⏳ Target URL: {url}") t0 = time.time()# Use wget via subprocess for maximum speed in Colab subprocess.run( ["wget", "-q", "--show-progress", "-O", zip_fname, url], check=True ) download_time = time.time() - t0 file_size = os.path.getsize(zip_fname)print(f"✔️ Download complete! Saved {format_size(file_size)} in {download_time:.1f} seconds.\n")print(f"==================================================")print(f"📦 STEP 2: EXTRACTING ARCHIVE")print(f"==================================================") t1 = time.time()with zipfile.ZipFile(zip_fname, 'r') as z: members = z.infolist()print(f"⏳ Found {len(members)} file(s) in ZIP. Starting extraction...")# Add a progress bar for the extraction processfor member in tqdm(members, desc="Extracting files"): z.extract(member, extract_dir) extract_time = time.time() - t1print(f"✔️ Extraction finished in {extract_time:.1f} seconds.\n")except subprocess.CalledProcessError as e:raiseRuntimeError(f"wget failed to download the file: {e}")exceptExceptionas e:raiseRuntimeError(f"Failed to extract GPKG: {e}")# Rasterize the global land polygondef rasterize_global_land(gpkg_path: str, out_nc: str, resolution: float=0.1):""" Read country boundaries from `gpkg_path`, rasterize to a global land mask at `resolution`, and save to NetCDF `out_nc`. """try:print(f"==================================================")print(f"🗺️ STEP 3: LOADING VECTOR DATA")print(f"==================================================") gpkg_size = os.path.getsize(gpkg_path)print(f"⏳ Reading GPKG file ({format_size(gpkg_size)}) from {gpkg_path} ...")print(" (Using 'pyogrio' engine for fast I/O - please wait)") t2 = time.time() gdf = gpd.read_file(gpkg_path, engine="pyogrio") load_time = time.time() - t2print(f"✔️ Loaded {len(gdf)} MultiPolygons into memory in {load_time:.1f} seconds.\n")print(f"==================================================")print(f"✂️ STEP 3.5: SIMPLIFYING & PROTECTING ISLANDS")print(f"==================================================")print(f"⏳ Exploding MultiPolygons to evaluate every single island...") t_simp = time.time()# FIX: Explode groups into single islands so we can track each one individually gdf = gdf.explode(ignore_index=True)print(f"⏳ Tracking {len(gdf)} individual islands/polygons...")# 1. Backup the original shapes orig_geoms = gdf.geometry.copy()# 2. Perform the fast, aggressive simplification gdf['geometry'] = gdf.geometry.simplify(tolerance=0.02, preserve_topology=False)# 3. Identify any tiny islands that collapsed collapsed_mask = gdf.geometry.is_empty |~gdf.geometry.type.str.contains('Polygon') num_collapsed = collapsed_mask.sum()# 4. Restore those specific tiny islandsif num_collapsed >0:print(f"⏳ Rescuing {num_collapsed} tiny islands that vanished... restoring originals.") gdf.loc[collapsed_mask, 'geometry'] = orig_geoms[collapsed_mask] simp_time = time.time() - t_simpprint(f"✔️ Polygons simplified in {simp_time:.1f} seconds. All tiny islands preserved!\n")print(f"==================================================")print(f"⚙️ STEP 4: RASTERIZING AT {resolution}° RESOLUTION")print(f"==================================================")# Define global grid lon_min, lon_max =-180, 180 lat_min, lat_max =-90, 90 width =int((lon_max - lon_min) / resolution) height =int((lat_max - lat_min) / resolution) transform = Affine.translation(lon_min, lat_max) * Affine.scale(resolution, -resolution)print(f"⏳ Target Grid: {width} columns × {height} rows (Total pixels: {width*height:,})")print(f"⏳ Rasterizing polygons (1=land, NaN=water)...") t3 = time.time() shapes = ((geom, 1) for geom in gdf.geometry) mask = features.rasterize( shapes, out_shape=(height, width), transform=transform, fill=np.nan, dtype="float32", all_touched=True ) raster_time = time.time() - t3print(f"✔️ Rasterization complete in {raster_time:.1f} seconds.\n")print(f"==================================================")print(f"💾 STEP 5: SAVING NETCDF")print(f"==================================================")print("⏳ Building xarray structure and saving to disk...") t4 = time.time() lon = lon_min + (np.arange(width) +0.5) * resolution lat = lat_max - (np.arange(height) +0.5) * resolution da = xr.DataArray(mask, coords={'lat': lat, 'lon': lon}, dims=['lat', 'lon'], name='land') da.attrs.update({"description": "Global land mask (1=land, NaN=water)","grid_resolution": f"{resolution}°","units": "1 / NaN" }) da.to_netcdf(out_nc) save_time = time.time() - t4 nc_size = os.path.getsize(out_nc)print(f"✔️ NetCDF saved! ({format_size(nc_size)}) in {save_time:.1f} seconds.\n")print(f"🎉 SUCCESS: World land mask generated at {out_nc}")exceptExceptionas e:raiseRuntimeError(f"Error during rasterization or saving NetCDF: {e}")def main():try:# Define directories (adjust main_dir as needed) bdir = os.path.join(main_dir, 'data/downloads/boundary') wdir = os.path.join(main_dir, 'data/mask/world') os.makedirs(bdir, exist_ok=True) os.makedirs(wdir, exist_ok=True) gpkg_url ="https://data.fieldmaps.io/adm0/osm/intl/adm0_polygons.gpkg.zip"# Start total timer total_start = time.time() download_and_extract_gpkg(gpkg_url, bdir) gpkg = os.path.join(bdir, "adm0_polygons.gpkg") out_nc = os.path.join(wdir, "wld_land_mask.nc") rasterize_global_land(gpkg, out_nc) total_time = time.time() - total_startprint(f"==================================================")print(f"⏱️ TOTAL PIPELINE TIME: {total_time:.1f} seconds")print(f"==================================================")exceptExceptionas e:print(f"\n❌ SCRIPT FAILED: {e}")# Runif__name__=="__main__": main()
==================================================
⬇️ STEP 1: DOWNLOADING DATA
==================================================
⏳ Target URL: https://data.fieldmaps.io/adm0/osm/intl/adm0_polygons.gpkg.zip
✔️ Download complete! Saved 919.06 MB in 13.4 seconds.
==================================================
📦 STEP 2: EXTRACTING ARCHIVE
==================================================
⏳ Found 1 file(s) in ZIP. Starting extraction...
✔️ Extraction finished in 24.9 seconds.
==================================================
🗺️ STEP 3: LOADING VECTOR DATA
==================================================
⏳ Reading GPKG file (1.27 GB) from /content/drive/MyDrive/hybrid-bias-correction/data/downloads/boundary/adm0_polygons.gpkg ...
(Using 'pyogrio' engine for fast I/O - please wait)
✔️ Loaded 318 MultiPolygons into memory in 11.8 seconds.
==================================================
✂️ STEP 3.5: SIMPLIFYING & PROTECTING ISLANDS
==================================================
⏳ Exploding MultiPolygons to evaluate every single island...
⏳ Tracking 803342 individual islands/polygons...
⏳ Rescuing 796754 tiny islands that vanished... restoring originals.
✔️ Polygons simplified in 23.0 seconds. All tiny islands preserved!
==================================================
⚙️ STEP 4: RASTERIZING AT 0.1° RESOLUTION
==================================================
⏳ Target Grid: 3600 columns × 1800 rows (Total pixels: 6,480,000)
⏳ Rasterizing polygons (1=land, NaN=water)...
✔️ Rasterization complete in 119.2 seconds.
==================================================
💾 STEP 5: SAVING NETCDF
==================================================
⏳ Building xarray structure and saving to disk...
✔️ NetCDF saved! (24.77 MB) in 0.1 seconds.
🎉 SUCCESS: World land mask generated at /content/drive/MyDrive/hybrid-bias-correction/data/mask/world/wld_land_mask.nc
==================================================
⏱️ TOTAL PIPELINE TIME: 193.1 seconds
==================================================
With this NetCDF now in place, we can move on to Section 4.2 to clip it to any Area of Interest—whether by country code, bounding box, or our own custom shapefile.
4.2 AOI Options
Defining a precise Area of Interest (AOI) is crucial for targeted analysis. There are at least three options to obtain the AOI: (1) using publicly available administrative boundaries, (2) using a bounding box or predefined polygon, or (3) using our own data.
Using the global land mask created in Section 4.1, we can clip the world mask to any of these AOIs. The following subsections describe sample workflows for each approach, including scripts to extract the AOI from - Global administrative boundaries (Section 4.2.1) - Custom bounding boxes (Section 4.2.2) - User-supplied datasets (Section 4.2.3).
For a smoother experience, we recommend using administrative boundaries from the first option, but we are free to choose whichever option best fits our needs!
By accurately isolating the focus area using one of these methods, we ensure that the data processing is relevant and efficient for specific requirements.
4.2.1 Country Boundaries
In this section, we utilize publicly available administrative boundary datasets to select a desired region. This option is often preferred because it provides country boundaries, which are widely used by many humanitarian professionals.
Let’s first check the attributes of the country boundary dataset we have downloaded. This will help us identify the fields containing the ISO3 country code and the country name, which we will use to filter for specific countries.
# Inspect the GeoPackage attributesimport osimport geopandas as gpd# Path to your GPKGgpkg_file = os.path.join(main_dir, 'data/downloads/boundary/adm0_polygons.gpkg')print("⏳ Peeking at the first 5 rows of the 1.2GB file...")# OPTIMIZATION: Use 'pyogrio' for speed, and 'max_features=5' to stop reading after 5 rowsgdf = gpd.read_file(gpkg_file, engine="pyogrio", max_features=5)# Print the column namesprint("\n✔️ Available columns:")print(list(gdf.columns))print("\n✔️ Data preview:")# Use display() instead of print() for a beautifully formatted interactive table in Colab!display(gdf)
⏳ Peeking at the first 5 rows of the 1.2GB file...
✔️ Available columns:
['adm0_id', 'adm0_src', 'adm0_name', 'adm0_name1', 'adm0_name2', 'iso_cd', 'iso_2', 'iso_3', 'iso_3_grp', 'region3_cd', 'region3_nm', 'region2_cd', 'region2_nm', 'region1_cd', 'region1_nm', 'status_cd', 'status_nm', 'wld_date', 'wld_update', 'wld_land', 'wld_view', 'wld_notes', 'geometry']
✔️ Data preview:
adm0_id
adm0_src
adm0_name
adm0_name1
adm0_name2
iso_cd
iso_2
iso_3
iso_3_grp
region3_cd
...
region1_cd
region1_nm
status_cd
status_nm
wld_date
wld_update
wld_land
wld_view
wld_notes
geometry
0
ABW-20250729
ABW
Aruba (Neth.)
Aruba
NaN
533
AW
ABW
NLD
29
...
19
Americas
5
Territory/Self-governing territory
2025-02-24
2025-07-29
osm
intl
None
MULTIPOLYGON (((-69.97919 12.4655, -69.97918 1...
1
AFG-20250729
AFG
Afghanistan
Afghanistan
NaN
4
AF
AFG
AFG
34
...
142
Asia
1
State
2025-02-24
2025-07-29
osm
intl
None
MULTIPOLYGON (((74.88904 37.23228, 74.88871 37...
2
AGO-20250729
AGO
Angola
Angola
NaN
24
AO
AGO
AGO
17
...
2
Africa
1
State
2025-02-24
2025-07-29
osm
intl
None
MULTIPOLYGON (((11.72436 -16.56755, 11.72424 -...
3
AIA-20250729
AIA
Anguilla (UK)
Anguilla
NaN
660
AI
AIA
GBR
29
...
19
Americas
3
Non-self-governing territory
2025-02-24
2025-07-29
osm
intl
None
MULTIPOLYGON (((-63.16208 18.15032, -63.16209 ...
4
ALA-20250729
ALA
Åland Islands (Fin.)
Åland Islands
NaN
248
AX
ALA
FIN
154
...
150
Europe
5
Territory/Self-governing territory
2025-02-24
2025-07-29
osm
intl
None
MULTIPOLYGON (((19.65382 60.12138, 19.65382 60...
5 rows × 23 columns
After inspecting the attributes of the country boundaries, we’ll proceed to filter the data based on the ISO3 codes adm0_src and Country names adm0_name.
Once the countries are selected, we can interactively preview them on a map to ensure that the boundaries are correct.
# Interactive preview of selected polygons with Foliumimport osimport geopandas as gpdimport foliumfrom folium.features import GeoJsonPopupfrom IPython.display import display # <-- Added for explicit Colab renderingdef load_and_filter_gpkg(gpkg_path: str, iso3_list: list):print(f"⏳ Loading GPKG from {gpkg_path} …")# Using pyogrio here so it loads in seconds, not minutes! gdf = gpd.read_file(gpkg_path, engine="pyogrio")print(f"✔️ Loaded {len(gdf)} features")print(f"⏳ Filtering for ISO3 codes: {iso3_list} …") gdf_f = gdf[gdf['adm0_src'].isin(iso3_list)].copy()print(f"✔️ {len(gdf_f)} features after filtering")# Drop any datetime columns (not JSON-serializable) dt_cols = gdf_f.select_dtypes(include=['datetime64']).columnsiflen(dt_cols):print(f"⏳ Dropping datetime columns: {list(dt_cols)} …") gdf_f.drop(columns=dt_cols, inplace=True)print("✔️ Datetime columns removed")return gdf_fdef build_folium_map(gdf, popup_fields: list, zoom_start: int=4):# Compute map center from bounds minx, miny, maxx, maxy = gdf.total_bounds center = [(miny + maxy) /2, (minx + maxx) /2]print(f"⏳ Building Folium map centered at {center} …") m = folium.Map(location=center, zoom_start=zoom_start, tiles="CartoDB positron") folium.GeoJson( gdf, name="Filtered Polygons", popup=GeoJsonPopup(fields=popup_fields, labels=True), style_function=lambda feat: {"fillColor": "#228B22","color": "black","weight": 0.7,"fillOpacity": 0.3, } ).add_to(m)print("✅ Folium map is ready")return mdef main():try:# Adjust main_dir if needed for your environment gpkg = os.path.join(main_dir, 'data/downloads/boundary/adm0_polygons.gpkg')# Changed 'SIN' to 'SGP' so Singapore is successfully filtered iso3_list = ['MMR', 'THA', 'LAO', 'KHM', 'VNM', 'PHL', 'MYS', 'SGP', 'BRN', 'TLS', 'IDN'] popup_fields = ['adm0_src', 'adm0_name'] gdf_filtered = load_and_filter_gpkg(gpkg, iso3_list) m = build_folium_map(gdf_filtered, popup_fields, zoom_start=4)# Explicitly display the map in the Colab output display(m)exceptExceptionas e:print(f"❌ Error: {e}")# Runif__name__=="__main__": main()
Output hidden; open in https://colab.research.google.com to view.
For this exercise, we will use Indonesia as selected country boundary, with the column adm0_src = ‘IDN’
# Generate and save AOI-specific land maskimport osimport timeimport geopandas as gpdimport numpy as npimport xarray as xrfrom rasterio import featuresfrom affine import Affine# =====================================================================# ⚙️ CONFIGURATION BLOCK (Change these values for different countries)# =====================================================================TARGET_ISO3 ='IDN'# e.g., 'IDN' for Indonesia, 'BRA' for Brazil, etc.# Note: 'main_dir' is inherited from your earlier directory setup cell.# =====================================================================def format_size(bytes_size):"""Helper to format bytes into MB/GB for readable console output."""for unit in ['B', 'KB', 'MB', 'GB']:if bytes_size <1024.0:returnf"{bytes_size:.2f}{unit}" bytes_size /=1024.0def load_world_mask(path: str) -> xr.DataArray:print(f"==================================================")print(f"🌍 STEP 1: LOADING GLOBAL MASK")print(f"==================================================")print(f"⏳ Loading world land mask from {path} …") t0 = time.time() da = xr.open_dataarray(path)print(f"✔️ World land mask loaded in {time.time()-t0:.2f} seconds.\n")return dadef filter_aoi(gpkg_path: str, iso3_code: str) -> gpd.GeoDataFrame:print(f"==================================================")print(f"🗺️ STEP 2: EXTRACTING AOI ('{iso3_code}')")print(f"==================================================")print(f"⏳ Querying GPKG file for {iso3_code} directly from disk...") t1 = time.time() gdf_aoi = gpd.read_file( gpkg_path, engine="pyogrio", where=f"adm0_src = '{iso3_code}'" )iflen(gdf_aoi) ==0:raiseValueError(f"No polygons found for ISO3 code: '{iso3_code}'. Check the code.")print(f"✔️ Extracted {len(gdf_aoi)} polygons for {iso3_code} in {time.time()-t1:.2f} seconds.\n")return gdf_aoidef rasterize_perfect_snap(aoi_gdf: gpd.GeoDataFrame, world_da: xr.DataArray) -> xr.DataArray:print(f"==================================================")print(f"✂️ STEP 3: GRID SNAPPING & SUBSETTING")print(f"==================================================") t2 = time.time()# 1. Simplify geometry to prevent memory crash (WITH EXPLODE FIX)print("⏳ Exploding MultiPolygons and simplifying to protect memory...") aoi_gdf = aoi_gdf.explode(ignore_index=True) orig_geoms = aoi_gdf.geometry.copy() aoi_gdf['geometry'] = aoi_gdf.geometry.simplify(tolerance=0.02, preserve_topology=False) collapsed = aoi_gdf.geometry.is_empty |~aoi_gdf.geometry.type.str.contains('Polygon')if collapsed.sum() >0:print(f"⏳ Rescuing {collapsed.sum()} tiny islands...") aoi_gdf.loc[collapsed, 'geometry'] = orig_geoms[collapsed]# 2. Get global coordinates lon = world_da['lon'].values lat = world_da['lat'].values res =float(abs(lon[1] - lon[0]))# 3. Find bounding box indices to guarantee PERFECT grid snapping minx, miny, maxx, maxy = aoi_gdf.total_bounds lon_idx = np.where((lon >= minx) & (lon <= maxx))[0] lat_idx = np.where((lat >= miny) & (lat <= maxy))[0]# Add a 2-pixel buffer around the edges so we don't cut off boundaries pad =2 lon_start =max(0, lon_idx[0] - pad) lon_end =min(len(lon) -1, lon_idx[-1] + pad) lat_start =max(0, lat_idx[0] - pad) lat_end =min(len(lat) -1, lat_idx[-1] + pad)# Slice the global DataArray directly by index to prevent shift da_sub = world_da.isel(lon=slice(lon_start, lon_end +1), lat=slice(lat_start, lat_end +1)) lon_sub = da_sub['lon'].values lat_sub = da_sub['lat'].values h, w =len(lat_sub), len(lon_sub)print(f"✔️ Grid safely subsetted and snapped to global map: ({h} rows x {w} cols)")# 4. Create the Transform strictly based on the subset coordinates lon_edge = lon_sub[0] - (res /2.0) is_lat_descending = lat_sub[0] > lat_sub[-1] lat_edge = lat_sub[0] + (res /2.0if is_lat_descending else-res /2.0) lat_scale =-res if is_lat_descending else res transform = Affine.translation(lon_edge, lat_edge) * Affine.scale(res, lat_scale)print(f"==================================================")print(f"⚙️ STEP 4: RASTERIZING REGIONAL GRID")print(f"==================================================") shapes = ((geom, 1) for geom in aoi_gdf.geometry) rast = features.rasterize( shapes=shapes, out_shape=(h, w), transform=transform, fill=0, dtype='uint8', all_touched=True )# 5. FIX: Do not pull values from da_sub. Generate clean 1.0 and NaN directly from the new raster. clipped = np.where(rast ==1, 1.0, np.nan)# Wrap it back into xarray da_aoi = xr.DataArray( clipped, coords={'lat': lat_sub, 'lon': lon_sub}, dims=['lat', 'lon'], name='land' ) iso_name = aoi_gdf["adm0_src"].iloc[0] da_aoi.attrs.update({'description': f'AOI land mask for {iso_name} (1=land, NaN elsewhere)','grid_resolution': f'{res}°','units': '1 / NaN' })print(f"✔️ AOI rasterized perfectly with 0 shift. Processing took {time.time()-t2:.2f} seconds.\n")return da_aoidef save_aoi_mask(da: xr.DataArray, out_path: str):print(f"==================================================")print(f"💾 STEP 5: SAVING NETCDF")print(f"==================================================")print(f"⏳ Saving AOI mask to {out_path} …") t3 = time.time() da.to_netcdf(out_path) file_size = os.path.getsize(out_path)print(f"✅ Saved! ({format_size(file_size)}) in {time.time()-t3:.2f} seconds.")def main():try:# Relying on your globally defined 'main_dir' world_nc = os.path.join(main_dir, 'data/mask/world/wld_land_mask.nc') gpkg = os.path.join(main_dir, 'data/downloads/boundary/adm0_polygons.gpkg') out_dir = os.path.join(main_dir, 'data/mask/iso3') os.makedirs(out_dir, exist_ok=True) out_file = os.path.join(out_dir, f'{TARGET_ISO3.lower()}_land_mask.nc') total_start = time.time() world_da = load_world_mask(world_nc) gdf_aoi = filter_aoi(gpkg, TARGET_ISO3) da_aoi = rasterize_perfect_snap(gdf_aoi, world_da) save_aoi_mask(da_aoi, out_file)print(f"\n==================================================")print(f"⏱️ TOTAL PIPELINE TIME: {time.time()-total_start:.2f} seconds")print(f"==================================================")exceptExceptionas e:print(f"\n❌ Error: {e}")# Runif__name__=="__main__": main()
==================================================
🌍 STEP 1: LOADING GLOBAL MASK
==================================================
⏳ Loading world land mask from /content/drive/MyDrive/hybrid-bias-correction/data/mask/world/wld_land_mask.nc …
✔️ World land mask loaded in 0.99 seconds.
==================================================
🗺️ STEP 2: EXTRACTING AOI ('IDN')
==================================================
⏳ Querying GPKG file for IDN directly from disk...
✔️ Extracted 1 polygons for IDN in 4.78 seconds.
==================================================
✂️ STEP 3: GRID SNAPPING & SUBSETTING
==================================================
⏳ Exploding MultiPolygons and simplifying to protect memory...
⏳ Rescuing 12762 tiny islands...
✔️ Grid safely subsetted and snapped to global map: (175 rows x 464 cols)
==================================================
⚙️ STEP 4: RASTERIZING REGIONAL GRID
==================================================
✔️ AOI rasterized perfectly with 0 shift. Processing took 3.56 seconds.
==================================================
💾 STEP 5: SAVING NETCDF
==================================================
⏳ Saving AOI mask to /content/drive/MyDrive/hybrid-bias-correction/data/mask/iso3/idn_land_mask.nc …
✅ Saved! (647.19 KB) in 0.04 seconds.
==================================================
⏱️ TOTAL PIPELINE TIME: 9.38 seconds
==================================================
Next, we’ll visualize the generated output.
# Preview AOI land mask using pure Matplotlibimport osimport xarray as xrimport matplotlib.pyplot as pltfrom mpl_toolkits.axes_grid1 import make_axes_locatable # <-- Imported the layout tooldef load_aoi_mask(path: str) -> xr.DataArray:print(f"⏳ Loading AOI mask from {path} …")ifnot os.path.exists(path):raiseFileNotFoundError(f"Mask file not found: {path}\nDid you run the rasterization step for {TARGET_ISO3}?") da = xr.open_dataarray(path)print("✔️ AOI mask loaded")return dadef ensure_lat_ascending(da: xr.DataArray) -> xr.DataArray:if da.lat[0] > da.lat[-1]:print("⏳ Sorting latitudes ascending …") da = da.sortby('lat')print("✔️ Latitudes sorted")else:print("✔️ Latitudes already ascending")return dadef plot_mask(da: xr.DataArray, iso3_code: str):print("⏳ Plotting AOI mask …") lon = da.lon.values lat = da.lat.values z = da.values fig, ax = plt.subplots(figsize=(10, 6))# Set the background to light blue so the NaN (water) pixels look like an ocean ax.set_facecolor('#e0f3ff') mesh = ax.pcolormesh( lon, lat, z, cmap='Greens', # Land will be green shading='auto' )# Keep the aspect ratio equal so the country doesn't look stretched or squished ax.set_aspect('equal', adjustable='box')# --- NEW COLORBAR LOGIC ---# Create a divider to lock the colorbar to the exact height of the axes divider = make_axes_locatable(ax) cax = divider.append_axes("right", size="3%", pad=0.15) cbar = plt.colorbar(mesh, cax=cax, label='Land = 1')# --------------------------# Add standard gridlines ax.grid(True, linestyle='--', color='gray', alpha=0.5) ax.set_xlabel("Longitude") ax.set_ylabel("Latitude") ax.set_title(f"AOI Land Mask: {iso3_code}", fontsize=14, pad=15) plt.tight_layout() plt.show()print("✅ Plot complete")def main():try:# Dynamically build the filename using TARGET_ISO3 from the previous cell filename =f"{TARGET_ISO3.lower()}_land_mask.nc" mask_path = os.path.join(main_dir, 'data/mask/iso3', filename)print(f"==================================================")print(f"🗺️ PREVIEWING MASK FOR: {TARGET_ISO3}")print(f"==================================================") da_mask = load_aoi_mask(mask_path) da_mask = ensure_lat_ascending(da_mask) plot_mask(da_mask, TARGET_ISO3)exceptNameError:print("❌ Error: 'TARGET_ISO3' or 'main_dir' is not defined. Please run the previous cells first.")exceptExceptionas e:print(f"❌ Error during preview: {e}")# Runif__name__=="__main__": main()
In cases where the AOI does not neatly align with country boundaries, we can define it using a bounding box or a custom polygon. This provides more flexibility when working with specific regions that don’t correspond to administrative borders.
We’ll walk through how to generate an AOI using a bounding box or a polygon defined by a GeoJSON text from tools such as geojson.io. This method is particularly useful when analyzing non-administrative regions or when the focus area extends beyond country boundaries.
# Generate and save AOI-specific land mask from GeoJSON inputimport osimport jsonimport timeimport geopandas as gpdimport numpy as npimport xarray as xrfrom rasterio import featuresfrom affine import Affine# =====================================================================# ⚙️ CONFIGURATION BLOCK# =====================================================================CUSTOM_AOI_NAME ='sumba'# Name your output (e.g., 'sumbawa_bbox')GEOJSON_INPUT ="""{ "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": {}, "geometry": { "type": "Polygon", "coordinates": [ [ [118.59479107734273, -9.01996206314675], [118.59479107734273, -10.672698952161383], [121.2472837636185, -10.672698952161383], [121.2472837636185, -9.01996206314675], [118.59479107734273, -9.01996206314675] ] ] } } ]}"""# =====================================================================def format_size(bytes_size):for unit in ['B', 'KB', 'MB', 'GB']:if bytes_size <1024.0:returnf"{bytes_size:.2f}{unit}" bytes_size /=1024.0def load_world_mask(path: str) -> xr.DataArray:print(f"==================================================")print(f"🌍 STEP 1: LOADING GLOBAL MASK")print(f"==================================================")print(f"⏳ Loading world land mask from {path} …") da = xr.open_dataarray(path)print("✔️ World land mask loaded\n")return dadef load_aoi_from_geojson(geojson_text: str) -> gpd.GeoDataFrame:print(f"==================================================")print(f"🗺️ STEP 2: PARSING GEOJSON")print(f"==================================================")print("⏳ Parsing AOI from GeoJSON input …")try: gj = json.loads(geojson_text) gdf = gpd.GeoDataFrame.from_features(gj["features"]) gdf.set_crs("EPSG:4326", inplace=True)print(f"✔️ Loaded AOI with {len(gdf)} feature(s)\n")return gdfexceptExceptionas e:raiseRuntimeError(f"Failed to parse GeoJSON: {e}")def rasterize_perfect_snap(aoi_gdf: gpd.GeoDataFrame, world_da: xr.DataArray) -> xr.DataArray:print(f"==================================================")print(f"✂️ STEP 3: GRID SNAPPING & RASTERIZING")print(f"==================================================") t0 = time.time()# Get global coordinates lon = world_da['lon'].values lat = world_da['lat'].values res =float(abs(lon[1] - lon[0]))# Find bounding box minx, miny, maxx, maxy = aoi_gdf.total_bounds# Match bounding box to global grid indices lon_idx = np.where((lon >= minx) & (lon <= maxx))[0] lat_idx = np.where((lat >= miny) & (lat <= maxy))[0]# 2-pixel buffer pad =2 lon_start =max(0, lon_idx[0] - pad) lon_end =min(len(lon) -1, lon_idx[-1] + pad) lat_start =max(0, lat_idx[0] - pad) lat_end =min(len(lat) -1, lat_idx[-1] + pad)# Slice the global map directly da_sub = world_da.isel(lon=slice(lon_start, lon_end +1), lat=slice(lat_start, lat_end +1)) lon_sub = da_sub['lon'].values lat_sub = da_sub['lat'].values h, w =len(lat_sub), len(lon_sub)print(f"✔️ Grid safely subsetted to: ({h} rows x {w} cols)")# Create the Transform strictly based on the subset coordinates lon_edge = lon_sub[0] - (res /2.0) is_lat_descending = lat_sub[0] > lat_sub[-1] lat_edge = lat_sub[0] + (res /2.0if is_lat_descending else-res /2.0) lat_scale =-res if is_lat_descending else res transform = Affine.translation(lon_edge, lat_edge) * Affine.scale(res, lat_scale)print("⏳ Rasterizing AOI onto snapped grid …") shapes = ((geom, 1) for geom in aoi_gdf.geometry) rast = features.rasterize( shapes=shapes, out_shape=(h, w), transform=transform, fill=0, dtype='uint8', all_touched=True )# Generate clean output based on rasterization clipped = np.where(rast ==1, 1.0, np.nan) da_aoi = xr.DataArray( clipped, coords={'lat': lat_sub, 'lon': lon_sub}, dims=['lat', 'lon'], name='land' ) da_aoi.attrs.update({'description': f'AOI land mask for {CUSTOM_AOI_NAME} (1=land, NaN elsewhere)','grid_resolution': f'{res}°','units': '1 / NaN' })print(f"✔️ AOI rasterized perfectly with 0 shift in {time.time()-t0:.2f} seconds.\n")return da_aoidef save_aoi_mask(da: xr.DataArray, out_path: str):print(f"==================================================")print(f"💾 STEP 4: SAVING NETCDF")print(f"==================================================")print(f"⏳ Saving AOI mask to {out_path} …") da.to_netcdf(out_path) file_size = os.path.getsize(out_path)print(f"✅ Saved! ({format_size(file_size)})")def main():try: world_nc = os.path.join(main_dir, 'data/mask/world/wld_land_mask.nc') out_dir = os.path.join(main_dir, 'data/mask/aoi') os.makedirs(out_dir, exist_ok=True)# Dynamically name the output file out_file = os.path.join(out_dir, f'{CUSTOM_AOI_NAME}_land_mask.nc') world_da = load_world_mask(world_nc) aoi_gdf = load_aoi_from_geojson(GEOJSON_INPUT) da_aoi = rasterize_perfect_snap(aoi_gdf, world_da) save_aoi_mask(da_aoi, out_file)print(f"\n🎉 Pipeline complete. Output: {CUSTOM_AOI_NAME}_land_mask.nc")exceptExceptionas e:print(f"\n❌ Error: {e}")# Runif__name__=="__main__": main()
==================================================
🌍 STEP 1: LOADING GLOBAL MASK
==================================================
⏳ Loading world land mask from /content/drive/MyDrive/hybrid-bias-correction/data/mask/world/wld_land_mask.nc …
✔️ World land mask loaded
==================================================
🗺️ STEP 2: PARSING GEOJSON
==================================================
⏳ Parsing AOI from GeoJSON input …
✔️ Loaded AOI with 1 feature(s)
==================================================
✂️ STEP 3: GRID SNAPPING & RASTERIZING
==================================================
✔️ Grid safely subsetted to: (21 rows x 30 cols)
⏳ Rasterizing AOI onto snapped grid …
✔️ AOI rasterized perfectly with 0 shift in 0.01 seconds.
==================================================
💾 STEP 4: SAVING NETCDF
==================================================
⏳ Saving AOI mask to /content/drive/MyDrive/hybrid-bias-correction/data/mask/aoi/sumba_land_mask.nc …
✅ Saved! (14.12 KB)
🎉 Pipeline complete. Output: sumba_land_mask.nc
Next, we’ll visualize the generated output.
# Preview AOI land mask with contextual boundaries using pure Matplotlibimport osimport xarray as xrimport geopandas as gpdimport matplotlib.pyplot as pltfrom mpl_toolkits.axes_grid1 import make_axes_locatable# =====================================================================# ⚙️ CONFIGURATION BLOCK# =====================================================================# Choose what you want to preview right now:PREVIEW_MODE ='aoi'# Options: 'aoi' (for GeoJSON) or 'iso3' (for Country)# =====================================================================def load_aoi_mask(path: str) -> xr.DataArray:print(f"⏳ Loading AOI mask from {path} …")ifnot os.path.exists(path):raiseFileNotFoundError(f"Mask file not found:\n{path}\nDid you run the generation step first?") da = xr.open_dataarray(path)print("✔️ AOI mask loaded")return dadef load_buffered_boundaries(gpkg_path: str, bounds: tuple, buffer: float=0.5) -> gpd.GeoDataFrame:""" Extracts a lightweight subset of global boundaries based on a buffered bounding box. """ minx, miny, maxx, maxy = bounds# Expand the bounding box by the buffer amount bbox = (minx -buffer, miny -buffer, maxx +buffer, maxy +buffer)print(f"⏳ Extracting contextual borders directly from disk (Buffer: {buffer}°)...")# OPTIMIZATION: 'bbox' pushdown prevents loading the massive file into memory! bg_gdf = gpd.read_file(gpkg_path, engine="pyogrio", bbox=bbox)print(f"✔️ Extracted {len(bg_gdf)} neighboring border polygons for context.")return bg_gdfdef ensure_lat_ascending(da: xr.DataArray) -> xr.DataArray:if da.lat[0] > da.lat[-1]:print("⏳ Sorting latitudes ascending …") da = da.sortby('lat')return dadef plot_mask_with_borders(da: xr.DataArray, gpkg_path: str, title_name: str):print("⏳ Plotting AOI mask …") lon = da.lon.values lat = da.lat.values z = da.values# Calculate bounding box of the raster mask minx, maxx = lon.min(), lon.max() miny, maxy = lat.min(), lat.max()# Fetch only boundaries that fit in this box borders_gdf = load_buffered_boundaries(gpkg_path, bounds=(minx, miny, maxx, maxy), buffer=0.5) fig, ax = plt.subplots(figsize=(10, 6)) ax.set_facecolor('#e0f3ff') # Ocean color# Draw the Raster mesh = ax.pcolormesh( lon, lat, z, cmap='Greens', shading='auto', zorder=1 )# Draw Contextual Boundaries (only if borders exist in this box)ifnot borders_gdf.empty: borders_gdf.plot( ax=ax, facecolor='none', edgecolor='black', linewidth=0.8, linestyle='-', zorder=2 ) ax.set_aspect('equal', adjustable='box')# Lock colorbar to map height divider = make_axes_locatable(ax) cax = divider.append_axes("right", size="3%", pad=0.15) plt.colorbar(mesh, cax=cax, label='Land = 1')# Lock plot limits so neighboring borders don't stretch the view ax.set_xlim(minx -0.1, maxx +0.1) ax.set_ylim(miny -0.1, maxy +0.1) ax.grid(True, linestyle='--', color='gray', alpha=0.5) ax.set_xlabel("Longitude") ax.set_ylabel("Latitude") ax.set_title(f"AOI Mask Preview: {title_name}", fontsize=14, pad=15) plt.tight_layout() plt.show()print("✅ Plot complete. Temporary boundary memory automatically released!")def main():try:# Dynamically link to the correct variable from previous cells!if PREVIEW_MODE =='aoi': target_name = CUSTOM_AOI_NAME filename =f"{target_name}_land_mask.nc" mask_path = os.path.join(main_dir, 'data/mask/aoi', filename)elif PREVIEW_MODE =='iso3': target_name = TARGET_ISO3 filename =f"{target_name.lower()}_land_mask.nc" mask_path = os.path.join(main_dir, 'data/mask/iso3', filename)else:raiseValueError("PREVIEW_MODE must be either 'aoi' or 'iso3'") gpkg_path = os.path.join(main_dir, 'data/downloads/boundary/adm0_polygons.gpkg')print(f"==================================================")print(f"🗺️ PREVIEWING MASK FOR: {target_name}")print(f"==================================================") da_mask = load_aoi_mask(mask_path) da_mask = ensure_lat_ascending(da_mask) plot_mask_with_borders(da_mask, gpkg_path, target_name)exceptNameErroras e:print(f"❌ Error: Colab forgot the variable name. Please re-run the generation cell first!\nDetails: {e}")exceptExceptionas e:print(f"❌ Error during preview: {e}")# Runif__name__=="__main__": main()
For those who have their own custom data, this option allows us to define the AOI using shapefiles, GeoJSON, or GeoPackages. This approach is especially useful for projects with non-standard administrative divisions or custom region definitions.
In this section, we describe how to load and process user-supplied vector data. Once loaded, we can rasterize the AOI to match the global land mask and proceed with further spatial analysis.
# Generate and save AOI-specific land mask from user-provided vectorimport osimport timeimport geopandas as gpdimport numpy as npimport xarray as xrfrom rasterio import featuresfrom affine import Affine# =====================================================================# ⚙️ CONFIGURATION BLOCK# =====================================================================# Place your AOI file under data/downloads/userdata/USER_VECTOR_FILENAME ='idn_bnd_adm1_bali.shp'CUSTOM_AOI_NAME ='bali_subset'# The name for your output file# =====================================================================def format_size(bytes_size):for unit in ['B', 'KB', 'MB', 'GB']:if bytes_size <1024.0:returnf"{bytes_size:.2f}{unit}" bytes_size /=1024.0def load_world_mask(path: str) -> xr.DataArray:print(f"==================================================")print(f"🌍 STEP 1: LOADING GLOBAL MASK")print(f"==================================================")print(f"⏳ Loading world land mask from {path} …") da = xr.open_dataarray(path)print("✔️ World land mask loaded\n")return dadef load_aoi(aoi_path: str) -> gpd.GeoDataFrame:print(f"==================================================")print(f"📁 STEP 2: LOADING USER VECTOR")print(f"==================================================")print(f"⏳ Loading AOI from {aoi_path} …")# Use Pyogrio for speed if supported, fall back to default if neededtry: gdf = gpd.read_file(aoi_path, engine="pyogrio")except: gdf = gpd.read_file(aoi_path)print(f"✔️ Loaded {len(gdf)} feature(s)")# CRITICAL FIX: Ensure user data matches the global map's projectionif gdf.crs !="EPSG:4326":print(f"⏳ Reprojecting user data from {gdf.crs} to EPSG:4326 …") gdf = gdf.to_crs("EPSG:4326")print("✔️ Reprojection complete")print("")return gdfdef rasterize_perfect_snap(aoi_gdf: gpd.GeoDataFrame, world_da: xr.DataArray) -> xr.DataArray:print(f"==================================================")print(f"✂️ STEP 3: GRID SNAPPING & RASTERIZING")print(f"==================================================") t0 = time.time()# 1. Protect against memory crashes (Explode & Simplify)print("⏳ Exploding polygons and simplifying to protect memory...") aoi_gdf = aoi_gdf.explode(ignore_index=True) orig_geoms = aoi_gdf.geometry.copy() aoi_gdf['geometry'] = aoi_gdf.geometry.simplify(tolerance=0.02, preserve_topology=False) collapsed = aoi_gdf.geometry.is_empty |~aoi_gdf.geometry.type.str.contains('Polygon')if collapsed.sum() >0: aoi_gdf.loc[collapsed, 'geometry'] = orig_geoms[collapsed]# 2. Get global coordinates lon = world_da['lon'].values lat = world_da['lat'].values res =float(abs(lon[1] - lon[0]))# 3. Find bounding box to subset the grid minx, miny, maxx, maxy = aoi_gdf.total_bounds lon_idx = np.where((lon >= minx) & (lon <= maxx))[0] lat_idx = np.where((lat >= miny) & (lat <= maxy))[0] pad =2 lon_start =max(0, lon_idx[0] - pad) lon_end =min(len(lon) -1, lon_idx[-1] + pad) lat_start =max(0, lat_idx[0] - pad) lat_end =min(len(lat) -1, lat_idx[-1] + pad)# Slice the global map directly da_sub = world_da.isel(lon=slice(lon_start, lon_end +1), lat=slice(lat_start, lat_end +1)) lon_sub = da_sub['lon'].values lat_sub = da_sub['lat'].values h, w =len(lat_sub), len(lon_sub)print(f"✔️ Grid safely subsetted to: ({h} rows x {w} cols)")# 4. Create local transform lon_edge = lon_sub[0] - (res /2.0) is_lat_descending = lat_sub[0] > lat_sub[-1] lat_edge = lat_sub[0] + (res /2.0if is_lat_descending else-res /2.0) lat_scale =-res if is_lat_descending else res transform = Affine.translation(lon_edge, lat_edge) * Affine.scale(res, lat_scale)print("⏳ Rasterizing AOI onto snapped grid …") shapes = ((geom, 1) for geom in aoi_gdf.geometry) rast = features.rasterize( shapes=shapes, out_shape=(h, w), transform=transform, fill=0, dtype='uint8', all_touched=True )# Generate clean output based on rasterization clipped = np.where(rast ==1, 1.0, np.nan) da_aoi = xr.DataArray( clipped, coords={'lat': lat_sub, 'lon': lon_sub}, dims=['lat', 'lon'], name='land' ) da_aoi.attrs.update({'description': f'AOI land mask for {CUSTOM_AOI_NAME} (1=land, NaN elsewhere)','grid_resolution': f'{res}°','units': '1 / NaN' })print(f"✔️ AOI rasterized perfectly with 0 shift in {time.time()-t0:.2f} seconds.\n")return da_aoidef save_aoi_mask(da: xr.DataArray, out_path: str):print(f"==================================================")print(f"💾 STEP 4: SAVING NETCDF")print(f"==================================================")print(f"⏳ Saving AOI mask to {out_path} …") da.to_netcdf(out_path) file_size = os.path.getsize(out_path)print(f"✅ Saved! ({format_size(file_size)})")def main():try: world_nc = os.path.join(main_dir, 'data/mask/world/wld_land_mask.nc') userdata_dir = os.path.join(main_dir, 'data/downloads/userdata') aoi_path = os.path.join(userdata_dir, USER_VECTOR_FILENAME)ifnot os.path.exists(aoi_path):raiseFileNotFoundError(f"User vector file not found: {aoi_path}") out_dir = os.path.join(main_dir, 'data/mask/aoi') os.makedirs(out_dir, exist_ok=True)# Output named dynamically based on CUSTOM_AOI_NAME out_file = os.path.join(out_dir, f'{CUSTOM_AOI_NAME}.nc') world_da = load_world_mask(world_nc) aoi_gdf = load_aoi(aoi_path) da_aoi = rasterize_perfect_snap(aoi_gdf, world_da) save_aoi_mask(da_aoi, out_file)print(f"\n🎉 Pipeline complete. Output: {CUSTOM_AOI_NAME}.nc")exceptExceptionas e:print(f"\n❌ Error: {e}")# Runif__name__=="__main__": main()
==================================================
🌍 STEP 1: LOADING GLOBAL MASK
==================================================
⏳ Loading world land mask from /content/drive/MyDrive/hybrid-bias-correction/data/mask/world/wld_land_mask.nc …
✔️ World land mask loaded
==================================================
📁 STEP 2: LOADING USER VECTOR
==================================================
⏳ Loading AOI from /content/drive/MyDrive/hybrid-bias-correction/data/downloads/userdata/idn_bnd_adm1_bali.shp …
✔️ Loaded 1 feature(s)
==================================================
✂️ STEP 3: GRID SNAPPING & RASTERIZING
==================================================
⏳ Exploding polygons and simplifying to protect memory...
✔️ Grid safely subsetted to: (11 rows x 17 cols)
⏳ Rasterizing AOI onto snapped grid …
✔️ AOI rasterized perfectly with 0 shift in 0.04 seconds.
==================================================
💾 STEP 4: SAVING NETCDF
==================================================
⏳ Saving AOI mask to /content/drive/MyDrive/hybrid-bias-correction/data/mask/aoi/bali_subset.nc …
✅ Saved! (9.19 KB)
🎉 Pipeline complete. Output: bali_subset.nc
In this notebook we established the spatial foundation for the bias-correction workflow:
Global land mask (Section 4.1) – We downloaded admin-0 boundaries from Fieldmaps.io and rasterized them onto a 0.1-degree grid aligned with the IMERG satellite precipitation product. The result is a global binary mask (1 = land, NaN = water) stored as NetCDF.
AOI extraction (Section 4.2) – Using the global mask as a starting point, we demonstrated three approaches to define an Area of Interest:
Country boundaries (4.2.1) – Filter by ISO3 code to obtain a national-level mask (example: Sri Lanka, LKA).
Bounding box / custom polygon (4.2.2) – Paste a GeoJSON geometry (e.g. from geojson.io) to clip an arbitrary rectangular or polygonal region.
User-supplied vector data (4.2.3) – Load a local shapefile, GeoPackage, or GeoJSON file for non-standard AOI definitions.
Each method produces a grid-aligned NetCDF land mask that is directly usable by the downstream notebooks. The mask is applied during bias correction (notebook 02), metric computation (notebook 03), and quality assessment (notebook 04) to restrict processing to land pixels within the target domain.
Next step: Proceed to Notebook 01 – Data Acquisition to download the IMERG and CPC-UNI precipitation datasets for the AOI defined here.