From 7359f8fd3a65da178ffa122775dc8bc192429f74 Mon Sep 17 00:00:00 2001 From: dougchristie Date: Mon, 25 May 2026 22:10:54 -0600 Subject: [PATCH 1/2] Add interactive launcher: mineral controls and in-viewer navigation main.py (GUI launcher): - Expose mineral on/off toggles (Quartz/Calcite/Dolomite) in the calculation dialog, so sandstone/shale wells are no longer forced into a quartz+calcite+dolomite solve that produced bad mineralogy. - Backfill zeroed curves for deselected minerals so the viewer's mineralogy track renders them as 0 instead of prompting. - Wrap the workflow in a loop with a "what next?" menu (change template, re-run calculations, open another file, quit) so you can iterate without restarting and re-picking the file. petropy/graphs.py: add Recalc / Template / Open File toolbar buttons (with icons) that hand control back to the launcher loop; falls back to the menu if the window is closed directly. run_petropy.bat: launch from this folder using the isolated "petropy" conda environment. Co-Authored-By: Claude Opus 4.7 --- main.py | 504 +++++++++++++++++++++++++++++++ petropy/data/images/openfile.png | Bin 0 -> 136 bytes petropy/data/images/recalc.png | Bin 0 -> 182 bytes petropy/data/images/template.png | Bin 0 -> 122 bytes petropy/graphs.py | 288 +++++++++++++++++- run_petropy.bat | 31 ++ 6 files changed, 812 insertions(+), 11 deletions(-) create mode 100644 main.py create mode 100644 petropy/data/images/openfile.png create mode 100644 petropy/data/images/recalc.png create mode 100644 petropy/data/images/template.png create mode 100644 run_petropy.bat diff --git a/main.py b/main.py new file mode 100644 index 0000000..a6db7cd --- /dev/null +++ b/main.py @@ -0,0 +1,504 @@ +import tkinter as tk +from tkinter import filedialog, ttk, messagebox +import numpy as np +import os +import petropy as ptr + +ALIAS_XML = os.path.join(os.path.dirname(ptr.__file__), 'data', 'curve_alias.xml') +REQUIRED_CURVES = ['GR_N', 'NPHI_N', 'RHOB_N', 'RESDEEP_N'] + + +def resolve_missing_curves(log): + """ + For each required curve missing from the log, show a dialog letting + the user pick a substitute from the available curves. + Saves the mapping to curve_alias.xml for future use. + Returns False if the user cancels a required curve, True otherwise. + """ + missing = [c for c in REQUIRED_CURVES if c not in log.keys()] + if not missing: + return True + + available = sorted(log.keys()) + + for curve_name in missing: + root = tk.Tk() + root.withdraw() + dlg = tk.Toplevel(root) + dlg.title('Missing Required Curve') + dlg.resizable(False, False) + w, h = 480, 230 + x = (root.winfo_screenwidth() - w) // 2 + y = (root.winfo_screenheight() - h) // 2 + dlg.geometry(f'{w}x{h}+{x}+{y}') + dlg.attributes('-topmost', True) + dlg.lift() + dlg.focus_force() + + pad = {'padx': 10, 'pady': 6} + tk.Label(dlg, + text=f'Required curve "{curve_name}" not found in this log.', + font=('TkDefaultFont', 10, 'bold')).pack(**pad) + tk.Label(dlg, + text='Select the curve from this log to use instead:', + anchor='w').pack(fill='x', padx=10) + + choice_var = tk.StringVar(value=available[0]) + ttk.Combobox(dlg, textvariable=choice_var, values=available, + state='readonly', width=42).pack(**pad) + + save_var = tk.BooleanVar(value=True) + tk.Checkbutton(dlg, text='Save to curve_alias.xml (remembered next time)', + variable=save_var).pack(anchor='w', padx=10) + + result = [None] + + def on_use(cn=curve_name): + chosen = choice_var.get() + if save_var.get(): + with open(ALIAS_XML, 'r', encoding='utf-8') as f: + content = f.read() + close_tag = f'' + new_entry = f' <{chosen}/>\n {close_tag}' + if close_tag in content and f'<{chosen}/>' not in content: + content = content.replace(close_tag, new_entry) + with open(ALIAS_XML, 'w', encoding='utf-8') as f: + f.write(content) + print(f'Saved alias: {chosen} -> {cn}') + result[0] = chosen + dlg.destroy() + + def on_skip(): + result[0] = 'SKIP' + dlg.destroy() + + btn = tk.Frame(dlg) + btn.pack(pady=10) + tk.Button(btn, text='Use Selected', width=14, command=on_use).pack(side='left', padx=8) + tk.Button(btn, text='Skip (may fail)', width=14, command=on_skip).pack(side='left', padx=8) + + root.wait_window(dlg) + root.destroy() + + if result[0] and result[0] != 'SKIP': + # copy the chosen curve data into the log under the required name + log.append_curve(curve_name, np.copy(log[result[0]]), + unit=log.get_curve(result[0]).unit, + descr=f'Alias of {result[0]}') + print(f'Mapped {result[0]} -> {curve_name}') + + return True + + +# ── File / options selection dialog ────────────────────────────────────────── + +def select_files(): + root = tk.Tk() + root.title('PetroPy - Open Well Log') + root.resizable(False, False) + w, h = 480, 310 + x = (root.winfo_screenwidth() - w) // 2 + y = (root.winfo_screenheight() - h) // 2 + root.geometry(f'{w}x{h}+{x}+{y}') + root.attributes('-topmost', True) + + pad = {'padx': 10, 'pady': 5} + + # LAS file + tk.Label(root, text='LAS File:', anchor='w', width=12).grid(row=0, column=0, **pad, sticky='w') + las_var = tk.StringVar() + tk.Entry(root, textvariable=las_var, width=38).grid(row=0, column=1, **pad) + def browse_las(): + p = filedialog.askopenfilename(title='Select LAS file', + filetypes=[('LAS files', '*.las'), ('All files', '*.*')]) + if p: las_var.set(p) + tk.Button(root, text='Browse', command=browse_las).grid(row=0, column=2, **pad) + + # Tops CSV + tk.Label(root, text='Tops CSV:', anchor='w', width=12).grid(row=1, column=0, **pad, sticky='w') + tops_var = tk.StringVar() + tk.Entry(root, textvariable=tops_var, width=38).grid(row=1, column=1, **pad) + def browse_tops(): + p = filedialog.askopenfilename(title='Select formation tops CSV (optional)', + filetypes=[('CSV files', '*.csv'), ('All files', '*.*')]) + if p: tops_var.set(p) + tk.Button(root, text='Browse', command=browse_tops).grid(row=1, column=2, **pad) + + # Template + tk.Label(root, text='Template:', anchor='w', width=12).grid(row=2, column=0, **pad, sticky='w') + templates = ['raw', 'full_oil', 'full_gas', 'multimin_oil', 'multimin_gas', + 'multimin_oil_sum', 'multimin_gas_sum'] + template_var = tk.StringVar(value='raw') + ttk.Combobox(root, textvariable=template_var, values=templates, + state='readonly', width=35).grid(row=2, column=1, **pad, sticky='w') + + # Figure size + tk.Label(root, text='Figure Size:', anchor='w', width=12).grid(row=3, column=0, **pad, sticky='w') + sizes = ['17 x 11 (landscape large)', '11 x 8.5 (landscape standard)', + '8.5 x 11 (portrait standard)', '24 x 11 (landscape wide)'] + size_var = tk.StringVar(value=sizes[0]) + ttk.Combobox(root, textvariable=size_var, values=sizes, + state='readonly', width=35).grid(row=3, column=1, **pad, sticky='w') + + # Run calculations checkbox + calc_var = tk.BooleanVar(value=False) + tk.Checkbutton(root, text='Run porosity & saturation calculations', + variable=calc_var).grid(row=4, column=0, columnspan=3, + padx=10, pady=8, sticky='w') + + result = {} + + def on_open(): + if not las_var.get(): + messagebox.showwarning('Missing File', 'Please select a LAS file.') + return + result['las'] = las_var.get() + result['tops'] = tops_var.get() or None + result['template'] = template_var.get() + w_str = size_var.get().split('x')[0].strip().split()[0] + h_str = size_var.get().split('x')[1].strip().split()[0] + result['fig_size'] = (float(w_str), float(h_str)) + result['run_calcs'] = calc_var.get() + root.destroy() + + def on_cancel(): + root.destroy() + + btn_frame = tk.Frame(root) + btn_frame.grid(row=5, column=0, columnspan=3, pady=10) + tk.Button(btn_frame, text='Open', width=12, command=on_open).pack(side='left', padx=10) + tk.Button(btn_frame, text='Cancel', width=12, command=on_cancel).pack(side='left', padx=10) + + root.mainloop() + return result + + +# ── Calculation parameters dialog ──────────────────────────────────────────── + +def select_calc_params(depth_min, depth_max): + root = tk.Tk() + root.title('PetroPy - Calculation Parameters') + root.resizable(False, False) + screen_h = root.winfo_screenheight() + h = min(680, screen_h - 80) + w = 520 + x = (root.winfo_screenwidth() - w) // 2 + y = max(0, (screen_h - h) // 2) + root.geometry(f'{w}x{h}+{x}+{y}') + root.attributes('-topmost', True) + + pad = {'padx': 10, 'pady': 2} + + def labeled_entry(row, label, default, tooltip=None): + tk.Label(root, text=label, anchor='w', width=28).grid( + row=row, column=0, **pad, sticky='w') + var = tk.StringVar(value=str(default)) + tk.Entry(root, textvariable=var, width=12).grid(row=row, column=1, **pad, sticky='w') + if tooltip: + tk.Label(root, text=tooltip, fg='grey', anchor='w').grid( + row=row, column=2, padx=5, sticky='w') + return var + + tk.Label(root, text='Depth Range', font=('TkDefaultFont', 9, 'bold')).grid( + row=0, column=0, columnspan=3, padx=10, pady=(10,2), sticky='w') + top_var = labeled_entry(1, 'Top depth (ft)', round(depth_min, 1)) + bottom_var = labeled_entry(2, 'Bottom depth (ft)', round(depth_max, 1)) + + tk.Label(root, text='Fluid Properties', font=('TkDefaultFont', 9, 'bold')).grid( + row=3, column=0, columnspan=3, padx=10, pady=(10,2), sticky='w') + mast_var = labeled_entry(4, 'Mean annual surface temp (°F)', 67) + temp_grad_var = labeled_entry(5, 'Temperature gradient (°F/ft)', 0.015) + press_grad_var = labeled_entry(6, 'Pressure gradient (psi/ft)', 0.5) + rws_var = labeled_entry(7, 'Water resistivity at surface', 0.1) + rwt_var = labeled_entry(8, 'Water sample temperature (°F)', 70) + oil_api_var = labeled_entry(9, 'Oil API gravity (0 = gas well)', 38) + gas_grav_var = labeled_entry(10, 'Gas specific gravity', 0.67) + + tk.Label(root, text='Porosity & Saturation Model', font=('TkDefaultFont', 9, 'bold')).grid( + row=11, column=0, columnspan=3, padx=10, pady=(10,2), sticky='w') + gr_matrix_var = labeled_entry(12, 'GR clean matrix (API)', 10) + gr_clay_var = labeled_entry(13, 'GR pure clay (API)', 350) + rho_clay_var = labeled_entry(14, 'Clay density (g/cc)', 2.64) + nphi_clay_var = labeled_entry(15, 'Clay neutron porosity', 0.65) + m_var = labeled_entry(16, 'Cementation exponent (m)', 2) + n_var = labeled_entry(17, 'Saturation exponent (n)', 2) + a_var = labeled_entry(18, 'Tortuosity factor (a)', 1) + + tk.Label(root, text='Mineral Model (rock matrix)', font=('TkDefaultFont', 9, 'bold')).grid( + row=19, column=0, columnspan=3, padx=10, pady=(10, 2), sticky='w') + tk.Label(root, + text='Tick only the minerals present. Clay/shale is handled automatically.\n' + 'For a sandstone/shale well, leave just Quartz ticked.', + fg='grey', justify='left').grid(row=20, column=0, columnspan=3, padx=10, sticky='w') + qtz_var = tk.BooleanVar(value=True) + clc_var = tk.BooleanVar(value=False) + dol_var = tk.BooleanVar(value=False) + min_frame = tk.Frame(root) + min_frame.grid(row=21, column=0, columnspan=3, padx=10, pady=4, sticky='w') + tk.Checkbutton(min_frame, text='Quartz', variable=qtz_var).pack(side='left', padx=6) + tk.Checkbutton(min_frame, text='Calcite', variable=clc_var).pack(side='left', padx=6) + tk.Checkbutton(min_frame, text='Dolomite', variable=dol_var).pack(side='left', padx=6) + + result = {} + + def on_run(): + try: + result.update({ + 'top': float(top_var.get()), + 'bottom': float(bottom_var.get()), + 'mast': float(mast_var.get()), + 'temp_grad': float(temp_grad_var.get()), + 'press_grad': float(press_grad_var.get()), + 'rws': float(rws_var.get()), + 'rwt': float(rwt_var.get()), + 'oil_api': float(oil_api_var.get()), + 'gas_grav': float(gas_grav_var.get()), + 'gr_matrix': float(gr_matrix_var.get()), + 'gr_clay': float(gr_clay_var.get()), + 'rho_clay': float(rho_clay_var.get()), + 'nphi_clay': float(nphi_clay_var.get()), + 'm': float(m_var.get()), + 'n': float(n_var.get()), + 'a': float(a_var.get()), + }) + except ValueError: + messagebox.showerror('Invalid Input', 'All fields must be numbers.') + return + result['include_qtz'] = 'YES' if qtz_var.get() else 'NO' + result['include_clc'] = 'YES' if clc_var.get() else 'NO' + result['include_dol'] = 'YES' if dol_var.get() else 'NO' + root.destroy() + + def on_cancel(): + root.destroy() + + btn_frame = tk.Frame(root) + btn_frame.grid(row=22, column=0, columnspan=3, pady=12) + tk.Button(btn_frame, text='Run Calculations', width=16, command=on_run).pack(side='left', padx=10) + tk.Button(btn_frame, text='Cancel', width=12, command=on_cancel).pack(side='left', padx=10) + + root.mainloop() + return result + + +# ── Main ────────────────────────────────────────────────────────────────────── + +import matplotlib.pyplot as plt # used to close figures between views + + +def run_calculations(log, file_sel): + """Prompt for parameters and run fluid_properties + multimineral_model. + Mutates file_sel['template'] (raw -> multimin) and returns True if run.""" + resolve_missing_curves(log) + params = select_calc_params(log[0].min(), log[0].max()) + if not params: + return False + + print('Running fluid properties...') + log.fluid_properties( + top = params['top'], + bottom = params['bottom'], + mast = params['mast'], + temp_grad = params['temp_grad'], + press_grad = params['press_grad'], + rws = params['rws'], + rwt = params['rwt'], + oil_api = params['oil_api'], + gas_grav = params['gas_grav'], + ) + print('Running multimineral model...') + log.multimineral_model( + top = params['top'], + bottom = params['bottom'], + gr_matrix = params['gr_matrix'], + gr_clay = params['gr_clay'], + rho_clay = params['rho_clay'], + nphi_clay = params['nphi_clay'], + m = params['m'], + n = params['n'], + a = params['a'], + include_qtz = params['include_qtz'], + include_clc = params['include_clc'], + include_dol = params['include_dol'], + ) + + # Backfill zeroed curves for any mineral that was turned off, so the + # viewer's mineralogy track renders them as 0 instead of prompting. + zeros = np.zeros(len(log[0])) + for flag, names in [ + (params['include_qtz'], ['VQTZ', 'BVQTZ', 'WTQTZ']), + (params['include_clc'], ['VCLC', 'BVCLC', 'WTCLC']), + (params['include_dol'], ['VDOL', 'BVDOL', 'WTDOL']), + ]: + if flag == 'NO': + for nm in names: + if nm not in log.keys(): + log.append_curve(nm, np.copy(zeros), + descr='Excluded from mineral model (0)') + print('Calculations complete.') + + diag_curves = ['VCLAY', 'PHIE', 'SW', 'BVH', 'BVWI', 'BVWF', + 'VQTZ', 'VCLC', 'VDOL', 'TOC', 'OIP', 'RW'] + total = len(log[0]) + print(f'\n--- Calculation Results ({total} depth samples) ---') + for c in diag_curves: + if c in log.keys(): + valid = int(np.sum(~np.isnan(log[c]))) + mean = float(np.nanmean(log[c])) if valid > 0 else float('nan') + print(f' {c:<8} {valid:>5} valid samples mean = {mean:.4f}') + else: + print(f' {c:<8} NOT CALCULATED') + + nan_counts = {c: int(np.sum(np.isnan(log[c]))) + for c in ['GR_N', 'NPHI_N', 'RHOB_N', 'RESDEEP_N'] + if c in log.keys()} + print(f'\n--- NaN counts in required input curves ---') + for c, cnt in nan_counts.items(): + print(f' {c:<12} {cnt:>5} NaN values ({100*cnt/total:.1f}%)') + print(f' Depth range in log: {log[0].min():.1f} - {log[0].max():.1f} ft') + print(f' Calc range: {params["top"]:.1f} - {params["bottom"]:.1f} ft') + print() + + # switch to a processed template if user left it on raw + if file_sel['template'] == 'raw': + file_sel['template'] = 'multimin_oil' if params['oil_api'] > 0 else 'multimin_gas' + return True + + +def build_log(file_sel): + """Load LAS + tops and optionally run calculations. Returns a Log.""" + log = ptr.Log(file_sel['las']) + print('Well:', log.well['WELL'].value) + print('UWI: ', log.well['UWI'].value) + if file_sel['tops']: + log.tops_from_csv(file_sel['tops']) + print('Tops:', log.tops) + if file_sel['run_calcs']: + run_calculations(log, file_sel) + return log + + +def show_log(log, file_sel): + """Build and display the LogViewer. Returns the next action requested via + the viewer's toolbar buttons, or None if it was closed with the window X.""" + plt.close('all') + viewer = ptr.LogViewer(log, template_defaults=file_sel['template']) + viewer.fig.set_size_inches(*file_sel['fig_size']) + if file_sel['tops'] and log.tops: + for name, depth in log.tops.items(): + for ax in viewer.axes: + ax.axhline(y=depth, color='black', linewidth=2.0, linestyle='--') + viewer.axes[0].text(0.05, depth, name, ha='left', va='bottom', + fontsize=9, fontweight='bold') + viewer.show() + return getattr(viewer, 'next_action', None) + + +def next_action_menu(): + """Shown after the viewer is closed via the window X. Lets you pick what to + do next without restarting. Returns 'recalc'|'template'|'newfile'|'quit'.""" + root = tk.Tk() + root.title('PetroPy - What next?') + root.resizable(False, False) + w, h = 360, 240 + x = (root.winfo_screenwidth() - w) // 2 + y = (root.winfo_screenheight() - h) // 2 + root.geometry(f'{w}x{h}+{x}+{y}') + root.attributes('-topmost', True) + + choice = {'action': 'quit'} + + def pick(a): + choice['action'] = a + root.destroy() + + tk.Label(root, text='What would you like to do next?', + font=('TkDefaultFont', 10, 'bold')).pack(pady=12) + tk.Button(root, text='Change template / plot type', width=30, + command=lambda: pick('template')).pack(pady=4) + tk.Button(root, text='Edit calc parameters & re-run', width=30, + command=lambda: pick('recalc')).pack(pady=4) + tk.Button(root, text='Open another LAS file', width=30, + command=lambda: pick('newfile')).pack(pady=4) + tk.Button(root, text='Quit', width=30, + command=lambda: pick('quit')).pack(pady=4) + + root.mainloop() + return choice['action'] + + +def choose_template(file_sel): + """Dialog to change the plot template and figure size. + Returns (template, fig_size) or None if cancelled.""" + root = tk.Tk() + root.title('PetroPy - Template & Size') + root.resizable(False, False) + root.attributes('-topmost', True) + + pad = {'padx': 10, 'pady': 6} + templates = ['raw', 'full_oil', 'full_gas', 'multimin_oil', 'multimin_gas', + 'multimin_oil_sum', 'multimin_gas_sum'] + tk.Label(root, text='Template:', anchor='w', width=12).grid(row=0, column=0, **pad, sticky='w') + template_var = tk.StringVar( + value=file_sel['template'] if file_sel['template'] in templates else templates[0]) + ttk.Combobox(root, textvariable=template_var, values=templates, + state='readonly', width=28).grid(row=0, column=1, **pad) + + sizes = ['17 x 11 (landscape large)', '11 x 8.5 (landscape standard)', + '8.5 x 11 (portrait standard)', '24 x 11 (landscape wide)'] + tk.Label(root, text='Figure Size:', anchor='w', width=12).grid(row=1, column=0, **pad, sticky='w') + size_var = tk.StringVar(value=sizes[0]) + ttk.Combobox(root, textvariable=size_var, values=sizes, + state='readonly', width=28).grid(row=1, column=1, **pad) + + result = {} + + def on_ok(): + w_str = size_var.get().split('x')[0].strip().split()[0] + h_str = size_var.get().split('x')[1].strip().split()[0] + result['template'] = template_var.get() + result['fig_size'] = (float(w_str), float(h_str)) + root.destroy() + + def on_cancel(): + root.destroy() + + btn = tk.Frame(root) + btn.grid(row=2, column=0, columnspan=2, pady=12) + tk.Button(btn, text='OK', width=12, command=on_ok).pack(side='left', padx=10) + tk.Button(btn, text='Cancel', width=12, command=on_cancel).pack(side='left', padx=10) + + root.mainloop() + if result: + return result['template'], result['fig_size'] + return None + + +file_sel = select_files() + +if not file_sel: + print('Cancelled.') +else: + log = build_log(file_sel) + while True: + action = show_log(log, file_sel) + if action is None: # window closed with the X -> show menu + action = next_action_menu() + + if action == 'quit': + break + elif action == 'template': + sel = choose_template(file_sel) + if sel: + file_sel['template'], file_sel['fig_size'] = sel + elif action == 'recalc': + run_calculations(log, file_sel) + elif action == 'newfile': + new_sel = select_files() + if new_sel: + file_sel = new_sel + log = build_log(file_sel) + else: + break + + print('PetroPy closed.') diff --git a/petropy/data/images/openfile.png b/petropy/data/images/openfile.png new file mode 100644 index 0000000000000000000000000000000000000000..9d0acfda10c27bba031a36c177d7d387ce9d10ca GIT binary patch literal 136 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gj{+=$5Ar*6y6C_v{Cx{p*6<_K3 z^Y3v`#p^q2ZZSna7=$-+eT?km7kJAY#CYbo%kPGm15M}4iWsUiF5F>XEXK2kH6mEh j^fU9>S_3XNIR=KAFZokv98#GEG@8NF)z4*}Q$iB}-lZ)5 literal 0 HcmV?d00001 diff --git a/petropy/data/images/recalc.png b/petropy/data/images/recalc.png new file mode 100644 index 0000000000000000000000000000000000000000..fa7d871199cd765e95440a0a93e738ce0978b73c GIT binary patch literal 182 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjwVp1HAr*6y6C?^9&YpO0ulY(+ zbL)d^JAEt8D%@nsnf73FPp1rbhv}Rwu?O60n{*t^1>%ncKVeSk_VA76$#Sdv-+9%g zE9#TUgNx2vLt40dq8#iNglQ?gV0M}&FzI+Eqv1@BNSW|L#|' + new_entry = f' <{chosen}/>\n {close_tag}' + if close_tag in content and f'<{chosen}/>' not in content: + content = content.replace(close_tag, new_entry) + with open(alias_xml_path, 'w', encoding='utf-8') as f: + f.write(content) + print(f'Saved alias: {chosen} -> {missing_curve}') + elif close_tag not in content: + print(f'Warning: {missing_curve} not found in alias XML.') + result[0] = chosen + dlg.destroy() + + def on_skip(): + result[0] = None + dlg.destroy() + + btn_frame = tk.Frame(dlg) + btn_frame.pack(pady=8) + tk.Button(btn_frame, text='Use Selected', width=14, command=on_use).pack(side='left', padx=8) + tk.Button(btn_frame, text='Skip Curve', width=14, command=on_skip).pack(side='left', padx=8) + + root.wait_window(dlg) + root.destroy() + return result[0] mpl.rcParams['backend'] = 'TkAgg' @@ -174,6 +255,9 @@ def __init__(self, log, template_xml_path = None, # stores bool to show downclick to edit curve self._edit_lock = False + # maps axis index -> list of (display_name, curve_name) tuples + self._track_curves = {} + # display name to log column name dictionary self._display_name_to_curve_name = {} @@ -188,6 +272,7 @@ def __init__(self, log, template_xml_path = None, } file_dir = os.path.dirname(__file__) + alias_xml_path = os.path.join(file_dir, 'data', 'curve_alias.xml') if template_xml_path is None and template_defaults is None: template_xml_path = os.path.join(file_dir, 'data', 'default_raw_template.xml') @@ -292,7 +377,7 @@ def __init__(self, log, template_xml_path = None, if 'scale' in track.attrib: scale = track.attrib['scale'] - ax.set_xscale(scale, nonposx = 'clip') + ax.set_xscale(scale, nonpositive = 'clip') track_width = 1 if 'width' in track.attrib: @@ -368,8 +453,11 @@ def __init__(self, log, template_xml_path = None, curve_name = curve.attrib['curve_name'] if curve_name not in self.log.keys(): - raise ValueError('Curve %s not found in log.' \ - % curve_name) + chosen = _ask_curve_mapping( + curve_name, list(self.log.keys()), alias_xml_path) + if chosen is None: + continue + curve_name = chosen if 'fill_color' not in curve.attrib: raise ValueError('Curve fill_color must be \ @@ -426,8 +514,11 @@ def __init__(self, log, template_xml_path = None, template at %s' % template_xml_path) if curve_name not in self.log.keys(): - raise ValueError('Curve %s not found in log.' \ - % curve_name) + chosen = _ask_curve_mapping( + curve_name, list(self.log.keys()), alias_xml_path) + if chosen is None: + continue + curve_name = chosen ### style and scale ### @@ -725,6 +816,12 @@ def __init__(self, log, template_xml_path = None, self._edit_curve_lines[curve_name] = \ (curve_line, m, b) + ax_idx = list(self.axes).index(ax) + if ax_idx not in self._track_curves: + self._track_curves[ax_idx] = [] + label = curve.attrib.get('display_name', curve_name) + self._track_curves[ax_idx].append((label, curve_name)) + if scale == 'log': ax.xaxis.grid(True, which = 'both',color='#e0e0e0') @@ -779,10 +876,12 @@ def __init__(self, log, template_xml_path = None, plt.ylim((top, bottom)) plt.gca().invert_yaxis() - self.fig.set_size_inches(11, 8.5) + n_tracks = max(len(self.axes) - 2, 1) + fig_width = max(11, n_tracks * 1.5) + self.fig.set_size_inches(fig_width, 8.5) - def show(self): + def show(self, edit_mode=False): """ Calls matplotlib.pyplot.show() to display log viewer. It includes options in the toolbar to graphically edit @@ -810,11 +909,11 @@ def show(self): if len(str(self.log.well['UWI'].value)) > 0: log_window_title = 'UWI: ' + str(self.log.well['UWI'].value) - elif len(self.log.well['API'].value) > 0: + elif 'API' in self.log.well and len(self.log.well['API'].value) > 0: log_window_title = 'API: ' + str(self.log.well['API'].value) else: log_window_title = 'Log Viewer' - self.fig.canvas.set_window_title(log_window_title) + self.fig.canvas.manager.set_window_title(log_window_title) # add edit tools tm = self.fig.canvas.manager.toolmanager tm.add_tool('Curve Edit', _CurveEditToggle) @@ -826,20 +925,143 @@ def show(self): t = tm.get_tool('Bulk Shift') self.fig.canvas.manager.toolbar.add_tool(t, 'PetroPy') + # navigation buttons -- let the launcher loop (main.py) go back and + # forth between the plot, the template chooser, and the calc dialog. + # Wrapped so a backend that dislikes image-less tools can't break the + # viewer; closing the window with the X still falls back to the menu. + self.next_action = None + self.fig._petropy_viewer = self + try: + for nav_name, nav_cls in [('Recalc', _RecalcTool), + ('Template', _TemplateTool), + ('Open File', _OpenFileTool)]: + tm.add_tool(nav_name, nav_cls) + self.fig.canvas.manager.toolbar.add_tool( + tm.get_tool(nav_name), 'Navigate') + except Exception as nav_err: + print('Note: navigation toolbar buttons unavailable (%s); ' + 'close the window for the menu instead.' % nav_err) + # remove non-useful tools self.fig.canvas.manager.toolmanager.remove_tool('forward') self.fig.canvas.manager.toolmanager.remove_tool('back') self.fig.canvas.manager.toolmanager.remove_tool('help') + self._mouse_button = None + self.fig.canvas.mpl_connect('pick_event', self._curve_pick) self.fig.canvas.mpl_connect('button_press_event', - self._edit_lock_toggle) + self._mouse_button_press) self.fig.canvas.mpl_connect('button_release_event', - self._edit_lock_toggle) + self._mouse_button_release) self.fig.canvas.mpl_connect('motion_notify_event', self._draw_curve) + self.fig.canvas.mpl_connect('key_press_event', self._key_scroll) + self.fig.canvas.mpl_connect('scroll_event', self._scroll_zoom) + self.fig.canvas.mpl_connect('motion_notify_event', self._on_hover) + + # annotation box for hover readout at top of figure + self._hover_text = self.fig.text( + 0.5, 0.001, '', ha='center', va='bottom', + fontsize=8, family='monospace', + bbox=dict(boxstyle='round,pad=0.3', fc='lightyellow', + ec='grey', alpha=0.85)) + plt.show() + def _on_hover(self, event): + """Show curve values at the current depth when hovering.""" + if event.inaxes is None: + self._hover_text.set_text('') + self.fig.canvas.draw_idle() + return + + depth = event.ydata + if depth is None: + return + + # find closest depth index + idx = int(np.argmin(np.abs(self.log[0] - depth))) + actual_depth = self.log[0][idx] + + # find which axis the mouse is in + ax_idx = list(self.axes).index(event.inaxes) \ + if event.inaxes in self.axes else None + + parts = [f'Depth: {actual_depth:.1f} ft'] + + if ax_idx is not None and ax_idx in self._track_curves: + for display_name, curve_name in self._track_curves[ax_idx]: + if curve_name in self.log.keys(): + val = self.log[curve_name][idx] + if np.isnan(val): + parts.append(f'{display_name}: --') + else: + parts.append(f'{display_name}: {val:.3g}') + + self._hover_text.set_text(' | '.join(parts)) + self.fig.canvas.draw_idle() + + def _mouse_button_press(self, event): + """Track which mouse button is held and forward to edit toggle.""" + self._mouse_button = event.button + self._edit_lock_toggle(event) + + def _mouse_button_release(self, event): + """Clear held mouse button and forward to edit toggle.""" + self._mouse_button = None + self._edit_lock_toggle(event) + + def _key_scroll(self, event): + """Page Up / Page Down to scroll depth axis by one page.""" + ax = self.axes[1] + bottom, top = ax.get_ylim() + page = bottom - top + min_depth = np.nanmin(self.log[0]) + max_depth = np.nanmax(self.log[0]) + if event.key == 'pagedown': + new_bottom = min(bottom + page, max_depth) + new_top = new_bottom - page + ax.set_ylim(new_bottom, new_top) + elif event.key == 'pageup': + new_top = max(top - page, min_depth) + new_bottom = new_top + page + ax.set_ylim(new_bottom, new_top) + else: + return + self.fig.canvas.draw() + + def _scroll_zoom(self, event): + """ + Scroll wheel behaviour depends on held mouse button: + Left button (1) held -> zoom in/out + Right button (3) held -> scroll up/down + No button held -> scroll up/down (default) + """ + ax = self.axes[1] + bottom, top = ax.get_ylim() + + if self._mouse_button == 1: + # left button held — zoom + depth = bottom - top + factor = 0.9 if event.button == 'up' else 1.1 + new_depth = depth * factor + center = (bottom + top) / 2 + ax.set_ylim(center + new_depth / 2, center - new_depth / 2) + else: + # right button held or no button — scroll + min_depth = np.nanmin(self.log[0]) + max_depth = np.nanmax(self.log[0]) + step = (bottom - top) * 0.2 + if event.button == 'up': + new_top = max(top - step, min_depth) + ax.set_ylim(new_top + (bottom - top), new_top) + else: + new_bottom = min(bottom + step, max_depth) + ax.set_ylim(new_bottom, new_bottom - (bottom - top)) + + self.fig.canvas.draw() + def _curve_pick(self, event): """ @@ -1010,6 +1232,50 @@ def disable(self, event): obj.fig.canvas.draw() +class _NavToolBase(ToolBase): + """ + Base class for launcher navigation buttons. Records the requested + action on the owning LogViewer and closes the figure so control + returns to the launcher loop in main.py. + """ + + _action = None + + def trigger(self, sender, event, data=None): + fig = None + tmgr = getattr(self, 'toolmanager', None) + if tmgr is not None and getattr(tmgr, 'canvas', None) is not None: + fig = tmgr.canvas.figure + if fig is None: + fig = getattr(self, 'figure', None) + viewer = getattr(fig, '_petropy_viewer', None) + if viewer is not None: + viewer.next_action = self._action + if fig is not None: + plt.close(fig) + + +class _RecalcTool(_NavToolBase): + description = 'Edit calculation parameters and re-run' + _action = 'recalc' + file_dir = os.path.dirname(__file__) + image = os.path.join(file_dir, 'data', 'images', 'recalc.png') + + +class _TemplateTool(_NavToolBase): + description = 'Change the log plot template / size' + _action = 'template' + file_dir = os.path.dirname(__file__) + image = os.path.join(file_dir, 'data', 'images', 'template.png') + + +class _OpenFileTool(_NavToolBase): + description = 'Open a different LAS file' + _action = 'newfile' + file_dir = os.path.dirname(__file__) + image = os.path.join(file_dir, 'data', 'images', 'openfile.png') + + class _PetroPyAxes(mpl.axes.Axes): """ Petropy axes to disallow scrolling on x-axis. Only allows diff --git a/run_petropy.bat b/run_petropy.bat new file mode 100644 index 0000000..2ca3c02 --- /dev/null +++ b/run_petropy.bat @@ -0,0 +1,31 @@ +@echo off +REM ============================================================ +REM Launch PetroPy from this folder using its OWN isolated +REM conda environment ("petropy"), so it never shares or +REM clashes with dependencies from any other project. +REM ============================================================ + +REM Run from the folder this .bat lives in (so the local +REM petropy package and main.py are found). +cd /d "%~dp0" + +set "PETROPY_PY=C:\Users\dougc\anaconda3\envs\petropy\python.exe" + +if not exist "%PETROPY_PY%" ( + echo. + echo ERROR: The isolated "petropy" environment was not found at: + echo %PETROPY_PY% + echo. + echo Create it first, then re-run this file. + echo. + pause + exit /b 1 +) + +echo Launching PetroPy with isolated environment... +echo. +"%PETROPY_PY%" main.py + +echo. +echo PetroPy has closed. Review any messages above. +pause From d7cbb52731c234d23ab7399e984fb2f697cec035 Mon Sep 17 00:00:00 2001 From: dougchristie Date: Fri, 29 May 2026 13:21:36 -0600 Subject: [PATCH 2/2] Add net-pay & per-formation statistics workflow New "Net pay & statistics..." entry in the "what next?" menu, plus three new functions in main.py: - select_pay_cutoffs: dialog for PHIE/SW/VCLAY/RESDEEP cutoffs (blank = skip), pay-flag name, and per-formation checkboxes. - compute_pay_statistics: calls add_pay_flag + summations + statistics and shows the table. - show_statistics_table: ttk.Treeview with Formation, Gross (ft), Net Pay (ft), N/G, Avg phi (pay), Avg Sw (pay), Avg Vclay (pay), HC pore-ft (pay), and OIP/GIP (pay). Save CSV button uses pandas to_csv. run_calculations now auto-runs summations() on OIP/GIP/GIP_FREE/GIP_ADS/ PHIE/BVH/BVW when tops are loaded, so the multimin_oil_sum and multimin_gas_sum templates render their OIP/GIP overlay tracks instead of prompting for missing _SUM curves. Co-Authored-By: Claude Opus 4.7 --- main.py | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 239 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index a6db7cd..0ce26a7 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ import tkinter as tk from tkinter import filedialog, ttk, messagebox import numpy as np +import pandas as pd import os import petropy as ptr @@ -337,6 +338,19 @@ def run_calculations(log, file_sel): descr='Excluded from mineral model (0)') print('Calculations complete.') + # Auto-run summations so the *_sum templates render correctly and the + # net-pay statistics workflow has summation curves ready. Needs tops. + if log.tops: + sum_candidates = ['OIP', 'GIP', 'GIP_FREE', 'GIP_ADS', + 'PHIE', 'BVH', 'BVW'] + sum_curves = [c for c in sum_candidates if c in log.keys()] + if sum_curves: + try: + log.summations(list(log.tops.keys()), curves=sum_curves) + print(f'Summations computed for: {", ".join(sum_curves)}') + except Exception as _sum_err: + print(f'(Summations skipped: {_sum_err})') + diag_curves = ['VCLAY', 'PHIE', 'SW', 'BVH', 'BVWI', 'BVWF', 'VQTZ', 'VCLC', 'VDOL', 'TOC', 'OIP', 'RW'] total = len(log[0]) @@ -396,11 +410,11 @@ def show_log(log, file_sel): def next_action_menu(): """Shown after the viewer is closed via the window X. Lets you pick what to - do next without restarting. Returns 'recalc'|'template'|'newfile'|'quit'.""" + do next without restarting. Returns 'recalc'|'template'|'newfile'|'stats'|'quit'.""" root = tk.Tk() root.title('PetroPy - What next?') root.resizable(False, False) - w, h = 360, 240 + w, h = 360, 290 x = (root.winfo_screenwidth() - w) // 2 y = (root.winfo_screenheight() - h) // 2 root.geometry(f'{w}x{h}+{x}+{y}') @@ -418,6 +432,8 @@ def pick(a): command=lambda: pick('template')).pack(pady=4) tk.Button(root, text='Edit calc parameters & re-run', width=30, command=lambda: pick('recalc')).pack(pady=4) + tk.Button(root, text='Net pay & statistics…', width=30, + command=lambda: pick('stats')).pack(pady=4) tk.Button(root, text='Open another LAS file', width=30, command=lambda: pick('newfile')).pack(pady=4) tk.Button(root, text='Quit', width=30, @@ -474,6 +490,225 @@ def on_cancel(): return None +def select_pay_cutoffs(formations): + """Dialog for net-pay cutoffs and which formations to score. Returns a + dict with 'name', 'phie_min', 'sw_max', 'vclay_max', 'rt_min', + 'formations'; or None if cancelled. Blank cutoff fields are skipped.""" + root = tk.Tk() + root.title('PetroPy - Net Pay Cutoffs') + root.resizable(False, False) + root.attributes('-topmost', True) + + tk.Label(root, text='Pay Cutoffs', + font=('TkDefaultFont', 11, 'bold')).pack(pady=(10, 4)) + tk.Label(root, text='Leave any field blank to skip that cutoff.', + fg='grey').pack(pady=(0, 6)) + + frame = tk.Frame(root); frame.pack(padx=14) + + def labeled(label_text, default, hint=''): + f = tk.Frame(frame); f.pack(fill='x', pady=2) + tk.Label(f, text=label_text, width=14, anchor='w').pack(side='left') + v = tk.StringVar(value=str(default)) + tk.Entry(f, textvariable=v, width=10).pack(side='left') + if hint: + tk.Label(f, text=hint, fg='grey').pack(side='left', padx=6) + return v + + name_var = labeled('Pay flag name:', 'PAY', '') + phie_var = labeled('PHIE ≥', '0.06', 'effective porosity min') + sw_var = labeled('SW ≤', '0.5', 'water saturation max') + vclay_var = labeled('VCLAY ≤', '0.4', 'clay volume max') + rt_var = labeled('RESDEEP ≥', '20', 'deep resistivity min (ohm.m)') + + tk.Label(root, text='Formations to include:', + font=('TkDefaultFont', 9, 'bold')).pack(pady=(10, 2)) + form_frame = tk.Frame(root); form_frame.pack(padx=14, pady=2) + form_vars = {} + cols = 3 + for i, f in enumerate(formations): + v = tk.BooleanVar(value=True) + form_vars[f] = v + tk.Checkbutton(form_frame, text=f, variable=v).grid( + row=i // cols, column=i % cols, sticky='w', padx=4) + + result = {} + + def _maybe_float(s): + s = s.strip() + return float(s) if s else None + + def on_ok(): + try: + result['name'] = name_var.get().strip() or 'PAY' + result['phie_min'] = _maybe_float(phie_var.get()) + result['sw_max'] = _maybe_float(sw_var.get()) + result['vclay_max'] = _maybe_float(vclay_var.get()) + result['rt_min'] = _maybe_float(rt_var.get()) + except ValueError: + messagebox.showerror('Invalid Input', + 'Cutoff fields must be numeric (or blank).') + return + result['formations'] = [f for f, v in form_vars.items() if v.get()] + if not result['formations']: + messagebox.showwarning('No Formations', + 'Select at least one formation.') + result.clear() + return + root.destroy() + + def on_cancel(): + result.clear() + root.destroy() + + btn = tk.Frame(root); btn.pack(pady=12) + tk.Button(btn, text='Compute', width=12, command=on_ok).pack(side='left', padx=8) + tk.Button(btn, text='Cancel', width=12, command=on_cancel).pack(side='left', padx=8) + + root.mainloop() + return result if result else None + + +def show_statistics_table(df, pay_name, hc_curve): + """Display the pay-statistics DataFrame in a table and offer CSV save.""" + root = tk.Tk() + root.title('PetroPy - Pay Statistics') + root.attributes('-topmost', True) + + # Add net-to-gross for convenience + df = df.copy() + pay_sum_col = f'{pay_name}_SUM' + if pay_sum_col in df.columns and 'GROSS_H' in df.columns: + with np.errstate(divide='ignore', invalid='ignore'): + df['NG_RATIO'] = df[pay_sum_col] / df['GROSS_H'] + + cols_spec = [ + ('FORMATION', 'Formation', 110), + ('GROSS_H', 'Gross (ft)', 90), + (pay_sum_col, 'Net Pay (ft)', 95), + ('NG_RATIO', 'N/G', 60), + (f'PHIE_{pay_name}_MEAN', 'Avg φ (pay)', 95), + (f'SW_{pay_name}_MEAN', 'Avg Sw (pay)', 95), + (f'VCLAY_{pay_name}_MEAN', 'Avg Vclay (pay)', 110), + (f'BVH_{pay_name}_SUM', 'HC pore-ft (pay)', 115), + ] + if hc_curve: + unit_word = 'MMbbl/section' if hc_curve == 'OIP' else 'BCF/section' + cols_spec.append( + (f'{hc_curve}_{pay_name}_SUM', + f'{hc_curve} pay [{unit_word}]', 170)) + + cols_spec = [(c, h, w) for c, h, w in cols_spec if c in df.columns] + col_ids = [c for c, _, _ in cols_spec] + + frame = tk.Frame(root); frame.pack(fill='both', expand=True, padx=8, pady=8) + tree = ttk.Treeview(frame, columns=col_ids, show='headings', + height=min(15, max(4, len(df)))) + for c, h, w in cols_spec: + tree.heading(c, text=h) + tree.column(c, width=w, anchor='center') + + def _fmt(c, v): + if pd.isna(v): + return '—' + if c == 'FORMATION': + return str(v) + if c == 'NG_RATIO': + return f'{v * 100:.1f}%' + if 'MEAN' in c and ('PHIE' in c or 'SW' in c or 'VCLAY' in c): + return f'{v:.3f}' + return f'{v:.2f}' + + for _, row in df.iterrows(): + tree.insert('', 'end', values=[_fmt(c, row[c]) for c in col_ids]) + + vsb = ttk.Scrollbar(frame, orient='vertical', command=tree.yview) + tree.configure(yscroll=vsb.set) + tree.pack(side='left', fill='both', expand=True) + vsb.pack(side='right', fill='y') + + btn = tk.Frame(root); btn.pack(pady=8) + + def on_save(): + path = filedialog.asksaveasfilename( + defaultextension='.csv', + filetypes=[('CSV', '*.csv'), ('All files', '*.*')], + title='Save statistics CSV') + if path: + df.to_csv(path, index=False) + messagebox.showinfo('Saved', f'Statistics saved to:\n{path}') + + tk.Button(btn, text='Save CSV…', width=12, + command=on_save).pack(side='left', padx=8) + tk.Button(btn, text='Close', width=12, + command=root.destroy).pack(side='left', padx=8) + + root.mainloop() + + +def compute_pay_statistics(log, file_sel): + """Define cutoffs, run add_pay_flag + summations + statistics, show the + per-formation table. Requires formation tops and run calculations first.""" + if not log.tops: + messagebox.showwarning('No Formation Tops', + 'Net-pay statistics need formation tops.\n' + 'Open the file with a tops CSV (or pick "Open another LAS file" ' + 'and supply one) to enable this.') + return False + + if 'PHIE' not in log.keys() or 'SW' not in log.keys(): + messagebox.showwarning('Calculations Required', + 'Run the porosity & saturation calculations first ' + '(tick "Run porosity & saturation calculations" when opening, ' + 'or use "Edit calc parameters & re-run").') + return False + + cutoffs = select_pay_cutoffs(list(log.tops.keys())) + if not cutoffs: + return False + + gtoe, ltoe = [], [] + if cutoffs['phie_min'] is not None: gtoe.append(('PHIE', cutoffs['phie_min'])) + if cutoffs['sw_max'] is not None: ltoe.append(('SW', cutoffs['sw_max'])) + if cutoffs['vclay_max'] is not None: ltoe.append(('VCLAY', cutoffs['vclay_max'])) + if cutoffs['rt_min'] is not None: gtoe.append(('RESDEEP_N', cutoffs['rt_min'])) + + pay_name = cutoffs['name'] + formations = cutoffs['formations'] + + # Flag value 1.0 so its sample-rate-weighted SUM yields net-pay thickness. + log.add_pay_flag(formations, flag=1.0, + less_than_or_equal=ltoe, + greater_than_or_equal=gtoe, + name=pay_name) + + hc_curve = 'OIP' if 'OIP' in log.keys() else ( + 'GIP' if 'GIP' in log.keys() else None) + + sum_curves = [c for c in ('PHIE', 'BVH', 'BVW') if c in log.keys()] + if hc_curve and hc_curve not in sum_curves: + sum_curves.append(hc_curve) + if sum_curves: + log.summations(formations, curves=sum_curves) + + stat_curves = [c for c in ('PHIE', 'SW', 'VCLAY', 'BVH', pay_name) + if c in log.keys()] + if hc_curve and hc_curve not in stat_curves: + stat_curves.append(hc_curve) + + df = log.statistics(formations, curves=stat_curves, pay_flags=[pay_name]) + + print('\n--- Pay Statistics ---') + try: + print(df.to_string(index=False)) + except Exception: + print(df) + print() + + show_statistics_table(df, pay_name, hc_curve) + return True + + file_sel = select_files() if not file_sel: @@ -493,6 +728,8 @@ def on_cancel(): file_sel['template'], file_sel['fig_size'] = sel elif action == 'recalc': run_calculations(log, file_sel) + elif action == 'stats': + compute_pay_statistics(log, file_sel) elif action == 'newfile': new_sel = select_files() if new_sel: