well-log-toolkit 0.1.79__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.
@@ -0,0 +1,1291 @@
1
+ Metadata-Version: 2.4
2
+ Name: well-log-toolkit
3
+ Version: 0.1.79
4
+ Summary: Fast LAS file processing with lazy loading and filtering for well log analysis
5
+ Author-email: Kristian dF Kollsgård <kkollsg@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/kkollsga/well_log_toolkit
8
+ Project-URL: Documentation, https://github.com/kkollsga/well_log_toolkit#readme
9
+ Project-URL: Repository, https://github.com/kkollsga/well_log_toolkit
10
+ Project-URL: Issues, https://github.com/kkollsga/well_log_toolkit/issues
11
+ Project-URL: Changelog, https://github.com/kkollsga/well_log_toolkit/releases
12
+ Keywords: well-log,las-file,petrophysics,geoscience,oil-and-gas,petroleum-engineering
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Operating System :: OS Independent
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ Requires-Dist: numpy>=1.20.0
29
+ Requires-Dist: pandas>=1.3.0
30
+ Requires-Dist: scipy>=1.7.0
31
+ Requires-Dist: matplotlib>=3.5.0
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
34
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
35
+ Requires-Dist: black>=23.0.0; extra == "dev"
36
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
37
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
38
+
39
+ # Well Log Toolkit
40
+
41
+ Fast, intuitive Python library for petrophysical well log analysis. Load LAS files, filter by zones, and compute depth-weighted statistics in just a few lines.
42
+
43
+ ## Key Features
44
+
45
+ - **Lazy Loading** - Parse headers instantly, load data on demand
46
+ - **Numpy-Style Operations** - `well.HC_Volume = well.PHIE * (1 - well.SW)`
47
+ - **Hierarchical Filtering** - Chain filters: `well.PHIE.filter('Zone').filter('Facies').sums_avg()`
48
+ - **Depth-Weighted Statistics** - Proper averaging for irregular sampling
49
+ - **Multi-Well Statistics** - Cross-well analytics: `manager.PHIE.filter('Zone').percentile(50)`
50
+ - **Multi-Well Management** - Broadcast operations: `manager.PHIE_percent = manager.PHIE * 100`
51
+ - **Well Log Visualization** - Create publication-quality log displays in Jupyter Lab
52
+ - **Project Persistence** - Save/load entire projects with metadata and templates
53
+
54
+ ## Installation
55
+
56
+ ```bash
57
+ pip install well-log-toolkit
58
+ ```
59
+
60
+ ## Table of Contents
61
+
62
+ - [1-Minute Tutorial](#1-minute-tutorial) - Get started immediately
63
+ - [Quick Start](#quick-start) - Core workflow in 5 minutes
64
+ - [Core Concepts](#core-concepts) - Essential patterns
65
+ - [Visualization](#visualization) - Create well log displays
66
+ - [Full Documentation](#documentation) - Complete guides
67
+
68
+ ---
69
+
70
+ ## 1-Minute Tutorial
71
+
72
+ Load LAS files, filter by zones, and compute statistics:
73
+
74
+ ```python
75
+ from well_log_toolkit import WellDataManager
76
+
77
+ # Load and analyze
78
+ manager = WellDataManager()
79
+ manager.load_las('well.las')
80
+
81
+ well = manager.well_12_3_4_A
82
+ stats = well.PHIE.filter('Zone').sums_avg()
83
+
84
+ print(stats['Top_Brent']['mean']) # → 0.182 (depth-weighted)
85
+ ```
86
+
87
+ **That's it!** Three lines to go from LAS file to zonal statistics.
88
+
89
+ > **New to this?** Continue to [Quick Start](#quick-start) for a complete 5-minute walkthrough.
90
+
91
+ ---
92
+
93
+ ## Quick Start
94
+
95
+ ### 1. Load Data
96
+
97
+ ```python
98
+ from well_log_toolkit import WellDataManager
99
+ import pandas as pd
100
+
101
+ # Load LAS files
102
+ manager = WellDataManager()
103
+ manager.load_las('well_A.las')
104
+ manager.load_las('well_B.las')
105
+
106
+ # Load formation tops from DataFrame
107
+ tops_df = pd.DataFrame({
108
+ 'Well': ['12/3-4 A', '12/3-4 A', '12/3-4 B'],
109
+ 'Surface': ['Top_Brent', 'Top_Statfjord', 'Top_Brent'],
110
+ 'MD': [2850.0, 3100.0, 2900.0]
111
+ })
112
+
113
+ manager.load_tops(tops_df, well_col='Well', discrete_col='Surface', depth_col='MD')
114
+ ```
115
+
116
+ ### 2. Access Wells and Properties
117
+
118
+ ```python
119
+ # Access well (special characters auto-sanitized)
120
+ well = manager.well_12_3_4_A
121
+
122
+ # Access properties directly
123
+ phie = well.PHIE
124
+ sw = well.SW
125
+
126
+ # List everything
127
+ print(well.properties) # ['PHIE', 'SW', 'PERM', 'Zone', ...]
128
+ print(well.sources) # ['Petrophysics', 'Imported_Tops']
129
+ ```
130
+
131
+ ### 3. Compute Statistics with Filtering
132
+
133
+ ```python
134
+ # Single filter - group by Zone
135
+ stats = well.PHIE.filter('Zone').sums_avg()
136
+ # → {'Top_Brent': {'mean': 0.182, 'thickness': 250.0, ...}, 'Top_Statfjord': {...}}
137
+
138
+ # Chain filters - hierarchical grouping
139
+ stats = well.PHIE.filter('Zone').filter('Facies').sums_avg()
140
+ # → {'Top_Brent': {'Sandstone': {...}, 'Shale': {...}}, 'Top_Statfjord': {...}}
141
+ ```
142
+
143
+ > **💡 Key:** Statistics are **depth-weighted** by default, accounting for irregular sampling. Add `arithmetic=True` to compare methods.
144
+
145
+ ### 4. Create New Properties
146
+
147
+ ```python
148
+ # Mathematical expressions (numpy-style)
149
+ well.HC_Volume = well.PHIE * (1 - well.SW)
150
+ well.PHIE_percent = well.PHIE * 100
151
+
152
+ # Comparison operations (auto-creates discrete flags)
153
+ well.Reservoir = (well.PHIE > 0.15) & (well.SW < 0.35)
154
+
155
+ # Apply to all wells at once
156
+ manager.PHIE_percent = manager.PHIE * 100
157
+ # → Converts PHIE to percent in every well
158
+ ```
159
+
160
+ > **⚠️ Important:** Operations require matching depth grids (like numpy). Use `.resample()` to align different grids.
161
+
162
+ ### 5. Export Results
163
+
164
+ ```python
165
+ # Export to DataFrame
166
+ df = well.data(include=['PHIE', 'SW', 'HC_Volume'])
167
+
168
+ # Export to LAS file
169
+ well.export_to_las('output.las')
170
+
171
+ # Save entire project
172
+ manager.save('my_project/')
173
+
174
+ # Load project later
175
+ manager = WellDataManager('my_project/')
176
+ ```
177
+
178
+ **Done!** You've learned the core workflow in 5 minutes.
179
+
180
+ ---
181
+
182
+ ## Core Concepts
183
+
184
+ ### Hierarchical Filtering
185
+
186
+ Filter properties by discrete logs (zones, facies, flags) to compute grouped statistics:
187
+
188
+ ```python
189
+ # Add labels for readable output
190
+ ntg_flag = well.get_property('NTG_Flag')
191
+ ntg_flag.type = 'discrete'
192
+ ntg_flag.labels = {0: 'NonNet', 1: 'Net'}
193
+
194
+ # Chain filters for hierarchical grouping
195
+ stats = well.PHIE.filter('Zone').filter('NTG_Flag').sums_avg()
196
+ # {
197
+ # 'Top_Brent': {
198
+ # 'Net': {'mean': 0.21, 'thickness': 150.0, 'samples': 150},
199
+ # 'NonNet': {'mean': 0.08, 'thickness': 100.0, 'samples': 100}
200
+ # },
201
+ # 'Top_Statfjord': {...}
202
+ # }
203
+ ```
204
+
205
+ **Each statistics group includes:**
206
+ - `mean`, `sum`, `std_dev` - Depth-weighted by default
207
+ - `percentile` - p10, p50, p90 values
208
+ - `thickness` - Depth interval for this group
209
+ - `samples` - Number of valid measurements
210
+ - `range`, `depth_range` - Min/max values and depths
211
+
212
+ ### Property Operations
213
+
214
+ Create computed properties using natural mathematical syntax:
215
+
216
+ ```python
217
+ # Arithmetic (strict depth matching like numpy)
218
+ well.HC_Volume = well.PHIE * (1 - well.SW)
219
+ well.Porosity_Avg = (well.PHIE + well.PHIT) / 2
220
+
221
+ # Comparisons (auto-creates discrete properties)
222
+ well.High_Poro = well.PHIE > 0.15
223
+ well.Reservoir = (well.PHIE > 0.15) & (well.SW < 0.35)
224
+
225
+ # Use computed properties in filtering
226
+ stats = well.PHIE.filter('Reservoir').sums_avg()
227
+ # → {'False': {...}, 'True': {...}}
228
+ ```
229
+
230
+ > **💡 Pro Tip:** Computed properties are stored in a special `'computed'` source and can be exported to LAS files.
231
+
232
+ ### Depth Alignment
233
+
234
+ Operations fail if depth grids don't match (prevents silent interpolation errors):
235
+
236
+ ```python
237
+ # This fails if depths don't match
238
+ result = well.PHIE + well.CorePHIE # DepthAlignmentError
239
+
240
+ # Explicit resampling required
241
+ core_resampled = well.CorePHIE.resample(well.PHIE)
242
+ result = well.PHIE + core_resampled # ✓ Works
243
+ ```
244
+
245
+ ### Manager-Level Statistics
246
+
247
+ Compute statistics across all wells in a single call:
248
+
249
+ ```python
250
+ # Single statistic across all wells
251
+ p50 = manager.PHIE.percentile(50)
252
+ # → {'well_A': 0.182, 'well_B': 0.195, 'well_C': 0.173}
253
+
254
+ # With filtering - grouped by filter values per well
255
+ stats = manager.PHIE.filter('Zone').percentile(50)
256
+ # → {'well_A': {'Top_Brent': 0.21, 'Top_Statfjord': 0.15},
257
+ # 'well_B': {'Top_Brent': 0.19, 'Top_Statfjord': 0.17}}
258
+
259
+ # Chain filters for hierarchical grouping
260
+ stats = manager.PHIE.filter('Zone').filter('Facies').mean()
261
+
262
+ # All statistics available: min, max, mean, median, std, percentile
263
+ stats = manager.PHIE.filter('Zone').min()
264
+ stats = manager.PHIE.filter('Zone').max()
265
+ ```
266
+
267
+ **Ambiguous properties** (existing in multiple sources like log + core) automatically nest by source:
268
+
269
+ ```python
270
+ # If well_A has PHIE in both 'log' and 'core' sources:
271
+ p50 = manager.PHIE.percentile(50)
272
+ # → {'well_A': {'log': 0.182, 'core': 0.205}, 'well_B': 0.195}
273
+
274
+ # With filtering, only sources with the filter property are included:
275
+ stats = manager.PHIE.filter('Zone').percentile(50)
276
+ # → {'well_A': {'log': {'Top_Brent': 0.21, ...}}, 'well_B': {...}}
277
+ # (core source excluded if it lacks 'Zone' property)
278
+ ```
279
+
280
+ > **💡 Pro Tip:** Use `nested=True` to always show source names: `manager.PHIE.percentile(50, nested=True)`
281
+
282
+ ### Manager Broadcasting
283
+
284
+ Apply operations to all wells at once:
285
+
286
+ ```python
287
+ # Broadcast to all wells with PHIE
288
+ manager.PHIE_percent = manager.PHIE * 100
289
+
290
+ # Broadcast complex operations
291
+ manager.HC_Volume = manager.PHIE * (1 - manager.SW)
292
+ # ✓ Created property 'HC_Volume' in 12 well(s)
293
+ # ⚠ Skipped 3 well(s) without property 'PHIE' or 'SW'
294
+ ```
295
+
296
+ Wells without required properties are automatically skipped with a warning.
297
+
298
+ ### Depth-Weighted Statistics
299
+
300
+ Standard arithmetic mean fails with irregular sampling:
301
+
302
+ ```python
303
+ # Example: NTG flag
304
+ # Depths: 1500m, 1501m, 1505m
305
+ # Values: 0, 1, 0
306
+
307
+ # Arithmetic mean: (0+1+0)/3 = 0.33 ❌ (treats all samples equally)
308
+ # Weighted mean: accounts for 2.5m interval at depth 1501m = 0.50 ✓
309
+
310
+ # Compare both methods
311
+ stats = well.NTG.filter('Zone').sums_avg(arithmetic=True)
312
+ # Returns: {'mean': {'weighted': 0.50, 'arithmetic': 0.33}, ...}
313
+ ```
314
+
315
+ > **✨ Key Insight:** Weighted statistics properly handle irregular sample spacing by accounting for depth intervals.
316
+
317
+ ### Sampled Data (Core Plugs)
318
+
319
+ Core plug measurements are point samples requiring different treatment:
320
+
321
+ ```python
322
+ # Load core data as sampled
323
+ manager.load_las('core_plugs.las', sampled=True)
324
+
325
+ # Or mark properties as sampled
326
+ well.CorePHIE.type = 'sampled'
327
+
328
+ # Sampled data uses arithmetic mean (each plug counts equally)
329
+ stats = well.CorePHIE.filter('Zone').sums_avg()
330
+ # → {'Top_Brent': {'mean': 0.205, 'samples': 12, 'calculation': 'arithmetic'}}
331
+ ```
332
+
333
+ ### Project Persistence
334
+
335
+ Save and restore entire projects:
336
+
337
+ ```python
338
+ # Save project structure
339
+ manager.save('my_project/')
340
+ # Creates: my_project/well_12_3_4_A/Petrophysics.las, Imported_Tops.las, ...
341
+
342
+ # Load project (restores everything)
343
+ manager = WellDataManager('my_project/')
344
+
345
+ # All wells, properties, labels, and metadata are restored
346
+ ```
347
+
348
+ ---
349
+
350
+ ## Visualization
351
+
352
+ Create publication-quality well log displays optimized for Jupyter Lab. Build customizable templates with multiple tracks showing continuous logs, discrete properties, fills, and formation tops.
353
+
354
+ ### Quick Start
355
+
356
+ ```python
357
+ from well_log_toolkit import WellDataManager, Template
358
+
359
+ # Load data
360
+ manager = WellDataManager()
361
+ manager.load_las("well.las")
362
+ well = manager.well_36_7_5_A
363
+
364
+ # Create a simple display with default template
365
+ view = well.WellView(depth_range=[2800, 3000])
366
+ view.show() # Displays inline in Jupyter
367
+
368
+ # Save to file
369
+ view.save("well_log.png", dpi=300)
370
+ ```
371
+
372
+ ### Creating Templates
373
+
374
+ Templates define the layout and styling of well log displays:
375
+
376
+ ```python
377
+ from well_log_toolkit import Template
378
+
379
+ # Create template
380
+ template = Template("reservoir")
381
+
382
+ # Add GR track with colormap fill
383
+ template.add_track(
384
+ track_type="continuous",
385
+ logs=[{"name": "GR", "x_range": [0, 150], "color": "black"}],
386
+ fill={
387
+ "left": "track_edge", # Simplified: track edge
388
+ "right": "GR", # Simplified: curve name
389
+ "colormap": "viridis", # Creates horizontal color bands
390
+ "color_range": [20, 150], # GR values map to colormap
391
+ "alpha": 0.7
392
+ },
393
+ title="Gamma Ray"
394
+ )
395
+
396
+ # Add porosity and saturation track
397
+ template.add_track(
398
+ track_type="continuous",
399
+ logs=[
400
+ {"name": "PHIE", "x_range": [0.45, 0], "color": "blue"},
401
+ {"name": "SW", "x_range": [0, 1], "color": "red"}
402
+ ],
403
+ fill={
404
+ "left": "PHIE", # Simplified: curve name
405
+ "right": 0, # Simplified: numeric value
406
+ "color": "lightblue",
407
+ "alpha": 0.5
408
+ },
409
+ title="Porosity & Saturation"
410
+ )
411
+
412
+ # Add resistivity track (logarithmic scale)
413
+ template.add_track(
414
+ track_type="continuous",
415
+ logs=[
416
+ {"name": "ILD", "x_range": [0.2, 2000], "color": "red"},
417
+ {"name": "ILM", "x_range": [0.2, 2000], "color": "green"}
418
+ ],
419
+ title="Resistivity"
420
+ )
421
+
422
+ # Add facies track (discrete/categorical)
423
+ template.add_track(
424
+ track_type="discrete",
425
+ logs=[{"name": "Facies"}],
426
+ tops={
427
+ "name": "Well_Tops",
428
+ "line_style": "--",
429
+ "line_width": 2.0,
430
+ "title_size": 9,
431
+ "title_weight": "bold",
432
+ "title_orientation": "right"
433
+ },
434
+ title="Facies"
435
+ )
436
+
437
+ # Add depth track
438
+ template.add_track(track_type="depth", width=0.3, title="Depth")
439
+
440
+ # Save template for reuse
441
+ template.save("reservoir_template.json")
442
+ ```
443
+
444
+ ### Using Templates
445
+
446
+ **Option 1: Pass template directly**
447
+ ```python
448
+ view = well.WellView(depth_range=[2800, 3000], template=template)
449
+ view.show()
450
+ ```
451
+
452
+ **Option 2: Store in manager (recommended)**
453
+ ```python
454
+ # Store template in manager
455
+ manager.set_template("reservoir", template)
456
+
457
+ # Use by name in any well
458
+ view = well.WellView(depth_range=[2800, 3000], template="reservoir")
459
+ view.show()
460
+
461
+ # List all templates
462
+ print(manager.list_templates()) # ['reservoir', 'qc', 'basic']
463
+
464
+ # Templates are saved with projects
465
+ manager.save("my_project/") # Saves to my_project/templates/reservoir.json
466
+ ```
467
+
468
+ **Option 3: Load from file**
469
+ ```python
470
+ template = Template.load("reservoir_template.json")
471
+ view = well.WellView(depth_range=[2800, 3000], template=template)
472
+ view.show()
473
+ ```
474
+
475
+ ### Track Types
476
+
477
+ **Continuous Tracks** - For log curves (GR, RHOB, NPHI, etc.)
478
+ ```python
479
+ template.add_track(
480
+ track_type="continuous",
481
+ logs=[
482
+ {"name": "GR", "x_range": [0, 150], "color": "green", "style": "-"},
483
+ {"name": "CALI", "x_range": [6, 16], "color": "black", "style": "--"}
484
+ ],
485
+ title="GR & Caliper"
486
+ )
487
+ ```
488
+
489
+ **Discrete Tracks** - For categorical data (facies, zones)
490
+ ```python
491
+ template.add_track(
492
+ track_type="discrete",
493
+ logs=[{"name": "Facies"}],
494
+ title="Lithology"
495
+ )
496
+ ```
497
+
498
+ **Depth Tracks** - Show depth axis
499
+ ```python
500
+ template.add_track(track_type="depth", width=0.3)
501
+ ```
502
+
503
+ ### Fill Patterns
504
+
505
+ **Solid Color Fill**
506
+ ```python
507
+ fill={
508
+ "left": "PHIE", # Simplified: curve name
509
+ "right": 0, # Simplified: numeric value
510
+ "color": "lightblue",
511
+ "alpha": 0.5
512
+ }
513
+ ```
514
+
515
+ **Colormap Fill** (horizontal bands colored by curve value)
516
+ ```python
517
+ fill={
518
+ "left": "track_edge", # Simplified: track edge
519
+ "right": "GR", # Simplified: curve name
520
+ "colormap": "viridis", # or "inferno", "plasma", "RdYlGn"
521
+ "color_range": [20, 150], # GR values map to colors
522
+ "alpha": 0.7
523
+ }
524
+ # Low GR (20) → dark purple, High GR (150) → bright yellow
525
+ ```
526
+
527
+ **Fill Between Two Curves**
528
+ ```python
529
+ fill={
530
+ "left": "RHOB", # Simplified: curve name
531
+ "right": "NPHI", # Simplified: curve name
532
+ "colormap": "RdYlGn",
533
+ "colormap_curve": "NPHI", # Use NPHI values for colors
534
+ "color_range": [0.15, 0.35],
535
+ "alpha": 0.6
536
+ }
537
+ ```
538
+
539
+ ### Formation Tops
540
+
541
+ Add formation markers to any track:
542
+
543
+ ```python
544
+ template.add_track(
545
+ track_type="discrete",
546
+ logs=[{"name": "Facies"}],
547
+ tops={
548
+ "name": "Well_Tops", # Property containing tops
549
+ "line_style": "--", # Line style
550
+ "line_width": 2.0, # Line thickness
551
+ "title_size": 9, # Label font size
552
+ "title_weight": "bold", # Font weight
553
+ "title_orientation": "right", # Label position (left/center/right)
554
+ "line_offset": 0.0 # Horizontal offset
555
+ }
556
+ )
557
+ ```
558
+
559
+ ### Template Management
560
+
561
+ ```python
562
+ # Store template
563
+ manager.set_template("reservoir", template)
564
+
565
+ # Retrieve template
566
+ template = manager.get_template("reservoir")
567
+
568
+ # List all templates
569
+ templates = manager.list_templates() # ['reservoir', 'qc', 'basic']
570
+
571
+ # Remove template
572
+ manager.remove_template("old_template")
573
+
574
+ # Templates save with projects
575
+ manager.save("my_project/")
576
+ # Creates: my_project/templates/*.json
577
+
578
+ # Templates load with projects
579
+ manager.load("my_project/")
580
+ print(manager.list_templates()) # All saved templates restored
581
+ ```
582
+
583
+ ### Editing Templates
584
+
585
+ ```python
586
+ # Load existing template
587
+ template = manager.get_template("reservoir")
588
+
589
+ # View tracks
590
+ df = template.list_tracks()
591
+ print(df)
592
+ # Index Type Logs Title Width
593
+ # 0 0 continuous [GR] Gamma Ray 1.0
594
+ # 1 1 continuous [PHIE, SW] Porosity 1.0
595
+ # 2 2 depth [] Depth 0.3
596
+
597
+ # Edit track
598
+ template.edit_track(0, title="New Title")
599
+
600
+ # Remove track
601
+ template.remove_track(2)
602
+
603
+ # Add new track
604
+ template.add_track(track_type="continuous", logs=[{"name": "RT"}])
605
+
606
+ # Save changes
607
+ template.save("updated_template.json")
608
+ ```
609
+
610
+ ### Customization Options
611
+
612
+ **Log Styling**
613
+ ```python
614
+ logs=[{
615
+ "name": "GR",
616
+ "x_range": [0, 150], # X-axis limits [left, right]
617
+ "color": "green", # Line color (name or hex)
618
+ "style": "-", # Line style ("-", "--", "-.", ":")
619
+ "thickness": 1.5, # Line width
620
+ "alpha": 0.8 # Transparency (0-1)
621
+ }]
622
+ ```
623
+
624
+ **Figure Settings**
625
+ ```python
626
+ view = well.WellView(
627
+ depth_range=[2800, 3000],
628
+ template="reservoir",
629
+ figsize=(12, 10), # Width x height in inches
630
+ dpi=100 # Resolution
631
+ )
632
+ ```
633
+
634
+ **Export Options**
635
+ ```python
636
+ # PNG for presentations
637
+ view.save("well_log.png", dpi=300)
638
+
639
+ # PDF for publications
640
+ view.save("well_log.pdf")
641
+
642
+ # SVG for editing
643
+ view.save("well_log.svg")
644
+ ```
645
+
646
+ ### Complete Example
647
+
648
+ ```python
649
+ from well_log_toolkit import WellDataManager, Template
650
+
651
+ # Setup
652
+ manager = WellDataManager()
653
+ manager.load_las("well.las")
654
+
655
+ # Create comprehensive template
656
+ template = Template("petrophysics")
657
+
658
+ # Track 1: GR with colormap
659
+ template.add_track(
660
+ track_type="continuous",
661
+ logs=[{"name": "GR", "x_range": [0, 150], "color": "black"}],
662
+ fill={
663
+ "left": "track_edge",
664
+ "right": "GR",
665
+ "colormap": "viridis",
666
+ "color_range": [20, 150],
667
+ "alpha": 0.7
668
+ },
669
+ title="Gamma Ray (API)"
670
+ )
671
+
672
+ # Track 2: Resistivity
673
+ template.add_track(
674
+ track_type="continuous",
675
+ logs=[
676
+ {"name": "ILD", "x_range": [0.2, 2000], "color": "red", "thickness": 1.5},
677
+ {"name": "ILM", "x_range": [0.2, 2000], "color": "green"}
678
+ ],
679
+ title="Resistivity (ohmm)"
680
+ )
681
+
682
+ # Track 3: Density-Neutron
683
+ template.add_track(
684
+ track_type="continuous",
685
+ logs=[
686
+ {"name": "RHOB", "x_range": [1.95, 2.95], "color": "red"},
687
+ {"name": "NPHI", "x_range": [0.45, -0.15], "color": "blue"}
688
+ ],
689
+ fill={
690
+ "left": "RHOB",
691
+ "right": "NPHI",
692
+ "colormap": "RdYlGn",
693
+ "color_range": [-0.15, 0.45],
694
+ "alpha": 0.5
695
+ },
696
+ title="Density-Neutron"
697
+ )
698
+
699
+ # Track 4: Porosity & Saturation
700
+ template.add_track(
701
+ track_type="continuous",
702
+ logs=[
703
+ {"name": "PHIE", "x_range": [0.45, 0], "color": "blue"},
704
+ {"name": "SW", "x_range": [0, 1], "color": "red"}
705
+ ],
706
+ fill={
707
+ "left": "PHIE",
708
+ "right": 0,
709
+ "color": "lightblue",
710
+ "alpha": 0.5
711
+ },
712
+ title="PHIE & SW"
713
+ )
714
+
715
+ # Track 5: Facies with formation tops
716
+ template.add_track(
717
+ track_type="discrete",
718
+ logs=[{"name": "Facies"}],
719
+ tops={
720
+ "name": "Well_Tops",
721
+ "line_style": "--",
722
+ "line_width": 2.0,
723
+ "title_size": 9,
724
+ "title_weight": "bold",
725
+ "title_orientation": "right"
726
+ },
727
+ title="Lithofacies"
728
+ )
729
+
730
+ # Track 6: Depth
731
+ template.add_track(track_type="depth", width=0.3, title="MD (m)")
732
+
733
+ # Save template and create display
734
+ manager.set_template("petrophysics", template)
735
+ well = manager.well_36_7_5_A
736
+ view = well.WellView(depth_range=[2800, 3200], template="petrophysics")
737
+ view.save("petrophysics_display.png", dpi=300)
738
+ ```
739
+
740
+ ---
741
+
742
+ ## Documentation
743
+
744
+ ### Quick References
745
+
746
+ Jump directly to specific topics:
747
+
748
+ - **[Managing Wells](#managing-wells)** - Add, remove, access wells
749
+ - **[Manager-Level Statistics](#manager-level-statistics)** - Cross-well analytics
750
+ - **[Visualization](#visualization)** - Create well log displays with templates
751
+ - **[Formation Tops](#formation-tops-setup)** - Load and configure formation tops
752
+ - **[Discrete Properties](#discrete-properties--labels)** - Set up labels for readable output
753
+ - **[Statistics Explained](#understanding-statistics-output)** - What each statistic means
754
+ - **[Export Options](#export-options)** - DataFrame and LAS export
755
+ - **[Managing Sources](#managing-sources)** - Organize and rename sources
756
+ - **[Adding Data](#adding-external-data)** - Import from DataFrames
757
+ - **[Property Printing](#property-printing)** - Inspect data visually
758
+ - **[Troubleshooting](#troubleshooting)** - Common issues and solutions
759
+
760
+ ### API Reference
761
+
762
+ ```python
763
+ # Main classes
764
+ from well_log_toolkit import WellDataManager, Well, Property, LasFile
765
+
766
+ # Visualization
767
+ from well_log_toolkit import Template, WellView
768
+
769
+ # Statistics functions
770
+ from well_log_toolkit import compute_intervals, mean, sum, std, percentile
771
+
772
+ # Exceptions
773
+ from well_log_toolkit import (
774
+ DepthAlignmentError,
775
+ PropertyNotFoundError,
776
+ PropertyTypeError
777
+ )
778
+ ```
779
+
780
+ ### Common Patterns
781
+
782
+ **Load and analyze quickly:**
783
+ ```python
784
+ manager = WellDataManager()
785
+ manager.load_las('well.las')
786
+ stats = manager.well_12_3_4_A.PHIE.filter('Zone').sums_avg()
787
+ ```
788
+
789
+ **Chain multiple filters:**
790
+ ```python
791
+ stats = well.PHIE.filter('Zone').filter('Facies').filter('NTG_Flag').sums_avg()
792
+ ```
793
+
794
+ **Multi-well statistics:**
795
+ ```python
796
+ # Cross-well P50 by zone
797
+ p50_by_zone = manager.PHIE.filter('Zone').percentile(50)
798
+
799
+ # Compare statistics across wells
800
+ means = manager.PHIE.filter('Zone').mean()
801
+ stds = manager.PHIE.filter('Zone').std()
802
+ ```
803
+
804
+ **Create computed properties:**
805
+ ```python
806
+ well.HC_Volume = well.PHIE * (1 - well.SW)
807
+ well.Reservoir = (well.PHIE > 0.15) & (well.SW < 0.35)
808
+ ```
809
+
810
+ **Broadcast across wells:**
811
+ ```python
812
+ manager.PHIE_percent = manager.PHIE * 100
813
+ manager.Reservoir = (manager.PHIE > 0.15) & (manager.SW < 0.35)
814
+ ```
815
+
816
+ **Visualize well logs:**
817
+ ```python
818
+ # Quick display
819
+ view = well.WellView(depth_range=[2800, 3000])
820
+ view.show()
821
+
822
+ # With custom template
823
+ template = Template("reservoir")
824
+ template.add_track(track_type="continuous", logs=[{"name": "GR"}])
825
+ manager.set_template("reservoir", template)
826
+ view = well.WellView(template="reservoir")
827
+ view.save("log.png", dpi=300)
828
+ ```
829
+
830
+ **Save and restore projects:**
831
+ ```python
832
+ manager.save('project/')
833
+ manager = WellDataManager('project/')
834
+ ```
835
+
836
+ ---
837
+
838
+ ## Detailed Guide
839
+
840
+ ### Managing Wells
841
+
842
+ ```python
843
+ # List all wells
844
+ print(manager.wells) # ['well_12_3_4_A', 'well_12_3_4_B']
845
+
846
+ # Access by sanitized name (attribute access)
847
+ well = manager.well_12_3_4_A
848
+
849
+ # Access by original name
850
+ well = manager.get_well('12/3-4 A') # Works with original name
851
+ well = manager.get_well('12_3_4_A') # Works with sanitized name
852
+ well = manager.get_well('well_12_3_4_A') # Works with well_ prefix
853
+
854
+ # Add well manually
855
+ well = manager.add_well('12/3-4 C')
856
+
857
+ # Remove well
858
+ manager.remove_well('12_3_4_A')
859
+ ```
860
+
861
+ ### Manager-Level Statistics
862
+
863
+ Compute statistics across multiple wells at once. Results are returned as nested dictionaries with well names as keys:
864
+
865
+ ```python
866
+ # Basic statistics - returns value per well
867
+ p50 = manager.PHIE.percentile(50)
868
+ # {'well_A': 0.182, 'well_B': 0.195, 'well_C': 0.173}
869
+
870
+ mean = manager.PHIE.mean()
871
+ std = manager.PHIE.std()
872
+ min_val = manager.PHIE.min()
873
+ max_val = manager.PHIE.max()
874
+ median = manager.PHIE.median()
875
+ ```
876
+
877
+ **With filtering** - returns grouped statistics per well:
878
+
879
+ ```python
880
+ # Group by one filter
881
+ stats = manager.PHIE.filter('Zone').percentile(50)
882
+ # {
883
+ # 'well_A': {'Top_Brent': 0.21, 'Top_Statfjord': 0.15, 'Top_Cook': 0.18},
884
+ # 'well_B': {'Top_Brent': 0.19, 'Top_Statfjord': 0.17}
885
+ # }
886
+
887
+ # Chain multiple filters for hierarchical grouping
888
+ stats = manager.PHIE.filter('Zone').filter('Facies').mean()
889
+ # {
890
+ # 'well_A': {
891
+ # 'Top_Brent': {'Sandstone': 0.23, 'Shale': 0.08},
892
+ # 'Top_Statfjord': {'Sandstone': 0.19, 'Shale': 0.06}
893
+ # },
894
+ # 'well_B': {...}
895
+ # }
896
+ ```
897
+
898
+ **Handling ambiguous properties** - properties existing in multiple sources (e.g., PHIE in both log and core):
899
+
900
+ ```python
901
+ # Without filters - nests by source when ambiguous
902
+ p50 = manager.PHIE.percentile(50)
903
+ # {
904
+ # 'well_A': {'log': 0.182, 'core': 0.205}, # Ambiguous in well_A
905
+ # 'well_B': 0.195 # Unique in well_B
906
+ # }
907
+
908
+ # With filters - only includes sources that have the filter property
909
+ stats = manager.PHIE.filter('Zone').percentile(50)
910
+ # {
911
+ # 'well_A': {'log': {'Top_Brent': 0.21, ...}}, # Only log has Zone
912
+ # 'well_B': {'Top_Brent': 0.19, ...} # Unique, no nesting
913
+ # }
914
+
915
+ # Force nesting for all wells (always show source names)
916
+ stats = manager.PHIE.percentile(50, nested=True)
917
+ # {
918
+ # 'well_A': {'log': 0.182, 'core': 0.205},
919
+ # 'well_B': {'log': 0.195} # Now shows source even though unique
920
+ # }
921
+ ```
922
+
923
+ **Available statistics:**
924
+ - `min()` - Minimum value
925
+ - `max()` - Maximum value
926
+ - `mean()` - Arithmetic or depth-weighted average
927
+ - `median()` - Median value
928
+ - `std()` - Standard deviation
929
+ - `percentile(p)` - Specified percentile (e.g., 10, 50, 90)
930
+
931
+ All methods support `weighted=True` (default) for depth-weighted calculations.
932
+
933
+ ### Formation Tops Setup
934
+
935
+ Formation tops require a specific DataFrame structure:
936
+
937
+ ```python
938
+ import pandas as pd
939
+
940
+ # Required columns: Well, Surface (formation name), MD (depth)
941
+ tops_df = pd.DataFrame({
942
+ 'Well': ['12/3-4 A', '12/3-4 A', '12/3-4 A'],
943
+ 'Surface': ['Top_Brent', 'Top_Statfjord', 'Top_Cook'],
944
+ 'MD': [2850.0, 3100.0, 3400.0]
945
+ })
946
+
947
+ manager.load_tops(
948
+ tops_df,
949
+ property_name='Zone', # Name for discrete property (default: 'Well_Tops')
950
+ source_name='Tops', # Source name (default: 'Imported_Tops')
951
+ well_col='Well', # Column with well names
952
+ discrete_col='Surface', # Column with formation names
953
+ depth_col='MD' # Column with depths
954
+ )
955
+ ```
956
+
957
+ **How tops work:**
958
+ - Each top marks the **start** of that formation
959
+ - Uses **forward-fill**: Top_Brent applies from 2850m down to 3100m
960
+ - At 3100m, Top_Statfjord takes over and applies down to 3400m
961
+ - Labels are automatically created: `{0: 'Top_Brent', 1: 'Top_Statfjord', 2: 'Top_Cook'}`
962
+
963
+ ### Discrete Properties & Labels
964
+
965
+ Labels make discrete properties human-readable:
966
+
967
+ ```python
968
+ # Get discrete property
969
+ ntg = well.get_property('NTG_Flag')
970
+
971
+ # Mark as discrete
972
+ ntg.type = 'discrete'
973
+
974
+ # Add labels (maps numeric values to names)
975
+ ntg.labels = {
976
+ 0: 'NonNet',
977
+ 1: 'Net'
978
+ }
979
+
980
+ # Now statistics use labels instead of numbers
981
+ stats = well.PHIE.filter('NTG_Flag').sums_avg()
982
+ # Returns: {'NonNet': {...}, 'Net': {...}}
983
+ # Instead of: {0: {...}, 1: {...}}
984
+ ```
985
+
986
+ **When to use discrete type:**
987
+ - Zones/formations
988
+ - Facies classifications
989
+ - Flags (net/non-net, pay/non-pay)
990
+ - Rock types
991
+ - Any categorical data
992
+
993
+ ### Understanding Statistics Output
994
+
995
+ Each statistics group contains:
996
+
997
+ ```python
998
+ stats = well.PHIE.filter('Zone').sums_avg()
999
+
1000
+ # Example output for one zone:
1001
+ {
1002
+ 'Top_Brent': {
1003
+ # Core statistics (depth-weighted by default)
1004
+ 'mean': 0.182, # Average porosity
1005
+ 'sum': 45.5, # Sum (useful for flags: sum of NTG = net thickness)
1006
+ 'std_dev': 0.044, # Standard deviation
1007
+
1008
+ # Percentiles
1009
+ 'percentile': {
1010
+ 'p10': 0.09, # 10th percentile (pessimistic)
1011
+ 'p50': 0.18, # Median
1012
+ 'p90': 0.24 # 90th percentile (optimistic)
1013
+ },
1014
+
1015
+ # Value range
1016
+ 'range': {
1017
+ 'min': 0.05, # Minimum value
1018
+ 'max': 0.28 # Maximum value
1019
+ },
1020
+
1021
+ # Depth information
1022
+ 'depth_range': {
1023
+ 'min': 2850.0, # Top of zone
1024
+ 'max': 3100.0 # Base of zone
1025
+ },
1026
+
1027
+ # Sample information
1028
+ 'samples': 250, # Number of non-NaN measurements
1029
+ 'thickness': 250.0, # Interval thickness (sum of depth intervals)
1030
+ 'gross_thickness': 555.0, # Total thickness across all zones
1031
+ 'thickness_fraction': 0.45, # Fraction of total (thickness/gross_thickness)
1032
+
1033
+ # Metadata
1034
+ 'calculation': 'weighted' # Method: 'weighted', 'arithmetic', or 'both'
1035
+ }
1036
+ }
1037
+ ```
1038
+
1039
+ **Compare weighted vs arithmetic:**
1040
+ ```python
1041
+ stats = well.PHIE.filter('Zone').sums_avg(arithmetic=True)
1042
+
1043
+ # Values become dicts with both methods:
1044
+ {
1045
+ 'Top_Brent': {
1046
+ 'mean': {'weighted': 0.182, 'arithmetic': 0.179},
1047
+ 'sum': {'weighted': 45.5, 'arithmetic': 44.8},
1048
+ # ... other fields also have both methods
1049
+ 'calculation': 'both'
1050
+ }
1051
+ }
1052
+ ```
1053
+
1054
+ ### Export Options
1055
+
1056
+ **To DataFrame:**
1057
+ ```python
1058
+ # All properties
1059
+ df = well.data()
1060
+
1061
+ # Specific properties only
1062
+ df = well.data(include=['PHIE', 'SW', 'PERM'])
1063
+
1064
+ # Exclude properties
1065
+ df = well.data(exclude=['DEPT'])
1066
+
1067
+ # Auto-resample to common depth grid (when properties have different depths)
1068
+ df = well.data(auto_resample=True)
1069
+
1070
+ # Use labels for discrete properties
1071
+ df = well.data(discrete_labels=True)
1072
+ # Zone column shows: 'Top_Brent', 'Top_Statfjord'
1073
+ # Instead of: 0, 1
1074
+ ```
1075
+
1076
+ **To LAS file:**
1077
+ ```python
1078
+ # Export all properties
1079
+ well.export_to_las('output.las')
1080
+
1081
+ # Specific properties
1082
+ well.export_to_las('output.las', include=['PHIE', 'SW'])
1083
+
1084
+ # Use original LAS as template (preserves header info)
1085
+ well.export_to_las('output.las', use_template=True)
1086
+
1087
+ # Export each source separately
1088
+ well.export_sources('output_folder/')
1089
+ # Creates: Petrophysics.las, Imported_Tops.las, computed.las
1090
+ ```
1091
+
1092
+ ### Managing Sources
1093
+
1094
+ Sources organize properties by origin (e.g., different LAS files, imported data):
1095
+
1096
+ ```python
1097
+ # List all sources
1098
+ print(well.sources) # ['Petrophysics', 'CoreData', 'Imported_Tops']
1099
+
1100
+ # Access properties through source
1101
+ phie = well.Petrophysics.PHIE
1102
+ core_phie = well.CoreData.CorePHIE
1103
+
1104
+ # List properties in a source
1105
+ print(well.Petrophysics.properties) # ['DEPT', 'PHIE', 'SW', 'PERM']
1106
+
1107
+ # Rename source
1108
+ well.rename_source('CoreData', 'Core_Porosity')
1109
+ print(well.sources) # ['Petrophysics', 'Core_Porosity', 'Imported_Tops']
1110
+
1111
+ # Remove source (deletes all its properties)
1112
+ well.remove_source('Core_Porosity')
1113
+ print(well.sources) # ['Petrophysics', 'Imported_Tops']
1114
+ ```
1115
+
1116
+ **Changes are saved to disk:**
1117
+ ```python
1118
+ manager.save() # Renamed files updated, removed files deleted
1119
+ ```
1120
+
1121
+ ### Adding External Data
1122
+
1123
+ Load data from pandas DataFrames:
1124
+
1125
+ ```python
1126
+ import pandas as pd
1127
+
1128
+ # Create DataFrame with depth column
1129
+ external_df = pd.DataFrame({
1130
+ 'DEPT': [2800, 2801, 2802, 2803],
1131
+ 'CorePHIE': [0.20, 0.22, 0.19, 0.21],
1132
+ 'CorePERM': [150, 200, 120, 180]
1133
+ })
1134
+
1135
+ # Add to well
1136
+ well.add_dataframe(
1137
+ external_df,
1138
+ source_name='CoreData', # Optional, defaults to 'external_df'
1139
+ unit_mappings={ # Optional, specify units
1140
+ 'CorePHIE': 'v/v',
1141
+ 'CorePERM': 'mD'
1142
+ },
1143
+ type_mappings={ # Optional, specify types
1144
+ 'CorePHIE': 'continuous',
1145
+ 'CorePERM': 'continuous'
1146
+ },
1147
+ label_mappings={} # Optional, for discrete properties
1148
+ )
1149
+
1150
+ # Access new properties
1151
+ print(well.CoreData.CorePHIE.values)
1152
+ ```
1153
+
1154
+ ### Property Printing
1155
+
1156
+ Inspect properties directly:
1157
+
1158
+ ```python
1159
+ # Print property (numpy-style, auto-clips large arrays)
1160
+ print(well.PHIE)
1161
+ # [PHIE] (1001 samples)
1162
+ # depth: [2800.00, 2801.00, 2802.00, ..., 3798.00, 3799.00, 3800.00]
1163
+ # values (v/v): [0.180, 0.185, 0.192, ..., 0.215, 0.212, 0.210]
1164
+
1165
+ # Print filtered property (shows filter values)
1166
+ filtered = well.PHIE.filter('Zone').filter('NTG_Flag')
1167
+ print(filtered)
1168
+ # [PHIE] (1001 samples)
1169
+ # depth: [2800.00, 2801.00, ..., 3800.00]
1170
+ # values (v/v): [0.180, 0.185, ..., 0.210]
1171
+ #
1172
+ # Filters:
1173
+ # Zone: [Top_Brent, Top_Brent, ..., Top_Statfjord]
1174
+ # NTG_Flag: [NonNet, Net, ..., Net]
1175
+
1176
+ # Print manager-level property (shows all wells)
1177
+ print(manager.PHIE)
1178
+ # [PHIE] across 3 well(s):
1179
+ #
1180
+ # Well: well_12_3_4_A
1181
+ # [PHIE] (1001 samples)
1182
+ # ...
1183
+ #
1184
+ # Well: well_12_3_4_B
1185
+ # [PHIE] (856 samples)
1186
+ # ...
1187
+ ```
1188
+
1189
+ ### Troubleshooting
1190
+
1191
+ **DepthAlignmentError: Cannot combine properties with different depth grids**
1192
+
1193
+ ```python
1194
+ # Problem: Properties have different depths
1195
+ result = well.PHIE + well.CorePHIE # Error!
1196
+
1197
+ # Solution: Explicitly resample
1198
+ core_resampled = well.CorePHIE.resample(well.PHIE)
1199
+ result = well.PHIE + core_resampled # Works!
1200
+ ```
1201
+
1202
+ **PropertyNotFoundError: Property not found**
1203
+
1204
+ ```python
1205
+ # Problem: Property doesn't exist or wrong name
1206
+ phie = well.PHIE_TOTAL # Error if property doesn't exist
1207
+
1208
+ # Solution: Check available properties
1209
+ print(well.properties) # List all properties
1210
+ print(well.sources) # List all sources
1211
+
1212
+ # Or check if property exists
1213
+ try:
1214
+ phie = well.get_property('PHIE_TOTAL')
1215
+ except PropertyNotFoundError:
1216
+ print("Property not found, using default")
1217
+ phie = well.PHIE
1218
+ ```
1219
+
1220
+ **PropertyTypeError: Property must be discrete type**
1221
+
1222
+ ```python
1223
+ # Problem: Trying to filter by non-discrete property
1224
+ stats = well.PHIE.filter('PERM').sums_avg() # Error!
1225
+
1226
+ # Solution: Mark property as discrete
1227
+ perm = well.get_property('PERM')
1228
+ perm.type = 'discrete'
1229
+ perm.labels = {0: 'Low', 1: 'Medium', 2: 'High'}
1230
+ stats = well.PHIE.filter('PERM').sums_avg() # Works!
1231
+ ```
1232
+
1233
+ **Missing statistics for some zones**
1234
+
1235
+ ```python
1236
+ # Problem: No valid data in some zones
1237
+ stats = well.PHIE.filter('Zone').sums_avg()
1238
+ # Some zones might be missing if all PHIE values are NaN in that zone
1239
+
1240
+ # Solution: Check raw data
1241
+ print(well.PHIE.values) # Look for NaN values
1242
+ print(well.Zone.values) # Check zone distribution
1243
+
1244
+ # Or filter to remove NaN
1245
+ valid_mask = ~np.isnan(well.PHIE.values)
1246
+ valid_depths = well.PHIE.depth[valid_mask]
1247
+ ```
1248
+
1249
+ **Computed properties not showing up**
1250
+
1251
+ ```python
1252
+ # After creating computed properties
1253
+ well.HC_Volume = well.PHIE * (1 - well.SW)
1254
+
1255
+ # Check they exist
1256
+ print(well.sources) # Should include 'computed'
1257
+ print(well.computed.properties) # List computed properties
1258
+
1259
+ # Access directly
1260
+ hc = well.HC_Volume # Works
1261
+
1262
+ # Or through source
1263
+ hc = well.computed.HC_Volume # Also works
1264
+ ```
1265
+
1266
+ ---
1267
+
1268
+ ## Requirements
1269
+
1270
+ - Python >= 3.9
1271
+ - numpy >= 1.20.0
1272
+ - pandas >= 1.3.0
1273
+ - scipy >= 1.7.0
1274
+ - matplotlib >= 3.5.0
1275
+
1276
+ ## Performance
1277
+
1278
+ All operations use **vectorized numpy** for maximum speed:
1279
+ - 100M+ samples/second throughput
1280
+ - Typical well logs (1k-10k samples) process in < 1ms
1281
+ - Filtered statistics (2 filters, 10 wells): ~9ms
1282
+ - Manager-level operations optimized with property caching
1283
+ - I/O bottleneck eliminated with lazy loading
1284
+
1285
+ ## Contributing
1286
+
1287
+ Contributions welcome! Please submit a Pull Request.
1288
+
1289
+ ## License
1290
+
1291
+ MIT License