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.
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/PKG-INFO +1 -1
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/pyproject.toml +1 -1
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/manager.py +619 -9
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/property.py +10 -4
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit.egg-info/PKG-INFO +1 -1
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/README.md +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/setup.cfg +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/__init__.py +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/exceptions.py +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/las_file.py +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/operations.py +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/regression.py +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/statistics.py +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/utils.py +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/visualization.py +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit/well.py +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit.egg-info/SOURCES.txt +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit.egg-info/dependency_links.txt +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit.egg-info/requires.txt +0 -0
- {well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "well-log-toolkit"
|
|
7
|
-
version = "0.1.
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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
|
-
|
|
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', '
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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
|
-
|
|
1788
|
+
interval_result = self._recursive_group(
|
|
1785
1789
|
0,
|
|
1786
1790
|
interval_mask,
|
|
1787
1791
|
weighted=weighted,
|
|
1788
1792
|
arithmetic=arithmetic,
|
|
1789
|
-
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=
|
|
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
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit.egg-info/requires.txt
RENAMED
|
File without changes
|
{well_log_toolkit-0.1.149 → well_log_toolkit-0.1.151}/well_log_toolkit.egg-info/top_level.txt
RENAMED
|
File without changes
|