Skip to content

Commit

Permalink
Add volume change analysis plot to VolumetricAnalysis
Browse files Browse the repository at this point in the history
  • Loading branch information
tnatt committed Mar 12, 2024
1 parent f3bbc33 commit 08272f1
Show file tree
Hide file tree
Showing 3 changed files with 303 additions and 36 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Callable, Optional, Union
from typing import Callable, List, Optional, Tuple, Union

import numpy as np
import pandas as pd
Expand All @@ -11,6 +11,7 @@
from webviz_subsurface._models import InplaceVolumesModel

from ..utils.table_and_figure_utils import (
VolumeWaterFallPlot,
add_correlation_line,
create_data_table,
create_table_columns,
Expand All @@ -19,6 +20,7 @@
from ..views.comparison_layout import (
comparison_qc_plots_layout,
comparison_table_layout,
waterfall_plot_layout,
)


Expand Down Expand Up @@ -86,6 +88,7 @@ def _update_page_ens_comp(
)


# pylint: disable=too-many-return-statements
def comparison_callback(
compare_on: str,
volumemodel: InplaceVolumesModel,
Expand All @@ -112,7 +115,7 @@ def comparison_callback(
if selections["Response"] == "FACIES_FRACTION" and "FACIES" not in groupby:
groupby.append("FACIES")

if display_option == "multi-response table":
if display_option in ["multi-response table", "waterfall plot"]:
# select max one hc_response for a cleaner table
responses = [selections["Response"]] + [
col
Expand All @@ -130,18 +133,34 @@ def comparison_callback(
if df.empty:
return html.Div("No data left after filtering")

return comparison_table_layout(
table=create_comaprison_table(
tabletype=display_option,
df=df,
groupby=groupby,
if display_option == "multi-response table":
return comparison_table_layout(
table=create_comaprison_table(
tabletype=display_option,
df=df,
groupby=groupby,
selections=selections,
compare_on=compare_on,
volumemodel=volumemodel,
),
table_type=display_option,
selections=selections,
compare_on=compare_on,
volumemodel=volumemodel,
),
table_type=display_option,
filter_info="SOURCE" if compare_on != "SOURCE" else "ENSEMBLE",
)

# Water fall plot
require_response = selections["Response"] in ("STOIIP", "GIIP")
required_columns = ["BULK", "PORO", "SW"]
required_columns.append("BO" if selections["Response"] == "STOIIP" else "BG")
if not require_response or all(col in df for col in required_columns):
return html.Div(
"Water fall plot is only available for analyzing STOIIP/GIIP changes from static"
f"sources containing all {required_columns=}."
)
return waterfall_plot_layout(
selections=selections,
filter_info="SOURCE" if compare_on != "SOURCE" else "ENSEMBLE",
figures=create_waterfall_figures(df, selections, groupby, max_figures=10),
)

if compare_on == "SOURCE" or "REAL" in groupby:
Expand Down Expand Up @@ -190,13 +209,7 @@ def comparison_callback(
)

if display_option == "plots":
if "|" in selections["value1"]:
ens1, sens1 = selections["value1"].split("|")
ens2, sens2 = selections["value2"].split("|")
value1, value2 = (sens1, sens2) if ens1 == ens2 else (ens1, ens2)
else:
value1, value2 = selections["value1"], selections["value2"]

value1, value2 = get_selected_values(selections)
resp1 = f"{selections['Response']} {value1}"
resp2 = f"{selections['Response']} {value2}"

Expand Down Expand Up @@ -280,10 +293,10 @@ def create_comparison_df(
if df.empty or any(x not in df[compare_on].values for x in [value1, value2]):
return pd.DataFrame()

df = df.loc[:, groups + responses].pivot_table(
columns=compare_on,
index=[x for x in groups if x not in [compare_on, "SENSNAME_CASE"]],
)
index = [x for x in groups if x not in [compare_on, "SENSNAME_CASE"]]
column_filter = [compare_on] + index + responses
df = df.loc[:, column_filter].pivot_table(columns=compare_on, index=index)

responses = [x for x in responses if x in df]
for col in responses:
df[col, "diff"] = df[col][value2] - df[col][value1]
Expand Down Expand Up @@ -479,3 +492,80 @@ def add_fluid_zone_column(dframe: pd.DataFrame, filters: dict) -> pd.DataFrame:
if "FLUID_ZONE" not in dframe and "FLUID_ZONE" in filters:
dframe["FLUID_ZONE"] = (" + ").join(filters["FLUID_ZONE"])
return dframe


def get_selected_values(selections: dict) -> Tuple[str, str]:
if not "|" in selections["value1"]:
return selections["value1"], selections["value2"]
ens1, sens1 = selections["value1"].split("|")
ens2, sens2 = selections["value2"].split("|")
return (sens1, sens2) if ens1 == ens2 else (ens1, ens2)


def create_waterfall_figures(
df: pd.DataFrame, selections: dict, groups: List[str], max_figures: int
) -> List[go.Figure]:
"""
Create Water Fall plots showing volume change contributions, using
the comparison table as input. A maximum number of plots has been set
to reduce computation time if e.g. REAL is used in the groups.
The HC volume formula is as follows
Volume = (GRV*NTG*PORO*(1-SW)) / formation volume factor (Bo/Bg)
For properties that are numerators in this formula their diff in %
can be used to determine volume impact. Bo/BG is handled slightly
different as it is the denominator.
"""

response = selections["Response"]
val1, val2 = get_selected_values(selections)

sat_col = "SO" if response == "STOIIP" else "SG"
fvf_col = "BO" if response == "STOIIP" else "BG"

# split into NTG and PORO_NET if present
# the order of these properties will be the order of the bars
if any(col.startswith("NTG") for col in df):
props = ["BULK", "NTG", "PORO_NET", sat_col, fvf_col]
else:
props = ["BULK", "PORO", sat_col, fvf_col]

# create hc saturation and title columns
df[f"{sat_col} diff (%)"] = (
(1 - df[f"SW {val2}"]) / (1 - df[f"SW {val1}"]) - 1
) * 100
df["title"] = (
df[groups].astype(str).agg(", ".join, axis=1)
+ f" - {response} change contributions from {val1} to {val2}"
)

figures: List[go.Figure] = []
for _, row in df.iterrows():
if len(figures) >= max_figures:
break
vol_start = row[f"{response} {val1}"]
vol_end = row[f"{response} {val2}"]

volume_impact_properties: List[float] = []
for col in props:
# handle Bo/BG different as it is a denominator in the volume formula.
if col != fvf_col:
vol_multitplier = row[f"{col} diff (%)"] / 100
else:
vol_multitplier = -1 * (
1 - (row[f"{fvf_col} {val1}"] / row[f"{fvf_col} {val2}"])
)
# Need to compute the impact from last cumulative volume, hence the sum
volume_impact_properties.append(
(vol_start + sum(volume_impact_properties)) * vol_multitplier
)

figures.append(
VolumeWaterFallPlot(
barnames=[f"{val1}", *props, f"{val2}"],
initial_volume=vol_start,
final_volume=vol_end,
volume_impact_properties=volume_impact_properties,
title=row["title"],
).figure
)
return figures
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import math
from typing import List, Optional, Union
from typing import List, Optional, Tuple, Union

import numpy as np
import plotly.graph_objects as go
import webviz_core_components as wcc
from dash import dash_table

from webviz_subsurface._abbreviations.number_formatting import si_prefixed
from webviz_subsurface._models import InplaceVolumesModel
from webviz_subsurface._utils.colors import StandardColors

Expand Down Expand Up @@ -209,3 +210,136 @@ def add_line(

# update margin to make room for the labels
figure.update_layout({"margin_t": 100})


class VolumeWaterFallPlot:
def __init__(
self,
barnames: List[str],
initial_volume: float,
final_volume: float,
volume_impact_properties: List[float],
title: str,
) -> None:
self.barnames = barnames
self.title = title
self.y = [initial_volume, *volume_impact_properties, final_volume]
self.cumulative_volumes = self.compute_cumulative_volumes()

@staticmethod
def format_number(num: float) -> str:
"""Get a formatted number, use SI units if value is larger than 1000"""
if abs(num) < 1000:
return si_prefixed(num, number_format=".1f", locked_si_prefix="")
return si_prefixed(num, number_format=".3g")

def compute_cumulative_volumes(self) -> List[float]:
"""
Compute the cumulative volumes moving from one bar to another
First and last bar have volumes in absolute values, the middle
bars have relative volumes.
"""
cumulative_volumes = [sum(self.y[:idx]) for idx in range(1, len(self.y))]
cumulative_volumes.append(self.y[-1])
return cumulative_volumes

def calculate_volume_change_for_bar(self, idx: int) -> Tuple[float, float]:
"""
Calculate change in percent for a given bar index by
comparing volumes to the previous bar.
Return the change in actual value and in percent
"""
prev_bar_volume = self.cumulative_volumes[idx - 1]
vol_change = self.cumulative_volumes[idx] - prev_bar_volume
vol_change_percent = (
(100 * vol_change / prev_bar_volume) if prev_bar_volume != 0 else 0
)
return vol_change, vol_change_percent

@property
def number_of_bars(self) -> int:
"""Number of bars"""
return len(self.barnames)

@property
def textfont_size(self) -> int:
"""Text font size for the plot"""
return 15

@property
def measures(self) -> List[str]:
"""
List of measures. First and last bar have volumes in absolute
values, the middle bars have relative volumes.
"""
return ["absolute", *["relative"] * (self.number_of_bars - 2), "absolute"]

@property
def y_range(self) -> List[float]:
"""y axis range for the plot"""
cum_vol_min = min(self.cumulative_volumes)
cum_vol_max = max(self.cumulative_volumes)
range_extension = (cum_vol_max - cum_vol_min) / 2
return [cum_vol_min - range_extension, cum_vol_max + range_extension]

@property
def bartext(self) -> List[str]:
"""
Create bartext for each bar with volume changes relative
to previous bar. First and last bar show only absolute values.
"""
texttemplate = [self.format_number(self.y[0])]
for idx in range(self.number_of_bars):
if idx not in [0, self.number_of_bars - 1]:
delta, perc = self.calculate_volume_change_for_bar(idx)
sign = "+" if perc > 0 else ""
texttemplate.append(
f"{sign}{self.format_number(delta)} {sign}{perc:.1f}%"
)
texttemplate.append(self.format_number(self.y[-1]))
return texttemplate

@property
def axis_defaults(self) -> dict:
"""x and y axis defaults"""
return {
"showline": True,
"linewidth": 2,
"linecolor": "black",
"mirror": True,
"gridwidth": 1,
"gridcolor": "lightgrey",
"showgrid": False,
}

@property
def figure(self) -> go.Figure:
return (
go.Figure(
go.Waterfall(
orientation="v",
measure=self.measures,
x=self.barnames,
textposition="outside",
text=self.bartext,
y=self.y,
connector={"mode": "spanning"},
textfont_size=self.textfont_size,
)
)
.update_yaxes(
range=self.y_range,
tickfont_size=self.textfont_size,
**self.axis_defaults,
)
.update_xaxes(
type="category",
tickfont_size=self.textfont_size,
**self.axis_defaults,
)
.update_layout(
plot_bgcolor="white",
title=self.title,
margin={"t": 40, "b": 50, "l": 50, "r": 50},
)
)
Loading

0 comments on commit 08272f1

Please sign in to comment.