well-log-toolkit 0.1.131__py3-none-any.whl → 0.1.132__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.
@@ -642,7 +642,7 @@ class Property(PropertyOperationsMixin):
642
642
 
643
643
  def resample(self, target_depth: Union[np.ndarray, 'Property']) -> 'Property':
644
644
  """
645
- Resample property to a new depth grid using linear interpolation.
645
+ Resample property to a new depth grid using appropriate interpolation.
646
646
 
647
647
  This method creates a new Property object with values interpolated to match
648
648
  the target depth grid. This is required when combining properties with
@@ -663,7 +663,10 @@ class Property(PropertyOperationsMixin):
663
663
  Notes
664
664
  -----
665
665
  - Uses linear interpolation for continuous data
666
- - Uses nearest-neighbor interpolation for discrete data
666
+ - Uses forward-fill (previous) for discrete data - geological zones extend
667
+ from their top/boundary until the next boundary is encountered. For example,
668
+ "Cerisa West top" at 2929.93m remains active until "Cerisa West SST 1 top"
669
+ at 2955.10m is intercepted.
667
670
  - Values outside the original depth range are set to NaN
668
671
  - NaN values in original data are excluded from interpolation
669
672
 
@@ -728,7 +731,11 @@ class Property(PropertyOperationsMixin):
728
731
 
729
732
  # Choose interpolation method based on type
730
733
  if self.type == 'discrete':
731
- kind = 'nearest'
734
+ # Use 'previous' (forward-fill) for discrete properties
735
+ # This ensures geological zones extend from their top/boundary
736
+ # until the next top is encountered (e.g., "Cerisa West top" at 2929.93
737
+ # remains active until "Cerisa West SST 1 top" at 2955.10)
738
+ kind = 'previous'
732
739
  else:
733
740
  kind = 'linear'
734
741
 
@@ -3072,6 +3072,10 @@ class Crossplot:
3072
3072
  # Data cache
3073
3073
  self._data = None
3074
3074
 
3075
+ # Discrete property labels storage
3076
+ # Maps property role ('shape', 'color', 'size') to labels dict {0: 'label0', 1: 'label1', ...}
3077
+ self._discrete_labels = {}
3078
+
3075
3079
  def add_layer(self, x: str, y: str, label: str):
3076
3080
  """
3077
3081
  Add a new data layer to the crossplot.
@@ -3136,6 +3140,32 @@ class Crossplot:
3136
3140
  # Only do expensive allclose if needed
3137
3141
  return not np.allclose(prop_depth, ref_depth)
3138
3142
 
3143
+ # Helper function to align property values to target depth grid
3144
+ def align_property(prop, target_depth):
3145
+ """
3146
+ Align property values to target depth grid.
3147
+
3148
+ Uses appropriate interpolation based on property type:
3149
+ - Discrete properties: forward-fill/previous (geological zones extend from
3150
+ their top/boundary until the next boundary is encountered)
3151
+ - Continuous properties: linear interpolation
3152
+
3153
+ Args:
3154
+ prop: Property object to align
3155
+ target_depth: Target depth array
3156
+
3157
+ Returns:
3158
+ Aligned values array
3159
+ """
3160
+ if prop.type == 'discrete':
3161
+ # Use Property's resample method which handles discrete properties correctly
3162
+ # (forward-fill to preserve integer codes and geological zone logic)
3163
+ resampled = prop.resample(target_depth)
3164
+ return resampled.values
3165
+ else:
3166
+ # For continuous properties, use linear interpolation
3167
+ return np.interp(target_depth, prop.depth, prop.values, left=np.nan, right=np.nan)
3168
+
3139
3169
  # Loop through each layer
3140
3170
  for layer in self._layers:
3141
3171
  layer_x = layer['x']
@@ -3153,9 +3183,9 @@ class Crossplot:
3153
3183
  x_values = x_prop.values
3154
3184
  y_values = y_prop.values
3155
3185
 
3156
- # Align y values to x depth grid if needed
3186
+ # Align y values to x depth grid if needed using appropriate method
3157
3187
  if needs_alignment(y_prop.depth, depths):
3158
- y_values = np.interp(depths, y_prop.depth, y_prop.values, left=np.nan, right=np.nan)
3188
+ y_values = align_property(y_prop, depths)
3159
3189
 
3160
3190
  # Create dataframe for this well and layer
3161
3191
  df = pd.DataFrame({
@@ -3176,10 +3206,14 @@ class Crossplot:
3176
3206
  elif self.color and self.color != "depth":
3177
3207
  try:
3178
3208
  color_prop = well.get_property(self.color)
3179
- color_values = color_prop.values
3180
- # Align to x depth grid
3209
+ # Store labels if discrete property (only once)
3210
+ if 'color' not in self._discrete_labels:
3211
+ self._store_discrete_labels(color_prop, 'color')
3212
+ # Align to x depth grid using appropriate method for property type
3181
3213
  if needs_alignment(color_prop.depth, depths):
3182
- color_values = np.interp(depths, color_prop.depth, color_prop.values, left=np.nan, right=np.nan)
3214
+ color_values = align_property(color_prop, depths)
3215
+ else:
3216
+ color_values = color_prop.values
3183
3217
  df['color_val'] = color_values
3184
3218
  except (AttributeError, KeyError, PropertyNotFoundError):
3185
3219
  # Silently use depth as fallback
@@ -3194,10 +3228,14 @@ class Crossplot:
3194
3228
  elif self.size:
3195
3229
  try:
3196
3230
  size_prop = well.get_property(self.size)
3197
- size_values = size_prop.values
3198
- # Align to x depth grid
3231
+ # Store labels if discrete property (only once)
3232
+ if 'size' not in self._discrete_labels:
3233
+ self._store_discrete_labels(size_prop, 'size')
3234
+ # Align to x depth grid using appropriate method for property type
3199
3235
  if needs_alignment(size_prop.depth, depths):
3200
- size_values = np.interp(depths, size_prop.depth, size_prop.values, left=np.nan, right=np.nan)
3236
+ size_values = align_property(size_prop, depths)
3237
+ else:
3238
+ size_values = size_prop.values
3201
3239
  df['size_val'] = size_values
3202
3240
  except (AttributeError, KeyError, PropertyNotFoundError):
3203
3241
  # Silently skip if size property not found
@@ -3210,10 +3248,14 @@ class Crossplot:
3210
3248
  elif self.shape and self.shape != "well":
3211
3249
  try:
3212
3250
  shape_prop = well.get_property(self.shape)
3213
- shape_values = shape_prop.values
3214
- # Align to x depth grid
3251
+ # Store labels if discrete property (only once)
3252
+ if 'shape' not in self._discrete_labels:
3253
+ self._store_discrete_labels(shape_prop, 'shape')
3254
+ # Align to x depth grid using appropriate method for property type
3215
3255
  if needs_alignment(shape_prop.depth, depths):
3216
- shape_values = np.interp(depths, shape_prop.depth, shape_prop.values, left=np.nan, right=np.nan)
3256
+ shape_values = align_property(shape_prop, depths)
3257
+ else:
3258
+ shape_values = shape_prop.values
3217
3259
  df['shape_val'] = shape_values
3218
3260
  except (AttributeError, KeyError, PropertyNotFoundError):
3219
3261
  # Silently skip if shape property not found
@@ -3377,6 +3419,201 @@ class Crossplot:
3377
3419
 
3378
3420
  return best_pos, second_best_pos
3379
3421
 
3422
+ def _store_discrete_labels(self, prop, role: str) -> None:
3423
+ """
3424
+ Store labels from a discrete property for later use in legends.
3425
+
3426
+ Args:
3427
+ prop: Property object (must have type and labels attributes)
3428
+ role: Property role - 'shape', 'color', or 'size'
3429
+ """
3430
+ if hasattr(prop, 'type') and prop.type == 'discrete' and hasattr(prop, 'labels') and prop.labels:
3431
+ self._discrete_labels[role] = prop.labels.copy()
3432
+
3433
+ def _get_display_label(self, value, role: str) -> str:
3434
+ """
3435
+ Get display label for a value, using stored labels for discrete properties.
3436
+
3437
+ For discrete properties with labels, converts integer codes to readable names.
3438
+ For continuous properties or discrete without labels, returns string value.
3439
+
3440
+ Args:
3441
+ value: The value to get label for (could be int, float, or string)
3442
+ role: Property role - 'shape', 'color', or 'size'
3443
+
3444
+ Returns:
3445
+ Display label string
3446
+
3447
+ Examples:
3448
+ >>> # For discrete property with labels {0: 'Agat top', 1: 'Cerisa Main top'}
3449
+ >>> self._get_display_label(0.0, 'shape')
3450
+ 'Agat top'
3451
+
3452
+ >>> # For continuous property or no labels
3453
+ >>> self._get_display_label(2.5, 'color')
3454
+ '2.5'
3455
+ """
3456
+ if role in self._discrete_labels:
3457
+ # Try to convert to integer and look up label
3458
+ try:
3459
+ int_val = int(np.round(float(value)))
3460
+ return self._discrete_labels[role].get(int_val, str(value))
3461
+ except (ValueError, TypeError):
3462
+ return str(value)
3463
+ return str(value)
3464
+
3465
+ def _is_edge_location(self, location: str) -> bool:
3466
+ """Check if a legend location is on the left or right edge.
3467
+
3468
+ Args:
3469
+ location: Matplotlib location string
3470
+
3471
+ Returns:
3472
+ True if on left or right edge (for vertical stacking)
3473
+ """
3474
+ edge_locations = ['upper left', 'center left', 'lower left',
3475
+ 'upper right', 'center right', 'lower right']
3476
+ return location in edge_locations
3477
+
3478
+ def _create_grouped_legends(self,
3479
+ shape_handles, shape_title: str,
3480
+ color_handles, color_title: str,
3481
+ location: str) -> None:
3482
+ """Create grouped legends in the same region, stacked or side-by-side.
3483
+
3484
+ When both shape and color legends are needed, this groups them in the same
3485
+ 1/9th section without overlap. Stacks vertically on edges, side-by-side elsewhere.
3486
+
3487
+ Args:
3488
+ shape_handles: List of handles for shape legend
3489
+ shape_title: Title for shape legend
3490
+ color_handles: List of handles for color legend
3491
+ color_title: Title for color legend
3492
+ location: Matplotlib location string for positioning
3493
+ """
3494
+ is_edge = self._is_edge_location(location)
3495
+
3496
+ # Determine base anchor point from location string
3497
+ # Map location to (x, y) coordinates in figure space
3498
+ anchor_map = {
3499
+ 'upper left': (0.02, 0.98),
3500
+ 'upper center': (0.5, 0.98),
3501
+ 'upper right': (0.98, 0.98),
3502
+ 'center left': (0.02, 0.5),
3503
+ 'center': (0.5, 0.5),
3504
+ 'center right': (0.98, 0.5),
3505
+ 'lower left': (0.02, 0.02),
3506
+ 'lower center': (0.5, 0.02),
3507
+ 'lower right': (0.98, 0.02),
3508
+ }
3509
+
3510
+ base_x, base_y = anchor_map.get(location, (0.98, 0.98))
3511
+
3512
+ if is_edge:
3513
+ # Stack vertically on edges
3514
+ # Position shape legend at the top
3515
+ shape_legend = self.ax.legend(
3516
+ handles=shape_handles,
3517
+ title=shape_title,
3518
+ loc=location,
3519
+ frameon=True,
3520
+ framealpha=0.9,
3521
+ edgecolor='black',
3522
+ bbox_to_anchor=(base_x, base_y),
3523
+ bbox_transform=self.fig.transFigure
3524
+ )
3525
+ shape_legend.get_title().set_fontweight('bold')
3526
+ self.ax.add_artist(shape_legend)
3527
+
3528
+ # Calculate offset for color legend below shape legend
3529
+ # Estimate shape legend height and add spacing
3530
+ shape_height = len(shape_handles) * 0.025 + 0.05 # Rough estimate
3531
+
3532
+ # Adjust y position for color legend
3533
+ if 'upper' in location:
3534
+ color_y = base_y - shape_height - 0.02 # Stack below
3535
+ elif 'lower' in location:
3536
+ color_y = base_y + shape_height + 0.02 # Stack above
3537
+ else: # center
3538
+ color_y = base_y - shape_height / 2 - 0.01 # Stack below
3539
+
3540
+ color_legend = self.ax.legend(
3541
+ handles=color_handles,
3542
+ title=color_title,
3543
+ loc=location,
3544
+ frameon=True,
3545
+ framealpha=0.9,
3546
+ edgecolor='black',
3547
+ bbox_to_anchor=(base_x, color_y),
3548
+ bbox_transform=self.fig.transFigure
3549
+ )
3550
+ color_legend.get_title().set_fontweight('bold')
3551
+ else:
3552
+ # Place side by side for non-edge locations (top, bottom, center)
3553
+ # Estimate width of each legend
3554
+ legend_width = 0.15
3555
+
3556
+ if 'center' in location and location != 'center left' and location != 'center right':
3557
+ # For center positions, place them side by side
3558
+ shape_x = base_x - legend_width / 2 - 0.01
3559
+ color_x = base_x + legend_width / 2 + 0.01
3560
+
3561
+ shape_legend = self.ax.legend(
3562
+ handles=shape_handles,
3563
+ title=shape_title,
3564
+ loc='center',
3565
+ frameon=True,
3566
+ framealpha=0.9,
3567
+ edgecolor='black',
3568
+ bbox_to_anchor=(shape_x, base_y),
3569
+ bbox_transform=self.fig.transFigure
3570
+ )
3571
+ shape_legend.get_title().set_fontweight('bold')
3572
+ self.ax.add_artist(shape_legend)
3573
+
3574
+ color_legend = self.ax.legend(
3575
+ handles=color_handles,
3576
+ title=color_title,
3577
+ loc='center',
3578
+ frameon=True,
3579
+ framealpha=0.9,
3580
+ edgecolor='black',
3581
+ bbox_to_anchor=(color_x, base_y),
3582
+ bbox_transform=self.fig.transFigure
3583
+ )
3584
+ color_legend.get_title().set_fontweight('bold')
3585
+ else:
3586
+ # For other positions, fall back to stacking
3587
+ shape_legend = self.ax.legend(
3588
+ handles=shape_handles,
3589
+ title=shape_title,
3590
+ loc=location,
3591
+ frameon=True,
3592
+ framealpha=0.9,
3593
+ edgecolor='black'
3594
+ )
3595
+ shape_legend.get_title().set_fontweight('bold')
3596
+ self.ax.add_artist(shape_legend)
3597
+
3598
+ # Estimate offset
3599
+ shape_height = len(shape_handles) * 0.025 + 0.05
3600
+ if 'upper' in location:
3601
+ color_y = base_y - shape_height - 0.02
3602
+ else:
3603
+ color_y = base_y + shape_height + 0.02
3604
+
3605
+ color_legend = self.ax.legend(
3606
+ handles=color_handles,
3607
+ title=color_title,
3608
+ loc=location,
3609
+ frameon=True,
3610
+ framealpha=0.9,
3611
+ edgecolor='black',
3612
+ bbox_to_anchor=(base_x, color_y),
3613
+ bbox_transform=self.fig.transFigure
3614
+ )
3615
+ color_legend.get_title().set_fontweight('bold')
3616
+
3380
3617
  def _format_regression_label(self, name: str, reg, include_equation: bool = None, include_r2: bool = None) -> str:
3381
3618
  """Format a modern, compact regression label.
3382
3619
 
@@ -3898,7 +4135,7 @@ class Crossplot:
3898
4135
  # Create custom legend handles
3899
4136
  legend_elements = [Patch(facecolor=category_colors[cat],
3900
4137
  edgecolor=self.edge_color,
3901
- label=str(cat))
4138
+ label=self._get_display_label(cat, 'color'))
3902
4139
  for cat in unique_categories]
3903
4140
 
3904
4141
  colorbar_label = self.color if self.color != "depth" and self.color != "label" else "Category"
@@ -4008,20 +4245,53 @@ class Crossplot:
4008
4245
  edgecolors=self.edge_color,
4009
4246
  linewidths=self.edge_width,
4010
4247
  marker=marker,
4011
- label=str(group_name)
4248
+ label=self._get_display_label(group_name, 'shape')
4012
4249
  )
4013
4250
 
4014
4251
  if first_scatter is None and self.color and not is_categorical:
4015
4252
  first_scatter = scatter
4016
4253
 
4017
- # Add legend with smart placement
4018
- if self.show_legend:
4254
+ # Check if we need both shape and color legends (grouped layout)
4255
+ need_shape_legend = self.show_legend
4256
+ need_color_legend = self.color and is_categorical and self.show_legend
4257
+
4258
+ if need_shape_legend and need_color_legend:
4259
+ # Create grouped legends in the same region
4019
4260
  # Get best location based on data density
4020
4261
  if self._data is not None:
4021
4262
  primary_loc, _ = self._find_best_legend_locations(self._data)
4022
4263
  else:
4023
4264
  primary_loc = 'best'
4024
4265
 
4266
+ # Prepare shape legend handles (from scatter plots)
4267
+ shape_handles, _ = self.ax.get_legend_handles_labels()
4268
+
4269
+ # Prepare color legend handles
4270
+ c_vals_all = data['color_val'].values
4271
+ unique_categories = pd.Series(c_vals_all).dropna().unique()
4272
+ color_handles = [Patch(facecolor=category_colors[cat],
4273
+ edgecolor=self.edge_color,
4274
+ label=self._get_display_label(cat, 'color'))
4275
+ for cat in unique_categories]
4276
+
4277
+ colorbar_label = self.color if self.color != "depth" and self.color != "label" else "Category"
4278
+
4279
+ # Create grouped legends
4280
+ self._create_grouped_legends(
4281
+ shape_handles=shape_handles,
4282
+ shape_title=group_label,
4283
+ color_handles=color_handles,
4284
+ color_title=colorbar_label,
4285
+ location=primary_loc
4286
+ )
4287
+
4288
+ elif need_shape_legend:
4289
+ # Only shape legend needed
4290
+ if self._data is not None:
4291
+ primary_loc, _ = self._find_best_legend_locations(self._data)
4292
+ else:
4293
+ primary_loc = 'best'
4294
+
4025
4295
  legend = self.ax.legend(
4026
4296
  title=group_label,
4027
4297
  loc=primary_loc,
@@ -4030,43 +4300,15 @@ class Crossplot:
4030
4300
  edgecolor='black'
4031
4301
  )
4032
4302
  legend.get_title().set_fontweight('bold')
4033
-
4034
4303
  # Store the primary legend so it persists when regression legend is added
4035
4304
  self.ax.add_artist(legend)
4036
4305
 
4037
- # Add colorbar or color legend based on color type
4038
- if self.color:
4039
- if is_categorical and self.show_legend:
4040
- # Create separate legend for categorical colors
4041
- c_vals_all = data['color_val'].values
4042
- unique_categories = pd.Series(c_vals_all).dropna().unique()
4043
-
4044
- # Create custom legend handles
4045
- legend_elements = [Patch(facecolor=category_colors[cat],
4046
- edgecolor=self.edge_color,
4047
- label=str(cat))
4048
- for cat in unique_categories]
4049
-
4050
- colorbar_label = self.color if self.color != "depth" and self.color != "label" else "Category"
4051
-
4052
- # Find a good location for the color legend (opposite corner from shape legend)
4053
- if self._data is not None:
4054
- _, secondary_loc = self._find_best_legend_locations(self._data)
4055
- else:
4056
- secondary_loc = 'upper right'
4057
-
4058
- color_legend = self.ax.legend(handles=legend_elements,
4059
- title=colorbar_label,
4060
- loc=secondary_loc,
4061
- frameon=True,
4062
- framealpha=0.9,
4063
- edgecolor='black')
4064
- color_legend.get_title().set_fontweight('bold')
4065
- elif not is_categorical and self.show_colorbar and first_scatter:
4066
- # Add continuous colorbar
4067
- self.colorbar = self.fig.colorbar(first_scatter, ax=self.ax)
4068
- colorbar_label = self.color if self.color != "depth" else "Depth"
4069
- self.colorbar.set_label(colorbar_label, fontsize=11, fontweight='bold')
4306
+ # Add colorbar for continuous color mapping
4307
+ if self.color and not is_categorical and self.show_colorbar and first_scatter:
4308
+ # Add continuous colorbar
4309
+ self.colorbar = self.fig.colorbar(first_scatter, ax=self.ax)
4310
+ colorbar_label = self.color if self.color != "depth" else "Depth"
4311
+ self.colorbar.set_label(colorbar_label, fontsize=11, fontweight='bold')
4070
4312
 
4071
4313
  def add_regression(
4072
4314
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: well-log-toolkit
3
- Version: 0.1.131
3
+ Version: 0.1.132
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=PuHF8rqypirNIN77STHcvg8WneExikpq6ZvkcRbcQpg,109776
5
5
  well_log_toolkit/operations.py,sha256=z8j8fGBOwoJGUQFy-Vawjq9nm3OD_dUt0oaNh8yuG7o,18515
6
- well_log_toolkit/property.py,sha256=WOzoNQcmHCQ8moIKsnSyLgVC8s4LBu2x5IBXtFzmMe8,76236
6
+ well_log_toolkit/property.py,sha256=B-3mXNJmvIqjjMdsu1kgVSwMgEwbJ36wn_n_oppdJFw,76769
7
7
  well_log_toolkit/regression.py,sha256=7D3oI-1XVlFb-mOoHTxTTtUHERFyvQSBAzJzAGVoZnk,25192
8
8
  well_log_toolkit/statistics.py,sha256=_huPMbv2H3o9ezunjEM94mJknX5wPK8V4nDv2lIZZRw,16814
9
9
  well_log_toolkit/utils.py,sha256=O2KPq4htIoUlL74V2zKftdqqTjRfezU9M-568zPLme0,6866
10
- well_log_toolkit/visualization.py,sha256=MqMK5J3LGCXc2YDT1tlmLC6oMWA-F-YBuLBh5rbrPpI,169985
10
+ well_log_toolkit/visualization.py,sha256=204l_LFgcSfnpHti1Xc1iGF-KUVEyxkJNS88gkUDeXA,179959
11
11
  well_log_toolkit/well.py,sha256=Aav5Y-rui8YsJdvk7BFndNPUu1O9mcjwDApAGyqV9kw,104535
12
- well_log_toolkit-0.1.131.dist-info/METADATA,sha256=XC_qlZ-SFO5rFspWuxMXfvl1viLa-GpKZXKxWvA-RBc,59810
13
- well_log_toolkit-0.1.131.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
- well_log_toolkit-0.1.131.dist-info/top_level.txt,sha256=BMOo7OKLcZEnjo0wOLMclwzwTbYKYh31I8RGDOGSBdE,17
15
- well_log_toolkit-0.1.131.dist-info/RECORD,,
12
+ well_log_toolkit-0.1.132.dist-info/METADATA,sha256=5IVBXhf2t9MQW4Gswct9F1IHl5JJnKOWDZxK4g2vUq4,59810
13
+ well_log_toolkit-0.1.132.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ well_log_toolkit-0.1.132.dist-info/top_level.txt,sha256=BMOo7OKLcZEnjo0wOLMclwzwTbYKYh31I8RGDOGSBdE,17
15
+ well_log_toolkit-0.1.132.dist-info/RECORD,,