Skip to content

Commit 92010e4

Browse files
authored
Merge pull request #40 from CU-ESIIL/codex/add-polygon-example-to-prism-entry
Add PRISM polygon streaming example
2 parents 347db96 + 1f46466 commit 92010e4

File tree

1 file changed

+204
-0
lines changed

1 file changed

+204
-0
lines changed

docs/forecasting/prism/prism.md

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,210 @@ plot_prism(
157157
plot_prism(variable="ppt", date="2024-03", freq="monthly", resolution="4km")
158158
```
159159

160+
### Example: Stream PRISM to a polygon
161+
162+
Building on the point example above, the script below uses a GeoJSON polygon to
163+
clip PRISM data on the fly, warp it to EPSG:4326, and optionally return stats
164+
for the clipped grid.
165+
166+
```python
167+
#!/usr/bin/env python3
168+
"""
169+
stream_prism_to_polygon()
170+
- Streams PRISM via /vsicurl inside /vsizip (no manual download)
171+
- Clips/crops to a user-provided polygon (GeoJSON/GPKG/SHP path, GeoJSON dict/str, or WKT)
172+
- Warps to EPSG:4326 for easy plotting
173+
- Optionally returns data array, extent, and simple stats
174+
"""
175+
176+
from __future__ import annotations
177+
from osgeo import gdal, ogr, osr
178+
import numpy as np
179+
import matplotlib.pyplot as plt
180+
import datetime as dt
181+
from typing import Union, Tuple, Optional, Dict, Any
182+
import json
183+
import uuid
184+
185+
# Enable GDAL exceptions (GDAL 4.0 will default to this)
186+
gdal.UseExceptions()
187+
188+
# ---------------- PRISM helpers ---------------- #
189+
_RES_CODE = {"4km": "25m", "800m": "30s", "400m": "15s"}
190+
191+
def _as_datecode(date: Union[str, dt.date, dt.datetime], freq: str) -> Tuple[str, str]:
192+
if isinstance(date, (dt.datetime, dt.date)):
193+
d = date if isinstance(date, dt.date) and not isinstance(date, dt.datetime) else date.date()
194+
elif isinstance(date, str):
195+
s = date.strip()
196+
if freq == "daily":
197+
try: d = dt.datetime.strptime(s, "%Y-%m-%d").date()
198+
except ValueError: d = dt.datetime.strptime(s, "%Y%m%d").date()
199+
elif freq == "monthly":
200+
try: d = dt.datetime.strptime(s, "%Y-%m").date().replace(day=1)
201+
except ValueError: d = dt.datetime.strptime(s, "%Y%m").date().replace(day=1)
202+
elif freq == "annual":
203+
d = dt.datetime.strptime(s, "%Y").date().replace(month=1, day=1)
204+
else:
205+
raise ValueError("freq must be one of: 'daily','monthly','annual'")
206+
else:
207+
raise TypeError("date must be str, datetime, or date")
208+
209+
if freq == "daily": return d.strftime("%Y%m%d"), d.strftime("%Y")
210+
if freq == "monthly": return d.strftime("%Y%m"), d.strftime("%Y")
211+
return d.strftime("%Y"), d.strftime("%Y")
212+
213+
def _build_prism_vsi(variable: str, date, resolution: str, region: str, freq: str, network: str) -> str:
214+
if resolution not in _RES_CODE:
215+
raise ValueError("resolution must be one of {'800m','4km','400m'}")
216+
datecode, yyyy = _as_datecode(date, freq)
217+
res_code = _RES_CODE[resolution]
218+
base_dir = f"https://data.prism.oregonstate.edu/time_series/{region}/{network}/{resolution}/{variable}/{freq}/{yyyy}/"
219+
zip_name = f"prism_{variable}_{region}_{res_code}_{datecode}.zip"
220+
tif_name = f"prism_{variable}_{region}_{res_code}_{datecode}.tif"
221+
return f"/vsizip//vsicurl/{base_dir}{zip_name}/{tif_name}"
222+
223+
def _gdal_open_prism(**kwargs) -> gdal.Dataset:
224+
vsi = _build_prism_vsi(**kwargs)
225+
gdal.SetConfigOption("GDAL_DISABLE_READDIR_ON_OPEN", "YES")
226+
gdal.SetConfigOption("CPL_VSIL_CURL_ALLOWED_EXTENSIONS", ".zip,.tif,.xml,.stx,.prj,.aux.xml")
227+
ds = gdal.Open(vsi, gdal.GA_ReadOnly)
228+
if ds is None:
229+
raise RuntimeError(f"GDAL failed to open PRISM via VSI:\n{vsi}")
230+
return ds
231+
232+
def _extent_from_gt(ds) -> Tuple[float, float, float, float]:
233+
gt = ds.GetGeoTransform()
234+
w, h = ds.RasterXSize, ds.RasterYSize
235+
xmin, ymax = gt[0], gt[3]
236+
xmax, ymin = xmin + w * gt[1], ymax + h * gt[5]
237+
return (xmin, xmax, ymin, ymax)
238+
239+
# --------------- Polygon input helpers --------------- #
240+
def _write_geojson_vsimmem(geojson_obj) -> str:
241+
geojson_str = json.dumps(geojson_obj) if isinstance(geojson_obj, dict) else str(geojson_obj)
242+
path = f"/vsimem/cutline_{uuid.uuid4().hex}.geojson"
243+
gdal.FileFromMemBuffer(path, geojson_str.encode("utf-8"))
244+
return path
245+
246+
def _wkt_to_geojson_vsimmem(wkt: str, srs_epsg: str = "EPSG:4326") -> str:
247+
sr = osr.SpatialReference(); sr.SetFromUserInput(srs_epsg)
248+
geom = ogr.CreateGeometryFromWkt(wkt)
249+
if geom is None:
250+
raise ValueError("Could not parse WKT geometry.")
251+
feat = ogr.Feature(ogr.FeatureDefn()); feat.SetGeometry(geom)
252+
ds = ogr.GetDriverByName("GeoJSON").CreateDataSource("/vsimem/tmp_geojson.json")
253+
try:
254+
layer = ds.CreateLayer("cutline", srs=sr, geom_type=ogr.wkbUnknown); layer.CreateFeature(feat); ds.SyncToDisk()
255+
vsipath = f"/vsimem/cutline_{uuid.uuid4().hex}.geojson"
256+
buf = gdal.VSIGetMemFileBuffer("/vsimem/tmp_geojson.json", 0); gdal.FileFromMemBuffer(vsipath, buf)
257+
return vsipath
258+
finally:
259+
gdal.Unlink("/vsimem/tmp_geojson.json")
260+
261+
def _normalize_cutline_input(cutline, srs: str = "EPSG:4326") -> Tuple[Optional[str], Optional[str]]:
262+
if cutline is None: return None, None
263+
if isinstance(cutline, str) and cutline.lower().endswith((".geojson", ".json", ".gpkg", ".shp", ".zip")):
264+
return cutline, None
265+
if isinstance(cutline, str) and cutline.strip().upper().startswith(("POLYGON", "MULTIPOLYGON")):
266+
return _wkt_to_geojson_vsimmem(cutline, srs), None
267+
return _write_geojson_vsimmem(cutline), None
268+
269+
# ----------------- MAIN ONE-CALL FUNCTION ----------------- #
270+
def stream_prism_to_polygon(
271+
polygon: Union[str, dict],
272+
polygon_srs: str = "EPSG:4326",
273+
*,
274+
variable: str = "tmax",
275+
date: Union[str, dt.date, dt.datetime] = "2025-07-15",
276+
resolution: str = "800m",
277+
freq: str = "daily",
278+
region: str = "us",
279+
network: str = "an",
280+
title: Optional[str] = None,
281+
vmin: Optional[float] = None,
282+
vmax: Optional[float] = None,
283+
mask_outside: bool = True,
284+
return_array: bool = False,
285+
compute_stats: bool = False
286+
) -> Optional[Dict[str, Any]]:
287+
ds = _gdal_open_prism(variable=variable, date=date, resolution=resolution,
288+
region=region, freq=freq, network=network)
289+
290+
cutline_path, cutline_layer = _normalize_cutline_input(polygon, polygon_srs)
291+
292+
warp_opts = gdal.WarpOptions(
293+
format="VRT",
294+
dstSRS="EPSG:4326",
295+
resampleAlg="nearest",
296+
cutlineDSName=cutline_path,
297+
cutlineLayer=cutline_layer,
298+
cropToCutline=True,
299+
dstAlpha=False,
300+
warpOptions=[f"CUTLINE_SRS={polygon_srs}", f"INIT_DEST={'NO_DATA' if mask_outside else '0'}"],
301+
)
302+
vrt = gdal.Warp("", ds, options=warp_opts)
303+
if vrt is None:
304+
raise RuntimeError("GDAL.Warp failed to build VRT with the provided polygon.")
305+
306+
band = vrt.GetRasterBand(1)
307+
nodata = band.GetNoDataValue()
308+
arr = band.ReadAsArray()
309+
if nodata is not None:
310+
arr = np.where(arr == nodata, np.nan, arr)
311+
312+
extent = _extent_from_gt(vrt)
313+
plt.figure(figsize=(8, 6))
314+
im = plt.imshow(arr, extent=extent, origin="upper", vmin=vmin, vmax=vmax)
315+
plt.xlabel("Longitude"); plt.ylabel("Latitude")
316+
if title is None:
317+
title = f"PRISM {variable.upper()} ({freq}) {date}"
318+
plt.title(title)
319+
cb = plt.colorbar(im, shrink=0.85); cb.set_label(f"{variable} (native units)")
320+
plt.tight_layout(); plt.show()
321+
322+
if not return_array and not compute_stats:
323+
return None
324+
325+
out: Dict[str, Any] = {"array": arr, "extent": extent, "nodata": nodata}
326+
if compute_stats:
327+
finite = np.isfinite(arr)
328+
if finite.any():
329+
out["stats"] = {
330+
"count": int(finite.sum()),
331+
"mean": float(np.nanmean(arr)),
332+
"min": float(np.nanmin(arr)),
333+
"max": float(np.nanmax(arr)),
334+
"std": float(np.nanstd(arr)),
335+
}
336+
else:
337+
out["stats"] = {"count": 0, "mean": np.nan, "min": np.nan, "max": np.nan, "std": np.nan}
338+
return out
339+
340+
# ---------------- Example usage ---------------- #
341+
if __name__ == "__main__":
342+
boulder_poly = {
343+
"type": "FeatureCollection",
344+
"features": [{
345+
"type": "Feature",
346+
"properties": {},
347+
"geometry": {"type": "Polygon", "coordinates": [[
348+
[-105.38, 40.15], [-105.10, 40.25], [-104.95, 40.15],
349+
[-104.95, 39.95], [-105.15, 39.90], [-105.38, 40.00],
350+
[-105.38, 40.15]
351+
]]}
352+
}]
353+
}
354+
355+
out = stream_prism_to_polygon(
356+
polygon=boulder_poly, polygon_srs="EPSG:4326",
357+
variable="tmax", date="2025-07-15", resolution="800m", freq="daily",
358+
title="PRISM TMAX daily (°C×10) — Boulder polygon",
359+
return_array=True, compute_stats=True
360+
)
361+
print("Stats:", out["stats"])
362+
```
363+
160364
## R
161365
Paste into your R console:
162366
```r

0 commit comments

Comments
 (0)