From 95171361f591aeeda0c0cd96072eba656c266b31 Mon Sep 17 00:00:00 2001 From: Asgeir Nyvoll Date: Wed, 15 Jul 2020 15:40:21 +0200 Subject: [PATCH] Avoid assuming inplace units + support descriptions of any column. --- .../abbreviation_data/volume_terminology.json | 97 ++++++++++---- .../_abbreviations/volume_terminology.py | 40 +++--- .../_datainput/inplace_volumes.py | 14 ++ webviz_subsurface/plugins/_inplace_volumes.py | 126 ++++++++++-------- .../plugins/_inplace_volumes_onebyone.py | 33 +++-- 5 files changed, 199 insertions(+), 111 deletions(-) diff --git a/webviz_subsurface/_abbreviations/abbreviation_data/volume_terminology.json b/webviz_subsurface/_abbreviations/abbreviation_data/volume_terminology.json index a4b62b41bc..9461e6d7bf 100644 --- a/webviz_subsurface/_abbreviations/abbreviation_data/volume_terminology.json +++ b/webviz_subsurface/_abbreviations/abbreviation_data/volume_terminology.json @@ -1,27 +1,74 @@ { - "BULK_OIL": {"label": "Bulk volume (oil zone)", "unit": "m³"}, - "BULK_GAS": {"label": "Bulk volume (gas zone)", "unit": "m³"}, - "BULK_TOTAL": {"label": "Bulk volume (total)", "unit": "m³"}, - "NET_OIL": {"label": "Net volume (oil zone)", "unit": "m³"}, - "NET_GAS": {"label": "Net volume (gas zone)", "unit": "m³"}, - "NET_TOTAL": {"label": "Net volume (total)", "unit": "m³"}, - "PORV_OIL": {"label": "Pore volume (oil zone)", "unit": "m³"}, - "PORV_GAS": {"label": "Pore volume (gas zone)", "unit": "m³"}, - "PORV_TOTAL": {"label": "Pore volume (total)", "unit": "m³"}, - "PORE_OIL": {"label": "Pore volume (oil zone)", "unit": "m³"}, - "PORE_GAS": {"label": "Pore volume (gas zone)", "unit": "m³"}, - "PORE_TOTAL": {"label": "Pore volume (total)", "unit": "m³"}, - "HCPV_OIL": {"label": "Hydro carbon pore volume (oil zone)", "unit": "m³"}, - "HCPV_GAS": {"label": "Hydro carbon pore volume (gas zone)", "unit": "m³"}, - "HCPV_TOTAL": {"label": "Hydro carbon pore volume (total zone)", "unit": "m³"}, - "STOIIP_OIL": {"label": "Stock tank oil initially in place (oil zone)", "unit": "Sm³", "eclsum": ["FOIPL", "ROIPL"]}, - "STOIIP_GAS": {"label": "Stock tank oil initially in place (gas zone)", "unit": "Sm³", "eclsum": ["FOIPG", "ROIPG"]}, - "STOIIP_TOTAL": {"label": "Stock tank oil initially in place (total)", "unit": "Sm³", "eclsum": ["FOIP", "ROIP"]}, - "GIIP_OIL": {"label": "Gas initially in place (oil zone)", "unit": "Sm³", "eclsum": ["FGIPL", "RGIPL"]}, - "GIIP_GAS": {"label": "Gas initially in place (gas zone)", "unit": "Sm³", "eclsum": ["FGIPG", "RGIPG"]}, - "GIIP_TOTAL": {"label": "Gas initially in place (total)", "unit": "Sm³", "eclsum": ["FGIP", "RGIP"]}, - "RECOVERABLE_OIL": {"label": "Recoverable volume (oil zone)", "unit": "Sm³"}, - "RECOVERABLE_GAS": {"label": "Recoverable volume (gas zone)", "unit": "Sm³"}, - "RECOVERABLE_TOTAL": {"label": "Recoverable volume (total)", "unit": "Sm³"} + "BULK_OIL": { + "description": "Bulk volume (oil zone)" + }, + "BULK_GAS": { + "description": "Bulk volume (gas zone)" + }, + "BULK_TOTAL": { + "description": "Bulk volume (total)" + }, + "NET_OIL": { + "description": "Net volume (oil zone)" + }, + "NET_GAS": { + "description": "Net volume (gas zone)" + }, + "NET_TOTAL": { + "description": "Net volume (total)" + }, + "PORV_OIL": { + "description": "Pore volume (oil zone)" + }, + "PORV_GAS": { + "description": "Pore volume (gas zone)" + }, + "PORV_TOTAL": { + "description": "Pore volume (total)" + }, + "PORE_OIL": { + "description": "Pore volume (oil zone)" + }, + "PORE_GAS": { + "description": "Pore volume (gas zone)" + }, + "PORE_TOTAL": { + "description": "Pore volume (total)" + }, + "HCPV_OIL": { + "description": "Hydro carbon pore volume (oil zone)" + }, + "HCPV_GAS": { + "description": "Hydro carbon pore volume (gas zone)" + }, + "HCPV_TOTAL": { + "description": "Hydro carbon pore volume (total zone)" + }, + "STOIIP_OIL": { + "description": "Stock tank oil initially in place (oil zone)" + }, + "STOIIP_GAS": { + "description": "Stock tank oil initially in place (gas zone)" + }, + "STOIIP_TOTAL": { + "description": "Stock tank oil initially in place (total)" + }, + "GIIP_OIL": { + "description": "Gas initially in place (oil zone)" + }, + "GIIP_GAS": { + "description": "Gas initially in place (gas zone)" + }, + "GIIP_TOTAL": { + "description": "Gas initially in place (total)" + }, + "RECOVERABLE_OIL": { + "description": "Recoverable volume (oil zone)" + }, + "RECOVERABLE_GAS": { + "description": "Recoverable volume (gas zone)" + }, + "RECOVERABLE_TOTAL": { + "description": "Recoverable volume (total)" + } } - diff --git a/webviz_subsurface/_abbreviations/volume_terminology.py b/webviz_subsurface/_abbreviations/volume_terminology.py index bea36dfdf9..a09a935a9e 100644 --- a/webviz_subsurface/_abbreviations/volume_terminology.py +++ b/webviz_subsurface/_abbreviations/volume_terminology.py @@ -1,36 +1,36 @@ import json import pathlib - +from typing import Optional _DATA_PATH = pathlib.Path(__file__).parent.absolute() / "abbreviation_data" VOLUME_TERMINOLOGY = json.loads((_DATA_PATH / "volume_terminology.json").read_text()) -def volume_description(column: str): +def volume_description(column: str, metadata: Optional[dict] = None): """Return description for the column if defined""" + if metadata is not None: + try: + return metadata[column]["description"] + except KeyError: + pass try: - label = VOLUME_TERMINOLOGY[column]["label"] + description = VOLUME_TERMINOLOGY[column]["description"] except KeyError: - label = column - return label + description = column + return description -def volume_unit(column: str): +def volume_unit(column: str, metadata: Optional[dict] = None): """Return unit for the column if defined""" - try: - unit = VOLUME_TERMINOLOGY[column]["unit"] - except KeyError: - unit = "" - return unit + if metadata is not None: + try: + return metadata[column]["unit"] + except KeyError: + pass + return "" -def volume_simulation_vector_match(column: str): - """Return a list of simulation vectors that match the column - Useful to verify/propose data to compare. - """ - try: - vectors = VOLUME_TERMINOLOGY[column]["eclsum"] - except KeyError: - vectors = [] - return vectors if isinstance(vectors, list) else [vectors] +def column_title(response: str, metadata: dict): + unit = volume_unit(response, metadata) + return f"{volume_description(response, metadata)}" + (f" [{unit}]" if unit else "") diff --git a/webviz_subsurface/_datainput/inplace_volumes.py b/webviz_subsurface/_datainput/inplace_volumes.py index bf549d6f76..c6c3f4d1e8 100644 --- a/webviz_subsurface/_datainput/inplace_volumes.py +++ b/webviz_subsurface/_datainput/inplace_volumes.py @@ -1,4 +1,7 @@ import os +from pathlib import Path +from io import BytesIO +import json import pandas as pd import fmu.ensemble @@ -40,3 +43,14 @@ def extract_volumes(ensemble_paths, volfolder, volfiles) -> pd.DataFrame: f"Ensure that the files are present in relative folder {volfolder}" ) return pd.concat(dfs) + + +@CACHE.memoize(timeout=CACHE.TIMEOUT) +@webvizstore +def get_metadata(metadata: Path) -> BytesIO: + """Returns a json formatted dict stored in a BytesIO object""" + metadict = json.loads(metadata.read_text()) + bytesio = BytesIO() + bytesio.write(json.dumps(metadict).encode()) + bytesio.seek(0) + return bytesio diff --git a/webviz_subsurface/plugins/_inplace_volumes.py b/webviz_subsurface/plugins/_inplace_volumes.py index 2dd5333258..0acadd4d98 100644 --- a/webviz_subsurface/plugins/_inplace_volumes.py +++ b/webviz_subsurface/plugins/_inplace_volumes.py @@ -1,5 +1,5 @@ -from uuid import uuid4 from pathlib import Path +import json import numpy as np import pandas as pd @@ -12,8 +12,11 @@ from webviz_config.webviz_store import webvizstore from webviz_config import WebvizPluginABC -from .._datainput.inplace_volumes import extract_volumes -from .._abbreviations.volume_terminology import volume_description, volume_unit +from .._datainput.inplace_volumes import extract_volumes, get_metadata +from .._abbreviations.volume_terminology import ( + volume_description, + column_title, +) from .._abbreviations.number_formatting import table_statistics_base @@ -38,7 +41,8 @@ class InplaceVolumes(WebvizPluginABC): **Common settings for both input options** * **`response`:** Optional volume response to visualize initially. - +* **`metadata`:** Optional path to volume response metadata stored in a json-file. +Supports descriptions and units. --- ?> The input files must follow FMU standards. @@ -50,6 +54,8 @@ class InplaceVolumes(WebvizPluginABC): (https://github.com/equinor/webviz-subsurface-testdata/blob/master/reek_history_match/\ realization-0/iter-0/share/results/volumes/geogrid--oil.csv). +* ADD PATH TO METADATAFILE + **The following columns will be used as available filters, if present:** * `ZONE` @@ -62,7 +68,8 @@ class InplaceVolumes(WebvizPluginABC): **Remaining columns are seen as volumetric responses.** All names are allowed (except those mentioned above, in addition to `REAL` and `ENSEMBLE`), \ -but the following responses are given more descriptive names automatically: +but the following responses are given more descriptive names automatically, unless another \ +description is given in `metadata`: * `BULK_OIL`: Bulk Volume (Oil) * `NET_OIL`: Net Volume (Oil) @@ -89,6 +96,7 @@ def __init__( volfiles: dict = None, volfolder: str = "share/results/volumes", response: str = "STOIIP_OIL", + metadata: Path = None, ): super().__init__() @@ -118,8 +126,13 @@ def __init__( ) self.initial_response = response - self.uid = uuid4() - self.selectors_id = {x: str(uuid4()) for x in self.selectors} + self.metadata_path = metadata + self.metadata = ( + self.metadata_path + if self.metadata_path is None + else json.load(get_metadata(self.metadata_path)) + ) + self.selectors_id = {x: self.uuid(x) for x in self.selectors} self.plotly_theme = app.webviz_settings["theme"].plotly_theme if len(self.volumes["ENSEMBLE"].unique()) > 1: self.initial_plot = "Box plot" @@ -129,26 +142,22 @@ def __init__( self.initial_group = None self.set_callbacks(app) - def ids(self, element): - """Generate unique id for dom element""" - return f"{element}-id-{self.uid}" - @property def tour_steps(self): return [ { - "id": self.ids("layout"), + "id": self.uuid("layout"), "content": ("Dashboard displaying in place volumetric results. "), }, { - "id": self.ids("graph"), + "id": self.uuid("graph"), "content": ( "Chart showing results for the current selection. " "Different charts and options can be selected from the menu above." ), }, { - "id": self.ids("table"), + "id": self.uuid("table"), "content": ( "The table shows statistics for the current active selection. " "Rows can be filtered by searching, and sorted by " @@ -156,11 +165,11 @@ def tour_steps(self): ), }, { - "id": self.ids("response"), + "id": self.uuid("response"), "content": "Select the volumetric calculation to display.", }, { - "id": self.ids("plot-type"), + "id": self.uuid("plot-type"), "content": ( "Controls the type of the visualized chart. " "Per realization shows bars per realization, " @@ -168,11 +177,11 @@ def tour_steps(self): ), }, { - "id": self.ids("group"), + "id": self.uuid("group"), "content": ("Allows grouping of results on a given category."), }, { - "id": self.ids("filters"), + "id": self.uuid("filters"), "content": ( "Filter on different combinations of e.g. zones, facies and regions " "(The options will vary dependent on what was included " @@ -182,10 +191,11 @@ def tour_steps(self): ] def add_webvizstore(self): - return ( - [(read_csv, [{"csv_file": self.csvfile}])] - if self.csvfile - else [ + functions = [] + if self.csvfile: + functions.append((read_csv, [{"csv_file": self.csvfile}])) + else: + functions.append( ( extract_volumes, [ @@ -196,8 +206,10 @@ def add_webvizstore(self): } ], ) - ] - ) + ) + if self.metadata is not None: + functions.append((get_metadata, [{"metadata": self.metadata_path}])) + return functions @property def vol_columns(self): @@ -231,9 +243,9 @@ def vol_callback_inputs(self): selector columns in the volumes dataframe """ inputs = [] - inputs.append(Input(self.ids("response"), "value")) - inputs.append(Input(self.ids("plot-type"), "value")) - inputs.append(Input(self.ids("group"), "value")) + inputs.append(Input(self.uuid("response"), "value")) + inputs.append(Input(self.uuid("plot-type"), "value")) + inputs.append(Input(self.uuid("group"), "value")) for selector in self.selectors: inputs.append(Input(self.selectors_id[selector], "value")) return inputs @@ -287,9 +299,12 @@ def plot_options_layout(self): children=[ html.Span("Response:", style={"font-weight": "bold"}), dcc.Dropdown( - id=self.ids("response"), + id=self.uuid("response"), options=[ - {"label": volume_description(i), "value": i} + { + "label": volume_description(i, self.metadata), + "value": i, + } for i in self.responses ], value=self.initial_response @@ -305,7 +320,7 @@ def plot_options_layout(self): children=[ html.Span("Plot type:", style={"font-weight": "bold"}), dcc.Dropdown( - id=self.ids("plot-type"), + id=self.uuid("plot-type"), options=[ {"label": i, "value": i} for i in self.plot_types ], @@ -320,7 +335,7 @@ def plot_options_layout(self): children=[ html.Span("Group by:", style={"font-weight": "bold"}), dcc.Dropdown( - id=self.ids("group"), + id=self.uuid("group"), options=[ {"label": i.lower().capitalize(), "value": i} for i in self.selectors @@ -340,14 +355,14 @@ def layout(self): return html.Div( children=[ wcc.FlexBox( - id=self.ids("layout"), + id=self.uuid("layout"), children=[ html.Div( style={"flex": 1}, children=[ html.Span("Filters:", style={"font-weight": "bold"}), html.Div( - id=self.ids("filters"), + id=self.uuid("filters"), children=self.selector_dropdowns, ), ], @@ -358,17 +373,17 @@ def layout(self): self.plot_options_layout, html.Div( style={"height": 400}, - children=wcc.Graph(id=self.ids("graph")), + children=wcc.Graph(id=self.uuid("graph")), ), html.Div( children=[ html.Div( - id=self.ids("table_title"), + id=self.uuid("table_title"), style={"textAlign": "center"}, children="", ), DataTable( - id=self.ids("table"), + id=self.uuid("table"), sort_action="native", filter_action="native", page_action="native", @@ -386,10 +401,10 @@ def layout(self): def set_callbacks(self, app): @app.callback( [ - Output(self.ids("graph"), "figure"), - Output(self.ids("table"), "data"), - Output(self.ids("table"), "columns"), - Output(self.ids("table_title"), "children"), + Output(self.uuid("graph"), "figure"), + Output(self.uuid("table"), "data"), + Output(self.uuid("table"), "columns"), + Output(self.uuid("table_title"), "children"), ], self.vol_callback_inputs, ) @@ -436,11 +451,16 @@ def _render_vol_chart(*args): return ( { "data": plot_traces, - "layout": plot_layout(plot_type, response, theme=self.plotly_theme), + "layout": plot_layout( + plot_type, + response, + theme=self.plotly_theme, + metadata=self.metadata, + ), }, table, columns, - f"{volume_description(response)} [{volume_unit(response)}]", + column_title(response, self.metadata), ) @app.callback( @@ -449,7 +469,7 @@ def _render_vol_chart(*args): Output(self.selectors_id["ENSEMBLE"], "value"), Output(self.selectors_id["ENSEMBLE"], "size"), ], - [Input(self.ids("group"), "value")], + [Input(self.uuid("group"), "value")], ) def _set_iteration_selector(group_by): """If iteration is selected as group by set the iteration @@ -469,7 +489,7 @@ def _set_iteration_selector(group_by): Output(self.selectors_id["SOURCE"], "value"), Output(self.selectors_id["SOURCE"], "size"), ], - [Input(self.ids("group"), "value")], + [Input(self.uuid("group"), "value")], ) def _set_source_selector(group_by): """If iteration is selected as group by set the iteration @@ -520,7 +540,7 @@ def plot_table(dframe, response, name): @CACHE.memoize(timeout=CACHE.TIMEOUT) -def plot_layout(plot_type, response, theme): +def plot_layout(plot_type, response, theme, metadata): layout = {} layout.update(theme["layout"]) layout["height"] = 400 @@ -530,27 +550,17 @@ def plot_layout(plot_type, response, theme): "barmode": "overlay", "bargap": 0.01, "bargroupgap": 0.2, - "xaxis": { - "title": f"{volume_description(response)} [{volume_unit(response)}]" - }, + "xaxis": {"title": column_title(response, metadata)}, "yaxis": {"title": "Count"}, } ) elif plot_type == "Box plot": - layout.update( - { - "yaxis": { - "title": f"{volume_description(response)} [{volume_unit(response)}]" - } - } - ) + layout.update({"yaxis": {"title": column_title(response, metadata)}}) else: layout.update( { "margin": {"l": 60, "r": 40, "b": 30, "t": 10}, - "yaxis": { - "title": f"{volume_description(response)} [{volume_unit(response)}]" - }, + "yaxis": {"title": column_title(response, metadata)}, "xaxis": {"title": "Realization"}, } ) diff --git a/webviz_subsurface/plugins/_inplace_volumes_onebyone.py b/webviz_subsurface/plugins/_inplace_volumes_onebyone.py index 19a5c30e6d..03119870d4 100644 --- a/webviz_subsurface/plugins/_inplace_volumes_onebyone.py +++ b/webviz_subsurface/plugins/_inplace_volumes_onebyone.py @@ -1,6 +1,6 @@ import json -from uuid import uuid4 from pathlib import Path +import base64 import numpy as np import pandas as pd @@ -16,9 +16,13 @@ from webviz_config.webviz_store import webvizstore from .._private_plugins.tornado_plot import TornadoPlot -from .._datainput.inplace_volumes import extract_volumes +from .._datainput.inplace_volumes import extract_volumes, get_metadata from .._datainput.fmu_input import get_realizations, find_sens_type -from .._abbreviations.volume_terminology import volume_description, volume_unit +from .._abbreviations.volume_terminology import ( + volume_description, + volume_unit, + column_title, +) from .._abbreviations.number_formatting import table_statistics_base @@ -41,6 +45,7 @@ class InplaceVolumesOneByOne(WebvizPluginABC): E.g. `{geogrid: geogrid--oil.csv}`. * **`volfolder`:** Optional local folder for the `volfiles`. * **`response`:** Optional volume response to visualize initially. +* **`metadata`:** Optional volume response metadata. Supports descriptions and units. --- ?> The input files must follow FMU standards. @@ -55,6 +60,8 @@ class InplaceVolumesOneByOne(WebvizPluginABC): (https://github.com/equinor/webviz-subsurface-testdata/blob/master/reek_history_match/\ realization-0/iter-0/share/results/volumes/geogrid--oil.csv). +* ADD PATH TO METADATAFILE + The following columns will be used as available filters, if present: * `ZONE` @@ -67,7 +74,8 @@ class InplaceVolumesOneByOne(WebvizPluginABC): Remaining columns are seen as volumetric responses. All names are allowed (except those mentioned above, in addition to `REAL` and `ENSEMBLE`), \ -but the following responses are given more descriptive names automatically: +but the following responses are given more descriptive names automatically, unless another \ +description is given in `metadata`: * `BULK_OIL`: Bulk Volume (Oil) * `NET_OIL`: Net Volume (Oil) @@ -116,6 +124,7 @@ def __init__( volfiles: dict = None, volfolder: str = "share/results/volumes", response: str = "STOIIP_OIL", + metadata: Path = None, ): super().__init__() @@ -155,6 +164,12 @@ def __init__( '"ensembles" and "volfiles"' ) self.initial_response = response + self.metadata_path = metadata + self.metadata = ( + self.metadata_path + if self.metadata_path is None + else json.load(get_metadata(self.metadata_path)) + ) # Merge into one dataframe # (TODO: Should raise error if not all ensembles have sensitivity data) @@ -162,13 +177,12 @@ def __init__( # Initialize a tornado plot. Data is added in callback self.tornadoplot = TornadoPlot(app, parameters, allow_click=True) - self.uid = uuid4() self.selectors_id = {x: self.uuid(x) for x in self.selectors} self.theme = app.webviz_settings["theme"] self.set_callbacks(app) def add_webvizstore(self): - return ( + functions = ( [ ( read_csv, @@ -201,6 +215,9 @@ def add_webvizstore(self): ), ] ) + if self.metadata is not None: + functions.append((get_metadata, [{"metadata": self.metadata_path}])) + return functions def selector(self, label, id_name, column): return html.Div( @@ -488,7 +505,7 @@ def _render_table_and_tornado(ensemble, response, source, *filters): .reset_index()[["REAL", response]] .values.tolist(), "number_format": "#.4g", - "unit": volume_unit(response), + "unit": volume_unit(response, self.metadata), } ) return tornado, table, columns @@ -556,7 +573,7 @@ def _render_chart( ) # Volume title: - volume_title = f"{volume_description(response)} [{volume_unit(response)}]" + volume_title = column_title(response, self.metadata) # Make Plotly figure layout = {}