well-log-toolkit 0.1.148__tar.gz → 0.1.150__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/PKG-INFO +1 -1
  2. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/pyproject.toml +1 -1
  3. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit/manager.py +110 -5
  4. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit/property.py +94 -38
  5. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit/statistics.py +77 -0
  6. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit.egg-info/PKG-INFO +1 -1
  7. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/README.md +0 -0
  8. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/setup.cfg +0 -0
  9. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit/__init__.py +0 -0
  10. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit/exceptions.py +0 -0
  11. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit/las_file.py +0 -0
  12. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit/operations.py +0 -0
  13. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit/regression.py +0 -0
  14. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit/utils.py +0 -0
  15. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit/visualization.py +0 -0
  16. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit/well.py +0 -0
  17. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit.egg-info/SOURCES.txt +0 -0
  18. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit.egg-info/dependency_links.txt +0 -0
  19. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit.egg-info/requires.txt +0 -0
  20. {well_log_toolkit-0.1.148 → well_log_toolkit-0.1.150}/well_log_toolkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: well-log-toolkit
3
- Version: 0.1.148
3
+ Version: 0.1.150
4
4
  Summary: Fast LAS file processing with lazy loading and filtering for well log analysis
5
5
  Author-email: Kristian dF Kollsgård <kkollsg@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "well-log-toolkit"
7
- version = "0.1.148"
7
+ version = "0.1.150"
8
8
  description = "Fast LAS file processing with lazy loading and filtering for well log analysis"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1445,12 +1445,23 @@ class _ManagerPropertyProxy:
1445
1445
  prop = prop.filter(filter_name, source=source_name)
1446
1446
 
1447
1447
  # Compute sums_avg
1448
- source_results[source_name] = prop.sums_avg(
1448
+ result = prop.sums_avg(
1449
1449
  weighted=weighted,
1450
1450
  arithmetic=arithmetic,
1451
1451
  precision=precision
1452
1452
  )
1453
1453
 
1454
+ # Add well-level thickness for this source if using filter_intervals
1455
+ if self._custom_intervals and result:
1456
+ well_thickness = 0.0
1457
+ for key, value in result.items():
1458
+ if isinstance(value, dict) and 'thickness' in value:
1459
+ well_thickness += value['thickness']
1460
+ if well_thickness > 0:
1461
+ result['thickness'] = round(well_thickness, precision)
1462
+
1463
+ source_results[source_name] = result
1464
+
1454
1465
  except (PropertyNotFoundError, PropertyTypeError, AttributeError, KeyError, ValueError):
1455
1466
  # Property or filter doesn't exist in this source, or filter isn't discrete - skip it
1456
1467
  pass
@@ -1476,12 +1487,23 @@ class _ManagerPropertyProxy:
1476
1487
  prop = prop.filter(filter_name)
1477
1488
 
1478
1489
  # Compute sums_avg
1479
- return prop.sums_avg(
1490
+ result = prop.sums_avg(
1480
1491
  weighted=weighted,
1481
1492
  arithmetic=arithmetic,
1482
1493
  precision=precision
1483
1494
  )
1484
1495
 
1496
+ # Add well-level thickness (sum of all zone thicknesses) if using filter_intervals
1497
+ if self._custom_intervals and result:
1498
+ well_thickness = 0.0
1499
+ for key, value in result.items():
1500
+ if isinstance(value, dict) and 'thickness' in value:
1501
+ well_thickness += value['thickness']
1502
+ if well_thickness > 0:
1503
+ result['thickness'] = round(well_thickness, precision)
1504
+
1505
+ return result
1506
+
1485
1507
  except PropertyNotFoundError as e:
1486
1508
  # Check if it's ambiguous (exists in multiple sources)
1487
1509
  if "ambiguous" in str(e).lower():
@@ -1512,6 +1534,16 @@ class _ManagerPropertyProxy:
1512
1534
  arithmetic=arithmetic,
1513
1535
  precision=precision
1514
1536
  )
1537
+
1538
+ # Add well-level thickness for this source if using filter_intervals
1539
+ if self._custom_intervals and result:
1540
+ well_thickness = 0.0
1541
+ for key, value in result.items():
1542
+ if isinstance(value, dict) and 'thickness' in value:
1543
+ well_thickness += value['thickness']
1544
+ if well_thickness > 0:
1545
+ result['thickness'] = round(well_thickness, precision)
1546
+
1515
1547
  source_results[source_name] = result
1516
1548
 
1517
1549
  except (PropertyNotFoundError, PropertyTypeError, AttributeError, KeyError, ValueError):
@@ -1632,7 +1664,7 @@ class _ManagerMultiPropertyProxy:
1632
1664
  PROPERTY_STATS = {'mean', 'median', 'mode', 'sum', 'std_dev', 'percentile', 'range'}
1633
1665
 
1634
1666
  # Stats that are common across properties (stay at group level)
1635
- COMMON_STATS = {'depth_range', 'samples', 'thickness', 'gross_thickness', 'thickness_fraction', 'calculation'}
1667
+ COMMON_STATS = {'depth_range', 'samples', 'thickness', 'thickness_fraction', 'calculation'}
1636
1668
 
1637
1669
  def __init__(
1638
1670
  self,
@@ -1849,7 +1881,17 @@ class _ManagerMultiPropertyProxy:
1849
1881
  return self._merge_flat_results(property_results)
1850
1882
 
1851
1883
  # Merge results: nest property-specific stats, keep common stats at group level
1852
- return self._merge_property_results(property_results)
1884
+ merged = self._merge_property_results(property_results)
1885
+
1886
+ # Add well-level thickness (sum of all zone thicknesses)
1887
+ if self._custom_intervals and merged:
1888
+ well_thickness = 0.0
1889
+ for key, value in merged.items():
1890
+ if isinstance(value, dict) and 'thickness' in value:
1891
+ well_thickness += value['thickness']
1892
+ merged['thickness'] = round(well_thickness, 6)
1893
+
1894
+ return merged
1853
1895
 
1854
1896
  def _apply_filter_intervals(self, prop, well):
1855
1897
  """
@@ -3108,7 +3150,70 @@ class WellDataManager:
3108
3150
  ['well_12_3_2_B', 'well_12_3_2_A']
3109
3151
  """
3110
3152
  return list(self._wells.keys())
3111
-
3153
+
3154
+ @property
3155
+ def saved_intervals(self) -> dict[str, list[str]]:
3156
+ """
3157
+ List saved interval names for all wells.
3158
+
3159
+ Returns
3160
+ -------
3161
+ dict[str, list[str]]
3162
+ Dictionary mapping well names to their saved interval names
3163
+
3164
+ Examples
3165
+ --------
3166
+ >>> manager.saved_intervals
3167
+ {'well_A': ['Reservoir_Zones', 'Slump_Zones'], 'well_B': ['Reservoir_Zones']}
3168
+ """
3169
+ result = {}
3170
+ for well_name, well in self._wells.items():
3171
+ if well.saved_intervals:
3172
+ result[well_name] = well.saved_intervals
3173
+ return result
3174
+
3175
+ def get_intervals(self, name: str) -> dict[str, list[dict]]:
3176
+ """
3177
+ Get saved filter intervals by name from all wells that have them.
3178
+
3179
+ Parameters
3180
+ ----------
3181
+ name : str
3182
+ Name of the saved filter intervals
3183
+
3184
+ Returns
3185
+ -------
3186
+ dict[str, list[dict]]
3187
+ Dictionary mapping well names to their interval definitions
3188
+
3189
+ Raises
3190
+ ------
3191
+ KeyError
3192
+ If no wells have intervals with the given name
3193
+
3194
+ Examples
3195
+ --------
3196
+ >>> manager.get_intervals("Slump_Zones")
3197
+ {'well_A': [{'name': 'Zone_A', 'top': 2500, 'base': 2650}],
3198
+ 'well_B': [{'name': 'Zone_A', 'top': 2600, 'base': 2750}]}
3199
+ """
3200
+ result = {}
3201
+ for well_name, well in self._wells.items():
3202
+ if name in well.saved_intervals:
3203
+ result[well_name] = well.get_intervals(name)
3204
+
3205
+ if not result:
3206
+ # Collect all available interval names for error message
3207
+ all_names = set()
3208
+ for well in self._wells.values():
3209
+ all_names.update(well.saved_intervals)
3210
+ raise KeyError(
3211
+ f"No wells have saved intervals named '{name}'. "
3212
+ f"Available: {sorted(all_names) if all_names else 'none'}"
3213
+ )
3214
+
3215
+ return result
3216
+
3112
3217
  def get_well(self, name: str) -> Well:
3113
3218
  """
3114
3219
  Get well by original or sanitized name.
@@ -11,6 +11,7 @@ from scipy.interpolate import interp1d
11
11
  from .exceptions import PropertyError, PropertyNotFoundError, PropertyTypeError, DepthAlignmentError
12
12
  from .statistics import (
13
13
  compute_intervals,
14
+ compute_zone_intervals,
14
15
  mean as stat_mean, sum as stat_sum, std as stat_std, percentile as stat_percentile
15
16
  )
16
17
  from .utils import filter_names
@@ -1429,8 +1430,8 @@ class Property(PropertyOperationsMixin):
1429
1430
  max_valid_depth = valid_depths.max()
1430
1431
 
1431
1432
  # Find boundaries that fall within the valid data range
1432
- # Filter to boundaries within valid range first
1433
- mask = (boundary_depths > min_valid_depth) & (boundary_depths < max_valid_depth)
1433
+ # Filter to boundaries within valid range (inclusive on both ends)
1434
+ mask = (boundary_depths >= min_valid_depth) & (boundary_depths <= max_valid_depth)
1434
1435
  potential_boundaries = boundary_depths[mask]
1435
1436
 
1436
1437
  if len(potential_boundaries) == 0:
@@ -1760,6 +1761,9 @@ class Property(PropertyOperationsMixin):
1760
1761
 
1761
1762
  This allows overlapping intervals where the same depths can
1762
1763
  contribute to multiple zones.
1764
+
1765
+ Uses zone-aware intervals that are truncated at zone boundaries to ensure
1766
+ thickness is correctly attributed to each zone.
1763
1767
  """
1764
1768
  result = {}
1765
1769
 
@@ -1768,27 +1772,40 @@ class Property(PropertyOperationsMixin):
1768
1772
  top = float(interval['top'])
1769
1773
  base = float(interval['base'])
1770
1774
 
1771
- # Create mask for this interval (top <= depth < base)
1772
- interval_mask = (self.depth >= top) & (self.depth < base)
1775
+ # Compute zone-aware intervals truncated at zone boundaries
1776
+ zone_intervals = compute_zone_intervals(self.depth, top, base)
1777
+
1778
+ # Create mask based on zone intervals - includes any sample that
1779
+ # contributes to this zone (even boundary samples with partial intervals)
1780
+ interval_mask = zone_intervals > 0
1781
+
1782
+ # Calculate zone thickness (sum of valid intervals within zone)
1783
+ valid_mask = interval_mask & ~np.isnan(self.values)
1784
+ zone_thickness = float(np.sum(zone_intervals[valid_mask]))
1773
1785
 
1774
1786
  # If there are secondary properties, group within this interval
1775
1787
  if self.secondary_properties:
1776
- result[interval_name] = self._recursive_group(
1788
+ interval_result = self._recursive_group(
1777
1789
  0,
1778
1790
  interval_mask,
1779
1791
  weighted=weighted,
1780
1792
  arithmetic=arithmetic,
1781
- gross_thickness=gross_thickness,
1782
- precision=precision
1793
+ gross_thickness=zone_thickness, # Pass zone thickness as gross for children
1794
+ precision=precision,
1795
+ zone_intervals=zone_intervals
1783
1796
  )
1797
+ # Add zone-level thickness
1798
+ interval_result['thickness'] = round(zone_thickness, precision)
1799
+ result[interval_name] = interval_result
1784
1800
  else:
1785
1801
  # No secondary properties, compute stats directly for interval
1786
1802
  result[interval_name] = self._compute_stats(
1787
1803
  interval_mask,
1788
1804
  weighted=weighted,
1789
1805
  arithmetic=arithmetic,
1790
- gross_thickness=gross_thickness,
1791
- precision=precision
1806
+ gross_thickness=zone_thickness, # Use zone thickness for fraction calc
1807
+ precision=precision,
1808
+ zone_intervals=zone_intervals
1792
1809
  )
1793
1810
 
1794
1811
  return result
@@ -1905,6 +1922,9 @@ class Property(PropertyOperationsMixin):
1905
1922
  This allows overlapping intervals where the same depths can
1906
1923
  contribute to multiple zones. Zone-level metadata (depth_range, thickness)
1907
1924
  is shown at the interval level, and fractions are relative to zone thickness.
1925
+
1926
+ Uses zone-aware intervals that are truncated at zone boundaries to ensure
1927
+ thickness is correctly attributed to each zone.
1908
1928
  """
1909
1929
  result = {}
1910
1930
 
@@ -1913,13 +1933,16 @@ class Property(PropertyOperationsMixin):
1913
1933
  top = float(interval['top'])
1914
1934
  base = float(interval['base'])
1915
1935
 
1916
- # Create mask for this interval (top <= depth < base)
1917
- interval_mask = (self.depth >= top) & (self.depth < base)
1936
+ # Compute zone-aware intervals truncated at zone boundaries
1937
+ zone_intervals = compute_zone_intervals(self.depth, top, base)
1938
+
1939
+ # Create mask based on zone intervals - includes any sample that
1940
+ # contributes to this zone (even boundary samples with partial intervals)
1941
+ interval_mask = zone_intervals > 0
1918
1942
 
1919
- # Calculate zone thickness for fraction calculation
1920
- full_intervals = compute_intervals(self.depth)
1943
+ # Calculate zone thickness using zone-aware intervals
1921
1944
  valid_mask = ~np.isnan(self.values) & interval_mask
1922
- zone_thickness = float(np.sum(full_intervals[valid_mask]))
1945
+ zone_thickness = float(np.sum(zone_intervals[valid_mask]))
1923
1946
 
1924
1947
  # Get actual depth range within the interval (where we have data)
1925
1948
  if np.any(valid_mask):
@@ -1944,7 +1967,8 @@ class Property(PropertyOperationsMixin):
1944
1967
  interval_mask,
1945
1968
  gross_thickness=zone_thickness, # Use zone thickness for fractions
1946
1969
  precision=precision,
1947
- include_depth_range=False # Don't include depth_range per facies
1970
+ include_depth_range=False, # Don't include depth_range per facies
1971
+ zone_intervals=zone_intervals # Pass zone-aware intervals
1948
1972
  )
1949
1973
  else:
1950
1974
  # No secondary properties, compute stats directly for interval
@@ -1952,7 +1976,8 @@ class Property(PropertyOperationsMixin):
1952
1976
  interval_mask,
1953
1977
  gross_thickness=zone_thickness, # Use zone thickness for fractions
1954
1978
  precision=precision,
1955
- include_depth_range=False # Don't include depth_range per facies
1979
+ include_depth_range=False, # Don't include depth_range per facies
1980
+ zone_intervals=zone_intervals # Pass zone-aware intervals
1956
1981
  )
1957
1982
 
1958
1983
  # Nest facies stats under 'facies' key for cleaner structure
@@ -1967,7 +1992,8 @@ class Property(PropertyOperationsMixin):
1967
1992
  mask: np.ndarray,
1968
1993
  gross_thickness: float,
1969
1994
  precision: int = 6,
1970
- include_depth_range: bool = True
1995
+ include_depth_range: bool = True,
1996
+ zone_intervals: Optional[np.ndarray] = None
1971
1997
  ) -> dict:
1972
1998
  """
1973
1999
  Recursively group discrete statistics by secondary properties.
@@ -1984,6 +2010,9 @@ class Property(PropertyOperationsMixin):
1984
2010
  Number of decimal places for rounding
1985
2011
  include_depth_range : bool, default True
1986
2012
  Whether to include depth_range in per-facies stats
2013
+ zone_intervals : np.ndarray, optional
2014
+ Pre-computed zone-aware intervals truncated at zone boundaries.
2015
+ If None, computes intervals using standard midpoint method.
1987
2016
 
1988
2017
  Returns
1989
2018
  -------
@@ -1992,7 +2021,7 @@ class Property(PropertyOperationsMixin):
1992
2021
  """
1993
2022
  if filter_idx >= len(self.secondary_properties):
1994
2023
  # Base case: compute discrete statistics for this group
1995
- return self._compute_discrete_stats(mask, gross_thickness, precision, include_depth_range)
2024
+ return self._compute_discrete_stats(mask, gross_thickness, precision, include_depth_range, zone_intervals)
1996
2025
 
1997
2026
  # Get unique values for current filter
1998
2027
  current_filter = self.secondary_properties[filter_idx]
@@ -2002,12 +2031,16 @@ class Property(PropertyOperationsMixin):
2002
2031
 
2003
2032
  if len(unique_vals) == 0:
2004
2033
  # No valid values, return stats for current mask
2005
- return self._compute_discrete_stats(mask, gross_thickness, precision, include_depth_range)
2034
+ return self._compute_discrete_stats(mask, gross_thickness, precision, include_depth_range, zone_intervals)
2006
2035
 
2007
2036
  # Group by each unique value
2008
2037
  depth_array = self.depth
2009
2038
  values_array = self.values
2010
- full_intervals = compute_intervals(depth_array)
2039
+ # Use zone intervals if provided, otherwise compute on full array
2040
+ if zone_intervals is not None:
2041
+ full_intervals = zone_intervals
2042
+ else:
2043
+ full_intervals = compute_intervals(depth_array)
2011
2044
 
2012
2045
  result = {}
2013
2046
  for val in unique_vals:
@@ -2038,7 +2071,7 @@ class Property(PropertyOperationsMixin):
2038
2071
  key = f"{current_filter.name}_{val:.2f}"
2039
2072
 
2040
2073
  result[key] = self._recursive_discrete_group(
2041
- filter_idx + 1, sub_mask, group_thickness, precision, include_depth_range
2074
+ filter_idx + 1, sub_mask, group_thickness, precision, include_depth_range, zone_intervals
2042
2075
  )
2043
2076
 
2044
2077
  return result
@@ -2048,7 +2081,8 @@ class Property(PropertyOperationsMixin):
2048
2081
  mask: np.ndarray,
2049
2082
  gross_thickness: float,
2050
2083
  precision: int = 6,
2051
- include_depth_range: bool = True
2084
+ include_depth_range: bool = True,
2085
+ zone_intervals: Optional[np.ndarray] = None
2052
2086
  ) -> dict:
2053
2087
  """
2054
2088
  Compute categorical statistics for discrete property values.
@@ -2064,6 +2098,9 @@ class Property(PropertyOperationsMixin):
2064
2098
  include_depth_range : bool, default True
2065
2099
  Whether to include depth_range in per-facies stats.
2066
2100
  Set to False when using filter_intervals (depth_range shown at zone level).
2101
+ zone_intervals : np.ndarray, optional
2102
+ Pre-computed zone-aware intervals truncated at zone boundaries.
2103
+ If None, computes intervals using standard midpoint method.
2067
2104
 
2068
2105
  Returns
2069
2106
  -------
@@ -2077,9 +2114,12 @@ class Property(PropertyOperationsMixin):
2077
2114
  values = values_array[mask]
2078
2115
  depths = depth_array[mask]
2079
2116
 
2080
- # Compute intervals on full array then mask
2081
- full_intervals = compute_intervals(depth_array)
2082
- intervals = full_intervals[mask]
2117
+ # Use zone intervals if provided, otherwise compute on full array
2118
+ if zone_intervals is not None:
2119
+ intervals = zone_intervals[mask]
2120
+ else:
2121
+ full_intervals = compute_intervals(depth_array)
2122
+ intervals = full_intervals[mask]
2083
2123
 
2084
2124
  # Find unique discrete values
2085
2125
  valid_mask_local = ~np.isnan(values)
@@ -2132,7 +2172,8 @@ class Property(PropertyOperationsMixin):
2132
2172
  weighted: bool,
2133
2173
  arithmetic: bool,
2134
2174
  gross_thickness: float,
2135
- precision: int = 6
2175
+ precision: int = 6,
2176
+ zone_intervals: Optional[np.ndarray] = None
2136
2177
  ) -> dict:
2137
2178
  """
2138
2179
  Recursively group by secondary properties.
@@ -2151,6 +2192,9 @@ class Property(PropertyOperationsMixin):
2151
2192
  Total gross thickness for fraction calculation
2152
2193
  precision : int, default 6
2153
2194
  Number of decimal places for rounding
2195
+ zone_intervals : np.ndarray, optional
2196
+ Pre-computed zone-aware intervals truncated at zone boundaries.
2197
+ If None, computes intervals using standard midpoint method.
2154
2198
 
2155
2199
  Returns
2156
2200
  -------
@@ -2159,7 +2203,7 @@ class Property(PropertyOperationsMixin):
2159
2203
  """
2160
2204
  if filter_idx >= len(self.secondary_properties):
2161
2205
  # Base case: compute statistics for this group
2162
- return self._compute_stats(mask, weighted, arithmetic, gross_thickness, precision)
2206
+ return self._compute_stats(mask, weighted, arithmetic, gross_thickness, precision, zone_intervals)
2163
2207
 
2164
2208
  # Get unique values for current filter
2165
2209
  current_filter = self.secondary_properties[filter_idx]
@@ -2170,14 +2214,18 @@ class Property(PropertyOperationsMixin):
2170
2214
 
2171
2215
  if len(unique_vals) == 0:
2172
2216
  # No valid values, return stats for current mask
2173
- return self._compute_stats(mask, weighted, arithmetic, gross_thickness, precision)
2217
+ return self._compute_stats(mask, weighted, arithmetic, gross_thickness, precision, zone_intervals)
2174
2218
 
2175
2219
  # Calculate parent thickness BEFORE subdividing
2176
2220
  # This becomes the gross_thickness for all child groups
2177
2221
  # Cache property access to avoid overhead
2178
2222
  depth_array = self.depth
2179
2223
  values_array = self.values
2180
- parent_intervals = compute_intervals(depth_array)
2224
+ # Use zone intervals if provided, otherwise compute on full array
2225
+ if zone_intervals is not None:
2226
+ parent_intervals = zone_intervals
2227
+ else:
2228
+ parent_intervals = compute_intervals(depth_array)
2181
2229
  parent_valid = mask & ~np.isnan(values_array)
2182
2230
  parent_thickness = float(np.sum(parent_intervals[parent_valid]))
2183
2231
 
@@ -2212,7 +2260,7 @@ class Property(PropertyOperationsMixin):
2212
2260
  key = f"{current_filter.name}_{val:.2f}"
2213
2261
 
2214
2262
  result[key] = self._recursive_group(
2215
- filter_idx + 1, sub_mask, weighted, arithmetic, parent_thickness, precision
2263
+ filter_idx + 1, sub_mask, weighted, arithmetic, parent_thickness, precision, zone_intervals
2216
2264
  )
2217
2265
 
2218
2266
  return result
@@ -2223,7 +2271,8 @@ class Property(PropertyOperationsMixin):
2223
2271
  weighted: bool = True,
2224
2272
  arithmetic: bool = False,
2225
2273
  gross_thickness: float = 0.0,
2226
- precision: int = 6
2274
+ precision: int = 6,
2275
+ zone_intervals: Optional[np.ndarray] = None
2227
2276
  ) -> dict:
2228
2277
  """
2229
2278
  Compute statistics for values selected by mask.
@@ -2244,6 +2293,9 @@ class Property(PropertyOperationsMixin):
2244
2293
  Total gross thickness for fraction calculation
2245
2294
  precision : int, default 6
2246
2295
  Number of decimal places for rounding
2296
+ zone_intervals : np.ndarray, optional
2297
+ Pre-computed zone-aware intervals truncated at zone boundaries.
2298
+ If None, computes intervals using standard midpoint method.
2247
2299
 
2248
2300
  Returns
2249
2301
  -------
@@ -2263,12 +2315,17 @@ class Property(PropertyOperationsMixin):
2263
2315
  values = values_array[mask]
2264
2316
  valid = values[~np.isnan(values)]
2265
2317
 
2266
- # Compute depth intervals on FULL depth array first, then mask
2267
- # This is critical! Intervals must be computed on full grid so that
2268
- # zone boundary samples get correct weights based on their neighbors
2269
- # in the full sequence, not just within their zone.
2270
- full_intervals = compute_intervals(depth_array)
2271
- intervals = full_intervals[mask]
2318
+ # Use zone intervals if provided, otherwise compute on full array
2319
+ # Zone intervals are truncated at zone boundaries for accurate thickness
2320
+ if zone_intervals is not None:
2321
+ intervals = zone_intervals[mask]
2322
+ else:
2323
+ # Compute depth intervals on FULL depth array first, then mask
2324
+ # This is critical! Intervals must be computed on full grid so that
2325
+ # zone boundary samples get correct weights based on their neighbors
2326
+ # in the full sequence, not just within their zone.
2327
+ full_intervals = compute_intervals(depth_array)
2328
+ intervals = full_intervals[mask]
2272
2329
  valid_mask_local = ~np.isnan(values)
2273
2330
  valid_intervals = intervals[valid_mask_local]
2274
2331
 
@@ -2366,7 +2423,6 @@ class Property(PropertyOperationsMixin):
2366
2423
  'depth_range': _round_value(depth_range_dict),
2367
2424
  'samples': int(len(valid)),
2368
2425
  'thickness': round(thickness, precision),
2369
- 'gross_thickness': round(gross_thickness, precision),
2370
2426
  'thickness_fraction': round(fraction, precision),
2371
2427
  'calculation': calc_method,
2372
2428
  }
@@ -61,6 +61,83 @@ def compute_intervals(depth: np.ndarray) -> np.ndarray:
61
61
  return intervals
62
62
 
63
63
 
64
+ def compute_zone_intervals(
65
+ depth: np.ndarray,
66
+ top: float,
67
+ base: float
68
+ ) -> np.ndarray:
69
+ """
70
+ Compute depth intervals truncated to zone boundaries.
71
+
72
+ Uses the midpoint method but truncates intervals at zone boundaries
73
+ to ensure thickness is correctly attributed to each zone.
74
+
75
+ Parameters
76
+ ----------
77
+ depth : np.ndarray
78
+ Depth values (must be sorted ascending)
79
+ top : float
80
+ Zone top depth (inclusive)
81
+ base : float
82
+ Zone base depth (exclusive)
83
+
84
+ Returns
85
+ -------
86
+ np.ndarray
87
+ Interval thickness for each depth point, truncated to zone boundaries.
88
+ Points outside the zone have zero interval.
89
+
90
+ Examples
91
+ --------
92
+ >>> depth = np.array([2708.0, 2708.3, 2708.4, 2708.6])
93
+ >>> # Zone from 2708.0 to 2708.4
94
+ >>> compute_zone_intervals(depth, 2708.0, 2708.4)
95
+ array([0.15, 0.2, 0.05, 0.0])
96
+
97
+ The intervals are truncated at zone boundary 2708.4:
98
+ - 2708.0: from 2708.0 to midpoint(2708.0, 2708.3)=2708.15 = 0.15m
99
+ - 2708.3: from 2708.15 to midpoint(2708.3, 2708.4)=2708.35 = 0.2m
100
+ - 2708.4: from 2708.35 to 2708.4 (zone boundary) = 0.05m (truncated)
101
+ - 2708.6: outside zone = 0.0m
102
+ """
103
+ if len(depth) == 0:
104
+ return np.array([])
105
+
106
+ if len(depth) == 1:
107
+ # Single point - check if it's in the zone
108
+ if top <= depth[0] < base:
109
+ return np.array([base - top])
110
+ return np.array([0.0])
111
+
112
+ zone_intervals = np.zeros(len(depth))
113
+
114
+ for i in range(len(depth)):
115
+ d = depth[i]
116
+
117
+ # Calculate midpoint bounds for this sample
118
+ if i == 0:
119
+ lower_bound = d - (depth[1] - d) / 2.0 # Mirror first interval
120
+ else:
121
+ lower_bound = (depth[i - 1] + d) / 2.0
122
+
123
+ if i == len(depth) - 1:
124
+ upper_bound = d + (d - depth[-2]) / 2.0 # Mirror last interval
125
+ else:
126
+ upper_bound = (d + depth[i + 1]) / 2.0
127
+
128
+ # Truncate to zone boundaries
129
+ effective_lower = max(lower_bound, top)
130
+ effective_upper = min(upper_bound, base)
131
+
132
+ # Only count if there's overlap with the zone
133
+ if effective_upper > effective_lower:
134
+ zone_intervals[i] = effective_upper - effective_lower
135
+ else:
136
+ zone_intervals[i] = 0.0
137
+
138
+ return zone_intervals
139
+
140
+
64
141
  def mean(
65
142
  values: np.ndarray,
66
143
  weights: Optional[np.ndarray] = None,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: well-log-toolkit
3
- Version: 0.1.148
3
+ Version: 0.1.150
4
4
  Summary: Fast LAS file processing with lazy loading and filtering for well log analysis
5
5
  Author-email: Kristian dF Kollsgård <kkollsg@gmail.com>
6
6
  License: MIT