diff --git a/CHANGELOG.md b/CHANGELOG.md index 7978895b2..bb8cf822f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [UNRELEASED] - YYYY-MM-DD + +### Added +- [#1244](https://github.com/equinor/webviz-subsurface/pull/1244) - New functionality in `VolumetricAnalysis` to compute facies fractions if `FACIES` is present in the volumetric table. Also added possibility to have labels on bar plots with user defined value format. + + +## [0.2.22] - 2023-08-31 ### Added - [#1227](https://github.com/equinor/webviz-subsurface/pull/1227) - New functionality in `SimulationTimeSeriesOneByOne`: option to use the sensitivity filter on all visualisations, not only the timeseries. diff --git a/webviz_subsurface/_figures/px_figure.py b/webviz_subsurface/_figures/px_figure.py index 45006388a..29b5f4cfd 100644 --- a/webviz_subsurface/_figures/px_figure.py +++ b/webviz_subsurface/_figures/px_figure.py @@ -6,6 +6,8 @@ import plotly.graph_objects as go from pandas.api.types import is_numeric_dtype +VALID_BOXMODES = ["group", "overlay"] + def create_figure(plot_type: str, **kwargs: Any) -> go.Figure: """Create subplots for selected parameters""" @@ -33,6 +35,9 @@ def set_default_args(**plotargs: Any) -> dict: plotargs["barmode"] = plotargs.get("barmode", "group") plotargs["opacity"] = plotargs.get("opacity", 0.7) + if "boxmode" in plotargs and plotargs["boxmode"] not in VALID_BOXMODES: + plotargs["boxmode"] = VALID_BOXMODES[0] + if plotargs.get("facet_col") is not None: facet_cols = plotargs["data_frame"][plotargs["facet_col"]].nunique() plotargs.update( diff --git a/webviz_subsurface/_models/inplace_volumes_model.py b/webviz_subsurface/_models/inplace_volumes_model.py index 2c9c0a4ff..f5f213518 100644 --- a/webviz_subsurface/_models/inplace_volumes_model.py +++ b/webviz_subsurface/_models/inplace_volumes_model.py @@ -59,9 +59,7 @@ def __init__( if volume_type != "dynamic": # compute water zone volumes if total volumes are present if any(col.endswith("_TOTAL") for col in volumes_table.columns): - volumes_table = self._compute_water_zone_volumes( - volumes_table, selectors - ) + volumes_table = self._compute_water_zone_volumes(volumes_table) # stack dataframe on fluid zone and add fluid as column instead of a column suffix dfs = [] @@ -201,15 +199,20 @@ def parameters(self) -> List[str]: return self.pmodel.parameters @staticmethod - def _compute_water_zone_volumes( - voldf: pd.DataFrame, selectors: list - ) -> pd.DataFrame: + def _compute_water_zone_volumes(voldf: pd.DataFrame) -> pd.DataFrame: """Compute water zone volumes by subtracting HC-zone volumes from TOTAL volumes""" supported_columns = ["BULK_TOTAL", "NET_TOTAL", "PORE_TOTAL", "PORV_TOTAL"] + supported_responses_in_df = [ + col.replace("_TOTAL", "") for col in supported_columns if col in voldf + ] # Format check for src, df in voldf.groupby("SOURCE"): - volcols = [col for col in df if col not in selectors] + volcols = [ + col + for col in df + if any(col.startswith(resp) for resp in supported_responses_in_df) + ] if not any(col in volcols for col in supported_columns): continue if df[volcols].isnull().values.any(): @@ -221,7 +224,7 @@ def _compute_water_zone_volumes( ) return voldf - for col in [x.replace("_TOTAL", "") for x in voldf if x in supported_columns]: + for col in supported_responses_in_df: voldf[f"{col}_WATER"] = ( voldf[f"{col}_TOTAL"] - voldf.get(f"{col}_OIL", 0) @@ -247,6 +250,9 @@ def _set_initial_property_columns(self) -> None: if all(col in self._dataframe for col in ["HCPV", "PORV"]): self._property_columns.append("SW") + if "FACIES" in self.selectors and "BULK" in self._dataframe: + self._property_columns.append("FACIES_FRACTION") + for vol_column in ["STOIIP", "GIIP"]: if all(col in self._dataframe for col in ["HCPV", vol_column]): pvt = "BO" if vol_column == "STOIIP" else "BG" @@ -276,11 +282,11 @@ def compute_property_columns( dframe.replace(np.inf, np.nan, inplace=True) return dframe - def get_df( + def _get_dataframe_with_volumetrics_and_properties( self, - filters: Optional[Dict[str, list]] = None, - groups: Optional[list] = None, - parameters: Optional[list] = None, + filters: Dict[str, list], + groups: list, + parameters: list, properties: Optional[list] = None, ) -> pd.DataFrame: """Function to retrieve a dataframe with volumetrics and properties. Parameters @@ -290,10 +296,6 @@ def get_df( """ dframe = self.dataframe.copy() - groups = groups if groups is not None else [] - filters = filters if filters is not None else {} - parameters = parameters if parameters is not None else [] - if parameters and self.parameters: columns = parameters + ["REAL", "ENSEMBLE"] dframe = pd.merge( @@ -319,8 +321,48 @@ def get_df( dframe["BO"] = np.nan if not filters.get("FLUID_ZONE") == ["gas"]: dframe["BG"] = np.nan + if "FACIES" not in groups: + dframe["FACIES_FRACTION"] = np.nan + return dframe + def get_df( + self, + filters: Optional[Dict[str, list]] = None, + groups: Optional[list] = None, + parameters: Optional[list] = None, + properties: Optional[list] = None, + ) -> pd.DataFrame: + """ + Function to retrieve a dataframe with volumetrics and properties, using the + "_get_dataframe_with_volumetrics_and_properties" method. If FACIES is used as + a group selector, the returning dataframe will include facies fractions. + Note if FACIES has been included as filter this filter is applied after + calculating facies fractions. + """ + + groups = groups if groups is not None else [] + filters = filters if filters is not None else {} + parameters = parameters if parameters is not None else [] + + if "FACIES" not in groups: + return self._get_dataframe_with_volumetrics_and_properties( + filters, groups, parameters, properties + ) + + filters_excl_facies = { + key: val for key, val in filters.items() if key != "FACIES" + } + dframe = self._get_dataframe_with_volumetrics_and_properties( + filters_excl_facies, groups, parameters, properties + ) + # Remove "FACIES" to compute facies fraction for the individual groups + groups = [x for x in groups if x != "FACIES"] + df = dframe.groupby(groups) if groups else dframe + dframe["FACIES_FRACTION"] = df["BULK"].transform(lambda x: x / x.sum()) + + return dframe[dframe["FACIES"].isin(filters["FACIES"])] if filters else dframe + def filter_df(dframe: pd.DataFrame, filters: dict) -> pd.DataFrame: """ diff --git a/webviz_subsurface/plugins/_volumetric_analysis/controllers/comparison_controllers.py b/webviz_subsurface/plugins/_volumetric_analysis/controllers/comparison_controllers.py index d2337747b..45274d860 100644 --- a/webviz_subsurface/plugins/_volumetric_analysis/controllers/comparison_controllers.py +++ b/webviz_subsurface/plugins/_volumetric_analysis/controllers/comparison_controllers.py @@ -109,6 +109,9 @@ def comparison_callback( ): groupby.append("FLUID_ZONE") + if selections["Response"] == "FACIES_FRACTION" and "FACIES" not in groupby: + groupby.append("FACIES") + if display_option == "multi-response table": # select max one hc_response for a cleaner table responses = [selections["Response"]] + [ @@ -287,7 +290,6 @@ def create_comparison_df( df[col, "diff (%)"] = ((df[col][value2] / df[col][value1]) - 1) * 100 df.loc[df[col]["diff"] == 0, (col, "diff (%)")] = 0 df = df[responses].replace([np.inf, -np.inf], np.nan).reset_index() - # remove rows where the selected response is nan # can happen for properties where the volume columns are 0 df = df.loc[~((df[resp][value1].isna()) & (df[resp][value2].isna()))] diff --git a/webviz_subsurface/plugins/_volumetric_analysis/controllers/distribution_controllers.py b/webviz_subsurface/plugins/_volumetric_analysis/controllers/distribution_controllers.py index e6c1df8b8..0b9daad68 100644 --- a/webviz_subsurface/plugins/_volumetric_analysis/controllers/distribution_controllers.py +++ b/webviz_subsurface/plugins/_volumetric_analysis/controllers/distribution_controllers.py @@ -15,9 +15,11 @@ from webviz_subsurface._models import InplaceVolumesModel from ..utils.table_and_figure_utils import ( + FLUID_COLORS, create_data_table, create_table_columns, fluid_annotation, + get_text_format_bar_plot, ) from ..utils.utils import move_to_end_of_list, to_ranges from ..views.distribution_main_layout import ( @@ -67,6 +69,12 @@ def _update_page_custom(selections: dict, page_selected: str) -> tuple: "oil" if "BO" in selected_data else "gas" ] + if "FACIES_FRACTION" in selected_data and "FACIES" not in groups: + return html.Div( + "To plot FACIES_FRACTIONS, select 'FACIES' as response, subplot or color", + style={"margin-top": "40px"}, + ) + dframe = volumemodel.get_df( filters=selections["filters"], groups=groups, parameters=parameters ) @@ -98,8 +106,16 @@ def _update_page_custom(selections: dict, page_selected: str) -> tuple: color=selections["Color by"], color_discrete_sequence=selections["Colorscale"], color_continuous_scale=selections["Colorscale"], + color_discrete_map=FLUID_COLORS + if selections["Color by"] == "FLUID_ZONE" + else None, barmode=selections["barmode"], boxmode=selections["barmode"], + text_auto=get_text_format_bar_plot( + selected_data, selections, volumemodel + ) + if selections["Plot type"] == "bar" + else False, layout=dict( title=dict( text=( @@ -231,16 +247,16 @@ def _update_page_per_zr(selections: dict, page_selected: str) -> list: layout={"bargap": 0.05}, color_discrete_sequence=selections["Colorscale"], color=selections["Color by"], - text=selections["X Response"], xaxis=dict(type="category", tickangle=45, tickfont_size=17, title=None), - ).update_traces( - texttemplate=( - "%{text:.3s}" - if selections["X Response"] in volumemodel.volume_columns - else "%{text:.3g}" + text_auto=get_text_format_bar_plot( + responses=[selections["X Response"]], + selections=selections, + volumemodel=volumemodel, ), - textposition="auto", - ) + color_discrete_map=FLUID_COLORS + if selections["Color by"] == "FLUID_ZONE" + else None, + ).update_layout(margin_t=35) if selections["X Response"] not in volumemodel.hc_responses: barfig.add_annotation(fluid_annotation(selections)) diff --git a/webviz_subsurface/plugins/_volumetric_analysis/controllers/selections_controllers.py b/webviz_subsurface/plugins/_volumetric_analysis/controllers/selections_controllers.py index 8fb5f4a11..c1e68c6bf 100644 --- a/webviz_subsurface/plugins/_volumetric_analysis/controllers/selections_controllers.py +++ b/webviz_subsurface/plugins/_volumetric_analysis/controllers/selections_controllers.py @@ -127,6 +127,10 @@ def _store_initial_load_info( {"id": get_uuid("selections"), "tab": "voldist", "selector": "Color by"}, "value", ), + Input( + {"id": get_uuid("selections"), "tab": "voldist", "selector": "X Response"}, + "value", + ), State( {"id": get_uuid("selections"), "tab": "voldist", "selector": "bottom_viz"}, "options", @@ -143,6 +147,7 @@ def _plot_options( plot_type: str, selected_page: str, selected_color_by: list, + selected_x_response: str, visualization_options: list, selector_values: list, selector_ids: list, @@ -150,10 +155,12 @@ def _plot_options( selected_tab: str, ) -> tuple: ctx = callback_context.triggered[0] - if ( - selected_tab != "voldist" - or ("Color by" in ctx["prop_id"] and plot_type not in ["box", "bar"]) - or previous_selection is None + + if selected_tab != "voldist" or previous_selection is None: + raise PreventUpdate + + if ("Color by" in ctx["prop_id"] and plot_type not in ["box", "bar"]) or ( + "X Response" in ctx["prop_id"] and selected_x_response != "FACIES_FRACTION" ): raise PreventUpdate @@ -184,6 +191,10 @@ def _plot_options( settings[selector] = {"disable": disable, "value": value} + # Need to ensure a plot type is selected if page is custopm + if settings["Plot type"]["value"] is None and selected_page == "custom": + settings["Plot type"]["value"] = "histogram" + # update dropdown options based on plot type if settings["Plot type"]["value"] == "scatter": y_elm = x_elm = ( @@ -217,6 +228,11 @@ def _plot_options( settings["Color by"]["options"] = [ {"label": elm, "value": elm} for elm in colorby_elm ] + if settings["X Response"]["value"] == "FACIES_FRACTION": + if selected_page == "per_zr": + settings["Color by"]["value"] = "FACIES" + elif selected_page == "conv": + settings["Subplots"]["value"] = "FACIES" # disable vizualisation radioitem for some pages for x in visualization_options: diff --git a/webviz_subsurface/plugins/_volumetric_analysis/controllers/tornado_controllers.py b/webviz_subsurface/plugins/_volumetric_analysis/controllers/tornado_controllers.py index baa1206b2..5263616d8 100644 --- a/webviz_subsurface/plugins/_volumetric_analysis/controllers/tornado_controllers.py +++ b/webviz_subsurface/plugins/_volumetric_analysis/controllers/tornado_controllers.py @@ -22,7 +22,7 @@ from ..views.tornado_view import tornado_error_layout, tornado_plots_layout -# pylint: disable=too-many-locals, too-many-statements +# pylint: disable=too-many-locals, too-many-statements, too-many-branches def tornado_controllers( get_uuid: Callable, volumemodel: InplaceVolumesModel, theme: WebvizConfigTheme ) -> None: @@ -49,6 +49,23 @@ def _update_tornado_pages( filters = selections["filters"].copy() + if selections["Response"] == "FACIES_FRACTION" and "FACIES" not in groups: + if len(filters.get("FACIES", [])) == 1: + groups.append("FACIES") + else: + return update_relevant_components( + id_list=id_list, + update_info=[ + { + "new_value": tornado_error_layout( + "To see tornado for FACIES_FRACTION. Either select " + "'FACIES' as subplot value or filter to only one facies" + ), + "conditions": {"page": page_selected}, + } + ], + ) + figures = [] tables = [] responses = ( @@ -340,10 +357,4 @@ def create_tornado_table( columns = create_table_columns(columns=[subplots]) if subplots is not None else [] columns.extend(tornado_table.columns) - columns.extend( - create_table_columns( - columns=["Reference"], - use_si_format=["Reference"] if use_si_format else [], - ) - ) return table_data, columns diff --git a/webviz_subsurface/plugins/_volumetric_analysis/utils/table_and_figure_utils.py b/webviz_subsurface/plugins/_volumetric_analysis/utils/table_and_figure_utils.py index 6fc4fd5e2..e0d7bafb9 100644 --- a/webviz_subsurface/plugins/_volumetric_analysis/utils/table_and_figure_utils.py +++ b/webviz_subsurface/plugins/_volumetric_analysis/utils/table_and_figure_utils.py @@ -5,6 +5,15 @@ import webviz_core_components as wcc from dash import dash_table +from webviz_subsurface._models import InplaceVolumesModel +from webviz_subsurface._utils.colors import StandardColors + +FLUID_COLORS = { + "oil": StandardColors.OIL_GREEN, + "gas": StandardColors.GAS_RED, + "water": StandardColors.WATER_BLUE, +} + def create_table_columns( columns: list, @@ -85,11 +94,6 @@ def create_data_table( def fluid_table_style() -> list: - fluid_colors = { - "oil": "#007079", - "gas": "#FF1243", - "water": "#ADD8E6", - } return [ { "if": { @@ -99,7 +103,7 @@ def fluid_table_style() -> list: "color": color, "fontWeight": "bold", } - for fluid, color in fluid_colors.items() + for fluid, color in FLUID_COLORS.items() ] @@ -153,3 +157,19 @@ def update_tornado_figures_xaxis(figures: List[go.Figure]) -> None: x_absmax = max([max(abs(trace.x)) for fig in figures for trace in fig.data]) for fig in figures: fig.update_layout(xaxis_range=[-x_absmax, x_absmax]) + + +def get_text_format_bar_plot( + responses: list, selections: dict, volumemodel: InplaceVolumesModel +) -> Union[bool, str]: + """Get number format for bar plot labels""" + if not selections["textformat"]: + return False + + if selections["textformat"] == "default": + if any(x in responses for x in volumemodel.volume_columns): + return f".{selections['decimals']}s" + if any(x in responses for x in volumemodel.property_columns): + return f".{selections['decimals']}f" + + return f".{selections['decimals']}{selections['textformat']}" diff --git a/webviz_subsurface/plugins/_volumetric_analysis/views/selections_view.py b/webviz_subsurface/plugins/_volumetric_analysis/views/selections_view.py index 422c101ce..b9b5afea1 100644 --- a/webviz_subsurface/plugins/_volumetric_analysis/views/selections_view.py +++ b/webviz_subsurface/plugins/_volumetric_analysis/views/selections_view.py @@ -1,7 +1,7 @@ from typing import List, Optional import webviz_core_components as wcc -from dash import html +from dash import dcc, html from webviz_config import WebvizConfigTheme from webviz_subsurface._models import InplaceVolumesModel @@ -167,6 +167,7 @@ def settings_layout( remove_fluid_annotation(volumemodel, uuid=uuid, tab=tab), subplot_xaxis_range(uuid=uuid, tab=tab), histogram_options(uuid=uuid, tab=tab), + bar_text_options(uuid=uuid, tab=tab), html.Span("Colors", style={"font-weight": "bold"}), wcc.ColorScales( id={"id": uuid, "tab": tab, "settings": "Colorscale"}, @@ -227,6 +228,7 @@ def remove_fluid_annotation( def histogram_options(uuid: str, tab: str) -> html.Div: return html.Div( + style={"margin-bottom": "10px"}, children=[ wcc.RadioItems( label="Barmode:", @@ -248,5 +250,48 @@ def histogram_options(uuid: str, tab: str) -> html.Div: min=1, max=30, ), + ], + ) + + +def bar_text_options(uuid: str, tab: str) -> html.Div: + return html.Div( + children=[ + html.Span("Bar label format:", style={"font-weight": "bold"}), + html.Div( + style={"margin-bottom": "15px"}, + children=[ + wcc.RadioItems( + id={"id": uuid, "tab": tab, "selector": "textformat"}, + options=[ + {"label": "No label", "value": False}, + {"label": "Default", "value": "default"}, + {"label": "SI", "value": "s"}, + {"label": "Float", "value": "f"}, + ], + labelStyle={"display": "inline-flex", "margin-right": "5px"}, + value="default", + ), + wcc.FlexBox( + children=[ + wcc.Label( + "Label precision:", + style={"flex": 1, "minWidth": "40px"}, + ), + dcc.Input( + id={"id": uuid, "tab": tab, "selector": "decimals"}, + style={"flex": 1, "minWidth": "40px"}, + type="number", + required=True, + value=3, + step=1, + max=6, + persistence=True, + persistence_type="session", + ), + ], + ), + ], + ), ] )