well-log-toolkit 0.1.134__tar.gz → 0.1.136__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.134 → well_log_toolkit-0.1.136}/PKG-INFO +1 -1
  2. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/pyproject.toml +1 -1
  3. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit/regression.py +172 -1
  4. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit/visualization.py +177 -57
  5. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit.egg-info/PKG-INFO +1 -1
  6. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/README.md +0 -0
  7. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/setup.cfg +0 -0
  8. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit/__init__.py +0 -0
  9. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit/exceptions.py +0 -0
  10. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit/las_file.py +0 -0
  11. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit/manager.py +0 -0
  12. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit/operations.py +0 -0
  13. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit/property.py +0 -0
  14. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit/statistics.py +0 -0
  15. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit/utils.py +0 -0
  16. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit/well.py +0 -0
  17. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit.egg-info/SOURCES.txt +0 -0
  18. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit.egg-info/dependency_links.txt +0 -0
  19. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/well_log_toolkit.egg-info/requires.txt +0 -0
  20. {well_log_toolkit-0.1.134 → well_log_toolkit-0.1.136}/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.134
3
+ Version: 0.1.136
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.134"
7
+ version = "0.1.136"
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"
@@ -757,11 +757,182 @@ class PowerRegression(RegressionBase):
757
757
  return f"y = {self.a:.4f}*x^{self.b:.4f}"
758
758
 
759
759
 
760
+ class PolynomialExponentialRegression(RegressionBase):
761
+ """Polynomial-Exponential regression: y = 10^(a + b*x + c*x² + ... + n*x^degree)
762
+
763
+ This is an exponential function with a polynomial in the exponent.
764
+ Equivalent to: log₁₀(y) = a + b*x + c*x² + ... + n*x^degree
765
+
766
+ This form is particularly useful for petrophysical relationships like
767
+ porosity-permeability where data spans orders of magnitude and the
768
+ relationship has curvature in log-space.
769
+
770
+ Note: Only valid for positive y values.
771
+
772
+ Example:
773
+ >>> # Quadratic exponential (default degree=2)
774
+ >>> reg = PolynomialExponentialRegression(degree=2)
775
+ >>> reg.fit([0.1, 0.15, 0.2, 0.25], [0.1, 1.0, 10.0, 50.0])
776
+ >>> reg.predict([0.3])
777
+ array([150.])
778
+ >>> print(reg.equation())
779
+ y = 10^(-2.5694 + 25.2696*x - 21.0434*x²)
780
+
781
+ # Linear exponential (degree=1, same as exponential but base 10)
782
+ >>> reg = PolynomialExponentialRegression(degree=1)
783
+ >>> reg.fit(x, y)
784
+
785
+ # Lock specific coefficients
786
+ >>> reg = PolynomialExponentialRegression(degree=2, locked_params={'c0': 0.0})
787
+ >>> reg.fit(x, y) # Forces constant term to 0
788
+ """
789
+
790
+ def __init__(self, degree: int = 2, locked_params: Optional[Dict[str, float]] = None):
791
+ """Initialize polynomial-exponential regression.
792
+
793
+ Args:
794
+ degree: Polynomial degree in the exponent (default: 2 for quadratic)
795
+ locked_params: Dictionary to lock coefficients. Keys: 'c0', 'c1', ..., 'c{degree}'
796
+ where c0 is the constant term, c1 is the linear coefficient, etc.
797
+ """
798
+ super().__init__(locked_params)
799
+ if degree < 1:
800
+ raise ValueError("Polynomial degree must be at least 1")
801
+ self.degree = degree
802
+ self.coefficients: Optional[np.ndarray] = None
803
+
804
+ def fit(self, x: ArrayLike, y: ArrayLike) -> 'PolynomialExponentialRegression':
805
+ """Fit polynomial-exponential regression model.
806
+
807
+ Args:
808
+ x: Independent variable values
809
+ y: Dependent variable values (must be positive)
810
+
811
+ Returns:
812
+ Self for method chaining
813
+ """
814
+ x_clean, y_clean = self._prepare_data(x, y)
815
+
816
+ # Check for positive y values
817
+ if np.any(y_clean <= 0):
818
+ raise ValueError("Polynomial-Exponential regression requires all y values to be positive")
819
+
820
+ # Transform to polynomial: log₁₀(y) = a + b*x + c*x² + ...
821
+ log_y = np.log10(y_clean)
822
+
823
+ # Check for locked coefficients
824
+ locked_indices = {int(k[1:]): v for k, v in self._locked_params.items() if k.startswith('c')}
825
+
826
+ if not locked_indices:
827
+ # No locked coefficients - use standard polyfit
828
+ # Note: polyfit returns coefficients from highest to lowest degree
829
+ # We need to reverse to get [constant, linear, quadratic, ...]
830
+ self.coefficients = np.polyfit(x_clean, log_y, self.degree)[::-1]
831
+ else:
832
+ # Initialize coefficients array [c0, c1, c2, ...]
833
+ self.coefficients = np.zeros(self.degree + 1)
834
+
835
+ # Set locked coefficients
836
+ for idx, val in locked_indices.items():
837
+ if idx < 0 or idx > self.degree:
838
+ raise ValueError(f"Coefficient index c{idx} out of range for degree {self.degree}")
839
+ self.coefficients[idx] = val
840
+
841
+ # Subtract contribution of locked coefficients from log(y)
842
+ log_y_adjusted = log_y.copy()
843
+ for idx, val in locked_indices.items():
844
+ log_y_adjusted -= val * (x_clean ** idx)
845
+
846
+ # Fit only unlocked coefficients
847
+ unlocked_indices = [i for i in range(self.degree + 1) if i not in locked_indices]
848
+
849
+ if unlocked_indices:
850
+ # Build design matrix for unlocked terms
851
+ X = np.column_stack([x_clean ** i for i in unlocked_indices])
852
+
853
+ # Solve least squares
854
+ coefs_unlocked = np.linalg.lstsq(X, log_y_adjusted, rcond=None)[0]
855
+
856
+ # Assign unlocked coefficients
857
+ for i, idx in enumerate(unlocked_indices):
858
+ self.coefficients[idx] = coefs_unlocked[i]
859
+
860
+ # Calculate metrics (in log space for better R² with data spanning orders of magnitude)
861
+ log_y_pred = np.sum([self.coefficients[i] * (x_clean ** i) for i in range(self.degree + 1)], axis=0)
862
+ y_pred = 10 ** log_y_pred
863
+ self._calculate_metrics(x_clean, y_clean, y_pred, use_log_space=True)
864
+
865
+ self.fitted = True
866
+ return self
867
+
868
+ def predict(self, x: ArrayLike) -> np.ndarray:
869
+ """Predict y values using polynomial-exponential model.
870
+
871
+ Args:
872
+ x: Independent variable values
873
+
874
+ Returns:
875
+ Predicted y values
876
+ """
877
+ if not self.fitted:
878
+ raise ValueError("Model must be fitted before prediction. Call fit() first.")
879
+
880
+ x = np.asarray(x, dtype=float)
881
+
882
+ # Calculate polynomial in exponent
883
+ log_y = np.sum([self.coefficients[i] * (x ** i) for i in range(self.degree + 1)], axis=0)
884
+
885
+ # Return 10^(polynomial)
886
+ return 10 ** log_y
887
+
888
+ def equation(self) -> str:
889
+ """Return the polynomial-exponential equation as a string."""
890
+ if not self.fitted:
891
+ return "Model not fitted"
892
+
893
+ # Build polynomial terms
894
+ terms = []
895
+ for i, coef in enumerate(self.coefficients):
896
+ if abs(coef) < 1e-10: # Skip near-zero coefficients
897
+ continue
898
+
899
+ # Format coefficient
900
+ if not terms: # First term
901
+ coef_str = f"{coef:.4f}"
902
+ else:
903
+ sign = "+" if coef >= 0 else "-"
904
+ coef_str = f"{sign} {abs(coef):.4f}"
905
+
906
+ # Format power
907
+ if i == 0:
908
+ term = coef_str
909
+ elif i == 1:
910
+ term = f"{coef_str}*x"
911
+ elif i == 2:
912
+ term = f"{coef_str}*x²"
913
+ elif i == 3:
914
+ term = f"{coef_str}*x³"
915
+ else:
916
+ term = f"{coef_str}*x^{i}"
917
+
918
+ terms.append(term)
919
+
920
+ if not terms:
921
+ return "y = 10^(0)"
922
+
923
+ poly_str = "".join(terms).strip()
924
+ # Clean up leading plus sign
925
+ poly_str = poly_str.replace("+ -", "- ")
926
+
927
+ return f"y = 10^({poly_str})"
928
+
929
+
760
930
  __all__ = [
761
931
  'RegressionBase',
762
932
  'LinearRegression',
763
933
  'LogarithmicRegression',
764
934
  'ExponentialRegression',
765
935
  'PolynomialRegression',
766
- 'PowerRegression'
936
+ 'PowerRegression',
937
+ 'PolynomialExponentialRegression'
767
938
  ]
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
23
23
  # Import regression classes at module level for performance
24
24
  from .regression import (
25
25
  LinearRegression, LogarithmicRegression, ExponentialRegression,
26
- PolynomialRegression, PowerRegression
26
+ PolynomialRegression, PowerRegression, PolynomialExponentialRegression
27
27
  )
28
28
  from .exceptions import PropertyNotFoundError
29
29
 
@@ -38,30 +38,96 @@ def _create_regression(regression_type: str, **kwargs):
38
38
  """Factory function to create regression objects efficiently.
39
39
 
40
40
  Args:
41
- regression_type: Type of regression (linear, polynomial, etc.)
42
- **kwargs: Additional parameters (e.g., degree for polynomial)
41
+ regression_type: Type of regression with optional degree suffix
42
+ Examples: 'linear', 'polynomial', 'polynomial_3', 'exponential-polynomial_4'
43
+ **kwargs: Additional parameters (deprecated, use suffix notation instead)
43
44
 
44
45
  Returns:
45
46
  Regression object instance
46
47
  """
47
48
  regression_type = regression_type.lower()
49
+
50
+ # Parse degree suffix (e.g., polynomial_3 → degree=3)
51
+ degree = None
52
+ if "_" in regression_type:
53
+ parts = regression_type.split("_")
54
+ try:
55
+ degree = int(parts[-1])
56
+ regression_type = "_".join(parts[:-1]) # Remove degree suffix
57
+ except ValueError:
58
+ pass # Not a degree suffix, keep original
59
+
60
+ # Simple regression types
48
61
  if regression_type == "linear":
49
62
  return LinearRegression()
50
63
  elif regression_type == "logarithmic":
51
64
  return LogarithmicRegression()
52
65
  elif regression_type == "exponential":
53
66
  return ExponentialRegression()
54
- elif regression_type == "polynomial":
55
- degree = kwargs.get('degree', 2)
56
- return PolynomialRegression(degree=degree)
57
67
  elif regression_type == "power":
58
68
  return PowerRegression()
69
+
70
+ # Polynomial with degree
71
+ elif regression_type == "polynomial":
72
+ # Use suffix degree, then kwargs, then default
73
+ if degree is None:
74
+ degree = kwargs.get('degree', 2)
75
+ return PolynomialRegression(degree=degree)
76
+
77
+ # Exponential-polynomial (renamed from polynomial-exponential)
78
+ elif regression_type == "exponential-polynomial":
79
+ if degree is None:
80
+ degree = kwargs.get('degree', 2)
81
+ return PolynomialExponentialRegression(degree=degree)
82
+
83
+ # Backward compatibility: old name polynomial-exponential
84
+ elif regression_type == "polynomial-exponential":
85
+ import warnings
86
+ warnings.warn(
87
+ "'polynomial-exponential' is deprecated. Use 'exponential-polynomial' instead.",
88
+ DeprecationWarning,
89
+ stacklevel=3
90
+ )
91
+ if degree is None:
92
+ degree = kwargs.get('degree', 2)
93
+ return PolynomialExponentialRegression(degree=degree)
94
+
59
95
  else:
60
- raise ValueError(
61
- f"Unknown regression type: {regression_type}. "
62
- f"Choose from: linear, logarithmic, exponential, polynomial, power"
96
+ # Create detailed error message with equation formats
97
+ error_msg = (
98
+ f"Unknown regression type: '{regression_type}'. "
99
+ f"Available types:\n\n"
100
+ f" • linear: y = a*x + b\n"
101
+ f" • logarithmic: y = a*ln(x) + b\n"
102
+ f" • exponential: y = a*e^(b*x)\n"
103
+ f" • polynomial: y = a + b*x + c*x² (default: degree=2)\n"
104
+ f" - polynomial_1: y = a + b*x (1st degree)\n"
105
+ f" - polynomial_3: y = a + b*x + c*x² + d*x³ (3rd degree)\n"
106
+ f" • exponential-polynomial: y = 10^(a + b*x + c*x²) (default: degree=2)\n"
107
+ f" - exponential-polynomial_1: y = 10^(a + b*x) (1st degree)\n"
108
+ f" - exponential-polynomial_3: y = 10^(a + b*x + c*x² + d*x³) (3rd degree)\n"
109
+ f" • power: y = a*x^b\n\n"
110
+ f"Did you mean one of these?"
63
111
  )
64
112
 
113
+ # Check for common typos
114
+ suggestions = []
115
+ if "expo" in regression_type or "exp" in regression_type:
116
+ suggestions.append("exponential or exponential-polynomial")
117
+ if "poly" in regression_type:
118
+ suggestions.append("polynomial or exponential-polynomial")
119
+ if "log" in regression_type:
120
+ suggestions.append("logarithmic")
121
+ if "lin" in regression_type:
122
+ suggestions.append("linear")
123
+ if "pow" in regression_type:
124
+ suggestions.append("power")
125
+
126
+ if suggestions:
127
+ error_msg += f"\n Possible matches: {', '.join(suggestions)}"
128
+
129
+ raise ValueError(error_msg)
130
+
65
131
 
66
132
  def _downsample_for_plotting(depth: np.ndarray, values: np.ndarray, max_points: int = 2000) -> tuple[np.ndarray, np.ndarray]:
67
133
  """
@@ -3475,22 +3541,48 @@ class Crossplot:
3475
3541
  Returns:
3476
3542
  Tuple of (segment_number, matplotlib_location_string)
3477
3543
  """
3478
- # Get x and y bounds
3544
+ # Get x and y data values
3479
3545
  x_vals = data['x'].values
3480
3546
  y_vals = data['y'].values
3481
3547
 
3482
- # Handle log scales for binning
3548
+ # Get axes limits to convert data coordinates to axes-normalized coordinates (0-1)
3549
+ # This ensures we're dividing the GRAPH AREA, not the data space
3550
+ if self.ax is not None:
3551
+ x_lim = self.ax.get_xlim()
3552
+ y_lim = self.ax.get_ylim()
3553
+ else:
3554
+ # Fallback if ax not available yet
3555
+ x_lim = (np.nanmin(x_vals), np.nanmax(x_vals))
3556
+ y_lim = (np.nanmin(y_vals), np.nanmax(y_vals))
3557
+
3558
+ # Handle logarithmic axes - transform to log space for proper visual segment calculation
3559
+ # On log axes, equal visual spacing corresponds to equal ratios, not equal differences
3483
3560
  if self.x_log:
3484
- x_vals = np.log10(x_vals[x_vals > 0])
3561
+ # Filter out non-positive values before log transform
3562
+ x_valid = x_vals > 0
3563
+ x_vals_transformed = np.where(x_valid, np.log10(x_vals), np.nan)
3564
+ x_lim_transformed = (np.log10(max(x_lim[0], 1e-10)), np.log10(max(x_lim[1], 1e-10)))
3565
+ else:
3566
+ x_vals_transformed = x_vals
3567
+ x_lim_transformed = x_lim
3568
+
3485
3569
  if self.y_log:
3486
- y_vals = np.log10(y_vals[y_vals > 0])
3570
+ # Filter out non-positive values before log transform
3571
+ y_valid = y_vals > 0
3572
+ y_vals_transformed = np.where(y_valid, np.log10(y_vals), np.nan)
3573
+ y_lim_transformed = (np.log10(max(y_lim[0], 1e-10)), np.log10(max(y_lim[1], 1e-10)))
3574
+ else:
3575
+ y_vals_transformed = y_vals
3576
+ y_lim_transformed = y_lim
3487
3577
 
3488
- x_min, x_max = np.nanmin(x_vals), np.nanmax(x_vals)
3489
- y_min, y_max = np.nanmin(y_vals), np.nanmax(y_vals)
3578
+ # Normalize transformed coordinates to axes coordinates (0-1)
3579
+ # This divides the visible graph area properly, accounting for log scales
3580
+ x_norm = (x_vals_transformed - x_lim_transformed[0]) / (x_lim_transformed[1] - x_lim_transformed[0])
3581
+ y_norm = (y_vals_transformed - y_lim_transformed[0]) / (y_lim_transformed[1] - y_lim_transformed[0])
3490
3582
 
3491
- # Create 3x3 grid and count points in each square
3492
- x_bins = np.linspace(x_min, x_max, 4)
3493
- y_bins = np.linspace(y_min, y_max, 4)
3583
+ # Create 3x3 grid in axes-normalized space (0-1)
3584
+ x_bins = np.linspace(0, 1, 4)
3585
+ y_bins = np.linspace(0, 1, 4)
3494
3586
 
3495
3587
  # Map segments 1-9 to grid positions (i, j)
3496
3588
  # Segment numbering:
@@ -3522,12 +3614,12 @@ class Crossplot:
3522
3614
  9: 'lower right',
3523
3615
  }
3524
3616
 
3525
- # Count points in each segment
3526
- total_points = len(x_vals)
3617
+ # Count points in each segment using normalized coordinates
3618
+ total_points = len(x_norm)
3527
3619
  segment_counts = {}
3528
3620
  for segment, (i, j) in segment_to_grid.items():
3529
- x_mask = (x_vals >= x_bins[i]) & (x_vals < x_bins[i+1])
3530
- y_mask = (y_vals >= y_bins[j]) & (y_vals < y_bins[j+1])
3621
+ x_mask = (x_norm >= x_bins[i]) & (x_norm < x_bins[i+1])
3622
+ y_mask = (y_norm >= y_bins[j]) & (y_norm < y_bins[j+1])
3531
3623
  count = np.sum(x_mask & y_mask)
3532
3624
  segment_counts[segment] = count
3533
3625
 
@@ -3638,20 +3730,24 @@ class Crossplot:
3638
3730
  is_edge = self._is_edge_location(location)
3639
3731
 
3640
3732
  # Determine base anchor point from location string
3641
- # Map location to (x, y) coordinates in figure space
3733
+ # Map location to (x, y) coordinates in AXES space (0-1 within the graph area)
3734
+ # These match the segment corners:
3735
+ # Segment 1=upper left (0,1), 2=upper center (0.5,1), 3=upper right (1,1)
3736
+ # Segment 4=center left (0,0.5), 5=center (0.5,0.5), 6=center right (1,0.5)
3737
+ # Segment 7=lower left (0,0), 8=lower center (0.5,0), 9=lower right (1,0)
3642
3738
  anchor_map = {
3643
- 'upper left': (0.02, 0.98),
3644
- 'upper center': (0.5, 0.98),
3645
- 'upper right': (0.98, 0.98),
3646
- 'center left': (0.02, 0.5),
3739
+ 'upper left': (0, 1),
3740
+ 'upper center': (0.5, 1),
3741
+ 'upper right': (1, 1),
3742
+ 'center left': (0, 0.5),
3647
3743
  'center': (0.5, 0.5),
3648
- 'center right': (0.98, 0.5),
3649
- 'lower left': (0.02, 0.02),
3650
- 'lower center': (0.5, 0.02),
3651
- 'lower right': (0.98, 0.02),
3744
+ 'center right': (1, 0.5),
3745
+ 'lower left': (0, 0),
3746
+ 'lower center': (0.5, 0),
3747
+ 'lower right': (1, 0),
3652
3748
  }
3653
3749
 
3654
- base_x, base_y = anchor_map.get(location, (0.98, 0.98))
3750
+ base_x, base_y = anchor_map.get(location, (1, 1))
3655
3751
 
3656
3752
  if is_edge:
3657
3753
  # Stack vertically on edges
@@ -3664,23 +3760,23 @@ class Crossplot:
3664
3760
  framealpha=0.9,
3665
3761
  edgecolor='black',
3666
3762
  bbox_to_anchor=(base_x, base_y),
3667
- bbox_transform=self.fig.transFigure
3763
+ bbox_transform=self.ax.transAxes
3668
3764
  )
3669
3765
  shape_legend.get_title().set_fontweight('bold')
3670
3766
  shape_legend.set_clip_on(False) # Prevent clipping outside axes
3671
3767
  self.ax.add_artist(shape_legend)
3672
3768
 
3673
3769
  # Calculate offset for color legend below shape legend
3674
- # Estimate shape legend height and add spacing
3675
- shape_height = len(shape_handles) * 0.025 + 0.05 # Rough estimate
3770
+ # Estimate shape legend height in axes coordinates
3771
+ shape_height = len(shape_handles) * 0.05 + 0.08 # Adjusted for axes space
3676
3772
 
3677
3773
  # Adjust y position for color legend
3678
3774
  if 'upper' in location:
3679
- color_y = base_y - shape_height - 0.02 # Stack below
3775
+ color_y = base_y - shape_height # Stack below
3680
3776
  elif 'lower' in location:
3681
- color_y = base_y + shape_height + 0.02 # Stack above
3777
+ color_y = base_y + shape_height # Stack above
3682
3778
  else: # center
3683
- color_y = base_y - shape_height / 2 - 0.01 # Stack below
3779
+ color_y = base_y - shape_height / 2 # Stack below
3684
3780
 
3685
3781
  color_legend = self.ax.legend(
3686
3782
  handles=color_handles,
@@ -3690,19 +3786,19 @@ class Crossplot:
3690
3786
  framealpha=0.9,
3691
3787
  edgecolor='black',
3692
3788
  bbox_to_anchor=(base_x, color_y),
3693
- bbox_transform=self.fig.transFigure
3789
+ bbox_transform=self.ax.transAxes
3694
3790
  )
3695
3791
  color_legend.get_title().set_fontweight('bold')
3696
3792
  color_legend.set_clip_on(False) # Prevent clipping outside axes
3697
3793
  else:
3698
3794
  # Place side by side for non-edge locations (top, bottom, center)
3699
- # Estimate width of each legend
3700
- legend_width = 0.15
3795
+ # Estimate width of each legend in axes coordinates
3796
+ legend_width = 0.20
3701
3797
 
3702
3798
  if 'center' in location and location != 'center left' and location != 'center right':
3703
3799
  # For center positions, place them side by side
3704
- shape_x = base_x - legend_width / 2 - 0.01
3705
- color_x = base_x + legend_width / 2 + 0.01
3800
+ shape_x = base_x - legend_width / 2
3801
+ color_x = base_x + legend_width / 2
3706
3802
 
3707
3803
  shape_legend = self.ax.legend(
3708
3804
  handles=shape_handles,
@@ -3712,7 +3808,7 @@ class Crossplot:
3712
3808
  framealpha=0.9,
3713
3809
  edgecolor='black',
3714
3810
  bbox_to_anchor=(shape_x, base_y),
3715
- bbox_transform=self.fig.transFigure
3811
+ bbox_transform=self.ax.transAxes
3716
3812
  )
3717
3813
  shape_legend.get_title().set_fontweight('bold')
3718
3814
  shape_legend.set_clip_on(False) # Prevent clipping outside axes
@@ -3726,7 +3822,7 @@ class Crossplot:
3726
3822
  framealpha=0.9,
3727
3823
  edgecolor='black',
3728
3824
  bbox_to_anchor=(color_x, base_y),
3729
- bbox_transform=self.fig.transFigure
3825
+ bbox_transform=self.ax.transAxes
3730
3826
  )
3731
3827
  color_legend.get_title().set_fontweight('bold')
3732
3828
  color_legend.set_clip_on(False) # Prevent clipping outside axes
@@ -3738,18 +3834,20 @@ class Crossplot:
3738
3834
  loc=location,
3739
3835
  frameon=True,
3740
3836
  framealpha=0.9,
3741
- edgecolor='black'
3837
+ edgecolor='black',
3838
+ bbox_to_anchor=(base_x, base_y),
3839
+ bbox_transform=self.ax.transAxes
3742
3840
  )
3743
3841
  shape_legend.get_title().set_fontweight('bold')
3744
3842
  shape_legend.set_clip_on(False) # Prevent clipping outside axes
3745
3843
  self.ax.add_artist(shape_legend)
3746
3844
 
3747
- # Estimate offset
3748
- shape_height = len(shape_handles) * 0.025 + 0.05
3845
+ # Estimate offset in axes coordinates
3846
+ shape_height = len(shape_handles) * 0.05 + 0.08
3749
3847
  if 'upper' in location:
3750
- color_y = base_y - shape_height - 0.02
3848
+ color_y = base_y - shape_height
3751
3849
  else:
3752
- color_y = base_y + shape_height + 0.02
3850
+ color_y = base_y + shape_height
3753
3851
 
3754
3852
  color_legend = self.ax.legend(
3755
3853
  handles=color_handles,
@@ -3759,7 +3857,7 @@ class Crossplot:
3759
3857
  framealpha=0.9,
3760
3858
  edgecolor='black',
3761
3859
  bbox_to_anchor=(base_x, color_y),
3762
- bbox_transform=self.fig.transFigure
3860
+ bbox_transform=self.ax.transAxes
3763
3861
  )
3764
3862
  color_legend.get_title().set_fontweight('bold')
3765
3863
  color_legend.set_clip_on(False) # Prevent clipping outside axes
@@ -3837,14 +3935,32 @@ class Crossplot:
3837
3935
  secondary_loc = 'lower right'
3838
3936
 
3839
3937
  # Determine descriptive title based on regression type
3938
+ # Extract the regression type and add it to the title
3939
+ reg_type_str = None
3840
3940
  if self.regression_by_color_and_shape:
3841
- regression_title = 'Regressions by color and shape'
3941
+ base_title = 'Regressions by color and shape'
3942
+ config = self._parse_regression_config(self.regression_by_color_and_shape)
3943
+ reg_type_str = config.get('type', None)
3842
3944
  elif self.regression_by_color:
3843
- regression_title = 'Regressions by color'
3945
+ base_title = 'Regressions by color'
3946
+ config = self._parse_regression_config(self.regression_by_color)
3947
+ reg_type_str = config.get('type', None)
3844
3948
  elif self.regression_by_group:
3845
- regression_title = 'Regressions by group'
3949
+ base_title = 'Regressions by group'
3950
+ config = self._parse_regression_config(self.regression_by_group)
3951
+ reg_type_str = config.get('type', None)
3846
3952
  else:
3847
- regression_title = 'Regressions'
3953
+ base_title = 'Regressions'
3954
+ if self.regression:
3955
+ config = self._parse_regression_config(self.regression)
3956
+ reg_type_str = config.get('type', None)
3957
+
3958
+ # Add regression type to title (e.g., "Regressions by color - Power")
3959
+ if reg_type_str:
3960
+ reg_type_display = reg_type_str.capitalize()
3961
+ regression_title = f"{base_title} - {reg_type_display}"
3962
+ else:
3963
+ regression_title = base_title
3848
3964
 
3849
3965
  # Import legend from matplotlib
3850
3966
  from matplotlib.legend import Legend
@@ -4284,11 +4400,15 @@ class Crossplot:
4284
4400
  if self.y_log:
4285
4401
  self.ax.set_yscale('log')
4286
4402
 
4287
- # Disable scientific notation on axes
4403
+ # Disable scientific notation on linear axes only
4404
+ # (log axes use matplotlib's default log formatter for proper log scale labels)
4288
4405
  from matplotlib.ticker import ScalarFormatter
4289
4406
  formatter = ScalarFormatter(useOffset=False)
4290
4407
  formatter.set_scientific(False)
4291
- self.ax.yaxis.set_major_formatter(formatter)
4408
+
4409
+ # Only apply to linear axes - log axes need their default formatter
4410
+ if not self.y_log:
4411
+ self.ax.yaxis.set_major_formatter(formatter)
4292
4412
  if not self.x_log:
4293
4413
  self.ax.xaxis.set_major_formatter(formatter)
4294
4414
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: well-log-toolkit
3
- Version: 0.1.134
3
+ Version: 0.1.136
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