well-log-toolkit 0.1.142__py3-none-any.whl → 0.1.144__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.
@@ -1167,6 +1167,220 @@ class Property(PropertyOperationsMixin):
1167
1167
 
1168
1168
  return new_prop
1169
1169
 
1170
+ def filter_intervals(
1171
+ self,
1172
+ intervals: Union[list[dict], dict[str, list[dict]], str],
1173
+ name: str = "Custom_Intervals",
1174
+ insert_boundaries: Optional[bool] = None,
1175
+ save: Optional[str] = None
1176
+ ) -> 'Property':
1177
+ """
1178
+ Filter by custom depth intervals defined as top/base pairs.
1179
+
1180
+ Each interval is processed independently, allowing overlapping intervals
1181
+ where the same depths can be counted in multiple zones.
1182
+
1183
+ Parameters
1184
+ ----------
1185
+ intervals : list[dict] | dict[str, list[dict]] | str
1186
+ Interval definitions. Can be:
1187
+ - list[dict]: Direct list of intervals for the current well
1188
+ - dict[str, list[dict]]: Well-specific intervals keyed by well name.
1189
+ Current well must be included or raises error.
1190
+ - str: Name of a previously saved filter to use
1191
+ name : str, default "Custom_Intervals"
1192
+ Name for the filter property (used in output labels)
1193
+ insert_boundaries : bool, optional
1194
+ If True, insert synthetic samples at interval boundaries.
1195
+ Default is True for continuous properties, False for sampled properties.
1196
+ save : str, optional
1197
+ If provided, save the intervals to the well(s) under this name.
1198
+ Overwrites any existing filter with the same name.
1199
+
1200
+ Returns
1201
+ -------
1202
+ Property
1203
+ New property instance with custom intervals as filter dimension
1204
+
1205
+ Examples
1206
+ --------
1207
+ >>> # Filter by custom zones
1208
+ >>> intervals = [
1209
+ ... {"name": "Zone_A", "top": 2500, "base": 2600},
1210
+ ... {"name": "Zone_B", "top": 2600, "base": 2750},
1211
+ ... ]
1212
+ >>> filtered = well.PHIE.filter_intervals(intervals)
1213
+ >>> filtered.sums_avg()
1214
+
1215
+ >>> # Save intervals for reuse
1216
+ >>> well.PHIE.filter_intervals(intervals, save="Reservoir_Zones")
1217
+ >>> # Later, use saved filter by name
1218
+ >>> well.PHIE.filter_intervals("Reservoir_Zones").sums_avg()
1219
+
1220
+ >>> # Save different intervals for multiple wells
1221
+ >>> manager.well_A.PHIE.filter_intervals({
1222
+ ... "well_A": intervals_a,
1223
+ ... "well_B": intervals_b
1224
+ ... }, save="My_Zones")
1225
+ >>> # Now both wells have "My_Zones" saved
1226
+
1227
+ >>> # Overlapping intervals - each calculated independently
1228
+ >>> intervals = [
1229
+ ... {"name": "Full_Reservoir", "top": 2500, "base": 2800},
1230
+ ... {"name": "Upper_Only", "top": 2500, "base": 2650}
1231
+ ... ]
1232
+ >>> # Depths 2500-2650 will be counted in BOTH zones
1233
+
1234
+ Notes
1235
+ -----
1236
+ Intervals can overlap or have gaps. Depths outside all intervals
1237
+ are excluded from statistics. Overlapping intervals are calculated
1238
+ independently - the same depths can contribute to multiple zones.
1239
+ """
1240
+ # Handle string input (saved filter name)
1241
+ if isinstance(intervals, str):
1242
+ filter_name = intervals
1243
+ if self.parent_well is None:
1244
+ raise PropertyNotFoundError(
1245
+ f"Cannot use saved filter '{filter_name}': no parent well reference."
1246
+ )
1247
+ if filter_name not in self.parent_well._saved_filter_intervals:
1248
+ available = list(self.parent_well._saved_filter_intervals.keys())
1249
+ raise PropertyNotFoundError(
1250
+ f"Saved filter '{filter_name}' not found in well '{self.parent_well.name}'. "
1251
+ f"Available filters: {available if available else 'none'}"
1252
+ )
1253
+ intervals = self.parent_well._saved_filter_intervals[filter_name]
1254
+ # Use filter name as the output name if not overridden
1255
+ if name == "Custom_Intervals":
1256
+ name = filter_name
1257
+
1258
+ # Handle dict input (well-specific intervals)
1259
+ elif isinstance(intervals, dict):
1260
+ if self.parent_well is None:
1261
+ raise PropertyNotFoundError(
1262
+ "Cannot use well-specific intervals: no parent well reference."
1263
+ )
1264
+
1265
+ well_name = self.parent_well.name
1266
+ sanitized_name = self.parent_well.sanitized_name
1267
+
1268
+ # Check if current well is in the dict (by name or sanitized name)
1269
+ current_well_intervals = [] # Default to empty if not found
1270
+ if well_name in intervals:
1271
+ current_well_intervals = intervals[well_name]
1272
+ elif sanitized_name in intervals:
1273
+ current_well_intervals = intervals[sanitized_name]
1274
+
1275
+ # Save to all specified wells if save parameter provided
1276
+ if save and self.parent_well.parent_manager:
1277
+ manager = self.parent_well.parent_manager
1278
+ for key, well_intervals in intervals.items():
1279
+ # Find well by name or sanitized name
1280
+ target_well = None
1281
+ for w in manager._wells.values():
1282
+ if w.name == key or w.sanitized_name == key:
1283
+ target_well = w
1284
+ break
1285
+ if target_well:
1286
+ self._validate_intervals(well_intervals)
1287
+ target_well._saved_filter_intervals[save] = well_intervals
1288
+
1289
+ intervals = current_well_intervals
1290
+
1291
+ # Validate and save if we have intervals
1292
+ if intervals:
1293
+ # Validate interval structure
1294
+ self._validate_intervals(intervals)
1295
+
1296
+ # Save to current well if save parameter provided
1297
+ if save and self.parent_well:
1298
+ self.parent_well._saved_filter_intervals[save] = intervals
1299
+
1300
+ # Determine if we should insert boundaries
1301
+ if insert_boundaries is None:
1302
+ insert_boundaries = self.type != 'sampled'
1303
+
1304
+ # Collect all boundary depths from intervals for boundary insertion
1305
+ if insert_boundaries and intervals:
1306
+ boundary_depths = []
1307
+ for interval in intervals:
1308
+ boundary_depths.append(float(interval['top']))
1309
+ boundary_depths.append(float(interval['base']))
1310
+ boundary_depths = np.unique(boundary_depths)
1311
+
1312
+ # Create a temporary discrete property just for boundary insertion
1313
+ # Values don't matter here, only the depths
1314
+ temp_discrete = Property(
1315
+ name=name,
1316
+ depth=boundary_depths,
1317
+ values=np.arange(len(boundary_depths), dtype=float),
1318
+ parent_well=self.parent_well,
1319
+ prop_type='discrete'
1320
+ )
1321
+ new_depth, new_values, new_secondaries = self._insert_boundary_samples(temp_discrete)
1322
+ else:
1323
+ new_depth = self.depth.copy()
1324
+ new_values = self.values.copy()
1325
+ new_secondaries = [sp for sp in self.secondary_properties]
1326
+
1327
+ # Create new Property instance
1328
+ new_prop = Property(
1329
+ name=self.name,
1330
+ depth=new_depth,
1331
+ values=new_values,
1332
+ parent_well=self.parent_well,
1333
+ unit=self.unit,
1334
+ prop_type=self.type,
1335
+ description=self.description,
1336
+ null_value=-999.25,
1337
+ labels=self.labels,
1338
+ colors=self.colors,
1339
+ styles=self.styles,
1340
+ thicknesses=self.thicknesses,
1341
+ source_las=self.source_las,
1342
+ source_name=self.source_name,
1343
+ original_name=self.original_name
1344
+ )
1345
+ new_prop.secondary_properties = new_secondaries
1346
+
1347
+ # Store custom intervals for independent processing in sums_avg/discrete_summary
1348
+ new_prop._custom_intervals = intervals
1349
+ new_prop._custom_intervals_name = name
1350
+
1351
+ # Track filtering metadata
1352
+ new_prop._is_filtered = True
1353
+ new_prop._original_sample_count = len(self.depth)
1354
+ new_prop._boundary_samples_inserted = len(new_depth) - len(self.depth)
1355
+
1356
+ return new_prop
1357
+
1358
+ def _validate_intervals(self, intervals: list[dict]) -> None:
1359
+ """
1360
+ Validate interval structure.
1361
+
1362
+ Parameters
1363
+ ----------
1364
+ intervals : list[dict]
1365
+ List of interval definitions to validate
1366
+
1367
+ Raises
1368
+ ------
1369
+ ValueError
1370
+ If any interval is invalid
1371
+ """
1372
+ for i, interval in enumerate(intervals):
1373
+ if not isinstance(interval, dict):
1374
+ raise ValueError(f"Interval {i} must be a dict, got {type(interval)}")
1375
+ for key in ('name', 'top', 'base'):
1376
+ if key not in interval:
1377
+ raise ValueError(f"Interval {i} missing required key '{key}'")
1378
+ if interval['top'] >= interval['base']:
1379
+ raise ValueError(
1380
+ f"Interval '{interval['name']}': top ({interval['top']}) must be "
1381
+ f"less than base ({interval['base']})"
1382
+ )
1383
+
1170
1384
  def _insert_boundary_samples(
1171
1385
  self,
1172
1386
  discrete_prop: 'Property'
@@ -1500,6 +1714,16 @@ class Property(PropertyOperationsMixin):
1500
1714
  valid_mask = ~np.isnan(self.values)
1501
1715
  gross_thickness = float(np.sum(full_intervals[valid_mask]))
1502
1716
 
1717
+ # Check for custom intervals (from filter_intervals)
1718
+ # These are processed independently, allowing overlaps
1719
+ if hasattr(self, '_custom_intervals') and self._custom_intervals:
1720
+ return self._compute_stats_by_intervals(
1721
+ weighted=weighted,
1722
+ arithmetic=arithmetic,
1723
+ gross_thickness=gross_thickness,
1724
+ precision=precision
1725
+ )
1726
+
1503
1727
  if not self.secondary_properties:
1504
1728
  # No filters, simple statistics
1505
1729
  return self._compute_stats(
@@ -1520,6 +1744,51 @@ class Property(PropertyOperationsMixin):
1520
1744
  precision=precision
1521
1745
  )
1522
1746
 
1747
+ def _compute_stats_by_intervals(
1748
+ self,
1749
+ weighted: bool,
1750
+ arithmetic: bool,
1751
+ gross_thickness: float,
1752
+ precision: int
1753
+ ) -> dict:
1754
+ """
1755
+ Compute statistics for each custom interval independently.
1756
+
1757
+ This allows overlapping intervals where the same depths can
1758
+ contribute to multiple zones.
1759
+ """
1760
+ result = {}
1761
+
1762
+ for interval in self._custom_intervals:
1763
+ interval_name = interval['name']
1764
+ top = float(interval['top'])
1765
+ base = float(interval['base'])
1766
+
1767
+ # Create mask for this interval (top <= depth < base)
1768
+ interval_mask = (self.depth >= top) & (self.depth < base)
1769
+
1770
+ # If there are secondary properties, group within this interval
1771
+ if self.secondary_properties:
1772
+ result[interval_name] = self._recursive_group(
1773
+ 0,
1774
+ interval_mask,
1775
+ weighted=weighted,
1776
+ arithmetic=arithmetic,
1777
+ gross_thickness=gross_thickness,
1778
+ precision=precision
1779
+ )
1780
+ else:
1781
+ # No secondary properties, compute stats directly for interval
1782
+ result[interval_name] = self._compute_stats(
1783
+ interval_mask,
1784
+ weighted=weighted,
1785
+ arithmetic=arithmetic,
1786
+ gross_thickness=gross_thickness,
1787
+ precision=precision
1788
+ )
1789
+
1790
+ return result
1791
+
1523
1792
  def discrete_summary(self, precision: int = 6) -> dict:
1524
1793
  """
1525
1794
  Compute summary statistics for discrete/categorical properties.
@@ -1570,6 +1839,14 @@ class Property(PropertyOperationsMixin):
1570
1839
  valid_mask = ~np.isnan(self.values)
1571
1840
  gross_thickness = float(np.sum(full_intervals[valid_mask]))
1572
1841
 
1842
+ # Check for custom intervals (from filter_intervals)
1843
+ # These are processed independently, allowing overlaps
1844
+ if hasattr(self, '_custom_intervals') and self._custom_intervals:
1845
+ return self._compute_discrete_stats_by_intervals(
1846
+ gross_thickness=gross_thickness,
1847
+ precision=precision
1848
+ )
1849
+
1573
1850
  if not self.secondary_properties:
1574
1851
  # No filters, compute stats for all discrete values
1575
1852
  return self._compute_discrete_stats(
@@ -1586,12 +1863,80 @@ class Property(PropertyOperationsMixin):
1586
1863
  precision=precision
1587
1864
  )
1588
1865
 
1866
+ def _compute_discrete_stats_by_intervals(
1867
+ self,
1868
+ gross_thickness: float,
1869
+ precision: int
1870
+ ) -> dict:
1871
+ """
1872
+ Compute discrete statistics for each custom interval independently.
1873
+
1874
+ This allows overlapping intervals where the same depths can
1875
+ contribute to multiple zones. Zone-level metadata (depth_range, thickness)
1876
+ is shown at the interval level, and fractions are relative to zone thickness.
1877
+ """
1878
+ result = {}
1879
+
1880
+ for interval in self._custom_intervals:
1881
+ interval_name = interval['name']
1882
+ top = float(interval['top'])
1883
+ base = float(interval['base'])
1884
+
1885
+ # Create mask for this interval (top <= depth < base)
1886
+ interval_mask = (self.depth >= top) & (self.depth < base)
1887
+
1888
+ # Calculate zone thickness for fraction calculation
1889
+ full_intervals = compute_intervals(self.depth)
1890
+ valid_mask = ~np.isnan(self.values) & interval_mask
1891
+ zone_thickness = float(np.sum(full_intervals[valid_mask]))
1892
+
1893
+ # Get actual depth range within the interval (where we have data)
1894
+ if np.any(valid_mask):
1895
+ zone_depths = self.depth[valid_mask]
1896
+ zone_depth_range = {
1897
+ 'min': round(float(np.min(zone_depths)), precision),
1898
+ 'max': round(float(np.max(zone_depths)), precision)
1899
+ }
1900
+ else:
1901
+ zone_depth_range = {'min': top, 'max': base}
1902
+
1903
+ # Build interval result with zone-level metadata
1904
+ interval_result = {
1905
+ 'depth_range': zone_depth_range,
1906
+ 'thickness': round(zone_thickness, precision)
1907
+ }
1908
+
1909
+ # If there are secondary properties, group within this interval
1910
+ if self.secondary_properties:
1911
+ facies_stats = self._recursive_discrete_group(
1912
+ 0,
1913
+ interval_mask,
1914
+ gross_thickness=zone_thickness, # Use zone thickness for fractions
1915
+ precision=precision,
1916
+ include_depth_range=False # Don't include depth_range per facies
1917
+ )
1918
+ else:
1919
+ # No secondary properties, compute stats directly for interval
1920
+ facies_stats = self._compute_discrete_stats(
1921
+ interval_mask,
1922
+ gross_thickness=zone_thickness, # Use zone thickness for fractions
1923
+ precision=precision,
1924
+ include_depth_range=False # Don't include depth_range per facies
1925
+ )
1926
+
1927
+ # Nest facies stats under 'facies' key for cleaner structure
1928
+ interval_result['facies'] = facies_stats
1929
+ result[interval_name] = interval_result
1930
+
1931
+ return result
1932
+
1589
1933
  def _recursive_discrete_group(
1590
1934
  self,
1591
1935
  filter_idx: int,
1592
1936
  mask: np.ndarray,
1593
1937
  gross_thickness: float,
1594
- precision: int = 6
1938
+ precision: int = 6,
1939
+ include_depth_range: bool = True
1595
1940
  ) -> dict:
1596
1941
  """
1597
1942
  Recursively group discrete statistics by secondary properties.
@@ -1606,6 +1951,8 @@ class Property(PropertyOperationsMixin):
1606
1951
  Total gross thickness for fraction calculation
1607
1952
  precision : int, default 6
1608
1953
  Number of decimal places for rounding
1954
+ include_depth_range : bool, default True
1955
+ Whether to include depth_range in per-facies stats
1609
1956
 
1610
1957
  Returns
1611
1958
  -------
@@ -1614,7 +1961,7 @@ class Property(PropertyOperationsMixin):
1614
1961
  """
1615
1962
  if filter_idx >= len(self.secondary_properties):
1616
1963
  # Base case: compute discrete statistics for this group
1617
- return self._compute_discrete_stats(mask, gross_thickness, precision)
1964
+ return self._compute_discrete_stats(mask, gross_thickness, precision, include_depth_range)
1618
1965
 
1619
1966
  # Get unique values for current filter
1620
1967
  current_filter = self.secondary_properties[filter_idx]
@@ -1624,7 +1971,7 @@ class Property(PropertyOperationsMixin):
1624
1971
 
1625
1972
  if len(unique_vals) == 0:
1626
1973
  # No valid values, return stats for current mask
1627
- return self._compute_discrete_stats(mask, gross_thickness, precision)
1974
+ return self._compute_discrete_stats(mask, gross_thickness, precision, include_depth_range)
1628
1975
 
1629
1976
  # Group by each unique value
1630
1977
  depth_array = self.depth
@@ -1660,7 +2007,7 @@ class Property(PropertyOperationsMixin):
1660
2007
  key = f"{current_filter.name}_{val:.2f}"
1661
2008
 
1662
2009
  result[key] = self._recursive_discrete_group(
1663
- filter_idx + 1, sub_mask, group_thickness, precision
2010
+ filter_idx + 1, sub_mask, group_thickness, precision, include_depth_range
1664
2011
  )
1665
2012
 
1666
2013
  return result
@@ -1669,7 +2016,8 @@ class Property(PropertyOperationsMixin):
1669
2016
  self,
1670
2017
  mask: np.ndarray,
1671
2018
  gross_thickness: float,
1672
- precision: int = 6
2019
+ precision: int = 6,
2020
+ include_depth_range: bool = True
1673
2021
  ) -> dict:
1674
2022
  """
1675
2023
  Compute categorical statistics for discrete property values.
@@ -1682,12 +2030,15 @@ class Property(PropertyOperationsMixin):
1682
2030
  Total gross thickness for fraction calculation
1683
2031
  precision : int, default 6
1684
2032
  Number of decimal places for rounding
2033
+ include_depth_range : bool, default True
2034
+ Whether to include depth_range in per-facies stats.
2035
+ Set to False when using filter_intervals (depth_range shown at zone level).
1685
2036
 
1686
2037
  Returns
1687
2038
  -------
1688
2039
  dict
1689
2040
  Dictionary with stats for each discrete value:
1690
- {value_label: {code, count, thickness, fraction, depth_range}}
2041
+ {value_label: {code, count, thickness, fraction, [depth_range]}}
1691
2042
  """
1692
2043
  values_array = self.values
1693
2044
  depth_array = self.depth
@@ -1733,12 +2084,13 @@ class Property(PropertyOperationsMixin):
1733
2084
  'code': int_val,
1734
2085
  'count': count,
1735
2086
  'thickness': round(thickness, precision),
1736
- 'fraction': round(fraction, precision),
1737
- 'depth_range': {
2087
+ 'fraction': round(fraction, precision)
2088
+ }
2089
+ if include_depth_range:
2090
+ stats['depth_range'] = {
1738
2091
  'min': round(float(np.min(val_depths)), precision),
1739
2092
  'max': round(float(np.max(val_depths)), precision)
1740
2093
  }
1741
- }
1742
2094
  if label is not None:
1743
2095
  stats['label'] = label
1744
2096
 
well_log_toolkit/well.py CHANGED
@@ -220,6 +220,8 @@ class Well:
220
220
  self._deleted_sources: list[str] = [] # List of source names to delete
221
221
  # Track sources marked for rename (to rename files on save)
222
222
  self._renamed_sources: dict[str, str] = {} # {old_name: new_name}
223
+ # Saved filter intervals for use with filter_intervals()
224
+ self._saved_filter_intervals: dict[str, list[dict]] = {} # {filter_name: [intervals]}
223
225
 
224
226
  def __setattr__(self, name: str, value):
225
227
  """
@@ -1226,7 +1228,58 @@ class Well:
1226
1228
  f"Property '{name}' not found in well '{self.name}'. "
1227
1229
  f"Available properties: {available or 'none'}"
1228
1230
  )
1229
-
1231
+
1232
+ def get_intervals(self, name: str) -> list[dict]:
1233
+ """
1234
+ Get saved filter intervals by name.
1235
+
1236
+ Parameters
1237
+ ----------
1238
+ name : str
1239
+ Name of the saved filter intervals
1240
+
1241
+ Returns
1242
+ -------
1243
+ list[dict]
1244
+ List of interval definitions, each with keys 'name', 'top', 'base'
1245
+
1246
+ Raises
1247
+ ------
1248
+ KeyError
1249
+ If no intervals with this name exist
1250
+
1251
+ Examples
1252
+ --------
1253
+ >>> # Save intervals
1254
+ >>> well.PHIE.filter_intervals([
1255
+ ... {"name": "Zone_A", "top": 2500, "base": 2650}
1256
+ ... ], save="My_Zones")
1257
+ >>>
1258
+ >>> # Retrieve them later
1259
+ >>> intervals = well.get_intervals("My_Zones")
1260
+ >>> print(intervals)
1261
+ [{'name': 'Zone_A', 'top': 2500, 'base': 2650}]
1262
+ """
1263
+ if name not in self._saved_filter_intervals:
1264
+ available = list(self._saved_filter_intervals.keys())
1265
+ raise KeyError(
1266
+ f"Filter intervals '{name}' not found in well '{self.name}'. "
1267
+ f"Available: {available if available else 'none'}"
1268
+ )
1269
+ return self._saved_filter_intervals[name]
1270
+
1271
+ @property
1272
+ def saved_intervals(self) -> list[str]:
1273
+ """
1274
+ List of saved filter interval names.
1275
+
1276
+ Returns
1277
+ -------
1278
+ list[str]
1279
+ Names of all saved filter intervals
1280
+ """
1281
+ return list(self._saved_filter_intervals.keys())
1282
+
1230
1283
  @staticmethod
1231
1284
  def _is_regular_grid(depth: np.ndarray, tolerance: float = 1e-6) -> tuple[bool, Optional[float]]:
1232
1285
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: well-log-toolkit
3
- Version: 0.1.142
3
+ Version: 0.1.144
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
@@ -288,6 +288,74 @@ stats = well.PHIE.filter('Zone').filter('Facies').sums_avg()
288
288
  - `samples` - Number of valid measurements
289
289
  - `range`, `depth_range` - Min/max values and depths
290
290
 
291
+ ### Custom Interval Filtering
292
+
293
+ Define custom depth intervals without needing a discrete property in the well:
294
+
295
+ ```python
296
+ # Define intervals with name, top, and base
297
+ intervals = [
298
+ {"name": "Zone_A", "top": 2500, "base": 2650},
299
+ {"name": "Zone_B", "top": 2650, "base": 2800}
300
+ ]
301
+
302
+ # Use with sums_avg or discrete_summary
303
+ stats = well.PHIE.filter_intervals(intervals).sums_avg()
304
+ # → {'Zone_A': {'mean': 0.18, ...}, 'Zone_B': {'mean': 0.21, ...}}
305
+
306
+ facies_stats = well.Facies.filter_intervals(intervals).discrete_summary()
307
+ ```
308
+
309
+ **Overlapping intervals** are supported - each interval is calculated independently:
310
+
311
+ ```python
312
+ # These intervals overlap at 2600-2700m
313
+ intervals = [
314
+ {"name": "Full_Reservoir", "top": 2500, "base": 2800},
315
+ {"name": "Upper_Section", "top": 2500, "base": 2700}
316
+ ]
317
+ # Depths 2500-2700 are counted in BOTH zones
318
+ stats = well.PHIE.filter_intervals(intervals).sums_avg()
319
+ ```
320
+
321
+ **Save intervals for reuse:**
322
+
323
+ ```python
324
+ # Save intervals to the well
325
+ well.PHIE.filter_intervals(intervals, save="Reservoir_Zones")
326
+
327
+ # Use saved intervals by name
328
+ stats = well.PHIE.filter_intervals("Reservoir_Zones").sums_avg()
329
+
330
+ # List saved intervals
331
+ print(well.saved_intervals) # ['Reservoir_Zones']
332
+
333
+ # Retrieve intervals
334
+ intervals = well.get_intervals("Reservoir_Zones")
335
+ ```
336
+
337
+ **Save different intervals for multiple wells:**
338
+
339
+ ```python
340
+ # Define well-specific intervals
341
+ manager.well_A.PHIE.filter_intervals({
342
+ "Well_A": [{"name": "Zone_A", "top": 2500, "base": 2700}],
343
+ "Well_B": [{"name": "Zone_A", "top": 2600, "base": 2800}]
344
+ }, save="My_Zones")
345
+
346
+ # Both wells now have "My_Zones" saved with their respective intervals
347
+ ```
348
+
349
+ **Chain with other filters:**
350
+
351
+ ```python
352
+ # Combine custom intervals with property filters
353
+ stats = well.PHIE.filter_intervals(intervals).filter("NetFlag").sums_avg()
354
+ # → {'Zone_A': {'Net': {...}, 'NonNet': {...}}, 'Zone_B': {...}}
355
+ ```
356
+
357
+ > **💡 Key Difference:** Unlike `.filter('Well_Tops')` where each depth belongs to exactly one zone, `filter_intervals()` allows overlapping intervals where the same depths can contribute to multiple zones.
358
+
291
359
  ### Property Operations
292
360
 
293
361
  Create computed properties using natural mathematical syntax:
@@ -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=VIARJLkYhxqxgTqfVfAAZU6AVsAPkQWPOUE6RNGnIdY,110558
5
5
  well_log_toolkit/operations.py,sha256=z8j8fGBOwoJGUQFy-Vawjq9nm3OD_dUt0oaNh8yuG7o,18515
6
- well_log_toolkit/property.py,sha256=sMA4sfcI_qXg4L9rdL19gmkzPEyPa_3X7LAFdJNZ6Jk,85224
6
+ well_log_toolkit/property.py,sha256=4g5-_WRdJ9HwDKkufU4s_oOnCh6Deg58ZX5jE9Uwx2c,99833
7
7
  well_log_toolkit/regression.py,sha256=JDcRxaODJnFikAdPJyTq8eUV7iY0vCDmvnGufqlojxs,31625
8
8
  well_log_toolkit/statistics.py,sha256=_huPMbv2H3o9ezunjEM94mJknX5wPK8V4nDv2lIZZRw,16814
9
9
  well_log_toolkit/utils.py,sha256=O2KPq4htIoUlL74V2zKftdqqTjRfezU9M-568zPLme0,6866
10
10
  well_log_toolkit/visualization.py,sha256=nnpmFmbj44TbP0fsnLMR1GaKRkqKCEpI6Fd8Cp0oqBc,204716
11
- well_log_toolkit/well.py,sha256=Aav5Y-rui8YsJdvk7BFndNPUu1O9mcjwDApAGyqV9kw,104535
12
- well_log_toolkit-0.1.142.dist-info/METADATA,sha256=6Lvy02zTkolrANhxaWVTUY0l5HHqhbmRjlOyebp9fC4,61388
13
- well_log_toolkit-0.1.142.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
14
- well_log_toolkit-0.1.142.dist-info/top_level.txt,sha256=BMOo7OKLcZEnjo0wOLMclwzwTbYKYh31I8RGDOGSBdE,17
15
- well_log_toolkit-0.1.142.dist-info/RECORD,,
11
+ well_log_toolkit/well.py,sha256=n6XfaGSjGtyXCIaAr0ytslIK0DMUY_fSPQ_VCqj8jaU,106173
12
+ well_log_toolkit-0.1.144.dist-info/METADATA,sha256=5GdblMrgAGxLNG0LA9pKav9UHUl4iTps6Zl34xK-4CA,63473
13
+ well_log_toolkit-0.1.144.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
14
+ well_log_toolkit-0.1.144.dist-info/top_level.txt,sha256=BMOo7OKLcZEnjo0wOLMclwzwTbYKYh31I8RGDOGSBdE,17
15
+ well_log_toolkit-0.1.144.dist-info/RECORD,,