@@ -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
0 commit comments