## install packages
#!pip install rasterio geopandas shapely pyproj contextily matplotlib numpy
#!pip install contextily
This notebook:
Loads your suitability raster (values 0–10, float).
Masks pixels with value > 6.99.
Computes area of these pixels (reprojects to equal-area CRS if needed).
Calculates daily water savings if thinning reduces ET from 0.00365 m/day to 0.00258 m/day (ΔET = 0.00107 m/day).
Plots the high-suitability mask over a basemap of Arizona.
Writes a small CSV summary of the results.
Assumptions: ET rates are spatially uniform within the selected pixels; pixel area is computed in an equal-area CRS for accuracy.
In [1]:
1) Configuration
Set the path to your suitability raster and ET parameters. The raster path below uses your Windows path.
In [2]:
from pathlib import Path
# --- User inputs ---
= Path(r"C:\Users\rl587\Documents\ArcGIS\Projects\ThinningRechargePonderosa_v02_2025\Suitability_out.tif")
RASTER_PATH
# ET rates (meters/day)
= 0.00365 # average ET in non-thinned areas
ET_non_thinned = 0.00258 # average ET in thinned areas
ET_thinned = ET_non_thinned - ET_thinned # expected savings per day
delta_ET
# Suitability threshold
= 6.99 # strictly greater than 6.99 (GTE 7.0)
THRESH
print(f"Raster path: {RASTER_PATH}")
print(f"ΔET (m/day): {delta_ET:.8f}")
Raster path: C:\Users\rl587\Documents\ArcGIS\Projects\ThinningRechargePonderosa_v02_2025\Suitability_out.tif
ΔET (m/day): 0.00107000
2) Load raster and inspect metadata
We load the suitability raster with rasterio
, inspect the CRS and pixel size, and identify no-data values.
In [3]:
import rasterio
import numpy as np
with rasterio.open(RASTER_PATH) as src:
= src.read(1) # first band
data = src.profile.copy()
profile = src.crs
crs = src.transform
transform = src.nodata
nodata = src.res # (xres, yres)
res
print("CRS:", crs)
print("Resolution (x, y):", res)
print("Transform:", transform)
print("Nodata value:", nodata)
print("Array shape (rows, cols):", data.shape)
print("Data range (min, max):", np.nanmin(data), np.nanmax(data))
CRS: EPSG:6341
Resolution (x, y): (30.0, 30.0)
Transform: | 30.00, 0.00, 27232.83|
| 0.00,-30.00, 4253973.34|
| 0.00, 0.00, 1.00|
Nodata value: nan
Array shape (rows, cols): (29080, 29318)
Data range (min, max): 1.00002 8.886681
3) Ensure equal-area units for area calculations
If the raster is in a geographic CRS (units in degrees), we reproject it to EPSG:5070 (NAD83 / Conus Albers), which is an equal-area projection suitable for the U.S.
In [4]:
import rasterio
from rasterio.warp import calculate_default_transform, reproject, Resampling
def reproject_to_equal_area(array, src_profile, dst_crs="EPSG:5070", resampling=Resampling.nearest):
= src_profile["crs"]
src_crs = src_profile["transform"]
src_transform = src_profile["height"]
src_height = src_profile["width"]
src_width
= calculate_default_transform(
dst_transform, dst_width, dst_height *rasterio.transform.array_bounds(src_height, src_width, src_transform)
src_crs, dst_crs, src_width, src_height,
)= src_profile.copy()
dst_profile
dst_profile.update({"crs": dst_crs,
"transform": dst_transform,
"width": dst_width,
"height": dst_height,
"dtype": array.dtype
})
= np.empty((dst_height, dst_width), dtype=array.dtype)
dst
reproject(=array,
source=dst,
destination=src_transform,
src_transform=src_crs,
src_crs=dst_transform,
dst_transform=dst_crs,
dst_crs=resampling,
resampling=src_profile.get("nodata", None),
src_nodata=src_profile.get("nodata", None),
dst_nodata
)return dst, dst_profile
# Determine whether CRS is projected (meters) or geographic (degrees)
with rasterio.open(RASTER_PATH) as src:
= (src.crs is None) or (not src.crs.is_projected)
is_geographic
if is_geographic:
print("Input CRS appears geographic (degrees). Reprojecting to EPSG:5070 for area calculations...")
with rasterio.open(RASTER_PATH) as src:
= src.read(1)
data = src.profile.copy()
profile = reproject_to_equal_area(data, profile, "EPSG:5070")
data_aeq, profile_aeq else:
print("Input CRS is projected (likely meters). Using as-is for area calculations.")
with rasterio.open(RASTER_PATH) as src:
= src.read(1)
data_aeq = src.profile.copy()
profile_aeq
# Convenience variables in equal-area space
= profile_aeq["transform"].a, -profile_aeq["transform"].e # approximate x/y pixel sizes
aeq_res = abs(aeq_res[0] * aeq_res[1])
pixel_area_m2 print("Equal-area pixel size (approx):", aeq_res, "m")
print("Pixel area (m^2):", pixel_area_m2)
Input CRS is projected (likely meters). Using as-is for area calculations.
Equal-area pixel size (approx): (30.0, 30.0) m
Pixel area (m^2): 900.0
4) Select high-suitability pixels and compute area
We select pixels with value > 6.99, exclude no-data, and compute total area as count * pixel_area
. We also report hectares.
In [5]:
# Handle nodata
= profile_aeq.get("nodata", None)
nodata_aeq
= data_aeq.astype("float64")
arr if nodata_aeq is not None:
= np.where(arr == nodata_aeq, np.nan, arr)
arr
= arr > THRESH # strictly greater than 7
mask_high
= np.count_nonzero(mask_high & ~np.isnan(arr))
count_pixels = count_pixels * pixel_area_m2
total_area_m2 = total_area_m2 / 10_000.0
total_area_ha
print(f"High-suitability pixels (> {THRESH}): {count_pixels:,}")
print(f"Total area: {total_area_m2:,.2f} m² ({total_area_ha:,.2f} ha)")
High-suitability pixels (> 6.99): 2,065,379
Total area: 1,858,841,100.00 m² (185,884.11 ha)
5) Compute daily water savings
We use the ET difference:
\(\Delta ET = 0.00365 - 0.00258 = 0.00107\) m/day.
Daily water savings = \(\Delta ET \times \text{area}\).
In [6]:
= delta_ET # m/day
DELTA_ET
# Volumes
= DELTA_ET * total_area_m2 # 1 m * m² = m³
volume_m3_per_day = 1.0 / 1233.48184 # ac-ft per m³
ACRE_FT_PER_M3 = volume_m3_per_day * ACRE_FT_PER_M3
volume_acft_per_day
print(f"ΔET (m/day): {DELTA_ET:.8f}")
print(f"Water saved per day: {volume_m3_per_day:,.2f} m³/day (~{volume_acft_per_day:,.2f} ac-ft/day)")
ΔET (m/day): 0.00107000
Water saved per day: 1,988,959.98 m³/day (~1,612.48 ac-ft/day)
6) Add uncertainty to the ET calculation
incorporate your standard deviations (0.9 mm for thinned, 1.1 mm for unthinned)
In [7]:
# --- Uncertainty calculation (add ± std) ---
= 0.0011 # m/day (1.1 mm/day)
std_non_thinned = 0.0009 # m/day (0.9 mm/day)
std_thinned
# Max and min delta_ET based on uncertainty
= (ET_non_thinned + std_non_thinned) - (ET_thinned - std_thinned)
delta_ET_max = (ET_non_thinned - std_non_thinned) - (ET_thinned + std_thinned)
delta_ET_min
# Corresponding recharge volume ranges (m³/day)
= delta_ET_max * total_area_m2
volume_m3_day_max = delta_ET_min * total_area_m2
volume_m3_day_min
print(f"Recharge volume range: {volume_m3_day_min:,.2f} to {volume_m3_day_max:,.2f} m³/day")
Recharge volume range: -1,728,722.22 to 5,706,642.18 m³/day
In [11]:
def convert_volume(value, from_unit="m3", to_unit="acft"):
"""
Convert between cubic meters (m3) and acre-feet (acft).
Parameters
----------
value : float or array-like
The volume to convert.
from_unit : str, default 'm3'
The unit of the input value ('m3' or 'acft').
to_unit : str, default 'acft'
The unit of the output value ('m3' or 'acft').
Returns
-------
float or array-like
Converted volume.
"""
= 1233.48184 # exact conversion
m3_per_acft
if from_unit == "m3" and to_unit == "acft":
return value / m3_per_acft
elif from_unit == "acft" and to_unit == "m3":
return value * m3_per_acft
elif from_unit == to_unit:
return value
else:
raise ValueError("Units must be 'm3' or 'acft'.")
= convert_volume(volume_m3_day_min, from_unit="m3", to_unit="acft")
minacft =convert_volume(volume_m3_day_max, from_unit="m3", to_unit="acft")
maxacft
print(f"Recharge volume range: {minacft} AcreFeet/day to {maxacft} acreFeet/day with a mean value of {volume_acft_per_day}")
Recharge volume range: -1401.4979117973874 AcreFeet/day to 4626.450095933313 acreFeet/day with a mean value of 1612.4760920679632
7) Plot high-suitability mask
We plot the binary mask (value 1 where suitability > 6.99, else 0) using the raster’s current CRS and transform.
In [10]:
import numpy as np
import matplotlib.pyplot as plt
from rasterio.plot import plotting_extent
# Build/reuse a binary mask in the same grid used for area calc (profile_aeq)
# If you already created `mask_high` in Step 4, you can reuse it; otherwise:
= np.zeros_like(arr, dtype="float32")
binary_mask & (arr > THRESH)] = 1.0
binary_mask[np.isfinite(arr)
# Compute plot extent from the transform (left, right, bottom, top)
= plotting_extent(binary_mask, profile_aeq["transform"])
extent
# Axis labels based on projected vs geographic units
= "m"
units try:
if profile_aeq["crs"] is not None and not profile_aeq["crs"].is_projected:
= "deg"
units except Exception:
pass
= plt.subplots(figsize=(10, 10))
fig, ax = ax.imshow(binary_mask, extent=extent, origin="upper", alpha=1)
im "High-Suitability Areas (> 7)")
ax.set_title(f"X ({units})")
ax.set_xlabel(f"Y ({units})")
ax.set_ylabel(
# Optional: show a tiny colorbar for the mask (1 = selected)
= plt.colorbar(im, ax=ax, fraction=0.036, pad=0.02)
cbar "Mask (1 = >7)")
cbar.set_label(
plt.tight_layout() plt.show()
8) Save results
Write a small CSV with the key metrics.
In [9]:
import pandas as pd
# Assuming you already calculated:
# delta_ET_max, delta_ET_min, volume_m3_day_max, volume_m3_day_min
# and also have conversion for acre-feet/day:
= 0.000810713 # 1 m³ = 0.000810713 acre-feet
m3_to_acft = volume_m3_day_max * m3_to_acft
volume_acft_day_max = volume_m3_day_min * m3_to_acft
volume_acft_day_min
= pd.DataFrame([{
out "threshold": THRESH,
"delta_ET_m_per_day": DELTA_ET,
"pixel_area_m2": pixel_area_m2,
"count_pixels": int(count_pixels),
"area_m2": float(total_area_m2),
"area_hectares": float(total_area_ha),
"volume_m3_per_day_saved": float(volume_m3_per_day),
"volume_acft_per_day_saved": float(volume_acft_per_day),
"volume_m3_per_day_saved_min": float(volume_m3_day_min),
"volume_m3_per_day_saved_max": float(volume_m3_day_max),
"volume_acft_per_day_saved_min": float(volume_acft_day_min),
"volume_acft_per_day_saved_max": float(volume_acft_day_max),
}])
= "et_thinning_savings_summary.csv"
csv_path =False)
out.to_csv(csv_path, index
csv_path
'et_thinning_savings_summary.csv'
Notes & Caveats
- CRS: Accurate area requires an equal-area projection. This notebook reprojects to EPSG:5070 if your input is geographic (degrees).
- Edge effects: We treat each selected pixel uniformly and ignore edge effects or sub-pixel variation.
- ET assumptions: ET values are assumed spatially uniform for the selected pixels (>6.99). If you have spatially varying ET, substitute a raster of ET differences.
- Performance: For very large rasters, consider chunked/block processing with
rasterio
windows to reduce memory usage.