diff --git a/webviz_subsurface/plugins/_swatinit_qc/__init__.py b/webviz_subsurface/plugins/_swatinit_qc/__init__.py index 262986762..3d966a009 100644 --- a/webviz_subsurface/plugins/_swatinit_qc/__init__.py +++ b/webviz_subsurface/plugins/_swatinit_qc/__init__.py @@ -1 +1,2 @@ from ._plugin import SwatinitQC +from ._swatint import SwatinitQcDataModel diff --git a/webviz_subsurface/plugins/_swatinit_qc/_error.py b/webviz_subsurface/plugins/_swatinit_qc/_error.py new file mode 100644 index 000000000..bc998f235 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/_error.py @@ -0,0 +1,5 @@ +from dash import html + + +def error(error_message: str) -> html.Div: + return html.Div(children=error_message, style={"color": "red"}) diff --git a/webviz_subsurface/plugins/_swatinit_qc/_plugin.py b/webviz_subsurface/plugins/_swatinit_qc/_plugin.py index 8cdc38e0d..9a5b45c2f 100644 --- a/webviz_subsurface/plugins/_swatinit_qc/_plugin.py +++ b/webviz_subsurface/plugins/_swatinit_qc/_plugin.py @@ -4,38 +4,13 @@ import webviz_core_components as wcc from webviz_config import WebvizPluginABC, WebvizSettings -from ._business_logic import SwatinitQcDataModel -from ._callbacks import plugin_callbacks -from ._layout import plugin_main_layout +from ._error import error +from ._plugin_ids import PlugInIDs +from ._swatint import SwatinitQcDataModel +from .views import OverviewTabLayout, TabMaxPcInfoLayout, TabQqPlotLayout class SwatinitQC(WebvizPluginABC): - """This plugin is used to visualize the output from [check_swatinit]\ -(https://fmu-docs.equinor.com/docs/subscript/scripts/check_swatinit.html) which is a QC tool -for Water Initialization in Eclipse runs when the `SWATINIT` keyword has been used. It is used to -quantify how much the volume changes from `SWATINIT` to `SWAT` at time zero in the dynamical model, -and help understand why it changes. - ---- -* **`csvfile`:** Path to an csvfile from check_swatinit. The path should be relative to the runpath -if ensemble and realization is given as input, if not the path needs to be absolute. -* **`ensemble`:** Which ensemble in `shared_settings` to visualize. -* **`realization`:** Which realization to pick from the ensemble -* **`faultlines`**: A csv file containing faultpolygons to be visualized together with the map view. -Export format from [xtgeo.xyz.polygons.dataframe]( -https://xtgeo.readthedocs.io/en/latest/apiref/xtgeo.xyz.polygons.html#xtgeo.xyz.polygons.Polygons.dataframe -) \ -[(example file)](\ -https://github.com/equinor/webviz-subsurface-testdata/blob/master/01_drogon_ahm/\ -realization-0/iter-0/share/results/polygons/toptherys--gl_faultlines_extract_postprocess.csv). - ---- -The `csvfile` can be generated by running the [CHECK_SWATINIT](https://fmu-docs.equinor.com/\ -docs/ert/reference/forward_models.html?highlight=swatinit#CHECK_SWATINIT) forward model in ERT, -or with the "check_swatinit" command line tool. - -""" - def __init__( self, webviz_settings: WebvizSettings, @@ -44,7 +19,7 @@ def __init__( realization: Optional[int] = None, faultlines: Path = None, ) -> None: - super().__init__() + super().__init__(stretch=True) self._datamodel = SwatinitQcDataModel( webviz_settings=webviz_settings, @@ -53,15 +28,65 @@ def __init__( realization=realization, faultlines=faultlines, ) - self.add_webvizstore() - self.set_callbacks() + self.error_message = "" + + # Stores used in Overview tab + self.add_store( + PlugInIDs.Stores.Overview.BUTTON, WebvizPluginABC.StorageType.SESSION + ) + + # Stores used in Water tab + self.add_store( + PlugInIDs.Stores.Water.QC_VIZ, WebvizPluginABC.StorageType.SESSION + ) + self.add_store( + PlugInIDs.Stores.Water.EQLNUM, WebvizPluginABC.StorageType.SESSION + ) + self.add_store( + PlugInIDs.Stores.Water.COLOR_BY, WebvizPluginABC.StorageType.SESSION + ) + self.add_store( + PlugInIDs.Stores.Water.MAX_POINTS, WebvizPluginABC.StorageType.SESSION + ) + self.add_store( + PlugInIDs.Stores.Water.QC_FLAG, WebvizPluginABC.StorageType.SESSION + ) + self.add_store( + PlugInIDs.Stores.Water.SATNUM, WebvizPluginABC.StorageType.SESSION + ) + + # Stores used in Capilaty tab + self.add_store( + PlugInIDs.Stores.Capilary.SPLIT_TABLE_BY, + WebvizPluginABC.StorageType.SESSION, + ) + self.add_store( + PlugInIDs.Stores.Capilary.MAX_PC_SCALE, WebvizPluginABC.StorageType.SESSION + ) + self.add_store( + PlugInIDs.Stores.Capilary.EQLNUM, WebvizPluginABC.StorageType.SESSION + ) + + # Adding each tab as a view element + self.add_view( + OverviewTabLayout(self._datamodel), + PlugInIDs.SwatinitViews.OVERVIEW, + PlugInIDs.SwatinitViews.GROUP_NAME, + ) + self.add_view( + TabQqPlotLayout(self._datamodel), + PlugInIDs.SwatinitViews.WATER, + PlugInIDs.SwatinitViews.GROUP_NAME, + ) + self.add_view( + TabMaxPcInfoLayout(self._datamodel), + PlugInIDs.SwatinitViews.WATER, + PlugInIDs.SwatinitViews.GROUP_NAME, + ) @property def layout(self) -> wcc.Tabs: - return plugin_main_layout(self.uuid, self._datamodel) - - def set_callbacks(self) -> None: - plugin_callbacks(self.uuid, self._datamodel) + return error(self.error_message) def add_webvizstore(self) -> List[Tuple[Callable, List[dict]]]: return self._datamodel.webviz_store diff --git a/webviz_subsurface/plugins/_swatinit_qc/_plugin_ids.py b/webviz_subsurface/plugins/_swatinit_qc/_plugin_ids.py new file mode 100644 index 000000000..609773d89 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/_plugin_ids.py @@ -0,0 +1,53 @@ +# pylint: disable=too-few-public-methods +class PlugInIDs: + class Tabs: + QC_PLOTS = "Water Initializaion QC plots" + MAX_PC_SCALING = "Capillary pressure scaling" + OVERVIEW = "Overview and Information" + + class Stores: + class Shared: + PICK_VIEW = "pick-view" + + class Overview: + BUTTON = "button" + + class Water: + QC_VIZ = "qc-viz" + EQLNUM = "eqlnum" + COLOR_BY = "color_by" + MAX_POINTS = "max-points" + QC_FLAG = "qc-flag" + SATNUM = "satnum" + + class Capilary: + SPLIT_TABLE_BY = "split-table-by" + MAX_PC_SCALE = "max-pc-scale" + EQLNUM = "eqlnum" + + class SharedSettings: + PICK_VIEW = "pick-view" + FILTERS = "filters" + + class SettingsGroups: + WATER_SEELECTORS = "water-selectors" + WATER_FILTERS = "water-filters" + CAPILAR_SELECTORS = "capilar-selectors" + CAPILAR_FILTERS = "capilar-filters" + + class QcFlags: + FINE_EQUIL = "FINE_EQUIL" + HC_BELOW_FWL = "HC_BELOW_FWL" + PC_SCALED = "PC_SCALED" + PPCWMAX = "PPCWMAX" + SWATINIT_1 = "SWATINIT_1" + SWL_TRUNC = "SWL_TRUNC" + UNKNOWN = "UNKNOWN" + WATER = "WATER" + + class SwatinitViews: + GROUP_NAME = "swatinit-group" + + OVERVIEW = "overview" + WATER = "water" + CAPILAR = "capilar" diff --git a/webviz_subsurface/plugins/_swatinit_qc/_business_logic.py b/webviz_subsurface/plugins/_swatinit_qc/_swatint.py similarity index 100% rename from webviz_subsurface/plugins/_swatinit_qc/_business_logic.py rename to webviz_subsurface/plugins/_swatinit_qc/_swatint.py diff --git a/webviz_subsurface/plugins/_swatinit_qc/view_elements/__init__.py b/webviz_subsurface/plugins/_swatinit_qc/view_elements/__init__.py new file mode 100644 index 000000000..435f39c52 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/view_elements/__init__.py @@ -0,0 +1,9 @@ +from ._capilar_layout import CapilarViewelement +from ._dash_table import DashTable +from ._fullscreen import FullScreen +from ._layout_style import LayoutStyle +from ._map_figure import MapFigure +from ._overview_layout import OverviewViewelement +from ._properties_vs_depth import PropertiesVsDepthSubplots +from ._water_layout import WaterViewelement +from ._waterfall_plot import WaterfallPlot diff --git a/webviz_subsurface/plugins/_swatinit_qc/view_elements/_capilar_layout.py b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_capilar_layout.py new file mode 100644 index 000000000..de3e313df --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_capilar_layout.py @@ -0,0 +1,152 @@ +from cgi import print_arguments +from typing import List + +import pandas as pd +import plotly.graph_objects as go +import webviz_core_components as wcc +from dash import dash_table, dcc +from dash.development.base_component import Component +from webviz_config.webviz_plugin_subclasses import ViewElementABC + +from .._swatint import SwatinitQcDataModel +from ..views.capilar_tab.settings import CapilarFilters +from ._dash_table import DashTable +from ._fullscreen import FullScreen +from ._layout_style import LayoutStyle +from ._map_figure import MapFigure + +# This can be moved into a view_elements folder in views > capilar_tab + + +class CapilarViewelement(ViewElementABC): + """All elements visible in the 'Caplillary pressure scaling'-tab + gathered in one viewelement""" + + class IDs: + # pylint: disable=too-few-public-methods + INFO_TEXT = "info-text" + MAP = "map" + TABLE = "table" + + def __init__( + self, + datamodel: SwatinitQcDataModel, + ) -> None: + super().__init__() + self.datamodel = datamodel + + self.init_eqlnums = self.datamodel.eqlnums[:1] + continous_filters = ( + [self.datamodel.dframe[col].min(), self.datamodel.dframe[col].max()] + for col in self.datamodel.filters_continuous + ) + + continous_filters_ids = ( + [ + { + "id": CapilarFilters.IDs.RANGE_FILTERS, + "col": col, + } + ] + for col in self.datamodel.filters_continuous + ) + + print({"EQLNUM": self.init_eqlnums}) + self.dframe = self.datamodel.get_dataframe( + filters={"EQLNUM": self.init_eqlnums}, + range_filters=zip_filters(continous_filters, continous_filters_ids), + ) + + df_for_map = datamodel.resample_dataframe(self.dframe, max_points=10000) + self.selectors = self.datamodel.SELECTORS + + self.map_figure = MapFigure( + dframe=df_for_map, + color_by="EQLNUM", + faultlinedf=datamodel.faultlines_df, + colormap=datamodel.create_colormap("EQLNUM"), + ).figure + + def inner_layout(self) -> List[Component]: + return [ + wcc.Header("Maximum capillary pressure scaling", style=LayoutStyle.HEADER), + wcc.FlexBox( + style={"margin-top": "10px", "height": "40vh"}, + children=[ + wcc.FlexColumn( + dcc.Markdown(pc_columns_description()), + id=self.register_component_unique_id( + CapilarViewelement.IDs.INFO_TEXT + ), + flex=7, + style={"margin-right": "40px"}, + ), + wcc.FlexColumn( + FullScreen( + wcc.Graph( + style={"height": "100%", "min-height": "35vh"}, + figure=self.map_figure, + id=self.register_component_unique_id( + CapilarViewelement.IDs.MAP + ), + ) + ), + flex=3, + ), + ], + ), + self.max_pc_table, + ] + + @property + def max_pc_table(self) -> dash_table: + return DashTable( + id=self.register_component_unique_id(CapilarViewelement.IDs.TABLE), + data=self.dframe.to_dict("records"), + columns=[ + { + "name": i, + "id": i, + "type": "numeric" if i not in self.selectors else "text", + "format": {"specifier": ".4~r"} if i not in self.selectors else {}, + } + for i in self.dframe.columns + ], + height="48vh", + sort_action="native", + fixed_rows={"headers": True}, + style_cell={ + "minWidth": LayoutStyle.TABLE_CELL_WIDTH, + "maxWidth": LayoutStyle.TABLE_CELL_WIDTH, + "width": LayoutStyle.TABLE_CELL_WIDTH, + }, + style_data_conditional=[ + { + "if": { + "filter_query": f"{{{self.datamodel.COLNAME_THRESHOLD}}} > 0", + }, + **LayoutStyle.TABLE_HIGHLIGHT, + }, + ], + ) + + +# pylint: disable=anomalous-backslash-in-string +def pc_columns_description() -> str: + return f""" +> **Column descriptions** +> - **PCOW_MAX** - Maximum capillary pressure from the input SWOF/SWFN tables +> - **PC_SCALING** - Maximum capillary pressure scaling applied +> - **PPCW** - Maximum capillary pressure after scaling +> - **{SwatinitQcDataModel.COLNAME_THRESHOLD}** - Column showing how many percent of the pc-scaled dataset that match the user-selected threshold +*PPCW = PCOW_MAX \* PC_SCALING* +A threshold for the maximum capillary scaling can be set in the menu. +The table will show how many percent of the dataset that exceeds this value, and cells above the threshold will be shown in the map ➡️ +""" + + +def zip_filters(filter_values: list, filter_ids: list) -> dict: + for values, id_val in zip(filter_values, filter_ids): + print("val: ", values) + print("id: ", id_val) + return {id_val["col"]: values for values, id_val in zip(filter_values, filter_ids)} diff --git a/webviz_subsurface/plugins/_swatinit_qc/view_elements/_dash_table.py b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_dash_table.py new file mode 100644 index 000000000..ec6e8ba2d --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_dash_table.py @@ -0,0 +1,20 @@ +from typing import Any, List + +from dash import dash_table + +from ._layout_style import LayoutStyle + + +class DashTable(dash_table.DataTable): + def __init__( + self, data: List[dict], columns: List[dict], height: str = "none", **kwargs: Any + ) -> None: + super().__init__( + data=data, + columns=columns, + style_table={"height": height, **LayoutStyle.TABLE_STYLE}, + style_as_list_view=True, + css=LayoutStyle.TABLE_CSS, + style_header=LayoutStyle.TABLE_HEADER, + **kwargs, + ) diff --git a/webviz_subsurface/plugins/_swatinit_qc/view_elements/_fullscreen.py b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_fullscreen.py new file mode 100644 index 000000000..8ea588122 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_fullscreen.py @@ -0,0 +1,8 @@ +from typing import Any, List + +import webviz_core_components as wcc + + +class FullScreen(wcc.WebvizPluginPlaceholder): + def __init__(self, children: List[Any]) -> None: + super().__init__(buttons=["expand"], children=children) diff --git a/webviz_subsurface/plugins/_swatinit_qc/view_elements/_layout_style.py b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_layout_style.py new file mode 100644 index 000000000..9a284c79c --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_layout_style.py @@ -0,0 +1,19 @@ +class LayoutStyle: + MAIN_HEIGHT = "87vh" + HEADER = { + "font-size": "15px", + "color": "black", + "text-transform": "uppercase", + "border-color": "black", + } + TABLE_HEADER = {"fontWeight": "bold"} + TABLE_STYLE = {"max-height": MAIN_HEIGHT, "overflowY": "auto"} + TABLE_CELL_WIDTH = 95 + TABLE_CELL_HEIGHT = "10px" + TABLE_HIGHLIGHT = {"backgroundColor": "rgb(230, 230, 230)", "fontWeight": "bold"} + TABLE_CSS = [ + { + "selector": ".dash-spreadsheet tr", + "rule": f"height: {TABLE_CELL_HEIGHT};", + }, + ] diff --git a/webviz_subsurface/plugins/_swatinit_qc/view_elements/_map_figure.py b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_map_figure.py new file mode 100644 index 000000000..7689d5c08 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_map_figure.py @@ -0,0 +1,78 @@ +from typing import Optional + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from webviz_subsurface._figures import create_figure + + +class MapFigure: + def __init__( + self, + dframe: pd.DataFrame, + color_by: str, + colormap: dict, + faultlinedf: Optional[pd.DataFrame] = None, + ): + self.dframe = dframe + self.color_by = color_by + self.colormap = colormap + self.hover_data = ["I", "J", "K"] + + self._figure = self.create_figure() + + if faultlinedf is not None: + self.add_fault_lines(faultlinedf) + + @property + def figure(self) -> go.Figure: + return self._figure + + @property + def axis_layout(self) -> dict: + return { + "title": None, + "showticklabels": False, + "showgrid": False, + "showline": False, + } + + def create_figure(self) -> go.Figure: + return ( + create_figure( + plot_type="scatter", + data_frame=self.dframe, + x="X", + y="Y", + color=self.color_by + if self.color_by != "PERMX" + else np.log10(self.dframe[self.color_by]), + color_discrete_map=self.colormap, + xaxis={"constrain": "domain", **self.axis_layout}, + yaxis={"scaleanchor": "x", **self.axis_layout}, + hover_data=[self.color_by] + self.hover_data, + color_continuous_scale="Viridis", + ) + .update_traces(marker_size=10, unselected={"marker": {"opacity": 0}}) + .update_coloraxes(showscale=False) + .update_layout( + plot_bgcolor="white", + margin={"t": 10, "b": 10, "l": 0, "r": 0}, + showlegend=False, + ) + ) + + def add_fault_lines(self, faultlinedf: pd.DataFrame) -> None: + for _fault, faultdf in faultlinedf.groupby("POLY_ID"): + self._figure.add_trace( + { + "x": faultdf["X_UTME"], + "y": faultdf["Y_UTMN"], + "mode": "lines", + "type": "scatter", + "hoverinfo": "none", + "showlegend": False, + "line": {"color": "grey", "width": 1}, + } + ) diff --git a/webviz_subsurface/plugins/_swatinit_qc/view_elements/_overview_layout.py b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_overview_layout.py new file mode 100644 index 000000000..fa907e1fb --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_overview_layout.py @@ -0,0 +1,193 @@ +import webviz_core_components as wcc +from dash import dcc, html +from dash.development.base_component import Component +from webviz_config.webviz_plugin_subclasses import ViewElementABC + +from .._swatint import SwatinitQcDataModel +from ._dash_table import DashTable +from ._layout_style import LayoutStyle + +# This can be moved into a view_elements folder in views > overview_tab + + +class OverviewViewelement(ViewElementABC): + """All elements visible in the 'Overview and Information'-tab + gathered in one viewelement""" + + class IDs: + # pylint: disable=too-few-public-methods + LAYOUT = "layout" + DESCRIBTION = "describtion" + FRAME = "frame" + KEY_NUMBERS = "key-numbers" + BUTTON = "button" + INFO_DIALOG = "info-dialog" + TABLE_LABEL = "tabel-label" + TABLE = "table" + + def __init__(self, datamodel: SwatinitQcDataModel) -> None: + super().__init__() + self.datamodel = datamodel + max_pc, min_pc = datamodel.pc_scaling_min_max + wvol, hcvol = datamodel.vol_diff_total + self.number_style = { + "font-weight": "bold", + "font-size": "17px", + "margin-left": "20px", + } + self.infodata = [ + ("HC Volume difference:", f"{hcvol:.2f} %"), + ("Water Volume difference:", f"{wvol:.2f} %"), + ("Maximum Capillary Pressure scaling:", f"{max_pc:.1f}"), + ("Minimum Capillary Pressure scaling:", f"{min_pc:.3g}"), + ] + self.title = "Plugin and 'check_swatinit' information" + + self.tabledata, self.columns = datamodel.table_data_qc_vol_overview() + self.label = ( + "Table showing volume changes from SWATINIT to SWAT at Reservoir conditions" + ) + + def inner_layout(self) -> html.Div: + return html.Div( + id=self.register_component_unique_id(OverviewViewelement.IDs.LAYOUT), + children=[ + html.Div( + style={"height": "40vh", "overflow-y": "auto"}, + children=[ + wcc.FlexBox( + children=[ + wcc.FlexColumn( + [ + self.table, + ], + flex=7, + style={"margin-right": "20px"}, + ), + wcc.FlexColumn(self.infobox, flex=3), + ], + ), + ], + ), + self.describtion, + ], + ) + + @property + def describtion(self) -> html.Div: + return html.Div( + style={"margin-top": "20px"}, + id=OverviewViewelement.IDs.DESCRIBTION, + children=[ + wcc.Header("QC_FLAG descriptions", style=LayoutStyle.HEADER), + dcc.Markdown(qc_flag_description()), + ], + ) + + @property + def infobox(self) -> Component: + return wcc.Frame( + style={"height": "90%"}, + id=self.register_component_unique_id(OverviewViewelement.IDs.FRAME), + children=[ + wcc.Header("Information", style=LayoutStyle.HEADER), + self.info_dialog, + wcc.Header("Key numbers", style=LayoutStyle.HEADER), + html.Div( + [ + html.Div([text, html.Span(num, style=self.number_style)]) + for text, num in self.infodata + ], + id=self.register_component_unique_id( + OverviewViewelement.IDs.KEY_NUMBERS + ), + ), + ], + ) + + @property + def info_dialog(self) -> html.Div: + return html.Div( + style={"margin-bottom": "30px"}, + children=[ + html.Button( + "CLICK HERE FOR INFORMATION", + style={"width": "100%", "background-color": "white"}, + id=self.register_component_unique_id( + OverviewViewelement.IDs.BUTTON + ), + ), + wcc.Dialog( + title=self.title, + id=self.register_component_unique_id( + OverviewViewelement.IDs.INFO_DIALOG + ), + max_width="md", + open=False, + children=dcc.Markdown(check_swatinit_description()), + ), + ], + ) + + @property + def table(self) -> html.Div: + return html.Div( + children=[ + html.Div( + html.Label(self.label, className="webviz-underlined-label"), + style={"margin-bottom": "20px"}, + id=self.register_component_unique_id( + OverviewViewelement.IDs.TABLE_LABEL + ), + ), + DashTable( + id=self.register_component_unique_id(OverviewViewelement.IDs.TABLE), + data=self.tabledata, + columns=self.columns, + style_data_conditional=[ + { + "if": {"row_index": [0, len(self.tabledata) - 1]}, + **LayoutStyle.TABLE_HIGHLIGHT, + }, + ], + ), + ], + ) + + +def qc_flag_description() -> str: + return """ +- **PC_SCALED** - Capillary pressure have been scaled and SWATINIT was accepted. +- **FINE_EQUIL** - If item 9 in EQUIL is nonzero then initialization happens in a vertically + refined model. Capillary pressure is still scaled, but water might be added or lost. +- **SWL_TRUNC** - If SWL is larger than SWATINIT, SWAT will be reset to SWL. Extra water is + added and hydrocarbons are lost. +- **SWATINIT_1** - When SWATINIT is 1 above the contact, Eclipse will ignore SWATINIT and not + touch the capillary pressure function which typically results in extra hydrocarbons. + This could be ok as long as the porosities and/or permeabilities of these cells are small. + If SWU is included, cells where SWATINIT is equal or larger than SWU will + also be flagged as SWATINIT_1 +- **HC_BELOW_FWL** - If SWATINIT is less than 1 below the contact provided in EQUIL, Eclipse will + ignore it and not scale the capillary pressure function. SWAT will be 1, unless a capillary + pressure function with negative values is in SWOF/SWFN. This results in the loss of HC volumes. +- **PPCWMAX** - If an upper limit of how much capillary pressure scaling is allowed is set, water will be + lost if this limit is hit. +- **WATER** - SWATINIT was 1 in the water zone, and SWAT is set to 1. +> **Consult the [check_swatinit](https://fmu-docs.equinor.com/docs/subscript/scripts/check_swatinit.html) + documentation for more detailed descriptions** +""" + + +def check_swatinit_description() -> str: + return """ +This plugin is used to visualize the output from **check_swatinit** which is a **QC tool for Water Initialization in Eclipse runs +when the SWATINIT keyword has been used**. It is used to quantify how much the volume changes from **SWATINIT** to **SWAT** at time +zero in the dynamical model, and help understand why it changes. +When the keyword SWATINIT has been used as water initialization option in Eclipse, capillary pressure scaling on a cell-by-cell basis will +occur in order to match SWATINIT from the geomodel. +This process has some exceptions which can cause volume changes from SWATINIT to SWAT at time zero. +Each cell in the dynamical model has been flagged according to what has happened during initialization, and information is stored +in the **QC_FLAG** column. +> Check the maximum capillary pressure pr SATNUM in each EQLNUM to ensure extreme values were not necessary +[check_swatinit documentation](https://fmu-docs.equinor.com/docs/subscript/scripts/check_swatinit.html) +""" diff --git a/webviz_subsurface/plugins/_swatinit_qc/view_elements/_properties_vs_depth.py b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_properties_vs_depth.py new file mode 100644 index 000000000..1b6053903 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_properties_vs_depth.py @@ -0,0 +1,166 @@ +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots + +from webviz_subsurface._utils.colors import hex_to_rgb, rgb_to_str, scale_rgb_lightness + + +def axis_defaults(showgrid: bool = True) -> dict: + return { + "showline": True, + "linewidth": 2, + "linecolor": "black", + "mirror": True, + "showgrid": showgrid, + "gridwidth": 1, + "gridcolor": "lightgrey", + } + + +class PropertiesVsDepthSubplots: + def __init__( + self, + dframe: pd.DataFrame, + color_by: str, + colormap: dict, + discrete_color: bool = True, + ) -> None: + self.dframe = dframe + self.color_by = color_by + self.discrete_color = discrete_color + self.colormap = colormap + self.layout = [(1, 1), (1, 2), (2, 1), (2, 2)] + self.responses = ["SWATINIT", "SWAT", "PRESSURE", "PC"] + self.hover_data = self.create_hover_data( + include_columns=["QC_FLAG", "EQLNUM", "SATNUM", "I", "J", "K"] + ) + self.uirevision = "".join([str(x) for x in self.dframe["EQLNUM"].unique()]) + + self._figure = self.create_empty_subplots_figure(rows=2, cols=2) + self.add_subplotfigures_to_main_figure() + self.add_contacts_to_plot() + + @property + def figure(self) -> go.Figure: + return self._figure + + @property + def hovertemplate(self) -> go.Figure: + return ( + "X: %{x}
Y: %{y}
" + + "
".join( + [ + f"{col}:%{{customdata[{idx}]}}" + for idx, col in enumerate(self.hover_data) + ] + ) + + "" + ) + + def create_empty_subplots_figure(self, rows: int, cols: int) -> go.Figure: + return ( + make_subplots( + rows=rows, + cols=cols, + subplot_titles=[f"Depth vs {resp}" for resp in self.responses], + shared_yaxes=True, + vertical_spacing=0.07, + horizontal_spacing=0.05, + ) + .update_layout( + plot_bgcolor="white", + uirevision=self.uirevision, + margin={"t": 50, "b": 10, "l": 10, "r": 10}, + legend={"orientation": "h"}, + clickmode="event+select", + coloraxis={"colorscale": "Viridis", **self.colorbar}, + ) + .update_yaxes(autorange="reversed", **axis_defaults()) + .update_xaxes(axis_defaults()) + .update_xaxes(row=1, col=2, matches="x") + ) + + def add_subplotfigures_to_main_figure(self) -> None: + # for discrete colors there should be one trace per unique color + unique_traces = ( + self.dframe[self.color_by].unique() + if self.discrete_color + else [self.color_by] + ) + + for color in unique_traces: + df = self.dframe + df = df[df[self.color_by] == color] if self.discrete_color else df + customdata = np.stack([df[col] for col in self.hover_data], axis=-1) + + for idx, response in enumerate(self.responses): + trace = go.Scattergl( + x=df[response], + y=df["Z"], + mode="markers", + name=color, + showlegend=idx == 0, + marker=self.set_marker_style(color, df), + unselected={"marker": self.set_unselected_marker_style(color)}, + customdata=customdata, + hovertemplate=self.hovertemplate, + legendgroup=color, + ).update(marker_size=10) + + row, col = self.layout[idx] + self._figure.add_trace(trace, row=row, col=col) + + @property + def colorbar(self) -> dict: + if self.color_by != "PERMX": + return {} + tickvals = list(range(-4, 5, 1)) + return { + "colorbar": { + "tickvals": tickvals, + "ticktext": [10**val for val in tickvals], + } + } + + def create_hover_data(self, include_columns: list) -> list: + # ensure the colorby is the first entry in the list -> used in customdata in callback + hover_data = [self.color_by] + for col in include_columns: + if col not in hover_data: + hover_data.append(col) + return hover_data + + def set_marker_style(self, color: str, df: pd.DataFrame) -> dict: + if not self.discrete_color: + return { + "coloraxis": "coloraxis", + "color": df[self.color_by] + if self.color_by != "PERMX" + else np.log10(df[self.color_by]), + } + return {"color": self.colormap[color], "opacity": 0.5} + + def set_unselected_marker_style(self, color: str) -> dict: + if not self.discrete_color: + return {"opacity": 0.1} + return { + "color": rgb_to_str( + scale_rgb_lightness(hex_to_rgb(self.colormap[color]), 250) + ) + } + + def add_contacts_to_plot(self) -> None: + """Annotate axes with named horizontal lines for contacts.""" + for contact in ["OWC", "GWC", "GOC"]: + if contact in self.dframe and self.dframe["EQLNUM"].nunique() == 1: + # contacts are assumed constant in the dataframe + value = self.dframe[contact].values[0] + # do not include dummy contacts (shallower than the dataset) + if value > self.dframe["Z"].min(): + self._figure.add_hline( + value, + line={"color": "black", "dash": "dash", "width": 1.5}, + annotation_text=f"{contact}={value:g}", + annotation_position="bottom left", + ) diff --git a/webviz_subsurface/plugins/_swatinit_qc/view_elements/_water_layout.py b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_water_layout.py new file mode 100644 index 000000000..cf8d0265c --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_water_layout.py @@ -0,0 +1,118 @@ +import plotly.graph_objects as go +import webviz_core_components as wcc +from dash import dcc, html +from dash.development.base_component import Component +from webviz_config.webviz_plugin_subclasses import ViewElementABC + +from .._swatint import SwatinitQcDataModel +from ._fullscreen import FullScreen +from ._layout_style import LayoutStyle + +# This can be moved into a view_elements folder in views > water_tab + + +class WaterViewelement(ViewElementABC): + """All elements visible in the 'Water Initialization QC plots'-tab + gathered in one viewelement""" + + class IDs: + # pylint: disable=too-few-public-methods + MAIN_FIGURE = "main-figure" + MAP_FIGURE = "map-figure" + INFO_BOX_EQLNUMS = "info-box-eqlnums" + INFO_BOX_SATNUMS = "infobox-satnums" + INFO_BOX_VOL_DIFF = "info-box-vol-diff" + + def __init__( + self, + datamodel: SwatinitQcDataModel, + main_figure: go.Figure, + map_figure: go.Figure, + qc_volumes: dict, + ) -> None: + super().__init__() + self.datamodel = datamodel + + self.main_figure = main_figure + self.map_figure = map_figure + self.qc_volumes = qc_volumes + + def inner_layout(self) -> wcc.FlexBox: + return wcc.FlexBox( + children=[ + wcc.FlexColumn( + flex=4, + children=wcc.Graph( + style={"height": "85vh"}, + id=self.register_component_unique_id( + WaterViewelement.IDs.MAIN_FIGURE + ), + figure=self.main_figure, + ), + ), + wcc.FlexColumn( + flex=1, + children=[ + FullScreen( + wcc.Graph( + responsive=True, + style={"height": "100%", "min-height": "45vh"}, + id=self.register_component_unique_id( + WaterViewelement.IDs.MAP_FIGURE + ), + figure=self.map_figure, + ) + ), + self.info_box, + ], + ), + ] + ) + + @property + def info_box(self) -> html.Div: + qc_vols = self.qc_volumes + height = "35vh" + return html.Div( + [ + wcc.Header("Information about selection", style=LayoutStyle.HEADER), + html.Div( + "EQLNUMS:", + style={"font-weight": "bold", "font-size": "15px"}, + ), + html.Div( + ", ".join([str(x) for x in qc_vols["EQLNUMS"]]), + id=self.register_component_unique_id( + WaterViewelement.IDs.INFO_BOX_EQLNUMS + ), + ), + html.Div( + "SATNUMS:", + style={"font-weight": "bold", "font-size": "15px"}, + ), + html.Div( + ", ".join([str(x) for x in qc_vols["SATNUMS"]]), + id=self.register_component_unique_id( + WaterViewelement.IDs.INFO_BOX_SATNUMS + ), + ), + html.Div( + html.Span("Reservoir Volume Difference:"), + style={"font-weight": "bold", "margin-top": "10px"}, + ), + html.Div( + children=[ + html.Div(line) + for line in [ + f"Water Volume Diff: {qc_vols['WVOL_DIFF']/(10**6):.2f} Mrm3", + f"Water Volume Diff (%): {qc_vols['WVOL_DIFF_PERCENT']:.2f}", + f"HC Volume Diff (%): {qc_vols['HCVOL_DIFF_PERCENT']:.2f}", + ] + ], + id=self.register_component_unique_id( + WaterViewelement.IDs.INFO_BOX_VOL_DIFF + ), + ), + ], + style={"height": height, "padding": "10px"}, + ) diff --git a/webviz_subsurface/plugins/_swatinit_qc/view_elements/_waterfall_plot.py b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_waterfall_plot.py new file mode 100644 index 000000000..38b5437f3 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/view_elements/_waterfall_plot.py @@ -0,0 +1,114 @@ +from typing import List + +import plotly.graph_objects as go + +from .._plugin_ids import PlugInIDs + + +def axis_defaults(showgrid: bool = True) -> dict: + return { + "showline": True, + "linewidth": 2, + "linecolor": "black", + "mirror": True, + "showgrid": showgrid, + "gridwidth": 1, + "gridcolor": "lightgrey", + } + + +class WaterfallPlot: + # Ensure fixed order of plot elements: + ORDER = [ + "SWATINIT_WVOL", + PlugInIDs.QcFlags.SWL_TRUNC, + PlugInIDs.QcFlags.PPCWMAX, + PlugInIDs.QcFlags.FINE_EQUIL, + PlugInIDs.QcFlags.HC_BELOW_FWL, + PlugInIDs.QcFlags.SWATINIT_1, + "SWAT_WVOL", + ] + MEASURES = [ + "absolute", + "relative", + "relative", + "relative", + "relative", + "relative", + "total", + ] + + def __init__(self, qc_vols: dict) -> None: + # collect necessary values from input and make volume values more human friendly + self.qc_vols = { + key: (qc_vols[key] / (10**6)) + for key in self.ORDER + ["SWATINIT_HCVOL", "SWAT_HCVOL"] + } + self.qc_vols.update( + {key: qc_vols[key] for key in ["WVOL_DIFF_PERCENT", "HCVOL_DIFF_PERCENT"]} + ) + + @property + def range(self) -> list: + range_min = min(self.qc_vols["SWATINIT_WVOL"], self.qc_vols["SWAT_WVOL"]) * 0.95 + range_max = max(self.qc_vols["SWATINIT_WVOL"], self.qc_vols["SWAT_WVOL"]) * 1.05 + return [range_min, range_max] + + @property + def figure(self) -> go.Figure: + return ( + go.Figure( + go.Waterfall( + orientation="v", + measure=self.MEASURES, + x=self.ORDER, + textposition="outside", + text=self.create_bartext(), + y=[self.qc_vols[key] for key in self.ORDER], + connector={"mode": "spanning"}, + ) + ) + .update_yaxes( + title="Water Volume (Mrm3)", range=self.range, **axis_defaults() + ) + .update_xaxes( + type="category", + tickangle=-45, + tickfont_size=17, + **axis_defaults(showgrid=False), + ) + .update_layout( + plot_bgcolor="white", + title="Waterfall chart showing changes from SWATINIT to SWAT", + margin={"t": 50, "b": 50, "l": 50, "r": 50}, + ) + ) + + def create_bartext(self) -> List[str]: + """ + Create bartext for each qc_flag category with Water and HC volume change + relative to SWATINIT_WVOL in percent. + """ + text = [] + for bar_name in self.ORDER: + bartext = [f"{self.qc_vols[bar_name]:.2f} Mrm3"] + if bar_name != self.ORDER[0]: + bartext.append( + f"Water {self.get_water_diff_in_percent(bar_name):.1f} %" + ) + bartext.append(f"HC {self.get_hc_diff_in_percent(bar_name):.1f} %") + + text.append("
".join(bartext)) + return text + + def get_water_diff_in_percent(self, bar_name: str) -> float: + if bar_name == self.ORDER[-1]: + return self.qc_vols["WVOL_DIFF_PERCENT"] + return (self.qc_vols[bar_name] / self.qc_vols["SWATINIT_WVOL"]) * 100 + + def get_hc_diff_in_percent(self, bar_name: str) -> float: + if bar_name == self.ORDER[-1]: + return self.qc_vols["HCVOL_DIFF_PERCENT"] + if self.qc_vols["SWATINIT_HCVOL"] > 0: + return (-self.qc_vols[bar_name] / self.qc_vols["SWATINIT_HCVOL"]) * 100 + return 0 diff --git a/webviz_subsurface/plugins/_swatinit_qc/views/__init__.py b/webviz_subsurface/plugins/_swatinit_qc/views/__init__.py new file mode 100644 index 000000000..d47bb7016 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/views/__init__.py @@ -0,0 +1,3 @@ +from .capilar_tab import CapilarFilters, CapilarSelections, TabMaxPcInfoLayout +from .overbiew_tab import OverviewTabLayout +from .water_tab import TabQqPlotLayout, WaterFilters, WaterSelections diff --git a/webviz_subsurface/plugins/_swatinit_qc/views/capilar_tab/__init__.py b/webviz_subsurface/plugins/_swatinit_qc/views/capilar_tab/__init__.py new file mode 100644 index 000000000..bf186e2d4 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/views/capilar_tab/__init__.py @@ -0,0 +1,2 @@ +from ._capilary_preassure import TabMaxPcInfoLayout +from .settings import CapilarFilters, CapilarSelections diff --git a/webviz_subsurface/plugins/_swatinit_qc/views/capilar_tab/_capilary_preassure.py b/webviz_subsurface/plugins/_swatinit_qc/views/capilar_tab/_capilary_preassure.py new file mode 100644 index 000000000..7baa0e435 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/views/capilar_tab/_capilary_preassure.py @@ -0,0 +1,153 @@ +from typing import Dict, List, Optional + +from dash import ALL, Input, Output, State, callback +from webviz_config.webviz_plugin_subclasses import ViewABC + +from ..._plugin_ids import PlugInIDs +from ..._swatint import SwatinitQcDataModel +from ...view_elements import CapilarViewelement, MapFigure +from .settings import CapilarFilters, CapilarSelections + + +class TabMaxPcInfoLayout(ViewABC): + class IDs: + # pylint: disable=too-few-public-methods + CAPILAR_TAB = "capilar-tab" + MAIN_CLOUMN = "main-column" + + def __init__( + self, + datamodel: SwatinitQcDataModel, + ) -> None: + super().__init__("Capillary pressure scaling") + self.datamodel = datamodel + self.selectors = self.datamodel.SELECTORS + + main_column = self.add_column(TabMaxPcInfoLayout.IDs.MAIN_CLOUMN) + row = main_column.make_row() + row.add_view_element( + CapilarViewelement(self.datamodel), + TabMaxPcInfoLayout.IDs.CAPILAR_TAB, + ) + + self.add_settings_group( + CapilarSelections(self.datamodel), + PlugInIDs.SettingsGroups.CAPILAR_SELECTORS, + ) + self.add_settings_group( + CapilarFilters(self.datamodel), PlugInIDs.SettingsGroups.CAPILAR_FILTERS + ) + + def set_callbacks(self) -> None: + # update map + @callback( + Output( + self.view_element(TabMaxPcInfoLayout.IDs.CAPILAR_TAB) + .component_unique_id(CapilarViewelement.IDs.MAP) + .to_string(), + "figure", + ), + Input(self.get_store_unique_id(PlugInIDs.Stores.Capilary.EQLNUM), "data"), + Input( + self.get_store_unique_id(PlugInIDs.Stores.Capilary.MAX_PC_SCALE), + "data", + ), + Input( + { + "id": self.view_element(TabMaxPcInfoLayout.IDs.CAPILAR_TAB) + .component_unique_id(CapilarFilters.IDs.RANGE_FILTERS) + .to_string(), # litt usikker på denne...? + "col": ALL, + }, + "value", + ), + State( + { + "id": self.view_element(TabMaxPcInfoLayout.IDs.CAPILAR_TAB) + .component_unique_id(CapilarFilters.IDs.RANGE_FILTERS) + .to_string(), + "col": ALL, + }, + "id", + ), + ) + def _update_map( + eqlnums: list, + threshold: Optional[int], + continous_filters: List[List[str]], + continous_filters_ids: List[Dict[str, str]], + ) -> MapFigure: + df = self.datamodel.get_dataframe( + filters={"EQLNUM": eqlnums}, + range_filters=zip_filters(continous_filters, continous_filters_ids), + ) + df_for_map = df[df["PC_SCALING"] >= threshold] + if threshold is None: + df_for_map = self.datamodel.resample_dataframe(df, max_points=10000) + + return MapFigure( + dframe=df_for_map, + color_by="EQLNUM", + faultlinedf=self.datamodel.faultlines_df, + colormap=self.datamodel.create_colormap("EQLNUM"), + ).figure + + # update table + @callback( + Output( + self.view_element(TabMaxPcInfoLayout.IDs.CAPILAR_TAB) + .component_unique_id(CapilarViewelement.IDs.TABLE) + .to_string(), + "columns", + ), + Input( + self.get_store_unique_id(PlugInIDs.Stores.Capilary.MAX_PC_SCALE), + "data", + ), + Input( + self.get_store_unique_id(PlugInIDs.Stores.Capilary.SPLIT_TABLE_BY), + "data", + ), + Input(self.get_store_unique_id(PlugInIDs.Stores.Capilary.EQLNUM), "data"), + Input( + {"id": CapilarFilters.IDs.RANGE_FILTERS, "col": ALL}, + "value", + ), + State( + {"id": CapilarFilters.IDs.RANGE_FILTERS, "col": ALL}, + "id", + ), + ) + def _update_table( + threshold: Optional[list], + groupby_eqlnum: list, + eqlnums: list, + continous_filters: List[List[str]], + continous_filters_ids: List[Dict[str, str]], + ) -> List[dict]: + df = self.datamodel.get_dataframe( + filters={"EQLNUM": eqlnums}, + range_filters=zip_filters(continous_filters, continous_filters_ids), + ) + dframe = ( + self.datamodel.get_max_pc_info_and_percent_for_data_matching_condition( + dframe=df, + condition=threshold, + groupby_eqlnum=groupby_eqlnum == "both", + ) + ) + text_columns = self.selectors + columns = [ + { + "name": i, + "id": i, + "type": "numeric" if i not in text_columns else "text", + "format": {"specifier": ".4~r"} if i not in text_columns else {}, + } + for i in dframe.columns + ] + return columns + + +def zip_filters(filter_values: list, filter_ids: list) -> dict: + return {id_val["col"]: values for values, id_val in zip(filter_values, filter_ids)} diff --git a/webviz_subsurface/plugins/_swatinit_qc/views/capilar_tab/settings/__init__.py b/webviz_subsurface/plugins/_swatinit_qc/views/capilar_tab/settings/__init__.py new file mode 100644 index 000000000..6866d83b8 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/views/capilar_tab/settings/__init__.py @@ -0,0 +1 @@ +from ._caplilar_settings import CapilarFilters, CapilarSelections diff --git a/webviz_subsurface/plugins/_swatinit_qc/views/capilar_tab/settings/_caplilar_settings.py b/webviz_subsurface/plugins/_swatinit_qc/views/capilar_tab/settings/_caplilar_settings.py new file mode 100644 index 000000000..bcb19b096 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/views/capilar_tab/settings/_caplilar_settings.py @@ -0,0 +1,121 @@ +from logging import captureWarnings +from typing import List + +import webviz_core_components as wcc +from dash import Input, Output, callback, dcc +from dash.development.base_component import Component +from webviz_config.webviz_plugin_subclasses import SettingsGroupABC + +from ...._plugin_ids import PlugInIDs +from ...._swatint import SwatinitQcDataModel + + +class CapilarSelections(SettingsGroupABC): + class IDs: + # pylint: disable=too-few-public-methods + SPLIT_TABLE_BY = "split-table-by" + MAX_THRESH = "max-thresh" + + def __init__(self, datamodel: SwatinitQcDataModel) -> None: + super().__init__("Selections") + self.datamodel = datamodel + + def layout(self) -> List[Component]: + return [ + wcc.RadioItems( + label="Split table by:", + id=self.register_component_unique_id( + CapilarSelections.IDs.SPLIT_TABLE_BY + ), + options=[ + {"label": "SATNUM", "value": "SATNUM"}, + {"label": "SATNUM and EQLNUM", "value": "both"}, + ], + value="SATNUM", + ), + wcc.Label("Maximum PC_SCALING threshold"), + dcc.Input( + id=self.register_component_unique_id(CapilarSelections.IDs.MAX_THRESH), + type="number", + persistence=True, + persistence_type="session", + ), + ] + + def set_callbacks(self) -> None: + @callback( + Output( + self.get_store_unique_id(PlugInIDs.Stores.Capilary.SPLIT_TABLE_BY), + "data", + ), + Input( + self.component_unique_id( + CapilarSelections.IDs.SPLIT_TABLE_BY + ).to_string(), + "value", + ), + ) + def _set_split(split: str) -> str: + return split + + @callback( + Output( + self.get_store_unique_id(PlugInIDs.Stores.Capilary.MAX_PC_SCALE), "data" + ), + Input( + self.component_unique_id(CapilarSelections.IDs.MAX_THRESH).to_string(), + "value", + ), + ) + def _set_max_pc(max_pc: int) -> int: + return max_pc + + +class CapilarFilters(SettingsGroupABC): + class IDs: + # pylint: disable=too-few-public-methods + EQLNUM = "eqlnum" + RANGE_FILTERS = "range_filters" + + def __init__(self, datamodel: SwatinitQcDataModel) -> None: + super().__init__("Filter") + self.datamodel = datamodel + self.range_filters_id = self.register_component_unique_id( + CapilarFilters.IDs.RANGE_FILTERS + ) + + def layout(self) -> List[Component]: + return [ + wcc.SelectWithLabel( + label="EQLNUM", + id=self.register_component_unique_id(CapilarFilters.IDs.EQLNUM), + options=[ + {"label": ens, "value": ens} for ens in self.datamodel.eqlnums + ], + value=self.datamodel.eqlnums[:1], + size=min(8, len(self.datamodel.eqlnums)), + multi=True, + ), + self.range_filters, + ] + + @property + def range_filters(self) -> List: + dframe = self.datamodel.dframe + filters = [] + for col in self.datamodel.filters_continuous: + min_val, max_val = dframe[col].min(), dframe[col].max() + filters.append( + wcc.RangeSlider( + label="Depth range" if col == "Z" else col, + id={"id": self.range_filters_id, "col": col}, + min=min_val, + max=max_val, + value=[min_val, max_val], + marks={ + str(val): {"label": f"{val:.2f}"} for val in [min_val, max_val] + }, + tooltip={"always_visible": False}, + ) + ) + return filters diff --git a/webviz_subsurface/plugins/_swatinit_qc/views/overbiew_tab/__init__.py b/webviz_subsurface/plugins/_swatinit_qc/views/overbiew_tab/__init__.py new file mode 100644 index 000000000..98c6f3689 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/views/overbiew_tab/__init__.py @@ -0,0 +1 @@ +from ._overview_info import OverviewTabLayout diff --git a/webviz_subsurface/plugins/_swatinit_qc/views/overbiew_tab/_overview_info.py b/webviz_subsurface/plugins/_swatinit_qc/views/overbiew_tab/_overview_info.py new file mode 100644 index 000000000..1e3686224 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/views/overbiew_tab/_overview_info.py @@ -0,0 +1,46 @@ +import webviz_core_components as wcc +from dash import Input, Output, State, callback, html +from dash.exceptions import PreventUpdate +from webviz_config.webviz_plugin_subclasses import ViewABC + +from ..._plugin_ids import PlugInIDs +from ..._swatint import SwatinitQcDataModel +from ...view_elements import OverviewViewelement + + +class OverviewTabLayout(ViewABC): + class IDs: + # pylint: disable=too-few-public-methods + OVERVIEW_TAB = "overview-tab" + MAIN_CLOUMN = "main-column" + + def __init__(self, datamodel: SwatinitQcDataModel) -> None: + super().__init__("Overview and Information") + self.datamodel = datamodel + + main_column = self.add_column(OverviewTabLayout.IDs.MAIN_CLOUMN) + row = main_column.make_row() + row.add_view_element( + OverviewViewelement(self.datamodel), OverviewTabLayout.IDs.OVERVIEW_TAB + ) + + def set_callbacks(self) -> None: + @callback( + Output( + self.view_element(OverviewTabLayout.IDs.OVERVIEW_TAB) + .component_unique_id(OverviewViewelement.IDs.INFO_DIALOG) + .to_string(), + "open", + ), + Input(self.get_store_unique_id(PlugInIDs.Stores.Overview.BUTTON), "data"), + State( + self.view_element(OverviewTabLayout.IDs.OVERVIEW_TAB) + .component_unique_id(OverviewViewelement.IDs.INFO_DIALOG) + .to_string(), + "open", + ), + ) + def open_close_information_dialog(_n_click: list, is_open: bool) -> bool: + if _n_click is not None: + return not is_open + raise PreventUpdate diff --git a/webviz_subsurface/plugins/_swatinit_qc/views/water_tab/__init__.py b/webviz_subsurface/plugins/_swatinit_qc/views/water_tab/__init__.py new file mode 100644 index 000000000..c4f38d69d --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/views/water_tab/__init__.py @@ -0,0 +1,2 @@ +from ._water_initialization import TabQqPlotLayout +from .settings import WaterFilters, WaterSelections diff --git a/webviz_subsurface/plugins/_swatinit_qc/views/water_tab/_water_initialization.py b/webviz_subsurface/plugins/_swatinit_qc/views/water_tab/_water_initialization.py new file mode 100644 index 000000000..2088ddfce --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/views/water_tab/_water_initialization.py @@ -0,0 +1,233 @@ +from typing import Dict, List, Optional, Tuple, Union + +import plotly.graph_objects as go +from dash import ALL, Input, Output, State, callback, callback_context +from webviz_config.webviz_plugin_subclasses import ViewABC + +from ..._plugin_ids import PlugInIDs +from ..._swatint import SwatinitQcDataModel +from ...view_elements import ( + MapFigure, + PropertiesVsDepthSubplots, + WaterfallPlot, + WaterViewelement, +) +from .settings import WaterFilters, WaterSelections + + +class TabQqPlotLayout(ViewABC): + class IDs: + # pylint: disable=too-few-public-methods + WATER_TAB = "water-tab" + MAIN_COLUMN = "main-column" + + def __init__( + self, + datamodel: SwatinitQcDataModel, + ) -> None: + super().__init__("Water Initialization QC plots") + self.datamodel = datamodel + + # Need to define these quanitities for the inital case + self.main_figure = main_figure + self.map_figure = map_figure + self.qc_volumes = qc_volumes + + main_column = self.add_column(TabQqPlotLayout.IDs.MAIN_COLUMN) + row = main_column.make_row() + row.add_view_element( + WaterViewelement( + self.datamodel, self.main_figure, self.map_figure, self.qc_volumes + ), + TabQqPlotLayout.IDs.WATER_TAB, + ) + + self.add_settings_group( + WaterSelections(self.datamodel), PlugInIDs.SettingsGroups.WATER_SEELECTORS + ) + self.add_settings_group( + WaterFilters(self.datamodel), PlugInIDs.SettingsGroups.WATER_FILTERS + ) + + def set_callbacks(self) -> None: + # update + @callback( + Output( + self.view_element(TabQqPlotLayout.IDs.WATER_TAB) + .component_unique_id(WaterViewelement.IDs.MAIN_FIGURE) + .to_string(), + "figure", + ), + Output( + self.view_element(TabQqPlotLayout.IDs.WATER_TAB) + .component_unique_id(WaterViewelement.IDs.MAP_FIGURE) + .to_string(), + "figure", + ), + Output( + self.view_element(TabQqPlotLayout.IDs.WATER_TAB) + .component_unique_id(WaterViewelement.IDs.INFO_BOX_EQLNUMS) + .to_string(), + "children", + ), + Output( + self.view_elements(TabQqPlotLayout.IDs.WATER_TAB).component_unique_id( + WaterViewelement.IDs.INFO_BOX_SATNUMS + ), + "children", + ), + Output( + self.view_elements(TabQqPlotLayout.IDs.WATER_TAB).component_unique_id( + WaterViewelement.IDs.INFO_BOX_VOL_DIFF + ), + "children", + ), + Input(self.get_store_unique_id(PlugInIDs.Stores.Water.QC_VIZ), "data"), + Input(self.get_store_unique_id(PlugInIDs.Stores.Water.EQLNUM), "data"), + Input(self.get_store_unique_id(PlugInIDs.Stores.Water.COLOR_BY), "data"), + Input(self.get_store_unique_id(PlugInIDs.Stores.Water.MAX_POINTS), "data"), + Input({"id": WaterFilters.range_filters_id, "col": ALL}, "value"), + Input({"id": WaterFilters.descreate_filter_id, "col": ALL}, "value"), + State({"id": WaterFilters.range_filters_id, "col": ALL}, "id"), + State({"id": WaterFilters.descreate_filter_id, "col": ALL}, "id"), + ) + # pylint: disable=too-many-arguments + def _update_plot( + qc_viz: str, + eqlnums: List[str], + color_by: str, + max_points: int, + continous_filters_val: List[List[str]], + descreate_filters_val: List[List[str]], + continous_filters_ids: List[Dict[str, str]], + descreate_filters_ids: List[Dict[str, str]], + ) -> list: + + filters = zip_filters(descreate_filters_val, descreate_filters_ids) + filters.update({"EQLNUM": eqlnums}) + + df = self.datamodel.get_dataframe( + filters=filters, + range_filters=zip_filters(continous_filters_val, continous_filters_ids), + ) + if df.empty: + return ["No data left after filtering"] + + qc_volumes = self.datamodel.compute_qc_volumes(df) + + df = self.datamodel.filter_dframe_on_depth(df) + df = self.datamodel.resample_dataframe(df, max_points=max_points) + + colormap = self.datamodel.create_colormap(color_by) + main_plot = ( + WaterfallPlot(qc_vols=qc_volumes).figure + if qc_viz == WaterSelections.Values.WATERFALL + else PropertiesVsDepthSubplots( + dframe=df, + color_by=color_by, + colormap=colormap, + discrete_color=color_by in self.datamodel.SELECTORS, + ).figure + ) + map_figure = MapFigure( + dframe=df, + color_by=color_by, + faultlinedf=self.datamodel.faultlines_df, + colormap=colormap, + ).figure + + return qc_plot_layout.main_layout( + main_figure=main_plot, + map_figure=map_figure, + qc_volumes=qc_volumes, + ) # this must return something else + + @callback( + Output( + self.view_element(TabQqPlotLayout.IDs.WATER_TAB) + .component_unique_id(WaterViewelement.IDs.MAIN_FIGURE) + .to_string(), + "figure", + ), + Output( + self.view_element(TabQqPlotLayout.IDs.WATER_TAB) + .component_unique_id(WaterViewelement.IDs.MAP_FIGURE) + .to_string(), + "figure", + ), + Input( + self.view_element(TabQqPlotLayout.IDs.WATER_TAB) + .component_unique_id(WaterViewelement.IDs.MAIN_FIGURE) + .to_string(), + "selectedData", + ), + Input( + self.view_element(TabQqPlotLayout.IDs.WATER_TAB) + .component_unique_id(WaterViewelement.IDs.MAP_FIGURE) + .to_string(), + "selectedData", + ), + State( + self.view_element(TabQqPlotLayout.IDs.WATER_TAB) + .component_unique_id(WaterViewelement.IDs.MAIN_FIGURE) + .to_string(), + "figure", + ), + State( + self.view_element(TabQqPlotLayout.IDs.WATER_TAB) + .component_unique_id(WaterViewelement.IDs.MAP_FIGURE) + .to_string(), + "figure", + ), + ) + def _update_selected_points_in_figure( + selected_main: dict, selected_map: dict, mainfig: dict, mapfig: dict + ) -> Tuple[dict, dict]: + ctx = callback_context.triggered[0]["prop_id"] + + selected = ( + selected_map + if WaterViewelement.IDs.MAP_FIGURE in ctx + else selected_main + ) + point_indexes = get_point_indexes_from_selected(selected) + + for trace in mainfig["data"]: + update_selected_points_in_trace(trace, point_indexes) + for trace in mapfig["data"]: + update_selected_points_in_trace(trace, point_indexes) + + return mainfig, mapfig + + def get_point_indexes_from_selected( + selected: Optional[dict], + ) -> Union[list, dict]: + if not (isinstance(selected, dict) and "points" in selected): + return [] + + continous_color = "marker.color" in selected["points"][0] + if continous_color: + return [point["pointNumber"] for point in selected["points"]] + + point_indexes: dict = {} + for point in selected["points"]: + trace_name = str(point["customdata"][0]) + if trace_name not in point_indexes: + point_indexes[trace_name] = [] + point_indexes[trace_name].append(point["pointNumber"]) + return point_indexes + + def update_selected_points_in_trace( + trace: dict, point_indexes: Union[dict, list] + ) -> None: + if "name" in trace: + selectedpoints = ( + point_indexes + if isinstance(point_indexes, list) + else point_indexes.get(trace["name"], []) + ) + trace.update(selectedpoints=selectedpoints if point_indexes else None) + + +def zip_filters(filter_values: list, filter_ids: list) -> dict: + return {id_val["col"]: values for values, id_val in zip(filter_values, filter_ids)} diff --git a/webviz_subsurface/plugins/_swatinit_qc/views/water_tab/settings/__init__.py b/webviz_subsurface/plugins/_swatinit_qc/views/water_tab/settings/__init__.py new file mode 100644 index 000000000..fc900d6d1 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/views/water_tab/settings/__init__.py @@ -0,0 +1 @@ +from ._water_settings import WaterFilters, WaterSelections diff --git a/webviz_subsurface/plugins/_swatinit_qc/views/water_tab/settings/_water_settings.py b/webviz_subsurface/plugins/_swatinit_qc/views/water_tab/settings/_water_settings.py new file mode 100644 index 000000000..20ad65680 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc/views/water_tab/settings/_water_settings.py @@ -0,0 +1,163 @@ +from typing import List + +import webviz_core_components as wcc +from dash import Input, Output, callback, dcc +from dash.development.base_component import Component +from webviz_config.webviz_plugin_subclasses import SettingsGroupABC + +from ...._plugin_ids import PlugInIDs +from ...._swatint import SwatinitQcDataModel + + +class WaterSelections(SettingsGroupABC): + class IDs: + # pylint: disable=too-few-public-methods + SELECT_QC = "select-qc" + EQLNUM = "eqlnum" + COLOR_BY = "color-by" + MAX_POINTS = "max-points" + + class Values: + # pylint: disable=too-few-public-methods + WATERFALL = "waterfall" + PROP_VS_DEPTH = "prop-vs-depth" + + def __init__(self, datamodel: SwatinitQcDataModel) -> None: + super().__init__("Selections") + self.datamodel = datamodel + + def layout(self) -> List[Component]: + return [ + wcc.Dropdown( + label="Select QC-visualization:", + id=self.register_component_unique_id(WaterSelections.IDs.SELECT_QC), + options=[ + { + "label": "Waterfall plot for water vol changes", + "value": WaterSelections.Values.WATERFALL, + }, + { + "label": "Reservoir properties vs Depth", + "value": WaterSelections.Values.PROP_VS_DEPTH, + }, + ], + value=WaterSelections.Values.PROP_VS_DEPTH, + clearable=False, + ), + wcc.SelectWithLabel( + label="EQLNUM", + id=self.register_component_unique_id(WaterSelections.IDs.EQLNUM), + options=[ + {"label": ens, "value": ens} for ens in self.datamodel.eqlnums + ], + value=self.datamodel.eqlnums[:1], + size=min(8, len(self.datamodel.eqlnums)), + multi=True, + ), + wcc.Dropdown( + label="Color by", + id=self.register_component_unique_id(WaterSelections.IDs.COLOR_BY), + options=[ + {"label": ens, "value": ens} + for ens in self.datamodel.color_by_selectors + ], + value="QC_FLAG", + clearable=False, + ), + wcc.Label("Max number of points:"), + dcc.Input( + id=self.register_component_unique_id(WaterSelections.IDs.MAX_POINTS), + type="number", + value=5000, + ), + ] + + def set_callbacks(self) -> None: + @callback( + Output(self.get_store_unique_id(PlugInIDs.Stores.Water.QC_VIZ), "data"), + Input(self.component_unique_id(WaterSelections.IDs.SELECT_QC), "value"), + ) + def _set_qc_viz(qc_viz: str) -> str: + return qc_viz + + @callback( + Output(self.get_store_unique_id(PlugInIDs.Stores.Water.EQLNUM), "data"), + Input(self.component_unique_id(WaterSelections.IDs.EQLNUM), "value"), + ) + def _set_eqlnum(eqlnum: int) -> int: + return eqlnum + + @callback( + Output(self.get_store_unique_id(PlugInIDs.Stores.Water.COLOR_BY), "data"), + Input(self.component_unique_id(WaterSelections.IDs.COLOR_BY), "value"), + ) + def _set_color_by(color: str) -> str: + return color + + @callback( + Output(self.get_store_unique_id(PlugInIDs.Stores.Water.MAX_POINTS), "data"), + Input(self.component_unique_id(WaterSelections.IDs.MAX_POINTS), "value"), + ) + def _set_max_points(max: str) -> str: + return max + + +class WaterFilters(SettingsGroupABC): + class IDs: + # pylint: disable=too-few-public-methods + DESCREATE_FILTERS = "descreate-filters" + RANGE_FILTERS = "range_filters" + + def __init__(self, datamodel) -> None: + super().__init__("Filters") + self.datamodel = datamodel + self.descreate_fiters_id = self.register_component_unique_id( + WaterFilters.IDs.DESCREATE_FILTERS + ) + self.range_filters_id = self.register_component_unique_id( + WaterFilters.IDs.RANGE_FILTERS + ) + + def layout(self) -> List[Component]: + return [ + wcc.SelectWithLabel( + label="QC_FLAG", + id={"id": self.range_filters_id, "col": "qc-flag"}, + options=[ + {"label": ens, "value": ens} for ens in self.datamodel.qc_flag + ], + value=self.datamodel.qc_flag, + size=min(8, len(self.datamodel.qc_flag)), + ), + wcc.SelectWithLabel( + label="SATNUM", + id={"id": self.range_filters_id, "col": "satnum"}, + options=[ + {"label": ens, "value": ens} for ens in self.datamodel.satnums + ], + value=self.datamodel.satnums, + size=min(8, len(self.datamodel.satnums)), + ), + self.range_filters, + ] + + @property + def range_filters(self) -> List: + dframe = self.datamodel.dframe + filters = [] + for col in self.datamodel.filters_continuous: + min_val, max_val = dframe[col].min(), dframe[col].max() + filters.append( + wcc.RangeSlider( + label="Depth range" if col == "Z" else col, + id={"id": self.range_filters_id, "col": col}, + min=min_val, + max=max_val, + value=[min_val, max_val], + marks={ + str(val): {"label": f"{val:.2f}"} for val in [min_val, max_val] + }, + tooltip={"always_visible": False}, + ) + ) + return filters diff --git a/webviz_subsurface/plugins/_swatinit_qc_old/__init__.py b/webviz_subsurface/plugins/_swatinit_qc_old/__init__.py new file mode 100644 index 000000000..262986762 --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc_old/__init__.py @@ -0,0 +1 @@ +from ._plugin import SwatinitQC diff --git a/webviz_subsurface/plugins/_swatinit_qc_old/_business_logic.py b/webviz_subsurface/plugins/_swatinit_qc_old/_business_logic.py new file mode 100644 index 000000000..ea4ce335b --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc_old/_business_logic.py @@ -0,0 +1,330 @@ +import re +from enum import Enum +from pathlib import Path +from typing import Callable, Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd +from webviz_config import WebvizSettings +from webviz_config.common_cache import CACHE +from webviz_config.webviz_store import webvizstore + + +class QcFlags(str, Enum): + """Constants for use by check_swatinit""" + + FINE_EQUIL = "FINE_EQUIL" + HC_BELOW_FWL = "HC_BELOW_FWL" + PC_SCALED = "PC_SCALED" + PPCWMAX = "PPCWMAX" + SWATINIT_1 = "SWATINIT_1" + SWL_TRUNC = "SWL_TRUNC" + UNKNOWN = "UNKNOWN" + WATER = "WATER" + + +class SwatinitQcDataModel: + """Class keeping the data needed in the vizualisations and various + data providing methods. + """ + + COLNAME_THRESHOLD = "HC cells above threshold (%)" + SELECTORS = ["QC_FLAG", "SATNUM", "EQLNUM", "FIPNUM"] + + DROP_COLUMNS = [ + "Z_MIN", + "Z_MAX", + "GLOBAL_INDEX", + "SWATINIT_SWAT", + "SWATINIT_SWAT_WVOL", + "SWL_x", + "SWL_y", + "Z_DATUM", + "PRESSURE_DATUM", + ] + + def __init__( + self, + webviz_settings: WebvizSettings, + csvfile: str, + ensemble: str = None, + realization: Optional[int] = None, + faultlines: Optional[Path] = None, + ): + + self._theme = webviz_settings.theme + self._faultlines = faultlines + self.faultlines_df = read_csv(faultlines) if faultlines else None + + if ensemble is not None: + if isinstance(ensemble, list): + raise TypeError( + 'Incorrent argument type, "ensemble" must be a string instead of a list' + ) + if realization is None: + raise ValueError('Incorrent arguments, "realization" must be specified') + + ens_path = webviz_settings.shared_settings["scratch_ensembles"][ensemble] + # replace realization in string from scratch_ensemble with input realization + ens_path = re.sub( + "realization-[^/]", f"realization-{realization}", ens_path + ) + self.csvfile = Path(ens_path) / csvfile + else: + self.csvfile = Path(csvfile) + + self.dframe = read_csv(self.csvfile) + self.dframe.drop(columns=self.DROP_COLUMNS, errors="ignore", inplace=True) + + for col in self.SELECTORS + ["OWC", "GWC", "GOC"]: + if col in self.dframe: + self.dframe[col] = self.dframe[col].astype("category") + + self._initial_qc_volumes = self.compute_qc_volumes(self.dframe) + + @property + def webviz_store(self) -> List[Tuple[Callable, List[dict]]]: + return [ + ( + read_csv, + [ + {"csv_file": path} + for path in [self._faultlines, self.csvfile] + if path is not None + ], + ) + ] + + @property + def colors(self) -> List[str]: + return self._theme.plotly_theme["layout"]["colorway"] + + @property + def qc_flag_colors(self) -> Dict[str, str]: + """Predefined colors for the QC_FLAG column""" + return { + QcFlags.FINE_EQUIL.value: self.colors[8], + QcFlags.HC_BELOW_FWL.value: self.colors[5], + QcFlags.PC_SCALED.value: self.colors[2], + QcFlags.PPCWMAX.value: self.colors[9], + QcFlags.SWATINIT_1.value: self.colors[6], + QcFlags.SWL_TRUNC.value: self.colors[3], + QcFlags.UNKNOWN.value: self.colors[1], + QcFlags.WATER.value: self.colors[0], + } + + @property + def eqlnums(self) -> List[str]: + return sorted(list(self.dframe["EQLNUM"].unique()), key=int) + + @property + def satnums(self) -> List[str]: + return sorted(list(self.dframe["SATNUM"].unique()), key=int) + + @property + def qc_flag(self) -> List[str]: + return sorted(list(self.dframe["QC_FLAG"].unique())) + + @property + def filters_discrete(self) -> List[str]: + return ["QC_FLAG", "SATNUM"] + + @property + def filters_continuous(self) -> List[str]: + return ["Z", "PC", "SWATINIT", "PERMX", "PORO"] + + @property + def color_by_selectors(self) -> List[str]: + return self.SELECTORS + ["PERMX", "PORO"] + + @property + def pc_scaling_min_max(self) -> Tuple[float, float]: + return (self.dframe["PC_SCALING"].max(), self.dframe["PC_SCALING"].min()) + + @property + def vol_diff_total(self) -> Tuple[float, float]: + return ( + self._initial_qc_volumes["WVOL_DIFF_PERCENT"], + self._initial_qc_volumes["HCVOL_DIFF_PERCENT"], + ) + + def get_dataframe( + self, + filters: Optional[dict] = None, + range_filters: Optional[dict] = None, + ) -> pd.DataFrame: + + df = self.dframe.copy() + filters = filters if filters is not None else {} + range_filters = range_filters if range_filters is not None else {} + + for filt, value in filters.items(): + df = df[df[filt].isin(value)] + + for filt, value in range_filters.items(): + min_val, max_val = value + df = df[(df[filt] >= min_val) & (df[filt] <= max_val) | (df[filt].isnull())] + + return df + + @staticmethod + def resample_dataframe(dframe: pd.DataFrame, max_points: int) -> pd.DataFrame: + """Resample a dataframe to max number of points. The sampling will be + weighted in order to avoid removal of points that has an important qc_flag. + Points will mostly be removed if they are flagged as "WATER" or "PC_SCALED" + """ + if dframe.shape[0] > max_points: + dframe = dframe.copy() + dframe["sample_weight"] = 1 + dframe.loc[dframe["QC_FLAG"] == "WATER", "sample_weight"] = 0.1 + dframe.loc[dframe["QC_FLAG"] == "PC_SCALED", "sample_weight"] = 0.5 + return dframe.sample(max_points, weights=dframe["sample_weight"]) + return dframe + + @staticmethod + def filter_dframe_on_depth(dframe: pd.DataFrame) -> pd.DataFrame: + """Suggest a deep depth limit for what to plot, in order to avoid + showing too much of a less interesting water zone + """ + max_z = dframe["Z"].max() + hc_dframe = dframe[dframe["SWATINIT"] < 1] + if not hc_dframe.empty: + lowest_hc = hc_dframe["Z"].max() + hc_height = lowest_hc - dframe["Z"].min() + # Suggest to visualize a water height of 10% of the hc zone: + max_z = lowest_hc + 0.2 * hc_height + + return dframe[dframe["Z"] <= max_z] + + @staticmethod + def compute_qc_volumes(dframe: pd.DataFrame) -> dict: + """Compute numbers relevant for QC of saturation initialization of a + reservoir model. + Different volume numbers are typically related to the different QC_FLAG + """ + qc_vols: dict = {} + + # Ensure all QCFlag categories are represented: + for qc_cat in QcFlags: + qc_vols[qc_cat.value] = 0.0 + + # Overwrite dict values with correct figures: + for qc_cat, qc_df in dframe.groupby("QC_FLAG"): + qc_vols[qc_cat] = ( + (qc_df["SWAT"] - qc_df["SWATINIT"]) * qc_df["PORV"] + ).sum() + + if "VOLUME" in dframe: + qc_vols["VOLUME"] = dframe["VOLUME"].sum() + + qc_vols["PORV"] = dframe["PORV"].sum() + qc_vols["SWATINIT_WVOL"] = (dframe["SWATINIT"] * dframe["PORV"]).sum() + qc_vols["SWATINIT_HCVOL"] = qc_vols["PORV"] - qc_vols["SWATINIT_WVOL"] + qc_vols["SWAT_WVOL"] = (dframe["SWAT"] * dframe["PORV"]).sum() + qc_vols["SWAT_HCVOL"] = qc_vols["PORV"] - qc_vols["SWAT_WVOL"] + + # compute difference columns + qc_vols["WVOL_DIFF"] = qc_vols["SWAT_WVOL"] - qc_vols["SWATINIT_WVOL"] + qc_vols["WVOL_DIFF_PERCENT"] = ( + qc_vols["WVOL_DIFF"] / qc_vols["SWATINIT_WVOL"] + ) * 100 + qc_vols["HCVOL_DIFF"] = qc_vols["SWAT_HCVOL"] - qc_vols["SWATINIT_HCVOL"] + qc_vols["HCVOL_DIFF_PERCENT"] = ( + ((qc_vols["HCVOL_DIFF"] / qc_vols["SWATINIT_HCVOL"]) * 100) + if qc_vols["HCVOL_DIFF"] != 0.0 + else 0 + ) + qc_vols["EQLNUMS"] = sorted(dframe["EQLNUM"].unique()) + qc_vols["SATNUMS"] = sorted(dframe["SATNUM"].unique()) + + return qc_vols + + def create_colormap(self, color_by: str) -> dict: + """Create a colormap to ensure that the subplot and the mapfigure + has the same color for the same unique value. If 'QC_FLAG' is used as + color column, predefined colors are used. + """ + + return ( + dict(zip(self.dframe[color_by].unique(), self.colors * 10)) + if color_by != "QC_FLAG" + else self.qc_flag_colors + ) + + def get_max_pc_info_and_percent_for_data_matching_condition( + self, + dframe: pd.DataFrame, + condition: Optional[int], + groupby_eqlnum: bool = True, + ) -> pd.DataFrame: + def get_percent_of_match(df: pd.DataFrame, condition: Optional[int]) -> float: + df = df[df["QC_FLAG"] == "PC_SCALED"] + if condition is None or df.empty: + return np.nan + return (len(df[df["PC_SCALING"] >= condition]) / len(df)) * 100 + + groupby = ["SATNUM"] if not groupby_eqlnum else ["EQLNUM", "SATNUM"] + df_group = dframe.groupby(groupby) + df = df_group.max()[["PCOW_MAX", "PPCW", "PC_SCALING"]].round(6) + df[self.COLNAME_THRESHOLD] = df_group.apply( + lambda x: get_percent_of_match(x, condition) + ) + return df.reset_index().sort_values(groupby, key=lambda col: col.astype(int)) + + def table_data_qc_vol_overview(self) -> tuple: + """Return data and columns for dash_table showing overview of qc volumes""" + + skip_if_zero = [QcFlags.UNKNOWN.value, QcFlags.WATER.value] + column_order = [ + "", + "Response", + "Water Volume Diff", + "HC Volume Diff", + "Water Volume Mrm3", + "HC Volume Mrm3", + ] + qc_vols = self._initial_qc_volumes + + table_data = [] + # First report the SWATINIT volumes + table_data.append( + { + "Response": "SWATINIT", + "Water Volume Mrm3": f"{qc_vols['SWATINIT_WVOL']/1e6:>10.3f}", + "HC Volume Mrm3": f" {qc_vols['SWATINIT_HCVOL']/1e6:>8.3f}", + } + ) + # Then report the volume change per QC_FLAG + for key in [x.value for x in QcFlags]: + if key in skip_if_zero and np.isclose(qc_vols[key], 0, atol=1): + # Tolerance is 1 rm3, which is small in relevant contexts. + continue + table_data.append( + { + "": "+", + "Response": key, + "Water Volume Mrm3": f"{qc_vols[key]/1e6:>10.3f}", + "Water Volume Diff": f"{qc_vols[key]/qc_vols['SWATINIT_WVOL']*100:>3.2f} %", + "HC Volume Diff": f"{-qc_vols[key]/qc_vols['SWATINIT_HCVOL']*100:>3.2f} %" + if qc_vols["SWATINIT_HCVOL"] > 0 + else "0.00 %", + } + ) + # Last report the SWAT volumes and change from SWATINIT + table_data.append( + { + "": "=", + "Response": "SWAT", + "Water Volume Mrm3": f"{qc_vols['SWAT_WVOL']/1e6:>10.3f}", + "Water Volume Diff": f"{qc_vols['WVOL_DIFF_PERCENT']:>3.2f} %", + "HC Volume Diff": f"{qc_vols['HCVOL_DIFF_PERCENT']:>3.2f} %", + "HC Volume Mrm3": f"{qc_vols['SWAT_HCVOL']/1e6:>8.3f}", + } + ) + return table_data, [{"name": i, "id": i} for i in column_order] + + +@CACHE.memoize(timeout=CACHE.TIMEOUT) +@webvizstore +def read_csv(csv_file: str) -> pd.DataFrame: + return pd.read_csv(csv_file) diff --git a/webviz_subsurface/plugins/_swatinit_qc/_callbacks.py b/webviz_subsurface/plugins/_swatinit_qc_old/_callbacks.py similarity index 100% rename from webviz_subsurface/plugins/_swatinit_qc/_callbacks.py rename to webviz_subsurface/plugins/_swatinit_qc_old/_callbacks.py diff --git a/webviz_subsurface/plugins/_swatinit_qc/_figures.py b/webviz_subsurface/plugins/_swatinit_qc_old/_figures.py similarity index 100% rename from webviz_subsurface/plugins/_swatinit_qc/_figures.py rename to webviz_subsurface/plugins/_swatinit_qc_old/_figures.py diff --git a/webviz_subsurface/plugins/_swatinit_qc/_layout.py b/webviz_subsurface/plugins/_swatinit_qc_old/_layout.py similarity index 100% rename from webviz_subsurface/plugins/_swatinit_qc/_layout.py rename to webviz_subsurface/plugins/_swatinit_qc_old/_layout.py diff --git a/webviz_subsurface/plugins/_swatinit_qc/_markdown.py b/webviz_subsurface/plugins/_swatinit_qc_old/_markdown.py similarity index 100% rename from webviz_subsurface/plugins/_swatinit_qc/_markdown.py rename to webviz_subsurface/plugins/_swatinit_qc_old/_markdown.py diff --git a/webviz_subsurface/plugins/_swatinit_qc_old/_plugin.py b/webviz_subsurface/plugins/_swatinit_qc_old/_plugin.py new file mode 100644 index 000000000..8cdc38e0d --- /dev/null +++ b/webviz_subsurface/plugins/_swatinit_qc_old/_plugin.py @@ -0,0 +1,67 @@ +from pathlib import Path +from typing import Callable, List, Optional, Tuple + +import webviz_core_components as wcc +from webviz_config import WebvizPluginABC, WebvizSettings + +from ._business_logic import SwatinitQcDataModel +from ._callbacks import plugin_callbacks +from ._layout import plugin_main_layout + + +class SwatinitQC(WebvizPluginABC): + """This plugin is used to visualize the output from [check_swatinit]\ +(https://fmu-docs.equinor.com/docs/subscript/scripts/check_swatinit.html) which is a QC tool +for Water Initialization in Eclipse runs when the `SWATINIT` keyword has been used. It is used to +quantify how much the volume changes from `SWATINIT` to `SWAT` at time zero in the dynamical model, +and help understand why it changes. + +--- +* **`csvfile`:** Path to an csvfile from check_swatinit. The path should be relative to the runpath +if ensemble and realization is given as input, if not the path needs to be absolute. +* **`ensemble`:** Which ensemble in `shared_settings` to visualize. +* **`realization`:** Which realization to pick from the ensemble +* **`faultlines`**: A csv file containing faultpolygons to be visualized together with the map view. +Export format from [xtgeo.xyz.polygons.dataframe]( +https://xtgeo.readthedocs.io/en/latest/apiref/xtgeo.xyz.polygons.html#xtgeo.xyz.polygons.Polygons.dataframe +) \ +[(example file)](\ +https://github.com/equinor/webviz-subsurface-testdata/blob/master/01_drogon_ahm/\ +realization-0/iter-0/share/results/polygons/toptherys--gl_faultlines_extract_postprocess.csv). + +--- +The `csvfile` can be generated by running the [CHECK_SWATINIT](https://fmu-docs.equinor.com/\ +docs/ert/reference/forward_models.html?highlight=swatinit#CHECK_SWATINIT) forward model in ERT, +or with the "check_swatinit" command line tool. + +""" + + def __init__( + self, + webviz_settings: WebvizSettings, + csvfile: str = "share/results/tables/check_swatinit.csv", + ensemble: Optional[str] = None, + realization: Optional[int] = None, + faultlines: Path = None, + ) -> None: + super().__init__() + + self._datamodel = SwatinitQcDataModel( + webviz_settings=webviz_settings, + csvfile=csvfile, + ensemble=ensemble, + realization=realization, + faultlines=faultlines, + ) + self.add_webvizstore() + self.set_callbacks() + + @property + def layout(self) -> wcc.Tabs: + return plugin_main_layout(self.uuid, self._datamodel) + + def set_callbacks(self) -> None: + plugin_callbacks(self.uuid, self._datamodel) + + def add_webvizstore(self) -> List[Tuple[Callable, List[dict]]]: + return self._datamodel.webviz_store