diff --git a/geos-trame/src/geos_trame/app/__main__.py b/geos-trame/src/geos_trame/app/__main__.py index 149985a85..238394aee 100644 --- a/geos-trame/src/geos_trame/app/__main__.py +++ b/geos-trame/src/geos_trame/app/__main__.py @@ -33,3 +33,7 @@ def main( server=None, **kwargs ): app = GeosTrame( server, file_name ) app.server.start( **kwargs ) + + +if __name__ == "__main__": + main() diff --git a/geos-trame/src/geos_trame/app/core.py b/geos-trame/src/geos_trame/app/core.py index 4b1b77d21..a059c62cc 100644 --- a/geos-trame/src/geos_trame/app/core.py +++ b/geos-trame/src/geos_trame/app/core.py @@ -13,7 +13,8 @@ from geos_trame.app.ui.inspector import DeckInspector from geos_trame.app.ui.plotting import DeckPlotting from geos_trame.app.ui.timeline import TimelineEditor -from geos_trame.app.ui.viewer import DeckViewer +from geos_trame.app.ui.viewer.viewer import DeckViewer +from geos_trame.app.ui.alertHandler import AlertHandler import sys @@ -114,6 +115,8 @@ def build_ui( self, *args, **kwargs ): with VAppLayout( self.server ) as layout: self.simput_widget.register_layout( layout ) + self.alertHandler = AlertHandler() + def on_tab_change( tab_idx ): pass @@ -174,8 +177,11 @@ def on_tab_change( tab_idx ): if self.tree.input_file is not None: self.deck_ui() else: - + self.ctrl.on_add_error( + "Error", + "The file " + self.state.input_file + " cannot be parsed.", + ) print( - "Cannot build ui as the input file cannot be parse.", + "The file " + self.state.input_file + " cannot be parsed.", file=sys.stderr, ) diff --git a/geos-trame/src/geos_trame/app/deck/file.py b/geos-trame/src/geos_trame/app/deck/file.py index dc1308e40..3840aa534 100644 --- a/geos-trame/src/geos_trame/app/deck/file.py +++ b/geos-trame/src/geos_trame/app/deck/file.py @@ -221,6 +221,7 @@ def _build_inspect_tree_inner( key, obj, path ) -> dict: "VTKMesh", "InternalMesh", "InternalWell", + "VTKWell", "Perforation", ] sub_node[ "drawn" ] = False diff --git a/geos-trame/src/geos_trame/app/deck/tree.py b/geos-trame/src/geos_trame/app/deck/tree.py index 0513197db..708bf34b5 100644 --- a/geos-trame/src/geos_trame/app/deck/tree.py +++ b/geos-trame/src/geos_trame/app/deck/tree.py @@ -160,10 +160,12 @@ def write_files( self ): files = self._split( pb ) for filepath, content in files.items(): - includeName: str = self.input_file.xml_parser.file_to_relative_path[ filepath ] model_loaded: BaseModel = self.decode_data( content ) model_with_changes: BaseModel = self._apply_changed_properties( model_loaded ) - self._append_include_file( model_with_changes, includeName ) + + if self.input_file.xml_parser.contains_include_files(): + includeName: str = self.input_file.xml_parser.get_relative_path_of_file( filepath ) + self._append_include_file( model_with_changes, includeName ) model_as_xml: str = self.to_xml( model_with_changes ) diff --git a/geos-trame/src/geos_trame/app/io/xml_parser.py b/geos-trame/src/geos_trame/app/io/xml_parser.py index 7f23d4919..a5e9a5958 100644 --- a/geos-trame/src/geos_trame/app/io/xml_parser.py +++ b/geos-trame/src/geos_trame/app/io/xml_parser.py @@ -61,6 +61,18 @@ def get_simulation_deck( self ) -> ElementTree.Element: return return self.simulation_deck + def contains_include_files( self ) -> bool: + """ + Return True if the parsed file contains included file or not. + """ + return len( self.file_to_relative_path ) > 0 + + def get_relative_path_of_file( self, filename: str ) -> str: + """ + Return the relative path of a given filename. + """ + return self.file_to_relative_path[ filename ] + def _read( self ) -> ElementTree.Element: """Reads an xml file (and recursively its included files) into memory diff --git a/geos-trame/src/geos_trame/app/ui/alertHandler.py b/geos-trame/src/geos_trame/app/ui/alertHandler.py new file mode 100644 index 000000000..b3d416468 --- /dev/null +++ b/geos-trame/src/geos_trame/app/ui/alertHandler.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Lucas Givord - Kitware +import asyncio + +from trame.widgets import vuetify3 + + +class AlertHandler( vuetify3.VContainer ): + """ + Vuetify component used to display an alert status. + + This alert will be displayed in the bottom right corner of the screen. + It will be displayed until closed by the user or after 10 seconds if it is a success or warning. + """ + + def __init__( self ): + super().__init__( + fluid=True, + classes="pa-0 ma-0", + ) + + self.__max_number_of_status = 5 + self.__lifetime_of_alert = 10.0 + self._status_id = 0 + + self.state.alerts = [] + + self.server.controller.on_add_error.add_task( self.add_error ) + self.server.controller.on_add_warning.add_task( self.add_warning ) + + self.generate_alert_ui() + + def generate_alert_ui( self ): + """ + Generate the alert UI. + + The alert will be displayed in the bottom right corner of the screen. + + Use an abritary z-index value to put the alert on top of the other components. + """ + with self: + with vuetify3.VCol( style="width: 40%; position: fixed; right: 50px; bottom: 50px; z-index: 100;", ): + vuetify3.VAlert( + style="max-height: 20vh; overflow-y: auto", + classes="ma-2", + v_for=( "(status, index) in alerts", ), + key="status", + type=( "status.type", "info" ), + text=( "status.message", "" ), + title=( "status.title", "" ), + closable=True, + click_close=( self.on_close, f"[status.id]" ), + ) + + def add_alert( self, type: str, title: str, message: str ): + """ + Add a status to the stack with a unique id. + If there are more than 5 alerts displayed, remove the oldest. + A warning will be automatically closed after 10 seconds. + """ + self.state.alerts.append( { + "id": self._status_id, + "type": type, + "title": title, + "message": message, + } ) + + if len( self.state.alerts ) > self.__max_number_of_status: + self.state.alerts.pop( 0 ) + + alert_id = self._status_id + self._status_id += 1 + self.state.dirty( "alerts" ) + self.state.flush() + + if type == "warning": + asyncio.get_event_loop().call_later( self.__lifetime_of_alert, self.on_close, alert_id ) + + async def add_warning( self, title: str, message: str ): + """ + Add an alert of type "warning" + """ + self.add_alert( "warning", title, message ) + + async def add_error( self, title: str, message: str ): + """ + Add an alert of type "error" + """ + self.add_alert( "error", title, message ) + + def on_close( self, alert_id ): + """ + Remove in the state the alert associated to the given id. + """ + self.state.alerts = list( filter( lambda i: i[ "id" ] != alert_id, self.state.alerts ) ) + self.state.flush() diff --git a/geos-trame/src/geos_trame/app/ui/inspector.py b/geos-trame/src/geos_trame/app/ui/inspector.py index 4f4f348c2..309ba0994 100644 --- a/geos-trame/src/geos_trame/app/ui/inspector.py +++ b/geos-trame/src/geos_trame/app/ui/inspector.py @@ -7,6 +7,7 @@ import yaml from pydantic import BaseModel from trame.widgets import vuetify3 as vuetify +from trame.widgets import html from trame_simput import get_simput_manager from typing import Any @@ -15,6 +16,7 @@ class Renderable( Enum ): VTKMESH = "VTKMesh" INTERNALMESH = "InternalMesh" INTERNALWELL = "InternalWell" + VTKWELL = "VTKWell" PERFORATION = "Perforation" @@ -100,7 +102,7 @@ def get_node_dict( obj, node_id, path ): title=node_name, children=children if len( children ) else [], hidden_children=[], - is_drawable=node_id in ( k for k in Renderable ), + is_drawable=node_id in ( k.value for k in Renderable ), drawn=False, ) @@ -180,20 +182,15 @@ def __init__( self, listen_to_active=True, source=None, **kwargs ): **{ # style "hoverable": True, + "max_width": 500, "rounded": True, - # "dense": True, - # "density": "compact", - # "active_color": "blue", # activation logic - # "activatable": True, - # "active_strategy": "single-independent", - # "activated": ("active_ids", ), - # "update_activated": "(active_ids) => {active_id = active_ids[0]}", + "activatable": True, + "activated": ( "active_ids", ), + "active_strategy": "single-independent", + "update_activated": ( self.change_current_id, "$event" ), # selection logic - "selectable": True, - "select_strategy": "single-independent", - "selected": ( "active_ids", ), - "update_selected": "(active_ids) => {active_id = active_ids[0]}", + "selectable": False, **kwargs, }, ) @@ -201,7 +198,7 @@ def __init__( self, listen_to_active=True, source=None, **kwargs ): self._source = None self.listen_to_active = listen_to_active - self.state.obj_path = "" + self.state.object_state = [ "", False ] # register used types from Problem self.simput_types = [] @@ -225,18 +222,17 @@ def on_change( topic, ids=None, **kwargs ): with self: with vuetify.Template( v_slot_append="{ item }" ): - with vuetify.VBtn( - v_if=( "item.is_drawable" ), - icon=True, - flat=True, - slim=True, - input_value=( "item.drawn" ), - click=( self.to_draw_change, "[item.id]" ), - ): - vuetify.VIcon( - "{{ ((item.drawn)) ? 'mdi-eye' : 'mdi-eye-off' }}", - v_if=( "item.is_drawable" ), - ) + vuetify.VCheckboxBtn( v_if="item.is_drawable", + focused=True, + dense=True, + hide_details=True, + icon=True, + false_icon="mdi-eye-off", + true_icon="mdi-eye", + update_modelValue=( self.to_draw_change, "[ item, item.id, $event ] " ) ) + + def to_draw_change( self, item, item_id, drawn ): + self.state.object_state = [ item_id, drawn ] @property def source( self ): @@ -298,13 +294,14 @@ def set_source( self, v ): debug.set_property( key, getattr( active_block, key ) ) debug.commit() - def to_draw_change( self, path ): - self.state.obj_path = path - - # def on_active_change(self, **_): - # if self.listen_to_active: - # print("on_active_change") - # self.set_source_proxy(simple.GetActiveSource()) + def change_current_id( self, item_id=None ): + """ + Change the current id of the tree. + This function is called when the user click on the tree. + """ + if item_id is None: + # Silently ignore, it could occurs is the user click on the tree + # and this item is already selected + return - # def on_selection_change(self, node_active, **_): - # print("on_selection_change", node_active) + self.state.active_id = item_id diff --git a/geos-trame/src/geos_trame/app/ui/viewer.py b/geos-trame/src/geos_trame/app/ui/viewer.py deleted file mode 100644 index 29a5debfd..000000000 --- a/geos-trame/src/geos_trame/app/ui/viewer.py +++ /dev/null @@ -1,299 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. -# SPDX-FileContributor: Lionel Untereiner -import pyvista as pv -from pyvista.trame.ui import plotter_ui -from trame.widgets import html, vtk -from trame.widgets import vuetify3 as vuetify - -from geos_trame.schema_generated.schema_mod import InternalWell, Vtkmesh - -# def setup_viewer(): -# pl = pv.Plotter() - -# p.reset_camera() - -# return p.ren_vin - -# prevents launching an external window with rendering -pv.OFF_SCREEN = True - - -class RegionViewer: - - def __init__( self ) -> None: - self.input: pv.UnstructuredGrid = pv.UnstructuredGrid() - self.mesh: pv.UnstructuredGrid - - def __call__( self, normal: tuple[ float ], origin: tuple[ float ] ) -> None: - self.update_clip( normal, origin ) - - def add_mesh( self, mesh: pv.UnstructuredGrid ) -> None: - self.input = mesh # type: ignore - self.mesh = self.input.copy() # type: ignore - - def update_clip( self, normal: tuple[ float ], origin: tuple[ float ] ) -> None: - self.mesh.copy_from( self.input.clip( normal=normal, origin=origin, crinkle=True ) ) # type: ignore - - -# class WellViewer: -# def __init__(self, size: float, amplification: float) -> None: -# self.input: list[pv.PolyData] = [] -# self.tubes: list[pv.PolyData] = [] -# self.size: float = size -# self.amplification: float = amplification -# self.STARTING_VALUE: float = 5.0 - -# def __call__(self, value: float) -> None: -# self.update(value) - -# def add_mesh(self, mesh: pv.PolyData) -> None: -# self.input.append(mesh) # type: ignore -# radius = self.size * (self.STARTING_VALUE / 100) -# self.tubes.append( -# mesh.tube( -# radius=radius, n_sides=50 -# ) # .scale([1.0, 1.0, self.amplification], inplace=True) -# ) # type: ignore - -# def update(self, value: float) -> None: -# radius = self.size * (value / 100) -# for idx, m in enumerate(self.input): -# self.tubes[idx].copy_from( -# m.tube( -# radius=radius, n_sides=50 -# ) # .scale([1.0, 1.0, self.amplification], inplace=True) -# ) - -# class GeosPlotterBase: -# def add_region(self, mesh: pv.UnstructuredGrid, name: str, zscale: int = 1) -> None: -# super().add_mesh(mesh, name=name, **kwargs) - -# def add_well(self, mesh: pv.PolyData, name: str, zscale: int = 1) -> None: -# # build tube -# super().add_mesh() - -# def update_clip(self, normal: tuple[float], origin: tuple[float]) -> None: -# pass -# # for m in super().meshes: -# # m. - -# class GeosPlotter(GeosPlotterBase, pv.Plotter): -# pass - -### VIEWER - - -def button( click, icon, tooltip ): # numpydoc ignore=PR01 - """Create a vuetify button.""" - with vuetify.VTooltip( bottom=True ): - with vuetify.Template( v_slot_activator="{ on, attrs }" ): - with vuetify.VBtn( icon=True, v_bind="attrs", v_on="on", click=click ): - vuetify.VIcon( icon ) - html.Span( tooltip ) - - -def checkbox( model, icons, tooltip ): # numpydoc ignore=PR01 - """Create a vuetify checkbox.""" - with vuetify.VTooltip( bottom=True ): - with vuetify.Template( v_slot_activator="{ on, attrs }" ): - with html.Div( v_on="on", v_bind="attrs" ): - vuetify.VCheckbox( - v_model=model, - on_icon=icons[ 0 ], - off_icon=icons[ 1 ], - dense=True, - hide_details=True, - classes="my-0 py-0 ml-1", - ) - html.Span( tooltip ) - - -def spin_edit( model, tooltip ): # numpydoc ignore=PR01 - """Create a vuetify slider.""" - with vuetify.VTooltip( bottom=True ): - with vuetify.Template( v_slot_activator="{ on, attrs }" ): - with html.Div( v_on="on", v_bind="attrs" ): - vuetify.VTextField( - v_model=model, - dense=True, - hide_details=True, - classes="my-0 py-0 ml-1", - type="number", - counter="3", - # counter_value= - ) - html.Span( tooltip ) - - -class DeckViewer( vuetify.VCard ): - - def __init__( self, source, **kwargs ): - super().__init__( **kwargs ) - - self._source = source - self._pl = pv.Plotter() - - self.CUT_PLANE = f"_cut_plane_visibility" - self.ZAMPLIFICATION = f"_z_amplification" - self.server.state[ self.CUT_PLANE ] = False - self.server.state[ self.ZAMPLIFICATION ] = 1 - - self.state.change( "obj_path" )( self.add_to_3dviewer ) - - self.region_engine = RegionViewer() - - # self.well_engine = WellViewer() - - with self: - vuetify.VCardTitle( "3D View" ) - view = plotter_ui( - self._pl, - add_menu_items=self.rendering_menu_extra_items, - style="position: absolute;", - ) - self.ctrl.view_update = view.update - - @property - def plotter( self ): - return self._pl - - @property - def source( self ): - return self._source - - def rendering_menu_extra_items( self ): - self.state.change( self.CUT_PLANE )( self.on_cut_plane_visiblity_change ) - vuetify.VDivider( vertical=True, classes="mr-3" ) - # html.Span('foo', classes="mr-3") - # spin_edit( - # model=(self.ZAMPLIFICATION, 3), - # tooltip=f"Z Amplification", - # ) - - # tooltip=f"Toggle cut plane visibility ({{{{ {self.CUT_PLANE} ? 'on' : 'off' }}}})", - # with vuetify.VBtn( - # icon=True, - # # v_on="on", - # # v_bind="attrs", - # # click="(event) => {event.stopPropagation(); event.preventDefault();}", - # ): - # vuetify.VIcon( - # "mdi-plus-circle", - # ) - - def on_cut_plane_visiblity_change( self, **kwargs ): - pass - """Toggle cut plane visibility for all actors. - - Parameters - ---------- - **kwargs : dict, optional - Unused keyword arguments. - - """ - # value = kwargs[self.CUT_PLANE] - # for renderer in self.plotter.renderers: - # for actor in renderer.actors.values(): - # if isinstance(actor, pyvista.Actor): - # actor.prop.show_edges = value - # self.update() - - def add_to_3dviewer( self, obj_path, **kwargs ): - path = obj_path - - if path == "": - return - - active_block = self.source.decode( path ) - - # def update_tree(entry, search_id): - - # print("entry :", entry) - # print("search_id : ", search_id) - - # print(entry["id"]) - # if entry["id"] == search_id: - # entry["drawn"] = True - # return - - # for child in entry["children"]: - # update_tree(child, search_id) - # for child in entry["hidden_children"]: - # update_tree(child, search_id) - - # print(self.server.state.deck_tree[0]) - # update_tree(self.server.state.deck_tree, path) - # self.server.state.dirty("deck_tree") - - if isinstance( active_block, Vtkmesh ): - self.region_engine.add_mesh( pv.read( self.source.get_abs_path( active_block.file ) ) ) - self.plotter.add_mesh_clip_plane( - self.region_engine.mesh, - origin=self.region_engine.mesh.center, - normal=[ -1, 0, 0 ], - crinkle=True, - show_edges=False, - cmap="glasbey_bw", - # cmap=cmap, - # clim=clim, - # categories=True, - scalars="attribute", - # n_colors=n, - ) - - self.server.controller.view_update() - - if isinstance( active_block, InternalWell ): - s = active_block.polyline_node_coords - points = np.array( literal_eval( s.translate( tr ) ), dtype=np.float64 ) - tip = points[ 0 ] - - s = active_block.polyline_segment_conn - lines = np.array( literal_eval( s.translate( tr ) ), dtype=np.int64 ) - v_indices = np.unique( lines.flatten() ) - - r = literal_eval( active_block.radius.translate( tr ) ) - radius = np.repeat( r, points.shape[ 0 ] ) - - vpoints = vtk.vtkPoints() - vpoints.SetNumberOfPoints( points.shape[ 0 ] ) - vpoints.SetData( numpy_to_vtk( points ) ) - - polyLine = vtk.vtkPolyLine() - polyLine.GetPointIds().SetNumberOfIds( len( v_indices ) ) - - for iline, vidx in enumerate( v_indices ): - polyLine.GetPointIds().SetId( iline, vidx ) - - cells = vtk.vtkCellArray() - cells.InsertNextCell( polyLine ) - - vradius = vtk.vtkDoubleArray() - vradius.SetName( "radius" ) - vradius.SetNumberOfComponents( 1 ) - vradius.SetNumberOfTuples( points.shape[ 0 ] ) - vradius.SetVoidArray( numpy_to_vtk( radius ), points.shape[ 0 ], 1 ) - - polyData = vtk.vtkPolyData() - polyData.SetPoints( vpoints ) - polyData.SetLines( cells ) - polyData.GetPointData().AddArray( vradius ) - polyData.GetPointData().SetActiveScalars( "radius" ) - - bounds = self.region_engine.mesh.bounds - - xsize = bounds[ 1 ] - bounds[ 0 ] - ysize = bounds[ 3 ] - bounds[ 2 ] - - maxsize = max( xsize, ysize ) - self.well_engine = WellViewer( maxsize, 1 ) - - self.well_engine.add_mesh( pv.wrap( polyData ) ) - - for m in self.well_engine.tubes: - actor = self.plotter.add_mesh( m, color=True, show_edges=False ) - - self.server.controller.view_update() - - # if isinstance(active_block, Perforation): diff --git a/geos-trame/src/geos_trame/app/ui/viewer/__init__.py b/geos-trame/src/geos_trame/app/ui/viewer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/geos-trame/src/geos_trame/app/ui/viewer/perforationViewer.py b/geos-trame/src/geos_trame/app/ui/viewer/perforationViewer.py new file mode 100644 index 000000000..28678c5e3 --- /dev/null +++ b/geos-trame/src/geos_trame/app/ui/viewer/perforationViewer.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Lucas Givord - Kitware +import pyvista as pv + + +class PerforationViewer: + """ + Class representing how storing a GEOS Perforation. + + A perforation is represented by 2 meshes: + _perforation_mesh : which is a sphere located where the perforation is + _extracted_cell : the extracted cell at the perforation location + """ + + def __init__( self, mesh: pv.PolyData, center: list[ float ], radius: float, actor: pv.Actor ) -> None: + self.perforation_mesh: pv.PolyData = mesh + self.center: list[ float ] = center + self.radius: float = radius + self.perforation_actor: pv.Actor = actor + self.extracted_cell: pv.Actor + + def add_extracted_cell( self, cell_actor: pv.Actor ) -> None: + self.extracted_cell = cell_actor + + def update_perforation_radius( self, value: float ) -> None: + self.radius = value + self.perforation_mesh = pv.Sphere( radius=self.radius, center=self.center ) + self.perforation_actor.GetMapper().SetInputDataObject( self.perforation_mesh ) + self.perforation_actor.GetMapper().Update() + + def get_perforation_size( self ) -> float: + return self.radius + + def reset( self ) -> None: + self.perforation_actor = pv.Actor() + self.perforation_mesh = pv.PolyData() + self.extracted_cell = pv.Actor() diff --git a/geos-trame/src/geos_trame/app/ui/viewer/regionViewer.py b/geos-trame/src/geos_trame/app/ui/viewer/regionViewer.py new file mode 100644 index 000000000..0ab47c7fd --- /dev/null +++ b/geos-trame/src/geos_trame/app/ui/viewer/regionViewer.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Lucas Givord - Kitware +import pyvista as pv + + +class RegionViewer: + """ + Stores all related data information to represent the whole mesh. + + This mesh is represented in GEOS with a Region. + """ + + def __init__( self ) -> None: + self.input: pv.UnstructuredGrid + self.clip: pv.UnstructuredGrid + self.reset() + + def __call__( self, normal: tuple[ float ], origin: tuple[ float ] ) -> None: + self.update_clip( normal, origin ) + + def add_mesh( self, mesh: pv.UnstructuredGrid ) -> None: + self.input = mesh # type: ignore + self.clip = self.input.copy() # type: ignore + + def update_clip( self, normal: tuple[ float ], origin: tuple[ float ] ) -> None: + self.clip.copy_from( self.input.clip( normal=normal, origin=origin, crinkle=True ) ) # type: ignore + + def reset( self ) -> None: + self.input = pv.UnstructuredGrid() + self.clip = self.input diff --git a/geos-trame/src/geos_trame/app/ui/viewer/viewer.py b/geos-trame/src/geos_trame/app/ui/viewer/viewer.py new file mode 100644 index 000000000..b5d11acdf --- /dev/null +++ b/geos-trame/src/geos_trame/app/ui/viewer/viewer.py @@ -0,0 +1,368 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Lucas Givord - Kitware +import pyvista as pv +from pyvista.trame.ui import plotter_ui +from trame.widgets import vuetify3 as vuetify +from trame.widgets import html + +from geos_trame.schema_generated.schema_mod import ( + Vtkmesh, + Vtkwell, + Perforation, + InternalWell, +) + +import geos_trame.app.ui.viewer.regionViewer as RegionViewer +import geos_trame.app.ui.viewer.wellViewer as WellViewer +import geos_trame.app.ui.viewer.perforationViewer as PerforationViewer +from geos_trame.app.geosTrameException import GeosTrameException + +import numpy as np +from typing import Type, Any + +pv.OFF_SCREEN = True + + +class DeckViewer( vuetify.VCard ): + """ + Deck representing the 3D View using PyVista. + + This view can show: + - Vtkmesh, + - Vtkwell, + - Perforation, + - InternalWell + + Everything is handle in the method 'update_viewer()' which is trigger when the + 'state.object_state' changed (see DeckTree). + + This View handle widgets, such as clip widget or slider to control Wells or + Perforation settings. + """ + + def __init__( self, source, **kwargs ): + super().__init__( **kwargs ) + + self._source = source + self._pl = pv.Plotter() + + self.CUT_PLANE = "on_cut_plane_visibility_change" + self.ZAMPLIFICATION = "_z_amplification" + self.server.state[ self.CUT_PLANE ] = True + self.server.state[ self.ZAMPLIFICATION ] = 1 + + self.region_engine = RegionViewer.RegionViewer() + self.well_engine = WellViewer.WellViewer( 5, 5 ) + self._perforations: dict[ str, PerforationViewer.PerforationViewer ] = dict() + + self.state.change( "object_state" )( self.update_viewer ) + + with self: + vuetify.VCardTitle( "3D View" ) + view = plotter_ui( + self._pl, + add_menu_items=self.rendering_menu_extra_items, + style="position: absolute;", + ) + self.ctrl.view_update = view.update + + @property + def plotter( self ): + return self._pl + + @property + def source( self ): + return self._source + + def rendering_menu_extra_items( self ): + """ + Extend the default pyvista menu with custom button. + + For now, adding a button to show/hide all widgets. + """ + self.state.change( self.CUT_PLANE )( self._on_clip_visibility_change ) + vuetify.VDivider( vertical=True, classes="mr-1" ) + with vuetify.VTooltip( location="bottom" ): + with vuetify.Template( v_slot_activator=( "{ props }", ) ): + with html.Div( v_bind=( "props", ) ): + vuetify.VCheckbox( + v_model=( self.CUT_PLANE, True ), + icon=True, + true_icon="mdi-eye", + false_icon="mdi-eye-off", + dense=True, + hide_details=True, + ) + html.Span( "Show/Hide widgets" ) + + def update_viewer( self, object_state: list[ str, bool ], **kwargs ) -> None: + """ + Add from path the dataset given by the user. + Supported data type is: Vtkwell, Vtkmesh, InternalWell, Perforation. + + object_state : array used to store path to the data and if we want to show it or not. + """ + path = object_state[ 0 ] + show_obj = object_state[ 1 ] + + if path == "": + return + active_block = self.source.decode( path ) + + if isinstance( active_block, Vtkmesh ): + self._update_vtkmesh( active_block, show_obj ) + + if isinstance( active_block, Vtkwell ): + if self.region_engine.input.number_of_cells == 0 and show_obj: + + self.ctrl.on_add_warning( + "Can't display " + active_block.name, + "Please display the mesh before creating a well.", + ) + return + + self._update_vtkwell( active_block, path, show_obj ) + + if isinstance( active_block, InternalWell ): + if self.region_engine.input.number_of_cells == 0 and show_obj: + self.ctrl.on_add_warning( + "Can't display " + active_block.name, + "Please display the mesh before creating a well", + ) + return + + self._update_internalwell( active_block, path, show_obj ) + + if isinstance( active_block, Perforation ): + if self.well_engine.get_number_of_wells() == 0 and show_obj: + self.ctrl.on_add_warning( + "Can't display " + active_block.name, + "Please display a well before creating a perforation", + ) + return + self._update_perforation( active_block, show_obj, path ) + + def _on_clip_visibility_change( self, **kwargs ): + """Toggle cut plane visibility for all actors. + + Parameters + ---------- + **kwargs : dict, optional + Unused keyword arguments. + + """ + show_widgets = kwargs[ self.CUT_PLANE ] + if show_widgets: + self._setup_slider() + else: + self._remove_slider() + + if self.plotter.plane_widgets: + widgets = self.plotter.plane_widgets + widgets[ 0 ].SetEnabled( show_widgets ) + self.plotter.render() + + def _setup_slider( self ) -> None: + """ + Create slider to control in the gui well parameters. + """ + + wells_radius = self._get_tube_size() + self.plotter.add_slider_widget( + self._on_change_tube_size, + [ 1, 20 ], + title="Wells radius", + pointa=( 0.02, 0.12 ), + pointb=( 0.30, 0.12 ), + title_opacity=0.5, + title_color="black", + title_height=0.02, + value=wells_radius, + ) + + perforation_radius = self._get_perforation_size() + self.plotter.add_slider_widget( + self._on_change_perforation_size, + [ 1, 50 ], + title="Perforation radius", + title_opacity=0.5, + pointa=( 0.02, 0.25 ), + pointb=( 0.30, 0.25 ), + title_color="black", + title_height=0.02, + value=perforation_radius, + ) + + def _remove_slider( self ) -> None: + """ + Create slider to control in the gui well parameters. + """ + self.plotter.clear_slider_widgets() + + def _on_change_tube_size( self, value ) -> None: + self.well_engine.update( value ) + + def _get_tube_size( self ) -> float: + return self.well_engine.get_tube_size() + + def _on_change_perforation_size( self, value ) -> None: + for key, perforation in self._perforations.items(): + perforation.update_perforation_radius( value ) + + def _get_perforation_size( self ) -> float: + if len( self._perforations ) <= 0: + return 5 + + for key, perforation in self._perforations.items(): + return perforation.get_perforation_size() + + def _update_internalwell( self, well: InternalWell, path: str, show: bool ) -> None: + """ + Used to control the visibility of the InternalWell. + This method will create the mesh if it doesn't exist. + """ + if not show: + self.plotter.remove_actor( self.well_engine.get_actor( path ) ) + self.well_engine.remove( path ) + return + + points = self.__parse_polyline_property( well.polyline_node_coords, dtype=float ) + connectivity = self.__parse_polyline_property( well.polyline_segment_conn, dtype=int ) + connectivity = connectivity.flatten() + + sorted_points = [] + for id in connectivity: + sorted_points.append( points[ id ] ) + + well_polydata = pv.MultipleLines( sorted_points ) + index = self.well_engine.add_mesh( well_polydata, path ) + + tube_actor = self.plotter.add_mesh( self.well_engine.get_tube( index ) ) + self.well_engine.append_actor( path, tube_actor ) + + self.server.controller.view_update() + + def _update_vtkwell( self, well: Vtkwell, path: str, show: bool ) -> None: + """ + Used to control the visibility of the Vtkwell. + This method will create the mesh if it doesn't exist. + """ + if not show: + self.plotter.remove_actor( self.well_engine.get_actor( path ) ) + self.well_engine.remove( path ) + return + + well_polydata = pv.PolyData.SafeDownCast( pv.read( self.source.get_abs_path( well.file ) ) ) + index = self.well_engine.add_mesh( well_polydata, path ) + + tube_actor = self.plotter.add_mesh( self.well_engine.get_tube( index ) ) + self.well_engine.append_actor( path, tube_actor ) + + self.server.controller.view_update() + + def _update_vtkmesh( self, mesh: Vtkmesh, show: bool ) -> None: + """ + Used to control the visibility of the Vtkmesh. + This method will create the mesh if it doesn't exist. + + Additionally, a clip filter will be added. + """ + + if not show: + self.region_engine.reset() + self.plotter.clear_plane_widgets() + self.plotter.remove_actor( self._clip_mesh ) + return + + unsctructured_grid = pv.UnstructuredGrid.SafeDownCast( pv.read( self.source.get_abs_path( mesh.file ) ) ) + self.region_engine.add_mesh( unsctructured_grid ) + active_scalar = self.region_engine.input.active_scalars_name + self._clip_mesh = self.plotter.add_mesh_clip_plane( + self.region_engine.input, + origin=self.region_engine.input.center, + normal=[ -1, 0, 0 ], + crinkle=True, + show_edges=False, + cmap="glasbey_bw", + scalars=active_scalar, + ) + + self.server.controller.view_update() + + def _update_perforation( self, perforation: Perforation, show: bool, path: str ) -> None: + """ + Generate VTK dataset from a perforation. + """ + + if not show: + if path in self._perforations: + self._remove_perforation( path ) + return + + distance_from_head = float( perforation.distance_from_head ) + self._add_perforation( distance_from_head, path ) + + def _remove_perforation( self, path: str ) -> None: + """ + Remove all actor related to the given path and clean the stored perforation + """ + saved_perforation: PerforationViewer.PerforationViewer = self._perforations[ path ] + self.plotter.remove_actor( saved_perforation.extracted_cell ) + self.plotter.remove_actor( saved_perforation.perforation_actor ) + saved_perforation.reset() + + def _add_perforation( self, distance_from_head: float, path: str ) -> None: + """ + Generate perforation dataset based on the distance from the top of a polyline + """ + + polyline: pv.PolyData = self.well_engine.get_mesh( path ) + if polyline is None: + return + + point = polyline.points[ 0 ] + point_offsetted = [ + point[ 0 ], + point[ 1 ], + point[ 2 ] - distance_from_head, + ] + + center = [ point[ 0 ], point[ 1 ], point[ 2 ] - float( distance_from_head ) ] + sphere = pv.Sphere( radius=5, center=center ) + + perforation_actor = self.plotter.add_mesh( sphere ) + saved_perforation = PerforationViewer.PerforationViewer( sphere, center, 5, perforation_actor ) + + id = self.region_engine.input.find_closest_cell( point_offsetted ) + cell = self.region_engine.input.extract_cells( [ id ] ) + cell_actor = self.plotter.add_mesh( cell ) + saved_perforation.add_extracted_cell( cell_actor ) + + self._perforations[ path ] = saved_perforation + + def __parse_polyline_property( self, property: str, dtype: Type[ Any ] ) -> np.ndarray[ Any ]: + """ + Internal method used to parse and convert a property, such as polyline_node_coords, from an InternalWell. + This string always follow this for : + "{ { 800, 1450, 395.646 }, { 800, 1450, -554.354 } }" + """ + try: + nodes_str = property.split( "}, {" ) + points = [] + for i in range( 0, len( nodes_str ) ): + + nodes_str[ i ] = nodes_str[ i ].replace( " ", "" ) + nodes_str[ i ] = nodes_str[ i ].replace( "{", "" ) + nodes_str[ i ] = nodes_str[ i ].replace( "}", "" ) + + point = np.array( nodes_str[ i ].split( "," ), dtype=dtype ) + + points.append( point ) + + return np.array( points, dtype=dtype ) + except ValueError: + raise GeosTrameException( + "cannot be able to convert the property into a numeric array: ", + ValueError, + ) diff --git a/geos-trame/src/geos_trame/app/ui/viewer/wellViewer.py b/geos-trame/src/geos_trame/app/ui/viewer/wellViewer.py new file mode 100644 index 000000000..ece7d5482 --- /dev/null +++ b/geos-trame/src/geos_trame/app/ui/viewer/wellViewer.py @@ -0,0 +1,137 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Lucas Givord - Kitware +import pyvista as pv + +from dataclasses import dataclass + + +@dataclass +class Well: + """ + A Well is represented by a polyline and a tube. + + This class stores also the related actor and his given path + to simplify data management. + """ + + well_path: str + polyline: pv.PolyData + tube: pv.PolyData + actor: pv.Actor + + +class WellViewer: + """ + WellViewer stores all Well used in the pv.Plotter(). + + A Well in GEOS could a InternalWell or a Vtkwell. + """ + + def __init__( self, size: float, amplification: float ) -> None: + self._wells: list[ Well ] = [] + + self.size: float = size + self.amplification: float = amplification + self.STARTING_VALUE: float = 5.0 + + def __call__( self, value: float ) -> None: + self.update( value ) + + def add_mesh( self, mesh: pv.PolyData, mesh_path: str ) -> int: + """ + Store a given mesh representing a polyline. + This polyline will be used then to create a tube to represent this line. + + return the indexed position of the stored well. + """ + radius = self.size * ( self.STARTING_VALUE / 100 ) + tube = mesh.tube( radius=radius, n_sides=50 ) + + self._wells.append( Well( mesh_path, mesh, tube, pv.Actor() ) ) + + return len( self._wells ) - 1 + + def get_mesh( self, perforation_path: str ) -> pv.PolyData | None: + """ + Retrieve the polyline linked to a given perforation path. + """ + index = self._get_index_from_perforation( perforation_path ) + if index == -1: + print( "Cannot found the well to remove from path: ", perforation_path ) + return None + + return self._wells[ index ].polyline + + def get_tube( self, index: int ) -> pv.PolyData | None: + """ + Retrieve the polyline linked to a given perforation path. + """ + if index < 0 or index > len( self._wells ): + print( "Cannot get the tube at index: ", index ) + return None + + return self._wells[ index ].tube + + def get_tube_size( self ) -> float: + """ + get the size used for the tube. + """ + return self.size + + def append_actor( self, perforation_path: str, tube_actor: pv.Actor ) -> None: + """ + Append a given actor, typically the Actor returned by + the pv.Plotter() when a given mes is added. + """ + + index = self._get_index_from_perforation( perforation_path ) + if index == -1: + print( "Cannot found the well to remove from path: ", perforation_path ) + return None + + self._wells[ index ].actor = tube_actor + + def get_actor( self, perforation_path: str ) -> pv.Actor | None: + """ + Retrieve the polyline linked to a given perforation path. + """ + index = self._get_index_from_perforation( perforation_path ) + if index == -1: + print( "Cannot found the well to remove from path: ", perforation_path ) + return None + + return self._wells[ index ].actor + + def update( self, value: float ) -> None: + self.size = value + for idx, m in enumerate( self._wells ): + self._wells[ idx ].tube.copy_from( m.polyline.tube( radius=self.size, n_sides=50 ) ) + + def remove( self, perforation_path: str ) -> None: + """ + Clear all data stored in this class. + """ + index = self._get_index_from_perforation( perforation_path ) + if index == -1: + print( "Cannot found the well to remove from path: ", perforation_path ) + + self._wells.remove( self._wells[ index ] ) + + def _get_index_from_perforation( self, perforation_path: str ) -> int: + """ + Retrieve the well associated to a given perforation, otherwise return -1. + """ + index = -1 + if len( self._wells ) == 0: + return index + + for i in range( 0, len( self._wells ) ): + if self._wells[ i ].well_path in perforation_path: + index = i + break + + return index + + def get_number_of_wells( self ): + return len( self._wells ) diff --git a/geos-trame/tests/data/geosDeck/P4_launcher.sbatch b/geos-trame/tests/data/geosDeck/P4_launcher.sbatch new file mode 100644 index 000000000..9f14e7cd9 --- /dev/null +++ b/geos-trame/tests/data/geosDeck/P4_launcher.sbatch @@ -0,0 +1,31 @@ +#!/bin/sh + +#SBATCH --job-name="geos" +#SBATCH --ntasks=8 +#SBATCH --nodes=1 +#SBATCH --time=3:00:00 +#SBATCH --partition=p4_general +##SBATCH --exclusive +#SBATCH --output=job_GEOS_P4_%j.out +#SBATCH --error=job_GEOS_P4_%j.err +##SBATCH --mem=734GB + +# do not change +ulimit -s unlimited +ulimit -c unlimited + +# loading of the module +module purge +module use /workrd/SCR/NUM/GEOS_environment/p4/modulefiles/app +module load geos/develop_3bf12d2/pangea4-gcc12.1-hpcxompi2.17.1-onemkl2023.2.0-Release-2024-06-20-10-05 + +export HDF5_USE_FILE_LOCKING=FALSE +export OMP_NUM_THREADS=1 + +#----- Set standard Output and standrard error base file name. ----- +OUT_NAME=job_GEOS_P4_${SLURM_JOBID} + +srun --mpi=pmix_v3 --hint=nomultithread \ + -n ${SLURM_NTASKS} geos \ + -o modelA \ + -i simulationA.xml \ No newline at end of file diff --git a/geos-trame/tests/data/geosDeck/co2flash.txt b/geos-trame/tests/data/geosDeck/co2flash.txt new file mode 100644 index 000000000..5c4a8ad9d --- /dev/null +++ b/geos-trame/tests/data/geosDeck/co2flash.txt @@ -0,0 +1 @@ +FlashModel CO2Solubility 1e5 1.5e8 5e4 283.15 383.15 1 0 diff --git a/geos-trame/tests/data/geosDeck/geosDeck.xml b/geos-trame/tests/data/geosDeck/geosDeck.xml new file mode 100644 index 000000000..a4a7de8ef --- /dev/null +++ b/geos-trame/tests/data/geosDeck/geosDeck.xml @@ -0,0 +1,363 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/geos-trame/tests/data/geosDeck/pvtgas.txt b/geos-trame/tests/data/geosDeck/pvtgas.txt new file mode 100644 index 000000000..5d59a368e --- /dev/null +++ b/geos-trame/tests/data/geosDeck/pvtgas.txt @@ -0,0 +1,2 @@ +DensityFun SpanWagnerCO2Density 1e5 1.5e8 5e4 283.15 383.15 1 +ViscosityFun FenghourCO2Viscosity 1e5 1.5e8 5e4 283.15 383.15 1 diff --git a/geos-trame/tests/data/geosDeck/pvtliquid.txt b/geos-trame/tests/data/geosDeck/pvtliquid.txt new file mode 100644 index 000000000..8363302af --- /dev/null +++ b/geos-trame/tests/data/geosDeck/pvtliquid.txt @@ -0,0 +1,2 @@ +DensityFun PhillipsBrineDensity 1e5 1.5e8 5e4 283.15 383.15 1 0 +ViscosityFun PhillipsBrineViscosity 0 diff --git a/geos-trame/tests/data/geosDeck/well_1.vtk b/geos-trame/tests/data/geosDeck/well_1.vtk new file mode 100644 index 000000000..fdc61e538 Binary files /dev/null and b/geos-trame/tests/data/geosDeck/well_1.vtk differ diff --git a/geos-trame/tests/test_file_handling.py b/geos-trame/tests/test_file_handling.py index e8e8668ee..2cbf4c2cc 100644 --- a/geos-trame/tests/test_file_handling.py +++ b/geos-trame/tests/test_file_handling.py @@ -14,4 +14,4 @@ def test_unsupported_file( capsys ): GeosTrame( server, file_name ) captured = capsys.readouterr() - assert captured.err == "Cannot build ui as the input file cannot be parse.\n" + assert captured.err == "The file tests/data/acous3D/acous3D_vtu.xml cannot be parsed.\n" diff --git a/geos-trame/tests/test_well_intersection.py b/geos-trame/tests/test_well_intersection.py new file mode 100644 index 000000000..f45841da8 --- /dev/null +++ b/geos-trame/tests/test_well_intersection.py @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +# SPDX-FileContributor: Lucas Givord - Kitware +from trame.app import get_server +from trame_client.utils.testing import enable_testing +from geos_trame.app.core import GeosTrame + + +def test_internal_well_intersection(): + + server = enable_testing( get_server( client_type="vue3" ), "message" ) + file_name = "geos-trame/tests/data/geosDeck/geosDeck.xml" + + app = GeosTrame( server, file_name ) + app.state.ready() + + app.deckInspector.state.object_state = [ "Problem/Mesh/0/VTKMesh/0", True ] + app.deckInspector.state.flush() + + app.deckInspector.state.object_state = [ + "Problem/Mesh/0/VTKMesh/0/InternalWell/0", + True, + ] + app.deckInspector.state.flush() + + app.deckInspector.state.object_state = [ + "Problem/Mesh/0/VTKMesh/0/InternalWell/0/Perforation/0", + True, + ] + app.deckInspector.state.flush() + + app.deckInspector.state.object_state = [ + "Problem/Mesh/0/VTKMesh/0/InternalWell/0/Perforation/1", + True, + ] + app.deckInspector.state.flush() + + assert app.deckViewer.well_engine.get_number_of_wells() == 1 + assert len( app.deckViewer._perforations ) == 2 + + +def test_vtk_well_intersection(): + + server = enable_testing( get_server( client_type="vue3" ), "message" ) + file_name = "geos-trame/tests/data/geosDeck/geosDeck.xml" + + app = GeosTrame( server, file_name ) + app.state.ready() + + app.deckInspector.state.object_state = [ "Problem/Mesh/0/VTKMesh/0", True ] + app.deckInspector.state.flush() + + app.deckInspector.state.object_state = [ "Problem/Mesh/0/VTKMesh/0/VTKWell/0", True ] + app.deckInspector.state.flush() + + app.deckInspector.state.object_state = [ + "Problem/Mesh/0/VTKMesh/0/VTKWell/0/Perforation/0", + True, + ] + app.deckInspector.state.flush() + + app.deckInspector.state.object_state = [ + "Problem/Mesh/0/VTKMesh/0/VTKWell/0/Perforation/1", + True, + ] + app.deckInspector.state.flush() + + assert app.deckViewer.well_engine.get_number_of_wells() == 1 + assert len( app.deckViewer._perforations ) == 2 + + app.deckInspector.state.object_state = [ "Problem/Mesh/0/VTKMesh/0/VTKWell/0", False ] + app.deckInspector.state.flush() + + assert app.deckViewer.well_engine.get_number_of_wells() == 0