From f3bbc33959b72c38b3ee7ca902d11412211a6119 Mon Sep 17 00:00:00 2001 From: Audun Sektnan <99661468+AudunSektnanNR@users.noreply.github.com> Date: Thu, 7 Mar 2024 14:14:15 +0000 Subject: [PATCH] CO2Leakage - Visualize CO2 mass plus various other improvements (#1252) * CO2Leakage: Minor visual fix in Property-tab. * Black last commit. * CO2Leakage: First iteration, adding code for co2 mass maps. * CO2Leakage: Add some checks for existing files. * CO2Leakage: Minor fix for mass maps. * CO2Leakage: Plot unit for time migration and mass maps. * Removal of simple volume * Match ccs script naming (actual volume). Add space b4 well. * Change input from ccs-scripts to plume_x * Fix minor bug in space before well name hack * Fix zoom level when updating plot * Fix formatting of numbers and plot ranges * Fix zoom bug that messed up scales * Add visualization of dissolved and free CO2 * Add visualization threshold * Show total mass in realization * Small fix for zoom in containment plots * Add zone selection for containment plot. * Add zone functionality * Allow relative well and boundary paths * Use scientific notation for total mass * Clean up code for relative path to well and boundaries * Small fixes * Fix handling of input files * Add feedback button * Clean up zone functionality * Disable feedback button (for now) * Minor fixes * Fix black formating. * Minor fix pylint. * Various formatting fixes and small enhancements. (#5) * Various pylint fixes. * Minor fix black. * Minor fixes, pylint and mypy. * Various fixes, simplifications. * Edit standard surface filenames and fix settings buttons (#6) * Change expected surface filenames and add warning if missing * Bug fix, reenable options button * Make visualization threshold more intuitive * Minor formatting fix * Improve handling of update button n_clicks * Improve handling of update button n_clicks (#7) * Update CHANGELOG.md * Add unit selection and improve visualization threshold * Minor fix to map update handling * Add region support for the containment plots * Formatting * CCS-88: Change default behavior of UNSMRY-file, print warning if large file (#8) * CCS-88: Change default behavior of UNSMRY-file, only load and plot if specified. * CCS-88: Print warning if some CSV-files are large. * CCS-88: Minor fix. * Make visualization updates more intuitive. * Minor fix * Add support for separate well pick and polygon files for each ensemble (requires same relative path). * Minor formatting in formation-well warning. * pylint * mypy * Update changelog. --------- Co-authored-by: Audun Sektnan (SUB CC SIG) Co-authored-by: FredrikNevjenNR Co-authored-by: FredrikNevjenNR <150343101+FredrikNevjenNR@users.noreply.github.com> Co-authored-by: Hans Kallekleiv <16436291+HansKallekleiv@users.noreply.github.com> --- CHANGELOG.md | 5 + .../plugins/_co2_leakage/_plugin.py | 289 +++++++++---- .../_co2_leakage/_utilities/callbacks.py | 151 ++++++- .../_co2_leakage/_utilities/co2volume.py | 406 ++++++++++++++---- .../_co2_leakage/_utilities/generic.py | 29 +- .../_co2_leakage/_utilities/initialization.py | 121 +++++- .../_utilities/surface_publishing.py | 31 +- .../_co2_leakage/views/mainview/settings.py | 376 ++++++++++++++-- 8 files changed, 1178 insertions(+), 230 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ac1474e..267e978ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#1244](https://github.com/equinor/webviz-subsurface/pull/1244) - New functionality in `VolumetricAnalysis` to compute facies fractions if `FACIES` is present in the volumetric table. Also added possibility to have labels on bar plots with user defined value format. - [#1247](https://github.com/equinor/webviz-subsurface/pull/1247) - Added P10/P90 to the Uncertainty table in `StructuralUncertainty`. +### Changed +- [#1252](https://github.com/equinor/webviz-subsurface/pull/1252) - Added CO2 mass visualization to `CO2Leakage`, plus various other improvements: support for regions, option to plot containment split into zones, remove code for simple volume. + +### Fixed +- [#1252](https://github.com/equinor/webviz-subsurface/pull/1252) - Fixed zoom resetting issue in `CO2Leakage`. Also some fixes to avoid crash for large UNSMRY CSV-files. ## [0.2.22] - 2023-08-31 diff --git a/webviz_subsurface/plugins/_co2_leakage/_plugin.py b/webviz_subsurface/plugins/_co2_leakage/_plugin.py index f370dd330..5464283eb 100644 --- a/webviz_subsurface/plugins/_co2_leakage/_plugin.py +++ b/webviz_subsurface/plugins/_co2_leakage/_plugin.py @@ -1,3 +1,4 @@ +import logging from typing import Any, Dict, List, Optional, Tuple, Union import plotly.graph_objects as go @@ -16,6 +17,9 @@ generate_containment_figures, generate_unsmry_figures, get_plume_polygon, + process_containment_info, + process_summed_mass, + process_visualization_info, property_origin, readable_name, ) @@ -33,6 +37,8 @@ init_surface_providers, init_table_provider, init_well_pick_provider, + init_zone_and_region_options, + process_files, ) from webviz_subsurface.plugins._co2_leakage.views.mainview.mainview import ( MainView, @@ -43,9 +49,11 @@ from . import _error from ._utilities.color_tables import co2leakage_color_tables -TILE_PATH = "share/results/tables" +LOGGER = logging.getLogger(__name__) +TABLES_PATH = "share/results/tables" +# pylint: disable=too-many-instance-attributes class CO2Leakage(WebvizPluginABC): """ Plugin for analyzing CO2 leakage potential across multiple realizations in an FMU @@ -55,14 +63,11 @@ class CO2Leakage(WebvizPluginABC): * **`file_containment_boundary`:** Path to a polygon representing the containment area * **`file_hazardous_boundary`:** Path to a polygon representing the hazardous area * **`well_pick_file`:** Path to a file containing well picks - * **`co2_containment_relpath`:** Path to a table of co2 containment data (amount of + * **`plume_mass_relpath`:** Path to a table of co2 containment data (amount of CO2 outside/inside a boundary), for co2 mass. Relative to each realization. - * **`co2_containment_volume_actual_relpath`:** Path to a table of co2 containment data (amount + * **`plume_actual_volume_relpath`:** Path to a table of co2 containment data (amount of CO2 outside/inside a boundary), for co2 volume of type "actual". Relative to each realization. - * **`co2_containment_volume_actual_simple_relpath`:** Path to a table of co2 containment data - (amount of CO2 outside/inside a boundary), for co2 volume of type "actual_simple". - Relative to each realization. * **`unsmry_relpath`:** Relative path to a csv version of a unified summary file * **`fault_polygon_attribute`:** Polygons with this attribute are used as fault polygons @@ -91,12 +96,9 @@ def __init__( file_containment_boundary: Optional[str] = None, file_hazardous_boundary: Optional[str] = None, well_pick_file: Optional[str] = None, - co2_containment_relpath: str = TILE_PATH + "/co2_volumes.csv", - co2_containment_volume_actual_relpath: str = TILE_PATH - + "/plume_volume_actual.csv", - co2_containment_volume_actual_simple_relpath: str = TILE_PATH - + "/plume_volume_actual_simple.csv", - unsmry_relpath: str = TILE_PATH + "/unsmry--raw.csv", + plume_mass_relpath: str = TABLES_PATH + "/plume_mass.csv", + plume_actual_volume_relpath: str = TABLES_PATH + "/plume_actual_volume.csv", + unsmry_relpath: Optional[str] = None, fault_polygon_attribute: str = "dl_extracted_faultlines", initial_surface: Optional[str] = None, map_attribute_names: Optional[Dict[str, str]] = None, @@ -105,16 +107,24 @@ def __init__( ): super().__init__() self._error_message = "" - - self._file_containment_boundary = file_containment_boundary - self._file_hazardous_boundary = file_hazardous_boundary try: - self._ensemble_paths = { + ensemble_paths = { ensemble_name: webviz_settings.shared_settings["scratch_ensembles"][ ensemble_name ] for ensemble_name in ensembles } + ( + containment_poly_dict, + hazardous_poly_dict, + well_pick_dict, + ) = process_files( + file_containment_boundary, + file_hazardous_boundary, + well_pick_file, + ensemble_paths, + ) + self._polygon_files = [containment_poly_dict, hazardous_poly_dict] self._surface_server = SurfaceImageServer.instance(app) self._polygons_server = FaultPolygonsServer.instance(app) @@ -127,7 +137,7 @@ def __init__( self._fault_polygon_handlers = { ens: FaultPolygonsHandler( self._polygons_server, - self._ensemble_paths[ens], + ensemble_paths[ens], map_surface_names_to_fault_polygons or {}, fault_polygon_attribute, ) @@ -135,41 +145,58 @@ def __init__( } # CO2 containment self._co2_table_providers = init_table_provider( - self._ensemble_paths, - co2_containment_relpath, + ensemble_paths, + plume_mass_relpath, ) - self._co2_volume_actual_table_providers = init_table_provider( - self._ensemble_paths, - co2_containment_volume_actual_relpath, + self._co2_actual_volume_table_providers = init_table_provider( + ensemble_paths, + plume_actual_volume_relpath, ) - self._co2_volume_actual_simple_table_providers = init_table_provider( - self._ensemble_paths, - co2_containment_volume_actual_simple_relpath, - ) - self._unsmry_providers = init_table_provider( - self._ensemble_paths, - unsmry_relpath, + self._unsmry_providers = ( + init_table_provider( + ensemble_paths, + unsmry_relpath, + ) + if unsmry_relpath is not None + else None ) # Well picks self._well_pick_provider = init_well_pick_provider( - well_pick_file, + well_pick_dict, map_surface_names_to_well_pick_names, ) + # Zone and region options + self._zone_and_region_options = init_zone_and_region_options( + ensemble_paths, + self._co2_table_providers, + self._co2_actual_volume_table_providers, + self._ensemble_surface_providers, + ) except Exception as err: self._error_message = f"Plugin initialization failed: {err}" raise + self._summed_co2: Dict[str, Any] = {} + self._visualization_info = { + "threshold": -1.0, + "n_clicks": 0, + "change": False, + "unit": "kg", + } self._color_tables = co2leakage_color_tables() + self._well_pick_names = { + ens: prov.well_names() if prov is not None else [] + for ens, prov in self._well_pick_provider.items() + } self.add_shared_settings_group( ViewSettings( - self._ensemble_paths, + ensemble_paths, self._ensemble_surface_providers, initial_surface, self._map_attribute_names, [c["name"] for c in self._color_tables], # type: ignore - self._well_pick_provider.well_names() - if self._well_pick_provider - else [], + self._well_pick_names, + self._zone_and_region_options, ), self.Ids.MAIN_SETTINGS, ) @@ -203,7 +230,14 @@ def _ensemble_dates(self, ens: str) -> List[str]: return dates def _set_callbacks(self) -> None: + # Cannot avoid many arguments since all the parameters are needed + # to determine what to plot + # pylint: disable=too-many-arguments + # pylint: disable=too-many-locals @callback( + Output( + self._settings_component(ViewSettings.Ids.CONTAINMENT_VIEW), "value" + ), Output(self._view_component(MapViewElement.Ids.BAR_PLOT), "figure"), Output(self._view_component(MapViewElement.Ids.TIME_PLOT), "figure"), Output( @@ -222,6 +256,9 @@ def _set_callbacks(self) -> None: Input(self._settings_component(ViewSettings.Ids.Y_MIN_GRAPH), "value"), Input(self._settings_component(ViewSettings.Ids.Y_MAX_AUTO_GRAPH), "value"), Input(self._settings_component(ViewSettings.Ids.Y_MAX_GRAPH), "value"), + Input(self._settings_component(ViewSettings.Ids.ZONE), "value"), + Input(self._settings_component(ViewSettings.Ids.REGION), "value"), + Input(self._settings_component(ViewSettings.Ids.CONTAINMENT_VIEW), "value"), ) @callback_typecheck def update_graphs( @@ -233,65 +270,70 @@ def update_graphs( y_min_val: Optional[float], y_max_auto: List[str], y_max_val: Optional[float], - ) -> Tuple[go.Figure, go.Figure, Dict, Dict]: - styles = [{"display": "none"}] * 3 - figs = [no_update] * 3 + zone: Optional[str], + region: Optional[str], + containment_view: str, + ) -> Tuple[Dict, go.Figure, go.Figure]: + out = {"figs": [no_update] * 3, "styles": [{"display": "none"}] * 3} + cont_info = process_containment_info( + zone, + region, + containment_view, + self._zone_and_region_options[ensemble][source], + source, + ) if source in [ GraphSource.CONTAINMENT_MASS, - GraphSource.CONTAINMENT_VOLUME_ACTUAL, - GraphSource.CONTAINMENT_VOLUME_ACTUAL_SIMPLE, + GraphSource.CONTAINMENT_ACTUAL_VOLUME, ]: - y_limits = [] - if len(y_min_auto) == 0: - y_limits.append(y_min_val) - else: - y_limits.append(None) - if len(y_max_auto) == 0: - y_limits.append(y_max_val) - else: - y_limits.append(None) - styles = [{}] * 3 - + y_limits = [ + y_min_val if len(y_min_auto) == 0 else None, + y_max_val if len(y_max_auto) == 0 else None, + ] + out["styles"] = [{}] * 3 if ( source == GraphSource.CONTAINMENT_MASS and ensemble in self._co2_table_providers ): - figs[: len(figs)] = generate_containment_figures( + out["figs"][: len(out["figs"])] = generate_containment_figures( self._co2_table_providers[ensemble], co2_scale, realizations[0], y_limits, + cont_info, ) elif ( - source == GraphSource.CONTAINMENT_VOLUME_ACTUAL - and ensemble in self._co2_volume_actual_table_providers + source == GraphSource.CONTAINMENT_ACTUAL_VOLUME + and ensemble in self._co2_actual_volume_table_providers ): - figs[: len(figs)] = generate_containment_figures( - self._co2_volume_actual_table_providers[ensemble], + out["figs"][: len(out["figs"])] = generate_containment_figures( + self._co2_actual_volume_table_providers[ensemble], co2_scale, realizations[0], y_limits, + cont_info, ) - elif ( - source == GraphSource.CONTAINMENT_VOLUME_ACTUAL_SIMPLE - and ensemble in self._co2_volume_actual_simple_table_providers - ): - figs[: len(figs)] = generate_containment_figures( - self._co2_volume_actual_simple_table_providers[ensemble], - co2_scale, - realizations[0], - y_limits, + for fig in out["figs"]: + fig["layout"][ + "uirevision" + ] = f"{source}-{co2_scale}-{cont_info['zone']}-{cont_info['region']}" + out["figs"][-1]["layout"]["uirevision"] += f"-{realizations}" + elif source == GraphSource.UNSMRY: + if self._unsmry_providers is not None: + if ensemble in self._unsmry_providers: + u_figs = generate_unsmry_figures( + self._unsmry_providers[ensemble], + co2_scale, + self._co2_table_providers[ensemble], + ) + out["figs"][: len(u_figs)] = u_figs + out["styles"][: len(u_figs)] = [{}] * len(u_figs) + else: + LOGGER.warning( + """UNSMRY file has not been specified as input. + Please use unsmry_relpath in the configuration.""" ) - elif source == GraphSource.UNSMRY and ensemble in self._unsmry_providers: - u_figs = generate_unsmry_figures( - self._unsmry_providers[ensemble], - co2_scale, - self._co2_table_providers[ensemble], - ) - figs[: len(u_figs)] = u_figs - styles[: len(u_figs)] = [{}] * len(u_figs) - - return *figs, *styles # type: ignore + return cont_info["containment_view"], *out["figs"], *out["styles"] # type: ignore @callback( Output(self._view_component(MapViewElement.Ids.DATE_SLIDER), "marks"), @@ -333,14 +375,34 @@ def make_unit_list( Tuple[List[Co2MassScale], Co2MassScale], Tuple[List[Co2VolumeScale], Co2VolumeScale], ]: - if attribute in [ - GraphSource.CONTAINMENT_VOLUME_ACTUAL, - GraphSource.CONTAINMENT_VOLUME_ACTUAL_SIMPLE, - ]: + if attribute == GraphSource.CONTAINMENT_ACTUAL_VOLUME: return list(Co2VolumeScale), Co2VolumeScale.BILLION_CUBIC_METERS - return list(Co2MassScale), Co2MassScale.MTONS + @callback( + Output(ViewSettings.Ids.OPTIONS_DIALOG_WELL_FILTER, "options"), + Output(ViewSettings.Ids.OPTIONS_DIALOG_WELL_FILTER, "value"), + Output(ViewSettings.Ids.OPTIONS_DIALOG_WELL_FILTER, "style"), + Output(ViewSettings.Ids.WELL_FILTER_HEADER, "style"), + Input(self._settings_component(ViewSettings.Ids.ENSEMBLE), "value"), + ) + def set_well_options( + ensemble: str, + ) -> Tuple[List[Any], List[str], Dict[Any, Any], Dict[Any, Any]]: + return ( + [{"label": i, "value": i} for i in self._well_pick_names[ensemble]], + self._well_pick_names[ensemble], + { + "display": "block" if self._well_pick_names[ensemble] else "none", + "height": f"{len(self._well_pick_names[ensemble]) * 22}px", + }, + { + "flex": 3, + "minWidth": "20px", + "display": "block" if self._well_pick_names[ensemble] else "none", + }, + ) + # Cannot avoid many arguments and/or locals since all layers of the DeckGL map # needs to be updated simultaneously # pylint: disable=too-many-arguments,too-many-locals @@ -360,9 +422,19 @@ def make_unit_list( Input(self._settings_component(ViewSettings.Ids.CM_MAX), "value"), Input(self._settings_component(ViewSettings.Ids.PLUME_THRESHOLD), "value"), Input(self._settings_component(ViewSettings.Ids.PLUME_SMOOTHING), "value"), + Input( + self._settings_component(ViewSettings.Ids.VISUALIZATION_THRESHOLD), + "value", + ), + Input( + self._settings_component(ViewSettings.Ids.VISUALIZATION_UPDATE), + "n_clicks", + ), + Input(self._settings_component(ViewSettings.Ids.MASS_UNIT), "value"), Input(ViewSettings.Ids.OPTIONS_DIALOG_OPTIONS, "value"), Input(ViewSettings.Ids.OPTIONS_DIALOG_WELL_FILTER, "value"), - State(self._settings_component(ViewSettings.Ids.ENSEMBLE), "value"), + Input(self._settings_component(ViewSettings.Ids.ENSEMBLE), "value"), + State(self._view_component(MapViewElement.Ids.DECKGL_MAP), "views"), ) def update_map_attribute( attribute: MapAttribute, @@ -377,14 +449,27 @@ def update_map_attribute( cm_max_val: Optional[float], plume_threshold: Optional[float], plume_smoothing: Optional[float], + visualization_threshold: Optional[float], + visualization_update: int, + mass_unit: str, options_dialog_options: List[int], selected_wells: List[str], ensemble: str, + current_views: List[Any], ) -> Tuple[List[Dict[Any, Any]], List[Any], Dict[Any, Any]]: + # Unable to clear cache (when needed) without the protected member + # pylint: disable=protected-access + self._visualization_info = process_visualization_info( + visualization_update, + visualization_threshold, + mass_unit, + self._visualization_info, + self._surface_server._image_cache, + ) + if self._visualization_info["change"]: + return [], no_update, no_update attribute = MapAttribute(attribute) - if len(realization) == 0: - raise PreventUpdate - if ensemble is None: + if len(realization) == 0 or ensemble is None: raise PreventUpdate datestr = self._ensemble_dates(ensemble)[date] # Contour data @@ -396,9 +481,9 @@ def update_map_attribute( "smoothing": plume_smoothing, } # Surface - surf_data = None + surf_data, summed_mass = None, None if formation is not None and len(realization) > 0: - surf_data = SurfaceData.from_server( + surf_data, summed_mass = SurfaceData.from_server( server=self._surface_server, provider=self._ensemble_surface_providers[ensemble], address=derive_surface_address( @@ -416,7 +501,20 @@ def update_map_attribute( ), color_map_name=color_map_name, readable_name_=readable_name(attribute), + visualization_info=self._visualization_info, + map_attribute_names=self._map_attribute_names, ) + assert isinstance(self._visualization_info["unit"], str) + surf_data, self._summed_co2 = process_summed_mass( + formation, + realization, + datestr, + attribute, + summed_mass, + surf_data, + self._summed_co2, + self._visualization_info["unit"], + ) # Plume polygon plume_polygon = None if contour_data is not None: @@ -437,9 +535,9 @@ def update_map_attribute( realization, ) ), - file_containment_boundary=self._file_containment_boundary, - file_hazardous_boundary=self._file_hazardous_boundary, - well_pick_provider=self._well_pick_provider, + file_containment_boundary=self._polygon_files[0][ensemble], + file_hazardous_boundary=self._polygon_files[1][ensemble], + well_pick_provider=self._well_pick_provider[ensemble], plume_extent_data=plume_polygon, options_dialog_options=options_dialog_options, selected_wells=selected_wells, @@ -448,9 +546,11 @@ def update_map_attribute( formation=formation, surface_data=surf_data, colortables=self._color_tables, + attribute=attribute, + unit=self._visualization_info["unit"], ) - viewports = create_map_viewports() - return (layers, annotations, viewports) + viewports = no_update if current_views else create_map_viewports() + return layers, annotations, viewports @callback( Output(ViewSettings.Ids.OPTIONS_DIALOG, "open"), @@ -460,3 +560,12 @@ def open_close_options_dialog(_n_clicks: Optional[int]) -> bool: if _n_clicks is not None: return _n_clicks > 0 raise PreventUpdate + + @callback( + Output(ViewSettings.Ids.FEEDBACK, "open"), + Input(ViewSettings.Ids.FEEDBACK_BUTTON, "n_clicks"), + ) + def open_close_feedback(_n_clicks: Optional[int]) -> bool: + if _n_clicks is not None: + return _n_clicks > 0 + raise PreventUpdate diff --git a/webviz_subsurface/plugins/_co2_leakage/_utilities/callbacks.py b/webviz_subsurface/plugins/_co2_leakage/_utilities/callbacks.py index ac669202f..944f5e223 100644 --- a/webviz_subsurface/plugins/_co2_leakage/_utilities/callbacks.py +++ b/webviz_subsurface/plugins/_co2_leakage/_utilities/callbacks.py @@ -6,6 +6,7 @@ import numpy as np import plotly.graph_objects as go import webviz_subsurface_components as wsc +from flask_caching import Cache from webviz_subsurface._providers import ( EnsembleSurfaceProvider, @@ -29,6 +30,8 @@ from webviz_subsurface.plugins._co2_leakage._utilities.generic import ( Co2MassScale, Co2VolumeScale, + ContainmentViews, + GraphSource, LayoutLabels, MapAttribute, ) @@ -73,8 +76,16 @@ def from_server( color_map_range: Tuple[Optional[float], Optional[float]], color_map_name: str, readable_name_: str, - ) -> "SurfaceData": - surf_meta, img_url = publish_and_get_surface_metadata(server, provider, address) + visualization_info: Dict[str, Any], + map_attribute_names: Dict[MapAttribute, str], + ) -> Tuple[Any, Optional[Any]]: + surf_meta, img_url, summed_mass = publish_and_get_surface_metadata( + server, + provider, + address, + visualization_info, + map_attribute_names, + ) assert surf_meta is not None # Should not occur value_range = ( 0.0 if np.ma.is_masked(surf_meta.val_min) else surf_meta.val_min, @@ -84,13 +95,16 @@ def from_server( value_range[0] if color_map_range[0] is None else color_map_range[0], value_range[1] if color_map_range[1] is None else color_map_range[1], ) - return SurfaceData( - readable_name_, - color_map_range, - color_map_name, - value_range, - surf_meta, - img_url, + return ( + SurfaceData( + readable_name_, + color_map_range, + color_map_name, + value_range, + surf_meta, + img_url, + ), + summed_mass, ) @@ -183,10 +197,20 @@ def get_plume_polygon( ) +def _find_legend_title(attribute: MapAttribute, unit: str) -> str: + if attribute == MapAttribute.MIGRATION_TIME: + return "years" + if attribute in [MapAttribute.MASS, MapAttribute.DISSOLVED, MapAttribute.FREE]: + return unit + return "" + + def create_map_annotations( formation: str, surface_data: Optional[SurfaceData], colortables: List[Dict[str, Any]], + attribute: MapAttribute, + unit: str, ) -> List[wsc.ViewAnnotation]: annotations = [] if surface_data is not None: @@ -195,13 +219,14 @@ def create_map_annotations( id="1_view", children=[ wsc.WebVizColorLegend( + title=_find_legend_title(attribute, unit), min=surface_data.color_map_range[0], max=surface_data.color_map_range[1], colorName=surface_data.color_map_name, cssLegendStyles={"top": "0", "right": "0"}, openColorSelector=False, legendScaleSize=0.1, - legendFontSize=30, + legendFontSize=20, colorTables=colortables, ), wsc.ViewFooter(children=formation), @@ -305,11 +330,23 @@ def create_map_layers( ) if ( well_pick_provider is not None + and formation is not None and LayoutLabels.SHOW_WELLS in options_dialog_options ): well_data = dict(well_pick_provider.get_geojson(selected_wells, formation)) - if "features" in well_data and len(well_data["features"]) == 0: - warnings.warn(f'Formation name "{formation}" not found in well picks file.') + if "features" in well_data: + if len(well_data["features"]) == 0: + wellstring = "well: " if len(selected_wells) == 1 else "wells: " + wellstring += ", ".join(selected_wells) + warnings.warn( + f"Combination of formation: {formation} and " + f"{wellstring} not found in well picks file." + ) + for i in range(len(well_data["features"])): + current_attribute = well_data["features"][i]["properties"]["attribute"] + well_data["features"][i]["properties"]["attribute"] = ( + " " + current_attribute + ) layers.append( { "@@type": "GeoJsonLayer", @@ -346,20 +383,23 @@ def generate_containment_figures( co2_scale: Union[Co2MassScale, Co2VolumeScale], realization: int, y_limits: List[Optional[float]], + containment_info: Dict[str, Union[str, None, List[str]]], ) -> Tuple[go.Figure, go.Figure, go.Figure]: try: fig0 = generate_co2_volume_figure( table_provider, table_provider.realizations(), co2_scale, + containment_info, ) fig1 = generate_co2_time_containment_figure( table_provider, table_provider.realizations(), co2_scale, + containment_info, ) fig2 = generate_co2_time_containment_one_realization_figure( - table_provider, co2_scale, realization, y_limits + table_provider, co2_scale, realization, y_limits, containment_info ) except KeyError as exc: warnings.warn(f"Could not generate CO2 figures: {exc}") @@ -415,3 +455,88 @@ def _parse_polygon_file(filename: str) -> Dict[str, Any]: ], } return as_geojson + + +def process_visualization_info( + n_clicks: int, + threshold: Optional[float], + unit: str, + stored_info: Dict[str, Any], + cache: Cache, +) -> Dict[str, Any]: + """ + Clear surface cache if the threshold for visualization or mass unit is changed + """ + stored_info["change"] = False + stored_info["n_clicks"] = n_clicks + if unit != stored_info["unit"]: + stored_info["unit"] = unit + stored_info["change"] = True + if threshold is not None and threshold != stored_info["threshold"]: + stored_info["threshold"] = threshold + stored_info["change"] = True + if stored_info["change"]: + cache.clear() + # stored_info["n_clicks"] = n_clicks + return stored_info + + +def process_containment_info( + zone: Optional[str], + region: Optional[str], + view: Optional[str], + zone_and_region_options: Dict[str, List[str]], + source: str, +) -> Dict[str, Union[str, None, List[str]]]: + zones = zone_and_region_options["zones"] + regions = zone_and_region_options["regions"] + if source in [ + GraphSource.CONTAINMENT_MASS, + GraphSource.CONTAINMENT_ACTUAL_VOLUME, + ]: + if view == ContainmentViews.CONTAINMENTSPLIT: + return {"zone": zone, "region": region, "containment_view": view} + if view == ContainmentViews.ZONESPLIT and len(zones) > 0: + zones = [zone_name for zone_name in zones if zone_name != "all"] + elif view == ContainmentViews.REGIONSPLIT and len(regions) > 0: + regions = [reg_name for reg_name in regions if reg_name != "all"] + else: + return { + "zone": zone, + "region": region, + "containment_view": ContainmentViews.CONTAINMENTSPLIT, + } + return { + "zone": zone, + "region": region, + "containment_view": view, + "zones": zones, + "regions": regions, + } + return {"containment_view": ContainmentViews.CONTAINMENTSPLIT} + + +def process_summed_mass( + formation: str, + realization: List[int], + datestr: str, + attribute: MapAttribute, + summed_mass: Optional[float], + surf_data: Optional[SurfaceData], + summed_co2: Dict[str, float], + unit: str, +) -> Tuple[Optional[SurfaceData], Dict[str, float]]: + summed_co2_key = f"{formation}-{realization[0]}-{datestr}-{attribute}-{unit}" + if len(realization) == 1: + if attribute in [ + MapAttribute.MASS, + MapAttribute.DISSOLVED, + MapAttribute.FREE, + ]: + if summed_mass is not None and summed_co2_key not in summed_co2: + summed_co2[summed_co2_key] = summed_mass + if summed_co2_key in summed_co2 and surf_data is not None: + surf_data.readable_name += ( + f" ({unit}) (Total: {summed_co2[summed_co2_key]:.2E}): " + ) + return surf_data, summed_co2 diff --git a/webviz_subsurface/plugins/_co2_leakage/_utilities/co2volume.py b/webviz_subsurface/plugins/_co2_leakage/_utilities/co2volume.py index dd7ac4e85..29ce1fb3f 100644 --- a/webviz_subsurface/plugins/_co2_leakage/_utilities/co2volume.py +++ b/webviz_subsurface/plugins/_co2_leakage/_utilities/co2volume.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Tuple, Union import numpy as np import pandas @@ -10,6 +10,7 @@ from webviz_subsurface.plugins._co2_leakage._utilities.generic import ( Co2MassScale, Co2VolumeScale, + ContainmentViews, ) @@ -24,22 +25,130 @@ class _Columns(Enum): _COLOR_CONTAINED = "#00aa00" _COLOR_OUTSIDE = "#006ddd" _COLOR_HAZARDOUS = "#dd4300" +_COLOR_ZONES = [ + "#e91451", + "#daa218", + "#208eb7", + "#84bc04", + "#b74532", + "#9a89b4", + "#8d30ba", + "#256b33", + "#95704d", + "#1357ca", + "#f75ef0", + "#34b36f", +] def _read_dataframe( table_provider: EnsembleTableProvider, realization: int, scale_factor: float, + containment_info: Dict[str, Union[str, None, List[str]]], ) -> pandas.DataFrame: df = table_provider.get_column_data(table_provider.column_names(), [realization]) + if any(split in list(df.columns) for split in ["zone", "region"]): + df = _process_containment_information(df, containment_info) + if containment_info["containment_view"] != ContainmentViews.CONTAINMENTSPLIT: + df["aqueous"] = ( + df["aqueous_contained"] + + df["aqueous_outside"] + + df["aqueous_hazardous"] + ) + df["gas"] = df["gas_contained"] + df["gas_outside"] + df["gas_hazardous"] + df = df.drop( + columns=[ + "aqueous_contained", + "aqueous_outside", + "aqueous_hazardous", + "gas_contained", + "gas_outside", + "gas_hazardous", + ] + ) if scale_factor == 1.0: return df for col in df.columns: - if col != "date": + if col not in ["date", "zone", "region"]: df[col] /= scale_factor return df +def read_zone_and_region_options( + table_provider: EnsembleTableProvider, + realization: int, +) -> Dict[str, List[str]]: + df = table_provider.get_column_data(table_provider.column_names(), [realization]) + zones = ["all"] + if "zone" in list(df.columns): + for zone in list(df["zone"]): + if zone not in zones: + zones.append(zone) + regions = ["all"] + if "region" in list(df.columns): + for region in list(df["region"]): + if region not in regions: + regions.append(region) + return { + "zones": zones if len(zones) > 1 else [], + "regions": regions if len(regions) > 1 else [], + } + + +def _process_containment_information( + df: pandas.DataFrame, + containment_info: Dict[str, Union[str, None, List[str]]], +) -> pandas.DataFrame: + view = containment_info["containment_view"] + if view == ContainmentViews.ZONESPLIT: + return ( + df[df["zone"] != "all"] + .drop(columns="region", errors="ignore") + .reset_index(drop=True) + ) + if view == ContainmentViews.REGIONSPLIT: + return ( + df[df["region"] != "all"] + .drop(columns="zone", errors="ignore") + .reset_index(drop=True) + ) + zone = containment_info["zone"] + region = containment_info["region"] + if zone not in ["all", None]: + if zone in list(df["zone"]): + return df[df["zone"] == zone].drop( + columns=["zone", "region"], errors="ignore" + ) + print(f"Zone {zone} not found, using sum for each unique date.") + elif region not in ["all", None]: + if region in list(df["region"]): + return df[df["region"] == region].drop( + columns=["zone", "region"], errors="ignore" + ) + print(f"Region {region} not found, using sum for each unique date.") + if "zone" in list(df.columns): + if "region" in list(df.columns): + return df[ + [a and b for a, b in zip(df["zone"] == "all", df["region"] == "all")] + ].drop(columns=["zone", "region"]) + df = df[df["zone"] == "all"].drop(columns=["zone"]) + elif "region" in list(df.columns): + df = df[df["region"] == "all"].drop(columns=["region"]) + return df + + +def _split_colors(num_cols: int, split: str = "zone") -> List[str]: + options = list(_COLOR_ZONES) + if split == "region": + options.reverse() + if len(options) >= num_cols: + return options[:num_cols] + num_lengths = int(np.ceil(num_cols / len(options))) + new_cols = options * num_lengths + return new_cols[:num_cols] + + def _find_scale_factor( table_provider: EnsembleTableProvider, scale: Union[Co2MassScale, Co2VolumeScale], @@ -58,40 +167,65 @@ def _read_terminal_co2_volumes( table_provider: EnsembleTableProvider, realizations: List[int], scale: Union[Co2MassScale, Co2VolumeScale], + containment_info: Dict[str, Union[str, None, List[str]]], ) -> pandas.DataFrame: + view = containment_info["containment_view"] records: Dict[str, List[Any]] = { "real": [], "amount": [], - "containment": [], "phase": [], "sort_key": [], "sort_key_secondary": [], } + if view == ContainmentViews.ZONESPLIT: + records["zone"] = [] + elif view == ContainmentViews.REGIONSPLIT: + records["region"] = [] + else: + records["containment"] = [] scale_factor = _find_scale_factor(table_provider, scale) for real in realizations: - df = _read_dataframe(table_provider, real, scale_factor) - last = df.iloc[np.argmax(df["date"])] - label = str(real) - records["real"] += [label] * 6 - records["amount"] += [ - last["aqueous_contained"], - last["gas_contained"], - last["aqueous_outside"], - last["gas_outside"], - last["aqueous_hazardous"], - last["gas_hazardous"], - ] - records["containment"] += [ - "contained", - "contained", - "outside", - "outside", - "hazardous", - "hazardous", - ] - records["phase"] += ["aqueous", "gas", "aqueous", "gas", "aqueous", "gas"] - records["sort_key"] += [last["gas_hazardous"]] * 6 - records["sort_key_secondary"] += [last["gas_outside"]] * 6 + df = _read_dataframe(table_provider, real, scale_factor, containment_info) + if view != ContainmentViews.CONTAINMENTSPLIT: + split = "zone" if view == ContainmentViews.ZONESPLIT else "region" + last_ = df[df["date"] == np.max(df["date"])] + for i in range(last_.shape[0]): + last = last_.iloc[i] + label = str(real) + + records["real"] += [label] * 2 + records["amount"] += [ + last["aqueous"], + last["gas"], + ] + records["phase"] += ["aqueous", "gas"] + records[split] += [last[split]] * 2 + records["sort_key"] += [label] * 2 + records["sort_key_secondary"] += [last[split]] * 2 + else: + last = df.iloc[np.argmax(df["date"])] + label = str(real) + + records["real"] += [label] * 6 + records["amount"] += [ + last["aqueous_contained"], + last["gas_contained"], + last["aqueous_outside"], + last["gas_outside"], + last["aqueous_hazardous"], + last["gas_hazardous"], + ] + records["containment"] += [ + "contained", + "contained", + "outside", + "outside", + "hazardous", + "hazardous", + ] + records["phase"] += ["aqueous", "gas", "aqueous", "gas", "aqueous", "gas"] + records["sort_key"] += [last["gas_hazardous"]] * 6 + records["sort_key_secondary"] += [last["gas_outside"]] * 6 df = pandas.DataFrame.from_dict(records) df.sort_values( ["sort_key", "sort_key_secondary"], inplace=True, ascending=[True, True] @@ -103,11 +237,14 @@ def _read_co2_volumes( table_provider: EnsembleTableProvider, realizations: List[int], scale: Union[Co2MassScale, Co2VolumeScale], + containment_info: Dict[str, Union[str, None, List[str]]], ) -> pandas.DataFrame: scale_factor = _find_scale_factor(table_provider, scale) return pandas.concat( [ - _read_dataframe(table_provider, real, scale_factor).assign(realization=real) + _read_dataframe( + table_provider, real, scale_factor, containment_info + ).assign(realization=real) for real in realizations ] ) @@ -141,21 +278,36 @@ def generate_co2_volume_figure( table_provider: EnsembleTableProvider, realizations: List[int], scale: Union[Co2MassScale, Co2VolumeScale], + containment_info: Dict[str, Any], ) -> go.Figure: - df = _read_terminal_co2_volumes(table_provider, realizations, scale) + df = _read_terminal_co2_volumes( + table_provider, realizations, scale, containment_info + ) + if containment_info["containment_view"] == ContainmentViews.ZONESPLIT: + color = "zone" + cat_ord = {"zone": containment_info["zones"], "phase": ["gas", "aqueous"]} + colors = _split_colors(len(containment_info["zones"])) + elif containment_info["containment_view"] == ContainmentViews.REGIONSPLIT: + color = "region" + cat_ord = {"region": containment_info["regions"], "phase": ["gas", "aqueous"]} + colors = _split_colors(len(containment_info["regions"]), "region") + else: + color = "containment" + cat_ord = { + "containment": ["hazardous", "outside", "contained"], + "phase": ["gas", "aqueous"], + } + colors = [_COLOR_HAZARDOUS, _COLOR_OUTSIDE, _COLOR_CONTAINED] fig = px.bar( df, y="real", x="amount", - color="containment", + color=color, pattern_shape="phase", title="End-state CO2 containment (all realizations)", orientation="h", - category_orders={ - "containment": ["hazardous", "outside", "contained"], - "phase": ["gas", "aqueous"], - }, - color_discrete_sequence=[_COLOR_HAZARDOUS, _COLOR_OUTSIDE, _COLOR_CONTAINED], + category_orders=cat_ord, + color_discrete_sequence=colors, ) fig.layout.legend.title.text = "" fig.layout.legend.orientation = "h" @@ -173,8 +325,11 @@ def generate_co2_time_containment_one_realization_figure( scale: Union[Co2MassScale, Co2VolumeScale], time_series_realization: int, y_limits: List[Optional[float]], + containment_info: Dict[str, Any], ) -> go.Figure: - df = _read_co2_volumes(table_provider, [time_series_realization], scale) + df = _read_co2_volumes( + table_provider, [time_series_realization], scale, containment_info + ) df.sort_values(by="date", inplace=True) df = df.drop( columns=[ @@ -188,20 +343,43 @@ def generate_co2_time_containment_one_realization_figure( "total_aqueous", ] ) - df = pandas.melt(df, id_vars=["date"]) - df = df.rename(columns={"value": "mass", "variable": "type"}) - df.sort_values(by="date", inplace=True) - _change_type_names(df) - if y_limits[0] is None and y_limits[1] is not None: - y_limits[0] = 0.0 - elif y_limits[1] is None and y_limits[0] is not None: - y_limits[1] = max(df.groupby("date")["mass"].sum()) * 1.05 - fig = px.area( - df, - x="date", - y="mass", - color="type", - category_orders={ + if containment_info["containment_view"] == ContainmentViews.ZONESPLIT: + df = pandas.melt(df, id_vars=["date", "zone"]) + df["variable"] = df["zone"] + ", " + df["variable"] + df = df.drop(columns=["zone"]) + cat_ord = { + "type": [ + zone_name + ", " + phase + for zone_name in containment_info["zones"] + for phase in ["gas", "aqueous"] + ] + } + pattern = ["", "/"] * len(containment_info["zones"]) + colors = [ + col + for col in _split_colors(len(containment_info["zones"])) + for i in range(2) + ] + elif containment_info["containment_view"] == ContainmentViews.REGIONSPLIT: + df = pandas.melt(df, id_vars=["date", "region"]) + df["variable"] = df["region"] + ", " + df["variable"] + df = df.drop(columns=["region"]) + cat_ord = { + "type": [ + region_name + ", " + phase + for region_name in containment_info["regions"] + for phase in ["gas", "aqueous"] + ] + } + pattern = ["", "/"] * len(containment_info["regions"]) + colors = [ + col + for col in _split_colors(len(containment_info["regions"]), "region") + for i in range(2) + ] + else: + df = pandas.melt(df, id_vars=["date"]) + cat_ord = { "type": [ "Hazardous mobile gas", "Hazardous aqueous", @@ -210,24 +388,32 @@ def generate_co2_time_containment_one_realization_figure( "Contained mobile gas", "Contained aqueous", ] - }, - color_discrete_sequence=[ + } + pattern = ["", "/"] * 3 + colors = [ _COLOR_HAZARDOUS, _COLOR_HAZARDOUS, _COLOR_OUTSIDE, _COLOR_OUTSIDE, _COLOR_CONTAINED, _COLOR_CONTAINED, - ], + ] + df = df.rename(columns={"value": "mass", "variable": "type"}) + df.sort_values(by="date", inplace=True) + _change_type_names(df) + if y_limits[0] is None and y_limits[1] is not None: + y_limits[0] = 0.0 + elif y_limits[1] is None and y_limits[0] is not None: + y_limits[1] = max(df.groupby("date")["mass"].sum()) * 1.05 + fig = px.area( + df, + x="date", + y="mass", + color="type", + category_orders=cat_ord, + color_discrete_sequence=colors, pattern_shape="type", - pattern_shape_sequence=[ - "", - "/", - "", - "/", - "", - "/", - ], # ['', '/', '\\', 'x', '-', '|', '+', '.'], + pattern_shape_sequence=pattern, # ['', '/', '\\', 'x', '-', '|', '+', '.'], range_y=y_limits, ) fig.layout.yaxis.range = y_limits @@ -240,35 +426,95 @@ def generate_co2_time_containment_one_realization_figure( ) fig.layout.xaxis.title = "Time" fig.layout.yaxis.title = scale.value + fig.layout.yaxis.exponentformat = "power" _adjust_figure(fig) return fig +def _prepare_time_figure_options( + df: pandas.DataFrame, + containment_info: Dict[str, Any], +) -> Tuple[pandas.DataFrame, Dict[str, Tuple[str, str, str]], List[str]]: + view = containment_info["containment_view"] + if view != ContainmentViews.CONTAINMENTSPLIT: + split = "zone" if view == ContainmentViews.ZONESPLIT else "region" + options = ( + containment_info["zones"] + if split == "zone" + else containment_info["regions"] + ) + df = df.drop( + columns=[ + "REAL", + "total_gas", + "total_aqueous", + "total_contained", + "total_outside", + "total_hazardous", + "region" if split == "zone" else "zone", + ], + errors="ignore", + ) + df.sort_values(by=["date", "realization"], inplace=True) + df_ = df[["date", "realization"]][df[split] == options[0]].reset_index( + drop=True + ) + for name in options: + part_df = df[["total", "gas", "aqueous"]][df[split] == name] + part_df = part_df.rename( + columns={ + "total": name + ", total", + "gas": name + ", gas", + "aqueous": name + ", aqueous", + } + ).reset_index(drop=True) + df_ = pandas.concat([df_, part_df], axis=1) + colors = _split_colors(len(options), split) + cols_to_plot = {} + for phase, line_type in zip( + ["total", "gas", "aqueous"], ["solid", "dot", "dash"] + ): + for name, col in zip(options, colors): + cols_to_plot[name + ", " + phase] = ( + name + ", " + phase, + line_type, + col, + ) + active_cols_at_startup = [name + ", total" for name in options] + df = df_ + else: + df.sort_values(by="date", inplace=True) + cols_to_plot = { + "Total": ("total", "solid", _COLOR_TOTAL), + "Contained": ("total_contained", "solid", _COLOR_CONTAINED), + "Outside": ("total_outside", "solid", _COLOR_OUTSIDE), + "Hazardous": ("total_hazardous", "solid", _COLOR_HAZARDOUS), + "Gas": ("total_gas", "dot", _COLOR_TOTAL), + "Aqueous": ("total_aqueous", "dash", _COLOR_TOTAL), + "Contained mobile gas": ("gas_contained", "dot", _COLOR_CONTAINED), + "Outside mobile gas": ("gas_outside", "dot", _COLOR_OUTSIDE), + "Hazardous mobile gas": ("gas_hazardous", "dot", _COLOR_HAZARDOUS), + "Contained aqueous": ("aqueous_contained", "dash", _COLOR_CONTAINED), + "Outside aqueous": ("aqueous_outside", "dash", _COLOR_OUTSIDE), + "Hazardous aqueous": ("aqueous_hazardous", "dash", _COLOR_HAZARDOUS), + } + active_cols_at_startup = ["Total", "Outside", "Hazardous"] + return df, cols_to_plot, active_cols_at_startup + + def generate_co2_time_containment_figure( table_provider: EnsembleTableProvider, realizations: List[int], scale: Union[Co2MassScale, Co2VolumeScale], + containment_info: Dict[str, Any], ) -> go.Figure: - df = _read_co2_volumes(table_provider, realizations, scale) - df.sort_values(by="date", inplace=True) + df = _read_co2_volumes(table_provider, realizations, scale, containment_info) + df, cols_to_plot, active_cols_at_startup = _prepare_time_figure_options( + df, containment_info + ) fig = go.Figure() - cols_to_plot = { - "Total": ("total", "solid", _COLOR_TOTAL), - "Contained": ("total_contained", "solid", _COLOR_CONTAINED), - "Outside": ("total_outside", "solid", _COLOR_OUTSIDE), - "Hazardous": ("total_hazardous", "solid", _COLOR_HAZARDOUS), - "Gas": ("total_gas", "dot", _COLOR_TOTAL), - "Aqueous": ("total_aqueous", "dash", _COLOR_TOTAL), - "Contained mobile gas": ("gas_contained", "dot", _COLOR_CONTAINED), - "Outside mobile gas": ("gas_outside", "dot", _COLOR_OUTSIDE), - "Hazardous mobile gas": ("gas_hazardous", "dot", _COLOR_HAZARDOUS), - "Contained aqueous": ("aqueous_contained", "dash", _COLOR_CONTAINED), - "Outside aqueous": ("aqueous_outside", "dash", _COLOR_OUTSIDE), - "Hazardous aqueous": ("aqueous_hazardous", "dash", _COLOR_HAZARDOUS), - } - active_cols_at_startup = ["Total", "Outside", "Hazardous"] # Generate dummy scatters for legend entries - dummy_args = {"mode": "lines", "hoverinfo": "none"} + dummy_args = {"x": df["date"], "mode": "lines", "hoverinfo": "none"} for col, value in cols_to_plot.items(): args = { "line_dash": value[1], @@ -305,8 +551,8 @@ def generate_co2_time_containment_figure( fig.layout.title = "CO2 containment (all realizations)" fig.layout.xaxis.title = "Time" fig.layout.yaxis.title = scale.value - fig.layout.yaxis.exponentformat = "none" - fig.layout.yaxis.range = (0, 1.05 * df["total"].max()) + fig.layout.yaxis.exponentformat = "power" + fig.layout.yaxis.autorange = True _adjust_figure(fig) # fig.update_layout(legend=dict(font=dict(size=8)), legend_tracegroupgap=0) return fig diff --git a/webviz_subsurface/plugins/_co2_leakage/_utilities/generic.py b/webviz_subsurface/plugins/_co2_leakage/_utilities/generic.py index aec549150..c66f5d5e6 100644 --- a/webviz_subsurface/plugins/_co2_leakage/_utilities/generic.py +++ b/webviz_subsurface/plugins/_co2_leakage/_utilities/generic.py @@ -9,6 +9,9 @@ class MapAttribute(Enum): MAX_AMFG = "Maximum AMFG" SGAS_PLUME = "Plume (SGAS)" AMFG_PLUME = "Plume (AMFG)" + MASS = "Mass" + DISSOLVED = "Dissolved mass" + FREE = "Free mass" class Co2MassScale(StrEnum): @@ -26,8 +29,7 @@ class Co2VolumeScale(StrEnum): class GraphSource(StrEnum): UNSMRY = "UNSMRY" CONTAINMENT_MASS = "Containment Data (mass)" - CONTAINMENT_VOLUME_ACTUAL = "Containment Data (volume, actual)" - CONTAINMENT_VOLUME_ACTUAL_SIMPLE = "Containment Data (volume, actual_simple)" + CONTAINMENT_ACTUAL_VOLUME = "Containment Data (volume, actual)" class LayoutLabels(str, Enum): @@ -39,6 +41,8 @@ class LayoutLabels(str, Enum): SHOW_WELLS = "Show wells" WELL_FILTER = "Well filter" COMMON_SELECTIONS = "Options and global filters" + FEEDBACK = "User feedback" + VISUALIZATION_UPDATE = "Update threshold" # pylint: disable=too-few-public-methods @@ -52,3 +56,24 @@ class LayoutStyle: "line-height": "30px", "background-color": "lightgrey", } + + FEEDBACK_BUTTON = { + "marginBottom": "10px", + "width": "100%", + "height": "30px", + "line-height": "30px", + "background-color": "lightgrey", + } + + VISUALIZATION_BUTTON = { + "marginLeft": "10px", + "height": "30px", + "line-height": "30px", + "background-color": "lightgrey", + } + + +class ContainmentViews(StrEnum): + CONTAINMENTSPLIT = "Split into containment polygons" + ZONESPLIT = "Split into zones" + REGIONSPLIT = "Split into regions" diff --git a/webviz_subsurface/plugins/_co2_leakage/_utilities/initialization.py b/webviz_subsurface/plugins/_co2_leakage/_utilities/initialization.py index bd7848dc7..1f40be077 100644 --- a/webviz_subsurface/plugins/_co2_leakage/_utilities/initialization.py +++ b/webviz_subsurface/plugins/_co2_leakage/_utilities/initialization.py @@ -1,4 +1,8 @@ +import glob import logging +import os +import warnings +from pathlib import Path from typing import Dict, List, Optional from webviz_config import WebvizSettings @@ -10,12 +14,19 @@ EnsembleTableProviderFactory, ) from webviz_subsurface._utils.webvizstore_functions import read_csv -from webviz_subsurface.plugins._co2_leakage._utilities.generic import MapAttribute +from webviz_subsurface.plugins._co2_leakage._utilities.co2volume import ( + read_zone_and_region_options, +) +from webviz_subsurface.plugins._co2_leakage._utilities.generic import ( + GraphSource, + MapAttribute, +) from webviz_subsurface.plugins._map_viewer_fmu._tmp_well_pick_provider import ( WellPickProvider, ) LOGGER = logging.getLogger(__name__) +WARNING_THRESHOLD_CSV_FILE_SIZE_MB = 100.0 def init_map_attribute_names( @@ -27,6 +38,9 @@ def init_map_attribute_names( MapAttribute.MIGRATION_TIME: "migrationtime", MapAttribute.MAX_SGAS: "max_sgas", MapAttribute.MAX_AMFG: "max_amfg", + MapAttribute.MASS: "co2-mass-total", + MapAttribute.DISSOLVED: "co2-mass-aqu-phase", + MapAttribute.FREE: "co2-mass-gas-phase", } return {MapAttribute[key]: value for key, value in mapping.items()} @@ -45,17 +59,23 @@ def init_surface_providers( def init_well_pick_provider( - well_pick_path: Optional[str], + well_pick_dict: Dict[str, Optional[str]], map_surface_names_to_well_pick_names: Optional[Dict[str, str]], -) -> Optional[WellPickProvider]: - if well_pick_path is None: - return None - try: - return WellPickProvider( - read_csv(well_pick_path), map_surface_names_to_well_pick_names - ) - except OSError: - return None +) -> Dict[str, Optional[WellPickProvider]]: + well_pick_provider: Dict[str, Optional[WellPickProvider]] = {} + ensembles = list(well_pick_dict.keys()) + for ens in ensembles: + well_pick_path = well_pick_dict[ens] + if well_pick_path is None: + well_pick_provider[ens] = None + else: + try: + well_pick_provider[ens] = WellPickProvider( + read_csv(well_pick_path), map_surface_names_to_well_pick_names + ) + except OSError: + well_pick_provider[ens] = None + return well_pick_provider def init_table_provider( @@ -65,6 +85,16 @@ def init_table_provider( providers = {} factory = EnsembleTableProviderFactory.instance() for ens, ens_path in ensemble_roots.items(): + max_size_mb = _find_max_file_size_mb(ens_path, table_rel_path) + if max_size_mb > WARNING_THRESHOLD_CSV_FILE_SIZE_MB: + text = ( + "Some CSV-files are very large and might create problems when loading." + ) + text += f"\n ensembles: {ens}" + text += f"\n CSV-files: {table_rel_path}" + text += f"\n Max size : {max_size_mb:.2f} MB" + LOGGER.warning(text) + try: providers[ens] = factory.create_from_per_realization_csv_file( ens_path, table_rel_path @@ -74,3 +104,72 @@ def init_table_provider( f'Did not load "{table_rel_path}" for ensemble "{ens}" with error {exc}' ) return providers + + +def _find_max_file_size_mb(ens_path: str, table_rel_path: str) -> float: + glob_pattern = os.path.join(ens_path, table_rel_path) + paths = glob.glob(glob_pattern) + max_size = 0.0 + for file in paths: + if os.path.exists(file): + file_stats = os.stat(file) + size_in_mb = file_stats.st_size / (1024 * 1024) + max_size = max(max_size, size_in_mb) + return max_size + + +def init_zone_and_region_options( + ensemble_roots: Dict[str, str], + mass_table: Dict[str, EnsembleTableProvider], + actual_volume_table: Dict[str, EnsembleTableProvider], + ensemble_provider: Dict[str, EnsembleSurfaceProvider], +) -> Dict[str, Dict[str, Dict[str, List[str]]]]: + options: Dict[str, Dict[str, Dict[str, List[str]]]] = {} + for ens in ensemble_roots.keys(): + options[ens] = {} + real = ensemble_provider[ens].realizations()[0] + for source, table in zip( + [GraphSource.CONTAINMENT_MASS, GraphSource.CONTAINMENT_ACTUAL_VOLUME], + [mass_table, actual_volume_table], + ): + try: + options[ens][source] = read_zone_and_region_options(table[ens], real) + except KeyError: + options[ens][source] = {"zones": [], "regions": []} + options[ens][GraphSource.UNSMRY] = {"zones": [], "regions": []} + return options + + +def process_files( + cont_bound: Optional[str], + haz_bound: Optional[str], + well_file: Optional[str], + ensemble_paths: Dict[str, str], +) -> List[Dict[str, Optional[str]]]: + """ + Checks if the files exist (otherwise gives a warning and returns None) + Concatenates ensemble root dir and path to file if relative + """ + ensembles = list(ensemble_paths.keys()) + return [ + {ens: _process_file(source, ensemble_paths[ens]) for ens in ensembles} + for source in [cont_bound, haz_bound, well_file] + ] + + +def _process_file(file: Optional[str], ensemble_path: str) -> Optional[str]: + if file is not None: + if Path(file).is_absolute(): + if os.path.isfile(Path(file)): + return file + warnings.warn(f"Cannot find specified file {file}.") + return None + file = os.path.join(Path(ensemble_path).parents[1], file) + if not os.path.isfile(file): + warnings.warn( + f"Cannot find specified file {file}.\n" + "Note that relative paths are accepted from ensemble root " + "(directory with the realizations)." + ) + return None + return file diff --git a/webviz_subsurface/plugins/_co2_leakage/_utilities/surface_publishing.py b/webviz_subsurface/plugins/_co2_leakage/_utilities/surface_publishing.py index b7876f17f..83f8b7ae0 100644 --- a/webviz_subsurface/plugins/_co2_leakage/_utilities/surface_publishing.py +++ b/webviz_subsurface/plugins/_co2_leakage/_utilities/surface_publishing.py @@ -1,6 +1,7 @@ from dataclasses import dataclass -from typing import List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union +import numpy as np import xtgeo from webviz_subsurface._providers import ( @@ -15,10 +16,13 @@ from webviz_subsurface._providers.ensemble_surface_provider.ensemble_surface_provider import ( SurfaceStatistic, ) +from webviz_subsurface.plugins._co2_leakage._utilities.generic import MapAttribute from webviz_subsurface.plugins._co2_leakage._utilities.plume_extent import ( truncate_surfaces, ) +SCALE_DICT = {"kg": 1, "tons": 1000, "M tons": 1000000} + @dataclass class TruncatedSurfaceAddress: @@ -38,27 +42,42 @@ def publish_and_get_surface_metadata( server: SurfaceImageServer, provider: EnsembleSurfaceProvider, address: Union[SurfaceAddress, TruncatedSurfaceAddress], -) -> Tuple[Optional[SurfaceImageMeta], str]: + visualization_info: Dict[str, Any], + map_attribute_names: Dict[MapAttribute, str], +) -> Tuple[Optional[SurfaceImageMeta], str, Optional[Any]]: if isinstance(address, TruncatedSurfaceAddress): return _publish_and_get_truncated_surface_metadata(server, provider, address) provider_id: str = provider.provider_id() qualified_address = QualifiedSurfaceAddress(provider_id, address) surf_meta = server.get_surface_metadata(qualified_address) + summed_mass = None if not surf_meta: # This means we need to compute the surface surface = provider.get_surface(address) if not surface: raise ValueError(f"Could not get surface for address: {address}") + if address.attribute in [ + map_attribute_names[MapAttribute.MASS], + map_attribute_names[MapAttribute.FREE], + map_attribute_names[MapAttribute.DISSOLVED], + ]: + surface.values = surface.values / SCALE_DICT[visualization_info["unit"]] + summed_mass = np.ma.sum(surface.values) + if ( + address.attribute != map_attribute_names[MapAttribute.MIGRATION_TIME] + and visualization_info["threshold"] >= 0 + ): + surface.operation("elile", visualization_info["threshold"]) server.publish_surface(qualified_address, surface) surf_meta = server.get_surface_metadata(qualified_address) - return surf_meta, server.encode_partial_url(qualified_address) + return surf_meta, server.encode_partial_url(qualified_address), summed_mass def _publish_and_get_truncated_surface_metadata( server: SurfaceImageServer, provider: EnsembleSurfaceProvider, address: TruncatedSurfaceAddress, -) -> Tuple[Optional[SurfaceImageMeta], str]: +) -> Tuple[Optional[SurfaceImageMeta], str, Optional[Any]]: qualified_address = QualifiedSurfaceAddress( provider.provider_id(), # TODO: Should probably use a dedicated address type for this. Statistical surface @@ -74,13 +93,15 @@ def _publish_and_get_truncated_surface_metadata( ), ) surf_meta = server.get_surface_metadata(qualified_address) + summed_mass = None if surf_meta is None: surface = _generate_surface(provider, address) if surface is None: raise ValueError(f"Could not generate surface for address: {address}") + summed_mass = np.ma.sum(surface.values) server.publish_surface(qualified_address, surface) surf_meta = server.get_surface_metadata(qualified_address) - return surf_meta, server.encode_partial_url(qualified_address) + return surf_meta, server.encode_partial_url(qualified_address), summed_mass def _generate_surface( diff --git a/webviz_subsurface/plugins/_co2_leakage/views/mainview/settings.py b/webviz_subsurface/plugins/_co2_leakage/views/mainview/settings.py index ce6b4f81c..530b943c9 100644 --- a/webviz_subsurface/plugins/_co2_leakage/views/mainview/settings.py +++ b/webviz_subsurface/plugins/_co2_leakage/views/mainview/settings.py @@ -1,8 +1,8 @@ -from typing import Any, Dict, List, Optional, Tuple +import warnings +from typing import Any, Dict, List, Optional, Tuple, Union -import dash import webviz_core_components as wcc -from dash import Input, Output, State, callback, dcc, html +from dash import Input, Output, State, callback, dcc, html, no_update from dash.development.base_component import Component from webviz_config.utils import StrEnum from webviz_config.webviz_plugin_subclasses import SettingsGroupABC @@ -14,6 +14,7 @@ from webviz_subsurface.plugins._co2_leakage._utilities.callbacks import property_origin from webviz_subsurface.plugins._co2_leakage._utilities.generic import ( Co2MassScale, + ContainmentViews, GraphSource, LayoutLabels, LayoutStyle, @@ -27,6 +28,7 @@ class Ids(StrEnum): OPTIONS_DIALOG = "options-dialog" OPTIONS_DIALOG_OPTIONS = "options-dialog-options" OPTIONS_DIALOG_WELL_FILTER = "options-dialog-well-filter" + WELL_FILTER_HEADER = "well-filter-header" FORMATION = "formation" ENSEMBLE = "ensemble" @@ -46,10 +48,20 @@ class Ids(StrEnum): Y_MAX_GRAPH = "y-max-graph" Y_MIN_AUTO_GRAPH = "y-min-auto-graph" Y_MAX_AUTO_GRAPH = "y-max-auto-graph" + ZONE = "zone" + REGION = "region" + CONTAINMENT_VIEW = "containment_view" PLUME_THRESHOLD = "plume-threshold" PLUME_SMOOTHING = "plume-smoothing" + VISUALIZATION_THRESHOLD = "visualization-threshold" + VISUALIZATION_UPDATE = "visualization-update" + MASS_UNIT = "mass-unit" + + FEEDBACK_BUTTON = "feedback-button" + FEEDBACK = "feedback" + def __init__( self, ensemble_paths: Dict[str, str], @@ -57,7 +69,8 @@ def __init__( initial_surface: Optional[str], map_attribute_names: Dict[MapAttribute, str], color_scale_names: List[str], - well_names: List[str], + well_names_dict: Dict[str, List[str]], + zone_and_region_options: Dict[str, Dict[str, Dict[str, List[str]]]], ): super().__init__("Settings") self._ensemble_paths = ensemble_paths @@ -65,11 +78,22 @@ def __init__( self._map_attribute_names = map_attribute_names self._color_scale_names = color_scale_names self._initial_surface = initial_surface - self._well_names = well_names + self._well_names_dict = well_names_dict + self._zone_and_region_options = zone_and_region_options + self._has_zones = max( + len(inner_dict["zones"]) > 0 + for outer_dict in zone_and_region_options.values() + for inner_dict in outer_dict.values() + ) + self._has_regions = max( + len(inner_dict["regions"]) > 0 + for outer_dict in zone_and_region_options.values() + for inner_dict in outer_dict.values() + ) def layout(self) -> List[Component]: return [ - DialogLayout(self._well_names), + DialogLayout(self._well_names_dict, list(self._ensemble_paths.keys())), OpenDialogButton(), EnsembleSelectorLayout( self.register_component_unique_id(self.Ids.ENSEMBLE), @@ -86,19 +110,35 @@ def layout(self) -> List[Component]: self.register_component_unique_id(self.Ids.CM_MAX), self.register_component_unique_id(self.Ids.CM_MIN_AUTO), self.register_component_unique_id(self.Ids.CM_MAX_AUTO), + self.register_component_unique_id(self.Ids.VISUALIZATION_THRESHOLD), + self.register_component_unique_id(self.Ids.VISUALIZATION_UPDATE), + self.register_component_unique_id(self.Ids.MASS_UNIT), ), GraphSelectorsLayout( self.register_component_unique_id(self.Ids.GRAPH_SOURCE), self.register_component_unique_id(self.Ids.CO2_SCALE), - self.register_component_unique_id(self.Ids.Y_MIN_GRAPH), - self.register_component_unique_id(self.Ids.Y_MAX_GRAPH), - self.register_component_unique_id(self.Ids.Y_MIN_AUTO_GRAPH), - self.register_component_unique_id(self.Ids.Y_MAX_AUTO_GRAPH), + [ + self.register_component_unique_id(self.Ids.Y_MIN_GRAPH), + self.register_component_unique_id(self.Ids.Y_MIN_AUTO_GRAPH), + ], + [ + self.register_component_unique_id(self.Ids.Y_MAX_GRAPH), + self.register_component_unique_id(self.Ids.Y_MAX_AUTO_GRAPH), + ], + [ + self.register_component_unique_id(self.Ids.ZONE), + self.register_component_unique_id(self.Ids.REGION), + self.register_component_unique_id(self.Ids.CONTAINMENT_VIEW), + ], + self._has_zones, + self._has_regions, ), ExperimentalFeaturesLayout( self.register_component_unique_id(self.Ids.PLUME_THRESHOLD), self.register_component_unique_id(self.Ids.PLUME_SMOOTHING), ), + FeedbackLayout(), + FeedbackButton(), ] def set_callbacks(self) -> None: @@ -132,6 +172,11 @@ def set_formations( # Map prop_name = property_origin(MapAttribute(prop), self._map_attribute_names) surfaces = surface_provider.surface_names_for_attribute(prop_name) + if len(surfaces) == 0: + warnings.warn( + f"Surface not found for property: {prop}.\n" + f"Expected name: --{prop_name}--.gri" + ) # Formation names formations = [{"label": v.title(), "value": v} for v in surfaces] picked_formation = None @@ -139,7 +184,7 @@ def set_formations( if current_value is None and self._initial_surface in surfaces: picked_formation = self._initial_surface elif current_value in surfaces: - picked_formation = dash.no_update + picked_formation = no_update else: picked_formation = ( "all" @@ -176,6 +221,16 @@ def set_color_range_data( ) -> Tuple[bool, bool]: return len(min_auto) == 1, len(max_auto) == 1 + @callback( + Output( + self.component_unique_id(self.Ids.VISUALIZATION_THRESHOLD).to_string(), + "disabled", + ), + Input(self.component_unique_id(self.Ids.PROPERTY).to_string(), "value"), + ) + def set_visualization_threshold(attribute: str) -> bool: + return MapAttribute(attribute) == MapAttribute.MIGRATION_TIME + @callback( Output( self.component_unique_id(self.Ids.Y_MIN_GRAPH).to_string(), "disabled" @@ -195,6 +250,119 @@ def set_y_min_max( ) -> Tuple[bool, bool]: return len(min_auto) == 1, len(max_auto) == 1 + @callback( + Output(self.component_unique_id(self.Ids.ZONE).to_string(), "options"), + Output(self.component_unique_id(self.Ids.ZONE).to_string(), "value"), + Input(self.component_unique_id(self.Ids.GRAPH_SOURCE).to_string(), "value"), + Input(self.component_unique_id(self.Ids.ENSEMBLE).to_string(), "value"), + State(self.component_unique_id(self.Ids.ZONE).to_string(), "value"), + ) + def set_zones( + source: GraphSource, + ensemble: str, + current_value: str, + ) -> Tuple[List[Dict[str, str]], Union[Any, str]]: + if ensemble is not None: + zones = self._zone_and_region_options[ensemble][source]["zones"] + if len(zones) > 0: + options = [{"label": zone.title(), "value": zone} for zone in zones] + return options, no_update if current_value in zones else "all" + return [], None + + @callback( + Output(self.component_unique_id(self.Ids.ZONE).to_string(), "disabled"), + Input(self.component_unique_id(self.Ids.ZONE).to_string(), "value"), + Input(self.component_unique_id(self.Ids.REGION).to_string(), "value"), + Input( + self.component_unique_id(self.Ids.CONTAINMENT_VIEW).to_string(), "value" + ), + ) + def disable_zone(zone: str, region: str, containment_view: str) -> bool: + return ( + zone is None + or containment_view != ContainmentViews.CONTAINMENTSPLIT + or (region is not None and region != "all") + ) + + @callback( + Output(self.component_unique_id(self.Ids.REGION).to_string(), "options"), + Output(self.component_unique_id(self.Ids.REGION).to_string(), "value"), + Input(self.component_unique_id(self.Ids.GRAPH_SOURCE).to_string(), "value"), + Input(self.component_unique_id(self.Ids.ENSEMBLE).to_string(), "value"), + State(self.component_unique_id(self.Ids.REGION).to_string(), "value"), + ) + def set_regions( + source: GraphSource, + ensemble: str, + current_value: str, + ) -> Tuple[List[Dict[str, str]], Union[Any, str]]: + if ensemble is not None: + regions = self._zone_and_region_options[ensemble][source]["regions"] + if len(regions) > 0: + options = [{"label": reg.title(), "value": reg} for reg in regions] + return options, no_update if current_value in regions else "all" + return [], None + + @callback( + Output(self.component_unique_id(self.Ids.REGION).to_string(), "disabled"), + Input(self.component_unique_id(self.Ids.REGION).to_string(), "value"), + Input(self.component_unique_id(self.Ids.ZONE).to_string(), "value"), + Input( + self.component_unique_id(self.Ids.CONTAINMENT_VIEW).to_string(), "value" + ), + ) + def disable_region(region: str, zone: str, containment_view: str) -> bool: + return ( + region is None + or containment_view != ContainmentViews.CONTAINMENTSPLIT + or (zone is not None and zone != "all") + ) + + @callback( + Output( + self.component_unique_id(self.Ids.MASS_UNIT).to_string(), "disabled" + ), + Input(self.component_unique_id(self.Ids.PROPERTY).to_string(), "value"), + ) + def toggle_unit(attribute: str) -> bool: + if MapAttribute(attribute) not in ( + MapAttribute.MASS, + MapAttribute.FREE, + MapAttribute.DISSOLVED, + ): + return True + return False + + @callback( + Output("zone_col", "style"), + Output("region_col", "style"), + Output("both_col", "style"), + Output("zone_region_header", "style"), + Input( + self.component_unique_id(self.Ids.CONTAINMENT_VIEW).to_string(), "value" + ), + ) + def hide_dropdowns(view: str) -> List[Dict[str, str]]: + if view != ContainmentViews.CONTAINMENTSPLIT: + return [{"display": "none"}] * 4 + disp_zone = "flex" if self._has_zones else "none" + disp_region = "flex" if self._has_regions else "none" + disp_either = "flex" if self._has_zones or self._has_regions else "none" + return [ + { + "width": "50%" if self._has_regions else "100%", + "display": disp_zone, + "flex-direction": "column", + }, + { + "width": "50%" if self._has_zones else "100%", + "display": disp_region, + "flex-direction": "column", + }, + {"display": disp_either}, + {"display": disp_either}, + ] + class OpenDialogButton(html.Button): def __init__(self) -> None: @@ -211,7 +379,8 @@ class DialogLayout(wcc.Dialog): def __init__( self, - well_names: List[str], + well_names_dict: Dict[str, List[str]], + ensembles: List[str], ) -> None: checklist_options = [] checklist_values = [] @@ -238,12 +407,15 @@ def __init__( wcc.FlexBox( children=[ html.Div( + id=ViewSettings.Ids.WELL_FILTER_HEADER, style={ "flex": 3, "minWidth": "20px", - "display": "block" if well_names else "none", + "display": ( + "block" if well_names_dict[ensembles[0]] else "none" + ), }, - children=WellFilter(well_names), + children=WellFilter(well_names_dict, ensembles), ), ], style={"width": "20vw"}, @@ -253,15 +425,19 @@ def __init__( class WellFilter(html.Div): - def __init__(self, well_names: List[str]) -> None: + def __init__( + self, well_names_dict: Dict[str, List[str]], ensembles: List[str] + ) -> None: super().__init__( - style={"display": "block" if well_names else "none"}, children=wcc.SelectWithLabel( + style={"display": "block" if well_names_dict[ensembles[0]] else "none"}, label=LayoutLabels.WELL_FILTER, id=ViewSettings.Ids.OPTIONS_DIALOG_WELL_FILTER, - options=[{"label": i, "value": i} for i in well_names], - value=well_names, - size=min(20, len(well_names)), + options=[ + {"label": i, "value": i} for i in well_names_dict[ensembles[0]] + ], + value=well_names_dict[ensembles[0]], + size=min(20, len(well_names_dict[ensembles[0]])), ), ) @@ -297,6 +473,9 @@ def __init__( cm_max_id: str, cm_min_auto_id: str, cm_max_auto_id: str, + visualization_threshold_id: str, + visualization_update_id: str, + mass_unit_id: str, ): default_colormap = ( "turbo (Seq)" @@ -361,6 +540,32 @@ def __init__( ], style=self._CM_RANGE, ), + "Visualization threshold", + html.Div( + [ + dcc.Input( + id=visualization_threshold_id, + type="number", + value=-1.0, + style={"width": "70%"}, + ), + html.Div(style={"width": "5%"}), + html.Button( + "Update", + id=visualization_update_id, + style=LayoutStyle.VISUALIZATION_BUTTON, + n_clicks=0, + ), + ], + style={"display": "flex"}, + ), + "Mass unit (for mass maps)", + wcc.Dropdown( + id=mass_unit_id, + options=["kg", "tons", "M tons"], + value="kg", + clearable=False, + ), ], ) ], @@ -377,15 +582,41 @@ def __init__( self, graph_source_id: str, co2_scale_id: str, - y_min_id: str, - y_max_id: str, - y_min_auto_id: str, - y_max_auto_id: str, + y_min_ids: List[str], + y_max_ids: List[str], + containment_ids: List[str], + has_zones: bool, + has_regions: bool, ): + disp = "flex" if has_zones or has_regions else "none" + disp_zone = "flex" if has_zones else "none" + disp_region = "flex" if has_regions else "none" + only_zone = has_zones and not has_regions + only_region = has_regions and not has_zones + header = "Containment for specific" + if only_zone: + header += " zone" + elif only_region: + header += " region" + options = [ContainmentViews.CONTAINMENTSPLIT] + if has_zones: + options.append(ContainmentViews.ZONESPLIT) + if has_regions: + options.append(ContainmentViews.REGIONSPLIT) super().__init__( label="Graph Settings", open_details=False, children=[ + html.Div( + [ + dcc.RadioItems( + options, + ContainmentViews.CONTAINMENTSPLIT, + id=containment_ids[2], + ), + ], + style={"display": disp, "flex-direction": "column"}, + ), "Source", wcc.Dropdown( id=graph_source_id, @@ -393,6 +624,45 @@ def __init__( value=GraphSource.CONTAINMENT_MASS, clearable=False, ), + html.Div( + header, + id="zone_region_header", + style={"display": disp}, + ), + html.Div( + [ + html.Div( + ([] if only_zone else ["zone"]) + + [ + wcc.Dropdown( + id=containment_ids[0], + clearable=False, + ), + ], + id="zone_col", + style={ + "width": "50%" if has_regions else "100%", + "display": disp_zone, + }, + ), + html.Div( + ([] if only_region else ["region"]) + + [ + wcc.Dropdown( + id=containment_ids[1], + clearable=False, + ), + ], + id="region_col", + style={ + "width": "50%" if has_zones else "100%", + "display": disp_region, + }, + ), + ], + id="both_col", + style={"display": disp}, + ), "Unit", wcc.Dropdown( id=co2_scale_id, @@ -403,11 +673,11 @@ def __init__( "Minimum", html.Div( [ - dcc.Input(id=y_min_id, type="number"), + dcc.Input(id=y_min_ids[0], type="number"), dcc.Checklist( ["Auto"], ["Auto"], - id=y_min_auto_id, + id=y_min_ids[1], ), ], style=self._CM_RANGE, @@ -415,11 +685,11 @@ def __init__( "Maximum", html.Div( [ - dcc.Input(id=y_max_id, type="number"), + dcc.Input(id=y_max_ids[0], type="number"), dcc.Checklist( ["Auto"], ["Auto"], - id=y_max_auto_id, + id=y_max_ids[1], ), ], style=self._CM_RANGE, @@ -494,7 +764,11 @@ def __init__(self, ensemble_id: str, realization_id: str, ensembles: List[str]): def _compile_property_options() -> List[Dict[str, Any]]: return [ - {"label": "SGAS", "value": "", "disabled": True}, + { + "label": html.Span(["SGAS:"], style={"text-decoration": "underline"}), + "value": "", + "disabled": True, + }, { "label": MapAttribute.MIGRATION_TIME.value, "value": MapAttribute.MIGRATION_TIME.value, @@ -504,10 +778,54 @@ def _compile_property_options() -> List[Dict[str, Any]]: "label": MapAttribute.SGAS_PLUME.value, "value": MapAttribute.SGAS_PLUME.value, }, - {"label": "AMFG", "value": "", "disabled": True}, + { + "label": html.Span(["AMFG:"], style={"text-decoration": "underline"}), + "value": "", + "disabled": True, + }, {"label": MapAttribute.MAX_AMFG.value, "value": MapAttribute.MAX_AMFG.value}, { "label": MapAttribute.AMFG_PLUME.value, "value": MapAttribute.AMFG_PLUME.value, }, + { + "label": html.Span(["MASS:"], style={"text-decoration": "underline"}), + "value": "", + "disabled": True, + }, + {"label": MapAttribute.MASS.value, "value": MapAttribute.MASS.value}, + {"label": MapAttribute.DISSOLVED.value, "value": MapAttribute.DISSOLVED.value}, + {"label": MapAttribute.FREE.value, "value": MapAttribute.FREE.value}, ] + + +class FeedbackLayout(wcc.Dialog): + """Layout for the options dialog""" + + def __init__( + self, + ) -> None: + super().__init__( + title=LayoutLabels.FEEDBACK, + id=ViewSettings.Ids.FEEDBACK, + draggable=True, + open=False, + children=[ + dcc.Markdown( + """If you have any feedback regarding the CO2-leakage application, + please contact XXX@XX.X.""" + ) + ], + ) + + +class FeedbackButton(html.Button): + def __init__(self) -> None: + style = LayoutStyle.FEEDBACK_BUTTON + style["display"] = "none" + super().__init__( + LayoutLabels.FEEDBACK, + id=ViewSettings.Ids.FEEDBACK_BUTTON, + style=style, + n_clicks=0, + )