Skip to content

Commit 92d6981

Browse files
committed
fix: Updating intrusions code
- Added a new method `_validate_intrusion_inputs` in `GeologicalModel` to validate inputs for intrusions, ensuring necessary data is present before processing. - Updated `_build_intrusion` to call the new validation method, improving error handling for missing data. - Refactored `IntrusionBuilder.create_geometry_using_geometric_scaling` to clarify that geometric scaling is not currently implemented, raising a `NotImplementedError` immediately. - Simplified threshold handling in `IntrusionFeature` by removing redundant checks for marginal faults. - Removed the unused `intrusion_support_functions.py` file to clean up the codebase. - Updated tests in `test_intrusions.py` to cover new validation logic, ensuring clear error messages for missing data and parameters. - Added regression tests for previously silent errors related to weight handling and geometric scaling. (cherry picked from commit 8cb80c1)
1 parent 160c6e2 commit 92d6981

9 files changed

Lines changed: 850 additions & 525 deletions

File tree

INTRUSIONS.md

Lines changed: 443 additions & 0 deletions
Large diffs are not rendered by default.

LoopStructural/modelling/core/geological_model.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1402,6 +1402,50 @@ def create_and_add_intrusion(
14021402
**kwargs,
14031403
)
14041404

1405+
def _validate_intrusion_inputs(
1406+
self,
1407+
intrusion_name,
1408+
intrusion_frame_name,
1409+
intrusion_data,
1410+
intrusion_frame_data,
1411+
intrusion_frame_parameters,
1412+
):
1413+
"""Fail fast, at the `create_and_add_intrusion` boundary, with a clear
1414+
message naming the missing piece -- instead of a bare `KeyError`
1415+
several calls deep inside `IntrusionFrameBuilder`/`IntrusionBuilder`
1416+
once building has already started. See ``INTRUSIONS.md`` finding 4.
1417+
"""
1418+
if intrusion_data.empty:
1419+
raise ValueError(
1420+
f"No data found for intrusion '{intrusion_name}': check that "
1421+
"model.data contains rows with feature_name == "
1422+
f"'{intrusion_name}'"
1423+
)
1424+
if intrusion_frame_data.empty:
1425+
raise ValueError(
1426+
f"No data found for intrusion frame '{intrusion_frame_name}': "
1427+
"check that model.data contains rows with feature_name == "
1428+
f"'{intrusion_frame_name}'"
1429+
)
1430+
required_columns = ["intrusion_contact_type", "intrusion_side"]
1431+
missing_columns = [c for c in required_columns if c not in intrusion_data.columns]
1432+
if missing_columns:
1433+
raise ValueError(
1434+
f"Intrusion data for '{intrusion_name}' is missing required "
1435+
f"column(s) {missing_columns}: 'intrusion_contact_type' marks "
1436+
"each point as 'roof'/'top' or 'floor'/'base', and "
1437+
"'intrusion_side' (boolean) marks points used to constrain "
1438+
"the lateral extent"
1439+
)
1440+
contact_anisotropies = intrusion_frame_parameters.get("contact_anisotropies")
1441+
if not contact_anisotropies:
1442+
raise ValueError(
1443+
"intrusion_frame_parameters['contact_anisotropies'] is "
1444+
"required: provide a non-empty list of series-type features "
1445+
"to use as the inflation-gradient proxy for the intrusion "
1446+
"frame's coordinate 0"
1447+
)
1448+
14051449
def _build_intrusion(
14061450
self,
14071451
intrusion_name,
@@ -1458,6 +1502,14 @@ def _build_intrusion(
14581502
intrusion_data = self.data[self.data["feature_name"] == intrusion_name].copy()
14591503
intrusion_frame_data = self.data[self.data["feature_name"] == intrusion_frame_name].copy()
14601504

1505+
self._validate_intrusion_inputs(
1506+
intrusion_name,
1507+
intrusion_frame_name,
1508+
intrusion_data,
1509+
intrusion_frame_data,
1510+
intrusion_frame_parameters,
1511+
)
1512+
14611513
# -- get variables for intrusion frame interpolation
14621514
gxxgz = kwargs.get("gxxgz", 0)
14631515
gxxgy = kwargs.get("gxxgy", 0)
@@ -1496,7 +1548,7 @@ def _build_intrusion(
14961548
nelements=nelements,
14971549
w2=weights[0],
14981550
w1=weights[1],
1499-
gxygz=weights[2],
1551+
gyxgz=weights[2],
15001552
)
15011553

15021554
intrusion_frame = intrusion_frame_builder.frame

LoopStructural/modelling/intrusions/intrusion_builder.py

Lines changed: 19 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
from ...utils import getLogger, rng
55
from ..features.builders import BaseBuilder
6-
from .geometric_scaling_functions import *
76
from .intrusion_feature import IntrusionFeature
87

98
logger = getLogger(__name__)
@@ -113,45 +112,25 @@ def set_data_for_extent_calculation(self, intrusion_data: pd.DataFrame):
113112
def create_geometry_using_geometric_scaling(
114113
self, geometric_scaling_parameters, reference_contact_data
115114
):
116-
117-
geometric_scaling_parameters.get("intrusion_type", None)
118-
intrusion_length = geometric_scaling_parameters.get("intrusion_length", None)
119-
geometric_scaling_parameters.get("inflation_vector", np.array([[0, 0, 1]]))
120-
thickness = geometric_scaling_parameters.get("thickness", None)
121-
122-
if (
123-
self.intrusion_frame.builder.intrusion_network_contact == "floor"
124-
or self.intrusion_frame.builder.intrusion_network_contact == "base"
125-
):
126-
geometric_scaling_parameters.get("inflation_vector", np.array([[0, 0, 1]]))
127-
else:
128-
geometric_scaling_parameters.get("inflation_vector", np.array([[0, 0, -1]]))
129-
130-
if intrusion_length is None and thickness is None:
131-
raise ValueError(
132-
f"No {self.intrusion_frame.builder.intrusion_other_contact} data. Add intrusion_type and intrusion_length (or thickness) to geometric_scaling_parameters dictionary"
133-
)
134-
135-
else: # -- create synthetic data to constrain interpolation using geometric scaling
136-
estimated_thickness = thickness
137-
if estimated_thickness is None:
138-
raise NotImplementedError("Not implemented")
139-
# estimated_thickness = thickness_from_geometric_scaling(
140-
# intrusion_length, intrusion_type
141-
# )
142-
143-
logger.info(
144-
f"Building tabular intrusion using geometric scaling parameters: estimated thicknes = {round(estimated_thickness)} meters"
145-
)
146-
raise NotImplementedError("Not implemented")
147-
# (
148-
# other_contact_data_temp,
149-
# other_contact_data_xyz_temp,
150-
# ) = contact_pts_using_geometric_scaling(
151-
# estimated_thickness, reference_contact_data, inflation_vector
152-
# )
153-
154-
# return other_contact_data_temp
115+
"""Not currently implemented.
116+
117+
This is meant to synthesise the missing contact (roof or floor) from
118+
an estimated thickness (either given directly or derived from
119+
empirical length/thickness scaling laws, see
120+
``geometric_scaling_functions.thickness_from_geometric_scaling``) and
121+
an inflation vector, via
122+
``geometric_scaling_functions.contact_pts_using_geometric_scaling``.
123+
That wiring was never completed, so every call path here always
124+
raised ``NotImplementedError`` regardless of what was passed in
125+
(see ``INTRUSIONS.md`` finding 2). Raising immediately, rather than
126+
after partially validating parameters, makes that unambiguous.
127+
"""
128+
raise NotImplementedError(
129+
"geometric_scaling_parameters is not currently supported: "
130+
f"'{self.intrusion_frame.builder.intrusion_other_contact}' contact "
131+
"has no data, and synthesising it from geometric scaling is not "
132+
"implemented. Provide explicit data for both contacts instead."
133+
)
155134

156135
def prepare_data(self, geometric_scaling_parameters):
157136
"""Prepare the data to compute distance thresholds along the frame coordinates.

LoopStructural/modelling/intrusions/intrusion_feature.py

Lines changed: 2 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,8 @@ def evaluate_value(self, pos):
265265
intrusion_coord1_pts
266266
)
267267

268-
if self.intrusion_frame.builder.marginal_faults is not None:
269-
c2_minside_threshold = thresholds[0] # np.zeros_like(intrusion_coord2_pts)
270-
c2_maxside_threshold = thresholds[1]
271-
272-
else:
273-
c2_minside_threshold = thresholds[0]
274-
c2_maxside_threshold = thresholds[1]
268+
c2_minside_threshold = thresholds[0]
269+
c2_maxside_threshold = thresholds[1]
275270

276271
thresholds, _residuals, _conceptual = self.interpolate_vertical_thresholds(
277272
intrusion_coord1_pts, intrusion_coord2_pts
@@ -332,81 +327,6 @@ def evaluate_value(self, pos):
332327

333328
return intrusion_sf
334329

335-
def evaluate_value_test(self, points):
336-
"""
337-
Computes a distance scalar field to the intrusion contact (isovalue = 0).
338-
339-
Parameters
340-
------------
341-
points : numpy array (x,y,z), points where the IntrusionFeature is evaluated.
342-
343-
Returns
344-
------------
345-
intrusion_sf : numpy array, contains distance to intrusion contact
346-
347-
"""
348-
self.builder.up_to_date()
349-
350-
# compute coordinates values for each evaluated point
351-
intrusion_coord0_pts = self.intrusion_frame[0].evaluate_value(points)
352-
intrusion_coord1_pts = self.intrusion_frame[1].evaluate_value(points)
353-
intrusion_coord2_pts = self.intrusion_frame[2].evaluate_value(points)
354-
355-
self.evaluated_points = [
356-
points,
357-
intrusion_coord0_pts,
358-
intrusion_coord1_pts,
359-
intrusion_coord2_pts,
360-
]
361-
362-
thresholds, _residuals, _conceptual = self.interpolate_lateral_thresholds(
363-
intrusion_coord1_pts
364-
)
365-
366-
if self.intrusion_frame.builder.marginal_faults is not None:
367-
c2_minside_threshold = np.zeros_like(intrusion_coord2_pts)
368-
c2_maxside_threshold = thresholds[1]
369-
370-
else:
371-
c2_minside_threshold = thresholds[0]
372-
c2_maxside_threshold = thresholds[1]
373-
374-
thresholds, _residuals, _conceptual = self.interpolate_vertical_thresholds(
375-
intrusion_coord1_pts, intrusion_coord2_pts
376-
)
377-
c0_minside_threshold = thresholds[1]
378-
c0_maxside_threshold = thresholds[0]
379-
380-
mid_point = c0_minside_threshold + ((c0_maxside_threshold - c0_minside_threshold) / 2)
381-
382-
mod_intrusion_coord0_pts = intrusion_coord0_pts - mid_point
383-
mod_c0_minside_threshold = c0_minside_threshold - mid_point
384-
mod_c0_maxside_threshold = c0_maxside_threshold + mid_point
385-
386-
a = (
387-
(mod_intrusion_coord0_pts >= mid_point)
388-
* (c2_minside_threshold < intrusion_coord2_pts)
389-
* (intrusion_coord2_pts < c2_maxside_threshold)
390-
)
391-
b = (
392-
(mod_intrusion_coord0_pts <= mid_point)
393-
* (c2_minside_threshold < intrusion_coord2_pts)
394-
* (intrusion_coord2_pts < c2_maxside_threshold)
395-
)
396-
c = (
397-
(mod_intrusion_coord0_pts <= mid_point)
398-
* (mod_intrusion_coord0_pts >= mod_c0_minside_threshold)
399-
* (c2_minside_threshold < intrusion_coord2_pts)
400-
* (intrusion_coord2_pts < c2_maxside_threshold)
401-
)
402-
403-
intrusion_sf = mod_intrusion_coord0_pts
404-
intrusion_sf[a] = mod_intrusion_coord0_pts[a] - mod_c0_maxside_threshold[a]
405-
intrusion_sf[b] = abs(mod_c0_minside_threshold[b] + mod_intrusion_coord0_pts[b])
406-
intrusion_sf[c] = mod_intrusion_coord0_pts[c] - mod_c0_minside_threshold[c]
407-
408-
return intrusion_sf
409-
410330
def get_data(self, value_map: dict | None = None):
411331
pass
412332

LoopStructural/modelling/intrusions/intrusion_frame_builder.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
logger.error('Scikitlearn cannot be imported')
1818
raise
1919

20+
# Fixed (not derived from the shared `rng`) so that repeated builds of the
21+
# same intrusion produce the same contact/fault clustering: `loop_common`'s
22+
# shared `rng` is a fresh, unseeded `np.random.default_rng()` per process, so
23+
# routing this through it would make cluster labels vary run-to-run instead.
24+
_KMEANS_RANDOM_STATE = 0
25+
2026

2127
class IntrusionFrameBuilder(StructuralFrameBuilder):
2228
def __init__(
@@ -228,10 +234,9 @@ def add_contact_anisotropies(self, series_list: list | None = None, **kwargs):
228234

229235
# -- use scalar field values to find different contacts
230236
series_i_vals_mod = series_i_vals.reshape(len(series_i_vals), 1)
231-
# TODO create global loopstructural random state variable
232-
contact_clustering = KMeans(n_clusters=n_contacts, random_state=0).fit(
233-
series_i_vals_mod
234-
)
237+
contact_clustering = KMeans(
238+
n_clusters=n_contacts, random_state=_KMEANS_RANDOM_STATE
239+
).fit(series_i_vals_mod)
235240

236241
for j in range(n_contacts):
237242
z = np.ma.masked_not_equal(contact_clustering.labels_, j)
@@ -358,7 +363,9 @@ def set_intrusion_steps_parameters(self):
358363
)
359364
series_values = series_from_name.evaluate_value(data_points_xyz)
360365
series_values_mod = series_values.reshape(len(series_values), 1)
361-
contact_clustering = KMeans(n_clusters=2, random_state=0).fit(series_values_mod)
366+
contact_clustering = KMeans(
367+
n_clusters=2, random_state=_KMEANS_RANDOM_STATE
368+
).fit(series_values_mod)
362369

363370
# contact 0
364371
z = np.ma.masked_not_equal(contact_clustering.labels_, 0)
@@ -491,7 +498,6 @@ def set_marginal_faults_parameters(self):
491498
for fault_i in self.marginal_faults:
492499
marginal_fault = self.marginal_faults[fault_i].get("structure")
493500
block = self.marginal_faults[fault_i].get("block") # hanging wall or foot wall
494-
self.marginal_faults[fault_i].get("emplacement_mechanism")
495501
series_name = self.marginal_faults[fault_i].get("series")
496502

497503
series_values_temp = series_name.evaluate_value(intrusion_frame_c0_data_xyz)
@@ -828,7 +834,6 @@ def create_constraints_for_c0(self, **kwargs):
828834
delta_contact = self.marginal_faults[fault_i].get("delta_c", 1)
829835
marginal_fault = self.marginal_faults[fault_i].get("structure")
830836
block = self.marginal_faults[fault_i].get("block") # hanging wall or foot wall
831-
self.marginal_faults[fault_i].get("emplacement_mechanism")
832837
series_name = self.marginal_faults[fault_i].get("series")
833838

834839
fault_gridpoints_vals = marginal_fault[0].evaluate_value(grid_points)
@@ -976,7 +981,3 @@ def set_intrusion_frame_data(self, intrusion_frame_data): # , intrusion_network
976981

977982
self.add_data_from_data_frame(intrusion_frame_data_complete)
978983
self.update_geometry(intrusion_frame_data_complete[["X", "Y", "Z"]].to_numpy())
979-
980-
def update(self):
981-
for i in range(3):
982-
self.builders[i].update()

0 commit comments

Comments
 (0)