well-log-toolkit 0.1.148__py3-none-any.whl → 0.1.149__py3-none-any.whl

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.
@@ -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,8 +1772,12 @@ 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
1773
1781
 
1774
1782
  # If there are secondary properties, group within this interval
1775
1783
  if self.secondary_properties:
@@ -1779,7 +1787,8 @@ class Property(PropertyOperationsMixin):
1779
1787
  weighted=weighted,
1780
1788
  arithmetic=arithmetic,
1781
1789
  gross_thickness=gross_thickness,
1782
- precision=precision
1790
+ precision=precision,
1791
+ zone_intervals=zone_intervals
1783
1792
  )
1784
1793
  else:
1785
1794
  # No secondary properties, compute stats directly for interval
@@ -1788,7 +1797,8 @@ class Property(PropertyOperationsMixin):
1788
1797
  weighted=weighted,
1789
1798
  arithmetic=arithmetic,
1790
1799
  gross_thickness=gross_thickness,
1791
- precision=precision
1800
+ precision=precision,
1801
+ zone_intervals=zone_intervals
1792
1802
  )
1793
1803
 
1794
1804
  return result
@@ -1905,6 +1915,9 @@ class Property(PropertyOperationsMixin):
1905
1915
  This allows overlapping intervals where the same depths can
1906
1916
  contribute to multiple zones. Zone-level metadata (depth_range, thickness)
1907
1917
  is shown at the interval level, and fractions are relative to zone thickness.
1918
+
1919
+ Uses zone-aware intervals that are truncated at zone boundaries to ensure
1920
+ thickness is correctly attributed to each zone.
1908
1921
  """
1909
1922
  result = {}
1910
1923
 
@@ -1913,13 +1926,16 @@ class Property(PropertyOperationsMixin):
1913
1926
  top = float(interval['top'])
1914
1927
  base = float(interval['base'])
1915
1928
 
1916
- # Create mask for this interval (top <= depth < base)
1917
- interval_mask = (self.depth >= top) & (self.depth < base)
1929
+ # Compute zone-aware intervals truncated at zone boundaries
1930
+ zone_intervals = compute_zone_intervals(self.depth, top, base)
1931
+
1932
+ # Create mask based on zone intervals - includes any sample that
1933
+ # contributes to this zone (even boundary samples with partial intervals)
1934
+ interval_mask = zone_intervals > 0
1918
1935
 
1919
- # Calculate zone thickness for fraction calculation
1920
- full_intervals = compute_intervals(self.depth)
1936
+ # Calculate zone thickness using zone-aware intervals
1921
1937
  valid_mask = ~np.isnan(self.values) & interval_mask
1922
- zone_thickness = float(np.sum(full_intervals[valid_mask]))
1938
+ zone_thickness = float(np.sum(zone_intervals[valid_mask]))
1923
1939
 
1924
1940
  # Get actual depth range within the interval (where we have data)
1925
1941
  if np.any(valid_mask):
@@ -1944,7 +1960,8 @@ class Property(PropertyOperationsMixin):
1944
1960
  interval_mask,
1945
1961
  gross_thickness=zone_thickness, # Use zone thickness for fractions
1946
1962
  precision=precision,
1947
- include_depth_range=False # Don't include depth_range per facies
1963
+ include_depth_range=False, # Don't include depth_range per facies
1964
+ zone_intervals=zone_intervals # Pass zone-aware intervals
1948
1965
  )
1949
1966
  else:
1950
1967
  # No secondary properties, compute stats directly for interval
@@ -1952,7 +1969,8 @@ class Property(PropertyOperationsMixin):
1952
1969
  interval_mask,
1953
1970
  gross_thickness=zone_thickness, # Use zone thickness for fractions
1954
1971
  precision=precision,
1955
- include_depth_range=False # Don't include depth_range per facies
1972
+ include_depth_range=False, # Don't include depth_range per facies
1973
+ zone_intervals=zone_intervals # Pass zone-aware intervals
1956
1974
  )
1957
1975
 
1958
1976
  # Nest facies stats under 'facies' key for cleaner structure
@@ -1967,7 +1985,8 @@ class Property(PropertyOperationsMixin):
1967
1985
  mask: np.ndarray,
1968
1986
  gross_thickness: float,
1969
1987
  precision: int = 6,
1970
- include_depth_range: bool = True
1988
+ include_depth_range: bool = True,
1989
+ zone_intervals: Optional[np.ndarray] = None
1971
1990
  ) -> dict:
1972
1991
  """
1973
1992
  Recursively group discrete statistics by secondary properties.
@@ -1984,6 +2003,9 @@ class Property(PropertyOperationsMixin):
1984
2003
  Number of decimal places for rounding
1985
2004
  include_depth_range : bool, default True
1986
2005
  Whether to include depth_range in per-facies stats
2006
+ zone_intervals : np.ndarray, optional
2007
+ Pre-computed zone-aware intervals truncated at zone boundaries.
2008
+ If None, computes intervals using standard midpoint method.
1987
2009
 
1988
2010
  Returns
1989
2011
  -------
@@ -1992,7 +2014,7 @@ class Property(PropertyOperationsMixin):
1992
2014
  """
1993
2015
  if filter_idx >= len(self.secondary_properties):
1994
2016
  # Base case: compute discrete statistics for this group
1995
- return self._compute_discrete_stats(mask, gross_thickness, precision, include_depth_range)
2017
+ return self._compute_discrete_stats(mask, gross_thickness, precision, include_depth_range, zone_intervals)
1996
2018
 
1997
2019
  # Get unique values for current filter
1998
2020
  current_filter = self.secondary_properties[filter_idx]
@@ -2002,12 +2024,16 @@ class Property(PropertyOperationsMixin):
2002
2024
 
2003
2025
  if len(unique_vals) == 0:
2004
2026
  # No valid values, return stats for current mask
2005
- return self._compute_discrete_stats(mask, gross_thickness, precision, include_depth_range)
2027
+ return self._compute_discrete_stats(mask, gross_thickness, precision, include_depth_range, zone_intervals)
2006
2028
 
2007
2029
  # Group by each unique value
2008
2030
  depth_array = self.depth
2009
2031
  values_array = self.values
2010
- full_intervals = compute_intervals(depth_array)
2032
+ # Use zone intervals if provided, otherwise compute on full array
2033
+ if zone_intervals is not None:
2034
+ full_intervals = zone_intervals
2035
+ else:
2036
+ full_intervals = compute_intervals(depth_array)
2011
2037
 
2012
2038
  result = {}
2013
2039
  for val in unique_vals:
@@ -2038,7 +2064,7 @@ class Property(PropertyOperationsMixin):
2038
2064
  key = f"{current_filter.name}_{val:.2f}"
2039
2065
 
2040
2066
  result[key] = self._recursive_discrete_group(
2041
- filter_idx + 1, sub_mask, group_thickness, precision, include_depth_range
2067
+ filter_idx + 1, sub_mask, group_thickness, precision, include_depth_range, zone_intervals
2042
2068
  )
2043
2069
 
2044
2070
  return result
@@ -2048,7 +2074,8 @@ class Property(PropertyOperationsMixin):
2048
2074
  mask: np.ndarray,
2049
2075
  gross_thickness: float,
2050
2076
  precision: int = 6,
2051
- include_depth_range: bool = True
2077
+ include_depth_range: bool = True,
2078
+ zone_intervals: Optional[np.ndarray] = None
2052
2079
  ) -> dict:
2053
2080
  """
2054
2081
  Compute categorical statistics for discrete property values.
@@ -2064,6 +2091,9 @@ class Property(PropertyOperationsMixin):
2064
2091
  include_depth_range : bool, default True
2065
2092
  Whether to include depth_range in per-facies stats.
2066
2093
  Set to False when using filter_intervals (depth_range shown at zone level).
2094
+ zone_intervals : np.ndarray, optional
2095
+ Pre-computed zone-aware intervals truncated at zone boundaries.
2096
+ If None, computes intervals using standard midpoint method.
2067
2097
 
2068
2098
  Returns
2069
2099
  -------
@@ -2077,9 +2107,12 @@ class Property(PropertyOperationsMixin):
2077
2107
  values = values_array[mask]
2078
2108
  depths = depth_array[mask]
2079
2109
 
2080
- # Compute intervals on full array then mask
2081
- full_intervals = compute_intervals(depth_array)
2082
- intervals = full_intervals[mask]
2110
+ # Use zone intervals if provided, otherwise compute on full array
2111
+ if zone_intervals is not None:
2112
+ intervals = zone_intervals[mask]
2113
+ else:
2114
+ full_intervals = compute_intervals(depth_array)
2115
+ intervals = full_intervals[mask]
2083
2116
 
2084
2117
  # Find unique discrete values
2085
2118
  valid_mask_local = ~np.isnan(values)
@@ -2132,7 +2165,8 @@ class Property(PropertyOperationsMixin):
2132
2165
  weighted: bool,
2133
2166
  arithmetic: bool,
2134
2167
  gross_thickness: float,
2135
- precision: int = 6
2168
+ precision: int = 6,
2169
+ zone_intervals: Optional[np.ndarray] = None
2136
2170
  ) -> dict:
2137
2171
  """
2138
2172
  Recursively group by secondary properties.
@@ -2151,6 +2185,9 @@ class Property(PropertyOperationsMixin):
2151
2185
  Total gross thickness for fraction calculation
2152
2186
  precision : int, default 6
2153
2187
  Number of decimal places for rounding
2188
+ zone_intervals : np.ndarray, optional
2189
+ Pre-computed zone-aware intervals truncated at zone boundaries.
2190
+ If None, computes intervals using standard midpoint method.
2154
2191
 
2155
2192
  Returns
2156
2193
  -------
@@ -2159,7 +2196,7 @@ class Property(PropertyOperationsMixin):
2159
2196
  """
2160
2197
  if filter_idx >= len(self.secondary_properties):
2161
2198
  # Base case: compute statistics for this group
2162
- return self._compute_stats(mask, weighted, arithmetic, gross_thickness, precision)
2199
+ return self._compute_stats(mask, weighted, arithmetic, gross_thickness, precision, zone_intervals)
2163
2200
 
2164
2201
  # Get unique values for current filter
2165
2202
  current_filter = self.secondary_properties[filter_idx]
@@ -2170,14 +2207,18 @@ class Property(PropertyOperationsMixin):
2170
2207
 
2171
2208
  if len(unique_vals) == 0:
2172
2209
  # No valid values, return stats for current mask
2173
- return self._compute_stats(mask, weighted, arithmetic, gross_thickness, precision)
2210
+ return self._compute_stats(mask, weighted, arithmetic, gross_thickness, precision, zone_intervals)
2174
2211
 
2175
2212
  # Calculate parent thickness BEFORE subdividing
2176
2213
  # This becomes the gross_thickness for all child groups
2177
2214
  # Cache property access to avoid overhead
2178
2215
  depth_array = self.depth
2179
2216
  values_array = self.values
2180
- parent_intervals = compute_intervals(depth_array)
2217
+ # Use zone intervals if provided, otherwise compute on full array
2218
+ if zone_intervals is not None:
2219
+ parent_intervals = zone_intervals
2220
+ else:
2221
+ parent_intervals = compute_intervals(depth_array)
2181
2222
  parent_valid = mask & ~np.isnan(values_array)
2182
2223
  parent_thickness = float(np.sum(parent_intervals[parent_valid]))
2183
2224
 
@@ -2212,7 +2253,7 @@ class Property(PropertyOperationsMixin):
2212
2253
  key = f"{current_filter.name}_{val:.2f}"
2213
2254
 
2214
2255
  result[key] = self._recursive_group(
2215
- filter_idx + 1, sub_mask, weighted, arithmetic, parent_thickness, precision
2256
+ filter_idx + 1, sub_mask, weighted, arithmetic, parent_thickness, precision, zone_intervals
2216
2257
  )
2217
2258
 
2218
2259
  return result
@@ -2223,7 +2264,8 @@ class Property(PropertyOperationsMixin):
2223
2264
  weighted: bool = True,
2224
2265
  arithmetic: bool = False,
2225
2266
  gross_thickness: float = 0.0,
2226
- precision: int = 6
2267
+ precision: int = 6,
2268
+ zone_intervals: Optional[np.ndarray] = None
2227
2269
  ) -> dict:
2228
2270
  """
2229
2271
  Compute statistics for values selected by mask.
@@ -2244,6 +2286,9 @@ class Property(PropertyOperationsMixin):
2244
2286
  Total gross thickness for fraction calculation
2245
2287
  precision : int, default 6
2246
2288
  Number of decimal places for rounding
2289
+ zone_intervals : np.ndarray, optional
2290
+ Pre-computed zone-aware intervals truncated at zone boundaries.
2291
+ If None, computes intervals using standard midpoint method.
2247
2292
 
2248
2293
  Returns
2249
2294
  -------
@@ -2263,12 +2308,17 @@ class Property(PropertyOperationsMixin):
2263
2308
  values = values_array[mask]
2264
2309
  valid = values[~np.isnan(values)]
2265
2310
 
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]
2311
+ # Use zone intervals if provided, otherwise compute on full array
2312
+ # Zone intervals are truncated at zone boundaries for accurate thickness
2313
+ if zone_intervals is not None:
2314
+ intervals = zone_intervals[mask]
2315
+ else:
2316
+ # Compute depth intervals on FULL depth array first, then mask
2317
+ # This is critical! Intervals must be computed on full grid so that
2318
+ # zone boundary samples get correct weights based on their neighbors
2319
+ # in the full sequence, not just within their zone.
2320
+ full_intervals = compute_intervals(depth_array)
2321
+ intervals = full_intervals[mask]
2272
2322
  valid_mask_local = ~np.isnan(values)
2273
2323
  valid_intervals = intervals[valid_mask_local]
2274
2324
 
@@ -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.149
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
@@ -3,13 +3,13 @@ well_log_toolkit/exceptions.py,sha256=X_fzC7d4yaBFO9Vx74dEIB6xmI9Agi6_bTU3MPxn6k
3
3
  well_log_toolkit/las_file.py,sha256=Tj0mRfX1aX2s6uug7BBlY1m_mu3G50EGxHGzD0eEedE,53876
4
4
  well_log_toolkit/manager.py,sha256=Jx0w1KpxRB2Oex6bC0FQFZOlJkoTKNexW3HMXxndL68,136713
5
5
  well_log_toolkit/operations.py,sha256=z8j8fGBOwoJGUQFy-Vawjq9nm3OD_dUt0oaNh8yuG7o,18515
6
- well_log_toolkit/property.py,sha256=GsiD9c4SfBw8ar7ZJXS0NNejPlpvFRHKck_eBR2lLmo,100965
6
+ well_log_toolkit/property.py,sha256=TkBuqgunIGIGdsLtWtQkwwXPdh0yqY37PgO_150FilA,103782
7
7
  well_log_toolkit/regression.py,sha256=JDcRxaODJnFikAdPJyTq8eUV7iY0vCDmvnGufqlojxs,31625
8
- well_log_toolkit/statistics.py,sha256=_huPMbv2H3o9ezunjEM94mJknX5wPK8V4nDv2lIZZRw,16814
8
+ well_log_toolkit/statistics.py,sha256=cpUbaRGlqyqpGWKtETk9XpXWrMJIIjVacdqEqIBkvqQ,19118
9
9
  well_log_toolkit/utils.py,sha256=O2KPq4htIoUlL74V2zKftdqqTjRfezU9M-568zPLme0,6866
10
10
  well_log_toolkit/visualization.py,sha256=nnpmFmbj44TbP0fsnLMR1GaKRkqKCEpI6Fd8Cp0oqBc,204716
11
11
  well_log_toolkit/well.py,sha256=n6XfaGSjGtyXCIaAr0ytslIK0DMUY_fSPQ_VCqj8jaU,106173
12
- well_log_toolkit-0.1.148.dist-info/METADATA,sha256=k7HnPCJ1iza1s8kvTFazQhfvYziyfVQ24JJYxWjrtvQ,63473
13
- well_log_toolkit-0.1.148.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
14
- well_log_toolkit-0.1.148.dist-info/top_level.txt,sha256=BMOo7OKLcZEnjo0wOLMclwzwTbYKYh31I8RGDOGSBdE,17
15
- well_log_toolkit-0.1.148.dist-info/RECORD,,
12
+ well_log_toolkit-0.1.149.dist-info/METADATA,sha256=g__8aYidDAd_oKi-MQ7PLGX0Ss63NHRbxjudCfdGtF8,63473
13
+ well_log_toolkit-0.1.149.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
14
+ well_log_toolkit-0.1.149.dist-info/top_level.txt,sha256=BMOo7OKLcZEnjo0wOLMclwzwTbYKYh31I8RGDOGSBdE,17
15
+ well_log_toolkit-0.1.149.dist-info/RECORD,,