Skip to content

Commit d4cbdc9

Browse files
lachlangroseclaude
andcommitted
fix: correct stratigraphic value assignment and domain-fault boundary follow-ups
Several fixes to make domain-fault-bounded stratigraphic columns build and display correctly: - update_foliation_features now trains each unit's basal-contact data at its own max() (the boundary with the next-older unit, i.e. its true base) instead of min() (the boundary with the next-younger unit, i.e. its top). Basal contacts represent a unit's base, so training at min() anchored every unit's own data to the wrong boundary -- confirmed on a live project where units evaluated into their next-younger neighbour's bracket instead of their own, and a basement unit with no contact data of its own never appeared in the model at all. - Unit thickness now accumulates unconditionally while building that training data, so an undigitised placeholder unit no longer shifts every later unit's value by its own thickness. - Use each fault trace point's own local tangent (rather than one global best-fit line) when extending a domain-boundary fault to the model's bounding box and deriving its orientation constraints, so a curved trace doesn't get flattened into the wrong extrapolation. - Recompute stratigraphic unit value ranges after restoring a column from a saved project (both initial load and reload), matching what a fresh column already gets -- otherwise every restored unit kept the default (0, inf) range and couldn't be told apart from its neighbours. - Show the generic details panel for a domain-fault feature instead of an empty widget. - Skip an isosurface with no geometry when adding stratigraphic surfaces to the 3D viewer instead of crashing, since an undigitised unit can legitimately have no constrained geometry anywhere in the model. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent fbe4ce9 commit d4cbdc9

6 files changed

Lines changed: 282 additions & 107 deletions

File tree

‎loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from .add_foliation_dialog import AddFoliationDialog
2121
from .add_unconformity_dialog import AddUnconformityDialog
2222
from .feature_details_panel import (
23+
BaseFeatureDetailsPanel,
2324
FaultFeatureDetailsPanel,
2425
FoldedFeatureDetailsPanel,
2526
FoliationFeatureDetailsPanel,
@@ -461,6 +462,17 @@ def on_feature_selected(self, item):
461462
self.featureDetailsPanel = FoldedFeatureDetailsPanel(
462463
feature=feature, model_manager=self.model_manager, data_manager=self.data_manager
463464
)
465+
elif feature.type == FeatureType.DOMAINFAULT:
466+
# A domain fault is built by the same GeologicalFeatureBuilder
467+
# as a foliation (see create_and_add_domain_fault), just with a
468+
# different .type tag -- the generic base panel (interpolator
469+
# settings, data layers, export/evaluate) already applies to it
470+
# unchanged. Skip FoliationFeatureDetailsPanel's fold-frame
471+
# attachment controls, which don't make sense for a domain
472+
# boundary.
473+
self.featureDetailsPanel = BaseFeatureDetailsPanel(
474+
feature=feature, model_manager=self.model_manager, data_manager=self.data_manager
475+
)
464476
else:
465477
self.featureDetailsPanel = QWidget() # Default empty panel
466478

‎loopstructural/gui/visualisation/feature_list_widget.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,8 +458,18 @@ def add_stratigraphic_surfaces(self):
458458
stratigraphic_surfaces = self.model_manager.model.get_stratigraphic_surfaces()
459459

460460
for surface in stratigraphic_surfaces:
461+
mesh = surface.vtk()
462+
if mesh.n_points == 0:
463+
# A unit with no digitised data of its own (e.g. an
464+
# undigitised placeholder like "Top") can have no
465+
# constrained geometry anywhere in the model, so its
466+
# isovalue may not intersect the solved field at all --
467+
# pyvista refuses to plot an empty mesh, so skip it rather
468+
# than crashing every surface after it in this loop.
469+
logger.info(f"Skipping '{surface.name}': isosurface has no geometry.")
470+
continue
461471
self.viewer.add_mesh_object(
462-
surface.vtk(),
472+
mesh,
463473
name=surface.name,
464474
color=surface.colour,
465475
source_feature=surface.name,

‎loopstructural/main/data_manager.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,8 @@ def from_dict(self, data):
10991099
self._structural_orientations = data['structural_orientations']
11001100
if 'stratigraphic_column' in data:
11011101
self._stratigraphic_column = StratigraphicColumn.from_dict(data['stratigraphic_column'])
1102+
# See the matching call in update_from_dict for why this is needed.
1103+
self._stratigraphic_column.update_unit_values()
11021104
self.stratigraphic_column_callback()
11031105
self._fault_boundaries.clear()
11041106
if data.get('fault_boundaries'):
@@ -1199,6 +1201,16 @@ def update_from_dict(self, data):
11991201
)
12001202
if 'stratigraphic_column' in data:
12011203
self._stratigraphic_column.update_from_dict(data['stratigraphic_column'])
1204+
# update_from_dict restores elements via add_element, not
1205+
# add_unit -- only add_unit computes each unit's min/max
1206+
# scalar-field range as a side effect. Without this, every
1207+
# restored unit keeps the default (0, inf) range, so
1208+
# evaluate_model can't tell any unit in a group apart from any
1209+
# other and just labels every point with whichever unit was
1210+
# last in the group (see GeologicalModelManager.
1211+
# set_stratigraphic_column, which already does this for the
1212+
# very first load -- this covers every reload afterwards).
1213+
self._stratigraphic_column.update_unit_values()
12021214
else:
12031215
self._stratigraphic_column.clear()
12041216

‎loopstructural/main/model_manager.py‎

Lines changed: 127 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -592,91 +592,115 @@ def _clip_line_to_bounding_box(self, centroid, direction):
592592

593593
def _extend_fault_trace_to_domain(self, fault_data):
594594
"""Add two synthetic points that extend a fault's trace out to the
595-
edges of the model's bounding box along its own overall trend.
595+
edges of the model's bounding box, and attach a `strike` column
596+
derived from the trace's own *local* tangent at each point.
596597
597598
`create_and_add_domain_fault` interpolates a scalar field only from
598599
the points it is given, over the model's exact bounding box (no
599600
buffer, unlike a displacement fault's mesh) -- so a locally
600601
digitised trace only reliably constrains the surface near itself,
601-
and the domain crop can wander unpredictably further away. Fitting
602-
a line through the existing XY points and adding two constraint
603-
points where that line meets the bounding box edges keeps the
604-
interpolated surface following the fault's actual trend all the
605-
way across the domain, rather than an arbitrary extrapolation --
602+
and the domain crop can wander unpredictably further away.
603+
Extending each end along its own local tangent, out to where it
604+
meets the bounding box edge, keeps the interpolated surface
605+
following the trace's actual trend all the way across the domain --
606606
this is what makes the fault behave as an "infinite" domain
607607
boundary rather than a locally-anchored patch.
608608
609+
Using each point's *local* tangent (rather than one global
610+
best-fit line through the whole trace) matters for a genuinely
611+
curved trace: fitting a single global line flattens that curvature
612+
out, and extrapolating along it can land an extension point (or
613+
bias the interpolated field generally) on the wrong side of the
614+
real curve relative to data that's actually near the trace.
615+
Confirmed on a live project: a global-line fit classified a
616+
stratigraphic unit's own contact data as being on the opposite
617+
side of the domain fault from a bounding-box corner that a proper
618+
local (nearest-segment) classification put on the *same* side as
619+
that data -- i.e. the global fit was extrapolating the wrong way.
620+
609621
Z at each synthetic point is extrapolated linearly against
610-
distance along that line, so a dipping trace keeps its dip.
622+
distance along the local end segment, so a dipping trace keeps its
623+
dip at the point it's extended from.
611624
"""
612625
xy = fault_data[['X', 'Y']].to_numpy()
613-
if len(xy) < 2:
614-
return fault_data
615-
centroid = xy.mean(axis=0)
616-
# Principal direction of the trace via SVD -- robust to a
617-
# near-vertical (large-Y-range, small-X-range) trace, unlike a
618-
# simple polyfit of Y against X.
619-
_, _, vt = np.linalg.svd(xy - centroid)
620-
direction = vt[0]
621-
clipped = self._clip_line_to_bounding_box(centroid, direction)
622-
if clipped is None:
623-
return fault_data
624-
t_min, t_max = clipped
625-
projections = (xy - centroid) @ direction
626626
z = fault_data['Z'].to_numpy()
627-
if len(np.unique(projections)) > 1:
628-
z_slope, z_intercept = np.polyfit(projections, z, 1)
629-
else:
630-
z_slope, z_intercept = 0.0, float(np.mean(z))
631-
new_rows = []
632-
for t in (t_min, t_max):
633-
point_xy = centroid + direction * t
634-
new_rows.append({'X': point_xy[0], 'Y': point_xy[1], 'Z': z_slope * t + z_intercept})
635-
return pd.concat([fault_data, pd.DataFrame(new_rows)], ignore_index=True)
636-
637-
def _domain_fault_orientation_rows(self, points_xyz, fault_entry):
638-
"""Build strike/dip orientation constraint rows for a domain-boundary fault.
639-
640-
A domain fault built only from same-valued (val=0) points has no
641-
information about which direction the field should vary -- the
642-
minimum-curvature solution that exactly satisfies "value=0 along
643-
this line" with nothing else to go on is a trivial constant/flat
644-
field (zero curvature everywhere, zero misfit). That's
645-
geometrically useless: the crop condition
646-
`domain_fault.evaluate_value(pos) > 0`
647-
(see `LoopStructural.modelling.core._model_relationships`) is then
648-
never true anywhere, so everything on the "positive" side reads as
649-
NaN. Confirmed against a live project: a fault built from 31 trace
650-
points and 2 extension points, all val=0, interpolated to an
651-
exactly flat 0.0 field everywhere; adding one orientation
652-
constraint per point produced a properly varying field crossing
653-
zero along the fault's own trend.
654-
655-
Strike comes from the trace's own principal direction (matching
656-
`_extend_fault_trace_to_domain`'s line fit); dip comes from the
657-
ingested fault trace data's `dip` column if present (matching how
658-
`update_fault_features` picks up dip for a displacement fault),
659-
otherwise defaults to vertical (90 degrees).
660-
"""
661-
xy = points_xyz[['X', 'Y']].to_numpy()
662-
if len(xy) < 2:
663-
return None
664-
centroid = xy.mean(axis=0)
665-
_, _, vt = np.linalg.svd(xy - centroid)
666-
direction = vt[0]
627+
n = len(xy)
628+
result = fault_data.copy()
629+
if n < 2:
630+
result['strike'] = np.nan
631+
return result
632+
633+
# Local tangent per point: central difference for interior points,
634+
# forward/backward difference at the ends. Assumes points follow
635+
# the digitised line's vertex order (true for AllSampler-derived
636+
# trace data, which walks each LineString's coords in order).
637+
tangents = np.zeros((n, 2))
638+
tangents[0] = xy[1] - xy[0]
639+
tangents[-1] = xy[-1] - xy[-2]
640+
if n > 2:
641+
tangents[1:-1] = xy[2:] - xy[:-2]
667642
# strikedip2vector's strike is degrees clockwise from North (+Y);
668643
# atan2(dx, dy) matches that convention directly.
669-
strike = float(np.degrees(np.arctan2(direction[0], direction[1])) % 360)
670-
dip = 90.0
644+
result['strike'] = np.degrees(np.arctan2(tangents[:, 0], tangents[:, 1])) % 360
645+
646+
new_rows = []
647+
# Extend backward past the first point, continuing on its own
648+
# local tangent (pointing away from the second point).
649+
start_seg = xy[1] - xy[0]
650+
start_len = np.linalg.norm(start_seg)
651+
if start_len > 1e-9:
652+
direction = -start_seg / start_len
653+
clipped = self._clip_line_to_bounding_box(xy[0], direction)
654+
if clipped is not None:
655+
_, t_max = clipped
656+
if t_max > 0:
657+
point_xy = xy[0] + direction * t_max
658+
z_slope = (z[1] - z[0]) / start_len
659+
new_rows.append(
660+
{
661+
'X': point_xy[0],
662+
'Y': point_xy[1],
663+
'Z': z[0] - z_slope * t_max,
664+
'strike': result['strike'].iloc[0],
665+
}
666+
)
667+
# Extend forward past the last point, continuing on its own local
668+
# tangent (pointing away from the second-to-last point).
669+
end_seg = xy[-1] - xy[-2]
670+
end_len = np.linalg.norm(end_seg)
671+
if end_len > 1e-9:
672+
direction = end_seg / end_len
673+
clipped = self._clip_line_to_bounding_box(xy[-1], direction)
674+
if clipped is not None:
675+
_, t_max = clipped
676+
if t_max > 0:
677+
point_xy = xy[-1] + direction * t_max
678+
z_slope = (z[-1] - z[-2]) / end_len
679+
new_rows.append(
680+
{
681+
'X': point_xy[0],
682+
'Y': point_xy[1],
683+
'Z': z[-1] + z_slope * t_max,
684+
'strike': result['strike'].iloc[-1],
685+
}
686+
)
687+
if not new_rows:
688+
return result
689+
return pd.concat([result, pd.DataFrame(new_rows)], ignore_index=True)
690+
691+
def _domain_fault_dip(self, fault_entry):
692+
"""Dip (degrees from horizontal) for a domain-boundary fault.
693+
694+
Uses the ingested fault trace data's `dip` column if present
695+
(matching how `update_fault_features` picks up dip for a
696+
displacement fault), otherwise defaults to vertical (90 degrees).
697+
"""
671698
raw_data = fault_entry.get('data') if fault_entry else None
672699
if raw_data is not None and 'dip' in raw_data:
673700
dip_values = raw_data['dip'].dropna()
674701
if not dip_values.empty:
675-
dip = float(dip_values.mean())
676-
rows = points_xyz[['X', 'Y', 'Z']].copy()
677-
rows['strike'] = strike
678-
rows['dip'] = dip
679-
return rows
702+
return float(dip_values.mean())
703+
return 90.0
680704

681705
def _build_domain_fault_boundary(self, fault_name, groupname):
682706
"""Build `fault_name` as a domain-fault boundary in place of a flat unconformity.
@@ -702,14 +726,19 @@ def _build_domain_fault_boundary(self, fault_name, groupname):
702726
log_level=2,
703727
)
704728
return False
705-
data_for_fault = self._extend_fault_trace_to_domain(fault_data[['X', 'Y', 'Z']].copy())
706-
orientation_rows = self._domain_fault_orientation_rows(data_for_fault, fault_entry)
707-
data_for_fault['feature_name'] = fault_name
708-
data_for_fault['val'] = 0
709-
if orientation_rows is not None:
729+
extended = self._extend_fault_trace_to_domain(fault_data[['X', 'Y', 'Z']].copy())
730+
731+
value_rows = extended[['X', 'Y', 'Z']].copy()
732+
value_rows['feature_name'] = fault_name
733+
value_rows['val'] = 0
734+
735+
orientation_rows = extended.dropna(subset=['strike'])[['X', 'Y', 'Z', 'strike']].copy()
736+
data_for_fault = value_rows
737+
if not orientation_rows.empty:
738+
orientation_rows['dip'] = self._domain_fault_dip(fault_entry)
710739
orientation_rows['feature_name'] = fault_name
711740
orientation_rows['val'] = np.nan
712-
data_for_fault = pd.concat([data_for_fault, orientation_rows], ignore_index=True)
741+
data_for_fault = pd.concat([value_rows, orientation_rows], ignore_index=True)
713742
# Unlike create_and_add_foliation/create_and_add_fault (which
714743
# normalise their own `data=` argument internally via
715744
# model.prepare_data before building), create_and_add_domain_fault
@@ -758,10 +787,32 @@ def update_foliation_features(self):
758787
groupname = group.name
759788
stratigraphic_column[groupname] = {}
760789
for u in reversed(group.units):
790+
# `reversed(group.units)` walks youngest-to-oldest (matching
791+
# StratigraphicColumn.update_unit_values's own cumulative
792+
# walk), so `val` must accumulate every unit's thickness
793+
# *before* being used as that unit's own training value --
794+
# regardless of whether the unit has any digitised data --
795+
# to land on `u.max()`, not `u.min()`.
796+
#
797+
# `u.min()` is the boundary shared with the next *younger*
798+
# neighbour (this unit's top); `u.max()` is the boundary
799+
# shared with the next *older* neighbour (this unit's true
800+
# base). Digitised "basal contact" data represents a unit's
801+
# base, so it belongs at `u.max()`. Using `u.min()` instead
802+
# anchors every unit's own contact points to its top
803+
# boundary rather than its base -- confirmed on a live
804+
# project: every unit's own mapped points evaluated into its
805+
# next-younger neighbour's bracket instead of its own.
806+
#
807+
# Accumulating unconditionally (not skipped for a unit with
808+
# no digitised data, e.g. an undigitised "Top"/basement
809+
# placeholder) also keeps every later unit's value aligned
810+
# with `get_isovalues()`'s own cumulative-thickness bracket
811+
# boundaries, which don't know or care which units were
812+
# actually mapped.
813+
val += u.thickness
761814
unit_data = self.stratigraphy.get(u.name, None)
762-
if unit_data is None:
763-
continue
764-
else:
815+
if unit_data is not None:
765816
if 'contact' in unit_data:
766817
contact = unit_data['contact']
767818
if not contact.empty:
@@ -774,8 +825,6 @@ def update_foliation_features(self):
774825
orientations['val'] = np.nan
775826
orientations['feature_name'] = groupname
776827
data.append(orientations)
777-
778-
val += u.thickness
779828
if len(data) == 0:
780829
self._debug_manager.log(
781830
f"No data found for group {groupname}, skipping.", log_level=2

‎tests/qgis/test_fault_domain_boundary.py‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,16 @@ def test_group_boundary_linked_to_fault_builds_domain_fault(self, manager):
9090

9191
registered = manager.model.data
9292
fault_rows = registered.loc[registered['feature_name'] == 'boundary_fault']
93-
# The original 2 trace points plus 2 synthetic points extending the
94-
# trace to the model's bounding box edges (see
95-
# `_extend_fault_trace_to_domain`).
93+
# The default bounding box here (never set explicitly) is smaller
94+
# than the trace itself, so no synthetic edge-extension points get
95+
# added (see TestExtendFaultTraceToDomain for that, with a
96+
# realistic bounding box). What's registered is the 2 trace points
97+
# as value (val=0) constraints, plus the same 2 points again as
98+
# orientation (val=NaN, strike/dip) constraints -- a domain fault
99+
# needs both, see _domain_fault_dip / _extend_fault_trace_to_domain.
96100
assert len(fault_rows) == 4
97-
assert set(fault_rows['val']) == {0}
101+
assert len(fault_rows.loc[fault_rows['val'] == 0]) == 2
102+
assert fault_rows['val'].isna().sum() == 2
98103

99104
def test_group_boundary_without_fault_link_uses_flat_unconformity(self, manager):
100105
column, _boundary = _two_group_column()
@@ -258,3 +263,29 @@ def test_direction_parallel_to_axis_outside_domain_is_left_unchanged(self, manag
258263
extended = manager._extend_fault_trace_to_domain(trace)
259264

260265
assert len(extended) == 2
266+
267+
def test_local_tangent_varies_along_a_curved_trace(self, manager):
268+
"""Regression test for a real bug: fitting one global best-fit line
269+
through a curved trace (the old approach) flattens its curvature
270+
out, and can extrapolate/orient the interpolated surface on the
271+
wrong side of real nearby data. Confirmed on a live project where
272+
a global-line fit put a stratigraphic unit's own contact data on
273+
the opposite side of its domain-boundary fault from a bounding-box
274+
corner that a correct local (nearest-segment) classification put
275+
on the *same* side. Each point's strike must instead follow its
276+
own local tangent.
277+
"""
278+
manager.update_bounding_box(BoundingBox(origin=[0, 0, 0], maximum=[100, 100, 100]))
279+
# An L-shaped trace: a horizontal leg then a vertical leg.
280+
trace = pd.DataFrame(
281+
{'X': [20.0, 50.0, 50.0], 'Y': [50.0, 50.0, 80.0], 'Z': [0.0, 0.0, 0.0]}
282+
)
283+
284+
extended = manager._extend_fault_trace_to_domain(trace)
285+
286+
strikes = extended['strike'].to_numpy()
287+
# Row 0's local tangent is horizontal (towards row 1); row 2's is
288+
# vertical (away from row 1) -- these must differ substantially. A
289+
# single global best-fit line would instead give every point close
290+
# to the same strike.
291+
assert abs(((strikes[0] - strikes[2] + 180) % 360) - 180) > 45

0 commit comments

Comments
 (0)