Skip to content

Commit 329c66d

Browse files
Copilotlachlangrose
andcommitted
Implement LoopStructural interpolation algorithms
- Complete processAlgorithm for LoopStructuralInterpolationAlgorithm - Create new LoopStructuralInterpolationEvaluationAlgorithm - Register new algorithm in provider - Add support for building interpolators from value and gradient constraints - Add support for evaluating interpolators on raster grids, 3D grids, and point sets Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com>
1 parent 8487840 commit 329c66d

File tree

3 files changed

+615
-12
lines changed

3 files changed

+615
-12
lines changed

loopstructural/processing/algorithms/interpolation/interpolation_algorithm.py

Lines changed: 200 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,26 @@
1010
"""
1111

1212
from typing import Any, Optional
13+
import tempfile
14+
import numpy as np
15+
import dill as pickle
1316

14-
from qgis import processing
1517
from qgis.core import (
16-
QgsFeatureSink,
1718
QgsProcessing,
1819
QgsProcessingAlgorithm,
1920
QgsProcessingContext,
2021
QgsProcessingException,
2122
QgsProcessingFeedback,
2223
QgsProcessingParameterExtent,
2324
QgsProcessingParameterNumber,
24-
QgsProcessingParameterFeatureSink,
2525
QgsProcessingParameterFeatureSource,
26-
QgsProcessingParameterField
26+
QgsProcessingParameterField,
27+
QgsProcessingParameterFileDestination,
2728
)
2829

30+
from LoopStructural import InterpolatorBuilder
31+
from LoopStructural.datatypes import BoundingBox
32+
2933

3034
class LoopStructuralInterpolationAlgorithm(QgsProcessingAlgorithm):
3135
"""Processing algorithm to create basal contacts."""
@@ -146,33 +150,217 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None:
146150
)
147151

148152
self.addParameter(
149-
QgsProcessingParameterFeatureSink(
153+
QgsProcessingParameterFileDestination(
150154
self.OUTPUT,
151-
"Interpolated Surface",
155+
"Output interpolator file (pickle)",
156+
fileFilter='Pickle files (*.pkl)'
152157
)
153158
)
154-
pass
155159

156160
def processAlgorithm(
157161
self,
158162
parameters: dict[str, Any],
159163
context: QgsProcessingContext,
160164
feedback: QgsProcessingFeedback,
161165
) -> dict[str, Any]:
166+
# Get input parameters
162167
value_layer = self.parameterAsSource(parameters, self.VALUE, context)
163168
value_field = self.parameterAsFields(parameters, self.VALUE_FIELD, context)
164169

165170
gradient_layer = self.parameterAsSource(parameters, self.GRADIENT, context)
166171
strike_field = self.parameterAsFields(parameters, self.STRIKE_FIELD, context)
167172
dip_field = self.parameterAsFields(parameters, self.DIP_FIELD, context)
168-
169-
inequality_layer = self.parameterAsSource(parameters, self.INEQUALITY, context)
170-
upper_field = self.parameterAsFields(parameters, self.INEQUALITY_UPPER_FIELD, context)
171-
lower_field = self.parameterAsFields(parameters, self.INEQUALITY_LOWER_FIELD, context)
172173

173174
extent = self.parameterAsExtent(parameters, self.EXTENT, context)
175+
pixel_size = self.parameterAsDouble(parameters, self.PIXEL_SIZE, context)
176+
177+
# Validate that we have at least some data
178+
if value_layer is None and gradient_layer is None:
179+
raise QgsProcessingException(
180+
"At least one of value or gradient layers must be provided."
181+
)
182+
183+
# Extract field names from lists
184+
value_field_name = value_field[0] if value_field else None
185+
strike_field_name = strike_field[0] if strike_field else None
186+
dip_field_name = dip_field[0] if dip_field else None
187+
188+
feedback.pushInfo("Building interpolator from constraints...")
189+
190+
# Create bounding box from extent
191+
if extent is None:
192+
raise QgsProcessingException("Output extent must be provided.")
193+
194+
# Calculate z extent based on pixel size (arbitrary choice: 10x pixel size for depth)
195+
z_min = 0
196+
z_max = pixel_size * 10
197+
198+
bbox = BoundingBox(
199+
origin=[extent.xMinimum(), extent.yMinimum(), z_min],
200+
maximum=[extent.xMaximum(), extent.yMaximum(), z_max]
201+
)
202+
203+
# Create interpolator builder
204+
interpolator_builder = InterpolatorBuilder(
205+
interpolatortype='PLI', # Piecewise Linear Interpolator
206+
bounding_box=bbox
207+
)
208+
209+
# Add value constraints
210+
if value_layer is not None and value_field_name:
211+
feedback.pushInfo(f"Adding value constraints from field '{value_field_name}'...")
212+
value_constraints = self._extract_value_constraints(
213+
value_layer, value_field_name, feedback
214+
)
215+
if len(value_constraints) > 0:
216+
interpolator_builder.add_value_constraints(value_constraints)
217+
feedback.pushInfo(f"Added {len(value_constraints)} value constraints.")
218+
else:
219+
feedback.pushWarning("No valid value constraints found.")
220+
221+
# Add gradient constraints from strike/dip
222+
if gradient_layer is not None and strike_field_name and dip_field_name:
223+
feedback.pushInfo(
224+
f"Adding gradient constraints from strike/dip fields '{strike_field_name}'/'{dip_field_name}'..."
225+
)
226+
gradient_constraints = self._extract_gradient_constraints(
227+
gradient_layer, strike_field_name, dip_field_name, feedback
228+
)
229+
if len(gradient_constraints) > 0:
230+
interpolator_builder.add_gradient_constraints(gradient_constraints)
231+
feedback.pushInfo(f"Added {len(gradient_constraints)} gradient constraints.")
232+
else:
233+
feedback.pushWarning("No valid gradient constraints found.")
234+
235+
# Build the interpolator
236+
feedback.pushInfo("Building interpolator...")
237+
try:
238+
interpolator = interpolator_builder.build()
239+
except Exception as e:
240+
raise QgsProcessingException(f"Failed to build interpolator: {e}")
241+
242+
# Save interpolator to pickle file
243+
out_path = self.parameterAsString(parameters, self.OUTPUT, context)
244+
if not out_path:
245+
tmp = tempfile.NamedTemporaryFile(suffix='.pkl', delete=False)
246+
out_path = tmp.name
247+
tmp.close()
248+
249+
feedback.pushInfo(f"Saving interpolator to {out_path}...")
250+
try:
251+
with open(out_path, 'wb') as fh:
252+
pickle.dump(interpolator, fh)
253+
except Exception as e:
254+
raise QgsProcessingException(f"Failed to save interpolator to '{out_path}': {e}")
255+
256+
feedback.pushInfo("Interpolator saved successfully.")
257+
return {self.OUTPUT: out_path}
258+
259+
def _extract_value_constraints(
260+
self,
261+
source,
262+
value_field: str,
263+
feedback: QgsProcessingFeedback
264+
) -> np.ndarray:
265+
"""Extract value constraints as numpy array (x, y, z, value)."""
266+
constraints = []
267+
for feat in source.getFeatures():
268+
geom = feat.geometry()
269+
if geom is None or geom.isEmpty():
270+
continue
271+
272+
# Get value
273+
try:
274+
value = feat.attribute(value_field)
275+
if value is None:
276+
continue
277+
value = float(value)
278+
except (ValueError, TypeError):
279+
continue
280+
281+
# Extract points from geometry
282+
points = []
283+
if geom.isMultipart():
284+
if geom.type() == 0: # Point
285+
points = geom.asMultiPoint()
286+
elif geom.type() == 1: # Line
287+
for line in geom.asMultiPolyline():
288+
points.extend(line)
289+
else:
290+
if geom.type() == 0: # Point
291+
points = [geom.asPoint()]
292+
elif geom.type() == 1: # Line
293+
points = geom.asPolyline()
294+
295+
# Add constraint for each point
296+
for p in points:
297+
try:
298+
x = p.x()
299+
y = p.y()
300+
z = p.z() if hasattr(p, 'z') else 0.0
301+
constraints.append([x, y, z, value])
302+
except Exception:
303+
continue
304+
305+
return np.array(constraints) if constraints else np.array([]).reshape(0, 4)
306+
307+
def _extract_gradient_constraints(
308+
self,
309+
source,
310+
strike_field: str,
311+
dip_field: str,
312+
feedback: QgsProcessingFeedback
313+
) -> np.ndarray:
314+
"""Extract gradient constraints from strike/dip as numpy array (x, y, z, gx, gy, gz)."""
315+
constraints = []
316+
for feat in source.getFeatures():
317+
geom = feat.geometry()
318+
if geom is None or geom.isEmpty():
319+
continue
320+
321+
# Get strike and dip
322+
try:
323+
strike = feat.attribute(strike_field)
324+
dip = feat.attribute(dip_field)
325+
if strike is None or dip is None:
326+
continue
327+
strike = float(strike)
328+
dip = float(dip)
329+
except (ValueError, TypeError):
330+
continue
331+
332+
# Extract points from geometry (should be point geometry)
333+
points = []
334+
if geom.isMultipart():
335+
if geom.type() == 0: # Point
336+
points = geom.asMultiPoint()
337+
else:
338+
if geom.type() == 0: # Point
339+
points = [geom.asPoint()]
340+
341+
# Convert strike/dip to gradient vector
342+
# Strike is azimuth (0-360), dip is angle from horizontal (0-90)
343+
# This follows geological convention
344+
strike_rad = np.radians(strike)
345+
dip_rad = np.radians(dip)
346+
347+
# Gradient vector (normal to the plane)
348+
# This is the gradient direction pointing updip
349+
gx = np.sin(dip_rad) * np.sin(strike_rad)
350+
gy = -np.sin(dip_rad) * np.cos(strike_rad)
351+
gz = np.cos(dip_rad)
352+
353+
# Add constraint for each point
354+
for p in points:
355+
try:
356+
x = p.x()
357+
y = p.y()
358+
z = p.z() if hasattr(p, 'z') else 0.0
359+
constraints.append([x, y, z, gx, gy, gz])
360+
except Exception:
361+
continue
174362

175-
print(extent)
363+
return np.array(constraints) if constraints else np.array([]).reshape(0, 6)
176364

177365
def createInstance(self) -> QgsProcessingAlgorithm:
178366
"""Create a new instance of the algorithm."""

0 commit comments

Comments
 (0)