Skip to content

Commit e5c6e7f

Browse files
EliEli
authored andcommitted
Error message on stacked_dem_fill, re-write of raster_to_node for speed and accuracy -- previous refactor broke it.
1 parent 8d969d8 commit e5c6e7f

3 files changed

Lines changed: 216 additions & 106 deletions

File tree

schimpy/prepare_schism.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,19 @@ def create_hgrid(s, inputs, logger):
134134
optimized_elevation = optimizer.optimize(opt_params)
135135
s.mesh.nodes[:, 2] = np.negative(optimized_elevation)
136136
elif method=="volume_tvd":
137-
dem_spec = section.get("dem_list")
138-
if dem_spec is None:
137+
dem_list = section.get("dem_list")
138+
if dem_list is None:
139139
raise ValueError("For method=volume_tvd, provide dem_list")
140-
140+
s.dem_list = dem_list
141+
logger.info(f"Using stacked DEM fill for depth initialization. Length of DEM list: {len(dem_list)}")
142+
s.mesh.nodes[:, 2] = stacked_dem_fill(
143+
dem_list,
144+
s.mesh.nodes[:, :2],
145+
inputs["prepro_output_dir"],
146+
require_all=False,
147+
na_fill=default_depth_for_missing_dem,
148+
negate=True
149+
)
141150
sl = opt_params.get("shoreline")
142151
shoreline = None
143152
if sl is not None:
@@ -200,12 +209,12 @@ def create_hgrid(s, inputs, logger):
200209
logger.info("Start optimizing the mesh ('volume_tvd').")
201210
_ = refine_volume_tvd(
202211
s.mesh,
203-
dem_spec=dem_spec,
212+
dem_spec=dem_list,
204213
out_dir=inputs["prepro_output_dir"],
205214
shoreline=shoreline,
206215
floor=floor,
207216
tvd=tvd,
208-
cache_dir=os.path.join(inputs["prepro_output_dir"], "dem_cache"),
217+
cache_dir=os.path.join(inputs["prepro_output_dir"], ".dem_cache"),
209218
logger=logger
210219
)
211220
else:
@@ -535,13 +544,7 @@ def process_output_dir(inputs):
535544
outdir = inputs["prepro_output_dir"]
536545
force = True
537546
else:
538-
warnings.warn(
539-
"No output_dir specification. This will not be allowed in the future. \n"
540-
+ "Using '.' but specification of a separate directory recommended. ",
541-
FutureWarning,
542-
)
543-
outdir = "."
544-
inputs["prepro_output_dir"] = outdir
547+
raise ValueError("Inplace preprocessing not allowed. prepro_output_dir must be specified in launch yaml file. \nConvention is 'prepro_out'")
545548
if os.path.exists(outdir):
546549
created = False
547550
else:

schimpy/raster_to_nodes.py

Lines changed: 199 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,42 @@
1-
# -*- coding: utf-8 -*-
2-
"""Optimized raster_to_nodes function without rasterstats, using rasterio directly."""
3-
41
import numpy as np
5-
import rasterio
6-
from rasterio.mask import mask
7-
from shapely.geometry import Polygon, mapping
82
import itertools
3+
import rasterio
4+
from rasterio.features import rasterize
5+
from rasterio.windows import from_bounds
6+
from shapely.geometry import Polygon, box
7+
8+
# ---------- helpers (minimal) ----------
9+
10+
def _union_missing_mask(ma, extra_mask_values):
11+
"""Union raster mask with explicit sentinels (e.g., 0.0, -9999)."""
12+
if extra_mask_values is None:
13+
return ma
14+
if not isinstance(extra_mask_values, (list, tuple, np.ndarray)):
15+
extra_mask_values = [extra_mask_values]
16+
data = ma.data
17+
add_mask = np.zeros(data.shape, dtype=bool)
18+
for mv in extra_mask_values:
19+
add_mask |= (data == mv)
20+
return np.ma.array(data, mask=(np.asarray(ma.mask, bool) | add_mask), copy=False)
21+
22+
def _elements_bounds_in_raster(elements_polys, raster_bounds):
23+
"""BBox of union(elements ∩ raster) or None if empty."""
24+
rb = box(*raster_bounds)
25+
any_hit = False
26+
xmin, ymin, xmax, ymax = +np.inf, +np.inf, -np.inf, -np.inf
27+
for poly in elements_polys:
28+
if poly.is_empty:
29+
continue
30+
inter = poly.intersection(rb)
31+
if inter.is_empty:
32+
continue
33+
bxmin, bymin, bxmax, bymax = inter.bounds
34+
xmin, ymin = min(xmin, bxmin), min(ymin, bymin)
35+
xmax, ymax = max(xmax, bxmax), max(ymax, bymax)
36+
any_hit = True
37+
return (xmin, ymin, xmax, ymax) if any_hit else None
938

39+
# ---------- main (rasterize-only) ----------
1040

1141
def raster_to_nodes(
1242
mesh,
@@ -17,116 +47,192 @@ def raster_to_nodes(
1747
mask_value=None,
1848
fill_value=None,
1949
band=1,
50+
missing_policy="exclude",
51+
rasterize_all_touched=True,
2052
):
2153
"""
22-
Applies raster values to mesh by calculating means over surrounding elements.
54+
Map raster values onto mesh nodes by averaging values over surrounding elements.
55+
56+
This function samples a raster onto an unstructured mesh. Each element polygon
57+
is intersected with the raster grid, values are averaged per element, and then
58+
element values are aggregated to nodes using an area-weighted mean.
2359
2460
Parameters
2561
----------
26-
(same as original)
62+
mesh : object
63+
Mesh object with the following interface:
64+
- ``mesh.nodes`` : array of node coordinates (N, >=2), x and y in the first two columns.
65+
- ``mesh.elem(i)`` : return node indices for element ``i``.
66+
- ``mesh.get_elems_i_from_node(node_i)`` : return element indices connected to node ``node_i``.
67+
- ``mesh.areas()`` : array of element areas indexed by element id.
68+
nodes_sel : sequence of int
69+
Node indices at which values will be computed.
70+
path_raster : str
71+
Path to a raster file (e.g., GeoTIFF) readable by rasterio.
72+
bins : sequence of float, optional
73+
Bin edges used to classify raster values. If provided, the raster values
74+
are digitized into bins before aggregation. Length must be ``M``.
75+
mapped_values : sequence of float, optional
76+
Values assigned to each bin index. Must have length ``M+1`` if ``bins`` is provided.
77+
Typically used to map classes into the interval [0, 1].
78+
mask_value : scalar or sequence of scalars, optional
79+
Additional raster values to treat as missing (in addition to raster
80+
NoData). Example: ``[0.0, -9999]``.
81+
fill_value : float, optional
82+
Default value for elements with no valid raster pixels in the continuous
83+
(non-classified) case. If omitted, defaults to 0.0. Ignored when
84+
classification is active.
85+
band : int, default=1
86+
1-based band index in the raster to sample.
87+
missing_policy : {"exclude", "as_zero"}, default="exclude"
88+
Policy for handling missing pixels in the classified case:
89+
90+
- ``"exclude"`` : compute class averages using only valid pixels. Elements
91+
with no valid pixels receive the default class value (0.0).
92+
- ``"as_zero"`` : missing pixels count as class 0 by including them in the
93+
denominator. This dilutes averages toward 0 when coverage is sparse.
94+
95+
Has no effect in the continuous (non-classified) case.
96+
rasterize_all_touched : bool, default=True
97+
If True, count all raster cells touched by element polygons. If False,
98+
count only cells whose centers fall within the polygons.
2799
28100
Returns
29101
-------
30-
numpy.array
31-
means of raster values of the element balls around the nodes
102+
ndarray of float
103+
Array of values at each node in ``nodes_sel``. Length matches ``len(nodes_sel)``.
104+
105+
Notes
106+
-----
107+
- Continuous mode (no ``bins``): averages raster values directly.
108+
- Classified mode (with ``bins`` and ``mapped_values``): raster values are
109+
digitized into bins, mapped to user-provided class values, then averaged.
110+
- All aggregation is area-weighted: element values are computed from raster
111+
pixels, and node values are weighted by connected element areas.
32112
"""
33113

34-
# Step 1: Prepare binning if needed
35-
classify_raster = False
36-
if bins is not None:
114+
classify = bins is not None
115+
if classify:
37116
if mapped_values is None:
38117
raise ValueError("mapped_values must be provided if bins are used.")
39118
if len(mapped_values) != len(bins) + 1:
40119
raise ValueError("mapped_values must be one longer than bins.")
41-
classify_raster = True
120+
if missing_policy not in ("exclude", "as_zero"):
121+
raise ValueError("missing_policy must be 'exclude' or 'as_zero'.")
42122

43-
# Step 2: Precompute node balls (cache)
44-
elements_in_balls = {
45-
node_i: list(mesh.get_elems_i_from_node(node_i)) for node_i in nodes_sel
46-
}
47-
elements_in_polygon = sorted(
48-
set(itertools.chain.from_iterable(elements_in_balls.values()))
49-
)
123+
# Node -> elements and unique element ids
124+
elements_in_balls = {n: list(mesh.get_elems_i_from_node(n)) for n in nodes_sel}
125+
elements_all = sorted(set(itertools.chain.from_iterable(elements_in_balls.values())))
50126

51-
# Step 3: Build element polygons
127+
# Build element polygons (XY only)
52128
element_polygons = {}
53-
for elem_i in elements_in_polygon:
129+
for elem_i in elements_all:
54130
nodes_idx = mesh.elem(elem_i)
55-
coords = mesh.nodes[nodes_idx, :2] # x,y only
131+
coords = mesh.nodes[nodes_idx, :2]
56132
element_polygons[elem_i] = Polygon(coords)
57133

58-
# Step 4: Open raster
134+
# Defaults per path
135+
elem_default_cont = (fill_value if fill_value is not None else 0.0)
136+
elem_default_class = 0.0
137+
59138
with rasterio.open(path_raster) as src:
60-
nodata = src.nodatavals[band - 1] if src.nodatavals else None
61-
62-
# Read full raster band if binning is needed
63-
if classify_raster:
64-
raster_array = src.read(band)
65-
if mask_value is not None:
66-
raster_array = np.where(
67-
raster_array == mask_value, fill_value, raster_array
68-
)
69-
digitized = np.digitize(raster_array, bins, right=False)
70-
classified_array = np.array(mapped_values)[digitized]
71-
else:
72-
classified_array = None # Will read on demand
73-
74-
# Step 5: Calculate mean per element
75-
sav_in_elements = {}
76-
for elem_i, polygon in element_polygons.items():
77-
geom = [mapping(polygon)]
78-
if classify_raster is False:
79-
out_image, out_transform = mask(
80-
src, geom, crop=True, indexes=band, nodata=nodata
81-
)
82-
data = out_image[0]
139+
# Minimal window that covers mesh ∩ raster
140+
bbox = _elements_bounds_in_raster(list(element_polygons.values()), src.bounds)
141+
# If nothing overlaps: return defaults
142+
if bbox is None:
143+
default_val = elem_default_class if classify else elem_default_cont
144+
return np.full(len(nodes_sel), default_val, dtype=float)
145+
146+
win = from_bounds(*bbox, transform=src.transform)
147+
full_win = from_bounds(*src.bounds, transform=src.transform)
148+
win = win.intersection(full_win)
149+
150+
# Read data as masked array over that window
151+
data = src.read(band, window=win, masked=True) # (H,W) masked
152+
if data.ndim == 3: # (1,H,W) -> (H,W) if env returns 3D
153+
data = data[0]
154+
data = _union_missing_mask(data, mask_value)
155+
156+
# Rasterize element IDs on same window grid
157+
window_transform = rasterio.windows.transform(win, src.transform)
158+
shapes = [(poly, int(eid)) for eid, poly in element_polygons.items()]
159+
labels = rasterize(
160+
shapes,
161+
out_shape=data.shape,
162+
transform=window_transform,
163+
fill=0, # background label
164+
dtype="int32",
165+
all_touched=rasterize_all_touched,
166+
)
167+
168+
# Prepare result per-element
169+
elem_values = {eid: (elem_default_class if classify else elem_default_cont)
170+
for eid in element_polygons.keys()}
171+
172+
# VALID pixels only
173+
valid = ~data.mask
174+
labs_valid = labels[valid]
175+
vals_valid = data.data[valid]
176+
sel_valid = labs_valid != 0
177+
178+
if classify:
179+
# If there are valid class pixels, aggregate them
180+
if sel_valid.any():
181+
bins_arr = np.asarray(bins)
182+
mapped = np.asarray(mapped_values)
183+
idx = np.digitize(vals_valid[sel_valid], bins_arr, right=False) # 0..len(bins)
184+
class_vals = mapped[idx]
185+
186+
max_id = int(labels.max()) if labels.size else 0
187+
sums_valid = np.bincount(labs_valid[sel_valid], weights=class_vals, minlength=max_id + 1)
188+
cnts_valid = np.bincount(labs_valid[sel_valid], minlength=max_id + 1)
83189
else:
84-
# Clip manually from classified_array
85-
bounds = polygon.bounds # (minx, miny, maxx, maxy)
86-
row_min, col_min = src.index(bounds[0], bounds[3]) # (xmin, ymax)
87-
row_max, col_max = src.index(bounds[2], bounds[1]) # (xmax, ymin)
88-
rows = slice(min(row_min, row_max), max(row_min, row_max) + 1)
89-
cols = slice(min(col_min, col_max), max(col_min, col_max) + 1)
90-
if rows.start == rows.stop or cols.start == cols.stop:
91-
sav_in_elements[elem_i] = 0.0
92-
continue
93-
window = classified_array[rows, cols]
94-
# Verify that window has nonzero size
95-
if window.shape[0] == 0 or window.shape[1] == 0:
96-
sav_in_elements[elem_i] = 0.0
97-
continue
98-
# Build temporary profile
99-
transform = src.window_transform(
100-
((rows.start, rows.stop), (cols.start, cols.stop))
101-
)
102-
with rasterio.io.MemoryFile() as memfile:
103-
with memfile.open(
104-
driver="GTiff",
105-
height=window.shape[0],
106-
width=window.shape[1],
107-
count=1,
108-
dtype=window.dtype,
109-
transform=transform,
110-
crs=src.crs,
111-
nodata=-999,
112-
) as dataset:
113-
dataset.write(window, 1)
114-
out_image, out_transform = mask(
115-
dataset, geom, crop=True, indexes=1, nodata=-999
116-
)
117-
data = out_image[0]
118-
119-
masked = np.ma.masked_array(data, mask=(data == nodata))
120-
mean_val = masked.mean() if masked.count() > 0 else 0.0
121-
sav_in_elements[elem_i] = mean_val
122-
123-
# Step 6: Assemble node values
190+
max_id = int(labels.max()) if labels.size else 0
191+
sums_valid = np.zeros(max_id + 1, dtype=float)
192+
cnts_valid = np.zeros(max_id + 1, dtype=float)
193+
194+
if missing_policy == "as_zero":
195+
# Denominator: all pixels of element (valid+masked), excluding background
196+
labs_all = labels.ravel()
197+
sel_all = labs_all != 0
198+
cnts_total = np.bincount(labs_all[sel_all], minlength=max_id + 1)
199+
present = np.nonzero(cnts_total)[0]
200+
for eid in present:
201+
denom = cnts_total[eid]
202+
num = sums_valid[eid] # masked contribute 0
203+
elem_values[eid] = float(num / denom) if denom > 0 else elem_default_class
204+
else:
205+
# "exclude": denominator is count of valid only
206+
present = np.nonzero(cnts_valid)[0]
207+
for eid in present:
208+
denom = cnts_valid[eid]
209+
elem_values[eid] = float(sums_valid[eid] / denom) if denom > 0 else elem_default_class
210+
211+
else:
212+
# Continuous: mean over valid pixels per element
213+
if sel_valid.any():
214+
max_id = int(labels.max()) if labels.size else 0
215+
sums = np.bincount(labs_valid[sel_valid], weights=vals_valid[sel_valid], minlength=max_id + 1)
216+
cnts = np.bincount(labs_valid[sel_valid], minlength=max_id + 1)
217+
present = np.nonzero(cnts)[0]
218+
for eid in present:
219+
elem_values[eid] = float(sums[eid] / cnts[eid]) if cnts[eid] > 0 else elem_default_cont
220+
# else: keep defaults (no valid pixels anywhere)
221+
222+
# Area-weighted average from elements to nodes
124223
elem_areas = mesh.areas()
125-
sav_at_nodes = np.empty((len(nodes_sel),), dtype=float)
224+
out_vals = np.empty((len(nodes_sel),), dtype=float)
126225
for i, node_i in enumerate(nodes_sel):
127226
ball = elements_in_balls[node_i]
128-
values = [sav_in_elements[e] for e in ball]
129-
weights = elem_areas[ball]
130-
sav_at_nodes[i] = np.average(values, weights=weights)
227+
if not ball:
228+
out_vals[i] = 0.0
229+
continue
230+
values = np.array([elem_values[e] for e in ball], dtype=float)
231+
weights = np.array(elem_areas[ball], dtype=float)
232+
bad = ~np.isfinite(values)
233+
if bad.any():
234+
weights = weights.copy(); weights[bad] = 0.0
235+
values = np.where(bad, 0.0, values)
236+
out_vals[i] = np.average(values, weights=weights) if weights.sum() > 0 else 0.0
131237

132-
return sav_at_nodes
238+
return out_vals

schimpy/stacked_dem_fill.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,11 @@ def _stacked_dem_fill(
6464
f_out = ensure_outdir(out_dir, "dem_misses.txt")
6565
miss_idx = np.isnan(values)
6666
miss_points = points[miss_idx]
67+
nmiss = miss_points.shape[0]
6768
f = open(f_out, "w")
6869
for p in miss_points:
6970
f.write("%s,%s\n" % tuple(p))
70-
message = "DEMs provided do not cover all the points. See file dem_misses.txt for locations"
71+
message = f"DEMs provided do not cover {nmiss} points. See file dem_misses.txt for locations and to check for significance"
7172
f.close()
7273

7374
gdf = gpd.GeoDataFrame(

0 commit comments

Comments
 (0)