Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d936aa5
feat: add AlongSection thickness calculator
rabii-chaarani Nov 10, 2025
3822263
feat: add utils for AlongSection class
rabii-chaarani Nov 10, 2025
7a22234
fix: renamed to _approx_dist
rabii-chaarani Nov 10, 2025
aa813aa
Adding exception handling suggestions
lachlangrose Nov 13, 2025
6b8e674
style: style fixes by ruff and autoformatting by black
lachlangrose Nov 13, 2025
7a7ecbd
refactor: added overlap checks
rabii-chaarani Jan 22, 2026
d8bc060
fix: use standard unit column name
rabii-chaarani Jan 22, 2026
431bc0a
fix: move extra sort arguments to sorter init (#241)
lachlangrose Dec 1, 2025
1451d36
chore(master): release 3.2.9 (#242)
github-actions[bot] Dec 1, 2025
c60264d
fix: change to no default arguments and add unit_name_column as a req…
lachlangrose Dec 1, 2025
9f3369f
fix: set age based sorter args
lachlangrose Dec 1, 2025
144a224
fix: project import for test
lachlangrose Dec 1, 2025
ddbd0c4
fix: updating requirements for alpha sorter plus add check for empty …
lachlangrose Dec 2, 2025
d71c001
fix: update maximise contacts to compute length and check for empty c…
lachlangrose Dec 2, 2025
9697204
fix: add option to set logger level and handler.
lachlangrose Dec 15, 2025
c7435b5
fix: updating incorrect type hints
lachlangrose Dec 15, 2025
e8031c9
fix: adding safeguarding and fixing merge
lachlangrose Dec 15, 2025
7d581cf
fix: save location tracking as a line geodataframe for qgis compatibi…
lachlangrose Dec 16, 2025
33e2f66
fix: only store geodataframe if layer is not empty
lachlangrose Dec 16, 2025
4f4d0a5
fix: updating logic
lachlangrose Dec 16, 2025
b8c71f0
fix: beartype issues
lachlangrose Dec 17, 2025
1e4882d
fix: add ability to specify fault field to topoloy
lachlangrose Dec 17, 2025
c44913b
fix: add call methods to allow for generic debug exporting
lachlangrose Dec 18, 2025
a5ed095
fix: replace args for kwargs
lachlangrose Jan 18, 2026
651a9e6
fix: applying copilot suggestions
lachlangrose Jan 19, 2026
ac28072
Enable auto-update for conda in workflow
lachlangrose Jan 19, 2026
7cc4b09
style: formatting
lachlangrose Jan 19, 2026
4aff960
chore(master): release 3.2.10
github-actions[bot] Jan 20, 2026
8cbb6fc
fix: added utils imports
rabii-chaarani Jan 22, 2026
c4fe880
fix: merged changes from master
rabii-chaarani Jan 22, 2026
92ce07f
fix: minor fixes
rabii-chaarani Jan 22, 2026
c061040
Merge branch 'master' into feature/thickness_calc_cross_section
rabii-chaarani Jan 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion map2loop/data_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ def check_keys(d: dict, parent_key=""):
"structure": {
"orientation_type", "dipdir_column", "dip_column",
"description_column", "bedding_text", "overturned_column", "overturned_text",
"objectid_column", "desciption_column",
"objectid_column", "desciption_column", "interp_source_column"
},
"geology": {
"unitname_column", "alt_unitname_column", "group_column",
Expand Down
339 changes: 337 additions & 2 deletions map2loop/thickness_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
multiline_to_line,
find_segment_strike_from_pt,
set_z_values_from_raster_df,
value_from_raster
value_from_raster,
segment_measure_range,
clean_line_geometry,
nearest_orientation_to_line,
iter_line_segments
)
from .interpolators import DipDipDirectionInterpolator

Expand All @@ -15,11 +19,13 @@

# external imports
from abc import ABC, abstractmethod
from typing import Optional
from typing import Optional, List
from collections import defaultdict
import scipy.interpolate
import beartype
import numpy
import scipy
from scipy.spatial import cKDTree
import pandas
import geopandas
import shapely
Expand Down Expand Up @@ -775,3 +781,332 @@ def compute(
self._check_thickness_percentage_calculations(output_units)

return output_units


class AlongSection(ThicknessCalculator):
"""Thickness calculator that estimates true thicknesses along supplied section lines."""

def __init__(
self,
sections: geopandas.GeoDataFrame,
dtm_data: Optional[gdal.Dataset] = None,
bounding_box: Optional[dict] = None,
max_line_length: Optional[float] = None,
is_strike: Optional[bool] = False,
):
super().__init__(dtm_data, bounding_box, max_line_length, is_strike) # initialise base calculator bits
self.thickness_calculator_label = "AlongSection" # label used externally to identify this strategy
self.sections = (
sections.copy() # keep a copy so editing outside does not mutate our internal state
if sections is not None
else geopandas.GeoDataFrame({"geometry": []}, geometry="geometry") # ensure predictable structure when no sections supplied
)
self.section_thickness_records: geopandas.GeoDataFrame = geopandas.GeoDataFrame() # populated as GeoDataFrame during compute
self.section_intersection_points: dict = {}

def type(self):
"""Return the calculator label."""
return self.thickness_calculator_label

@beartype.beartype
def compute(
self,
units: pandas.DataFrame,
stratigraphic_order: list,
basal_contacts: geopandas.GeoDataFrame,
structure_data: pandas.DataFrame,
geology_data: geopandas.GeoDataFrame,
sampled_contacts: pandas.DataFrame,
) -> pandas.DataFrame:
"""Estimate unit thicknesses along cross sections.

Args:
units (pandas.DataFrame): Table of stratigraphic units that will be annotated with
thickness statistics. Must expose a ``name`` column used to map aggregated
thickness values back to each unit.
stratigraphic_order (list): Accepted for API compatibility but not used by this
calculator. Retained so the method signature matches the other calculators.
basal_contacts (geopandas.GeoDataFrame): Unused placeholder maintained for parity
with the other calculators.
structure_data (pandas.DataFrame): Orientation measurements containing X/Y columns
and one dip column (``DIP``, ``dip`` or ``Dip``). A KD-tree is built from these
points so each split section segment can grab the nearest dip when converting its
length to true thickness.
geology_data (geopandas.GeoDataFrame): Mandatory polygon dataset describing map
units. Each section is intersected with these polygons to generate unit-aware
line segments whose lengths underpin the thickness calculation.
sampled_contacts (pandas.DataFrame): Part of the public interface but not used in
this implementation.

Returns:
pandas.DataFrame: Copy of ``units`` with three new columns – ``ThicknessMean``,
``ThicknessMedian`` and ``ThicknessStdDev`` – populated from the accumulated segment
thickness samples. Units that never receive a measurement retain the sentinel value
``-1`` in each column.

Workflow:
1. Validate the presence of section geometries, geology polygons, and a recognizable
unit-name column. Reproject sections to match the geology CRS when required.
2. Pre-build helpers: a spatial index over geology polygons for fast section/polygon
queries, and (if possible) a KD-tree of orientation points so the closest dip to a
split segment can be queried in O(log n).
3. For every section, reduce complex/multi-part geometries to a single `LineString`,
intersect it with candidate geology polygons, and collect the resulting split line
segments along with their length and measure positions along the parent section.
4. Walk the ordered segments and keep those bounded by two distinct neighbouring units
(i.e., the segment sits between different units on either side). Fetch the nearest
dip (fallback to 90° once if no structures exist), convert the segment length to
true thickness using ``length * sin(dip)``, and store the sample plus provenance in
``self.section_thickness_records``.
5. Aggregate all collected samples per unit (mean/median/std) and return the enriched
``units`` table. The helper ``self.section_intersection_points`` captures the raw
split segments grouped by section to aid downstream inspection.

Notes:
- ``stratigraphic_order``, ``basal_contacts`` and ``sampled_contacts`` are retained for
interface compatibility but do not influence this algorithm.
- When no nearby orientation measurements exist, the method emits a single warning and
assumes a vertical dip (90°) for affected segments to avoid silently dropping units.
- ``self.section_thickness_records`` is materialised as a ``GeoDataFrame`` so the split
segments (geometry column) can be visualised directly in notebooks or GIS clients.
"""

if self.sections is None or self.sections.empty:
logger.warning("AlongSection: No sections provided; skipping thickness calculation.")
return units

if geology_data is None or geology_data.empty:
logger.warning(
"AlongSection: Geology polygons are required to split sections; skipping thickness calculation."
)
return units
unit_column = "UNITNAME"
sections = self.sections.copy()
geology = geology_data.copy()

if geology.crs is not None:
if sections.crs is None:
sections = sections.set_crs(geology.crs)
elif sections.crs != geology.crs:
sections = sections.to_crs(geology.crs)
elif sections.crs is not None:
geology = geology.set_crs(sections.crs)

try:
geology_sindex = geology.sindex
except Exception as e:
logger.error("Failed to create spatial index for geology data: %s", e, exc_info=True)
geology_sindex = None

sections_bounds = sections.total_bounds
geology_bounds = geology.total_bounds
if (
sections_bounds[2] < geology_bounds[0]
or sections_bounds[0] > geology_bounds[2]
or sections_bounds[3] < geology_bounds[1]
or sections_bounds[1] > geology_bounds[3]
):
logger.warning("AlongSection: Sections do not overlap geology extent; skipping thickness calculation.")
return units

if geology_sindex is None:
overlaps = False
for geom in sections.geometry:
if geom is None or geom.is_empty:
continue
if geology.intersects(geom).any():
overlaps = True
break
if not overlaps:
logger.warning("AlongSection: Sections do not overlap geology; skipping thickness calculation.")
return units
else:
overlaps = False
for geom in sections.geometry:
if geom is None or geom.is_empty:
continue
candidate_idx = list(geology_sindex.intersection(geom.bounds))
if not candidate_idx:
continue
if geology.iloc[candidate_idx].intersects(geom).any():
overlaps = True
break
if not overlaps:
logger.warning("AlongSection: Sections do not overlap geology; skipping thickness calculation.")
return units

dip_column = next((col for col in ("DIP", "dip", "Dip") if col in structure_data.columns), None)
orientation_tree = None
orientation_dips = None
orientation_coords = None
if dip_column is not None and {"X", "Y"}.issubset(structure_data.columns):
orient_df = structure_data.dropna(subset=["X", "Y", dip_column]).copy()
if not orient_df.empty:
orient_df["_dip_value"] = pandas.to_numeric(orient_df[dip_column], errors="coerce")
orient_df = orient_df.dropna(subset=["_dip_value"])
if not orient_df.empty:
try:
orientation_coords = orient_df[["X", "Y"]].astype(float).to_numpy()
except ValueError:
logger.debug(
"Failed to convert orientation coordinates to float for %d rows. Data quality issue likely. Example rows: %s",
len(orient_df),
orient_df[["X", "Y"]].head(3).to_dict(orient="records")
)
orientation_coords = numpy.empty((0, 2))
if orientation_coords.size:
orientation_dips = orient_df["_dip_value"].astype(float).to_numpy()
try:
orientation_tree = cKDTree(orientation_coords)
except (ValueError, TypeError) as e:
logger.error(f"Failed to construct cKDTree for orientation data: {e}")
orientation_tree = None
default_dip_warning_emitted = False

units_lookup = dict(zip(units["name"], units.index))
thickness_by_unit = {name: [] for name in units_lookup.keys()}
thickness_records: List[dict] = []
split_segments_by_section: dict = defaultdict(list)

for section_idx, section_row in sections.iterrows():
line = clean_line_geometry(section_row.geometry)
if line is None or line.length == 0:
continue
section_id = section_row.get("ID", section_idx)

candidate_idx = (
list(geology_sindex.intersection(line.bounds))
if geology_sindex is not None
else list(range(len(geology)))
)
if not candidate_idx:
continue

split_segments = []
for idx in candidate_idx:
poly = geology.iloc[idx]
polygon_geom = poly.geometry
if polygon_geom is None or polygon_geom.is_empty:
continue
intersection = line.intersection(polygon_geom)
if intersection.is_empty:
continue
for segment in iter_line_segments(intersection):
seg_length = segment.length
if seg_length <= 0:
continue
start_measure, end_measure = segment_measure_range(line, segment)
segment_record = {
"section_id": section_id,
"geometry": segment,
"unit": poly[unit_column],
"length": seg_length,
"start_measure": start_measure,
"end_measure": end_measure,
}
split_segments.append(segment_record)
split_segments_by_section[section_id].append(segment_record)

if len(split_segments) < 1:
continue

split_segments.sort(key=lambda item: (item["start_measure"], item["end_measure"]))

for idx_segment, segment in enumerate(split_segments):
prev_unit = split_segments[idx_segment - 1]["unit"] if idx_segment > 0 else None
next_unit = (
split_segments[idx_segment + 1]["unit"]
if idx_segment + 1 < len(split_segments)
else None
)

if prev_unit is None or next_unit is None:
continue
if prev_unit == segment["unit"] or next_unit == segment["unit"]:
continue
if prev_unit == next_unit:
continue

dip_value, orientation_idx, orientation_distance = nearest_orientation_to_line(
orientation_tree,
orientation_dips,
orientation_coords,
segment["geometry"]
)
if numpy.isnan(dip_value):
dip_value = 90.0
orientation_idx = None
orientation_distance = None
if not default_dip_warning_emitted:
logger.warning(
"AlongSection: Missing structure measurements near some sections; assuming vertical dip (90°) for those segments."
)
default_dip_warning_emitted = True

thickness = segment["length"] * math.sin(math.radians(dip_value))
if thickness <= 0:
continue

unit_name = segment["unit"]
if unit_name not in thickness_by_unit:
thickness_by_unit[unit_name] = []
thickness_by_unit[unit_name].append(thickness)

thickness_records.append(
{
"section_id": section_id,
"unit_id": unit_name,
"thickness": thickness,
"segment_length": segment["length"],
"dip_used_deg": dip_value,
"prev_unit": prev_unit,
"next_unit": next_unit,
"orientation_index": orientation_idx,
"orientation_distance": orientation_distance,
"geometry": segment["geometry"],
}
)

output_units = units.copy()
output_units["ThicknessMean"] = -1.0
output_units["ThicknessMedian"] = -1.0
output_units["ThicknessStdDev"] = -1.0

for unit_name, values in thickness_by_unit.items():
if not values:
continue
idx = units_lookup.get(unit_name)
if idx is None:
continue
arr = numpy.asarray(values, dtype=numpy.float64)
output_units.at[idx, "ThicknessMean"] = float(numpy.nanmean(arr))
output_units.at[idx, "ThicknessMedian"] = float(numpy.nanmedian(arr))
output_units.at[idx, "ThicknessStdDev"] = float(numpy.nanstd(arr))

if thickness_records:
self.section_thickness_records = geopandas.GeoDataFrame(
thickness_records,
geometry="geometry",
crs=sections.crs,
)
else:
self.section_thickness_records = geopandas.GeoDataFrame(
columns=[
"section_id",
"unit_id",
"thickness",
"segment_length",
"dip_used_deg",
"prev_unit",
"next_unit",
"orientation_index",
"orientation_distance",
"geometry",
],
geometry="geometry",
crs=sections.crs,
)
self.section_intersection_points = dict(split_segments_by_section)
self._check_thickness_percentage_calculations(output_units)

return output_units
4 changes: 4 additions & 0 deletions map2loop/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@
normal_vector_to_dipdirection_dip,
strike_dip_vector,
generate_grid,
segment_measure_range,
clean_line_geometry,
nearest_orientation_to_line,
iter_line_segments
)
Loading
Loading