well-log-toolkit 0.1.142__tar.gz → 0.1.143__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.142 → well_log_toolkit-0.1.143}/PKG-INFO +1 -1
  2. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/pyproject.toml +1 -1
  3. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit/property.py +316 -0
  4. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit/well.py +54 -1
  5. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit.egg-info/PKG-INFO +1 -1
  6. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/README.md +0 -0
  7. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/setup.cfg +0 -0
  8. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit/__init__.py +0 -0
  9. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit/exceptions.py +0 -0
  10. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit/las_file.py +0 -0
  11. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit/manager.py +0 -0
  12. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit/operations.py +0 -0
  13. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit/regression.py +0 -0
  14. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit/statistics.py +0 -0
  15. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit/utils.py +0 -0
  16. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit/visualization.py +0 -0
  17. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit.egg-info/SOURCES.txt +0 -0
  18. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit.egg-info/dependency_links.txt +0 -0
  19. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/well_log_toolkit.egg-info/requires.txt +0 -0
  20. {well_log_toolkit-0.1.142 → well_log_toolkit-0.1.143}/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.142
3
+ Version: 0.1.143
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.142"
7
+ version = "0.1.143"
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"
@@ -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,6 +1863,45 @@ 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.
1876
+ """
1877
+ result = {}
1878
+
1879
+ for interval in self._custom_intervals:
1880
+ interval_name = interval['name']
1881
+ top = float(interval['top'])
1882
+ base = float(interval['base'])
1883
+
1884
+ # Create mask for this interval (top <= depth < base)
1885
+ interval_mask = (self.depth >= top) & (self.depth < base)
1886
+
1887
+ # If there are secondary properties, group within this interval
1888
+ if self.secondary_properties:
1889
+ result[interval_name] = self._recursive_discrete_group(
1890
+ 0,
1891
+ interval_mask,
1892
+ gross_thickness=gross_thickness,
1893
+ precision=precision
1894
+ )
1895
+ else:
1896
+ # No secondary properties, compute stats directly for interval
1897
+ result[interval_name] = self._compute_discrete_stats(
1898
+ interval_mask,
1899
+ gross_thickness=gross_thickness,
1900
+ precision=precision
1901
+ )
1902
+
1903
+ return result
1904
+
1589
1905
  def _recursive_discrete_group(
1590
1906
  self,
1591
1907
  filter_idx: int,
@@ -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.143
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