From 2276c200fc2949a37f79b2c0a947e2b18bc0e2b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20Herje?= <82032112+jorgenherje@users.noreply.github.com> Date: Thu, 13 Jun 2024 13:58:00 +0200 Subject: [PATCH] Group tree module - initial version w/ single realization trees (#615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Øyvind Lind-Johansen <47847084+lindjoha@users.noreply.github.com> --- backend_py/primary/primary/main.py | 2 + .../primary/routers/group_tree/router.py | 70 ++ .../primary/routers/group_tree/schemas.py | 33 + .../_group_tree_dataframe_model.py | 287 ++++++ .../group_tree_assembler.py | 960 ++++++++++++++++++ .../services/sumo_access/group_tree_access.py | 75 ++ .../services/sumo_access/group_tree_types.py | 76 ++ frontend/package-lock.json | 14 + frontend/package.json | 1 + frontend/src/api/ApiService.ts | 3 + frontend/src/api/index.ts | 6 + frontend/src/api/models/DatedTree.ts | 10 + frontend/src/api/models/GroupTreeData.ts | 12 + frontend/src/api/models/GroupTreeMetadata.ts | 9 + frontend/src/api/models/NodeType.ts | 9 + frontend/src/api/models/TreeNode.ts | 19 + frontend/src/api/services/GroupTreeService.ts | 44 + frontend/src/framework/ModuleDataTags.ts | 6 + frontend/src/modules/GroupTree/loadModule.tsx | 13 + frontend/src/modules/GroupTree/preview.svg | 6 + frontend/src/modules/GroupTree/preview.tsx | 7 + .../src/modules/GroupTree/registerModule.ts | 20 + .../GroupTree/settings/atoms/baseAtoms.ts | 30 + .../GroupTree/settings/atoms/derivedAtoms.ts | 143 +++ .../GroupTree/settings/atoms/queryAtoms.ts | 44 + .../modules/GroupTree/settings/settings.tsx | 221 ++++ .../GroupTree/settingsToViewInterface.ts | 55 + frontend/src/modules/GroupTree/types.ts | 31 + frontend/src/modules/GroupTree/view.tsx | 43 + frontend/src/modules/registerAllModules.ts | 1 + 30 files changed, 2250 insertions(+) create mode 100644 backend_py/primary/primary/routers/group_tree/router.py create mode 100644 backend_py/primary/primary/routers/group_tree/schemas.py create mode 100644 backend_py/primary/primary/services/group_tree_assembler/_group_tree_dataframe_model.py create mode 100644 backend_py/primary/primary/services/group_tree_assembler/group_tree_assembler.py create mode 100644 backend_py/primary/primary/services/sumo_access/group_tree_access.py create mode 100644 backend_py/primary/primary/services/sumo_access/group_tree_types.py create mode 100644 frontend/src/api/models/DatedTree.ts create mode 100644 frontend/src/api/models/GroupTreeData.ts create mode 100644 frontend/src/api/models/GroupTreeMetadata.ts create mode 100644 frontend/src/api/models/NodeType.ts create mode 100644 frontend/src/api/models/TreeNode.ts create mode 100644 frontend/src/api/services/GroupTreeService.ts create mode 100644 frontend/src/modules/GroupTree/loadModule.tsx create mode 100644 frontend/src/modules/GroupTree/preview.svg create mode 100644 frontend/src/modules/GroupTree/preview.tsx create mode 100644 frontend/src/modules/GroupTree/registerModule.ts create mode 100644 frontend/src/modules/GroupTree/settings/atoms/baseAtoms.ts create mode 100644 frontend/src/modules/GroupTree/settings/atoms/derivedAtoms.ts create mode 100644 frontend/src/modules/GroupTree/settings/atoms/queryAtoms.ts create mode 100644 frontend/src/modules/GroupTree/settings/settings.tsx create mode 100644 frontend/src/modules/GroupTree/settingsToViewInterface.ts create mode 100644 frontend/src/modules/GroupTree/types.ts create mode 100644 frontend/src/modules/GroupTree/view.tsx diff --git a/backend_py/primary/primary/main.py b/backend_py/primary/primary/main.py index e2890691e..4a5e12d7e 100644 --- a/backend_py/primary/primary/main.py +++ b/backend_py/primary/primary/main.py @@ -19,6 +19,7 @@ from primary.routers.graph.router import router as graph_router from primary.routers.grid3d.router import router as grid3d_router from primary.routers.grid3d.router_vtk import router as grid3d_router_vtk +from primary.routers.group_tree.router import router as group_tree_router from primary.routers.inplace_volumetrics.router import router as inplace_volumetrics_router from primary.routers.observations.router import router as observations_router from primary.routers.parameters.router import router as parameters_router @@ -79,6 +80,7 @@ def custom_generate_unique_id(route: APIRoute) -> str: app.include_router(correlations_router, prefix="/correlations", tags=["correlations"]) app.include_router(grid3d_router, prefix="/grid3d", tags=["grid3d"]) app.include_router(grid3d_router_vtk, prefix="/grid3d", tags=["grid3d"]) +app.include_router(group_tree_router, prefix="/group_tree", tags=["group_tree"]) app.include_router(pvt_router, prefix="/pvt", tags=["pvt"]) app.include_router(well_completions_router, prefix="/well_completions", tags=["well_completions"]) app.include_router(well_router, prefix="/well", tags=["well"]) diff --git a/backend_py/primary/primary/routers/group_tree/router.py b/backend_py/primary/primary/routers/group_tree/router.py new file mode 100644 index 000000000..41fef2f7e --- /dev/null +++ b/backend_py/primary/primary/routers/group_tree/router.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, Depends, Query +from primary.auth.auth_helper import AuthHelper +from primary.services.group_tree_assembler.group_tree_assembler import GroupTreeAssembler +from primary.services.sumo_access.group_tree_access import GroupTreeAccess +from primary.services.sumo_access.group_tree_types import TreeModeOptions, NodeType +from primary.services.sumo_access.summary_access import Frequency, SummaryAccess +from primary.services.utils.authenticated_user import AuthenticatedUser + +from . import schemas + +from webviz_pkg.core_utils.perf_timer import PerfTimer +import logging + +LOGGER = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/realization_group_tree_data/") +async def get_realization_group_tree_data( + # fmt:off + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + case_uuid: str = Query(description="Sumo case uuid"), + ensemble_name: str = Query(description="Ensemble name"), + realization: int = Query(description="Realization"), + resampling_frequency: schemas.Frequency = Query(description="Resampling frequency"), + node_type_set: set[schemas.NodeType] = Query(description="Node types"), + # fmt:on +) -> schemas.GroupTreeData: + timer = PerfTimer() + + group_tree_access = await GroupTreeAccess.from_case_uuid_async( + authenticated_user.get_sumo_access_token(), case_uuid, ensemble_name + ) + summary_access = SummaryAccess.from_case_uuid(authenticated_user.get_sumo_access_token(), case_uuid, ensemble_name) + summary_frequency = Frequency.from_string_value(resampling_frequency.value) + if summary_frequency is None: + summary_frequency = Frequency.YEARLY + + # Convert to NodeType enum in group_tree_types + unique_node_types = set([NodeType(elm.value) for elm in node_type_set]) + + group_tree_data = GroupTreeAssembler( + group_tree_access=group_tree_access, + summary_access=summary_access, + realization=realization, + summary_frequency=summary_frequency, + node_types=unique_node_types, + group_tree_mode=TreeModeOptions.SINGLE_REAL, + ) + + timer.lap_ms() + await group_tree_data.fetch_and_initialize_async() + initialize_time_ms = timer.lap_ms() + + ( + dated_trees, + edge_metadata, + node_metadata, + ) = await group_tree_data.create_dated_trees_and_metadata_lists() + create_data_time_ms = timer.lap_ms() + + LOGGER.info( + f"Group tree data for single realization fetched and processed in: {timer.elapsed_ms()}ms " + f"(initialize={initialize_time_ms}ms, create group tree={create_data_time_ms}ms)" + ) + + return schemas.GroupTreeData( + edge_metadata_list=edge_metadata, node_metadata_list=node_metadata, dated_trees=dated_trees + ) diff --git a/backend_py/primary/primary/routers/group_tree/schemas.py b/backend_py/primary/primary/routers/group_tree/schemas.py new file mode 100644 index 000000000..49c6e4dbb --- /dev/null +++ b/backend_py/primary/primary/routers/group_tree/schemas.py @@ -0,0 +1,33 @@ +from typing import List +from enum import Enum +from primary.services.sumo_access.group_tree_types import GroupTreeMetadata, DatedTree +from pydantic import BaseModel + + +class Frequency(str, Enum): + DAILY = "DAILY" + WEEKLY = "WEEKLY" + MONTHLY = "MONTHLY" + QUARTERLY = "QUARTERLY" + YEARLY = "YEARLY" + + +class StatOption(str, Enum): + MEAN = "MEAN" + P10 = "P10" + P90 = "P90" + P50 = "P50" + MIN = "MIN" + MAX = "MAX" + + +class NodeType(str, Enum): + PROD = "prod" + INJ = "inj" + OTHER = "other" + + +class GroupTreeData(BaseModel): + edge_metadata_list: List[GroupTreeMetadata] + node_metadata_list: List[GroupTreeMetadata] + dated_trees: List[DatedTree] diff --git a/backend_py/primary/primary/services/group_tree_assembler/_group_tree_dataframe_model.py b/backend_py/primary/primary/services/group_tree_assembler/_group_tree_dataframe_model.py new file mode 100644 index 000000000..4ea7987fc --- /dev/null +++ b/backend_py/primary/primary/services/group_tree_assembler/_group_tree_dataframe_model.py @@ -0,0 +1,287 @@ +from typing import Callable, List, Optional + +import pandas as pd + +from primary.services.sumo_access.group_tree_types import DataType, TreeType + +GROUP_TREE_FIELD_DATATYPE_TO_VECTOR_MAP = { + DataType.OILRATE: "FOPR", + DataType.GASRATE: "FGPR", + DataType.WATERRATE: "FWPR", + DataType.WATERINJRATE: "FWIR", + DataType.GASINJRATE: "FGIR", + DataType.PRESSURE: "GPR", +} + +TREE_TYPE_DATATYPE_TO_GROUP_VECTOR_DATATYPE_MAP = { + "GRUPTREE": { + DataType.OILRATE: "GOPR", + DataType.GASRATE: "GGPR", + DataType.WATERRATE: "GWPR", + DataType.WATERINJRATE: "GWIR", + DataType.GASINJRATE: "GGIR", + DataType.PRESSURE: "GPR", + }, + # BRANPROP can not be used for injection, but the nodes + # might also be GNETINJE and could therefore have injection. + "BRANPROP": { + DataType.OILRATE: "GOPRNB", + DataType.GASRATE: "GGPRNB", + DataType.WATERRATE: "GWPRNB", + DataType.WATERINJRATE: "GWIR", + DataType.GASINJRATE: "GGIR", + DataType.PRESSURE: "GPR", + }, +} + +GROUPTREE_DATATYPE_TO_WELL_VECTOR_DATATYPE_MAP = { + DataType.OILRATE: "WOPR", + DataType.GASRATE: "WGPR", + DataType.WATERRATE: "WWPR", + DataType.WATERINJRATE: "WWIR", + DataType.GASINJRATE: "WGIR", + DataType.PRESSURE: "WTHP", + DataType.BHP: "WBHP", + DataType.WMCTL: "WMCTL", +} + +FIELD_VECTORS_OF_INTEREST: List[str] = list(GROUP_TREE_FIELD_DATATYPE_TO_VECTOR_MAP.values()) +WELLS_VECTOR_DATATYPES_OF_INTEREST: List[str] = list(GROUPTREE_DATATYPE_TO_WELL_VECTOR_DATATYPE_MAP.values()) +GROUP_VECTOR_DATATYPES_OF_INTEREST = [ + v for kw in ["GRUPTREE", "BRANPROP"] for v in TREE_TYPE_DATATYPE_TO_GROUP_VECTOR_DATATYPE_MAP[kw].values() +] + + +class GroupTreeDataframeModel: + """ + A helper class for handling group tree dataframes retrieved from Sumo. + + Provides a set of methods for filtering and extracting data from the dataframe. + + The group tree dataframe in model has to have the following columns: + + * DATE + * CHILD + * PARENT + * KEYWORD (GRUPTREE, BRANPROP or WELSPECS) + * REAL + + If gruptrees are exactly equal in all realizations then only one tree is + stored in the dataframe. That means the REAL column will only have one unique value. + If not, all trees are stored. + """ + + _grouptree_df: pd.DataFrame + _terminal_node: Optional[str] + _tree_type: TreeType + + _grouptree_wells: List[str] = [] + _grouptree_groups: List[str] = [] + _grouptree_wstat_vectors: List[str] = [] + + def __init__( + self, + grouptree_dataframe: pd.DataFrame, + tree_type: TreeType, + terminal_node: Optional[str] = None, + ): + """ + Initialize the group tree model with group tree dataframe and tree type + + Expected columns have to be present in the dataframe: + * DATE + * CHILD + * PARENT + * KEYWORD (GRUPTREE, BRANPROP or WELSPECS) + """ + expected_columns = {"DATE", "CHILD", "KEYWORD", "PARENT"} + if not expected_columns.issubset(grouptree_dataframe.columns): + raise ValueError( + f"Expected columns: {expected_columns} not found in the grouptree dataframe. " + f"Columns found: {grouptree_dataframe.columns}" + ) + + # Note: Only support single realization for now + if "REAL" in grouptree_dataframe.columns: + raise ValueError("Only single realization is supported for group tree now.") + + self._grouptree_df = grouptree_dataframe + + if tree_type.value not in self._grouptree_df["KEYWORD"].unique(): + raise ValueError(f"Tree type: {tree_type} not found in grouptree dataframe.") + + self._terminal_node = terminal_node + self._tree_type = tree_type + + if self._tree_type == TreeType.GRUPTREE: + # Filter out BRANPROP entries + self._grouptree_df = self._grouptree_df[self._grouptree_df["KEYWORD"] != TreeType.BRANPROP.value] + elif self._tree_type == TreeType.BRANPROP: + # Filter out GRUPTREE entries + self._grouptree_df = self._grouptree_df[self._grouptree_df["KEYWORD"] != TreeType.GRUPTREE.value] + + group_tree_wells: set[str] = set() + group_tree_groups: set[str] = set() + group_tree_keywords: List[str] = self._grouptree_df["KEYWORD"].to_list() + group_tree_nodes: List[str] = self._grouptree_df["CHILD"].to_list() + for keyword, node in zip(group_tree_keywords, group_tree_nodes): + if keyword == "WELSPECS": + group_tree_wells.add(node) + elif keyword in ["GRUPTREE", "BRANPROP"]: + group_tree_groups.add(node) + + self._grouptree_wells = list(group_tree_wells) + self._grouptree_groups = list(group_tree_groups) + self._grouptree_wstat_vectors = [f"WSTAT:{well}" for well in self._grouptree_wells] + + @property + def dataframe(self) -> pd.DataFrame: + """Returns a dataframe that will have the following columns: + * DATE + * CHILD (node in tree) + * PARENT (node in tree) + * KEYWORD (GRUPTREE, WELSPECS or BRANPROP) + * REAL + + If gruptrees are exactly equal in all realizations then only one tree is + stored in the dataframe. That means the REAL column will only have one unique value. + If not, all trees are stored. + """ + return self._grouptree_df + + @property + def group_tree_wells(self) -> List[str]: + return self._grouptree_wells + + @property + def group_tree_groups(self) -> List[str]: + return self._grouptree_groups + + @property + def wstat_vectors(self) -> List[str]: + """ + Returns the well state indicator vectors for all wells in the group tree + + The vectors are of the form "WSTAT:{well_name}" + """ + return self._grouptree_wstat_vectors + + def create_filtered_dataframe( + self, + terminal_node: Optional[str] = None, + excl_well_startswith: Optional[List[str]] = None, + excl_well_endswith: Optional[List[str]] = None, + ) -> pd.DataFrame: + """This function returns a sub-set of the rows in the gruptree dataframe + filtered according to the input arguments: + + - terminal_node: returns the terminal node and all nodes below it in the + tree (for all realizations and dates) + - excl_well_startswith: removes WELSPECS rows where CHILD starts with any + of the entries in the list. + - excl_well_endswith: removes WELSPECS rows where CHILD ends with any + of the entries in the list. + + """ + df = self._grouptree_df + + if terminal_node is not None: + if terminal_node not in self._grouptree_df["CHILD"].unique(): + raise ValueError( + f"Terminal node '{terminal_node}' not found in 'CHILD' column " "of the gruptree data." + ) + if terminal_node != "FIELD": + branch_nodes = self._create_branch_node_list(terminal_node) + df = self._grouptree_df[self._grouptree_df["CHILD"].isin(branch_nodes)] + + def filter_wells(dframe: pd.DataFrame, well_name_criteria: Callable) -> pd.DataFrame: + return dframe[ + (dframe["KEYWORD"] != "WELSPECS") + | ((dframe["KEYWORD"] == "WELSPECS") & (~well_name_criteria(dframe["CHILD"]))) + ] + + if excl_well_startswith is not None: + # Filter out WELSPECS rows where CHILD starts with any element in excl_well_startswith + # Conversion to tuple done outside lambda due to mypy + excl_well_startswith_tuple = tuple(excl_well_startswith) + df = filter_wells(df, lambda x: x.str.startswith(excl_well_startswith_tuple)) + + if excl_well_endswith is not None: + # Filter out WELSPECS rows where CHILD ends with any element in excl_well_endswith + # Conversion to tuple done outside lambda due to mypy + excl_well_endswith_tuple = tuple(excl_well_endswith) + df = filter_wells(df, lambda x: x.str.endswith(excl_well_endswith_tuple)) + + return df.copy() + + def create_vector_of_interest_list(self) -> List[str]: + """ + Create a list of vectors based on the possible combinations of vector datatypes and vector nodes + for a group tree + + This implies vectors for field, group and well. + + Only returns the candidates which exist among the valid vectors + """ + + # Find all summary vectors with group tree wells + group_tree_well_vector_candidates = _create_vector_candidates( + WELLS_VECTOR_DATATYPES_OF_INTEREST, self._grouptree_wells + ) + + # Find all summary vectors with group tree groups + group_tree_group_vector_candidates = _create_vector_candidates( + GROUP_VECTOR_DATATYPES_OF_INTEREST, self._grouptree_groups + ) + + # Find all summary vectors with field vectors + group_tree_field_vectors_candidates = FIELD_VECTORS_OF_INTEREST + + all_vectors_of_interest = ( + group_tree_well_vector_candidates + + group_tree_group_vector_candidates + + group_tree_field_vectors_candidates + + self._grouptree_wstat_vectors + ) + + # Ensure non duplicated vectors + unique_vectors_of_interst = list(set(all_vectors_of_interest)) + return unique_vectors_of_interst + + def _create_branch_node_list(self, terminal_node: str) -> List[str]: + """ + This function lists all nodes in a branch of the group tree starting from the terminal node. + """ + branch_node_set = set(terminal_node) + + nodes_array = self._grouptree_df["CHILD"].to_numpy() + parents_array = self._grouptree_df["PARENT"].to_numpy() + + if terminal_node not in parents_array: + return list(branch_node_set) + + current_parents = [terminal_node] + while len(current_parents) > 0: + # Find all indexes matching the current parents + children_indices = set([i for i, x in enumerate(parents_array) if x in current_parents]) + + # Find all children of the current parents + children = nodes_array[list(children_indices)] + branch_node_set.update(children) + current_parents = children + + return list(branch_node_set) + + +def _create_vector_candidates(vector_datatype_candidates: List[str], vector_node_candidates: List[str]) -> List[str]: + """Create a list of vectors based on the list of vector datatype candidates and vector node candidates + + A vector is then given by "{vector_datatype}:{vector_node}" + + E.g. "WOPT:WELL1" + """ + result: List[str] = [] + for datatype in vector_datatype_candidates: + for node in vector_node_candidates: + result.append(f"{datatype}:{node}") + return result diff --git a/backend_py/primary/primary/services/group_tree_assembler/group_tree_assembler.py b/backend_py/primary/primary/services/group_tree_assembler/group_tree_assembler.py new file mode 100644 index 000000000..7ac40a249 --- /dev/null +++ b/backend_py/primary/primary/services/group_tree_assembler/group_tree_assembler.py @@ -0,0 +1,960 @@ +import logging +from typing import Dict, List, Literal, Optional, Sequence, Tuple +from dataclasses import dataclass + +import numpy as np +import pandas as pd +import pyarrow as pa +import pyarrow.compute as pc + +from fastapi import HTTPException +from primary.services.sumo_access.group_tree_access import GroupTreeAccess +from primary.services.sumo_access.group_tree_types import ( + DataType, + DatedTree, + EdgeOrNode, + GroupTreeMetadata, + NodeType, + TreeNode, + TreeModeOptions, + TreeType, +) +from primary.services.sumo_access.summary_access import Frequency, SummaryAccess + +from ._group_tree_dataframe_model import ( + GroupTreeDataframeModel, + GROUP_TREE_FIELD_DATATYPE_TO_VECTOR_MAP, + TREE_TYPE_DATATYPE_TO_GROUP_VECTOR_DATATYPE_MAP, + GROUPTREE_DATATYPE_TO_WELL_VECTOR_DATATYPE_MAP, +) + +from webviz_pkg.core_utils.perf_timer import PerfTimer + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class NodeClassification: + """ + Classification of a node in the group tree. + Can be producer, injector and other over the time period of the group tree. + """ + + IS_PROD: bool + IS_INJ: bool + IS_OTHER: bool + + +@dataclass +class SummaryVectorInfo: + """ + Info/metadata for a summary vector for a node in the group tree + """ + + DATATYPE: DataType + EDGE_NODE: EdgeOrNode + + +# For each node, the summary vectors needed to create the group tree dataset +@dataclass +class NodeSummaryVectorsInfo: + # Dict with summary vector name as key, and its metadata as values + # E.g.: {sumvec_1: SummaryVectorInfo, sumvec_2: SummaryVectorInfo, ...} + SMRY_INFO: Dict[str, SummaryVectorInfo] + + +@dataclass +class GroupTreeSummaryVectorsInfo: + """ + Dataclass to hold summary vectors info for the group tree. + + - node_summary_vectors_info_dict - Dict with node name and all its summary vectors info as value + - all_summary_vectors - List of all summary vectors present in the group tree + - edge_summary_vectors - List of summary vectors used for edges in the group tree + """ + + # Dict with node as key, and all the summary vectors w/ metadata for the node as value + node_summary_vectors_info_dict: Dict[str, NodeSummaryVectorsInfo] + all_summary_vectors: set[str] # All summary vectors present in the group tree + edge_summary_vectors: set[str] # All summary vectors used for edges in the group tree + + +@dataclass +class StaticNodeWorkingData: + """ + Static working data for a node in the group tree. + + Data independent of dates, used for building the group tree. + """ + + node_name: str # Redundant, but kept for debugging purposes + node_classification: NodeClassification + node_summary_vectors_info: Dict[str, SummaryVectorInfo] + + +class GroupTreeAssembler: + """ + Class to fetch group tree table data and summary data from access layers, and assemble + the data into a format for the router layer. + + """ + + def __init__( + self, + group_tree_access: GroupTreeAccess, + summary_access: SummaryAccess, + realization: int, + summary_frequency: Frequency, + node_types: set[NodeType], + group_tree_mode: TreeModeOptions, + terminal_node: str = "FIELD", + tree_type: TreeType = TreeType.GRUPTREE, + excl_well_startswith: Optional[List[str]] = None, + excl_well_endswith: Optional[List[str]] = None, + ): + self._tree_mode = group_tree_mode + + # NOTE: Temporary only supporting single real + if self._tree_mode != TreeModeOptions.SINGLE_REAL: + raise ValueError("Only SINGLE_REAL mode is supported at the moment.") + + self._realization = realization + self._group_tree_access = group_tree_access + self._summary_access = summary_access + self._terminal_node = terminal_node + self._tree_type = tree_type + self._excl_well_startswith = excl_well_startswith + self._excl_well_endswith = excl_well_endswith + self._summary_resampling_frequency = summary_frequency + self._node_types = node_types + + self._has_waterinj = False + self._has_gasinj = False + + self._group_tree_df: pd.DataFrame | None = None + self._all_vectors: List[str] | None = None + self._smry_table_sorted_by_date: pa.Table | None = None + + self._node_static_working_data_dict: Dict[str, StaticNodeWorkingData] | None = None + + async def _initialize_all_vectors_list_async(self) -> None: + vector_info_arr = await self._summary_access.get_available_vectors_async() + self._all_vectors = [vec.name for vec in vector_info_arr] + + async def fetch_and_initialize_async(self) -> None: + """ + Fetch group tree and summary data from Sumo, and initialize the data structures needed to build the single realization + group tree. + + This method initialize and create data structures for optimized access and performance for the single realization group tree + with summary data. + """ + if self._tree_mode != TreeModeOptions.SINGLE_REAL: + raise ValueError("Tree mode must be SINGLE_REAL to initialize single realization data") + if self._realization is None: + raise ValueError("GroupTreeAssembler missing realization") + + timer = PerfTimer() + + await self._initialize_all_vectors_list_async() + get_summary_vector_list_time_ms = timer.lap_ms() + if self._all_vectors is None: + raise ValueError("List of summary vectors has not been initialized") + + # Get group tree data from Sumo + group_tree_table_df = await self._group_tree_access.get_group_tree_table(realization=self._realization) + get_group_tree_table_time_ms = timer.lap_ms() + if group_tree_table_df is None: + raise HTTPException(status_code=404, detail="Group tree data not found") + + timer.lap_ms() + + # Initialize dataframe model + group_tree_df_model = GroupTreeDataframeModel(group_tree_table_df, self._tree_type) + initialize_grouptree_model_time_ms = timer.lap_ms() + + # Ensure "WSTAT" vectors expected for group tree exist among summary vectors + _verify_that_sumvecs_exists(group_tree_df_model.wstat_vectors, self._all_vectors) + + # Get all vectors of interest existing in the summary data + vectors_of_interest = group_tree_df_model.create_vector_of_interest_list() + vectors_of_interest = [vec for vec in vectors_of_interest if vec in self._all_vectors] + + # Has any water injection or gas injection vectors among vectors of interest + has_wi_vectors = False + has_gi_vectors = False + for vec in vectors_of_interest: + if has_wi_vectors and has_gi_vectors: + break + if vec.startswith("WWIR") or vec.startswith("GWIR"): + has_wi_vectors = True + if vec.startswith("WGIR") or vec.startswith("GGIR"): + has_gi_vectors = True + + # If any water or gas injection vectors exist, require field injection vectors exist + if has_wi_vectors and "FWIR" not in vectors_of_interest: + raise ValueError("Water injection vectors (WWIR/GWIR) found, but missing expected: FWIR") + if has_gi_vectors and "FGIR" not in vectors_of_interest: + raise ValueError("Gas injection vectors (WGIR/GGIR) found, but missing expected: FGIR") + + # Get summary vectors for all data simultaneously to obtain one request from Sumo + # Many summary vectors might not be needed, but will be filtered out later on. This is the most efficient way to get the data + # NOTE: "WSTAT" vectors are enumerated well state indicator, thus interpolated values might create issues (should be resolved by resampling-code) + timer.lap_ms() + single_realization_vectors_table, _ = await self._summary_access.get_single_real_vectors_table_async( + vector_names=vectors_of_interest, + resampling_frequency=self._summary_resampling_frequency, + realization=self._realization, + ) + get_summary_vectors_time_ms = timer.lap_ms() + + # Create list of column names in the table once (for performance) + vectors_table_column_names = single_realization_vectors_table.column_names + + # Create well node classifications based on "WSTAT" vectors + well_node_classifications: Dict[str, NodeClassification] = {} + for wstat_vector in group_tree_df_model.wstat_vectors: + well = wstat_vector.split(":")[1] + well_states = set(single_realization_vectors_table[wstat_vector].to_pylist()) + well_node_classifications[well] = NodeClassification( + IS_PROD=1.0 in well_states, + IS_INJ=2.0 in well_states, + IS_OTHER=(1.0 not in well_states) and (2.0 not in well_states), + ) + + # Create filtered group tree df from model + timer.lap_ms() + group_tree_df = group_tree_df_model.create_filtered_dataframe( + terminal_node=self._terminal_node, + excl_well_startswith=self._excl_well_startswith, + excl_well_endswith=self._excl_well_endswith, + ) + create_filtered_dataframe_time_ms = timer.lap_ms() + + # Create node classifications based on leaf node classifications + node_classification_dict = _create_node_classification_dict( + group_tree_df, well_node_classifications, single_realization_vectors_table + ) + create_node_classifications_time_ms = timer.lap_ms() + + # Initialize injection states based on group tree data + if self._terminal_node in node_classification_dict: + is_inj_in_grouptree = node_classification_dict[self._terminal_node].IS_INJ + if is_inj_in_grouptree and "FWIR" in vectors_table_column_names: + self._has_waterinj = pc.sum(single_realization_vectors_table["FWIR"]).as_py() > 0 + if is_inj_in_grouptree and "FGIR" in vectors_table_column_names: + self._has_gasinj = pc.sum(single_realization_vectors_table["FGIR"]).as_py() > 0 + + # Get nodes with summary vectors and their metadata, and all summary vectors, and edge summary vectors + # _node_sumvec_info_dict, all_sumvecs, edge_sumvecs = + _group_tree_summary_vectors_info = _create_group_tree_summary_vectors_info( + group_tree_df, node_classification_dict, self._terminal_node, self._has_waterinj, self._has_gasinj + ) + create_group_tree_summary_vectors_info_tims_ms = timer.lap_ms() + + # Check if all edges is subset of initialized single realization vectors column names + if not _group_tree_summary_vectors_info.edge_summary_vectors.issubset(vectors_table_column_names): + missing_sumvecs = _group_tree_summary_vectors_info.edge_summary_vectors - set(vectors_table_column_names) + raise ValueError(f"Missing summary vectors for edges in the GroupTree: {', '.join(missing_sumvecs)}.") + + # Expect all dictionaries to have the same keys + if set(_group_tree_summary_vectors_info.node_summary_vectors_info_dict.keys()) != set( + node_classification_dict.keys() + ): + raise ValueError("Node classifications and summary vector info must have the same keys.") + + # Create static working data for each node + node_static_working_data_dict: Dict[str, StaticNodeWorkingData] = {} + for node_name, node_classification in node_classification_dict.items(): + node_summary_vectors_info = _group_tree_summary_vectors_info.node_summary_vectors_info_dict[ + node_name + ].SMRY_INFO + node_static_working_data_dict[node_name] = StaticNodeWorkingData( + node_name=node_name, + node_classification=node_classification, + node_summary_vectors_info=node_summary_vectors_info, + ) + self._node_static_working_data_dict = node_static_working_data_dict + + # Expect each node to have working data + node_names_set = set(group_tree_df["CHILD"].unique().tolist()) + if set(self._node_static_working_data_dict.keys()) != node_names_set: + missing_node_working_data = node_names_set - set(self._node_static_working_data_dict.keys()) + raise ValueError(f"Missing static working data for nodes: {missing_node_working_data}") + + # Find group tree vectors existing in summary data + valid_summary_vectors = [ + vec for vec in _group_tree_summary_vectors_info.all_summary_vectors if vec in vectors_table_column_names + ] + columns_of_interest = list(valid_summary_vectors) + ["DATE"] + self._smry_table_sorted_by_date = single_realization_vectors_table.select(columns_of_interest).sort_by("DATE") + + # Assign group tree dataframe + self._group_tree_df = group_tree_df + + # Log download from Sumo times + LOGGER.info( + f"Total time to fetch data from Sumo: {get_summary_vector_list_time_ms+get_summary_vectors_time_ms+get_group_tree_table_time_ms}ms, " + f"Get summary vector list in: {get_summary_vector_list_time_ms}ms, " + f"Get group tree table in: {get_group_tree_table_time_ms}ms, " + f"Get summary vectors in: {get_summary_vectors_time_ms}ms" + ) + + # Log initialization of data structures times + LOGGER.info( + f"Initialize GroupTreeModel in: {initialize_grouptree_model_time_ms}ms, " + f"Create filtered dataframe in: {create_filtered_dataframe_time_ms}ms, " + f"Create node classifications in: {create_node_classifications_time_ms}ms, " + f"Create group tree summary vectors info in: {create_group_tree_summary_vectors_info_tims_ms}ms" + ) + + async def create_dated_trees_and_metadata_lists( + self, + ) -> Tuple[List[DatedTree], List[GroupTreeMetadata], List[GroupTreeMetadata]]: + """ + This method creates the dated trees and metadata lists for the single realization dataset. + + It does not create new data structures, but access the already fetched and initialized data for the single realization. + Data structures are chosen and tested for optimized access and performance. + """ + if self._tree_mode != TreeModeOptions.SINGLE_REAL: + raise ValueError("Tree mode must be SINGLE_REAL to create a single realization dataset") + + if self._smry_table_sorted_by_date is None: + raise ValueError("Summary dataframe sorted by date has not been initialized") + + if self._node_static_working_data_dict is None: + raise ValueError("Static working data for nodes has not been initialized") + + dated_tree_list = _create_dated_trees( + self._smry_table_sorted_by_date, + self._group_tree_df, + self._node_static_working_data_dict, + self._node_types, + self._terminal_node, + ) + + return ( + dated_tree_list, + self._get_edge_options(self._node_types), + [ + GroupTreeMetadata(key=datatype.value, label=_get_label(datatype)) + for datatype in [DataType.PRESSURE, DataType.BHP, DataType.WMCTL] + ], + ) + + def _get_edge_options(self, node_types: set[NodeType]) -> List[GroupTreeMetadata]: + """Returns a list with edge node options for the dropdown + menu in the GroupTree component. + + The output list has the format: + [ + {"name": DataType.OILRATE.value, "label": "Oil Rate"}, + {"name": DataType.GASRATE.value, "label": "Gas Rate"}, + ] + """ + options: List[GroupTreeMetadata] = [] + if NodeType.PROD in node_types: + for rate in [DataType.OILRATE, DataType.GASRATE, DataType.WATERRATE]: + options.append(GroupTreeMetadata(key=rate.value, label=_get_label(rate))) + if NodeType.INJ in node_types and self._has_waterinj: + options.append(GroupTreeMetadata(key=DataType.WATERINJRATE.value, label=_get_label(DataType.WATERINJRATE))) + if NodeType.INJ in node_types and self._has_gasinj: + options.append(GroupTreeMetadata(key=DataType.GASINJRATE.value, label=_get_label(DataType.GASINJRATE))) + if options: + return options + return [GroupTreeMetadata(key=DataType.OILRATE.value, label=_get_label(DataType.OILRATE))] + + +def _create_group_tree_summary_vectors_info( + group_tree_df: pd.DataFrame, + node_classification_dict: Dict[str, NodeClassification], + terminal_node: str, + has_waterinj: bool, + has_gasinj: bool, +) -> GroupTreeSummaryVectorsInfo: + """ + Extract summary vector info from the provided group tree dataframe and node classifications. + + The group tree dataframe must have columns ["CHILD", "KEYWORD"] + + Returns a dataclass which holds summary vectors info for the group tree. A dictionary with node name as key, + and all its summary vectors info as value. Also returns a set with all summary vectors present in the group tree, + and a set with summary vectors used for edges in the group tree. + + Rates are not required for the terminal node since they will not be used. + + `Arguments`: + group_tree_df: pd.DataFrame - Group tree dataframe. Expected columns are: ["CHILD", "KEYWORD"] + node_classification_dict: Dict[str, NodeClassification] - Dictionary with node name as key, and classification as value + terminal_node: str - Name of the terminal node in the group tree + has_waterinj: bool - True if water injection is present in the group tree + has_gasinj: bool - True if gas injection is present in the group tree + + `Returns`: + GroupTreeSummaryVectorsInfo + """ + node_sumvecs_info_dict: Dict[str, NodeSummaryVectorsInfo] = {} + all_sumvecs: set[str] = set() + edge_sumvecs: set[str] = set() + + unique_nodes = group_tree_df.drop_duplicates(subset=["CHILD", "KEYWORD"]) + + node_names = unique_nodes["CHILD"].to_numpy() + node_keyword = unique_nodes["KEYWORD"].to_numpy() + + if len(node_names) != len(node_keyword): + raise ValueError("Length of node names and keywords must be equal.") + + if set(node_names) != set(node_classification_dict.keys()): + missing_node_names = set(node_names) - set(node_classification_dict.keys()) + raise ValueError(f"Node names missing in node classification dict: {missing_node_names}") + + num_nodes = len(node_names) + for i in range(num_nodes): + nodename = node_names[i] + keyword = node_keyword[i] + node_classification = node_classification_dict[nodename] + is_prod = node_classification.IS_PROD + is_inj = node_classification.IS_INJ + + if not isinstance(nodename, str) or not isinstance(keyword, str): + raise ValueError("Nodename and keyword must be strings") + + datatypes = [DataType.PRESSURE] + if is_prod and nodename != terminal_node: + datatypes += [DataType.OILRATE, DataType.GASRATE, DataType.WATERRATE] + if is_inj and has_waterinj and nodename != terminal_node: + datatypes.append(DataType.WATERINJRATE) + if is_inj and has_gasinj and nodename != terminal_node: + datatypes.append(DataType.GASINJRATE) + if keyword == "WELSPECS": + datatypes += [DataType.BHP, DataType.WMCTL] + + if len(datatypes) > 0: + node_sumvecs_info_dict[nodename] = NodeSummaryVectorsInfo(SMRY_INFO={}) + + for datatype in datatypes: + sumvec_name = _create_sumvec_from_datatype_nodename_and_keyword(datatype, nodename, keyword) + edge_or_node = _get_edge_node(datatype) + all_sumvecs.add(sumvec_name) + if edge_or_node == EdgeOrNode.EDGE: + edge_sumvecs.add(sumvec_name) + node_sumvecs_info_dict[nodename].SMRY_INFO[sumvec_name] = SummaryVectorInfo( + DATATYPE=datatype, EDGE_NODE=edge_or_node + ) + + return GroupTreeSummaryVectorsInfo( + node_summary_vectors_info_dict=node_sumvecs_info_dict, + all_summary_vectors=all_sumvecs, + edge_summary_vectors=edge_sumvecs, + ) + + +def _verify_that_sumvecs_exists(check_sumvecs: Sequence[str], valid_sumvecs: Sequence[str]) -> None: + """ + Takes in a list of summary vectors and checks if they are present among the valid summary vectors. + If any are missing, a ValueError is raised with the list of all missing summary vectors. + """ + + # Find vectors that are missing in the valid sumvecs + missing_sumvecs = set(check_sumvecs) - set(valid_sumvecs) + if len(missing_sumvecs) > 0: + str_missing_sumvecs = ", ".join(missing_sumvecs) + raise ValueError("Missing summary vectors for the GroupTree plugin: " f"{str_missing_sumvecs}.") + + +def _create_node_classification_dict( + group_tree_df: pd.DataFrame, + well_node_classifications: Dict[str, NodeClassification], + summary_vectors_table: pa.Table, +) -> Dict[str, NodeClassification]: + """ + Create dictionary with node name as key, and corresponding classification as value. + + The nodes are classified without considering the dates of the group trees. Thereby the classification + is given across all dates. + + The states are found for the leaf nodes, and then the parent nodes are classified based on the leaf nodes. "Bottom-up" approach. + + Well leaf nodes are classified from the well_node_classifications dictionary. A group leaf node is defined by summary vectors + for the node. + + `Arguments`: + `group_tree_df: pd.DataFrame - Group tree df to modify. Expected columns: ["PARENT", "CHILD", "KEYWORD", "DATE"] + `well_node_classifications: Dict[str, NodeClassification] - Dictionary with well node as key, and classification as value + `summary_vectors_table: pa.Table - Summary table with all summary vectors. Needed to retrieve the classification for leaf nodes of type "GRUPTREE" or "BRANPROP" + """ + + # Get unique nodes, neglect dates + nodes_df = group_tree_df.drop_duplicates(subset=["CHILD"], keep="first").copy() + + timer = PerfTimer() + + # Prepare arrays for node names, parent nodes and keywords + node_parent_ndarray = nodes_df["PARENT"].to_numpy() + node_name_ndarray = nodes_df["CHILD"].to_numpy() + node_keyword_ndarray = nodes_df["KEYWORD"].to_numpy() + + if len(node_parent_ndarray) != len(node_name_ndarray) or len(node_name_ndarray) != len(node_keyword_ndarray): + raise ValueError("Length of node names, parent names and keywords must be equal.") + + num_nodes = len(node_name_ndarray) + + # Build lists of leaf node, their keyword and parent node. + leaf_node_list: List[str] = [] + leaf_node_keyword_list: List[str] = [] + leaf_node_parent_list: List[str] = [] + for i in range(num_nodes): + node_name = node_name_ndarray[i] + is_leaf_node = np.count_nonzero(node_parent_ndarray == node_name) == 0 + if is_leaf_node: + leaf_node_list.append(node_name) + leaf_node_keyword_list.append(node_keyword_ndarray[i]) + leaf_node_parent_list.append(node_parent_ndarray[i]) + + if len(leaf_node_list) != len(leaf_node_keyword_list) or len(leaf_node_list) != len(leaf_node_parent_list): + raise ValueError("Length of leaf node names, keyword and parent names must be equal.") + + is_leafnode_time_ms = timer.lap_ms() + + # Classify leaf nodes as producer, injector or other + leaf_node_classification_map = _create_leaf_node_classification_map( + leaf_node_list, leaf_node_keyword_list, well_node_classifications, summary_vectors_table + ) + + classifying_leafnodes_time_ms = timer.lap_ms() + + # Initial node classifications are leaf nodes + node_classifications: Dict[str, NodeClassification] = leaf_node_classification_map + + # Build tree node classifications bottom up + current_parent_nodes = set(leaf_node_parent_list) + node_name_list: List[str] = node_name_ndarray.tolist() + while len(current_parent_nodes) > 0: + grandparent_nodes = set() + + # For each parent node, handle its children + for parent_node in current_parent_nodes: + if parent_node is None: + continue + + children_indices = [index for index, value in enumerate(node_parent_ndarray) if value == parent_node] + children_node_names = node_name_ndarray[children_indices] + + parent_node_classification = NodeClassification(IS_PROD=False, IS_INJ=False, IS_OTHER=False) + children_classifications = [ + node_classifications[child] for child in children_node_names if child in node_classifications + ] + for child_classification in children_classifications: + # Update parent node classification (or-logic) + if child_classification.IS_PROD: + parent_node_classification.IS_PROD = True + if child_classification.IS_INJ: + parent_node_classification.IS_INJ = True + if child_classification.IS_OTHER: + parent_node_classification.IS_OTHER = True + + if ( + parent_node_classification.IS_PROD + and parent_node_classification.IS_INJ + and parent_node_classification.IS_OTHER + ): + break + + # Add parent node classification to the dict + node_classifications[parent_node] = parent_node_classification + + # Add grandparent node to the set + grandparent_node_index = node_name_list.index(parent_node) + grandparent_node = node_parent_ndarray[grandparent_node_index] + grandparent_nodes.add(grandparent_node) + + current_parent_nodes = grandparent_nodes + + # Expect the length of node classifications to be the same as the number of nodes + if set(node_classifications.keys()) != set(node_name_list): + missing_node_classifications = set(node_name_list) - set(node_classifications.keys()) + raise ValueError(f"Node classifications missing for nodes: {missing_node_classifications}") + + classify_remaining_nodes_time_ms = timer.lap_ms() + + LOGGER.info( + f"Leaf node classification took: {is_leafnode_time_ms}ms, " + f"Classifying leaf nodes took: {classifying_leafnodes_time_ms}ms, " + f"Classify remaining nodes took: {classify_remaining_nodes_time_ms}ms " + f"Total time add node type columns: {timer.elapsed_ms()}ms" + ) + + return node_classifications + + +def _create_leaf_node_classification_map( + leaf_nodes: List[str], + leaf_node_keywords: List[str], + well_node_classifications: Dict[str, NodeClassification], + summary_vectors_table: pa.Table, +) -> Dict[str, NodeClassification]: + """Creates a dictionary with node names as keys and NodeClassification as values. + + The leaf nodes and keywords must be sorted and have the same length. I.e. pairwise by index. + + Well leaf nodes are classified from the well_node_classifications dictionary. A group leaf node is defined by summary vectors + for the node. + + `Arguments`: + - `leaf_nodes`: List[str] - List of leaf node names + - `leaf_node_keywords`: List[str] - List of keywords for the leaf nodes + - `well_node_classifications`: Dict[str, NodeClassification] - Dictionary with well node as key, and classification as value + - `summary_vectors_table`: pa.Table - Summary table with all summary vectors. Needed to retrieve the classification for leaf nodes of type "GRUPTREE" or "BRANPROP" + + `Return`: + Dict of leaf node name as key, and NodeClassification as value + """ + if len(leaf_nodes) != len(leaf_node_keywords): + raise ValueError("Length of node names and keywords must be equal.") + + summary_columns = summary_vectors_table.column_names + + leaf_node_classifications: Dict[str, NodeClassification] = {} + for i, node in enumerate(leaf_nodes): + well_node_classification = well_node_classifications.get(node) + if leaf_node_keywords[i] == "WELSPECS" and well_node_classification is not None: + leaf_node_classifications[node] = well_node_classification + else: + # For groups, classify based on summary vectors + prod_sumvecs = [ + _create_sumvec_from_datatype_nodename_and_keyword(datatype, node, leaf_node_keywords[i]) + for datatype in [DataType.OILRATE, DataType.GASRATE, DataType.WATERRATE] + ] + inj_sumvecs = ( + [ + _create_sumvec_from_datatype_nodename_and_keyword(datatype, node, leaf_node_keywords[i]) + for datatype in [DataType.WATERINJRATE, DataType.GASINJRATE] + ] + if leaf_node_keywords[i] != "BRANPROP" + else [] + ) + + prod_sum = sum( + pc.sum(summary_vectors_table[sumvec]).as_py() for sumvec in prod_sumvecs if sumvec in summary_columns + ) + inj_sums = sum( + pc.sum(summary_vectors_table[sumvec]).as_py() for sumvec in inj_sumvecs if sumvec in summary_columns + ) + is_prod = prod_sum > 0 + is_inj = inj_sums > 0 + + leaf_node_classifications[node] = NodeClassification( + IS_PROD=is_prod, IS_INJ=is_inj, IS_OTHER=not is_prod and not is_inj + ) + + return leaf_node_classifications + + +def _get_label(datatype: DataType) -> str: + """Returns a more readable label for the summary datatypes""" + labels = { + DataType.OILRATE: "Oil Rate", + DataType.GASRATE: "Gas Rate", + DataType.WATERRATE: "Water Rate", + DataType.WATERINJRATE: "Water Inj Rate", + DataType.GASINJRATE: "Gas Inj Rate", + DataType.PRESSURE: "Pressure", + DataType.BHP: "BHP", + DataType.WMCTL: "WMCTL", + } + label = labels.get(datatype) + if label is None: + raise ValueError(f"Label for datatype {datatype.value} not implemented.") + return label + + +def _get_edge_node(datatype: DataType) -> EdgeOrNode: + """Returns if a given datatype is edge (typically rates) or node (f.ex pressures)""" + if datatype in [ + DataType.OILRATE, + DataType.GASRATE, + DataType.WATERRATE, + DataType.WATERINJRATE, + DataType.GASINJRATE, + ]: + return EdgeOrNode.EDGE + if datatype in [DataType.PRESSURE, DataType.BHP, DataType.WMCTL]: + return EdgeOrNode.NODE + raise ValueError(f"Data type {datatype.value} not implemented.") + + +def _create_sumvec_from_datatype_nodename_and_keyword( + datatype: DataType, + nodename: str, + keyword: str, +) -> str: + """Returns the correct summary vector for a given + * datatype: oilrate, gasrate etc + * nodename: FIELD, well name or group name in Eclipse network + * keyword: GRUPTREE, BRANPROP or WELSPECS + """ + + if nodename == "FIELD": + datatype_ecl = GROUP_TREE_FIELD_DATATYPE_TO_VECTOR_MAP[datatype] + if datatype == "pressure": + return f"{datatype_ecl}:{nodename}" + return datatype_ecl + try: + if keyword == "WELSPECS": + datatype_ecl = GROUPTREE_DATATYPE_TO_WELL_VECTOR_DATATYPE_MAP[datatype] + else: + datatype_ecl = TREE_TYPE_DATATYPE_TO_GROUP_VECTOR_DATATYPE_MAP[keyword][datatype] + except KeyError as exc: + error = ( + f"Summary vector not found for eclipse keyword: {keyword}, " + f"data type: {datatype.value} and node name: {nodename}. " + ) + raise KeyError(error) from exc + return f"{datatype_ecl}:{nodename}" + + +def _create_dated_trees( + smry_sorted_by_date: pa.Table, + group_tree_df: pd.DataFrame, + node_static_working_data_dict: Dict[str, StaticNodeWorkingData], + valid_node_types: set[NodeType], + terminal_node: str, +) -> List[DatedTree]: + """ + Create a list of static group trees with summary data, based on the group trees and resampled summary data. + + The summary data should be valid for the time span of the group tree. + + The node structure for a dated tree in the list is static. The summary data for each node in the dated tree is given by + by the time span where the tree is valid (from date of the tree to the next tree). + + `Arguments`: + - `smry_sorted_by_date`. pa.Table - Summary data table sorted by date. Expected columns: [DATE, summary_vector_1, ... , summary_vector_n] + - `group_tree_df`: Dataframe with group tree for dates - expected columns: [KEYWORD, CHILD, PARENT], optional column: [VFP_TABLE] + - `node_static_working_data_dict`: Dictionary with node name as key and its static work data for building group trees + - `valid_node_types`: Set of valid node types for the group tree + - `terminal_node`: Name of the terminal node in the group tree + + `Returns`: + A list of dated trees with recursive node structure and summary data for each node in the tree. + """ + dated_trees: List[DatedTree] = [] + + # loop trees + timer = PerfTimer() + + # Group the group tree data by date + grouptree_per_date = group_tree_df.groupby("DATE") + grouptree_dates = group_tree_df["DATE"].unique() + + initial_grouping_and_dates_extract_time_ms = timer.lap_ms() + + # NOTE: What if resampling freq of gruptree data is higher than summary data? + # A lot of "No summary data found for gruptree between {date} and {next_date}" is printed + # Pick the latest group tree state or? Can a node change states prod/inj in between and details are + timer.lap_ms() + total_create_dated_trees_time_ms = 0 + total_smry_table_filtering_ms = 0 + total_find_next_date_time_ms = 0 + + total_loop_time_ms_start = timer.elapsed_ms() + for date, grouptree_at_date in grouptree_per_date: + timer.lap_ms() + next_date = grouptree_dates[grouptree_dates > date].min() + if pd.isna(next_date): + # Pick last smry date from sorted date column + next_date = smry_sorted_by_date["DATE"][-1] + total_find_next_date_time_ms += timer.lap_ms() + + timer.lap_ms() + # Filter summary data for the time span defined by the group tree date and the next group tree date + greater_equal_expr = pc.greater_equal(pc.field("DATE"), date) + less_expr = pc.less(pc.field("DATE"), next_date) + datespan_mask_expr = pc.and_kleene(greater_equal_expr, less_expr) + + smry_in_datespan_sorted_by_date: pa.Table = smry_sorted_by_date.filter(datespan_mask_expr) + total_smry_table_filtering_ms += timer.lap_ms() + + if smry_in_datespan_sorted_by_date.num_rows > 0: + dates = smry_in_datespan_sorted_by_date["DATE"] + + timer.lap_ms() + dated_trees.append( + DatedTree( + dates=[date.as_py().strftime("%Y-%m-%d") for date in dates], + tree=_create_dated_tree( + grouptree_at_date, + date, + smry_in_datespan_sorted_by_date, + len(dates), + node_static_working_data_dict, + valid_node_types, + terminal_node, + ), + ) + ) + total_create_dated_trees_time_ms += timer.lap_ms() + else: + LOGGER.info(f"""No summary data found for gruptree between {date} and {next_date}""") + + total_loop_time_ms = timer.elapsed_ms() - total_loop_time_ms_start + + LOGGER.info( + f"Total time create_dated_trees func: {timer.elapsed_ms()}ms, " + f"Total loop time for grouptree_per_date: {total_loop_time_ms}ms, " + f"Total filter smry table: {total_smry_table_filtering_ms}ms " + f"Total create dated tree: {total_create_dated_trees_time_ms}ms " + ) + + return dated_trees + + +def _create_dated_tree( + grouptree_at_date: pd.DataFrame, + grouptree_date: pd.Timestamp, + smry_for_grouptree_sorted_by_date: pa.Table, + number_of_dates_in_smry: int, + node_static_working_data_dict: Dict[str, StaticNodeWorkingData], + valid_node_types: set[NodeType], + terminal_node: str, +) -> TreeNode: + """ + Create a static group tree with summary data for a set of dates. + + The node structure is static, but the summary data for each node is given for a set of dates. + + `Arguments`: + - `grouptree_at_date`: Dataframe with group tree for one date - expected columns: [KEYWORD, CHILD, PARENT, EDGE_LABEL] + - `grouptree_date`: Timestamp - Date of the group tree + - smry_for_grouptree_sorted_by_date: Summary data for time span defined from the group tree at date to the next group tree date. The summary data is + sorted by date, which implies unique dates, ordered by date. Thereby each node or edge is a column in the summary dataframe. + - number_of_dates_in_smry: Number of unique dates in the summary data df. To be used for filling missing data - i.e. num rows of smry_sorted_by_date + - node_static_working_data_dict: Dictionary with node name as key and its static work data for building group tree + - valid_node_types: Set of valid node types for the group tree + - terminal_node: Name of the terminal node in the group tree + + `Returns`: + A dated tree with recursive node structure and summary data for each node for the set of dates. + """ + # Dictionary of node name, with info about parent nodename RecursiveTreeNode with empty child array + # I.e. iterate over rows in df (better than recursive search) + nodes_dict: Dict[str, Tuple[str, TreeNode]] = {} + + # Extract columns as numpy arrays for index access resulting in faster processing + # NOTE: Expect all columns to be 1D arrays and present in the dataframe + node_names = grouptree_at_date["CHILD"].to_numpy() + parent_names = grouptree_at_date["PARENT"].to_numpy() + keywords = grouptree_at_date["KEYWORD"].to_numpy() + + if len(node_names) != len(parent_names) or len(node_names) != len(keywords): + raise ValueError("Length of node_names, parent_names and keywords must be the same") + + # Create edge label for nodes + edge_labels = [""] * len(node_names) + if "VFP_TABLE" in grouptree_at_date.columns: + edge_labels = _create_edge_label_list_from_vfp_table_column(grouptree_at_date["VFP_TABLE"]) + + # Extract names once + smry_columns_set = set(smry_for_grouptree_sorted_by_date.column_names) + + num_rows = len(node_names) + # Iterate over every row in the grouptree dataframe to create the tree nodes + for i in range(num_rows): + node_name = node_names[i] + parent_name = parent_names[i] + if node_name not in nodes_dict: + # Find working data for the node + node_static_working_data = node_static_working_data_dict.get(node_name) + if node_static_working_data is None: + raise ValueError(f"No summary vector info found for node {node_name}") + + if not _is_valid_node_type(node_static_working_data.node_classification, valid_node_types): + continue + + edge_data: Dict[str, List[float]] = {} + node_data: Dict[str, List[float]] = {} + + # Each row in summary data is a unique date + summary_vector_info = node_static_working_data.node_summary_vectors_info + for sumvec, info in summary_vector_info.items(): + datatype = info.DATATYPE + sumvec_name = sumvec + if info.EDGE_NODE == EdgeOrNode.EDGE: + if sumvec_name in smry_columns_set: + edge_data[datatype] = smry_for_grouptree_sorted_by_date[sumvec_name].to_numpy().round(2) + continue + else: + edge_data[datatype] = list([np.nan] * number_of_dates_in_smry) + continue + else: + if sumvec_name in smry_columns_set: + node_data[datatype] = smry_for_grouptree_sorted_by_date[sumvec_name].to_numpy().round(2) + continue + else: + node_data[datatype] = list([np.nan] * number_of_dates_in_smry) + continue + + node_type: Literal["Well", "Group"] = "Well" if keywords[i] == "WELSPECS" else "Group" + edge_label = edge_labels[i] + + # children = [], and are added below after each node is created, to prevent recursive search + nodes_dict[node_name] = ( + parent_name, + TreeNode( + node_label=node_name, + node_type=node_type, + edge_label=edge_label, + edge_data=edge_data, + node_data=node_data, + children=[], + ), + ) + + # Add children to the nodes, start with terminal node + terminal_node_elm = nodes_dict.get(terminal_node) + if terminal_node_elm is None: + date_str = grouptree_date.strftime("%Y-%m-%d") + raise ValueError(f"No terminal node {terminal_node} found in group tree at date {date_str}") + + # Iterate over the nodes dict and add children to the nodes by looking at the parent name + # Operates by reference, so each node is updated in the dict + for node_name, (parent_name, node) in nodes_dict.items(): + if parent_name in nodes_dict: + nodes_dict[parent_name][1].children.append(node) + + # Terminal node is the dated tree + result = nodes_dict[terminal_node][1] + + return result + + +def _is_valid_node_type(node_classification: NodeClassification, valid_node_types: set[NodeType]) -> bool: + """Returns True if the node classification is a valid node type""" + if node_classification.IS_PROD and NodeType.PROD in valid_node_types: + return True + if node_classification.IS_INJ and NodeType.INJ in valid_node_types: + return True + if node_classification.IS_OTHER and NodeType.OTHER in valid_node_types: + return True + return False + + +def _create_edge_label_list_from_vfp_table_column(vfp_table_column: pd.Series) -> list[str]: + """ + Creates an edge label list based on the column named "VFP_TABLE". + + If the VFP_TABLE column is not present, the function will raise a ValueError. + """ + if vfp_table_column.empty: + raise ValueError("VFP_TABLE column is empty.") + + edge_labels: list[str] = [] + for vfp_nb in vfp_table_column: + if vfp_nb in [None, 9999] or np.isnan(vfp_nb): + edge_labels.append("") + else: + edge_labels.append(f"VFP {int(vfp_nb)}") + + return edge_labels diff --git a/backend_py/primary/primary/services/sumo_access/group_tree_access.py b/backend_py/primary/primary/services/sumo_access/group_tree_access.py new file mode 100644 index 000000000..0f606c49c --- /dev/null +++ b/backend_py/primary/primary/services/sumo_access/group_tree_access.py @@ -0,0 +1,75 @@ +import logging +from typing import Optional + +import pandas as pd +from fmu.sumo.explorer.objects import Case + +from webviz_pkg.core_utils.perf_timer import PerfTimer + +from ._helpers import create_sumo_client, create_sumo_case_async + + +LOGGER = logging.getLogger(__name__) + + +class GroupTreeAccess: + """ + Class for accessing and retrieving group tree data + """ + + TAGNAME = "gruptree" + + def __init__(self, case: Case, iteration_name: str): + self._case = case + self._iteration_name = iteration_name + + @classmethod + async def from_case_uuid_async(cls, access_token: str, case_uuid: str, iteration_name: str) -> "GroupTreeAccess": + sumo_client = create_sumo_client(access_token) + case: Case = await create_sumo_case_async(sumo_client, case_uuid, want_keepalive_pit=False) + return GroupTreeAccess(case, iteration_name) + + async def get_group_tree_table(self, realization: Optional[int]) -> Optional[pd.DataFrame]: + """Get well group tree data for case and iteration""" + timer = PerfTimer() + + # With single realization, filter on realization + if realization is not None: + table_collection = self._case.tables.filter( + tagname=GroupTreeAccess.TAGNAME, realization=realization, iteration=self._iteration_name + ) + if await table_collection.length_async() == 0: + return None + if await table_collection.length_async() > 1: + raise ValueError("Multiple tables found.") + + group_tree_df = table_collection[0].to_pandas + + _validate_group_tree_df(group_tree_df) + + LOGGER.debug(f"Loaded gruptree table from Sumo in: {timer.elapsed_ms()}ms") + return group_tree_df + + # If no realization is specified, get all tables and merge them + table_collection = self._case.tables.filter( + tagname=GroupTreeAccess.TAGNAME, aggregation="collection", iteration=self._iteration_name + ) + + df0 = table_collection[0].to_pandas + df1 = table_collection[1].to_pandas + df2 = table_collection[2].to_pandas + + group_tree_df = pd.merge(df0, df1, left_index=True, right_index=True) + group_tree_df = pd.merge(group_tree_df, df2, left_index=True, right_index=True) + + _validate_group_tree_df(group_tree_df) + + LOGGER.debug(f"Loaded gruptree table from Sumo in: {timer.elapsed_ms()}ms") + return group_tree_df + + +def _validate_group_tree_df(df: pd.DataFrame) -> None: + expected_columns = {"DATE", "CHILD", "KEYWORD", "PARENT"} + + if not expected_columns.issubset(df.columns): + raise ValueError(f"Expected columns: {expected_columns} - got: {df.columns}") diff --git a/backend_py/primary/primary/services/sumo_access/group_tree_types.py b/backend_py/primary/primary/services/sumo_access/group_tree_types.py new file mode 100644 index 000000000..df8e396fd --- /dev/null +++ b/backend_py/primary/primary/services/sumo_access/group_tree_types.py @@ -0,0 +1,76 @@ +from enum import StrEnum +from typing import Dict, List, Literal + +from pydantic import BaseModel + + +class GroupTreeMetadata(BaseModel): + key: str + label: str + + +class TreeNode(BaseModel): + node_type: Literal["Group", "Well"] + node_label: str + edge_label: str + node_data: Dict[str, List[float]] + edge_data: Dict[str, List[float]] + children: List["TreeNode"] + + +class DatedTree(BaseModel): + dates: List[str] + tree: TreeNode + + +class TreeType(StrEnum): + GRUPTREE = "GRUPTREE" + BRANPROP = "BRANPROP" + + +class TreeModeOptions(StrEnum): + STATISTICS = "statistics" + SINGLE_REAL = "single_real" + + +class StatOptions(StrEnum): + MEAN = "mean" + P10 = "p10" + P50 = "p50" + P90 = "p90" + MAX = "max" + MIN = "min" + + +class NodeType(StrEnum): + PROD = "prod" + INJ = "inj" + OTHER = "other" + + +class DataType(StrEnum): + OILRATE = "oilrate" + GASRATE = "gasrate" + WATERRATE = "waterrate" + WATERINJRATE = "waterinjrate" + GASINJRATE = "gasinjrate" + PRESSURE = "pressure" + BHP = "bhp" + WMCTL = "wmctl" + + +class EdgeOrNode(StrEnum): + EDGE = "edge" + NODE = "node" + + +DataTypeToStringLabelMap = { + DataType.OILRATE: "Oil Rate", + DataType.GASRATE: "Gas Rate", + DataType.WATERRATE: "Water Rate", + DataType.WATERINJRATE: "Water Inj Rate", + DataType.GASINJRATE: "Gas Inj Rate", + DataType.PRESSURE: "Pressure", + DataType.BHP: "BHP", + DataType.WMCTL: "WMCTL", +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e23ee59dd..2584d33c1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@tanstack/react-query": "^5.0.5", "@tanstack/react-query-devtools": "^5.4.2", "@types/geojson": "^7946.0.14", + "@webviz/group-tree-plot": "^1.1.14", "@webviz/subsurface-viewer": "^0.21.0", "@webviz/well-completions-plot": "^0.0.1-alpha.1", "animate.css": "^4.1.1", @@ -5011,6 +5012,19 @@ "pako": "^1.0.10" } }, + "node_modules/@webviz/group-tree-plot": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@webviz/group-tree-plot/-/group-tree-plot-1.1.14.tgz", + "integrity": "sha512-3N5lhuQWn/lBvg0jQSN31Jp7hPjMhIygTYkCLGpFKGTZgPUB6RvNLKnl31EZJhFsQf9xhjJg/XfI5WifrTs+qw==", + "dependencies": { + "d3": "^7.8.2", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^17 || ^18", + "react-dom": "^17 || ^18" + } + }, "node_modules/@webviz/subsurface-viewer": { "version": "0.21.0", "resolved": "https://registry.npmjs.org/@webviz/subsurface-viewer/-/subsurface-viewer-0.21.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8ac0a6567..6078437c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@tanstack/react-query": "^5.0.5", "@tanstack/react-query-devtools": "^5.4.2", "@types/geojson": "^7946.0.14", + "@webviz/group-tree-plot": "^1.1.14", "@webviz/subsurface-viewer": "^0.21.0", "@webviz/well-completions-plot": "^0.0.1-alpha.1", "animate.css": "^4.1.1", diff --git a/frontend/src/api/ApiService.ts b/frontend/src/api/ApiService.ts index 9f9a1a29a..94a645d4c 100644 --- a/frontend/src/api/ApiService.ts +++ b/frontend/src/api/ApiService.ts @@ -9,6 +9,7 @@ import { DefaultService } from './services/DefaultService'; import { ExploreService } from './services/ExploreService'; import { GraphService } from './services/GraphService'; import { Grid3DService } from './services/Grid3DService'; +import { GroupTreeService } from './services/GroupTreeService'; import { InplaceVolumetricsService } from './services/InplaceVolumetricsService'; import { ObservationsService } from './services/ObservationsService'; import { ParametersService } from './services/ParametersService'; @@ -26,6 +27,7 @@ export class ApiService { public readonly explore: ExploreService; public readonly graph: GraphService; public readonly grid3D: Grid3DService; + public readonly groupTree: GroupTreeService; public readonly inplaceVolumetrics: InplaceVolumetricsService; public readonly observations: ObservationsService; public readonly parameters: ParametersService; @@ -54,6 +56,7 @@ export class ApiService { this.explore = new ExploreService(this.request); this.graph = new GraphService(this.request); this.grid3D = new Grid3DService(this.request); + this.groupTree = new GroupTreeService(this.request); this.inplaceVolumetrics = new InplaceVolumetricsService(this.request); this.observations = new ObservationsService(this.request); this.parameters = new ParametersService(this.request); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 9c1ddd232..69ae276ba 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -20,6 +20,7 @@ export type { Body_post_sample_surface_in_points as Body_post_sample_surface_in_ export type { BoundingBox3d as BoundingBox3d_api } from './models/BoundingBox3d'; export type { CaseInfo as CaseInfo_api } from './models/CaseInfo'; export type { Completions as Completions_api } from './models/Completions'; +export type { DatedTree as DatedTree_api } from './models/DatedTree'; export type { EnsembleDetails as EnsembleDetails_api } from './models/EnsembleDetails'; export type { EnsembleInfo as EnsembleInfo_api } from './models/EnsembleInfo'; export type { EnsembleParameter as EnsembleParameter_api } from './models/EnsembleParameter'; @@ -40,9 +41,12 @@ export type { Grid3dZone as Grid3dZone_api } from './models/Grid3dZone'; export type { GridDimensions as GridDimensions_api } from './models/GridDimensions'; export type { GridIntersectionVtk as GridIntersectionVtk_api } from './models/GridIntersectionVtk'; export type { GridSurfaceVtk as GridSurfaceVtk_api } from './models/GridSurfaceVtk'; +export type { GroupTreeData as GroupTreeData_api } from './models/GroupTreeData'; +export type { GroupTreeMetadata as GroupTreeMetadata_api } from './models/GroupTreeMetadata'; export type { HTTPValidationError as HTTPValidationError_api } from './models/HTTPValidationError'; export type { InplaceVolumetricsCategoricalMetaData as InplaceVolumetricsCategoricalMetaData_api } from './models/InplaceVolumetricsCategoricalMetaData'; export type { InplaceVolumetricsTableMetaData as InplaceVolumetricsTableMetaData_api } from './models/InplaceVolumetricsTableMetaData'; +export { NodeType as NodeType_api } from './models/NodeType'; export type { Observations as Observations_api } from './models/Observations'; export type { PointSetXY as PointSetXY_api } from './models/PointSetXY'; export type { PolygonData as PolygonData_api } from './models/PolygonData'; @@ -71,6 +75,7 @@ export type { SurfaceIntersectionData as SurfaceIntersectionData_api } from './m export type { SurfaceMeta as SurfaceMeta_api } from './models/SurfaceMeta'; export type { SurfaceRealizationSampleValues as SurfaceRealizationSampleValues_api } from './models/SurfaceRealizationSampleValues'; export { SurfaceStatisticFunction as SurfaceStatisticFunction_api } from './models/SurfaceStatisticFunction'; +export { TreeNode as TreeNode_api } from './models/TreeNode'; export type { UserInfo as UserInfo_api } from './models/UserInfo'; export type { ValidationError as ValidationError_api } from './models/ValidationError'; export type { VectorDescription as VectorDescription_api } from './models/VectorDescription'; @@ -92,6 +97,7 @@ export { DefaultService } from './services/DefaultService'; export { ExploreService } from './services/ExploreService'; export { GraphService } from './services/GraphService'; export { Grid3DService } from './services/Grid3DService'; +export { GroupTreeService } from './services/GroupTreeService'; export { InplaceVolumetricsService } from './services/InplaceVolumetricsService'; export { ObservationsService } from './services/ObservationsService'; export { ParametersService } from './services/ParametersService'; diff --git a/frontend/src/api/models/DatedTree.ts b/frontend/src/api/models/DatedTree.ts new file mode 100644 index 000000000..f7ca2b7d6 --- /dev/null +++ b/frontend/src/api/models/DatedTree.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { TreeNode } from './TreeNode'; +export type DatedTree = { + dates: Array; + tree: TreeNode; +}; + diff --git a/frontend/src/api/models/GroupTreeData.ts b/frontend/src/api/models/GroupTreeData.ts new file mode 100644 index 000000000..ba8941905 --- /dev/null +++ b/frontend/src/api/models/GroupTreeData.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { DatedTree } from './DatedTree'; +import type { GroupTreeMetadata } from './GroupTreeMetadata'; +export type GroupTreeData = { + edge_metadata_list: Array; + node_metadata_list: Array; + dated_trees: Array; +}; + diff --git a/frontend/src/api/models/GroupTreeMetadata.ts b/frontend/src/api/models/GroupTreeMetadata.ts new file mode 100644 index 000000000..449561747 --- /dev/null +++ b/frontend/src/api/models/GroupTreeMetadata.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type GroupTreeMetadata = { + key: string; + label: string; +}; + diff --git a/frontend/src/api/models/NodeType.ts b/frontend/src/api/models/NodeType.ts new file mode 100644 index 000000000..42a1260bc --- /dev/null +++ b/frontend/src/api/models/NodeType.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export enum NodeType { + PROD = 'prod', + INJ = 'inj', + OTHER = 'other', +} diff --git a/frontend/src/api/models/TreeNode.ts b/frontend/src/api/models/TreeNode.ts new file mode 100644 index 000000000..c0b5bbb02 --- /dev/null +++ b/frontend/src/api/models/TreeNode.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type TreeNode = { + node_type: TreeNode.node_type; + node_label: string; + edge_label: string; + node_data: Record>; + edge_data: Record>; + children: Array; +}; +export namespace TreeNode { + export enum node_type { + GROUP = 'Group', + WELL = 'Well', + } +} + diff --git a/frontend/src/api/services/GroupTreeService.ts b/frontend/src/api/services/GroupTreeService.ts new file mode 100644 index 000000000..a5f08cc67 --- /dev/null +++ b/frontend/src/api/services/GroupTreeService.ts @@ -0,0 +1,44 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Frequency } from '../models/Frequency'; +import type { GroupTreeData } from '../models/GroupTreeData'; +import type { NodeType } from '../models/NodeType'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import type { BaseHttpRequest } from '../core/BaseHttpRequest'; +export class GroupTreeService { + constructor(public readonly httpRequest: BaseHttpRequest) {} + /** + * Get Realization Group Tree Data + * @param caseUuid Sumo case uuid + * @param ensembleName Ensemble name + * @param realization Realization + * @param resamplingFrequency Resampling frequency + * @param nodeTypeSet Node types + * @returns GroupTreeData Successful Response + * @throws ApiError + */ + public getRealizationGroupTreeData( + caseUuid: string, + ensembleName: string, + realization: number, + resamplingFrequency: Frequency, + nodeTypeSet: Array, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/group_tree/realization_group_tree_data/', + query: { + 'case_uuid': caseUuid, + 'ensemble_name': ensembleName, + 'realization': realization, + 'resampling_frequency': resamplingFrequency, + 'node_type_set': nodeTypeSet, + }, + errors: { + 422: `Validation Error`, + }, + }); + } +} diff --git a/frontend/src/framework/ModuleDataTags.ts b/frontend/src/framework/ModuleDataTags.ts index 15024f876..0fdbd01aa 100644 --- a/frontend/src/framework/ModuleDataTags.ts +++ b/frontend/src/framework/ModuleDataTags.ts @@ -1,6 +1,7 @@ export enum ModuleDataTagId { SURFACE = "surface", GRID3D = "grid3d", + GROUP_TREE = "group-tree", DRILLED_WELLS = "drilled-wells", SUMMARY = "summary", INPLACE_VOLUMETRICS = "inplace-volumetrics", @@ -28,6 +29,11 @@ export const ModuleDataTags: ModuleDataTag[] = [ name: "3D grid model", description: "3D grid model", }, + { + id: ModuleDataTagId.GROUP_TREE, + name: "Group tree", + description: "Group tree data", + }, { id: ModuleDataTagId.DRILLED_WELLS, name: "Drilled wells", diff --git a/frontend/src/modules/GroupTree/loadModule.tsx b/frontend/src/modules/GroupTree/loadModule.tsx new file mode 100644 index 000000000..f9f54611c --- /dev/null +++ b/frontend/src/modules/GroupTree/loadModule.tsx @@ -0,0 +1,13 @@ +import { ModuleRegistry } from "@framework/ModuleRegistry"; + +import { MODULE_NAME } from "./registerModule"; +import { Settings } from "./settings/settings"; +import { Interface, State, interfaceInitialization } from "./settingsToViewInterface"; +import { View } from "./view"; + +const defaultState: State = {}; + +const module = ModuleRegistry.initModule(MODULE_NAME, defaultState, {}, interfaceInitialization); + +module.viewFC = View; +module.settingsFC = Settings; diff --git a/frontend/src/modules/GroupTree/preview.svg b/frontend/src/modules/GroupTree/preview.svg new file mode 100644 index 000000000..b560205ab --- /dev/null +++ b/frontend/src/modules/GroupTree/preview.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/modules/GroupTree/preview.tsx b/frontend/src/modules/GroupTree/preview.tsx new file mode 100644 index 000000000..6ba8d9cf8 --- /dev/null +++ b/frontend/src/modules/GroupTree/preview.tsx @@ -0,0 +1,7 @@ +import { DrawPreviewFunc } from "@framework/Preview"; + +import previewImg from "./preview.svg"; + +export const preview: DrawPreviewFunc = function (width: number, height: number) { + return ; +}; diff --git a/frontend/src/modules/GroupTree/registerModule.ts b/frontend/src/modules/GroupTree/registerModule.ts new file mode 100644 index 000000000..070aef5e5 --- /dev/null +++ b/frontend/src/modules/GroupTree/registerModule.ts @@ -0,0 +1,20 @@ +import { ModuleCategory, ModuleDevState } from "@framework/Module"; +import { ModuleDataTagId } from "@framework/ModuleDataTags"; +import { ModuleRegistry } from "@framework/ModuleRegistry"; + +import { preview } from "./preview"; +import { Interface, State } from "./settingsToViewInterface"; + +export const MODULE_NAME = "GroupTree"; + +const description = "Visualizes dated group trees over time."; + +ModuleRegistry.registerModule({ + moduleName: MODULE_NAME, + defaultTitle: "Group Tree", + category: ModuleCategory.MAIN, + devState: ModuleDevState.DEV, + dataTagIds: [ModuleDataTagId.GROUP_TREE, ModuleDataTagId.SUMMARY], + preview, + description, +}); diff --git a/frontend/src/modules/GroupTree/settings/atoms/baseAtoms.ts b/frontend/src/modules/GroupTree/settings/atoms/baseAtoms.ts new file mode 100644 index 000000000..aca1a3da5 --- /dev/null +++ b/frontend/src/modules/GroupTree/settings/atoms/baseAtoms.ts @@ -0,0 +1,30 @@ +import { Frequency_api, NodeType_api } from "@api"; +import { EnsembleIdent } from "@framework/EnsembleIdent"; +import { atomWithCompare } from "@framework/utils/atomUtils"; + +import { atom } from "jotai"; + +function areEnsembleIdentsEqual(a: EnsembleIdent | null, b: EnsembleIdent | null) { + if (a === null) { + return b === null; + } + return a.equals(b); +} + +export const selectedResamplingFrequencyAtom = atom(Frequency_api.YEARLY); + +export const selectedNodeTypesAtom = atom>( + new Set([NodeType_api.INJ, NodeType_api.PROD, NodeType_api.OTHER]) +); + +export const userSelectedDateTimeAtom = atom(null); + +export const userSelectedRealizationNumberAtom = atom(null); + +export const validRealizationNumbersAtom = atom(null); + +export const userSelectedEnsembleIdentAtom = atomWithCompare(null, areEnsembleIdentsEqual); + +export const userSelectedEdgeKeyAtom = atom(null); + +export const userSelectedNodeKeyAtom = atom(null); diff --git a/frontend/src/modules/GroupTree/settings/atoms/derivedAtoms.ts b/frontend/src/modules/GroupTree/settings/atoms/derivedAtoms.ts new file mode 100644 index 000000000..3b3d83102 --- /dev/null +++ b/frontend/src/modules/GroupTree/settings/atoms/derivedAtoms.ts @@ -0,0 +1,143 @@ +import { EnsembleIdent } from "@framework/EnsembleIdent"; +import { EnsembleSetAtom } from "@framework/GlobalAtoms"; +import { fixupEnsembleIdent } from "@framework/utils/ensembleUiHelpers"; +import { DatedTree, EdgeMetadata, NodeMetadata } from "@webviz/group-tree-plot"; + +import { atom } from "jotai"; + +import { + userSelectedDateTimeAtom, + userSelectedEdgeKeyAtom, + userSelectedEnsembleIdentAtom, + userSelectedNodeKeyAtom, + userSelectedRealizationNumberAtom, + validRealizationNumbersAtom, +} from "./baseAtoms"; +import { realizationGroupTreeQueryAtom } from "./queryAtoms"; + +import { QueryStatus } from "../../types"; + +export const groupTreeQueryResultAtom = atom((get) => { + return get(realizationGroupTreeQueryAtom); +}); + +export const selectedEnsembleIdentAtom = atom((get) => { + const ensembleSet = get(EnsembleSetAtom); + const userSelectedEnsembleIdent = get(userSelectedEnsembleIdentAtom); + + const validEnsembleIdent = fixupEnsembleIdent(userSelectedEnsembleIdent, ensembleSet); + return validEnsembleIdent; +}); + +export const selectedRealizationNumberAtom = atom((get) => { + const userSelectedRealizationNumber = get(userSelectedRealizationNumberAtom); + const validRealizationNumbers = get(validRealizationNumbersAtom); + + if (!validRealizationNumbers) { + return null; + } + + if (userSelectedRealizationNumber === null) { + const firstRealization = validRealizationNumbers.length > 0 ? validRealizationNumbers[0] : null; + return firstRealization; + } + + const validRealizationNumber = validRealizationNumbers.includes(userSelectedRealizationNumber) + ? userSelectedRealizationNumber + : null; + return validRealizationNumber; +}); + +export const queryStatusAtom = atom((get) => { + const groupTreeQueryResult = get(groupTreeQueryResultAtom); + + if (groupTreeQueryResult.isFetching) { + return QueryStatus.Loading; + } + if (groupTreeQueryResult.isError) { + return QueryStatus.Error; + } + return QueryStatus.Idle; +}); + +export const availableDateTimesAtom = atom((get) => { + const groupTreeQueryResult = get(groupTreeQueryResultAtom); + + if (!groupTreeQueryResult.data) return []; + + const dateTimes = new Set(); + groupTreeQueryResult.data.dated_trees.forEach((datedTree) => { + datedTree.dates.forEach((date) => { + dateTimes.add(date); + }); + }); + + return Array.from(dateTimes); +}); + +export const selectedDateTimeAtom = atom((get) => { + const availableDateTimes = get(availableDateTimesAtom); + const userSelectedDateTime = get(userSelectedDateTimeAtom); + + if (availableDateTimes.length === 0) { + return null; + } + if (!userSelectedDateTime || !availableDateTimes.includes(userSelectedDateTime)) { + return availableDateTimes[0]; + } + + return userSelectedDateTime; +}); + +export const availableEdgeKeysAtom = atom((get) => { + const groupTreeQueryResult = get(groupTreeQueryResultAtom); + return groupTreeQueryResult.data?.edge_metadata_list.map((item) => item.key) ?? []; +}); + +export const selectedEdgeKeyAtom = atom((get) => { + const availableEdgeKeys = get(availableEdgeKeysAtom); + const userSelectedEdgeKey = get(userSelectedEdgeKeyAtom); + + if (availableEdgeKeys.length === 0) { + return null; + } + if (!userSelectedEdgeKey || !availableEdgeKeys.includes(userSelectedEdgeKey)) { + return availableEdgeKeys[0]; + } + + return userSelectedEdgeKey; +}); + +export const availableNodeKeysAtom = atom((get) => { + const groupTreeQueryResult = get(groupTreeQueryResultAtom); + return groupTreeQueryResult.data?.node_metadata_list.map((item) => item.key) ?? []; +}); + +export const selectedNodeKeyAtom = atom((get) => { + const availableNodeKeys = get(availableNodeKeysAtom); + const userSelectedNodeKey = get(userSelectedNodeKeyAtom); + + if (availableNodeKeys.length === 0) { + return null; + } + if (!userSelectedNodeKey || !availableNodeKeys.includes(userSelectedNodeKey)) { + return availableNodeKeys[0]; + } + + return userSelectedNodeKey; +}); + +export const edgeMetadataListAtom = atom((get) => { + const groupTreeQueryResult = get(groupTreeQueryResultAtom); + return groupTreeQueryResult.data?.edge_metadata_list ?? []; +}); + +export const nodeMetadataListAtom = atom((get) => { + const groupTreeQueryResult = get(groupTreeQueryResultAtom); + return groupTreeQueryResult.data?.node_metadata_list ?? []; +}); + +export const datedTreesAtom = atom((get) => { + const groupTreeQueryResult = get(groupTreeQueryResultAtom); + return groupTreeQueryResult.data?.dated_trees ?? []; +}); diff --git a/frontend/src/modules/GroupTree/settings/atoms/queryAtoms.ts b/frontend/src/modules/GroupTree/settings/atoms/queryAtoms.ts new file mode 100644 index 000000000..b23a22f06 --- /dev/null +++ b/frontend/src/modules/GroupTree/settings/atoms/queryAtoms.ts @@ -0,0 +1,44 @@ +import { apiService } from "@framework/ApiService"; + +import { atomWithQuery } from "jotai-tanstack-query"; + +import { selectedNodeTypesAtom, selectedResamplingFrequencyAtom } from "./baseAtoms"; +import { selectedEnsembleIdentAtom, selectedRealizationNumberAtom } from "./derivedAtoms"; + +const STALE_TIME = 60 * 1000; +const CACHE_TIME = 60 * 1000; + +export const realizationGroupTreeQueryAtom = atomWithQuery((get) => { + const selectedEnsembleIdent = get(selectedEnsembleIdentAtom); + const selectedRealizationNumber = get(selectedRealizationNumberAtom); + const selectedResamplingFrequency = get(selectedResamplingFrequencyAtom); + const selectedNodeTypes = get(selectedNodeTypesAtom); + + const query = { + queryKey: [ + "getGroupTreeData", + selectedEnsembleIdent?.getCaseUuid(), + selectedEnsembleIdent?.getEnsembleName(), + selectedRealizationNumber, + selectedResamplingFrequency, + Array.from(selectedNodeTypes), + ], + queryFn: () => + apiService.groupTree.getRealizationGroupTreeData( + selectedEnsembleIdent?.getCaseUuid() ?? "", + selectedEnsembleIdent?.getEnsembleName() ?? "", + selectedRealizationNumber ?? 0, + selectedResamplingFrequency, + Array.from(selectedNodeTypes) + ), + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + enabled: !!( + selectedEnsembleIdent?.getCaseUuid() && + selectedEnsembleIdent?.getEnsembleName() && + selectedRealizationNumber !== null && + selectedNodeTypes.size > 0 + ), + }; + return query; +}); diff --git a/frontend/src/modules/GroupTree/settings/settings.tsx b/frontend/src/modules/GroupTree/settings/settings.tsx new file mode 100644 index 000000000..04ec2ad17 --- /dev/null +++ b/frontend/src/modules/GroupTree/settings/settings.tsx @@ -0,0 +1,221 @@ +import React from "react"; + +import { Frequency_api, NodeType_api } from "@api"; +import { EnsembleIdent } from "@framework/EnsembleIdent"; +import { ModuleSettingsProps } from "@framework/Module"; +import { useEnsembleRealizationFilterFunc, useEnsembleSet } from "@framework/WorkbenchSession"; +import { EnsembleDropdown } from "@framework/components/EnsembleDropdown"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; +import { DiscreteSlider } from "@lib/components/DiscreteSlider"; +import { Dropdown } from "@lib/components/Dropdown"; +import { Label } from "@lib/components/Label"; +import { QueryStateWrapper } from "@lib/components/QueryStateWrapper"; +import { Select } from "@lib/components/Select"; + +import { useAtom, useAtomValue, useSetAtom } from "jotai"; + +import { + selectedNodeTypesAtom, + selectedResamplingFrequencyAtom, + userSelectedDateTimeAtom, + userSelectedEdgeKeyAtom, + userSelectedEnsembleIdentAtom, + userSelectedNodeKeyAtom, + userSelectedRealizationNumberAtom, + validRealizationNumbersAtom, +} from "./atoms/baseAtoms"; +import { + availableDateTimesAtom, + availableEdgeKeysAtom, + availableNodeKeysAtom, + groupTreeQueryResultAtom, + selectedDateTimeAtom, + selectedEdgeKeyAtom, + selectedEnsembleIdentAtom, + selectedNodeKeyAtom, + selectedRealizationNumberAtom, +} from "./atoms/derivedAtoms"; + +import { Interface, State } from "../settingsToViewInterface"; +import { FrequencyEnumToStringMapping, NodeTypeEnumToStringMapping } from "../types"; + +export function Settings({ workbenchSession }: ModuleSettingsProps) { + const ensembleSet = useEnsembleSet(workbenchSession); + + const availableDateTimes = useAtomValue(availableDateTimesAtom); + const availableEdgeKeys = useAtomValue(availableEdgeKeysAtom); + const availableNodeKeys = useAtomValue(availableNodeKeysAtom); + + const [selectedResamplingFrequency, setSelectedResamplingFrequency] = useAtom(selectedResamplingFrequencyAtom); + const [selectedNodeTypes, setSelectedNodeTypes] = useAtom(selectedNodeTypesAtom); + + const selectedEdgeKey = useAtomValue(selectedEdgeKeyAtom); + const setUserSelectedEdgeKey = useSetAtom(userSelectedEdgeKeyAtom); + + const selectedNodeKey = useAtomValue(selectedNodeKeyAtom); + const setUserSelectedNodeKey = useSetAtom(userSelectedNodeKeyAtom); + + const selectedEnsembleIdent = useAtomValue(selectedEnsembleIdentAtom); + const setUserSelectedEnsembleIdent = useSetAtom(userSelectedEnsembleIdentAtom); + + const selectedRealizationNumber = useAtomValue(selectedRealizationNumberAtom); + const setUserSelectedRealizationNumber = useSetAtom(userSelectedRealizationNumberAtom); + + const selectedDateTime = useAtomValue(selectedDateTimeAtom); + const setUserSelectedDateTime = useSetAtom(userSelectedDateTimeAtom); + + const groupTreeQueryResult = useAtomValue(groupTreeQueryResultAtom); + + const setValidRealizationNumbersAtom = useSetAtom(validRealizationNumbersAtom); + const filterEnsembleRealizationsFunc = useEnsembleRealizationFilterFunc(workbenchSession); + const validRealizations = selectedEnsembleIdent ? [...filterEnsembleRealizationsFunc(selectedEnsembleIdent)] : null; + setValidRealizationNumbersAtom(validRealizations); + + const timeStepSliderDebounceTimeMs = 10; + const timeStepSliderDebounceTimerRef = React.useRef | null>(null); + + React.useEffect(() => { + if (timeStepSliderDebounceTimerRef.current) { + clearTimeout(timeStepSliderDebounceTimerRef.current); + } + }); + + function handleSelectedNodeKeyChange(value: string) { + setUserSelectedNodeKey(value); + } + + function handleSelectedEdgeKeyChange(value: string) { + setUserSelectedEdgeKey(value); + } + + function handleEnsembleSelectionChange(ensembleIdent: EnsembleIdent | null) { + setUserSelectedEnsembleIdent(ensembleIdent); + } + + function handleFrequencySelectionChange(newFrequencyStr: string) { + const newFreq = newFrequencyStr as Frequency_api; + setSelectedResamplingFrequency(newFreq); + } + + function handleRealizationNumberChange(value: string) { + const realizationNumber = parseInt(value); + setUserSelectedRealizationNumber(realizationNumber); + } + + function handleSelectedTimeStepIndexChange(value: number | number[]) { + const singleValue = typeof value === "number" ? value : value.length > 0 ? value[0] : 0; + const validIndex = singleValue >= 0 && singleValue < availableDateTimes.length ? singleValue : null; + const newDateTime = validIndex !== null ? availableDateTimes[validIndex] : null; + + if (timeStepSliderDebounceTimerRef.current) { + clearTimeout(timeStepSliderDebounceTimerRef.current); + } + + timeStepSliderDebounceTimerRef.current = setTimeout(() => { + setUserSelectedDateTime(newDateTime); + }, timeStepSliderDebounceTimeMs); + } + + function handleSelectedNodeTypesChange(values: string[]) { + const newNodeTypes = new Set(values.map((val) => val as NodeType_api)); + setSelectedNodeTypes(newNodeTypes); + } + + const createValueLabelFormat = React.useCallback( + function createValueLabelFormat(value: number): string { + if (!availableDateTimes || availableDateTimes.length === 0 || value >= availableDateTimes.length) return ""; + + return availableDateTimes[value]; + }, + [availableDateTimes] + ); + + const selectedDateTimeIndex = selectedDateTime ? availableDateTimes.indexOf(selectedDateTime) : -1; + + return ( +
+ + + + + { + return { value: val, label: FrequencyEnumToStringMapping[val] }; + })} + value={selectedResamplingFrequency} + onChange={handleFrequencySelectionChange} + /> + + +
+ +