Skip to content

Commit 43ea77b

Browse files
committed
Introduce a checkbox to resample non-equidistant grids to equidistant.
1 parent afa0eb6 commit 43ea77b

File tree

2 files changed

+141
-14
lines changed

2 files changed

+141
-14
lines changed

imodqgis/idf/conversion.py

+128-6
Original file line numberDiff line numberDiff line change
@@ -124,22 +124,25 @@ def read_idf(path: str) -> Tuple[Dict[str, Any], np.ndarray]:
124124
# equidistant IDFs
125125
ieq = not struct.unpack("?", f.read(1))[0]
126126
itb = struct.unpack("?", f.read(1))[0]
127-
if not ieq:
128-
raise ValueError(f"Non-equidistant IDF are not supported: {path}\n")
129127

130128
f.read(2) # not used
131129
if doubleprecision:
132130
f.read(4) # not used
133131

134132
# dx and dy are stored positively in the IDF
135133
# dy is made negative here to be consistent with the nonequidistant case
136-
attrs["dx"] = struct.unpack(floatformat, f.read(floatsize))[0]
137-
attrs["dy"] = -struct.unpack(floatformat, f.read(floatsize))[0]
134+
if ieq:
135+
attrs["dx"] = struct.unpack(floatformat, f.read(floatsize))[0]
136+
attrs["dy"] = -struct.unpack(floatformat, f.read(floatsize))[0]
138137

139138
if itb:
140139
attrs["top"] = struct.unpack(floatformat, f.read(floatsize))[0]
141140
attrs["bot"] = struct.unpack(floatformat, f.read(floatsize))[0]
142141

142+
if not ieq:
143+
attrs["dx"] = np.fromfile(f, dtype, ncol)
144+
attrs["dy"] = -np.fromfile(f, dtype, nrow)
145+
143146
# These are derived, remove after using them downstream
144147
attrs["ncol"] = ncol
145148
attrs["nrow"] = nrow
@@ -225,7 +228,124 @@ def write(path, a, spatial_reference, nodata=1.0e20, dtype=np.float32):
225228
return
226229

227230

228-
def convert_idf_to_gdal(path: str, crs_wkt: str) -> Path:
231+
def _check_for_equidistance(dx: np.ndarray) -> Tuple[bool, float, float]:
232+
# Check if equidistant, even if the ieq flag says otherwise:
233+
# Allow 0.1% deviation in cell size.
234+
absdx = abs(dx)
235+
dxmax = absdx.max()
236+
dxmin = absdx.min()
237+
if not np.allclose(dxmin, absdx, atol=abs(1.0e-4 * dxmin)):
238+
return False, dxmin, dxmax
239+
return True, dxmin, dxmax
240+
241+
242+
def _resample(
243+
attrs: Dict[str, Any], values: np.ndarray, new_dx: float, new_dy: float
244+
) -> Tuple[Dict[str, Any], np.ndarray]:
245+
"""
246+
Resample a non-equidistant grid to equidistant form.
247+
248+
We keep the lower-left (xmin, ymin) corner and work from there.
249+
250+
Parameters
251+
----------
252+
attrs: dict
253+
Contains IDF header information.
254+
values: np.ndarray
255+
Data values of the non-equidistant grid.
256+
new_dx: float
257+
New (equidistant) cell size along x.
258+
new_dy: float
259+
New (equidistant) cell size along y.
260+
261+
Returns
262+
-------
263+
resampled_attrs: dict
264+
resampled_values: np.ndarray
265+
"""
266+
267+
def _inbounds_searchsorted(a: np.ndarray, v: np.ndarray):
268+
# np.searchsorted may give indexes larger than what array `a` allows.
269+
return np.clip(np.searchsorted(a, v), a_min=0, a_max=a.size - 1)
270+
271+
xmin = attrs["xmin"]
272+
ymin = attrs["ymin"]
273+
# Make all cell sizes positive for simplicity.
274+
dx = abs(attrs["dx"])
275+
dy = abs(attrs["dy"])
276+
new_dx = abs(new_dx)
277+
new_dy = abs(new_dy)
278+
# Note: iMOD stores dy from low to high!
279+
280+
# Compute the end location of each old cell
281+
# Note: y is INCREASING here since searchsorted requires sorted numbers!
282+
xend = xmin + dx.cumsum()
283+
yend = ymin + dy.cumsum()
284+
midx = np.arange(xmin, attrs["xmax"], new_dx) + 0.5 * new_dx
285+
midy = np.arange(ymin, attrs["ymax"], new_dy) + 0.5 * new_dy
286+
new_nrow = midy.size
287+
new_ncol = midx.size
288+
289+
# Compute how to index into the old array.
290+
# Search before which end the new midpoint lies.
291+
column = _inbounds_searchsorted(xend, midx)
292+
row = _inbounds_searchsorted(yend, midy)
293+
iy, ix = (a.ravel() for a in np.meshgrid(row, column, indexing="ij"))
294+
295+
# Take into account that y is actually DECREASING in spatial rasters:
296+
# index 0 becomes nrow - 1.
297+
# index nrow - 1 becomes 0.
298+
row = (attrs["nrow"] - 1) - row
299+
resampled_values = values[iy, ix].reshape((new_nrow, new_ncol))
300+
301+
# Updated attrs
302+
resampled_attrs = {
303+
"dx": new_dx,
304+
"dy": -new_dy,
305+
"ncol": new_ncol,
306+
"nrow": new_nrow,
307+
"xmin": xmin,
308+
"xmax": midx[-1] + 0.5 * new_dx,
309+
"ymin": ymin,
310+
"ymax": midy[-1] + 0.5 * new_dy,
311+
"nodata": attrs["nodata"],
312+
}
313+
return resampled_attrs, resampled_values
314+
315+
316+
def _maybe_resample(path: str, resample: bool) -> Tuple[Dict[str, Any], np.ndarray]:
317+
attrs, values = read_idf(path)
318+
319+
dx = attrs["dx"]
320+
dy = attrs["dy"]
321+
if isinstance(dx, float) and isinstance(dy, float):
322+
# Exactly equidistant, nothing to do.
323+
return attrs, values
324+
325+
# Check if approximately equidistant: allow 0.1% deviation)
326+
dx = np.atleast_1d(attrs["dx"])
327+
dy = np.atleast_1d(attrs["dy"])
328+
fixed_dx, dxmin, dxmax = _check_for_equidistance(dx)
329+
fixed_dy, dymin, dymax = _check_for_equidistance(dy)
330+
331+
if fixed_dx and fixed_dy:
332+
# Approximately equidistant. Just modify dx and dy.
333+
attrs["dx"] = dxmin
334+
attrs["dy"] = -dymin
335+
return attrs, values
336+
else:
337+
# Resample if allowed.
338+
if resample:
339+
return _resample(attrs, values, dxmin, dymin)
340+
else:
341+
raise ValueError(
342+
f"IDF file is not equidistant along x or y.\n"
343+
f"Cell sizes in x vary from {dxmin} to {dxmax}.\n"
344+
f"Cell sizes in y vary from {dymin} to {dymax}.\n"
345+
)
346+
347+
348+
def convert_idf_to_gdal(path: str, crs_wkt: str, resample: bool) -> Path:
229349
"""
230350
Read the contents of an iMOD IDF file and write it to a GeoTIFF, next to
231351
the IDF. This is similar to how iMOD treats ASCII files.
@@ -237,13 +357,15 @@ def convert_idf_to_gdal(path: str, crs_wkt: str) -> Path:
237357
crs_wkt: str
238358
Desired CRS to write in the created GeoTIFF. IDFs do not have a CRS on
239359
their own, one must be provided.
360+
resample: bool
361+
Whether to allow nearest-neigbhor resampling for non-equidistant grids.
240362
241363
Returns
242364
-------
243365
tiff_path: pathlib.Path
244366
Path to the newly created GeoTIFF file.
245367
"""
246-
attrs, values = read_idf(path)
368+
attrs, values = _maybe_resample(path, resample)
247369

248370
path = Path(path)
249371
tiff_path = (path.parent / (path.stem)).with_suffix(".tif")

imodqgis/idf/idf_dialog.py

+13-8
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def __init__(self, iface, parent) -> None:
3636
self.label = QLabel("iMOD IDF File(s)")
3737
self.line_edit = QLineEdit()
3838
self.line_edit.setMinimumWidth(250)
39+
self.resample_checkbox = QCheckBox("Resample non-equidistant IDFs")
3940
self.dialog_button = QPushButton("...")
4041
self.dialog_button.clicked.connect(self.file_dialog)
4142
self.close_button = QPushButton("Close")
@@ -48,19 +49,22 @@ def __init__(self, iface, parent) -> None:
4849
self.crs_widget = QgsCrsSelectionWidget(self)
4950
self.crs_widget.setCrs(iface.mapCanvas().mapSettings().destinationCrs())
5051
first_row = QHBoxLayout()
51-
first_row.addWidget(self.label)
52-
first_row.addWidget(self.line_edit)
53-
first_row.addWidget(self.dialog_button)
52+
first_row.addWidget(self.resample_checkbox)
5453
second_row = QHBoxLayout()
55-
second_row.addWidget(self.crs_widget)
54+
second_row.addWidget(self.label)
55+
second_row.addWidget(self.line_edit)
56+
second_row.addWidget(self.dialog_button)
5657
third_row = QHBoxLayout()
57-
third_row.addStretch()
58-
third_row.addWidget(self.close_button)
59-
third_row.addWidget(self.add_button)
58+
third_row.addWidget(self.crs_widget)
59+
fourth_row = QHBoxLayout()
60+
fourth_row.addStretch()
61+
fourth_row.addWidget(self.close_button)
62+
fourth_row.addWidget(self.add_button)
6063
layout = QVBoxLayout()
6164
layout.addLayout(first_row)
6265
layout.addLayout(second_row)
6366
layout.addLayout(third_row)
67+
layout.addLayout(fourth_row)
6468
self.setLayout(layout)
6569

6670
def file_dialog(self) -> None:
@@ -80,9 +84,10 @@ def add_idfs(self) -> None:
8084
if not self.crs_widget.hasValidSelection():
8185
return
8286
crs_wkt = self.crs_widget.crs().toWkt()
87+
resample = self.resample_checkbox.isChecked()
8388

8489
for path in paths:
85-
tiff_path = convert_idf_to_gdal(path, crs_wkt)
90+
tiff_path = convert_idf_to_gdal(path, crs_wkt, resample)
8691
layer = QgsRasterLayer(str(tiff_path), tiff_path.stem)
8792
renderer = pseudocolor_renderer(layer, band=1, colormap="Turbo", nclass=10)
8893
layer.setRenderer(renderer)

0 commit comments

Comments
 (0)