diff --git a/src/xtgeo/well/_well_roxapi.py b/src/xtgeo/well/_well_roxapi.py index 622c98697..ab6c79591 100644 --- a/src/xtgeo/well/_well_roxapi.py +++ b/src/xtgeo/well/_well_roxapi.py @@ -1,10 +1,15 @@ # -*- coding: utf-8 -*- """Well input and output, private module for ROXAPI.""" +from __future__ import annotations + +from typing import Any, Literal, Optional + import numpy as np import numpy.ma as npma import pandas as pd +import xtgeo from xtgeo.common import XTGeoDialog from xtgeo.common._xyz_enum import _AttrName, _AttrType from xtgeo.common.constants import UNDEF_INT_LIMIT, UNDEF_LIMIT @@ -149,20 +154,26 @@ def _get_roxlog(wlogtypes, wlogrecords, roxlrun, lname): # pragma: no cover def export_well_roxapi( - self, + self: xtgeo.Well, project, wname, - lognames="all", - logrun="log", - trajectory="Drilled trajectory", - realisation=0, + lognames: str | list[str] = "all", + logrun: str = "log", + trajectory: str = "Drilled trajectory", + realisation: int = 0, + update_option: Optional[Literal["overwrite", "append"]] = None, ): - """Private function for well export (store in RMS) from XTGeo to RoxarAPI.""" + """Private function for well export (i.e. store in RMS) from XTGeo to RoxarAPI.""" logger.debug("Opening RMS project ...") rox = RoxUtils(project, readonly=False) - _roxapi_export_well(self, rox, wname, lognames, logrun, trajectory, realisation) + if wname in rox.project.wells: + _roxapi_update_well( + self, rox, wname, lognames, logrun, trajectory, realisation, update_option + ) + else: + _roxapi_create_well(self, rox, wname, lognames, logrun, trajectory, realisation) if rox._roxexternal: rox.project.save() @@ -170,66 +181,89 @@ def export_well_roxapi( rox.safe_close() -def _roxapi_export_well(self, rox, wname, lognames, logrun, trajectory, realisation): - if wname in rox.project.wells: - _roxapi_update_well(self, rox, wname, lognames, logrun, trajectory, realisation) - else: - _roxapi_create_well(self, rox, wname, lognames, logrun, trajectory, realisation) +def _store_log_in_roxapi(self, lrun: Any, logname: str) -> None: + """Store a single log in RMS / Roxar API for a well""" + if logname in (self.xname, self.yname, self.zname): + return + isdiscrete = False + xtglimit = UNDEF_LIMIT + if self.wlogtypes[logname] == _AttrType.DISC.value: + isdiscrete = True + xtglimit = UNDEF_INT_LIMIT -def _roxapi_update_well(self, rox, wname, lognames, logrun, trajectory, realisation): - """Assume well is to updated only with logs, new or changed. + store_logname = logname - Also, the length of arrays should not change, at least not for now. + # the MD name is applied as default in RMS for measured depth; hence it can be wise + # to avoid duplicate names here, since the measured depth log is crucial. + if logname == "MD": + store_logname = "MD_imported" + xtg.warn(f"Logname MD is stored as {store_logname}") - """ - logger.debug("Key realisation not in use: %s", realisation) + if isdiscrete: + thelog = lrun.log_curves.create_discrete(name=store_logname) + else: + thelog = lrun.log_curves.create(name=store_logname) - well = rox.project.wells[wname] - traj = well.wellbore.trajectories[trajectory] - lrun = traj.log_runs[logrun] + values = thelog.generate_values() - lrun.log_curves.clear() + if values.size != self.dataframe[logname].values.size: + raise ValueError("New logs have different sampling or size, not possible") - if lognames == "all": - uselognames = self.lognames - else: - uselognames = lognames + usedtype = values.dtype - for lname in uselognames: - isdiscrete = False - xtglimit = UNDEF_LIMIT - if self.wlogtypes[lname] == _AttrType.DISC.value: - isdiscrete = True - xtglimit = UNDEF_INT_LIMIT - - if isdiscrete: - thelog = lrun.log_curves.create_discrete(name=lname) + vals = np.ma.masked_invalid(self.dataframe[logname].values) + vals = np.ma.masked_greater(vals, xtglimit) + vals = vals.astype(usedtype) + thelog.set_values(vals) + + if isdiscrete: + # roxarapi requires keys to be ints, while xtgeo can accept any, e.g. strings + if vals.mask.all(): + codedict = {0: "unset"} else: - thelog = lrun.log_curves.create(name=lname) + codedict = { + int(key): str(value) for key, value in self.wlogrecords[logname].items() + } + thelog.set_code_names(codedict) + + +def _roxapi_update_well( + self: xtgeo.Well, + rox: Any, + wname: str, + lognames: str | list[str], + logrun: str, + trajectory: str, + realisation: int, + update_option: Optional[Literal["overwrite", "append"]] = None, +): + """Assume well is to updated only with logs, new only are appended - values = thelog.generate_values() + Also, the length of arrays are not allowed not change (at least not for now). + + """ + logger.debug("Key realisation not in use: %s", realisation) + if update_option not in (None, "overwrite", "append"): + raise ValueError( + f"The update_option <{update_option}> is invalid, valid " + "options are: None | overwrite | append" + ) - if values.size != self.dataframe[lname].values.size: - raise ValueError("New logs have different sampling or size, not possible") + lrun = rox.project.wells[wname].wellbore.trajectories[trajectory].log_runs[logrun] - usedtype = values.dtype + # find existing lognames in target + current_logs = [lname.name for lname in lrun.log_curves] - vals = np.ma.masked_invalid(self.dataframe[lname].values) - vals = np.ma.masked_greater(vals, xtglimit) - vals = vals.astype(usedtype) - thelog.set_values(vals) + uselognames = self.lognames if lognames == "all" else lognames - if isdiscrete: - # roxarapi requires keys to int, while xtgeo can accept any, e.g. strings - if vals.mask.all(): - codedict = {0: "unset"} - else: - codedict = { - int(key): str(value) - for key, value in self._wlogrecords[lname].items() - } - thelog.set_code_names(codedict) + if update_option is None: + lrun.log_curves.clear() # clear existing logs; all will be replaced + + for lname in uselognames: + if update_option == "append" and lname in current_logs: + continue + _store_log_in_roxapi(self, lrun, lname) def _roxapi_create_well(self, rox, wname, lognames, logrun, trajectory, realisation): @@ -257,28 +291,6 @@ def _roxapi_create_well(self, rox, wname, lognames, logrun, trajectory, realisat lrun = traj.log_runs.create(logrun) lrun.set_measured_depths(md) - # Add log curves - for curvename, curveprop in self.get_wlogs().items(): - if curvename not in self.lognames: - continue # skip X_UTME .. Z_TVDSS - if lognames and lognames != "all" and curvename not in lognames: - continue - if not lognames: - continue - - cname = curvename - if curvename == "MD": - cname = "MD_imported" - xtg.warn(f"Logname MD is renamed to {cname}") - - if curveprop[0] == _AttrType.DISC.value: - lcurve = lrun.log_curves.create_discrete(cname) - cc = np.ma.masked_invalid(self.dataframe[curvename].values) - lcurve.set_values(cc.astype(np.int32)) - codedict = {int(key): str(value) for key, value in curveprop[1].items()} - lcurve.set_code_names(codedict) - else: - lcurve = lrun.log_curves.create(cname) - lcurve.set_values(self.dataframe[curvename].values) - - logger.debug("Log curve created: %s", cname) + uselognames = self.lognames if lognames == "all" else lognames + for lname in uselognames: + _store_log_in_roxapi(self, lrun, lname) diff --git a/src/xtgeo/well/well1.py b/src/xtgeo/well/well1.py index 2a6121531..af52a226a 100644 --- a/src/xtgeo/well/well1.py +++ b/src/xtgeo/well/well1.py @@ -679,10 +679,6 @@ def to_roxar(self, *args, **kwargs): The current implementation will either update the existing well (then well log array size must not change), or it will make a new well in RMS. - Note: - When project is file path (direct access, outside RMS) then - ``to_roxar()`` will implicitly do a project save. Otherwise, the project - will not be saved until the user do an explicit project save action. Args: project (str, object): Magic string 'project' or file path to project @@ -690,16 +686,54 @@ def to_roxar(self, *args, **kwargs): lognames (:obj:list or :obj:str): List of lognames to save, or use simply 'all' for current logs for this well. Default is 'all' realisation (int): Currently inactive - trajectory (str): Name of trajectory in RMS - logrun (str): Name of logrun in RMS + trajectory (str): Name of trajectory in RMS, default is "Drilled trajectory" + logrun (str): Name of logrun in RMS, defaault is "log" + update_option (str): None | "overwrite" | "append". This only applies + when the well (wname) exists in RMS, and rules are based on name + matching. Default is None which means that all well logs in + RMS are emptied and then replaced with the content from xtgeo. + The "overwrite" option will replace logs in RMS with logs from xtgeo, + and append new if they do not exist in RMS. The + "append" option will only append logs if name does not exist in RMS + already. Reading only a subset of logs and then use "overwrite" or + "append" may speed up execution significantly. + + Note: + When project is file path (direct access, outside RMS) then + ``to_roxar()`` will implicitly do a project save. Otherwise, the project + will not be saved until the user do an explicit project save action. + + Example:: + + # assume that existing logs in RMS are ["PORO", "PERMH", "GR", "DT", "FAC"] + # read only one existing log (faster) + wll = xtgeo.well_from_roxar(project, "WELL1", lognames=["PORO"]) + wll.dataframe["PORO"] += 0.2 # add 0.2 to PORO log + wll.create_log("NEW", value=0.333) # create a new log with constant value + + # the "option" is a variable... for output, ``lognames="all"`` is default + if option is None: + # remove all current logs in RMS; only logs will be PORO and NEW + wll.to_roxar(project, "WELL1", update_option=option) + elif option == "overwrite": + # keep all original logs but update PORO and add NEW + wll.to_roxar(project, "WELL1", update_option=option) + elif option == "append": + # keep all original logs as they were (incl. PORO) and add NEW + wll.to_roxar(project, "WELL1", update_option=option) + + Note: + The keywords ``lognames`` and ``update_option`` will interact .. versionadded:: 2.12 .. versionchanged:: 2.15 Saving to new wells enabled (earlier only modifying existing) - + .. versionchanged:: 3.5 + Add key ``update_option`` """ # use *args, **kwargs since this method is overrided in blocked_well, and - # signature should be the same + # signature should be the same (TODO: change this to keywords; think this is + # a python 2.7 relict?) project = args[0] wname = args[1] @@ -707,6 +741,7 @@ def to_roxar(self, *args, **kwargs): trajectory = kwargs.get("trajectory", "Drilled trajectory") logrun = kwargs.get("logrun", "log") realisation = kwargs.get("realisation", 0) + update_option = kwargs.get("update_option", None) logger.debug("Not in use: realisation %s", realisation) @@ -718,6 +753,7 @@ def to_roxar(self, *args, **kwargs): trajectory=trajectory, logrun=logrun, realisation=realisation, + update_option=update_option, ) def get_lognames(self): diff --git a/tests/test_roxarapi/test_roxarapi_reek.py b/tests/test_roxarapi/test_roxarapi_reek.py index cc5f29d11..67547c86b 100644 --- a/tests/test_roxarapi/test_roxarapi_reek.py +++ b/tests/test_roxarapi/test_roxarapi_reek.py @@ -434,3 +434,60 @@ def test_rox_well_with_added_logs(roxar_project): # check that export with set codes well.set_logrecord("Facies", {1: "name"}) well.to_roxar(roxar_project, "dummy3", logrun="log", trajectory="My trajectory") + + +@pytest.mark.requires_roxar +@pytest.mark.parametrize( + "update_option, expected_logs, expected_poroavg", + [ + (None, ["Poro", "NewPoro"], 0.26376), + ("overwrite", ["Zonelog", "Perm", "Poro", "Facies", "NewPoro"], 0.26376), + ("append", ["Zonelog", "Perm", "Poro", "Facies", "NewPoro"], 0.16376), + ], +) +def test_rox_well_update(roxar_project, update_option, expected_logs, expected_poroavg): + """Operations on discrete well logs""" + initial_wellname = WELLS1[1].replace(".w", "") + wellname = "TESTWELL" + + initial_well = xtgeo.well_from_roxar( + roxar_project, + initial_wellname, + logrun="log", + lognames="all", + trajectory="My trajectory", + ) + initial_well.to_roxar(roxar_project, wellname) + + print("###############################################") + + well = xtgeo.well_from_roxar( + roxar_project, + wellname, + lognames=["Poro"], + ) + well.create_log("NewPoro") + well.dataframe["Poro"] += 0.1 + + well.to_roxar( + roxar_project, + wellname, + lognames=well.lognames, + update_option=update_option, + ) + print("Lognames are", well.lognames) + + rox = xtgeo.RoxUtils(roxar_project) + + rox_lcurves = ( + rox.project.wells[wellname] + .wellbore.trajectories["Drilled trajectory"] + .log_runs["log"] + .log_curves + ) + rox_lognames = [lname.name for lname in rox_lcurves] + assert rox_lognames == expected_logs + + assert rox_lcurves["Poro"].get_values().mean() == pytest.approx( + expected_poroavg, abs=0.001 + )