well-log-toolkit 0.1.139__tar.gz → 0.1.141__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.139 → well_log_toolkit-0.1.141}/PKG-INFO +1 -1
  2. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/pyproject.toml +1 -1
  3. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit/property.py +225 -0
  4. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit/visualization.py +66 -9
  5. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit.egg-info/PKG-INFO +1 -1
  6. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/README.md +0 -0
  7. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/setup.cfg +0 -0
  8. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit/__init__.py +0 -0
  9. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit/exceptions.py +0 -0
  10. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit/las_file.py +0 -0
  11. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit/manager.py +0 -0
  12. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit/operations.py +0 -0
  13. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit/regression.py +0 -0
  14. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit/statistics.py +0 -0
  15. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit/utils.py +0 -0
  16. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit/well.py +0 -0
  17. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit.egg-info/SOURCES.txt +0 -0
  18. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit.egg-info/dependency_links.txt +0 -0
  19. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/well_log_toolkit.egg-info/requires.txt +0 -0
  20. {well_log_toolkit-0.1.139 → well_log_toolkit-0.1.141}/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.139
3
+ Version: 0.1.141
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.139"
7
+ version = "0.1.141"
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"
@@ -1520,6 +1520,231 @@ class Property(PropertyOperationsMixin):
1520
1520
  precision=precision
1521
1521
  )
1522
1522
 
1523
+ def discrete_summary(self, precision: int = 6) -> dict:
1524
+ """
1525
+ Compute summary statistics for discrete/categorical properties.
1526
+
1527
+ This method is designed for discrete logs (like facies, lithology flags,
1528
+ or net/gross indicators) where categorical statistics are more meaningful
1529
+ than continuous statistics like mean or standard deviation.
1530
+
1531
+ Parameters
1532
+ ----------
1533
+ precision : int, default 6
1534
+ Number of decimal places for rounding numeric results
1535
+
1536
+ Returns
1537
+ -------
1538
+ dict
1539
+ Nested dictionary with statistics for each discrete value.
1540
+ If secondary properties (filters) exist, the structure is hierarchical.
1541
+
1542
+ For each discrete value, includes:
1543
+ - label: Human-readable name (if labels defined)
1544
+ - code: Numeric code for this category
1545
+ - count: Number of samples with this value
1546
+ - thickness: Total depth interval (meters) for this category
1547
+ - fraction: Proportion of total thickness (0-1)
1548
+ - depth_range: {min, max} depth extent
1549
+
1550
+ Examples
1551
+ --------
1552
+ >>> # Simple discrete summary (no filters)
1553
+ >>> facies = well.get_property('Facies')
1554
+ >>> stats = facies.discrete_summary()
1555
+ >>> # {'Sand': {'code': 1, 'count': 150, 'thickness': 25.5, 'fraction': 0.45, ...},
1556
+ >>> # 'Shale': {'code': 2, 'count': 180, 'thickness': 30.8, 'fraction': 0.55, ...}}
1557
+
1558
+ >>> # Grouped by zones
1559
+ >>> filtered = facies.filter('Well_Tops')
1560
+ >>> stats = filtered.discrete_summary()
1561
+ >>> # {'Zone_A': {'Sand': {...}, 'Shale': {...}},
1562
+ >>> # 'Zone_B': {'Sand': {...}, 'Shale': {...}}}
1563
+
1564
+ Notes
1565
+ -----
1566
+ For continuous properties, use `sums_avg()` instead.
1567
+ """
1568
+ # Calculate gross thickness for fraction calculation
1569
+ full_intervals = compute_intervals(self.depth)
1570
+ valid_mask = ~np.isnan(self.values)
1571
+ gross_thickness = float(np.sum(full_intervals[valid_mask]))
1572
+
1573
+ if not self.secondary_properties:
1574
+ # No filters, compute stats for all discrete values
1575
+ return self._compute_discrete_stats(
1576
+ np.ones(len(self.depth), dtype=bool),
1577
+ gross_thickness=gross_thickness,
1578
+ precision=precision
1579
+ )
1580
+
1581
+ # Build hierarchical grouping
1582
+ return self._recursive_discrete_group(
1583
+ 0,
1584
+ np.ones(len(self.depth), dtype=bool),
1585
+ gross_thickness=gross_thickness,
1586
+ precision=precision
1587
+ )
1588
+
1589
+ def _recursive_discrete_group(
1590
+ self,
1591
+ filter_idx: int,
1592
+ mask: np.ndarray,
1593
+ gross_thickness: float,
1594
+ precision: int = 6
1595
+ ) -> dict:
1596
+ """
1597
+ Recursively group discrete statistics by secondary properties.
1598
+
1599
+ Parameters
1600
+ ----------
1601
+ filter_idx : int
1602
+ Index of current secondary property
1603
+ mask : np.ndarray
1604
+ Boolean mask for current group
1605
+ gross_thickness : float
1606
+ Total gross thickness for fraction calculation
1607
+ precision : int, default 6
1608
+ Number of decimal places for rounding
1609
+
1610
+ Returns
1611
+ -------
1612
+ dict
1613
+ Discrete stats dict or nested dict of stats
1614
+ """
1615
+ if filter_idx >= len(self.secondary_properties):
1616
+ # Base case: compute discrete statistics for this group
1617
+ return self._compute_discrete_stats(mask, gross_thickness, precision)
1618
+
1619
+ # Get unique values for current filter
1620
+ current_filter = self.secondary_properties[filter_idx]
1621
+ current_filter_values = current_filter.values
1622
+ filter_values = current_filter_values[mask]
1623
+ unique_vals = np.unique(filter_values[~np.isnan(filter_values)])
1624
+
1625
+ if len(unique_vals) == 0:
1626
+ # No valid values, return stats for current mask
1627
+ return self._compute_discrete_stats(mask, gross_thickness, precision)
1628
+
1629
+ # Calculate parent thickness for fraction calculation in child groups
1630
+ depth_array = self.depth
1631
+ values_array = self.values
1632
+ parent_intervals = compute_intervals(depth_array)
1633
+ parent_valid = mask & ~np.isnan(values_array)
1634
+ parent_thickness = float(np.sum(parent_intervals[parent_valid]))
1635
+
1636
+ # Group by each unique value
1637
+ result = {}
1638
+ for val in unique_vals:
1639
+ sub_mask = mask & (current_filter_values == val)
1640
+
1641
+ # Create readable key with label if available
1642
+ if current_filter.type == 'discrete':
1643
+ int_val = int(val)
1644
+ else:
1645
+ int_val = int(val) if val == int(val) else None
1646
+
1647
+ if current_filter.labels is not None:
1648
+ if int_val is not None and int_val in current_filter.labels:
1649
+ key = current_filter.labels[int_val]
1650
+ elif val in current_filter.labels:
1651
+ key = current_filter.labels[val]
1652
+ elif int_val is not None:
1653
+ key = f"{current_filter.name}_{int_val}"
1654
+ else:
1655
+ key = f"{current_filter.name}_{val:.2f}"
1656
+ elif int_val is not None:
1657
+ key = f"{current_filter.name}_{int_val}"
1658
+ else:
1659
+ key = f"{current_filter.name}_{val:.2f}"
1660
+
1661
+ result[key] = self._recursive_discrete_group(
1662
+ filter_idx + 1, sub_mask, parent_thickness, precision
1663
+ )
1664
+
1665
+ return result
1666
+
1667
+ def _compute_discrete_stats(
1668
+ self,
1669
+ mask: np.ndarray,
1670
+ gross_thickness: float,
1671
+ precision: int = 6
1672
+ ) -> dict:
1673
+ """
1674
+ Compute categorical statistics for discrete property values.
1675
+
1676
+ Parameters
1677
+ ----------
1678
+ mask : np.ndarray
1679
+ Boolean mask selecting subset of data
1680
+ gross_thickness : float
1681
+ Total gross thickness for fraction calculation
1682
+ precision : int, default 6
1683
+ Number of decimal places for rounding
1684
+
1685
+ Returns
1686
+ -------
1687
+ dict
1688
+ Dictionary with stats for each discrete value:
1689
+ {value_label: {code, count, thickness, fraction, depth_range}}
1690
+ """
1691
+ values_array = self.values
1692
+ depth_array = self.depth
1693
+
1694
+ values = values_array[mask]
1695
+ depths = depth_array[mask]
1696
+
1697
+ # Compute intervals on full array then mask
1698
+ full_intervals = compute_intervals(depth_array)
1699
+ intervals = full_intervals[mask]
1700
+
1701
+ # Find unique discrete values
1702
+ valid_mask_local = ~np.isnan(values)
1703
+ valid_values = values[valid_mask_local]
1704
+ valid_depths = depths[valid_mask_local]
1705
+ valid_intervals = intervals[valid_mask_local]
1706
+
1707
+ if len(valid_values) == 0:
1708
+ return {}
1709
+
1710
+ unique_vals = np.unique(valid_values)
1711
+
1712
+ result = {}
1713
+ for val in unique_vals:
1714
+ val_mask = valid_values == val
1715
+ val_intervals = valid_intervals[val_mask]
1716
+ val_depths = valid_depths[val_mask]
1717
+
1718
+ thickness = float(np.sum(val_intervals))
1719
+ count = int(np.sum(val_mask))
1720
+ fraction = thickness / gross_thickness if gross_thickness > 0 else 0.0
1721
+
1722
+ # Determine the key and label
1723
+ int_val = int(val)
1724
+ if self.labels is not None and int_val in self.labels:
1725
+ key = self.labels[int_val]
1726
+ label = self.labels[int_val]
1727
+ else:
1728
+ key = f"{self.name}_{int_val}"
1729
+ label = None
1730
+
1731
+ stats = {
1732
+ 'code': int_val,
1733
+ 'count': count,
1734
+ 'thickness': round(thickness, precision),
1735
+ 'fraction': round(fraction, precision),
1736
+ 'depth_range': {
1737
+ 'min': round(float(np.min(val_depths)), precision),
1738
+ 'max': round(float(np.max(val_depths)), precision)
1739
+ }
1740
+ }
1741
+ if label is not None:
1742
+ stats['label'] = label
1743
+
1744
+ result[key] = stats
1745
+
1746
+ return result
1747
+
1523
1748
  def _recursive_group(
1524
1749
  self,
1525
1750
  filter_idx: int,
@@ -14,7 +14,7 @@ import pandas as pd
14
14
  import matplotlib.pyplot as plt
15
15
  import matplotlib.cm as cm
16
16
  from matplotlib.collections import PolyCollection
17
- from matplotlib.colors import Normalize
17
+ from matplotlib.colors import Normalize, LogNorm
18
18
  from matplotlib.patches import Rectangle, Patch
19
19
 
20
20
  if TYPE_CHECKING:
@@ -2137,6 +2137,20 @@ class WellView:
2137
2137
  crossover_mask = left_values > right_values
2138
2138
  left_values = np.where(crossover_mask, right_values, left_values)
2139
2139
 
2140
+ # Create valid mask - skip points where boundary values are NaN
2141
+ boundary_valid_mask = ~(np.isnan(left_values) | np.isnan(right_values))
2142
+
2143
+ # Filter arrays to only valid points
2144
+ if not np.all(boundary_valid_mask):
2145
+ left_values = left_values[boundary_valid_mask]
2146
+ right_values = right_values[boundary_valid_mask]
2147
+ depth_for_fill = depth_for_fill[boundary_valid_mask]
2148
+ n_points = len(depth_for_fill)
2149
+
2150
+ if n_points < 2:
2151
+ # Not enough valid points to draw fill
2152
+ return
2153
+
2140
2154
  # Apply fill
2141
2155
  fill_color = fill.get("color", "lightblue")
2142
2156
  fill_alpha = fill.get("alpha", 0.3)
@@ -2171,6 +2185,10 @@ class WellView:
2171
2185
  warnings.warn("Cannot determine colormap values (no curve specified for left or right)")
2172
2186
  return
2173
2187
 
2188
+ # Apply same boundary mask to colormap values
2189
+ if not np.all(boundary_valid_mask):
2190
+ colormap_values = colormap_values[boundary_valid_mask]
2191
+
2174
2192
  # Get color range for normalization
2175
2193
  # Check if we have valid values
2176
2194
  valid_mask = ~np.isnan(colormap_values)
@@ -2179,7 +2197,17 @@ class WellView:
2179
2197
  return
2180
2198
 
2181
2199
  color_range = fill.get("color_range", [np.nanmin(colormap_values), np.nanmax(colormap_values)])
2182
- norm = Normalize(vmin=color_range[0], vmax=color_range[1])
2200
+ # Default color_log to track's log_scale setting
2201
+ color_log = fill.get("color_log", track_log_scale)
2202
+
2203
+ # Use LogNorm for log scale colormap, Normalize for linear
2204
+ if color_log:
2205
+ # Ensure positive values for log scale
2206
+ vmin = max(color_range[0], 1e-10)
2207
+ vmax = max(color_range[1], vmin * 10)
2208
+ norm = LogNorm(vmin=vmin, vmax=vmax)
2209
+ else:
2210
+ norm = Normalize(vmin=color_range[0], vmax=color_range[1])
2183
2211
  cmap = plt.get_cmap(cmap_name)
2184
2212
 
2185
2213
  # Create horizontal bands - each depth interval gets a color based on the curve value
@@ -2274,7 +2302,8 @@ class WellView:
2274
2302
  ax: plt.Axes,
2275
2303
  fill: dict,
2276
2304
  plotted_curves: dict,
2277
- depth: np.ndarray
2305
+ depth: np.ndarray,
2306
+ track_log_scale: bool = False
2278
2307
  ) -> None:
2279
2308
  """
2280
2309
  Add fill between curves or values.
@@ -2349,6 +2378,20 @@ class WellView:
2349
2378
  crossover_mask = left_values > right_values
2350
2379
  left_values = np.where(crossover_mask, right_values, left_values)
2351
2380
 
2381
+ # Create valid mask - skip points where boundary values are NaN
2382
+ boundary_valid_mask = ~(np.isnan(left_values) | np.isnan(right_values))
2383
+
2384
+ # Filter arrays to only valid points
2385
+ depth_for_fill = depth # Use local variable for consistency
2386
+ if not np.all(boundary_valid_mask):
2387
+ left_values = left_values[boundary_valid_mask]
2388
+ right_values = right_values[boundary_valid_mask]
2389
+ depth_for_fill = depth[boundary_valid_mask]
2390
+
2391
+ if len(depth_for_fill) < 2:
2392
+ # Not enough valid points to draw fill
2393
+ return
2394
+
2352
2395
  # Apply fill
2353
2396
  fill_color = fill.get("color", "lightblue")
2354
2397
  fill_alpha = fill.get("alpha", 0.3)
@@ -2383,6 +2426,10 @@ class WellView:
2383
2426
  warnings.warn("Cannot determine colormap values (no curve specified for left or right)")
2384
2427
  return
2385
2428
 
2429
+ # Apply same boundary mask to colormap values
2430
+ if not np.all(boundary_valid_mask):
2431
+ colormap_values = colormap_values[boundary_valid_mask]
2432
+
2386
2433
  # Get color range for normalization
2387
2434
  # Check if we have valid values
2388
2435
  valid_mask = ~np.isnan(colormap_values)
@@ -2391,12 +2438,22 @@ class WellView:
2391
2438
  return
2392
2439
 
2393
2440
  color_range = fill.get("color_range", [np.nanmin(colormap_values), np.nanmax(colormap_values)])
2394
- norm = Normalize(vmin=color_range[0], vmax=color_range[1])
2441
+ # Default color_log to track's log_scale setting
2442
+ color_log = fill.get("color_log", track_log_scale)
2443
+
2444
+ # Use LogNorm for log scale colormap, Normalize for linear
2445
+ if color_log:
2446
+ # Ensure positive values for log scale
2447
+ vmin = max(color_range[0], 1e-10)
2448
+ vmax = max(color_range[1], vmin * 10)
2449
+ norm = LogNorm(vmin=vmin, vmax=vmax)
2450
+ else:
2451
+ norm = Normalize(vmin=color_range[0], vmax=color_range[1])
2395
2452
  cmap = plt.get_cmap(cmap_name)
2396
2453
 
2397
2454
  # Create horizontal bands - each depth interval gets a color based on the curve value
2398
2455
  # Use PolyCollection for performance (1000x faster than loop with fill_betweenx)
2399
- n_intervals = len(depth) - 1
2456
+ n_intervals = len(depth_for_fill) - 1
2400
2457
 
2401
2458
  # Compute color values for each interval (average of adjacent points)
2402
2459
  color_values = (colormap_values[:-1] + colormap_values[1:]) / 2
@@ -2407,10 +2464,10 @@ class WellView:
2407
2464
  verts = []
2408
2465
  for i in range(n_intervals):
2409
2466
  verts.append([
2410
- (left_values[i], depth[i]),
2411
- (right_values[i], depth[i]),
2412
- (right_values[i+1], depth[i+1]),
2413
- (left_values[i+1], depth[i+1])
2467
+ (left_values[i], depth_for_fill[i]),
2468
+ (right_values[i], depth_for_fill[i]),
2469
+ (right_values[i+1], depth_for_fill[i+1]),
2470
+ (left_values[i+1], depth_for_fill[i+1])
2414
2471
  ])
2415
2472
 
2416
2473
  # Create PolyCollection with all polygons at once
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: well-log-toolkit
3
- Version: 0.1.139
3
+ Version: 0.1.141
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