well-log-toolkit 0.1.149__tar.gz → 0.1.151__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.149 → well_log_toolkit-0.1.151}/PKG-INFO +1 -1
  2. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/pyproject.toml +1 -1
  3. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/manager.py +619 -9
  4. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/property.py +10 -4
  5. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit.egg-info/PKG-INFO +1 -1
  6. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/README.md +0 -0
  7. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/setup.cfg +0 -0
  8. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/__init__.py +0 -0
  9. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/exceptions.py +0 -0
  10. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/las_file.py +0 -0
  11. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/operations.py +0 -0
  12. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/regression.py +0 -0
  13. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/statistics.py +0 -0
  14. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/utils.py +0 -0
  15. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/visualization.py +0 -0
  16. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/well.py +0 -0
  17. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit.egg-info/SOURCES.txt +0 -0
  18. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit.egg-info/dependency_links.txt +0 -0
  19. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit.egg-info/requires.txt +0 -0
  20. {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/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.149
3
+ Version: 0.1.151
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.149"
7
+ version = "0.1.151"
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"
@@ -48,6 +48,511 @@ def _sanitize_for_json(obj):
48
48
  return obj
49
49
 
50
50
 
51
+ class SumsAvgResult(dict):
52
+ """
53
+ Dictionary subclass for sums_avg results with reporting capabilities.
54
+
55
+ Behaves exactly like a regular dict but adds the `.report()` method
56
+ for generating formatted reports with cross-well aggregation.
57
+
58
+ Examples
59
+ --------
60
+ >>> results = manager.properties(['PHIE', 'PERM']).filter('Facies').filter_intervals("Zones").sums_avg()
61
+ >>> results['Well_A'] # Normal dict access
62
+ >>> results.report(zones=[...], groups={...}, columns=[...]) # Generate report
63
+ """
64
+
65
+ def report(
66
+ self,
67
+ zones: list[str],
68
+ groups: dict[str, list[str]],
69
+ columns: list[dict],
70
+ print_report: bool = True
71
+ ) -> Optional[dict]:
72
+ """
73
+ Generate a structured report with cross-well aggregation.
74
+
75
+ Parameters
76
+ ----------
77
+ zones : list[str]
78
+ Zone names to include in the report
79
+ groups : dict[str, list[str]]
80
+ Facies grouping, e.g., {"NonNet": ["NonNet", "Slump"], "Net": ["Channel Sand"]}
81
+ columns : list[dict]
82
+ Column specifications, each with:
83
+ - property (str, required): Property name in results
84
+ - stat (str, required): Statistic to extract (mean, std_dev, p10, etc.)
85
+ - label (str, optional): Display name (defaults to stat)
86
+ - format (str, optional): Number format (e.g., ".4f")
87
+ - unit (str, optional): Display unit for printing
88
+ - factor (float, optional): Multiplier for value conversion (default 1.0)
89
+ - agg (str, optional): Cross-well aggregation method:
90
+ - "arithmetic" (default for mean): thickness-weighted arithmetic mean
91
+ - "geometric": thickness-weighted geometric mean (for permeability)
92
+ - "pooled" (default for std_dev): pooled standard deviation
93
+ - "sum": simple sum (for thickness)
94
+ print_report : bool, default True
95
+ If True, print formatted report. If False, return structured data.
96
+
97
+ Returns
98
+ -------
99
+ dict or None
100
+ If print_report=False, returns structured data dict.
101
+ If print_report=True, prints report and returns None.
102
+
103
+ Raises
104
+ ------
105
+ ValueError
106
+ If pooled aggregation is requested but corresponding mean column is missing.
107
+
108
+ Examples
109
+ --------
110
+ >>> results.report(
111
+ ... zones=["Sand 3_SST", "Sand 2_SST"],
112
+ ... groups={"NonNet": ["NonNet", "Slump"], "Net": ["Channel Sand", "LowQ Sand"]},
113
+ ... columns=[
114
+ ... {"property": "CPI_PHIE_2025", "stat": "mean", "label": "por", "format": ".4f"},
115
+ ... {"property": "CPI_PHIE_2025", "stat": "std_dev", "label": "std", "format": ".4f"},
116
+ ... {"property": "CPI_PERM_CALC_2025", "stat": "mean", "label": "perm", "agg": "geometric", "unit": "mD", "format": ".2f"},
117
+ ... ]
118
+ ... )
119
+ """
120
+ # Validate columns
121
+ self._validate_columns(columns)
122
+
123
+ # Generate structured data
124
+ report_data = self._generate_report_data(zones, groups, columns)
125
+
126
+ if print_report:
127
+ self._print_report(report_data, columns)
128
+ return None
129
+ else:
130
+ return report_data
131
+
132
+ def _validate_columns(self, columns: list[dict]) -> None:
133
+ """Validate column specifications."""
134
+ # Check required fields
135
+ for i, col in enumerate(columns):
136
+ if 'property' not in col:
137
+ raise ValueError(f"Column {i} missing required 'property' field")
138
+ if 'stat' not in col:
139
+ raise ValueError(f"Column {i} missing required 'stat' field")
140
+
141
+ # Check pooled std_dev has corresponding mean
142
+ for col in columns:
143
+ agg = col.get('agg')
144
+ stat = col.get('stat')
145
+ prop = col.get('property')
146
+
147
+ # Default agg for std_dev is pooled
148
+ if stat == 'std_dev' and agg is None:
149
+ agg = 'pooled'
150
+
151
+ if agg == 'pooled':
152
+ # Find corresponding mean column
153
+ has_mean = any(
154
+ c.get('property') == prop and c.get('stat') == 'mean'
155
+ for c in columns
156
+ )
157
+ if not has_mean:
158
+ raise ValueError(
159
+ f"Column with property='{prop}' and stat='std_dev' uses pooled aggregation, "
160
+ f"but no corresponding mean column found. Add a column with "
161
+ f"property='{prop}' and stat='mean'."
162
+ )
163
+
164
+ def _get_column_defaults(self, col: dict) -> dict:
165
+ """Get column spec with defaults applied."""
166
+ stat = col.get('stat', 'mean')
167
+
168
+ # Default aggregation based on stat type
169
+ if stat == 'std_dev':
170
+ default_agg = 'pooled'
171
+ else:
172
+ default_agg = 'arithmetic'
173
+
174
+ return {
175
+ 'property': col['property'],
176
+ 'stat': stat,
177
+ 'label': col.get('label', stat),
178
+ 'format': col.get('format', '.4f'),
179
+ 'unit': col.get('unit', ''),
180
+ 'factor': col.get('factor', 1.0),
181
+ 'agg': col.get('agg', default_agg),
182
+ }
183
+
184
+ def _extract_value(self, facies_data: dict, col: dict) -> Optional[float]:
185
+ """Extract a value from facies data based on column spec."""
186
+ col = self._get_column_defaults(col)
187
+ prop = col['property']
188
+ stat = col['stat']
189
+ factor = col['factor']
190
+
191
+ if prop not in facies_data:
192
+ return None
193
+
194
+ prop_data = facies_data[prop]
195
+ if not isinstance(prop_data, dict) or stat not in prop_data:
196
+ return None
197
+
198
+ value = prop_data[stat]
199
+ if value is None:
200
+ return None
201
+
202
+ return value * factor
203
+
204
+ def _generate_report_data(
205
+ self,
206
+ zones: list[str],
207
+ groups: dict[str, list[str]],
208
+ columns: list[dict]
209
+ ) -> dict:
210
+ """Generate structured report data from results."""
211
+ report = {}
212
+
213
+ # Collect data for aggregation
214
+ # Structure: {zone: {group: {facies: {label: [values], "thick": [thicknesses]}}}}
215
+ aggregation_data = {}
216
+
217
+ # Process each well
218
+ for well_name, well_data in self.items():
219
+ if well_name == "Summary":
220
+ continue # Skip if already has summary
221
+
222
+ well_report = {}
223
+
224
+ for zone_name in zones:
225
+ if zone_name not in well_data:
226
+ continue
227
+
228
+ zone_data = well_data[zone_name]
229
+ zone_report = {}
230
+
231
+ # Calculate total zone thickness from all facies
232
+ zone_thickness = 0.0
233
+ for facies_name, facies_data in zone_data.items():
234
+ if isinstance(facies_data, dict) and 'thickness' in facies_data:
235
+ zone_thickness += facies_data['thickness']
236
+
237
+ zone_report['thickness'] = zone_thickness
238
+
239
+ # Process each group
240
+ for group_name, facies_list in groups.items():
241
+ existing_facies = [f for f in facies_list if f in zone_data]
242
+ if not existing_facies:
243
+ continue
244
+
245
+ group_report = {}
246
+ group_thickness = sum(
247
+ zone_data[f]['thickness'] for f in existing_facies
248
+ if isinstance(zone_data[f], dict) and 'thickness' in zone_data[f]
249
+ )
250
+
251
+ group_report['thickness'] = group_thickness
252
+ group_report['fraction'] = group_thickness / zone_thickness if zone_thickness > 0 else 0.0
253
+
254
+ # Process each facies in the group
255
+ for facies_name in existing_facies:
256
+ facies_data = zone_data[facies_name]
257
+ if not isinstance(facies_data, dict):
258
+ continue
259
+
260
+ facies_thickness = facies_data.get('thickness', 0.0)
261
+ if facies_thickness <= 0:
262
+ continue
263
+
264
+ facies_report = {
265
+ 'thickness': facies_thickness,
266
+ 'fraction': facies_thickness / group_thickness if group_thickness > 0 else 0.0,
267
+ }
268
+
269
+ # Extract column values
270
+ for col in columns:
271
+ col_def = self._get_column_defaults(col)
272
+ label = col_def['label']
273
+ value = self._extract_value(facies_data, col)
274
+ facies_report[label] = value
275
+
276
+ group_report[facies_name] = facies_report
277
+
278
+ # Collect for aggregation
279
+ if zone_name not in aggregation_data:
280
+ aggregation_data[zone_name] = {}
281
+ if group_name not in aggregation_data[zone_name]:
282
+ aggregation_data[zone_name][group_name] = {}
283
+ if facies_name not in aggregation_data[zone_name][group_name]:
284
+ aggregation_data[zone_name][group_name][facies_name] = {
285
+ 'thick': [], 'values': {}
286
+ }
287
+
288
+ agg_facies = aggregation_data[zone_name][group_name][facies_name]
289
+ agg_facies['thick'].append(facies_thickness)
290
+
291
+ for col in columns:
292
+ col_def = self._get_column_defaults(col)
293
+ label = col_def['label']
294
+ value = self._extract_value(facies_data, col)
295
+ if label not in agg_facies['values']:
296
+ agg_facies['values'][label] = []
297
+ agg_facies['values'][label].append(value)
298
+
299
+ zone_report[group_name] = group_report
300
+
301
+ if zone_report:
302
+ well_report[zone_name] = zone_report
303
+
304
+ if well_report:
305
+ report[well_name] = well_report
306
+
307
+ # Generate Summary
308
+ summary = self._generate_summary(aggregation_data, columns, zones, groups)
309
+ if summary:
310
+ report['Summary'] = summary
311
+
312
+ return report
313
+
314
+ def _generate_summary(
315
+ self,
316
+ aggregation_data: dict,
317
+ columns: list[dict],
318
+ zones: list[str],
319
+ groups: dict[str, list[str]]
320
+ ) -> dict:
321
+ """Generate cross-well summary using thickness-weighted aggregation."""
322
+ summary = {}
323
+
324
+ # Build lookup for mean values needed by pooled std
325
+ # {(zone, group, facies, property): grand_mean}
326
+ grand_means = {}
327
+
328
+ # First pass: compute all arithmetic means (needed for pooled std)
329
+ for zone_name, zone_agg in aggregation_data.items():
330
+ for group_name, group_agg in zone_agg.items():
331
+ for facies_name, facies_agg in group_agg.items():
332
+ thicks = np.array(facies_agg['thick'])
333
+ total_thick = np.sum(thicks)
334
+ if total_thick <= 0:
335
+ continue
336
+
337
+ for col in columns:
338
+ col_def = self._get_column_defaults(col)
339
+ if col_def['stat'] == 'mean':
340
+ label = col_def['label']
341
+ prop = col_def['property']
342
+ values = facies_agg['values'].get(label, [])
343
+
344
+ valid_mask = [v is not None for v in values]
345
+ if not any(valid_mask):
346
+ continue
347
+
348
+ valid_vals = np.array([v for v, m in zip(values, valid_mask) if m])
349
+ valid_thicks = np.array([t for t, m in zip(thicks, valid_mask) if m])
350
+ valid_total = np.sum(valid_thicks)
351
+
352
+ if valid_total > 0:
353
+ grand_mean = np.sum(valid_vals * valid_thicks) / valid_total
354
+ grand_means[(zone_name, group_name, facies_name, prop)] = grand_mean
355
+
356
+ # Second pass: compute all aggregated values
357
+ for zone_name in zones:
358
+ if zone_name not in aggregation_data:
359
+ continue
360
+
361
+ zone_agg = aggregation_data[zone_name]
362
+ zone_summary = {'thickness': 0.0}
363
+
364
+ for group_name, facies_list in groups.items():
365
+ if group_name not in zone_agg:
366
+ continue
367
+
368
+ group_agg = zone_agg[group_name]
369
+ group_summary = {'thickness': 0.0}
370
+
371
+ for facies_name in facies_list:
372
+ if facies_name not in group_agg:
373
+ continue
374
+
375
+ facies_agg = group_agg[facies_name]
376
+ thicks = np.array(facies_agg['thick'])
377
+ total_thick = np.sum(thicks)
378
+
379
+ if total_thick <= 0:
380
+ continue
381
+
382
+ facies_summary = {'thickness': total_thick, 'fraction': 0.0}
383
+
384
+ for col in columns:
385
+ col_def = self._get_column_defaults(col)
386
+ label = col_def['label']
387
+ prop = col_def['property']
388
+ agg_method = col_def['agg']
389
+
390
+ values = facies_agg['values'].get(label, [])
391
+ valid_mask = [v is not None for v in values]
392
+
393
+ if not any(valid_mask):
394
+ facies_summary[label] = None
395
+ continue
396
+
397
+ valid_vals = np.array([v for v, m in zip(values, valid_mask) if m])
398
+ valid_thicks = np.array([t for t, m in zip(thicks, valid_mask) if m])
399
+ valid_total = np.sum(valid_thicks)
400
+
401
+ if valid_total <= 0:
402
+ facies_summary[label] = None
403
+ continue
404
+
405
+ if agg_method == 'arithmetic':
406
+ # Thickness-weighted arithmetic mean
407
+ agg_value = np.sum(valid_vals * valid_thicks) / valid_total
408
+
409
+ elif agg_method == 'geometric':
410
+ # Thickness-weighted geometric mean
411
+ # Filter out non-positive values for log
412
+ pos_mask = valid_vals > 0
413
+ if not np.any(pos_mask):
414
+ facies_summary[label] = None
415
+ continue
416
+ pos_vals = valid_vals[pos_mask]
417
+ pos_thicks = valid_thicks[pos_mask]
418
+ pos_total = np.sum(pos_thicks)
419
+ agg_value = np.exp(np.sum(np.log(pos_vals) * pos_thicks) / pos_total)
420
+
421
+ elif agg_method == 'pooled':
422
+ # Pooled standard deviation
423
+ # Requires the grand mean from the corresponding mean column
424
+ grand_mean = grand_means.get((zone_name, group_name, facies_name, prop))
425
+ if grand_mean is None:
426
+ facies_summary[label] = None
427
+ continue
428
+
429
+ # Get the corresponding std values (these are the per-well stds)
430
+ # and the per-well means
431
+ mean_label = None
432
+ for c in columns:
433
+ c_def = self._get_column_defaults(c)
434
+ if c_def['property'] == prop and c_def['stat'] == 'mean':
435
+ mean_label = c_def['label']
436
+ break
437
+
438
+ if mean_label is None:
439
+ facies_summary[label] = None
440
+ continue
441
+
442
+ mean_values = facies_agg['values'].get(mean_label, [])
443
+ std_values = values # current column values (stds)
444
+
445
+ # Both must be valid
446
+ combined_mask = [
447
+ m is not None and s is not None
448
+ for m, s in zip(mean_values, std_values)
449
+ ]
450
+ if not any(combined_mask):
451
+ facies_summary[label] = None
452
+ continue
453
+
454
+ combined_means = np.array([v for v, m in zip(mean_values, combined_mask) if m])
455
+ combined_stds = np.array([v for v, m in zip(std_values, combined_mask) if m])
456
+ combined_thicks = np.array([t for t, m in zip(thicks, combined_mask) if m])
457
+ combined_total = np.sum(combined_thicks)
458
+
459
+ # Pooled variance formula:
460
+ # var_pooled = sum(thick * (std^2 + (mean - grand_mean)^2)) / total_thick
461
+ pooled_var = np.sum(
462
+ combined_thicks * (combined_stds**2 + (combined_means - grand_mean)**2)
463
+ ) / combined_total
464
+ agg_value = np.sqrt(pooled_var)
465
+
466
+ elif agg_method == 'sum':
467
+ agg_value = np.sum(valid_vals)
468
+
469
+ else:
470
+ # Default to arithmetic
471
+ agg_value = np.sum(valid_vals * valid_thicks) / valid_total
472
+
473
+ facies_summary[label] = agg_value
474
+
475
+ group_summary[facies_name] = facies_summary
476
+ group_summary['thickness'] += total_thick
477
+
478
+ # Update facies fractions based on group total
479
+ group_thick = group_summary['thickness']
480
+ for facies_name in facies_list:
481
+ if facies_name in group_summary and isinstance(group_summary[facies_name], dict):
482
+ f_thick = group_summary[facies_name].get('thickness', 0)
483
+ group_summary[facies_name]['fraction'] = f_thick / group_thick if group_thick > 0 else 0.0
484
+
485
+ if group_summary['thickness'] > 0:
486
+ zone_summary[group_name] = group_summary
487
+ zone_summary['thickness'] += group_summary['thickness']
488
+
489
+ # Calculate group fractions
490
+ zone_thick = zone_summary['thickness']
491
+ for group_name in groups:
492
+ if group_name in zone_summary and isinstance(zone_summary[group_name], dict):
493
+ g_thick = zone_summary[group_name].get('thickness', 0)
494
+ zone_summary[group_name]['fraction'] = g_thick / zone_thick if zone_thick > 0 else 0.0
495
+
496
+ if zone_summary['thickness'] > 0:
497
+ summary[zone_name] = zone_summary
498
+
499
+ return summary
500
+
501
+ def _print_report(self, report_data: dict, columns: list[dict]) -> None:
502
+ """Print formatted report."""
503
+ indent = " "
504
+
505
+ # Prepare column formatting
506
+ col_defs = [self._get_column_defaults(c) for c in columns]
507
+
508
+ for well_name, well_data in report_data.items():
509
+ print(f"\n{well_name}")
510
+
511
+ for zone_name, zone_data in well_data.items():
512
+ if not isinstance(zone_data, dict):
513
+ continue
514
+
515
+ zone_thick = zone_data.get('thickness', 0)
516
+ print(f"{indent}{zone_name:<13} iso: {zone_thick:>6.2f}m")
517
+
518
+ for group_name, group_data in zone_data.items():
519
+ if group_name == 'thickness' or not isinstance(group_data, dict):
520
+ continue
521
+
522
+ group_thick = group_data.get('thickness', 0)
523
+ group_frac = group_data.get('fraction', 0)
524
+ print(f"{indent*2}-- {group_name:<10} fraction: {group_frac:>7.4f} iso: {group_thick:>6.2f}m")
525
+
526
+ for facies_name, facies_data in group_data.items():
527
+ if facies_name in ('thickness', 'fraction') or not isinstance(facies_data, dict):
528
+ continue
529
+
530
+ f_thick = facies_data.get('thickness', 0)
531
+ f_frac = facies_data.get('fraction', 0)
532
+
533
+ # Build column values string
534
+ col_strs = []
535
+ for col_def in col_defs:
536
+ label = col_def['label']
537
+ fmt = col_def['format']
538
+ unit = col_def['unit']
539
+ value = facies_data.get(label)
540
+
541
+ if value is not None:
542
+ formatted = f"{value:{fmt}}"
543
+ if unit:
544
+ col_strs.append(f"{label}: {formatted:>8}{unit}")
545
+ else:
546
+ col_strs.append(f"{label}: {formatted:>8}")
547
+ else:
548
+ col_strs.append(f"{label}: {'N/A':>8}")
549
+
550
+ col_str = " ".join(col_strs)
551
+ print(f"{indent*2}| {facies_name:<15} frac: {f_frac:>7.4f} iso: {f_thick:>6.2f}m {col_str}")
552
+
553
+ print("")
554
+
555
+
51
556
  def _flatten_to_dataframe(nested_dict: dict, property_name: str) -> pd.DataFrame:
52
557
  """
53
558
  Flatten nested dictionary results into a DataFrame.
@@ -1299,7 +1804,7 @@ class _ManagerPropertyProxy:
1299
1804
  arithmetic: Optional[bool] = None,
1300
1805
  precision: int = 6,
1301
1806
  nested: bool = False
1302
- ) -> dict:
1807
+ ) -> SumsAvgResult:
1303
1808
  """
1304
1809
  Compute hierarchical statistics grouped by filters across all wells.
1305
1810
 
@@ -1406,7 +1911,7 @@ class _ManagerPropertyProxy:
1406
1911
  if well_result is not None:
1407
1912
  result[well_name] = well_result
1408
1913
 
1409
- return _sanitize_for_json(result)
1914
+ return SumsAvgResult(_sanitize_for_json(result))
1410
1915
 
1411
1916
  def _compute_sums_avg_for_well(
1412
1917
  self,
@@ -1445,12 +1950,23 @@ class _ManagerPropertyProxy:
1445
1950
  prop = prop.filter(filter_name, source=source_name)
1446
1951
 
1447
1952
  # Compute sums_avg
1448
- source_results[source_name] = prop.sums_avg(
1953
+ result = prop.sums_avg(
1449
1954
  weighted=weighted,
1450
1955
  arithmetic=arithmetic,
1451
1956
  precision=precision
1452
1957
  )
1453
1958
 
1959
+ # Add well-level thickness for this source if using filter_intervals
1960
+ if self._custom_intervals and result:
1961
+ well_thickness = 0.0
1962
+ for key, value in result.items():
1963
+ if isinstance(value, dict) and 'thickness' in value:
1964
+ well_thickness += value['thickness']
1965
+ if well_thickness > 0:
1966
+ result['thickness'] = round(well_thickness, precision)
1967
+
1968
+ source_results[source_name] = result
1969
+
1454
1970
  except (PropertyNotFoundError, PropertyTypeError, AttributeError, KeyError, ValueError):
1455
1971
  # Property or filter doesn't exist in this source, or filter isn't discrete - skip it
1456
1972
  pass
@@ -1476,12 +1992,23 @@ class _ManagerPropertyProxy:
1476
1992
  prop = prop.filter(filter_name)
1477
1993
 
1478
1994
  # Compute sums_avg
1479
- return prop.sums_avg(
1995
+ result = prop.sums_avg(
1480
1996
  weighted=weighted,
1481
1997
  arithmetic=arithmetic,
1482
1998
  precision=precision
1483
1999
  )
1484
2000
 
2001
+ # Add well-level thickness (sum of all zone thicknesses) if using filter_intervals
2002
+ if self._custom_intervals and result:
2003
+ well_thickness = 0.0
2004
+ for key, value in result.items():
2005
+ if isinstance(value, dict) and 'thickness' in value:
2006
+ well_thickness += value['thickness']
2007
+ if well_thickness > 0:
2008
+ result['thickness'] = round(well_thickness, precision)
2009
+
2010
+ return result
2011
+
1485
2012
  except PropertyNotFoundError as e:
1486
2013
  # Check if it's ambiguous (exists in multiple sources)
1487
2014
  if "ambiguous" in str(e).lower():
@@ -1512,6 +2039,16 @@ class _ManagerPropertyProxy:
1512
2039
  arithmetic=arithmetic,
1513
2040
  precision=precision
1514
2041
  )
2042
+
2043
+ # Add well-level thickness for this source if using filter_intervals
2044
+ if self._custom_intervals and result:
2045
+ well_thickness = 0.0
2046
+ for key, value in result.items():
2047
+ if isinstance(value, dict) and 'thickness' in value:
2048
+ well_thickness += value['thickness']
2049
+ if well_thickness > 0:
2050
+ result['thickness'] = round(well_thickness, precision)
2051
+
1515
2052
  source_results[source_name] = result
1516
2053
 
1517
2054
  except (PropertyNotFoundError, PropertyTypeError, AttributeError, KeyError, ValueError):
@@ -1632,7 +2169,7 @@ class _ManagerMultiPropertyProxy:
1632
2169
  PROPERTY_STATS = {'mean', 'median', 'mode', 'sum', 'std_dev', 'percentile', 'range'}
1633
2170
 
1634
2171
  # Stats that are common across properties (stay at group level)
1635
- COMMON_STATS = {'depth_range', 'samples', 'thickness', 'gross_thickness', 'thickness_fraction', 'calculation'}
2172
+ COMMON_STATS = {'depth_range', 'samples', 'thickness', 'thickness_fraction', 'calculation'}
1636
2173
 
1637
2174
  def __init__(
1638
2175
  self,
@@ -1727,7 +2264,7 @@ class _ManagerMultiPropertyProxy:
1727
2264
  weighted: Optional[bool] = None,
1728
2265
  arithmetic: Optional[bool] = None,
1729
2266
  precision: int = 6
1730
- ) -> dict:
2267
+ ) -> SumsAvgResult:
1731
2268
  """
1732
2269
  Compute statistics for multiple properties across all wells.
1733
2270
 
@@ -1785,7 +2322,7 @@ class _ManagerMultiPropertyProxy:
1785
2322
  if well_result is not None:
1786
2323
  result[well_name] = well_result
1787
2324
 
1788
- return _sanitize_for_json(result)
2325
+ return SumsAvgResult(_sanitize_for_json(result))
1789
2326
 
1790
2327
  def _compute_sums_avg_for_well(
1791
2328
  self,
@@ -1849,7 +2386,17 @@ class _ManagerMultiPropertyProxy:
1849
2386
  return self._merge_flat_results(property_results)
1850
2387
 
1851
2388
  # Merge results: nest property-specific stats, keep common stats at group level
1852
- return self._merge_property_results(property_results)
2389
+ merged = self._merge_property_results(property_results)
2390
+
2391
+ # Add well-level thickness (sum of all zone thicknesses)
2392
+ if self._custom_intervals and merged:
2393
+ well_thickness = 0.0
2394
+ for key, value in merged.items():
2395
+ if isinstance(value, dict) and 'thickness' in value:
2396
+ well_thickness += value['thickness']
2397
+ merged['thickness'] = round(well_thickness, 6)
2398
+
2399
+ return merged
1853
2400
 
1854
2401
  def _apply_filter_intervals(self, prop, well):
1855
2402
  """
@@ -3108,7 +3655,70 @@ class WellDataManager:
3108
3655
  ['well_12_3_2_B', 'well_12_3_2_A']
3109
3656
  """
3110
3657
  return list(self._wells.keys())
3111
-
3658
+
3659
+ @property
3660
+ def saved_intervals(self) -> dict[str, list[str]]:
3661
+ """
3662
+ List saved interval names for all wells.
3663
+
3664
+ Returns
3665
+ -------
3666
+ dict[str, list[str]]
3667
+ Dictionary mapping well names to their saved interval names
3668
+
3669
+ Examples
3670
+ --------
3671
+ >>> manager.saved_intervals
3672
+ {'well_A': ['Reservoir_Zones', 'Slump_Zones'], 'well_B': ['Reservoir_Zones']}
3673
+ """
3674
+ result = {}
3675
+ for well_name, well in self._wells.items():
3676
+ if well.saved_intervals:
3677
+ result[well_name] = well.saved_intervals
3678
+ return result
3679
+
3680
+ def get_intervals(self, name: str) -> dict[str, list[dict]]:
3681
+ """
3682
+ Get saved filter intervals by name from all wells that have them.
3683
+
3684
+ Parameters
3685
+ ----------
3686
+ name : str
3687
+ Name of the saved filter intervals
3688
+
3689
+ Returns
3690
+ -------
3691
+ dict[str, list[dict]]
3692
+ Dictionary mapping well names to their interval definitions
3693
+
3694
+ Raises
3695
+ ------
3696
+ KeyError
3697
+ If no wells have intervals with the given name
3698
+
3699
+ Examples
3700
+ --------
3701
+ >>> manager.get_intervals("Slump_Zones")
3702
+ {'well_A': [{'name': 'Zone_A', 'top': 2500, 'base': 2650}],
3703
+ 'well_B': [{'name': 'Zone_A', 'top': 2600, 'base': 2750}]}
3704
+ """
3705
+ result = {}
3706
+ for well_name, well in self._wells.items():
3707
+ if name in well.saved_intervals:
3708
+ result[well_name] = well.get_intervals(name)
3709
+
3710
+ if not result:
3711
+ # Collect all available interval names for error message
3712
+ all_names = set()
3713
+ for well in self._wells.values():
3714
+ all_names.update(well.saved_intervals)
3715
+ raise KeyError(
3716
+ f"No wells have saved intervals named '{name}'. "
3717
+ f"Available: {sorted(all_names) if all_names else 'none'}"
3718
+ )
3719
+
3720
+ return result
3721
+
3112
3722
  def get_well(self, name: str) -> Well:
3113
3723
  """
3114
3724
  Get well by original or sanitized name.
@@ -1779,24 +1779,31 @@ class Property(PropertyOperationsMixin):
1779
1779
  # contributes to this zone (even boundary samples with partial intervals)
1780
1780
  interval_mask = zone_intervals > 0
1781
1781
 
1782
+ # Calculate zone thickness (sum of valid intervals within zone)
1783
+ valid_mask = interval_mask & ~np.isnan(self.values)
1784
+ zone_thickness = float(np.sum(zone_intervals[valid_mask]))
1785
+
1782
1786
  # If there are secondary properties, group within this interval
1783
1787
  if self.secondary_properties:
1784
- result[interval_name] = self._recursive_group(
1788
+ interval_result = self._recursive_group(
1785
1789
  0,
1786
1790
  interval_mask,
1787
1791
  weighted=weighted,
1788
1792
  arithmetic=arithmetic,
1789
- gross_thickness=gross_thickness,
1793
+ gross_thickness=zone_thickness, # Pass zone thickness as gross for children
1790
1794
  precision=precision,
1791
1795
  zone_intervals=zone_intervals
1792
1796
  )
1797
+ # Add zone-level thickness
1798
+ interval_result['thickness'] = round(zone_thickness, precision)
1799
+ result[interval_name] = interval_result
1793
1800
  else:
1794
1801
  # No secondary properties, compute stats directly for interval
1795
1802
  result[interval_name] = self._compute_stats(
1796
1803
  interval_mask,
1797
1804
  weighted=weighted,
1798
1805
  arithmetic=arithmetic,
1799
- gross_thickness=gross_thickness,
1806
+ gross_thickness=zone_thickness, # Use zone thickness for fraction calc
1800
1807
  precision=precision,
1801
1808
  zone_intervals=zone_intervals
1802
1809
  )
@@ -2416,7 +2423,6 @@ class Property(PropertyOperationsMixin):
2416
2423
  'depth_range': _round_value(depth_range_dict),
2417
2424
  'samples': int(len(valid)),
2418
2425
  'thickness': round(thickness, precision),
2419
- 'gross_thickness': round(gross_thickness, precision),
2420
2426
  'thickness_fraction': round(fraction, precision),
2421
2427
  'calculation': calc_method,
2422
2428
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: well-log-toolkit
3
- Version: 0.1.149
3
+ Version: 0.1.151
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