├── tests ├── __init__.py ├── data │ ├── __init__.py │ ├── spe1_st │ │ ├── field_properties_priors │ │ ├── field_properties.tmpl │ │ ├── resources │ │ │ └── refcase │ │ │ │ ├── SPE1CASE2.SMSPEC │ │ │ │ └── SPE1CASE2.UNSMRY │ │ ├── tests │ │ │ ├── conftest.py │ │ │ └── test_webviz_ert.py │ │ ├── obs.txt │ │ └── spe1.ert │ └── snake_oil_data.py ├── test_data_loader.py ├── models │ ├── test_response_model.py │ └── test_ensemble_model.py ├── views │ ├── assets │ │ └── ert-style.css │ ├── test_state_saving.py │ ├── test_observation_view.py │ ├── test_general_stuff.py │ ├── test_response_correlation.py │ ├── test_ensemble_selector.py │ ├── test_parameter_selector.py │ └── test_plot_view.py ├── controllers │ ├── test_controller_functions.py │ └── test_response_correlation_controller.py ├── conftest.py └── plots │ └── test_controller.py ├── webviz_ert ├── __init__.py ├── plugins │ ├── __init__.py │ ├── _webviz_ert.py │ ├── _observation_analyzer.py │ ├── _parameter_comparison.py │ ├── _response_comparison.py │ └── _response_correlation.py ├── models │ ├── data_model.py │ ├── parameter_model.py │ ├── realization.py │ ├── observation.py │ ├── __init__.py │ ├── response.py │ └── ensemble_model.py ├── assets │ ├── webviz-config.yml │ ├── __init__.py │ ├── ert-style.css │ └── ert-style.json ├── views │ ├── __init__.py │ ├── correlation_view.py │ ├── parallel_coordinates_view.py │ ├── element_dropdown_view.py │ ├── response_view.py │ ├── misfit_view.py │ ├── ensemble_selector_view.py │ ├── selector_view.py │ ├── plot_view.py │ └── parameter_view.py ├── controllers │ ├── __init__.py │ ├── controller_functions.py │ ├── parameter_comparison_controller.py │ ├── element_dropdown_controller.py │ ├── plot_view_controller.py │ ├── multi_parameter_controller.py │ ├── observation_response_controller.py │ ├── ensemble_selector_controller.py │ ├── parameter_selector_controller.py │ └── multi_response_controller.py └── __main__.py ├── .github ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── publish_to_pypi.yml │ └── python.yml ├── mypy.ini ├── types_requirements.txt ├── pyproject.toml ├── test_requirements.txt ├── CONTRIBUTING.md ├── SECURITY.md ├── setup.py ├── ci └── testkomodo.sh ├── .gitignore ├── README.md └── CODE_OF_CONDUCT.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/spe1_st/field_properties_priors: -------------------------------------------------------------------------------- 1 | POROSITY UNIFORM 0.1 0.9 2 | X_MID_PERMEABILITY UNIFORM 0.1 0.9 3 | -------------------------------------------------------------------------------- /tests/data/spe1_st/field_properties.tmpl: -------------------------------------------------------------------------------- 1 | { 2 | "porosity":, 3 | "x_mid_permeability": 4 | } 5 | -------------------------------------------------------------------------------- /webviz_ert/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from .__version__ import __version__ 3 | except ImportError: 4 | __version__ = "0.0.0" 5 | -------------------------------------------------------------------------------- /tests/data/spe1_st/resources/refcase/SPE1CASE2.SMSPEC: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/webviz-ert/HEAD/tests/data/spe1_st/resources/refcase/SPE1CASE2.SMSPEC -------------------------------------------------------------------------------- /tests/data/spe1_st/resources/refcase/SPE1CASE2.UNSMRY: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/webviz-ert/HEAD/tests/data/spe1_st/resources/refcase/SPE1CASE2.UNSMRY -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | disallow_untyped_defs = True 4 | show_error_codes = True 5 | 6 | [mypy-pkg_resources] 7 | ignore_missing_imports = True 8 | -------------------------------------------------------------------------------- /types_requirements.txt: -------------------------------------------------------------------------------- 1 | mypy 2 | types-requests 3 | types-setuptools 4 | types-PyYAML 5 | types-python-dateutil 6 | flask>=2.2.5 # not directly required, pinned by Snyk to avoid a vulnerability 7 | dash[testing]<3.0.0 8 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Issue** 2 | Resolves #my_issue 3 | 4 | 5 | **Approach** 6 | _Short description of the approach_ 7 | 8 | 9 | ## Pre review checklist 10 | 11 | - [ ] Added appropriate labels 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools_scm"] 3 | 4 | [tool.pytest.ini_options] 5 | addopts = "-m 'not spe1'" 6 | markers = [ 7 | "spe1: marks tests needing results from ert model of the spe1 test case", 8 | ] 9 | -------------------------------------------------------------------------------- /webviz_ert/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | from ._webviz_ert import WebvizErtPluginABC 2 | from ._response_comparison import ResponseComparison 3 | from ._observation_analyzer import ObservationAnalyzer 4 | from ._parameter_comparison import ParameterComparison 5 | from ._response_correlation import ResponseCorrelation 6 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-mock 3 | pylint 4 | black 5 | scipy 6 | dash[testing]>=2.5.1,<3.0.0 7 | selenium 8 | urllib3<2 9 | flask>=2.2.5 # not directly required, pinned by Snyk to avoid a vulnerability 10 | werkzeug>=3.0.6 # not directly required, pinned by Snyk to avoid a vulnerability 11 | -------------------------------------------------------------------------------- /tests/data/spe1_st/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(scope="session", autouse=True) 5 | def start_storage_server(): 6 | try: 7 | from ert.services import StorageService as Storage 8 | except ImportError: 9 | from ert.services import Storage 10 | 11 | with Storage.start_server() as service: 12 | service.wait_until_ready(timeout=30) 13 | yield service 14 | -------------------------------------------------------------------------------- /tests/test_data_loader.py: -------------------------------------------------------------------------------- 1 | from webviz_ert.data_loader import get_ensembles, refresh_data 2 | 3 | 4 | def test_get_ensembles(tmp_path, mock_data): 5 | ensembles = get_ensembles(project_id=tmp_path) 6 | assert [ensemble["id"] for ensemble in ensembles] == [1, 2, 3, 42] 7 | 8 | 9 | def test_refresh_data(tmp_path, mock_data): 10 | resp = refresh_data(project_id=tmp_path) 11 | assert resp.status_code == 200 12 | -------------------------------------------------------------------------------- /webviz_ert/models/data_model.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info < (3, 11): 4 | from enum import Enum 5 | 6 | class StrEnum(str, Enum): 7 | pass 8 | 9 | else: 10 | from enum import StrEnum 11 | 12 | 13 | class DataType(StrEnum): 14 | RESPONSE = "resp" 15 | PARAMETER = "param" 16 | ENSEMBLE = "ens" 17 | 18 | 19 | class AxisType(StrEnum): 20 | INDEX = "index" 21 | TIMESTAMP = "timestamp" 22 | -------------------------------------------------------------------------------- /tests/data/spe1_st/obs.txt: -------------------------------------------------------------------------------- 1 | SUMMARY_OBSERVATION WOPT_2016 2 | { 3 | VALUE = 1e7; 4 | ERROR = 1e6; 5 | DATE = 2016-01-01; 6 | KEY = WOPT:PROD; 7 | }; 8 | 9 | SUMMARY_OBSERVATION WOPT_2017 10 | { 11 | VALUE = 2e7; 12 | ERROR = 1e6; 13 | DATE = 2017-01-31; 14 | KEY = WOPT:PROD; 15 | }; 16 | 17 | SUMMARY_OBSERVATION WOPT_2018 18 | { 19 | VALUE = 3e7; 20 | ERROR = 1e6; 21 | DATE = 2018-01-31; 22 | KEY = WOPT:PROD; 23 | }; 24 | -------------------------------------------------------------------------------- /tests/models/test_response_model.py: -------------------------------------------------------------------------------- 1 | from webviz_ert.models import Response 2 | 3 | 4 | def test_response_model(mock_data): 5 | resp_model = Response( 6 | name="SNAKE_OIL_GPR_DIFF@199", 7 | ensemble_id="1", 8 | project_id="", 9 | ensemble_size=1, 10 | active_realizations=[0], 11 | resp_schema={"id": "id", "name": "name"}, 12 | ) 13 | assert resp_model.name == "SNAKE_OIL_GPR_DIFF@199" 14 | assert len(resp_model.data.columns) == 1 15 | -------------------------------------------------------------------------------- /webviz_ert/assets/webviz-config.yml: -------------------------------------------------------------------------------- 1 | title: ERT - Visualization tool 2 | 3 | pages: 4 | 5 | - title: Plot Viewer 6 | content: 7 | - ResponseComparison: 8 | 9 | - title: Observation Analyzer 10 | content: 11 | - ObservationAnalyzer: 12 | 13 | - title: Parameter Comparison Viewer 14 | content: 15 | - ParameterComparison: 16 | experimental: False 17 | 18 | - title: Response Correlation Viewer 19 | content: 20 | - ResponseCorrelation: 21 | beta: True 22 | experimental: False 23 | -------------------------------------------------------------------------------- /webviz_ert/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .parameter_view import parameter_view 2 | from .ensemble_selector_view import ensemble_selector_list 3 | from .response_view import response_view 4 | from .plot_view import plot_view_body, plot_view_header, plot_view_menu 5 | from .misfit_view import response_obs_view 6 | from .parallel_coordinates_view import parallel_coordinates_view 7 | from .selector_view import parameter_selector_view 8 | from .correlation_view import correlation_view 9 | from .element_dropdown_view import element_dropdown_view 10 | -------------------------------------------------------------------------------- /webviz_ert/views/correlation_view.py: -------------------------------------------------------------------------------- 1 | from dash.development.base_component import Component 2 | from webviz_config import WebvizPluginABC 3 | 4 | from dash import html 5 | from dash import dcc 6 | 7 | 8 | def correlation_view(id_view: str) -> Component: 9 | return html.Div( 10 | id=f"container_{id_view}", 11 | className="ert-view-container", 12 | children=[ 13 | dcc.Graph( 14 | id=id_view, 15 | className="ert-view-cell", 16 | config={"responsive": True}, 17 | ) 18 | ], 19 | ) 20 | -------------------------------------------------------------------------------- /tests/data/spe1_st/spe1.ert: -------------------------------------------------------------------------------- 1 | QUEUE_SYSTEM LOCAL 2 | QUEUE_OPTION LOCAL MAX_RUNNING 50 3 | 4 | RUNPATH spe1_out/realization-%d/iter-%d 5 | 6 | NUM_REALIZATIONS 2 7 | 8 | ECLBASE SPE1CASE2 9 | REFCASE /resources/refcase/SPE1CASE2 10 | 11 | SUMMARY WOPT:PROD WWPT:PROD WGPT:PROD WWIT:INJ 12 | OBS_CONFIG obs.txt 13 | 14 | GEN_KW FIELD_PROPERTIES field_properties.tmpl field_properties.json field_properties_priors 15 | 16 | FORWARD_MODEL TEMPLATE_RENDER(=parameters.json, =/resources/SPE1CASE2.DATA.jinja2, =SPE1CASE2.DATA) 17 | 18 | FORWARD_MODEL ECLIPSE100(=2022.2) 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | The following is a set of guidelines for contributing to webviz-ert 4 | 5 | ## Ground Rules 6 | 7 | 1. We use PEP8 8 | 1. We use Black code formatting 9 | 1. We use Pylint 10 | 11 | ## Pull Request Process 12 | 13 | 1. Work on your own fork of the main repo 14 | 1. Push your commits and make a draft pull request using the pull request template; follow the 15 | instructions in the template. 16 | 1. Check that your pull request passes all tests. 17 | 1. When all tests have passed and you are happy with your changes, change your pull request to "ready for review" 18 | and ask for a code review. 19 | 1. When your code has been approved - merge your changes. 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /webviz_ert/views/parallel_coordinates_view.py: -------------------------------------------------------------------------------- 1 | from webviz_config import WebvizPluginABC 2 | from dash.development.base_component import Component 3 | 4 | from dash import html 5 | from dash import dcc 6 | import webviz_ert.assets as assets 7 | 8 | 9 | def parallel_coordinates_view(parent: WebvizPluginABC) -> Component: 10 | return html.Div( 11 | className="ert-view-container", 12 | children=[ 13 | dcc.Graph( 14 | id={ 15 | "id": parent.uuid("parallel-coor"), 16 | "type": parent.uuid("graph"), 17 | }, 18 | className="ert-view-cell allow-overflow", 19 | config={"responsive": True}, 20 | ) 21 | ], 22 | ) 23 | -------------------------------------------------------------------------------- /webviz_ert/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from .ensemble_selector_controller import ensemble_list_selector_controller 2 | from .multi_response_controller import multi_response_controller 3 | from .observation_response_controller import observation_response_controller 4 | from .multi_parameter_controller import multi_parameter_controller 5 | from .controller_functions import response_options, parameter_options 6 | from .plot_view_controller import plot_view_controller 7 | from .parameter_comparison_controller import parameter_comparison_controller 8 | from .parameter_selector_controller import parameter_selector_controller 9 | from .response_correlation_controller import response_correlation_controller 10 | from .element_dropdown_controller import element_dropdown_controller 11 | -------------------------------------------------------------------------------- /webviz_ert/views/element_dropdown_view.py: -------------------------------------------------------------------------------- 1 | from dash.development.base_component import Component 2 | from webviz_config import WebvizPluginABC 3 | 4 | from dash import html 5 | from dash import dcc 6 | from webviz_ert.models.data_model import DataType 7 | 8 | 9 | def element_dropdown_view(parent: WebvizPluginABC, data_type: DataType) -> Component: 10 | return html.Div( 11 | [ 12 | dcc.Dropdown( 13 | id=parent.uuid(f"element-dropdown-{data_type}"), 14 | multi=False, 15 | ), 16 | dcc.Store( 17 | id=parent.uuid(f"element-dropdown-store-{data_type}"), 18 | storage_type="session", 19 | data=None, 20 | ), 21 | ], 22 | ) 23 | -------------------------------------------------------------------------------- /webviz_ert/assets/__init__.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | import json 3 | from pathlib import Path 4 | from webviz_config.webviz_assets import WEBVIZ_ASSETS 5 | 6 | 7 | ASSETS_DIR = Path(pkg_resources.resource_filename("webviz_ert", "assets")) 8 | WEBVIZ_ASSETS.add(ASSETS_DIR / "bootstrap-grid.css") 9 | WEBVIZ_ASSETS.add(ASSETS_DIR / "bootstrap.min.css") 10 | WEBVIZ_ASSETS.add(ASSETS_DIR / "ert-style.css") 11 | with open(ASSETS_DIR / "ert-style.json") as f: 12 | ERTSTYLE = json.load(f) 13 | 14 | COLOR_WHEEL = ERTSTYLE["ensemble-selector"]["color_wheel"] 15 | 16 | WEBVIZ_CONFIG = ( 17 | Path(pkg_resources.resource_filename("webviz_ert", "assets")) / "webviz-config.yml" 18 | ) 19 | 20 | 21 | def get_color(index: int) -> str: 22 | return COLOR_WHEEL[index % len(COLOR_WHEEL)] 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build-n-publish: 10 | name: Build and publish Python 🐍 distributions 📦 to PyPI 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v6 14 | with: 15 | fetch-depth: 0 16 | - name: Set up Python 3.12 17 | uses: actions/setup-python@v6 18 | with: 19 | python-version: 3.12 20 | - name: Install dependencies 21 | run: pip install --upgrade setuptools setuptools_scm wheel twine 22 | - name: Build package 23 | run: python setup.py sdist bdist_wheel 24 | - name: Upload deploy 25 | env: 26 | TWINE_USERNAME: __token__ 27 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 28 | run: python -m twine upload dist/* 29 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | If you discover a security vulnerability in this project, please follow these steps to responsibly disclose it: 2 | 3 | 1. **Do not** create a public GitHub issue for the vulnerability. 4 | 2. Follow our guideline for Responsible Disclosure Policy at [https://www.equinor.com/about-us/csirt](https://www.equinor.com/about-us/csirt) to report the issue 5 | 6 | The following information will help us triage your report more quickly: 7 | 8 | - Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 9 | - Full paths of source file(s) related to the manifestation of the issue 10 | - The location of the affected source code (tag/branch/commit or direct URL) 11 | - Any special configuration required to reproduce the issue 12 | - Step-by-step instructions to reproduce the issue 13 | - Proof-of-concept or exploit code (if possible) 14 | - Impact of the issue, including how an attacker might exploit the issue 15 | 16 | We prefer all communications to be in English. 17 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Python 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ["3.11", "3.12"] 17 | os: [ubuntu-latest] 18 | 19 | steps: 20 | - uses: actions/checkout@v6 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v6 23 | with: 24 | python-version: "${{ matrix.python-version }}" 25 | - name: Install 26 | run: | 27 | pip install . 28 | - name: Test with mypy typing 29 | run: | 30 | pip install -r types_requirements.txt 31 | mypy --config-file mypy.ini --package webviz_ert 32 | - name: Test with pytest 33 | run: | 34 | pip install -r test_requirements.txt 35 | pytest 36 | 37 | - name: Run black 38 | if: ${{ matrix.python-version == '3.12'}} 39 | uses: psf/black@stable 40 | -------------------------------------------------------------------------------- /webviz_ert/models/parameter_model.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from typing import List, Any, Optional, Union 3 | from webviz_ert.data_loader import get_data_loader 4 | 5 | 6 | class PriorModel: 7 | def __init__( 8 | self, 9 | function: str, 10 | function_parameter_names: List[str], 11 | function_parameter_values: List[Union[float, int]], 12 | ): 13 | self.function = function 14 | self.function_parameter_names = function_parameter_names 15 | self.function_parameter_values = function_parameter_values 16 | 17 | 18 | class ParametersModel: 19 | def __init__(self, **kwargs: Any): 20 | self._project_id = kwargs["project_id"] 21 | self.group = kwargs["group"] 22 | self.key = kwargs["key"] 23 | self.priors = kwargs["prior"] 24 | self._id = kwargs["param_id"] 25 | self._ensemble_id = kwargs["ensemble_id"] 26 | self._realizations = kwargs.get("realizations") 27 | self._data_df = pd.DataFrame() 28 | self._data_loader = get_data_loader(self._project_id) 29 | 30 | def data_df(self) -> pd.DataFrame: 31 | if self._data_df.empty: 32 | _data_df = self._data_loader.get_ensemble_parameter_data( 33 | ensemble_id=self._ensemble_id, 34 | parameter_name=self.key, 35 | ) 36 | if _data_df is not None: 37 | _data_df.index.name = self.key 38 | self._data_df = _data_df 39 | return self._data_df 40 | -------------------------------------------------------------------------------- /webviz_ert/models/realization.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | import pandas as pd 3 | import math 4 | 5 | 6 | class Realization: 7 | def __init__(self, realization_schema: dict): 8 | self._name = realization_schema["name"] 9 | self._data = realization_schema["data"] 10 | self._univariate_misfits_df = self._extract_univariate_misfits( 11 | realization_schema.get("univariate_misfits") 12 | ) 13 | 14 | def _extract_univariate_misfits( 15 | self, schema: Optional[dict] 16 | ) -> Optional[pd.DataFrame]: 17 | if not schema: 18 | return None 19 | 20 | misfits_list = [] 21 | for _, misfits in schema.items(): 22 | for misfits_instance in misfits: 23 | misfits_list.append(misfits_instance) 24 | 25 | df = pd.DataFrame(data=misfits_list) 26 | df["value_sign"] = df[["value", "sign"]].apply( 27 | lambda row: -1.0 * math.sqrt(row[0]) if row[1] else math.sqrt(row[0]), 28 | axis=1, 29 | ) 30 | return df 31 | 32 | @property 33 | def summarized_misfits_value(self) -> float: 34 | return self.univariate_misfits_df["value"].sum() 35 | 36 | @property 37 | def univariate_misfits_df(self) -> pd.DataFrame: 38 | return self._univariate_misfits_df 39 | 40 | @property 41 | def data(self) -> List[float]: 42 | return self._data 43 | 44 | @property 45 | def name(self) -> str: 46 | return self._name 47 | -------------------------------------------------------------------------------- /webviz_ert/controllers/controller_functions.py: -------------------------------------------------------------------------------- 1 | from typing import List, Set 2 | from webviz_ert.models import Response, EnsembleModel 3 | 4 | 5 | def _valid_response_option(response_filters: List[str], response: Response) -> bool: 6 | if "historical" in response_filters: 7 | if response.name.split(":")[0][-1] == "H": 8 | return False 9 | 10 | if "obs" in response_filters: 11 | return response.has_observations 12 | 13 | return True 14 | 15 | 16 | def response_options( 17 | response_filters: List[str], 18 | ensembles: List[EnsembleModel], 19 | ) -> Set: 20 | response_names = set() 21 | 22 | for ensemble in ensembles: 23 | for name, response in ensemble.responses.items(): 24 | if name in response_names: 25 | continue 26 | if _valid_response_option(response_filters, response): 27 | response_names.add(name) 28 | return response_names 29 | 30 | 31 | def parameter_options(ensembles: List[EnsembleModel], union_keys: bool = True) -> Set: 32 | params_included: Set[str] = set() 33 | for ensemble in ensembles: 34 | if ensemble.parameters: 35 | parameters = set([parameter for parameter in ensemble.parameters]) 36 | if not params_included: 37 | params_included = parameters 38 | elif union_keys: 39 | params_included = params_included.union(parameters) 40 | else: 41 | params_included = params_included.intersection(parameters) 42 | return params_included 43 | -------------------------------------------------------------------------------- /webviz_ert/models/observation.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional, List, Union 2 | import pandas as pd 3 | import datetime 4 | 5 | from webviz_ert.models import indexes_to_axis, AxisType 6 | 7 | 8 | class Observation: 9 | def __init__(self, observation_schema: Dict): 10 | self.name = str(observation_schema["name"]) 11 | self._x_axis = indexes_to_axis(observation_schema["x_axis"]) 12 | self._std = observation_schema["errors"] 13 | self._values = observation_schema["values"] 14 | self._attributes = "" 15 | self._active = [True for _ in self._x_axis] if self._x_axis else [] 16 | 17 | if "attributes" in observation_schema: 18 | for k, v in observation_schema["attributes"].items(): 19 | self._attributes += f"{k}: {v}
" 20 | 21 | def data_df(self) -> pd.DataFrame: 22 | return pd.DataFrame( 23 | data={ 24 | "values": self._values, 25 | "std": self._std, 26 | "x_axis": indexes_to_axis(self._x_axis), 27 | "attributes": self._attributes, 28 | "active": self._active, 29 | } 30 | ) 31 | 32 | @property 33 | def axis(self) -> Optional[List[Union[int, str, datetime.datetime]]]: 34 | return self._x_axis 35 | 36 | @property 37 | def axis_type(self) -> Optional[AxisType]: 38 | if self.axis is None: 39 | return None 40 | if str(self.axis[0]).isnumeric(): 41 | return AxisType.INDEX 42 | return AxisType.TIMESTAMP 43 | -------------------------------------------------------------------------------- /tests/views/assets/ert-style.css: -------------------------------------------------------------------------------- 1 | .ert-label { 2 | white-space: nowrap; 3 | } 4 | .ert-plot-options { 5 | height: 84px !important; 6 | } 7 | .ert-parameter-selector-container-hide { 8 | display: none; 9 | } 10 | .ert-parameter-selector-container-show { 11 | display: block; 12 | } 13 | .ert-parameter-label-checkbox { 14 | display: block; 15 | } 16 | .ert-input-number { 17 | width: 65px; 18 | } 19 | .ert-parameter-view-caption { 20 | width: auto; 21 | overflow: hidden; 22 | white-space: nowrap; 23 | text-overflow: ellipsis; 24 | } 25 | .ert-parameter-view-caption-tooltip { 26 | background-color: rgba(56,108,176, 0.8); 27 | color: white; 28 | padding-left: 20px; 29 | padding-right: 20px; 30 | padding-top: 10px; 31 | padding-bottom: 10px; 32 | } 33 | 34 | .selected-ensemble-dropdown> div > div >div.Select-value:nth-child(5n-4) { 35 | background-color: rgba(56,108,176,0.8); 36 | color: black; 37 | } 38 | 39 | .selected-ensemble-dropdown> div > div >div.Select-value:nth-child(5n-3) { 40 | background-color: rgba(127,201,127,0.8); 41 | color: black; 42 | } 43 | 44 | .selected-ensemble-dropdown> div > div >div.Select-value:nth-child(5n-2) { 45 | background-color: rgba(253,192,134,0.8); 46 | color: black; 47 | } 48 | 49 | .selected-ensemble-dropdown> div > div >div.Select-value:nth-child(5n-1) { 50 | background-color: rgba(240,2,127,0.8); 51 | color: black; 52 | } 53 | 54 | .selected-ensemble-dropdown> div > div >div.Select-value:nth-child(5n-0) { 55 | background-color: rgba(191,91,23,0.8); 56 | color: black; 57 | } 58 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open("README.md", "r") as fh: 4 | LONG_DESCRIPTION = fh.read() 5 | 6 | setup( 7 | name="webviz-ert", 8 | description="Webviz plugins for ERT", 9 | long_description=LONG_DESCRIPTION, 10 | long_description_content_type="text/markdown", 11 | author="Equinor", 12 | packages=find_packages(exclude=["tests"]), 13 | package_data={"webviz_ert.assets": ["*"]}, 14 | include_package_data=True, 15 | entry_points={ 16 | "webviz_config_plugins": [ 17 | "ResponseComparison = webviz_ert.plugins:ResponseComparison", 18 | "ObservationAnalyzer = webviz_ert.plugins:ObservationAnalyzer", 19 | "ParameterComparison = webviz_ert.plugins:ParameterComparison", 20 | "ResponseCorrelation = webviz_ert.plugins:ResponseCorrelation", 21 | ], 22 | }, 23 | install_requires=[ 24 | "dash-bootstrap-components", 25 | "dash-daq", 26 | "dash<3.0.0", 27 | "pandas", 28 | "requests", 29 | "webviz-config-equinor", 30 | "webviz-config>=0.0.40", 31 | "webviz-subsurface-components", 32 | ], 33 | setup_requires=["setuptools_scm"], 34 | use_scm_version=True, 35 | zip_safe=False, 36 | classifiers=[ 37 | "Natural Language :: English", 38 | "Environment :: Web Environment", 39 | "Framework :: Dash", 40 | "Framework :: Flask", 41 | "Topic :: Scientific/Engineering", 42 | "Intended Audience :: Science/Research", 43 | "Programming Language :: Python", 44 | "Programming Language :: Python :: 3", 45 | "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", 46 | ], 47 | ) 48 | -------------------------------------------------------------------------------- /tests/views/test_state_saving.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from webviz_ert.plugins import ( 3 | ResponseComparison, 4 | WebvizErtPluginABC, 5 | ) 6 | import dash 7 | 8 | from tests.conftest import ( 9 | setup_plugin, 10 | select_ensemble, 11 | select_parameter, 12 | select_response, 13 | ) 14 | 15 | 16 | @pytest.mark.browser_test 17 | def test_state_saved(mock_data, dash_duo, tmpdir): 18 | root_path = tmpdir.strpath 19 | plugin = setup_plugin( 20 | dash_duo, __name__, ResponseComparison, project_identifier=root_path 21 | ) 22 | 23 | ens_name = "default" 24 | resp_name = "SNAKE_OIL_GPR_DIFF@199" 25 | param_name = "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE" 26 | 27 | select_ensemble(dash_duo, plugin, ens_name) 28 | select_response(dash_duo, plugin, resp_name) 29 | select_parameter(dash_duo, plugin, param_name) 30 | 31 | # Check state has been saved 32 | plugin_state = plugin.load_state() 33 | assert plugin_state[f"{plugin._class_name}"]["ensembles"] == [ens_name] 34 | assert plugin_state[f"{plugin._class_name}"]["param"] == [param_name] 35 | assert plugin_state[f"{plugin._class_name}"]["resp"] == [resp_name] 36 | 37 | state_before_quit = plugin_state 38 | 39 | # Simulate application exit by killing the driver 40 | dash_duo.driver.quit() 41 | # Manually reset the global state 42 | WebvizErtPluginABC._state = {} 43 | # Check plugin state is also reset 44 | assert plugin.load_state() == {} 45 | 46 | # Check initializing new plugin will load state from disk if state is not set 47 | app = dash.Dash(__name__) 48 | new_plugin = ResponseComparison(app, project_identifier=root_path) 49 | state_after_loading = new_plugin.load_state() 50 | 51 | assert state_after_loading == state_before_quit 52 | -------------------------------------------------------------------------------- /webviz_ert/models/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union, Optional, TYPE_CHECKING 2 | import datetime 3 | import pandas as pd 4 | 5 | 6 | def indexes_to_axis( 7 | indexes: Optional[List[Union[int, str, datetime.datetime]]], 8 | ) -> Optional[List[Union[int, str, datetime.datetime]]]: 9 | try: 10 | if indexes and type(indexes[0]) is str and not str(indexes[0]).isnumeric(): 11 | return list(map(lambda dt: pd.Timestamp(dt), indexes)) 12 | if indexes and type(indexes[0]) is str and str(indexes[0]).isnumeric(): 13 | return list(map(lambda idx: int(str(idx)), indexes)) 14 | return indexes 15 | except ValueError as e: 16 | raise ValueError("Could not parse indexes as either int or dates", e) 17 | 18 | 19 | if TYPE_CHECKING: 20 | from .ensemble_model import EnsembleModel 21 | from webviz_ert.plugins import WebvizErtPluginABC 22 | 23 | 24 | def load_ensemble( 25 | parent_page: "WebvizErtPluginABC", ensemble_id: str 26 | ) -> "EnsembleModel": 27 | ensemble = parent_page.get_ensemble(ensemble_id=ensemble_id) 28 | if ensemble is None: 29 | ensemble = EnsembleModel( 30 | ensemble_id=ensemble_id, project_id=parent_page.project_identifier 31 | ) 32 | parent_page.add_ensemble(ensemble) 33 | return ensemble 34 | 35 | 36 | from .data_model import DataType, AxisType 37 | from .observation import Observation 38 | from .realization import Realization 39 | from .response import Response 40 | from .plot_model import ( 41 | PlotModel, 42 | ResponsePlotModel, 43 | HistogramPlotModel, 44 | MultiHistogramPlotModel, 45 | BoxPlotModel, 46 | ParallelCoordinatesPlotModel, 47 | BarChartPlotModel, 48 | ) 49 | from .parameter_model import PriorModel, ParametersModel 50 | from .ensemble_model import EnsembleModel 51 | -------------------------------------------------------------------------------- /webviz_ert/views/response_view.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from dash.development.base_component import Component 3 | from webviz_config import WebvizPluginABC 4 | 5 | from dash import html 6 | from dash import dcc 7 | import dash_bootstrap_components as dbc 8 | 9 | 10 | def response_view(parent: WebvizPluginABC, index: str = "") -> List[Component]: 11 | return [ 12 | dcc.Store( 13 | id={"index": index, "type": parent.uuid("response-id-store")}, data=index 14 | ), 15 | dbc.Row( 16 | className="ert-plot-options", 17 | children=[ 18 | dbc.Col( 19 | [html.H4(index)], 20 | align="center", 21 | ), 22 | dbc.Col( 23 | [ 24 | html.Label("Graph Type:", className="ert-label"), 25 | ], 26 | width="auto", 27 | align="center", 28 | ), 29 | dbc.Col( 30 | [ 31 | dcc.RadioItems( 32 | options=[ 33 | {"label": key, "value": key} 34 | for key in ["Function plot", "Statistics"] 35 | ], 36 | value="Statistics", 37 | id={"index": index, "type": parent.uuid("plot-type")}, 38 | persistence="session", 39 | ), 40 | ], 41 | align="center", 42 | ), 43 | ], 44 | ), 45 | dcc.Graph( 46 | id={ 47 | "index": index, 48 | "id": parent.uuid("response-graphic"), 49 | "type": parent.uuid("graph"), 50 | }, 51 | config={"responsive": True}, 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /tests/views/test_observation_view.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from webviz_ert.plugins import ObservationAnalyzer 3 | from tests.conftest import ( 4 | get_options, 5 | select_ensemble, 6 | verify_key_in_dropdown, 7 | setup_plugin, 8 | ) 9 | 10 | 11 | @pytest.mark.browser_test 12 | def test_observation_analyzer_view_ensemble_no_observations( 13 | mock_data, 14 | dash_duo, 15 | ): 16 | # This test selects an ensemble from the ensemble-multi-selector 17 | # where no response has any observations, and checks that no responses are 18 | # available in the response selector 19 | plugin = setup_plugin(dash_duo, __name__, ObservationAnalyzer) 20 | 21 | ensemble_name = select_ensemble(dash_duo, plugin) 22 | dash_duo.wait_for_contains_text( 23 | "#" + plugin.uuid("selected-ensemble-dropdown"), 24 | ensemble_name, 25 | ) 26 | 27 | response_options_selector = f"#{plugin.uuid('response-selector')}" 28 | dash_duo.wait_for_text_to_equal(response_options_selector, "Select...") 29 | 30 | # assert dash_duo.get_logs() == [], "browser console should contain no error" 31 | 32 | 33 | @pytest.mark.browser_test 34 | def test_observation_analyzer_view_ensemble_with_observations( 35 | mock_data, 36 | dash_duo, 37 | ): 38 | # This test selects an ensemble from the ensemble-multi-selector that has 39 | # responses with observations, and checks that a response with observations 40 | # is present among the choosable responses. 41 | plugin = setup_plugin(dash_duo, __name__, ObservationAnalyzer) 42 | 43 | ensemble_name = select_ensemble(dash_duo, plugin, "default3") 44 | 45 | dash_duo.wait_for_contains_text( 46 | "#" + plugin.uuid("selected-ensemble-dropdown"), 47 | ensemble_name, 48 | ) 49 | 50 | verify_key_in_dropdown(dash_duo, plugin.uuid("response-selector"), "FOPR") 51 | 52 | # assert dash_duo.get_logs() == [], "browser console should contain no error" 53 | -------------------------------------------------------------------------------- /ci/testkomodo.sh: -------------------------------------------------------------------------------- 1 | 2 | copy_test_files () { 3 | cp -r $CI_SOURCE_ROOT/tests $CI_TEST_ROOT/tests 4 | cp $CI_SOURCE_ROOT/pyproject.toml $CI_TEST_ROOT # for test markers 5 | } 6 | 7 | start_tests () { 8 | start_integration_test 9 | test_result=$? 10 | if [ "$test_result" -gt 0 ]; 11 | then 12 | exit $test_result 13 | fi 14 | pytest -vs -m "not spe1" 15 | } 16 | 17 | start_integration_test () { 18 | 19 | chromium_version=$(chromium-browser --version | grep -oP '\d+\.\d+\.\d+\.\d+') 20 | chromium_minor_version=$(echo $chromium_version | grep -oP '^\d+\.\d+\.\d+') 21 | 22 | # Sometimes the chromium-browser has no matching chromedriver. 23 | download_url="https://storage.googleapis.com/chrome-for-testing-public/$chromium_version/linux64/chromedriver-linux64.zip" 24 | 25 | driver_version=$chromium_version 26 | if ! wget --spider "$download_url" 2>/dev/null; then 27 | # If file not exists fall back to last good version 28 | googlechromelabs_url='https://googlechromelabs.github.io/chrome-for-testing/latest-patch-versions-per-build.json' 29 | driver_version=$(curl -s "$googlechromelabs_url" | jq -r .builds.\"$chromium_minor_version\".version) 30 | download_url="https://storage.googleapis.com/chrome-for-testing-public/$driver_version/linux64/chromedriver-linux64.zip" 31 | fi 32 | 33 | echo "Downloading chromedriver v$driver_version for chromium-browser v$chromium_version" 34 | wget -O chromedriver.zip "$download_url" 35 | unzip -j chromedriver.zip chromedriver-linux64/chromedriver -d ../test-kenv/root/bin 36 | 37 | pip install pytest selenium dash[testing] 38 | 39 | pushd tests/data/spe1_st 40 | 41 | echo "Initiating Ert run for Spe1 with new storage enabled..." 42 | ert ensemble_experiment --disable-monitor spe1.ert 43 | echo "Ert ensemble_experiment run finished" 44 | 45 | # $HOST is set to machine name for f_komodo user's bash shells. 46 | # Must unset for Dash to use localhost as server name, as Selenium expects. 47 | unset HOST 48 | 49 | echo "Test for webviz-ert plugins..." 50 | pytest ../../../ -vs -m spe1 51 | 52 | popd 53 | } 54 | -------------------------------------------------------------------------------- /tests/models/test_ensemble_model.py: -------------------------------------------------------------------------------- 1 | from webviz_ert.models import EnsembleModel 2 | import dash 3 | from webviz_ert.plugins import ParameterComparison 4 | from webviz_ert.plugins import ObservationAnalyzer 5 | from webviz_ert.models import load_ensemble 6 | 7 | 8 | def test_ensemble_model(mock_data): 9 | ens_model = EnsembleModel(ensemble_id=1, project_id=None) 10 | assert ens_model.name == "default" 11 | # Will be same as responses in experiment 12 | assert len(ens_model.responses) == 4 13 | 14 | 15 | def test_ensemble_model_parameter_data(mock_data): 16 | ens_id = 42 17 | ens_model = EnsembleModel(ensemble_id=ens_id, project_id=None) 18 | parameters = ens_model.parameters 19 | assert len(parameters) == 2 20 | 21 | data = parameters["SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE"].data_df().values 22 | assert data.flatten().tolist() == [0.1, 1.1, 2.1] 23 | 24 | data = ( 25 | parameters["SNAKE_OIL_PARAM:BPR_138_PERSISTENCE"].data_df()["a"].values.tolist() 26 | ) 27 | assert data == [0.01, 1.01, 2.01] 28 | 29 | 30 | def test_ensemble_caching(mock_data): 31 | app = dash.Dash(__name__) 32 | 33 | plotter_view = ParameterComparison(app, project_identifier=None) 34 | ens_model_1 = EnsembleModel(ensemble_id=1, project_id=None) 35 | assert len(plotter_view.get_ensembles()) == 0 36 | 37 | ensemble = plotter_view.get_ensemble(ensemble_id=1) 38 | assert ensemble is None 39 | 40 | plotter_view.add_ensemble(ens_model_1) 41 | load_ensemble(plotter_view, ensemble_id=42) 42 | 43 | plotter_view_ensembles = plotter_view.get_ensembles() 44 | 45 | assert len(plotter_view_ensembles) == 2 46 | assert [ens_id for ens_id, ens_model in plotter_view_ensembles.items()] == [ 47 | 1, 48 | 42, 49 | ] 50 | 51 | page_obs_analyzer = ObservationAnalyzer(app, project_identifier=None) 52 | page_obs_analyzer_ensembles = page_obs_analyzer.get_ensembles() 53 | assert len(page_obs_analyzer_ensembles) == 2 54 | assert page_obs_analyzer_ensembles == plotter_view_ensembles 55 | 56 | plotter_view.clear_ensembles() 57 | assert len(page_obs_analyzer_ensembles) == 0 58 | assert len(plotter_view_ensembles) == 0 59 | -------------------------------------------------------------------------------- /webviz_ert/controllers/parameter_comparison_controller.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import plotly.graph_objects as go 3 | from typing import Any, List, Optional, Dict, Tuple 4 | 5 | from dash.dependencies import Input, Output, State 6 | from webviz_ert.models import ( 7 | load_ensemble, 8 | ParallelCoordinatesPlotModel, 9 | ) 10 | from webviz_ert.plugins import WebvizErtPluginABC 11 | from webviz_ert import assets 12 | 13 | 14 | def parameter_comparison_controller(parent: WebvizErtPluginABC, app: dash.Dash) -> None: 15 | graph_id = {"id": parent.uuid("parallel-coor"), "type": parent.uuid("graph")} 16 | 17 | @app.callback( 18 | [Output(graph_id, "figure"), Output(graph_id, "style")], 19 | [ 20 | Input(parent.uuid("parameter-selection-store-param"), "modified_timestamp"), 21 | Input(parent.uuid("ensemble-selection-store"), "modified_timestamp"), 22 | ], 23 | [ 24 | State(parent.uuid("parameter-selection-store-param"), "data"), 25 | State(parent.uuid("ensemble-selection-store"), "data"), 26 | ], 27 | ) 28 | def update_parallel_coor( 29 | _: Any, 30 | __: Any, 31 | selected_parameters: Optional[List[str]], 32 | selected_ensembles: Optional[Dict[str, List]], 33 | ) -> Tuple[go.Figure, Optional[Dict[str, str]]]: 34 | ensembles = None 35 | if selected_ensembles and selected_ensembles["selected"]: 36 | ensembles = selected_ensembles["selected"] 37 | 38 | # If no ensemble or parameter is selected just don't display the figure 39 | if not ensembles or not selected_parameters: 40 | return go.Figure(), {"display": "none"} 41 | 42 | data = {} 43 | colors = {} 44 | for idx, selected_ensemble in enumerate(ensembles): 45 | ensemble = load_ensemble(parent, selected_ensemble["value"]) 46 | ens_key = ensemble.name 47 | df = ensemble.parameters_df(selected_parameters) 48 | df["ensemble_id"] = idx 49 | data[ens_key] = df.copy() 50 | colors[ens_key] = assets.get_color(index=idx) 51 | parallel_plot = ParallelCoordinatesPlotModel(data, colors) 52 | 53 | return parallel_plot.repr, None 54 | -------------------------------------------------------------------------------- /tests/controllers/test_controller_functions.py: -------------------------------------------------------------------------------- 1 | from webviz_ert.models.ensemble_model import EnsembleModel 2 | from webviz_ert.controllers.controller_functions import ( 3 | response_options, 4 | _valid_response_option, 5 | ) 6 | 7 | 8 | def test_response_options(mock_data): 9 | ensemble3 = EnsembleModel(ensemble_id=3, project_id=None) 10 | ensemble4 = EnsembleModel(ensemble_id=4, project_id=None) 11 | 12 | options = response_options(response_filters=[], ensembles=[ensemble3, ensemble4]) 13 | options_with_obs = response_options( 14 | response_filters=["obs"], ensembles=[ensemble3, ensemble4] 15 | ) 16 | 17 | assert len(options) == 4 18 | assert len(options_with_obs) == 2 19 | 20 | 21 | def test_valid_response_option(): 22 | response_filters = ["obs", "historical"] 23 | 24 | class Response: 25 | name = "DummyResponse" 26 | has_observations = True 27 | 28 | response = Response() 29 | assert _valid_response_option(response_filters, response) is True 30 | 31 | response.name = "DummyResponseH" 32 | assert _valid_response_option(response_filters, response) is False 33 | 34 | response.name = "DummyResponseH:OP_1" 35 | assert _valid_response_option(response_filters, response) is False 36 | 37 | response.has_observations = False 38 | assert _valid_response_option(response_filters, response) is False 39 | 40 | response.name = "DummyResponse" 41 | assert _valid_response_option(response_filters, response) is False 42 | 43 | response_filters = ["obs"] 44 | response.name = "DummyResponseH" 45 | response.has_observations = True 46 | assert _valid_response_option(response_filters, response) is True 47 | 48 | response_filters = ["obs"] 49 | response.name = "DummyResponseH" 50 | response.has_observations = False 51 | assert _valid_response_option(response_filters, response) is False 52 | 53 | response_filters = ["historical"] 54 | response.name = "DummyResponse" 55 | response.has_observations = False 56 | assert _valid_response_option(response_filters, response) is True 57 | 58 | response_filters = ["historical"] 59 | response.name = "DummyResponseH" 60 | response.has_observations = False 61 | assert _valid_response_option(response_filters, response) is False 62 | -------------------------------------------------------------------------------- /webviz_ert/views/misfit_view.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from dash.development.base_component import Component 3 | from webviz_ert.plugins import WebvizErtPluginABC 4 | 5 | 6 | from dash import html 7 | from dash import dcc 8 | 9 | 10 | def response_obs_view(parent: WebvizErtPluginABC) -> List[Component]: 11 | return [ 12 | html.H5("Observation/Misfits plots"), 13 | html.Div( 14 | className="ert-dropdown-container", 15 | children=[ 16 | html.Label("Response", className="ert-label"), 17 | dcc.Dropdown( 18 | id=parent.uuid("response-selector"), 19 | className="ert-dropdown", 20 | ), 21 | dcc.Store( 22 | id=parent.uuid("response-selector-store"), 23 | data=parent.load_state("response"), 24 | storage_type="session", 25 | ), 26 | ], 27 | ), 28 | html.Div( 29 | [ 30 | html.Div( 31 | className="ert-graph-options", 32 | children=[ 33 | html.Label("Y-axis type:"), 34 | dcc.RadioItems( 35 | options=[ 36 | {"label": key, "value": key} 37 | for key in ["linear", "log"] 38 | ], 39 | value="linear", 40 | id=parent.uuid("yaxis-type"), 41 | ), 42 | html.Label("Misfits Type:"), 43 | dcc.RadioItems( 44 | options=[ 45 | {"label": key, "value": key} 46 | for key in ["Univariate", "Summary"] 47 | ], 48 | value="Univariate", 49 | id=parent.uuid("misfits-type"), 50 | ), 51 | ], 52 | ), 53 | dcc.Graph( 54 | id={ 55 | "id": parent.uuid("response-graphic"), 56 | "type": parent.uuid("graph"), 57 | }, 58 | className="ert-graph", 59 | ), 60 | ], 61 | className="ert-graph-container", 62 | id=parent.uuid("observations-graph-container"), 63 | ), 64 | ] 65 | -------------------------------------------------------------------------------- /webviz_ert/controllers/element_dropdown_controller.py: -------------------------------------------------------------------------------- 1 | import dash 2 | 3 | from typing import List, Tuple, Dict, Optional, Any 4 | from dash.dependencies import Input, Output, State 5 | 6 | from webviz_ert.models import DataType 7 | from webviz_ert.plugins import WebvizErtPluginABC 8 | from webviz_ert.models import load_ensemble 9 | from webviz_ert.controllers import parameter_options, response_options 10 | 11 | 12 | def element_dropdown_controller( 13 | parent: WebvizErtPluginABC, 14 | app: dash.Dash, 15 | data_type: DataType, 16 | ) -> None: 17 | @app.callback( 18 | [ 19 | Output(parent.uuid(f"element-dropdown-{data_type}"), "options"), 20 | Output(parent.uuid(f"element-dropdown-{data_type}"), "value"), 21 | Output(parent.uuid(f"element-dropdown-store-{data_type}"), "data"), 22 | ], 23 | [ 24 | Input(parent.uuid("ensemble-selection-store"), "modified_timestamp"), 25 | Input(parent.uuid(f"element-dropdown-{data_type}"), "value"), 26 | ], 27 | [ 28 | State(parent.uuid(f"element-dropdown-store-{data_type}"), "data"), 29 | State(parent.uuid("ensemble-selection-store"), "data"), 30 | ], 31 | ) 32 | def set_callbacks( 33 | _: Any, 34 | selected: Optional[str], 35 | selected_store: Optional[str], 36 | ensemble_selection_store: Dict[str, List], 37 | ) -> Tuple[List[Dict], Optional[str], Optional[str]]: 38 | if not ensemble_selection_store or not ensemble_selection_store["selected"]: 39 | return [], None, None 40 | 41 | selected_ensembles = [ 42 | selection["value"] for selection in ensemble_selection_store["selected"] 43 | ] 44 | 45 | ensembles = [ 46 | load_ensemble(parent, ensemble_id) for ensemble_id in selected_ensembles 47 | ] 48 | if data_type == DataType.PARAMETER: 49 | options = parameter_options(ensembles, union_keys=False) 50 | elif data_type == DataType.RESPONSE: 51 | options = response_options(response_filters=[], ensembles=ensembles) 52 | else: 53 | raise ValueError(f"Undefined data type {data_type}") 54 | 55 | element_options = [{"label": name, "value": name} for name in sorted(options)] 56 | 57 | # Keep track of selected element for the session 58 | if not selected and selected_store is not None: 59 | selected = selected_store 60 | 61 | return element_options, selected, selected 62 | -------------------------------------------------------------------------------- /webviz_ert/views/ensemble_selector_view.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from dash.development.base_component import Component 3 | from webviz_ert.plugins import WebvizErtPluginABC 4 | 5 | from dash import html 6 | from dash import dcc 7 | import webviz_core_components as wcc 8 | import dash_bootstrap_components as dbc 9 | 10 | 11 | def ensemble_selector_list(parent: WebvizErtPluginABC) -> List[Component]: 12 | return [ 13 | html.Div( 14 | dbc.Row( 15 | [ 16 | dbc.Col( 17 | html.H6("Ensembles", className="ert-label"), 18 | align="left", 19 | width="auto", 20 | ), 21 | dbc.Col( 22 | html.Button( 23 | id=parent.uuid("ensemble-refresh-button"), 24 | children="Refresh", 25 | n_clicks=0, 26 | ), 27 | align="right", 28 | ), 29 | dbc.Col( 30 | html.Button( 31 | id=parent.uuid("parameter-selector-button"), 32 | children=("Hide Selectors"), 33 | ), 34 | align="right", 35 | width="auto", 36 | ), 37 | ], 38 | align="center", 39 | ) 40 | ), 41 | html.Div( 42 | wcc.Select( 43 | id=parent.uuid("ensemble-multi-selector"), 44 | multi=True, 45 | size=10, 46 | persistence=True, 47 | persistence_type="session", 48 | options=[], 49 | value=[], 50 | className="ert-select-variable", 51 | ), 52 | id=parent.uuid("container-ensemble-selector-multi"), 53 | className="ert-ensemble-selector-container-show", 54 | ), 55 | dcc.Dropdown( 56 | id=parent.uuid("selected-ensemble-dropdown"), 57 | value=[], 58 | multi=True, 59 | options=[], 60 | persistence=True, 61 | searchable=False, 62 | placeholder="", 63 | persistence_type="session", 64 | className="selected-ensemble-dropdown", 65 | ), 66 | dcc.Store( 67 | id=parent.uuid("ensemble-selection-store"), 68 | storage_type="session", 69 | ), 70 | ] 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | #IDE's 132 | .vscode 133 | .idea 134 | 135 | /webviz_ert/__version__.py 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI version](https://badge.fury.io/py/webviz-ert.svg)](https://badge.fury.io/py/webviz-ert) 2 | [![Build Status](https://github.com/equinor/webviz-ert/workflows/Python/badge.svg)](https://github.com/equinor/webviz-ert/actions?query=workflow%3APython) 3 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 4 | [![PyPI license](https://img.shields.io/pypi/l/webviz-ert.svg)](https://pypi.org/project/webviz-ert/) 5 | 6 | # Web based visualization for ERT 7 | 8 | ## What is Webviz-ert 9 | Webviz-ert is a visualization tool for ERT based on [dash](https://github.com/plotly/dash) 10 | and [webviz-config](https://github.com/equinor/webviz-config). 11 | 12 | ## Download project 13 | The code is hosted on GitHub: 14 | https://github.com/equinor/webviz-ert 15 | 16 | ```sh 17 | # From the downloaded project's root folder - to install 18 | pip install . 19 | ``` 20 | 21 | ## Running tests 22 | Make sure that you have a browser and driver available for running the tests, 23 | e.g. chrome / chromium and the chrome driver, which should be installed / 24 | downloaded as binaries and made available in the path. 25 | 26 | ```sh 27 | # From the downloaded project's root folder - to run tests 28 | pip install -r test_requirements.txt 29 | cd tests 30 | pytest 31 | ``` 32 | 33 | ## Run Webviz-ert 34 | Webviz-ert connects automatically to a storage server running in [ERT](https://github.com/equinor/ert). 35 | Here are a few steps to get an example version of webviz-ert running. 36 | 37 | ```sh 38 | # Run simulations in ert - /test-data/ert/poly_example/ 39 | ert ensemble_smoother --target-ensemble smoother_%d poly.ert 40 | 41 | # After simulation has finished, start webviz-ert from the same location with 42 | ert vis 43 | 44 | # Alternatively, you might have to supply the config file if you're using the 45 | # classic ert storage solution: 46 | ert vis poly.ert 47 | ``` 48 | 49 | ## Example 50 | 51 | #### Start up 52 | 53 | ![startup](https://user-images.githubusercontent.com/4508053/186850915-4b53c4fb-273d-4c15-961c-299966c3232c.gif) 54 | 55 | #### Plot viewer 56 | 57 | ![plot_viewer](https://user-images.githubusercontent.com/4508053/186850936-38a13f16-f795-4691-8455-fc12f8372d8e.gif) 58 | 59 | #### Observation Analyzer 60 | 61 | ![observation_analyzer](https://user-images.githubusercontent.com/4508053/186850964-68b137eb-17c4-4bf9-9436-81c8c86e5956.gif) 62 | 63 | #### Parameter comparison 64 | 65 | ![parameter_comparison](https://user-images.githubusercontent.com/4508053/186851000-e5b750e1-d7ae-4da4-a612-b6c3740f5698.gif) 66 | 67 | #### Response correlation 68 | 69 | ![response_correlation](https://user-images.githubusercontent.com/4508053/186851026-df2085fd-5c35-42a6-a87e-c1890a963254.gif) 70 | -------------------------------------------------------------------------------- /webviz_ert/views/selector_view.py: -------------------------------------------------------------------------------- 1 | from dash.development.base_component import Component 2 | from webviz_ert.plugins import WebvizErtPluginABC 3 | 4 | from dash import html 5 | from dash import dcc 6 | import webviz_core_components as wcc 7 | import dash_bootstrap_components as dbc 8 | 9 | from webviz_ert.models.data_model import DataType 10 | 11 | 12 | def parameter_selector_view( 13 | parent: WebvizErtPluginABC, 14 | data_type: DataType, 15 | titleLabel: str = "Parameters", 16 | ) -> Component: 17 | return html.Div( 18 | [ 19 | dbc.Row( 20 | [ 21 | dbc.Col( 22 | html.H6( 23 | titleLabel, 24 | className="ert-label", 25 | ), 26 | align="left", 27 | ), 28 | dbc.Col( 29 | [ 30 | html.Label( 31 | "Search: ", 32 | className="ert-label", 33 | ), 34 | dcc.Input( 35 | id=parent.uuid( 36 | f"parameter-selector-filter-{data_type}" 37 | ), 38 | type="search", 39 | placeholder="Substring...", 40 | persistence="session", 41 | ), 42 | ], 43 | align="right", 44 | width="auto", 45 | ), 46 | ], 47 | align="center", 48 | ), 49 | html.Div( 50 | wcc.Select( 51 | id=parent.uuid(f"parameter-selector-multi-{data_type}"), 52 | multi=True, 53 | size=10, 54 | persistence="session", 55 | className="ert-select-variable", 56 | ), 57 | id=parent.uuid(f"container-parameter-selector-multi-{data_type}"), 58 | className="ert-parameter-selector-container-show", 59 | ), 60 | dcc.Dropdown( 61 | id=parent.uuid(f"parameter-deactivator-{data_type}"), 62 | multi=True, 63 | searchable=False, 64 | placeholder="", 65 | persistence="session", 66 | ), 67 | dcc.Store( 68 | id=parent.uuid(f"parameter-selection-store-{data_type}"), 69 | storage_type="session", 70 | data=parent.load_state(f"{data_type}", []), 71 | ), 72 | ], 73 | ) 74 | -------------------------------------------------------------------------------- /webviz_ert/assets/ert-style.css: -------------------------------------------------------------------------------- 1 | .ert-label { 2 | white-space: nowrap; 3 | } 4 | .ert-plot-options { 5 | height: 84px !important; 6 | } 7 | .ert-parameter-selector-container-hide, .ert-ensemble-selector-container-hide { 8 | display: none; 9 | } 10 | .ert-parameter-selector-container-show, .ert-ensemble-selector-container-show { 11 | display: block; 12 | } 13 | .ert-parameter-label-checkbox { 14 | display: block; 15 | } 16 | .ert-input-number { 17 | width: 65px; 18 | } 19 | .ert-parameter-view-caption { 20 | width: auto; 21 | overflow: hidden; 22 | white-space: nowrap; 23 | text-overflow: ellipsis; 24 | } 25 | .ert-parameter-view-caption-tooltip { 26 | background-color: rgba(56,108,176, 0.8); 27 | color: white; 28 | padding-left: 20px; 29 | padding-right: 20px; 30 | padding-top: 10px; 31 | padding-bottom: 10px; 32 | } 33 | .ert-beta-warning { 34 | color: #664d03; 35 | background-color: #fff3cd; 36 | border-color: #ffecb5; 37 | padding: 1rem 1rem; 38 | } 39 | 40 | .selected-ensemble-dropdown> div > div >div.Select-value:nth-child(5n-4) { 41 | background-color: rgba(56,108,176,0.8); 42 | color: black; 43 | } 44 | 45 | .selected-ensemble-dropdown> div > div >div.Select-value:nth-child(5n-3) { 46 | background-color: rgba(127,201,127,0.8); 47 | color: black; 48 | } 49 | 50 | .selected-ensemble-dropdown> div > div >div.Select-value:nth-child(5n-2) { 51 | background-color: rgba(253,192,134,0.8); 52 | color: black; 53 | } 54 | 55 | .selected-ensemble-dropdown> div > div >div.Select-value:nth-child(5n-1) { 56 | background-color: rgba(240,2,127,0.8); 57 | color: black; 58 | } 59 | 60 | .selected-ensemble-dropdown> div > div >div.Select-value:nth-child(5n-0) { 61 | background-color: rgba(191,91,23,0.8); 62 | color: black; 63 | } 64 | 65 | .ert-select-variable { 66 | height: 15rem; 67 | } 68 | .active-info { 69 | margin-top: 1rem; 70 | text-align: center; 71 | } 72 | .active-info span { 73 | font-size: 1.5rem; 74 | } 75 | .active-info > span { 76 | margin-left: 1rem; 77 | margin-right: 1rem; 78 | } 79 | /* for some reason necessary to vertically center text in badges */ 80 | .active-info span.badge { 81 | display: inline; 82 | } 83 | /* center correlation options vertically in row */ 84 | .correlation-option { 85 | display: flex; 86 | align-items: center; 87 | } 88 | /* arrange correlation options on top of each other */ 89 | .correlation-option label { 90 | display: block; 91 | } 92 | .heatmap-options > * { 93 | display: inline-block; /* arrange options next to each other */ 94 | /* give options a bit of space */ 95 | margin-left: .5rem; 96 | margin-right: .5rem; 97 | } 98 | .allow-overflow * { 99 | overflow: visible!important; 100 | } 101 | -------------------------------------------------------------------------------- /webviz_ert/plugins/_webviz_ert.py: -------------------------------------------------------------------------------- 1 | from typing import MutableMapping, Optional, Dict, Any 2 | import dash 3 | from webviz_config import WebvizPluginABC 4 | from webviz_ert.models import EnsembleModel 5 | import pathlib 6 | import json 7 | import tempfile 8 | 9 | 10 | class WebvizErtPluginABC(WebvizPluginABC): 11 | _ensembles: MutableMapping[str, "EnsembleModel"] = {} 12 | _state: MutableMapping[str, Any] = {} 13 | _state_file_name: str = "webviz_ert_state.json" 14 | _state_path: Optional[pathlib.Path] = None 15 | 16 | def __init__(self, app: dash.Dash, project_identifier: str): 17 | super().__init__() 18 | if not project_identifier: 19 | project_identifier = tempfile.NamedTemporaryFile().name 20 | WebvizErtPluginABC._state = {} 21 | WebvizErtPluginABC._state_path = None 22 | 23 | self.project_identifier: str = project_identifier 24 | WebvizErtPluginABC._state = self.init_state(pathlib.Path(project_identifier)) 25 | self._class_name: str = type(self).__name__.lower() 26 | 27 | @staticmethod 28 | def init_state(project_root: pathlib.Path) -> MutableMapping[str, Any]: 29 | if not WebvizErtPluginABC._state: 30 | if WebvizErtPluginABC._state_path is None: 31 | WebvizErtPluginABC._state_path = ( 32 | project_root / f"{WebvizErtPluginABC._state_file_name}" 33 | ) 34 | if not WebvizErtPluginABC._state_path.exists(): 35 | WebvizErtPluginABC._state_path.parent.mkdir(parents=True, exist_ok=True) 36 | return dict() 37 | with open(WebvizErtPluginABC._state_path, "r", encoding="utf-8") as f: 38 | return json.load(f) 39 | return WebvizErtPluginABC._state 40 | 41 | @classmethod 42 | def get_ensembles(cls) -> MutableMapping[str, "EnsembleModel"]: 43 | return cls._ensembles 44 | 45 | @classmethod 46 | def get_ensemble(cls, ensemble_id: str) -> Optional[EnsembleModel]: 47 | return cls._ensembles.get(ensemble_id) 48 | 49 | @classmethod 50 | def add_ensemble(cls, ensemble: "EnsembleModel") -> None: 51 | if ensemble.id not in cls._ensembles: 52 | cls._ensembles[ensemble.id] = ensemble 53 | 54 | @classmethod 55 | def clear_ensembles(cls) -> None: 56 | cls._ensembles.clear() 57 | 58 | def save_state(self, key: str, data: Any) -> None: 59 | page_state = WebvizErtPluginABC._state.get(self._class_name, {}) 60 | page_state[key] = data 61 | WebvizErtPluginABC._state[self._class_name] = page_state 62 | if WebvizErtPluginABC._state_path is not None: 63 | with open(WebvizErtPluginABC._state_path, "w", encoding="utf-8") as f: 64 | json.dump(WebvizErtPluginABC._state, f, indent=4, sort_keys=True) 65 | 66 | def load_state( 67 | self, key: Optional[str] = None, default: Optional[Any] = None 68 | ) -> MutableMapping[str, Any]: 69 | if key is not None: 70 | page_state = WebvizErtPluginABC._state.get(self._class_name, {}) 71 | return page_state.get(key, default) 72 | 73 | return WebvizErtPluginABC._state 74 | -------------------------------------------------------------------------------- /tests/views/test_general_stuff.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import dash 3 | from tests.conftest import setup_plugin, select_ensemble 4 | 5 | from webviz_ert.plugins import ( 6 | ResponseComparison, 7 | ObservationAnalyzer, 8 | ParameterComparison, 9 | ResponseCorrelation, 10 | ) 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "plugin_class,input", 15 | [ 16 | pytest.param(ResponseComparison, True), 17 | pytest.param(ResponseComparison, False), 18 | pytest.param(ObservationAnalyzer, True), 19 | pytest.param(ObservationAnalyzer, False), 20 | pytest.param(ParameterComparison, True), 21 | pytest.param(ParameterComparison, False), 22 | pytest.param(ResponseCorrelation, True), 23 | pytest.param(ResponseCorrelation, False), 24 | ], 25 | ) 26 | @pytest.mark.browser_test 27 | def test_displaying_beta_warning(plugin_class, input: bool, dash_duo): 28 | plugin = setup_plugin(dash_duo, __name__, plugin_class, beta=input) 29 | beta_warning_element = dash_duo.find_element("#" + plugin.uuid("beta-warning")) 30 | assert beta_warning_element.is_displayed() == input 31 | 32 | 33 | skip_responses = "resp" 34 | skip_parameters = "param" 35 | 36 | 37 | @pytest.mark.parametrize( 38 | "plugin_class,skip", 39 | [ 40 | pytest.param(ResponseComparison, [], id="ResponseComparison"), 41 | pytest.param( 42 | ObservationAnalyzer, 43 | [skip_responses, skip_parameters], 44 | id="ObservationAnalyzer", 45 | ), 46 | pytest.param(ParameterComparison, [skip_responses], id="ParameterComparison"), 47 | pytest.param(ResponseCorrelation, [], id="ResponseCorrelation"), 48 | ], 49 | ) 50 | @pytest.mark.browser_test 51 | def test_selectors_visibility_toggle_button(plugin_class, skip, mock_data, dash_duo): 52 | # we test whether the selector visibility toggle button changes class on 53 | # all selectors, as expected 54 | 55 | plugin = setup_plugin(dash_duo, __name__, plugin_class, (2048, 1536)) 56 | 57 | visibility_toggler = dash_duo.find_element( 58 | "#" + plugin.uuid("parameter-selector-button") 59 | ) 60 | visibility_toggler.click() 61 | 62 | ensemble_container_class_prefix = "ert-ensemble-selector-container" 63 | dash_duo.wait_for_class_to_equal( 64 | f'#{plugin.uuid("container-ensemble-selector-multi")}', 65 | f"{ensemble_container_class_prefix}-hide", 66 | ) 67 | 68 | variable_container_class_prefix = "ert-parameter-selector-container" 69 | if skip_responses not in skip: 70 | dash_duo.wait_for_class_to_equal( 71 | f'#{plugin.uuid("container-parameter-selector-multi-resp")}', 72 | f"{variable_container_class_prefix}-hide", 73 | ) 74 | 75 | if skip_parameters not in skip: 76 | dash_duo.wait_for_class_to_equal( 77 | f'#{plugin.uuid("container-parameter-selector-multi-param")}', 78 | f"{variable_container_class_prefix}-hide", 79 | ) 80 | 81 | # assert dash_duo.get_logs() == [], "browser console should contain no error" 82 | 83 | 84 | @pytest.mark.browser_test 85 | def test_response_selector_sorting(mock_data, dash_duo): 86 | plugin = setup_plugin(dash_duo, __name__, ResponseComparison) 87 | wanted_ensemble_name = "nr_42" 88 | select_ensemble(dash_duo, plugin, wanted_ensemble_name) 89 | 90 | response_selector_container = dash_duo.find_element( 91 | "#" + plugin.uuid("container-parameter-selector-multi-resp") 92 | ) 93 | response_list = response_selector_container.text.split("\n") 94 | 95 | assert response_list == ["FGPT", "FOPR", "SNAKE_OIL_GPR_DIFF@199", "WOPR:OP1"] 96 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of conduct for open source projects in Equinor 2 | 3 | In Equinor, [how we deliver is as important as what we deliver](https://www.equinor.com/en/careers/our-culture.html). 4 | 5 | This principle applies to all work done in projects maintained by Equinor, as well as for any contributions by Equinor to other projects within the open-source community. 6 | 7 | ## Open, Collaborative, Courageous, Caring 8 | 9 | As a values-based organization, our code of conduct for open-source projects is simply a reflection of our values, and how we live up to them as teams and as individuals. 10 | 11 | This sets the expectations for how we collaborate in our open-source projects, which apply to all community members and participants of any project maintained by Equinor. 12 | 13 | In addition to any definition or interpretation of the open source code of conduct, the company wide [Equinor code of conduct](https://www.equinor.com/content/dam/statoil/documents/ethics/equinor-code-of-conduct.pdf) always applies to all employees and hired contractors. 14 | 15 | # Handling issues within the communities 16 | 17 | If at any point concerns are raised that a group or member is not acting according to our values, the behaviour in question should cease, be discussed, and tried to be resolved. 18 | 19 | We expect everyone to have a low threshold for raising issues, or in general discuss how we live up to our values. 20 | Equally, we encourage all community members to appreciate when concerns are raised and do their best to solve them. 21 | 22 | Project maintainers are responsible both for *what* is delivered, as well as *how* it is delivered. 23 | This includes creating an environment for proper handling of issues raised within the communities. 24 | 25 | # Reach out for assistance 26 | 27 | For any problem not directly resolvable within the community, we encourage you to reach out for assistance. 28 | An outsider’s perspective might be just what is needed for you to proceed. 29 | 30 | Send an e-mail to opensource_at_equinor.com and invite for a discussion. 31 | The e-mail will be handled by a team within the Equinor organization. 32 | 33 | # Reporting an issue 34 | 35 | For reporting direct violations of the code of conduct, or failed attempts at solving conflicts within the community, send a description of the issue to opensource_at_equinor.com. 36 | 37 | To encourage members of the community to file reports, all reports are kept confidential unless you choose to disclose it yourself. 38 | 39 | Consequences of violations reflect the severity of the issue, and/or repeated failure to take action following mandated decisions. 40 | 41 | For projects maintained by Equinor, only the Equinor open-source team has the mandate to take corrective actions against individuals that have violated the code of conduct. 42 | These decisions are never made within each project alone. 43 | As a last resort, a potential consequence may be the exclusion from participation in that particular community. 44 | 45 | # Ethics helpline 46 | 47 | In Equinor, we want you to speak up whenever you see unethical behaviour that conflicts with our values or threatens our reputation. 48 | 49 | To underline this, we continuously encourage and remind our employees and any external third parties interacting with us to raise concerns or report any suspected or potential breaches of law or company policies. 50 | 51 | For any questions or issues that one suspect falls outside the scope of behaviour regulated, and handled within the open source code of conduct, or you wish to place an anonymous, confidential report we encourage to use the [Equinor Ethics helpline](https://secure.ethicspoint.eu/domain/media/en/gui/102166/index.html). 52 | 53 | This helpline is hosted by a third party helpline provider. 54 | -------------------------------------------------------------------------------- /webviz_ert/views/plot_view.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from dash.development.base_component import Component 3 | from webviz_ert.plugins import WebvizErtPluginABC 4 | 5 | from dash import html 6 | from dash import dcc 7 | import webviz_core_components as wcc 8 | import dash_bootstrap_components as dbc 9 | from .selector_view import parameter_selector_view 10 | from webviz_ert.models.data_model import DataType 11 | from .ensemble_selector_view import ensemble_selector_list 12 | 13 | 14 | def plot_view_header(parent: WebvizErtPluginABC) -> List[Component]: 15 | return [ 16 | dbc.Row( 17 | [ 18 | dbc.Col( 19 | id=parent.uuid("ensemble-content"), 20 | children=ensemble_selector_list(parent=parent), 21 | width=4, 22 | ), 23 | dbc.Col( 24 | [ 25 | parameter_selector_view( 26 | parent, 27 | data_type=DataType.RESPONSE, 28 | titleLabel="Responses", 29 | ), 30 | dcc.Checklist( 31 | id=parent.uuid("response-observations-check"), 32 | options=[ 33 | { 34 | "label": "Show only responses with observations", 35 | "value": "obs", 36 | }, 37 | { 38 | "label": "Remove all key-names ending with 'H' ( " 39 | "probably historical vectors )", 40 | "value": "historical", 41 | }, 42 | ], 43 | value=[], 44 | labelStyle={"display": "block"}, 45 | ), 46 | ], 47 | width=4, 48 | id=parent.uuid("response-section"), 49 | ), 50 | dbc.Col( 51 | [ 52 | parameter_selector_view( 53 | parent, 54 | data_type=DataType.PARAMETER, 55 | titleLabel="Parameters", 56 | ), 57 | ], 58 | width=4, 59 | id=parent.uuid("parameter-section"), 60 | ), 61 | ] 62 | ), 63 | dcc.Store(id=parent.uuid("plot-selection-store-resp"), storage_type="session"), 64 | dcc.Store(id=parent.uuid("plot-selection-store-param"), storage_type="session"), 65 | ] 66 | 67 | 68 | def plot_view_body(parent: WebvizErtPluginABC) -> List[Component]: 69 | return [ 70 | html.Div( 71 | [ 72 | dbc.Row(children=[], id=parent.uuid("plotting-content-resp")), 73 | dbc.Row(children=[], id=parent.uuid("plotting-content-param")), 74 | ], 75 | id=parent.uuid("plotting-content-container"), 76 | ), 77 | ] 78 | 79 | 80 | def plot_view_menu(parent: WebvizErtPluginABC) -> List[Component]: 81 | return [ 82 | html.Div( 83 | dcc.Checklist( 84 | id=parent.uuid("param-label-check"), 85 | options=[ 86 | { 87 | "label": "Show legend description", 88 | "value": "label", 89 | } 90 | ], 91 | value=["label"], 92 | persistence="session", 93 | ), 94 | className="ert-parameter-label-checkbox", 95 | ), 96 | ] 97 | -------------------------------------------------------------------------------- /tests/views/test_response_correlation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from webviz_ert.plugins._response_correlation import ResponseCorrelation 3 | from tests.conftest import ( 4 | setup_plugin, 5 | select_by_name, 6 | select_ensemble, 7 | select_response, 8 | select_parameter, 9 | ) 10 | 11 | 12 | @pytest.mark.browser_test 13 | def test_axes_labels(mock_data, dash_duo): 14 | """test_axis_labels loads two different plots and checks that axes are 15 | labelled correctly""" 16 | 17 | plugin = setup_plugin( 18 | dash_duo, __name__, ResponseCorrelation, window_size=(1024, 2048) 19 | ) 20 | 21 | # find the right ensemble which has mock data prepared for this test 22 | wanted_ensemble_name = "default3" 23 | select_ensemble(dash_duo, plugin, wanted_ensemble_name) 24 | 25 | wanted_responses = ["WOPR:OP1", "FOPR"] 26 | 27 | # we only see one response at a time, so we choose and check one after the 28 | # other 29 | for wanted_response in wanted_responses: 30 | response_selector_id = plugin.uuid("parameter-selector-multi-resp") 31 | select_by_name(dash_duo, f"#{response_selector_id}", wanted_response) 32 | 33 | plot_id = plugin.uuid("response-overview") 34 | 35 | # check that y axis label spells out "Value"; test is flaky so longer 36 | # timeout. 37 | dash_duo.wait_for_text_to_equal(f"#{plot_id} text.ytitle", "Value", timeout=20) 38 | 39 | # check that one has date, the other has index as x axis label 40 | if wanted_response == "FOPR": 41 | dash_duo.wait_for_text_to_equal(f"#{plot_id} text.xtitle", "Date") 42 | else: 43 | dash_duo.wait_for_text_to_equal(f"#{plot_id} text.xtitle", "Index") 44 | 45 | # clear response selection so next selection can be displayed 46 | response_deactivator_id = plugin.uuid("parameter-deactivator-resp") 47 | selected_response_closer = dash_duo.find_element( 48 | f"#{response_deactivator_id} .Select-value-icon" 49 | ) 50 | selected_response_closer.click() 51 | 52 | # wait for deselected option to reappear among available options 53 | dash_duo.wait_for_contains_text(f"#{response_selector_id}", wanted_response) 54 | 55 | # assert dash_duo.get_logs() == [], "browser console should contain no error" 56 | 57 | 58 | @pytest.mark.browser_test 59 | def test_show_respo_with_obs(mock_data, dash_duo): 60 | """Test response observation filter works as expected""" 61 | plugin = setup_plugin(dash_duo, __name__, ResponseCorrelation) 62 | 63 | select_ensemble(dash_duo, plugin, "default3") 64 | 65 | expected_responses_with_observations = ["FOPR", "WOPR:OP1"] 66 | 67 | response_selector_id = "#" + plugin.uuid("parameter-selector-multi-resp") 68 | 69 | dash_duo.wait_for_text_to_equal( 70 | response_selector_id, 71 | "\n".join(expected_responses_with_observations), 72 | timeout=20, 73 | ) 74 | 75 | 76 | @pytest.mark.browser_test 77 | def test_info_text_appears_as_expected( 78 | mock_data, 79 | dash_duo, 80 | ): 81 | ensemble = "default3" 82 | response = "FOPR" 83 | parameter = "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE" 84 | index = "2010-01-10" 85 | plugin = setup_plugin(dash_duo, __name__, ResponseCorrelation) 86 | select_ensemble(dash_duo, plugin, ensemble) 87 | select_response(dash_duo, plugin, response, wait_for_plot=False) 88 | select_parameter(dash_duo, plugin, parameter, wait_for_plot=False) 89 | info_text_selector = f"#{plugin.uuid('info-text')}" 90 | expected_text = "".join( 91 | [f"RESPONSE: {response}", f"INDEX: {index}", f"PARAMETER: {parameter}"] 92 | ) 93 | dash_duo.wait_for_text_to_equal(info_text_selector, expected_text, timeout=20) 94 | 95 | # assert dash_duo.get_logs() == [], "browser console should contain no error" 96 | -------------------------------------------------------------------------------- /webviz_ert/models/response.py: -------------------------------------------------------------------------------- 1 | from typing import List, Mapping, Optional, Any 2 | import pandas as pd 3 | from webviz_ert.data_loader import get_data_loader, DataLoader 4 | 5 | from webviz_ert.models import Observation, AxisType 6 | 7 | 8 | class Response: 9 | def __init__( 10 | self, 11 | name: str, 12 | ensemble_id: str, 13 | project_id: str, 14 | ensemble_size: int, 15 | active_realizations: List[int], 16 | resp_schema: Any, 17 | ): 18 | self._data_loader: DataLoader = get_data_loader(project_id) 19 | self._document: Optional[Mapping[str, Any]] = None 20 | self._id: str = resp_schema["id"] 21 | self._ensemble_id: str = ensemble_id 22 | self.name: str = name 23 | self._data: Optional[pd.DataFrame] = None 24 | self._observations: Optional[List[Observation]] = None 25 | self._univariate_misfits_df: Optional[pd.DataFrame] = None 26 | self._summary_misfits_df: Optional[pd.DataFrame] = None 27 | self._ensemble_size: int = ensemble_size 28 | self._active_realizations: List[int] = active_realizations 29 | self._has_observations: bool = resp_schema.get("has_observations") 30 | 31 | @property 32 | def ensemble_id(self) -> str: 33 | return self._ensemble_id 34 | 35 | @property 36 | def axis(self) -> pd.Index: 37 | return self.data.index 38 | 39 | @property 40 | def axis_type(self) -> Optional[AxisType]: 41 | if self.axis is None or self.axis.empty: 42 | return None 43 | if str(self.axis[0]).isnumeric(): 44 | return AxisType.INDEX 45 | return AxisType.TIMESTAMP 46 | 47 | @property 48 | def data(self) -> pd.DataFrame: 49 | if self._data is None: 50 | self._data = self._data_loader.get_ensemble_record_data( 51 | self._ensemble_id, self.name 52 | ) 53 | return self._data 54 | 55 | @property 56 | def empty(self) -> bool: 57 | return self.data.empty 58 | 59 | def univariate_misfits_df( 60 | self, selection: Optional[List[int]] = None 61 | ) -> pd.DataFrame: 62 | if self._univariate_misfits_df is None: 63 | self._univariate_misfits_df = self._data_loader.compute_misfit( 64 | self._ensemble_id, self.name, summary=False 65 | ) 66 | if selection: 67 | return self._univariate_misfits_df.iloc[selection, :] 68 | return self._univariate_misfits_df 69 | 70 | def summary_misfits_df(self, selection: Optional[List[int]] = None) -> pd.DataFrame: 71 | if self._summary_misfits_df is None: 72 | self._summary_misfits_df = self._data_loader.compute_misfit( 73 | self._ensemble_id, self.name, summary=True 74 | ) 75 | if selection: 76 | self._summary_misfits_df.iloc[selection, :] 77 | return self._summary_misfits_df 78 | 79 | def data_df(self, selection: Optional[List[int]] = None) -> pd.DataFrame: 80 | if selection: 81 | self.data.iloc[selection, :] 82 | return self.data 83 | 84 | @property 85 | def observations(self) -> Optional[List[Observation]]: 86 | if self._observations is None: 87 | _observations_schemas = self._data_loader.get_ensemble_record_observations( 88 | self._ensemble_id, self.name 89 | ) 90 | self._observations = [] 91 | for observation_schema in _observations_schemas: 92 | self._observations.append( 93 | Observation(observation_schema=observation_schema) 94 | ) 95 | return self._observations 96 | 97 | @property 98 | def has_observations(self) -> bool: 99 | return self._has_observations 100 | -------------------------------------------------------------------------------- /webviz_ert/plugins/_observation_analyzer.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import webviz_ert.models 3 | import webviz_ert.controllers 4 | from dash.development.base_component import Component 5 | from typing import List, Dict 6 | from webviz_ert.views import ensemble_selector_list, response_obs_view 7 | from webviz_ert.plugins import WebvizErtPluginABC 8 | 9 | 10 | class ObservationAnalyzer(WebvizErtPluginABC): 11 | def __init__(self, app: dash.Dash, project_identifier: str, beta: bool = False): 12 | super().__init__(app, project_identifier) 13 | self.set_callbacks(app) 14 | self.beta = beta 15 | 16 | @property 17 | def layout(self) -> Component: 18 | return dash.html.Div( 19 | [ 20 | dash.html.Div( 21 | children=[ 22 | dash.html.P( 23 | [ 24 | "This page is considered a ", 25 | dash.html.B("beta"), 26 | " version and could be changed or removed. You are encouraged to use it and give feedback to us regarding functionality and / or bugs.", 27 | ], 28 | className="ert-beta-warning", 29 | id=self.uuid("beta-warning"), 30 | ) 31 | ], 32 | hidden=not self.beta, 33 | ), 34 | dash.html.Div( 35 | id=self.uuid("ensemble-content"), 36 | children=ensemble_selector_list(parent=self), 37 | ), 38 | dash.html.Div( 39 | id=self.uuid("plotting-content"), 40 | children=response_obs_view(parent=self), 41 | ), 42 | ] 43 | ) 44 | 45 | @property 46 | def tour_steps(self) -> List[Dict[str, str]]: 47 | steps = [ 48 | { 49 | "id": self.uuid("ensemble-multi-selector"), 50 | "content": "List of experiment ensembles.", 51 | }, 52 | { 53 | "id": self.uuid("selected-ensemble-dropdown"), 54 | "content": "List of currently selected ensembles.", 55 | }, 56 | { 57 | "id": self.uuid(f"ensemble-refresh-button"), 58 | "content": ( 59 | "Forces a refresh of all ensemble data including parameter and response data." 60 | ), 61 | }, 62 | { 63 | "id": self.uuid("response-selector"), 64 | "content": ( 65 | "Response selection list will be populated" 66 | " when an ensemble is selected in the list of ensembles" 67 | " and will contain only responses that have observations." 68 | ), 69 | }, 70 | { 71 | "id": self.uuid("observations-graph-container"), 72 | "content": ( 73 | "Representation of the misfit between the estimated response value from the forward model" 74 | " and the existing observation values." 75 | " One can choose to show misfits grouped by time (Univeriate) as candles" 76 | " showing misfits statistics per a single time point" 77 | " or to render misfits as a histogram aggregating misfits over the entire temporal axes (Summary)." 78 | " When multiple ensembles are selected each graph will be overlayed on top of each other" 79 | " transparently, where each ensemble gets its own colour" 80 | ), 81 | }, 82 | ] 83 | return steps 84 | 85 | def set_callbacks(self, app: dash.Dash) -> None: 86 | webviz_ert.controllers.ensemble_list_selector_controller(self, app) 87 | webviz_ert.controllers.observation_response_controller(self, app) 88 | -------------------------------------------------------------------------------- /tests/views/test_ensemble_selector.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import pytest 3 | from webviz_ert.assets import get_color 4 | from webviz_ert.plugins import ParameterComparison 5 | from tests.conftest import select_first, get_options 6 | from tests.data.snake_oil_data import all_ensemble_names 7 | from tests.conftest import setup_plugin, select_ensemble 8 | 9 | 10 | @pytest.mark.browser_test 11 | def test_ensemble_refresh( 12 | mock_data, 13 | dash_duo, 14 | ): 15 | plugin = setup_plugin(dash_duo, __name__, ParameterComparison) 16 | 17 | ensemble_selector_id = f"#{plugin.uuid('ensemble-multi-selector')}" 18 | expected_initial_ensembles = [ 19 | "default", 20 | "default_smoother_update", 21 | "default3", 22 | "nr_42", 23 | ] 24 | dash_duo.wait_for_text_to_equal( 25 | ensemble_selector_id, "\n".join(expected_initial_ensembles) 26 | ) 27 | 28 | # Select first ensemble 29 | first_ensemble_name = select_ensemble(dash_duo, plugin) 30 | 31 | # Select a parameter 32 | first_parameter = select_first( 33 | dash_duo, "#" + plugin.uuid("parameter-selector-multi-param") 34 | ) 35 | 36 | selected_parameters_finder = ( 37 | f"#{plugin.uuid('parameter-deactivator-param')} " ".Select-value-label" 38 | ) 39 | dash_duo.wait_for_contains_text(selected_parameters_finder, first_parameter) 40 | # for some reason, there is an empty label 41 | expected_selected_parameters = [first_parameter, " "] 42 | dash_duo.wait_for_text_to_equal( 43 | selected_parameters_finder, "\n".join(expected_selected_parameters) 44 | ) 45 | 46 | # Select second ensemble 47 | second_ensemble_name = select_ensemble(dash_duo, plugin) 48 | 49 | # Check selected parameters are unchanged (in particular, not duplicated) 50 | dash_duo.wait_for_text_to_equal( 51 | selected_parameters_finder, "\n".join(expected_selected_parameters) 52 | ) 53 | 54 | # Check only expected selectable options are available 55 | expected_ensembles_after_selection = list( 56 | filter( 57 | lambda ens: ens not in (first_ensemble_name, second_ensemble_name), 58 | all_ensemble_names, 59 | ) 60 | ) 61 | dash_duo.wait_for_text_to_equal( 62 | ensemble_selector_id, "\n".join(sorted(expected_ensembles_after_selection)) 63 | ) 64 | 65 | # Click the refresh button 66 | ensemble_refresh = dash_duo.find_element( 67 | "#" + plugin.uuid("ensemble-refresh-button") 68 | ) 69 | ensemble_refresh.click() 70 | 71 | # Check clicking refresh button also removes the selected parameters and options 72 | dash_duo.wait_for_text_to_equal( 73 | "#" + plugin.uuid("parameter-selector-multi-param"), 74 | "", 75 | ) 76 | 77 | dash_duo.wait_for_text_to_equal( 78 | "#" + plugin.uuid("parameter-deactivator-param"), 79 | "", 80 | ) 81 | 82 | # Check after the refresh all the initial ensemble cases are available as 83 | # options 84 | dash_duo.wait_for_text_to_equal( 85 | ensemble_selector_id, "\n".join(expected_initial_ensembles) 86 | ) 87 | 88 | 89 | @pytest.mark.browser_test 90 | def test_ensemble_color(mock_data, dash_duo): 91 | plugin = setup_plugin(dash_duo, __name__, ParameterComparison, (630, 1200)) 92 | 93 | ensembles = get_options(dash_duo, "#" + plugin.uuid("ensemble-multi-selector")) 94 | 95 | for _ in ensembles: 96 | ensemble_name = select_first( 97 | dash_duo, "#" + plugin.uuid("ensemble-multi-selector") 98 | ) 99 | dash_duo.wait_for_contains_text( 100 | "#" + plugin.uuid("selected-ensemble-dropdown"), 101 | ensemble_name, 102 | ) 103 | 104 | selected_ensembles = dash_duo.find_elements( 105 | "#" + plugin.uuid("selected-ensemble-dropdown") + " .Select-value" 106 | ) 107 | for idx, ensemble in enumerate(selected_ensembles): 108 | color = ensemble.value_of_css_property("background-color") 109 | expected_color = get_color(idx) 110 | assert color.replace(" ", "") == expected_color 111 | -------------------------------------------------------------------------------- /webviz_ert/views/parameter_view.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from typing import List 3 | from dash.development.base_component import Component 4 | from webviz_config import WebvizPluginABC 5 | 6 | from dash import html 7 | from dash import dcc 8 | import dash_bootstrap_components as dbc 9 | import webviz_ert.assets as assets 10 | 11 | 12 | def parameter_view(parent: WebvizPluginABC, index: str = "") -> List[Component]: 13 | return [ 14 | dcc.Store( 15 | id={"index": index, "type": parent.uuid("parameter-id-store")}, data=index 16 | ), 17 | dbc.Row( 18 | children=[ 19 | dbc.Col( 20 | [ 21 | html.H4( 22 | index, 23 | className="ert-parameter-view-caption", 24 | id={ 25 | "index-caption": index, 26 | "type": parent.uuid("parameter-id-store"), 27 | }, 28 | ) 29 | ], 30 | align="left", 31 | ), 32 | dbc.Tooltip( 33 | index, 34 | target={ 35 | "index-caption": index, 36 | "type": parent.uuid("parameter-id-store"), 37 | }, 38 | placement="bottom-start", 39 | class_name="ert-parameter-view-caption-tooltip", 40 | ), 41 | ] 42 | ), 43 | dbc.Row( 44 | children=[ 45 | dbc.Col( 46 | [ 47 | html.Label("Plots:"), 48 | ], 49 | width="auto", 50 | align="left", 51 | ), 52 | dbc.Col( 53 | [ 54 | dcc.Checklist( 55 | id={ 56 | "index": index, 57 | "type": parent.uuid("hist-check"), 58 | }, 59 | options=[ 60 | {"label": "histogram", "value": "hist"}, 61 | {"label": "kde", "value": "kde"}, 62 | ], 63 | value=["hist", "kde"], 64 | persistence="session", 65 | ), 66 | ], 67 | align="left", 68 | width="auto", 69 | ), 70 | dbc.Col( 71 | [ 72 | html.Label("Number of bins:", className="ert-label"), 73 | ], 74 | align="left", 75 | width="auto", 76 | ), 77 | dbc.Col( 78 | [ 79 | dcc.Input( 80 | id={ 81 | "index": index, 82 | "type": parent.uuid("hist-bincount"), 83 | }, 84 | type="number", 85 | placeholder="# bins", 86 | min=2, 87 | debounce=True, 88 | className="ert-input-number", 89 | ), 90 | ], 91 | align="left", 92 | width="auto", 93 | ), 94 | dcc.Store( 95 | id={"index": index, "type": parent.uuid("bincount-store")}, 96 | storage_type="session", 97 | ), 98 | ], 99 | ), 100 | dcc.Graph( 101 | id={ 102 | "index": index, 103 | "id": parent.uuid("parameter-scatter"), 104 | "type": parent.uuid("graph"), 105 | }, 106 | config={"responsive": True}, 107 | style={"height": "450px"}, 108 | ), 109 | ] 110 | -------------------------------------------------------------------------------- /webviz_ert/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | import pathlib 5 | import shutil 6 | import signal 7 | import sys 8 | import tempfile 9 | from typing import Any, Dict, Optional 10 | 11 | import yaml 12 | 13 | from webviz_ert.assets import WEBVIZ_CONFIG 14 | 15 | logger = logging.getLogger() 16 | 17 | 18 | def run_webviz_ert( 19 | title: str, 20 | experimental_mode: bool = False, 21 | verbose: bool = False, 22 | project_identifier: Optional[str] = None, 23 | ) -> None: 24 | signal.signal(signal.SIGINT, handle_exit) 25 | # The entry point of webviz is to call it from command line, and so do we. 26 | 27 | webviz = shutil.which("webviz") 28 | if webviz: 29 | send_ready() 30 | with tempfile.NamedTemporaryFile() as temp_config: 31 | if project_identifier is None: 32 | project_identifier = os.getcwd() 33 | create_config( 34 | title, project_identifier, WEBVIZ_CONFIG, temp_config, experimental_mode 35 | ) 36 | os.execl( 37 | webviz, 38 | webviz, 39 | "build", 40 | temp_config.name, 41 | "--theme", 42 | "equinor", 43 | "--loglevel", 44 | "DEBUG" if verbose else "WARNING", 45 | ) 46 | else: 47 | logger.error("Failed to find webviz") 48 | 49 | 50 | def send_ready() -> None: 51 | """ 52 | Tell ERT's BaseService that we're ready, even though we're not actually 53 | ready to accept requests. At the moment, ERT doesn't interface with 54 | webviz-ert in any way, so it's not necessary to send the signal later. 55 | """ 56 | if "ERT_COMM_FD" not in os.environ: 57 | logger.info("webviz ert is running outside of ert context") 58 | return 59 | fd = int(os.environ["ERT_COMM_FD"]) 60 | with os.fdopen(fd, "w") as f: 61 | f.write("{}") # Empty, but valid JSON 62 | 63 | 64 | def handle_exit( 65 | *_: Any, 66 | ) -> None: 67 | # pylint: disable=logging-not-lazy 68 | logger.info("\n" + "=" * 32) 69 | logger.info("Session terminated by the user.\nThank you for using webviz-ert!") 70 | logger.info("=" * 32) 71 | sys.tracebacklimit = 0 72 | sys.stdout = open(os.devnull, "w") 73 | sys.exit() 74 | 75 | 76 | def create_config( 77 | title: str, 78 | project_identifier: Optional[str], 79 | config_file: pathlib.Path, 80 | temp_config: Any, 81 | experimental_mode: bool, 82 | ) -> None: 83 | with open(config_file, "r") as f: 84 | config_dict = yaml.safe_load(f) 85 | for page in config_dict["pages"]: 86 | for element in page["content"]: 87 | for key in element: 88 | if element[key] is None: 89 | element[key] = {"project_identifier": project_identifier} 90 | else: 91 | element[key]["project_identifier"] = project_identifier 92 | 93 | new_config_dict = config_dict 94 | 95 | def filter_experimental_pages( 96 | page: Dict[str, Any], experimental_mode: bool 97 | ) -> bool: 98 | if "experimental" in page: 99 | if page["experimental"]: 100 | return experimental_mode 101 | return True 102 | 103 | new_config_dict["pages"] = [ 104 | page 105 | for page in new_config_dict["pages"] 106 | if filter_experimental_pages(page, experimental_mode) 107 | ] 108 | 109 | new_config_dict["title"] = f"{title} - {project_identifier}" 110 | 111 | output_str = yaml.dump(new_config_dict) 112 | temp_config.write(str.encode(output_str)) 113 | temp_config.seek(0) 114 | 115 | 116 | if __name__ == "__main__": 117 | parser = argparse.ArgumentParser() 118 | parser.add_argument("--experimental-mode", action="store_true") 119 | parser.add_argument( 120 | "--verbose", action="store_true", help="Show verbose output.", default=False 121 | ) 122 | parser.add_argument( 123 | "--title", help="Set title of html document", default="ERT - Visualization tool" 124 | ) 125 | parser.add_argument( 126 | "--project_identifier", help="Set title of html document", default=os.getcwd() 127 | ) 128 | 129 | args = parser.parse_args() 130 | 131 | run_webviz_ert( 132 | args.title, args.experimental_mode, args.verbose, args.project_identifier 133 | ) 134 | -------------------------------------------------------------------------------- /webviz_ert/controllers/plot_view_controller.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import dash_bootstrap_components as dbc 3 | 4 | from dash import html 5 | from typing import List, Union, Any, Dict, Tuple, Optional, Mapping 6 | from dash.development.base_component import Component 7 | from dash.dependencies import Input, Output, State 8 | 9 | import webviz_ert.controllers 10 | import webviz_ert.assets as assets 11 | 12 | from webviz_ert.views import response_view, parameter_view 13 | from webviz_ert.plugins import WebvizErtPluginABC 14 | from webviz_ert.models import DataType 15 | 16 | 17 | def _new_child(parent: WebvizErtPluginABC, plot: str, data_type: DataType) -> Component: 18 | if data_type == DataType.RESPONSE: 19 | p = response_view(parent=parent, index=plot) 20 | if data_type == DataType.PARAMETER: 21 | p = parameter_view(parent=parent, index=plot) 22 | 23 | return dbc.Col( 24 | html.Div(id=parent.uuid(plot), children=p), 25 | xl=12, 26 | lg=6, 27 | style=assets.ERTSTYLE["dbc-column-extra-high"], 28 | key=plot, 29 | ) 30 | 31 | 32 | def plot_view_controller( 33 | parent: WebvizErtPluginABC, app: dash.Dash, data_type: DataType 34 | ) -> None: 35 | if data_type == DataType.PARAMETER: 36 | webviz_ert.controllers.multi_parameter_controller(parent, app) 37 | elif data_type == DataType.RESPONSE: 38 | webviz_ert.controllers.multi_response_controller(parent, app) 39 | else: 40 | raise Exception(f"Unexpected data type `{data_type}`") 41 | 42 | @app.callback( 43 | Output(parent.uuid(f"plot-selection-store-{data_type}"), "data"), 44 | [ 45 | Input( 46 | parent.uuid(f"parameter-selection-store-{data_type}"), 47 | "modified_timestamp", 48 | ), 49 | ], 50 | [ 51 | State(parent.uuid(f"parameter-selection-store-{data_type}"), "data"), 52 | State(parent.uuid(f"plot-selection-store-{data_type}"), "data"), 53 | ], 54 | ) 55 | def update_plot_selection( 56 | _: Any, 57 | selection: Optional[List[str]], 58 | current_plots: Optional[List[str]], 59 | ) -> List[str]: 60 | selection = [] if not selection else selection 61 | current_plots = [] if not current_plots else current_plots 62 | for plot in current_plots.copy(): 63 | if plot not in selection: 64 | current_plots.remove(plot) 65 | for selected in selection: 66 | if selected not in current_plots: 67 | current_plots.append(selected) 68 | 69 | return current_plots 70 | 71 | @app.callback( 72 | Output(parent.uuid(f"plotting-content-{data_type}"), "children"), 73 | Input(parent.uuid(f"plot-selection-store-{data_type}"), "data"), 74 | State(parent.uuid(f"plotting-content-{data_type}"), "children"), 75 | ) 76 | def create_grid( 77 | plots: List[str], 78 | bootstrap_col_children: Union[None, Component, List[Component]], 79 | ) -> List[Component]: 80 | if not plots: 81 | return [] 82 | 83 | children: List[Component] = ( 84 | [] if bootstrap_col_children is None else bootstrap_col_children 85 | ) 86 | if type(children) is not list: 87 | children = [children] 88 | 89 | children_names = [] 90 | for c in children: 91 | children_names.append(c["props"]["key"]) 92 | 93 | if len(plots) > len(children_names): 94 | # Add new plots to the grid 95 | for plot in plots: 96 | if plot not in children_names: 97 | children.append(_new_child(parent, plot, data_type)) 98 | elif len(plots) < len(children_names): 99 | # Remove no longer selected plots from grid 100 | children = list( 101 | filter(lambda child: child["props"]["key"] in plots, children) 102 | ) 103 | 104 | for c in children: 105 | xl_width = 6 if len(children) > 1 else 12 106 | if isinstance(c, dict): 107 | c["props"]["xl"] = xl_width 108 | else: 109 | c.xl = xl_width 110 | 111 | # Every change to the childred even if the there is no change 112 | # and the input is returned as output triggers a redrawing 113 | # of every plot in the graph grid. This will sap performance 114 | # for very large data sets or large number of plots in the grid. 115 | # There is no clear way to avoid this wihtout redesigning the grid. 116 | return children 117 | -------------------------------------------------------------------------------- /webviz_ert/assets/ert-style.json: -------------------------------------------------------------------------------- 1 | { 2 | "dbc-column": { 3 | "min-height": "500px" 4 | }, 5 | "dbc-column-extra-high": { 6 | "min-height": "534px" 7 | }, 8 | "ensemble-selector": { 9 | "stylesheet": [ 10 | { 11 | "selector": "edge", 12 | "style": { 13 | "line-color": "rgba(128,128,128,0.5)" 14 | } 15 | }, 16 | { 17 | "selector": "node", 18 | "style": { 19 | "label": "data(label)" 20 | } 21 | }, 22 | { 23 | "selector": "node:selected", 24 | "style": { 25 | "label": "data(label)", 26 | "background-color": "data(color)" 27 | } 28 | } 29 | ], 30 | "color_wheel": [ 31 | "rgba(56,108,176,0.8)", 32 | "rgba(127,201,127,0.8)", 33 | "rgba(253,192,134,0.8)", 34 | "rgba(240,2,127,0.8)", 35 | "rgba(191,91,23,0.8)" 36 | ], 37 | "default_color": "rgba(128,128,128,0.3)" 38 | }, 39 | "observation-selection-plot":{ 40 | "mode": "markers", 41 | "line": null, 42 | "marker": { 43 | "color": "rgba(56,108,176,0.8)", 44 | "size": 9 45 | } 46 | }, 47 | "observation-response-plot": { 48 | "observation": { 49 | "mode": "markers", 50 | "color": "rgb(176, 28, 52)", 51 | "line": null, 52 | "marker": { 53 | "symbol": "line-ew-open", 54 | "color": "rgb(176, 28, 52)", 55 | "size": 20, 56 | "line": { 57 | "width": 5, 58 | "color": "DarkSlateGrey" 59 | } 60 | } 61 | }, 62 | "misfits": { 63 | "mode": "markers+lines", 64 | "line": { 65 | "color": "rgba(56,108,176,0.8)", 66 | "dash": "dash", 67 | "width": 2 68 | }, 69 | "marker": { 70 | "color": "rgba(56,108,176,0.8)", 71 | "size": 10, 72 | "line": { 73 | "width": 3, 74 | "color": "DarkSlateGrey" 75 | } 76 | } 77 | } 78 | }, 79 | "response-plot": { 80 | "observation": { 81 | "mode": "markers", 82 | "color": "rgb(176, 28, 52)", 83 | "line": null, 84 | "marker": { 85 | "color": "rgb(176, 28, 52)", 86 | "size": 10 87 | } 88 | }, 89 | "response": { 90 | "mode": "markers+lines", 91 | "line": { 92 | "color": "rgba(56,108,176,0.8)" 93 | }, 94 | "marker": { 95 | "color": "rgba(56,108,176,0.8)", 96 | "size": 1 97 | } 98 | }, 99 | "response-index": { 100 | "mode": "markers", 101 | "line": null, 102 | "marker": { 103 | "color": "rgba(56,108,176,0.8)", 104 | "size": 10 105 | } 106 | }, 107 | "statistics": { 108 | "mode": "lines", 109 | "line": { 110 | "color": "rgba(56,108,176,0.8)", 111 | "dash": "dash", 112 | "width": 4 113 | }, 114 | "marker": null 115 | } 116 | }, 117 | "parameter-plot": { 118 | "prior": { 119 | "line": { 120 | "dash": "dash", 121 | "width": 4 122 | } 123 | } 124 | }, 125 | "figure": { 126 | "layout": { 127 | "hovermode": "closest", 128 | "uirevision": true, 129 | "margin": { 130 | "l": 5, 131 | "b": 5, 132 | "t": 25, 133 | "r": 5 134 | }, 135 | "autosize": true 136 | }, 137 | "legend-bellow": { 138 | "showlegend": true, 139 | "legend": { 140 | "xanchor": "center", 141 | "yanchor": "top", 142 | "y": -0.3, 143 | "x": 0.5 144 | } 145 | }, 146 | "layout-value-y-axis-label": { 147 | "yaxis": { 148 | "title": { 149 | "text": "Value" 150 | } 151 | } 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /tests/data/spe1_st/tests/test_webviz_ert.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from webviz_ert.plugins import ( 3 | ParameterComparison, 4 | ResponseComparison, 5 | ResponseCorrelation, 6 | ObservationAnalyzer, 7 | ) 8 | from tests.conftest import ( 9 | select_first, 10 | select_ensemble, 11 | select_parameter, 12 | select_response, 13 | setup_plugin, 14 | verify_key_in_dropdown, 15 | ) 16 | 17 | parameter_keys = ["FIELD_PROPERTIES:POROSITY", "FIELD_PROPERTIES:X_MID_PERMEABILITY"] 18 | response_keys = ["WGPT:PROD", "WWPT:PROD", "WOPT:PROD", "WWIT:INJ"] 19 | response_keys_with_observations = ["WOPT:PROD"] 20 | 21 | 22 | def _verify_keys_in_menu(dash_duo_handle, plugin, keys, selector): 23 | dash_duo_handle.wait_for_element("#" + plugin.uuid(selector)) 24 | for key in keys: 25 | dash_duo_handle.wait_for_contains_text( 26 | "#" + plugin.uuid(selector), 27 | key, 28 | ) 29 | 30 | 31 | @pytest.mark.spe1 32 | @pytest.mark.xfail(reason="Fails because ert>5 no longer supports ert-storage") 33 | def test_webviz_parameter_comparison(dash_duo): 34 | plugin = setup_plugin(dash_duo, __name__, ParameterComparison) 35 | 36 | # Wait for the ensemble selector to be initialized 37 | dash_duo.wait_for_contains_text( 38 | "#" + plugin.uuid("ensemble-multi-selector"), 39 | "default", 40 | ) 41 | 42 | ensemble_name = select_first(dash_duo, "#" + plugin.uuid("ensemble-multi-selector")) 43 | _verify_keys_in_menu( 44 | dash_duo, plugin, parameter_keys, "parameter-selector-multi-param" 45 | ) 46 | 47 | 48 | @pytest.mark.spe1 49 | @pytest.mark.xfail(reason="Fails because ert>5 no longer supports ert-storage") 50 | def test_webviz_response_correlation(dash_duo): 51 | plugin = setup_plugin(dash_duo, __name__, ResponseCorrelation) 52 | 53 | ensemble = "default" 54 | response = "WOPT:PROD" 55 | parameter = "FIELD_PROPERTIES:POROSITY::0" 56 | index = "2016-01-01" 57 | 58 | # Wait for the ensemble selector to be initialized 59 | dash_duo.wait_for_contains_text( 60 | "#" + plugin.uuid("ensemble-multi-selector"), 61 | ensemble, 62 | ) 63 | select_ensemble(dash_duo, plugin, ensemble) 64 | 65 | _verify_keys_in_menu( 66 | dash_duo, plugin, parameter_keys, "parameter-selector-multi-param" 67 | ) 68 | _verify_keys_in_menu( 69 | dash_duo, 70 | plugin, 71 | response_keys_with_observations, 72 | "parameter-selector-multi-resp", 73 | ) 74 | 75 | select_response(dash_duo, plugin, response, wait_for_plot=False) 76 | dash_duo.wait_for_contains_text( 77 | "#" + plugin.uuid("parameter-deactivator-resp"), 78 | f"×{response}", 79 | ) 80 | 81 | select_parameter(dash_duo, plugin, parameter, wait_for_plot=False) 82 | dash_duo.wait_for_contains_text( 83 | "#" + plugin.uuid("parameter-deactivator-param"), 84 | f"×{parameter}", 85 | ) 86 | 87 | 88 | @pytest.mark.spe1 89 | @pytest.mark.xfail(reason="Fails because ert>5 no longer supports ert-storage") 90 | def test_webviz_response_comparison(dash_duo): 91 | plugin = setup_plugin(dash_duo, __name__, ResponseComparison) 92 | 93 | # Wait for the ensemble selector to be initialized 94 | dash_duo.wait_for_contains_text( 95 | "#" + plugin.uuid("ensemble-multi-selector"), 96 | "default", 97 | ) 98 | 99 | ensemble_name = select_first(dash_duo, "#" + plugin.uuid("ensemble-multi-selector")) 100 | _verify_keys_in_menu( 101 | dash_duo, plugin, parameter_keys, "parameter-selector-multi-param" 102 | ) 103 | 104 | _verify_keys_in_menu( 105 | dash_duo, plugin, response_keys, "parameter-selector-multi-resp" 106 | ) 107 | 108 | response_name = select_first( 109 | dash_duo, "#" + plugin.uuid("parameter-selector-multi-resp") 110 | ) 111 | #':' must be escaped in element ids 112 | resp_plot_id = "#" + plugin.uuid(response_name.replace(":", "\\:")) 113 | dash_duo.wait_for_element(resp_plot_id) 114 | 115 | param_name = select_first( 116 | dash_duo, "#" + plugin.uuid("parameter-selector-multi-param") 117 | ) 118 | param_plot_id = "#" + plugin.uuid(param_name.replace(":", "\\:")) 119 | dash_duo.wait_for_element(param_plot_id) 120 | 121 | # assert dash_duo.get_logs() == [], "browser console should contain no error" 122 | 123 | 124 | @pytest.mark.spe1 125 | @pytest.mark.xfail(reason="Fails because ert>5 no longer supports ert-storage") 126 | def test_webviz_observation_analyzer(dash_duo): 127 | plugin = setup_plugin(dash_duo, __name__, ObservationAnalyzer) 128 | 129 | # Wait for the ensemble selector to be initialized 130 | dash_duo.wait_for_contains_text( 131 | "#" + plugin.uuid("ensemble-multi-selector"), 132 | "default", 133 | ) 134 | 135 | ensemble_name = select_first(dash_duo, "#" + plugin.uuid("ensemble-multi-selector")) 136 | dash_duo.wait_for_contains_text( 137 | "#" + plugin.uuid("selected-ensemble-dropdown"), 138 | ensemble_name, 139 | ) 140 | 141 | verify_key_in_dropdown(dash_duo, plugin.uuid("response-selector"), "WOPT:PROD") 142 | -------------------------------------------------------------------------------- /webviz_ert/plugins/_parameter_comparison.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import dash_bootstrap_components as dbc 3 | import webviz_ert.controllers 4 | import webviz_ert.models 5 | from dash.development.base_component import Component 6 | from typing import List, Dict 7 | 8 | from webviz_ert.views import ( 9 | ensemble_selector_list, 10 | parallel_coordinates_view, 11 | parameter_selector_view, 12 | ) 13 | from webviz_ert.plugins import WebvizErtPluginABC 14 | from webviz_ert.models.data_model import DataType 15 | 16 | 17 | class ParameterComparison(WebvizErtPluginABC): 18 | def __init__(self, app: dash.Dash, project_identifier: str, beta: bool = False): 19 | super().__init__(app, project_identifier) 20 | self.set_callbacks(app) 21 | self.beta = beta 22 | 23 | @property 24 | def layout(self) -> Component: 25 | return dash.html.Div( 26 | [ 27 | dash.html.Div( 28 | children=[ 29 | dash.html.P( 30 | [ 31 | "This page is considered a ", 32 | dash.html.B("beta"), 33 | " version and could be changed or removed. You are encouraged to use it and give feedback to us regarding functionality and / or bugs.", 34 | ], 35 | className="ert-beta-warning", 36 | id=self.uuid("beta-warning"), 37 | ) 38 | ], 39 | hidden=not self.beta, 40 | ), 41 | dbc.Row( 42 | [ 43 | dbc.Col( 44 | id=self.uuid("ensemble-content"), 45 | children=ensemble_selector_list(parent=self), 46 | width=6, 47 | ), 48 | dbc.Col( 49 | id=self.uuid("parameter-content"), 50 | children=[ 51 | parameter_selector_view( 52 | parent=self, data_type=DataType.PARAMETER 53 | ), 54 | ], 55 | width=6, 56 | ), 57 | ] 58 | ), 59 | dbc.Row( 60 | dbc.Col( 61 | id=self.uuid("parallel-coor-content"), 62 | children=[ 63 | parallel_coordinates_view(parent=self), 64 | ], 65 | width=12, 66 | ) 67 | ), 68 | ] 69 | ) 70 | 71 | @property 72 | def tour_steps(self) -> List[Dict[str, str]]: 73 | steps = [ 74 | { 75 | "id": self.uuid("ensemble-multi-selector"), 76 | "content": "List of experiment ensembles.", 77 | }, 78 | { 79 | "id": self.uuid("selected-ensemble-dropdown"), 80 | "content": "List of currently selected ensembles.", 81 | }, 82 | { 83 | "id": self.uuid(f"ensemble-refresh-button"), 84 | "content": ( 85 | "Forces a refresh of all ensemble data including parameter and response data." 86 | ), 87 | }, 88 | { 89 | "id": self.uuid(f"parameter-selector-multi-param"), 90 | "content": ( 91 | "List of parameters. This list is populated only" 92 | " if at least one ensemble is selected." 93 | " Selecting multiple parameters is possible" 94 | " using mouse `Click + Drag` inside the response list." 95 | ), 96 | }, 97 | { 98 | "id": self.uuid(f"parameter-deactivator-param"), 99 | "content": ( 100 | "List of currently selected parameters." 101 | "Every selected parameter is visualized in the parallel coordinates plot," 102 | " where each vertical axis represents a single parameter." 103 | " Multiple ensembles are distinguished by different colors." 104 | ), 105 | }, 106 | { 107 | "id": self.uuid(f"parameter-selector-filter-param"), 108 | "content": ( 109 | "Search field. The parameter list will show only" 110 | " elements that contain the search characters" 111 | ), 112 | }, 113 | ] 114 | return steps 115 | 116 | def set_callbacks(self, app: dash.Dash) -> None: 117 | webviz_ert.controllers.ensemble_list_selector_controller(self, app) 118 | webviz_ert.controllers.parameter_selector_controller( 119 | self, app, data_type=DataType.PARAMETER, union_keys=False 120 | ) 121 | webviz_ert.controllers.parameter_comparison_controller(self, app) 122 | -------------------------------------------------------------------------------- /webviz_ert/controllers/multi_parameter_controller.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import plotly.graph_objects as go 3 | 4 | from typing import List, Tuple, Any, Optional, Mapping, Dict 5 | from dash.exceptions import PreventUpdate 6 | from dash.dependencies import Input, Output, State, MATCH 7 | from webviz_ert.models import MultiHistogramPlotModel, load_ensemble 8 | from webviz_ert.plugins import WebvizErtPluginABC 9 | from webviz_ert import assets 10 | 11 | 12 | def multi_parameter_controller(parent: WebvizErtPluginABC, app: dash.Dash) -> None: 13 | @app.callback( 14 | Output({"index": MATCH, "type": parent.uuid("bincount-store")}, "data"), 15 | [Input({"index": MATCH, "type": parent.uuid("hist-bincount")}, "value")], 16 | [State({"index": MATCH, "type": parent.uuid("bincount-store")}, "data")], 17 | ) 18 | def update_bincount(hist_bincount: int, store_bincount: int) -> int: 19 | if not isinstance(hist_bincount, int): 20 | raise PreventUpdate 21 | if hist_bincount < 2: 22 | raise PreventUpdate 23 | if hist_bincount == store_bincount: 24 | raise PreventUpdate 25 | return hist_bincount 26 | 27 | @app.callback( 28 | [ 29 | Output( 30 | { 31 | "index": MATCH, 32 | "id": parent.uuid("parameter-scatter"), 33 | "type": parent.uuid("graph"), 34 | }, 35 | "figure", 36 | ), 37 | Output({"index": MATCH, "type": parent.uuid("hist-bincount")}, "value"), 38 | ], 39 | [ 40 | Input({"index": MATCH, "type": parent.uuid("hist-check")}, "value"), 41 | Input( 42 | {"index": MATCH, "type": parent.uuid("bincount-store")}, 43 | "modified_timestamp", 44 | ), 45 | Input(parent.uuid("ensemble-selection-store"), "modified_timestamp"), 46 | Input(parent.uuid("param-label-check"), "value"), 47 | ], 48 | [ 49 | State(parent.uuid("selected-ensemble-dropdown"), "value"), 50 | State({"index": MATCH, "type": parent.uuid("parameter-id-store")}, "data"), 51 | State({"index": MATCH, "type": parent.uuid("bincount-store")}, "data"), 52 | ], 53 | ) 54 | def update_histogram( 55 | hist_check_values: List[str], 56 | _: Any, 57 | __: Any, 58 | legend: List[str], 59 | selected_ensembles: List[str], 60 | parameter: str, 61 | bin_count: int, 62 | ) -> Tuple[go.Figure, int]: 63 | if not selected_ensembles: 64 | raise PreventUpdate 65 | 66 | data = {} 67 | colors = {} 68 | names = {} 69 | priors = {} 70 | for index, ensemble_id in enumerate(selected_ensembles): 71 | ensemble = load_ensemble(parent, ensemble_id) 72 | if ensemble.parameters and parameter in ensemble.parameters: 73 | key = str(ensemble) 74 | parameter_model = ensemble.parameters[parameter] 75 | data[key] = parameter_model.data_df() 76 | colors[key] = assets.get_color(index) 77 | names[key] = key if "label" in legend else "" 78 | 79 | if parameter_model.priors and "prior" in hist_check_values: 80 | priors[names[key]] = (parameter_model.priors, colors[key]) 81 | 82 | parameter_plot = MultiHistogramPlotModel( 83 | data, 84 | names=names, 85 | colors=colors, 86 | hist="hist" in hist_check_values, 87 | kde="kde" in hist_check_values, 88 | priors=priors, 89 | bin_count=bin_count, 90 | ) 91 | return parameter_plot.repr, parameter_plot.bin_count 92 | 93 | @app.callback( 94 | Output({"index": MATCH, "type": parent.uuid("hist-check")}, "options"), 95 | [ 96 | Input({"index": MATCH, "type": parent.uuid("parameter-id-store")}, "data"), 97 | ], 98 | [ 99 | State({"index": MATCH, "type": parent.uuid("hist-check")}, "options"), 100 | State(parent.uuid("selected-ensemble-dropdown"), "value"), 101 | ], 102 | ) 103 | def set_parameter_from_btn( 104 | parameter: str, 105 | plotting_options: List[Mapping[str, str]], 106 | selected_ensembles: List[str], 107 | ) -> List[Mapping[str, str]]: 108 | if not selected_ensembles: 109 | raise PreventUpdate 110 | has_priors = False 111 | for ensemble_id in selected_ensembles: 112 | ensemble = load_ensemble(parent, ensemble_id) 113 | if ensemble.parameters and parameter in ensemble.parameters: 114 | parameter_model = ensemble.parameters[parameter] 115 | if parameter_model.priors: 116 | has_priors = True 117 | break 118 | prior_option = {"label": "prior", "value": "prior"} 119 | if has_priors and prior_option not in plotting_options: 120 | plotting_options.append(prior_option) 121 | if not has_priors and prior_option in plotting_options: 122 | plotting_options.remove(prior_option) 123 | return plotting_options 124 | -------------------------------------------------------------------------------- /tests/views/test_parameter_selector.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from selenium.webdriver.common.keys import Keys 4 | from webviz_ert.plugins import ParameterComparison 5 | from tests.conftest import setup_plugin, select_ensemble, select_by_name 6 | 7 | 8 | @pytest.mark.browser_test 9 | def test_parameter_selector( 10 | mock_data, 11 | dash_duo, 12 | ): 13 | plugin = setup_plugin(dash_duo, __name__, ParameterComparison) 14 | select_ensemble(dash_duo, plugin) 15 | 16 | dash_duo.wait_for_element("#" + plugin.uuid("parameter-selector-multi-param")) 17 | 18 | dash_duo.wait_for_contains_text( 19 | "#" + plugin.uuid("parameter-selector-multi-param"), 20 | "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE", 21 | ) 22 | 23 | dash_duo.wait_for_contains_text( 24 | "#" + plugin.uuid("parameter-selector-multi-param"), 25 | "SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE", 26 | ) 27 | 28 | paremeter_deactivator = dash_duo.find_element( 29 | "#" + plugin.uuid("parameter-deactivator-param") 30 | ) 31 | 32 | paremeter_deactivator.click() 33 | 34 | parameter_selector_input = dash_duo.find_element( 35 | "#" + plugin.uuid("parameter-selector-filter-param") 36 | ) 37 | 38 | parameter_selector_input.send_keys("OP1") 39 | 40 | dash_duo.wait_for_text_to_equal( 41 | "#" + plugin.uuid("parameter-selector-filter-param"), "OP1" 42 | ) 43 | dash_duo.wait_for_text_to_equal( 44 | "#" + plugin.uuid("parameter-selector-multi-param"), 45 | "SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE", 46 | ) 47 | 48 | parameter_selector_container = dash_duo.find_element( 49 | "#" + plugin.uuid("container-parameter-selector-multi-param") 50 | ) 51 | 52 | assert parameter_selector_container.is_displayed() 53 | button_hide = dash_duo.find_element("#" + plugin.uuid("parameter-selector-button")) 54 | button_hide.click() 55 | parameter_selector_container = dash_duo.wait_for_element_by_css_selector( 56 | ".ert-parameter-selector-container-hide" 57 | ) 58 | # assert dash_duo.get_logs() == [] 59 | 60 | 61 | @pytest.mark.browser_test 62 | def test_search_input_return_functionality( 63 | mock_data, 64 | dash_duo, 65 | ): 66 | plugin = setup_plugin(dash_duo, __name__, ParameterComparison) 67 | select_ensemble(dash_duo, plugin) 68 | 69 | parameter_selector_container = dash_duo.find_element( 70 | "#" + plugin.uuid("container-parameter-selector-multi-param") 71 | ) 72 | 73 | dash_duo.wait_for_contains_text( 74 | "#" + plugin.uuid("parameter-selector-multi-param"), 75 | "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE", 76 | ) 77 | dash_duo.wait_for_contains_text( 78 | "#" + plugin.uuid("parameter-selector-multi-param"), 79 | "OP1_DIVERGENCE_SCALE", 80 | ) 81 | first_elem, _ = parameter_selector_container.text.split("\n") 82 | 83 | param_selector_id = plugin.uuid("parameter-selector-multi-param") 84 | select_by_name( 85 | dash_duo, f"#{param_selector_id}", "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE" 86 | ) 87 | 88 | parameter_deactivator = dash_duo.find_element( 89 | "#" + plugin.uuid("parameter-deactivator-param") 90 | ) 91 | 92 | dash_duo.wait_for_contains_text( 93 | "#" + plugin.uuid("parameter-deactivator-param"), 94 | "×SNAKE_OIL_PARAM:BPR_138_PERSISTENCE", 95 | ) 96 | parameter_deactivator.click() 97 | dash_duo.clear_input(parameter_deactivator) 98 | 99 | dash_duo.wait_for_contains_text( 100 | "#" + plugin.uuid("parameter-deactivator-param"), "" 101 | ) 102 | 103 | parameter_selector_input = dash_duo.find_element( 104 | "#" + plugin.uuid("parameter-selector-filter-param") 105 | ) 106 | parameter_selector_input.send_keys(Keys.ENTER) 107 | dash_duo.wait_for_contains_text( 108 | "#" + plugin.uuid("parameter-deactivator-param"), "" 109 | ) 110 | 111 | parameter_selector_input.send_keys("SNAKE_OIL_PARAM:OP1") 112 | 113 | dash_duo.wait_for_text_to_equal( 114 | "#" + plugin.uuid("parameter-selector-filter-param"), "SNAKE_OIL_PARAM:OP1" 115 | ) 116 | dash_duo.wait_for_text_to_equal( 117 | "#" + plugin.uuid("parameter-selector-multi-param"), 118 | "SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE", 119 | ) 120 | parameter_selector_input.send_keys(Keys.ENTER) 121 | 122 | dash_duo.wait_for_contains_text( 123 | "#" + plugin.uuid("parameter-deactivator-param"), "" 124 | ) 125 | 126 | select_by_name( 127 | dash_duo, f"#{param_selector_id}", "SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE" 128 | ) 129 | dash_duo.wait_for_contains_text( 130 | "#" + plugin.uuid("parameter-deactivator-param"), 131 | "×SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE", 132 | ) 133 | 134 | # assert dash_duo.get_logs() == [] 135 | 136 | 137 | @pytest.mark.browser_test 138 | def test_parameter_selector_sorting( 139 | mock_data, 140 | dash_duo, 141 | ): 142 | plugin = setup_plugin(dash_duo, __name__, ParameterComparison) 143 | 144 | wanted_ensemble_name = "nr_42" 145 | select_ensemble(dash_duo, plugin, wanted_ensemble_name) 146 | 147 | expected_parameters = [ 148 | "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE", 149 | "SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE", 150 | ] 151 | 152 | parameter_selector_container_id = "#" + plugin.uuid( 153 | "container-parameter-selector-multi-param" 154 | ) 155 | dash_duo.wait_for_text_to_equal( 156 | parameter_selector_container_id, "\n".join(expected_parameters) 157 | ) 158 | -------------------------------------------------------------------------------- /webviz_ert/plugins/_response_comparison.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import webviz_ert.controllers 3 | import webviz_ert.models 4 | from dash.development.base_component import Component 5 | from typing import List, Dict 6 | from webviz_ert.views import ( 7 | ensemble_selector_list, 8 | plot_view_body, 9 | plot_view_header, 10 | plot_view_menu, 11 | ) 12 | from webviz_ert.models.data_model import DataType 13 | from webviz_ert.plugins import WebvizErtPluginABC 14 | 15 | 16 | class ResponseComparison(WebvizErtPluginABC): 17 | def __init__(self, app: dash.Dash, project_identifier: str, beta: bool = False): 18 | super().__init__(app, project_identifier) 19 | self.set_callbacks(app) 20 | self.beta = beta 21 | 22 | @property 23 | def layout(self) -> Component: 24 | return dash.html.Div( 25 | [ 26 | dash.html.Div( 27 | children=[ 28 | dash.html.P( 29 | [ 30 | "This page is considered a ", 31 | dash.html.B("beta"), 32 | " version and could be changed or removed. You are encouraged to use it and give feedback to us regarding functionality and / or bugs.", 33 | ], 34 | className="ert-beta-warning", 35 | id=self.uuid("beta-warning"), 36 | ) 37 | ], 38 | hidden=not self.beta, 39 | ), 40 | dash.html.Div( 41 | children=plot_view_header(parent=self), 42 | ), 43 | dash.html.Div( 44 | children=plot_view_body(parent=self), 45 | ), 46 | dash.html.Div( 47 | children=plot_view_menu(parent=self), 48 | ), 49 | ] 50 | ) 51 | 52 | @property 53 | def tour_steps(self) -> List[Dict[str, str]]: 54 | steps = [ 55 | { 56 | "id": self.uuid("ensemble-multi-selector"), 57 | "content": "List of experiment ensembles.", 58 | }, 59 | { 60 | "id": self.uuid("selected-ensemble-dropdown"), 61 | "content": "List of currently selected ensembles.", 62 | }, 63 | { 64 | "id": self.uuid(f"ensemble-refresh-button"), 65 | "content": ( 66 | "Forces a refresh of all ensemble data including parameter and response data." 67 | ), 68 | }, 69 | { 70 | "id": self.uuid("response-section"), 71 | "content": "Response section.", 72 | }, 73 | { 74 | "id": self.uuid(f"parameter-selector-multi-resp"), 75 | "content": ( 76 | "List of responses. This list is populated only" 77 | " if at least one ensemble is selected." 78 | " Selecting multiple responses is possible" 79 | " using mouse `Click + Drag` inside the response list." 80 | ), 81 | }, 82 | { 83 | "id": self.uuid(f"parameter-deactivator-resp"), 84 | "content": "List of currently selected responses.", 85 | }, 86 | { 87 | "id": self.uuid(f"parameter-selector-filter-resp"), 88 | "content": ( 89 | "Response search field. The response list will show only" 90 | " elements that contain the search characters" 91 | ), 92 | }, 93 | { 94 | "id": self.uuid("parameter-section"), 95 | "content": "Parameter section", 96 | }, 97 | { 98 | "id": self.uuid(f"parameter-selector-multi-param"), 99 | "content": ( 100 | "List of parameters. This list is populated only" 101 | " if at least one ensemble is selected." 102 | " Selecting multiple parameters is possible" 103 | " using mouse `Click + Drag` inside the response list." 104 | ), 105 | }, 106 | { 107 | "id": self.uuid(f"parameter-deactivator-param"), 108 | "content": "List of currently selected parameters.", 109 | }, 110 | { 111 | "id": self.uuid(f"parameter-selector-filter-param"), 112 | "content": ( 113 | "Search field. The parameter list will show only" 114 | " elements that contain the search characters" 115 | ), 116 | }, 117 | ] 118 | return steps 119 | 120 | def set_callbacks(self, app: dash.Dash) -> None: 121 | webviz_ert.controllers.ensemble_list_selector_controller(self, app) 122 | webviz_ert.controllers.parameter_selector_controller( 123 | self, app, data_type=DataType.PARAMETER 124 | ) 125 | webviz_ert.controllers.parameter_selector_controller( 126 | self, app, data_type=DataType.RESPONSE, extra_input=True 127 | ) 128 | webviz_ert.controllers.plot_view_controller( 129 | self, app, webviz_ert.models.DataType.RESPONSE 130 | ) 131 | webviz_ert.controllers.plot_view_controller( 132 | self, app, webviz_ert.models.DataType.PARAMETER 133 | ) 134 | -------------------------------------------------------------------------------- /tests/views/test_plot_view.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from webviz_ert.plugins._response_comparison import ResponseComparison 3 | from tests.conftest import ( 4 | setup_plugin, 5 | select_ensemble, 6 | select_parameter, 7 | select_response, 8 | wait_a_bit, 9 | ) 10 | from urllib.parse import quote 11 | 12 | 13 | @pytest.mark.browser_test 14 | def test_plot_view( 15 | mock_data, 16 | dash_duo, 17 | ): 18 | # This test selects an ensemble from the ensemble-multi-selector 19 | # then selects a response and parameter and checks that the 20 | # DOM element for both are created. 21 | plugin = setup_plugin(dash_duo, __name__, ResponseComparison) 22 | 23 | select_ensemble(dash_duo, plugin) 24 | 25 | select_response(dash_duo, plugin, "SNAKE_OIL_GPR_DIFF@199") 26 | 27 | select_parameter(dash_duo, plugin, "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE") 28 | 29 | # assert dash_duo.get_logs() == [], "browser console should contain no error" 30 | 31 | 32 | @pytest.mark.browser_test 33 | def test_clearing_parameters_view( 34 | mock_data, 35 | dash_duo, 36 | ): 37 | # this is a regression test, ensuring that all appropriate plots are 38 | # cleared when a selection of multiple parameters is cleared. 39 | # we select an ensemble, select a response, select two parameters, check 40 | # that three graphs are present, clear all parameters, and check that just 41 | # the response graph remains 42 | plugin = setup_plugin(dash_duo, __name__, ResponseComparison) 43 | 44 | select_ensemble(dash_duo, plugin) 45 | 46 | response_name = "SNAKE_OIL_GPR_DIFF@199" 47 | 48 | # click the response, and check that the response graph appears 49 | select_response(dash_duo, plugin, response_name) 50 | 51 | # click some parameters 52 | select_parameter(dash_duo, plugin, "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE") 53 | select_parameter(dash_duo, plugin, "SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE") 54 | 55 | # clear parameter selection 56 | clear_all = dash_duo.find_element( 57 | "#" + plugin.uuid("parameter-deactivator-param") + " span.Select-clear-zone" 58 | ) 59 | clear_all.click() 60 | 61 | # verify only expected response plot is left in place 62 | dash_duo.find_element(f'.dash-graph[id*="{response_name}"]') 63 | 64 | # assert dash_duo.get_logs() == [], "browser console sho uld contain no error" 65 | 66 | 67 | @pytest.mark.browser_test 68 | def test_clearing_ensembles_view( 69 | mock_data, 70 | dash_duo, 71 | ): 72 | # this is a regression test, ensuring that clearing all ensembles actually 73 | # removes all ensembles, all responses, all parameters, and all plots 74 | 75 | plugin = setup_plugin(dash_duo, __name__, ResponseComparison, (1024, 2048)) 76 | 77 | # click and choose two ensembles 78 | select_ensemble(dash_duo, plugin) 79 | select_ensemble(dash_duo, plugin) 80 | 81 | # click the response, and check that the response graph appears 82 | select_response(dash_duo, plugin, "SNAKE_OIL_GPR_DIFF@199") 83 | 84 | # click some parameters 85 | select_parameter(dash_duo, plugin, "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE") 86 | select_parameter(dash_duo, plugin, "SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE") 87 | 88 | # clear ensemble selection 89 | clear_all = dash_duo.find_element( 90 | "#" + plugin.uuid("selected-ensemble-dropdown") + " span.Select-clear-zone" 91 | ) 92 | clear_all.click() 93 | 94 | # wait a bit for the page to update 95 | wait_a_bit(dash_duo) 96 | 97 | # verify all plots are gone 98 | plots = dash_duo.find_elements(".dash-graph") 99 | assert len(plots) == 0 100 | 101 | # verify no responses are selected 102 | chosen_responses = dash_duo.find_elements( 103 | "#" + plugin.uuid("parameter-deactivator-resp") + " .Select-value" 104 | ) 105 | assert len(chosen_responses) == 0 106 | 107 | # verify no parameters are selected 108 | chosen_parameters = dash_duo.find_elements( 109 | "#" + plugin.uuid("parameter-deactivator-param") + " .Select-value" 110 | ) 111 | assert len(chosen_parameters) == 0 112 | 113 | # assert dash_duo.get_logs() == [], "browser console should contain no error" 114 | 115 | 116 | @pytest.mark.browser_test 117 | def test_axis_labels(mock_data, dash_duo): 118 | """test_axis_labels loads two different plots in the plot view and checks 119 | that axes are labelled correctly""" 120 | 121 | plugin = setup_plugin( 122 | dash_duo, __name__, ResponseComparison, window_size=(1024, 2048) 123 | ) 124 | 125 | # find the right ensemble which has mock data prepared for this test 126 | wanted_ensemble_name = "default3" 127 | select_ensemble(dash_duo, plugin, wanted_ensemble_name) 128 | 129 | # click two responses, with specified names 130 | wanted_responses = ["FGPT", "FOPR"] 131 | 132 | for response in wanted_responses: 133 | select_response(dash_duo, plugin, response) 134 | 135 | # check that both have Value as y axis label 136 | for response in wanted_responses: 137 | y_axis_title_selector = f"#{plugin.uuid(response)} text.ytitle" 138 | dash_duo.wait_for_text_to_equal(y_axis_title_selector, "Value") 139 | 140 | # check that one has date, the other has index as x axis label 141 | date_plot_id = plugin.uuid("FOPR") 142 | dash_duo.wait_for_text_to_equal(f"#{date_plot_id} text.xtitle", "Date") 143 | 144 | index_plot_id = plugin.uuid("FGPT") 145 | dash_duo.wait_for_text_to_equal(f"#{index_plot_id} text.xtitle", "Index") 146 | 147 | # assert dash_duo.get_logs() == [], "browser console should contain no error" 148 | -------------------------------------------------------------------------------- /tests/controllers/test_response_correlation_controller.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import pytest 3 | 4 | from webviz_ert.controllers.response_correlation_controller import ( 5 | _define_style_ensemble, 6 | _layout_figure, 7 | _format_index_value, 8 | _format_index_text, 9 | ) 10 | 11 | from webviz_ert.controllers.response_correlation_controller import ( 12 | _sort_dataframe, 13 | _get_selected_indexes, 14 | ) 15 | from webviz_ert.models import PlotModel 16 | 17 | 18 | PLOT_STYLE = { 19 | "mode": "markers", 20 | "line": None, 21 | "marker": {"color": "rgba(56,108,176,0.8)", "size": 9}, 22 | "text": "NA", 23 | } 24 | 25 | 26 | def test_sort_dataframe(): 27 | one_key = "WOPR" 28 | other_key = "BGMC" 29 | data = {} 30 | data[one_key] = [-0.4, 0.6, 0.2] 31 | data[other_key] = [0.3, 0.8, -0.1] 32 | dataframe = pd.DataFrame(data=data) 33 | index = None 34 | sorted_dataframe, _ = _sort_dataframe(dataframe, index, other_key) 35 | assert list(sorted_dataframe[other_key]) == sorted(data[other_key]) 36 | assert list(sorted_dataframe[one_key]) == [0.2, -0.4, 0.6] 37 | 38 | 39 | @pytest.mark.parametrize( 40 | "index,expected_color", [(0, "rgba(56,108,176,0.8)"), (1, "rgba(127,201,127,0.8)")] 41 | ) 42 | def test_define_style_ensemble_color(index, expected_color): 43 | x_axis = pd.Index([1]) 44 | style = _define_style_ensemble(index, x_axis) 45 | assert style["line"]["color"] == expected_color 46 | assert style["marker"]["color"] == expected_color 47 | 48 | 49 | @pytest.mark.parametrize( 50 | "x_axis_list,expected_mode", 51 | [ 52 | ( 53 | [pd.Timestamp("01-01-2020")], 54 | "markers+lines", 55 | ), 56 | ([str(1)], "markers"), 57 | ], 58 | ) 59 | def test_define_style_ensemble_mode(x_axis_list, expected_mode): 60 | x_axis = pd.Index(x_axis_list) 61 | style = _define_style_ensemble(0, x_axis) 62 | assert style["mode"] == expected_mode 63 | 64 | 65 | def test_layout_figure(): 66 | layout = _layout_figure(x_axis_label="Index") # "Date" is also possible 67 | expected_layout = { 68 | "hovermode": "closest", 69 | "uirevision": True, 70 | "margin": {"l": 5, "b": 5, "t": 25, "r": 5}, 71 | "autosize": True, 72 | "showlegend": False, 73 | "clickmode": "event+select", 74 | "xaxis": {"title": {"text": "Index"}}, 75 | "yaxis": {"title": {"text": "Value"}}, 76 | } 77 | 78 | assert layout == expected_layout 79 | 80 | 81 | @pytest.mark.parametrize( 82 | "raw_value,expected_formatted_value", 83 | [ 84 | ("2022-08-05 14:25:00", "2022-08-05"), 85 | ("2022-09-21 14:25:00", "2022-09-21"), 86 | (14, "14"), 87 | ("213", "213"), 88 | ("SPAM", "SPAM"), 89 | ], 90 | ids=["date1", "date2", "numeric", "numeric-as-string", "silly-string"], 91 | ) 92 | def test_format_index_text(raw_value: str, expected_formatted_value: str): 93 | assert _format_index_text(raw_value) == expected_formatted_value 94 | 95 | 96 | @pytest.mark.parametrize( 97 | "raw_value,expected_formatted_value", 98 | [ 99 | ("2022-08-05 14:25:00", "2022-08-05 14:25:00"), 100 | ("2022-09-21 14:25:00", "2022-09-21 14:25:00"), 101 | (14, 14), 102 | ("213", 213), 103 | ("SPAM", "SPAM"), 104 | ], 105 | ids=["date1", "date2", "numeric", "numeric-as-string", "silly-string"], 106 | ) 107 | def test_format_index_value(raw_value: str, expected_formatted_value: str): 108 | assert _format_index_value(raw_value) == expected_formatted_value 109 | 110 | 111 | @pytest.mark.parametrize( 112 | "plots, date_ranges, expected", 113 | [ 114 | ([], None, {}), 115 | ([], {}, {}), 116 | ([], {}, {}), 117 | ( 118 | [ 119 | PlotModel( 120 | x_axis=[0, 5, 10], y_axis=[1, 1, 1], name="plot1", **PLOT_STYLE 121 | ) 122 | ], 123 | {"x": ["2020-01-01", "2020-01-03"]}, 124 | {}, 125 | ), 126 | ( 127 | [ 128 | PlotModel( 129 | x_axis=[0, 5, 10], y_axis=[1, 1, 1], name="plot1", **PLOT_STYLE 130 | ) 131 | ], 132 | {"x2": [1, 3]}, 133 | {}, 134 | ), 135 | ( 136 | [ 137 | PlotModel( 138 | x_axis=[0, 5, 10], y_axis=[1, 1, 1], name="plot1", **PLOT_STYLE 139 | ), 140 | PlotModel( 141 | x_axis=[pd.Timestamp("2020-01-02")], 142 | y_axis=[1, 1, 1], 143 | name="plot2", 144 | **PLOT_STYLE 145 | ), 146 | ], 147 | {"x": ["2020-01-01", "2020-01-03"]}, 148 | {"plot2": [pd.Timestamp("2020-01-02")]}, 149 | ), 150 | ( 151 | [ 152 | PlotModel( 153 | x_axis=[0, 5, 10], y_axis=[1, 1, 1], name="plot1", **PLOT_STYLE 154 | ), 155 | PlotModel( 156 | x_axis=[pd.Timestamp("2020-01-02")], 157 | y_axis=[1, 1, 1], 158 | name="plot2", 159 | **PLOT_STYLE 160 | ), 161 | ], 162 | {"x": ["2020-01-01", "2020-01-03"], "x2": [4, 6]}, 163 | {"plot1": [5], "plot2": [pd.Timestamp("2020-01-02")]}, 164 | ), 165 | ], 166 | ) 167 | def test_get_selected_indexes(plots, date_ranges, expected): 168 | assert _get_selected_indexes(plots, date_ranges) == expected 169 | -------------------------------------------------------------------------------- /webviz_ert/models/ensemble_model.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pandas as pd 3 | from typing import Mapping, List, Dict, Union, Any, Optional 4 | from webviz_ert.data_loader import get_data_loader, DataLoaderException 5 | from webviz_ert.models import Response, PriorModel, ParametersModel 6 | 7 | 8 | def _create_parameter_models( 9 | parameters_names: list, 10 | priors: dict, 11 | ensemble_id: str, 12 | project_id: str, 13 | ) -> Optional[Mapping[str, ParametersModel]]: 14 | parameters = {} 15 | for param in parameters_names: 16 | key = param 17 | prior_schema = priors.get(key, None) 18 | prior = None 19 | if prior_schema: 20 | prior = PriorModel( 21 | prior_schema["function"], 22 | [x[0] for x in prior_schema.items() if isinstance(x[1], (float, int))], 23 | [x[1] for x in prior_schema.items() if isinstance(x[1], (float, int))], 24 | ) 25 | 26 | parameters[key] = ParametersModel( 27 | group="", # TODO? 28 | key=key, 29 | prior=prior, 30 | param_id="", # TODO? 31 | project_id=project_id, 32 | ensemble_id=ensemble_id, 33 | ) 34 | return parameters 35 | 36 | 37 | class EnsembleModel: 38 | def __init__(self, ensemble_id: str, project_id: str) -> None: 39 | self._data_loader = get_data_loader(project_id) 40 | self._schema = self._data_loader.get_ensemble(ensemble_id) 41 | self._experiment_id = self._schema["experiment_id"] 42 | self._project_id = project_id 43 | self._metadata = self._schema["userdata"] 44 | self._name = self._metadata["name"] 45 | self._id = ensemble_id 46 | self._children = self._schema.get("child_ensemble_ids", []) 47 | self._parent = self._schema.get("parent_ensemble_id", []) 48 | self._size = self._schema["size"] 49 | self._active_realizations = self._schema["active_realizations"] 50 | self._responses: Dict[str, Response] = {} 51 | self._parameters: Optional[Mapping[str, ParametersModel]] = None 52 | self._cached_children: Optional[List["EnsembleModel"]] = None 53 | self._cached_parent: Optional["EnsembleModel"] = None 54 | 55 | @property 56 | def responses(self) -> Dict[str, Response]: 57 | if not self._responses: 58 | self._responses = {} 59 | responses_dict = self._data_loader.get_ensemble_responses(self._id) 60 | response_names = list(responses_dict.keys()) 61 | response_names.sort() 62 | 63 | for name in response_names: 64 | self._responses[name] = Response( 65 | name=name, 66 | ensemble_id=self._id, 67 | project_id=self._project_id, 68 | ensemble_size=self._size, 69 | active_realizations=self._active_realizations, 70 | resp_schema=responses_dict[name], 71 | ) 72 | return self._responses 73 | 74 | @property 75 | def children(self) -> Optional[List["EnsembleModel"]]: 76 | if not self._cached_children: 77 | self._cached_children = [ 78 | EnsembleModel( 79 | ensemble_id=child, 80 | project_id=self._project_id, 81 | ) 82 | for child in self._children 83 | ] 84 | return self._cached_children 85 | 86 | @property 87 | def parent(self) -> Optional["EnsembleModel"]: 88 | if not self._parent: 89 | return None 90 | if not self._cached_parent: 91 | self._cached_parent = EnsembleModel( 92 | ensemble_id=self._parent, 93 | project_id=self._project_id, 94 | ) 95 | return self._cached_parent 96 | 97 | @property 98 | def parameters( 99 | self, 100 | ) -> Optional[Mapping[str, ParametersModel]]: 101 | if not self._parameters: 102 | parameter_names = [] 103 | for params in self._data_loader.get_ensemble_parameters(self._id): 104 | labels = params["labels"] 105 | param_name = params["name"] 106 | if len(labels) > 0: 107 | for label in labels: 108 | parameter_names.append(f"{param_name}::{label}") 109 | else: 110 | parameter_names.append(param_name) 111 | parameter_priors = ( 112 | self._data_loader.get_experiment_priors(self._experiment_id) 113 | if not self._parent 114 | else {} 115 | ) 116 | self._parameters = _create_parameter_models( 117 | parameter_names, 118 | parameter_priors, 119 | ensemble_id=self._id, 120 | project_id=self._project_id, 121 | ) 122 | return self._parameters 123 | 124 | def parameters_df(self, parameter_list: Optional[List[str]] = None) -> pd.DataFrame: 125 | if not self.parameters or not parameter_list: 126 | return pd.DataFrame() 127 | data = { 128 | parameter: self.parameters[parameter].data_df().values.flatten() 129 | for parameter in parameter_list 130 | } 131 | return pd.DataFrame(data=data) 132 | 133 | @property 134 | def id(self) -> str: 135 | return self._id 136 | 137 | def __str__(self) -> str: 138 | return f"{self._name}" 139 | 140 | def __repr__(self) -> str: 141 | return f"{self.id}, {self._name}" 142 | 143 | @property 144 | def name(self) -> str: 145 | return self._name 146 | -------------------------------------------------------------------------------- /webviz_ert/controllers/observation_response_controller.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import pandas as pd 3 | import plotly.graph_objects as go 4 | 5 | from copy import deepcopy 6 | from typing import List, Dict, Union, Optional, Mapping, Any 7 | from dash.dependencies import Input, Output, State 8 | from dash.exceptions import PreventUpdate 9 | from webviz_ert.controllers.controller_functions import response_options 10 | from webviz_ert.plugins import WebvizErtPluginABC 11 | from webviz_ert.models import ( 12 | ResponsePlotModel, 13 | BoxPlotModel, 14 | Response, 15 | MultiHistogramPlotModel, 16 | load_ensemble, 17 | ) 18 | from webviz_ert import assets 19 | 20 | 21 | def _get_univariate_misfits_boxplots( 22 | misfits_df: Optional[pd.DataFrame], ensemble_name: str, color: str 23 | ) -> List[BoxPlotModel]: 24 | if misfits_df is None: 25 | return [] 26 | x_axis = misfits_df.columns 27 | realization_names = [f"Realization {name}" for name in misfits_df.index.values] 28 | misfit_plots = list() 29 | for misfits in misfits_df: 30 | plot = BoxPlotModel( 31 | y_axis=misfits_df[misfits].abs().values, 32 | name=misfits, 33 | ensemble_name=ensemble_name, 34 | color=color, 35 | customdata=realization_names, 36 | hovertemplate="(%{x}, %{y}) - %{customdata}", 37 | ) 38 | misfit_plots.append(plot) 39 | return misfit_plots 40 | 41 | 42 | def _create_misfits_plot( 43 | response: Response, selected_realizations: List[int], color: str, ensemble_name: str 44 | ) -> ResponsePlotModel: 45 | realizations = _get_univariate_misfits_boxplots( 46 | response.univariate_misfits_df(selected_realizations), 47 | ensemble_name=ensemble_name, 48 | color=color, 49 | ) 50 | ensemble_plot = ResponsePlotModel( 51 | realizations, 52 | [], 53 | dict( 54 | hovermode="closest", 55 | uirevision=True, 56 | ), 57 | ) 58 | return ensemble_plot 59 | 60 | 61 | def observation_response_controller(parent: WebvizErtPluginABC, app: dash.Dash) -> None: 62 | @app.callback( 63 | [ 64 | Output(parent.uuid("response-selector"), "options"), 65 | Output(parent.uuid("response-selector"), "value"), 66 | Output(parent.uuid("response-selector-store"), "data"), 67 | ], 68 | [ 69 | Input(parent.uuid("selected-ensemble-dropdown"), "value"), 70 | Input(parent.uuid("response-selector"), "value"), 71 | ], 72 | [ 73 | State(parent.uuid("response-selector-store"), "data"), 74 | ], 75 | ) 76 | def set_response_callback( 77 | selected_ensembles: List[str], 78 | selected_resp: Optional[str], 79 | selected_resp_store: Optional[str], 80 | ) -> List[Any]: 81 | ctx = dash.callback_context 82 | triggered_id = ctx.triggered[0]["prop_id"].split(".")[0] 83 | if not triggered_id: 84 | raise PreventUpdate 85 | 86 | if triggered_id == parent.uuid("response-selector"): 87 | selected_resp_store = selected_resp 88 | 89 | ensembles = [ 90 | load_ensemble(parent, ensemble_id) for ensemble_id in selected_ensembles 91 | ] 92 | responses = response_options(response_filters=["obs"], ensembles=ensembles) 93 | options = [{"label": name, "value": name} for name in sorted(responses)] 94 | 95 | if options: 96 | selected_resp = selected_resp_store 97 | else: 98 | selected_resp = None 99 | selected_resp_store = None 100 | 101 | parent.save_state("response", selected_resp_store) 102 | 103 | return [options, selected_resp, selected_resp_store] 104 | 105 | @app.callback( 106 | Output( 107 | {"id": parent.uuid("response-graphic"), "type": parent.uuid("graph")}, 108 | "figure", 109 | ), 110 | [ 111 | Input(parent.uuid("response-selector"), "value"), 112 | Input(parent.uuid("yaxis-type"), "value"), 113 | Input(parent.uuid("misfits-type"), "value"), 114 | ], 115 | [State(parent.uuid("selected-ensemble-dropdown"), "value")], 116 | ) 117 | def update_graph( 118 | response: Optional[str], 119 | yaxis_type: List[str], 120 | misfits_type: str, 121 | selected_ensembles: List[str], 122 | ) -> go.Figure: 123 | if not response or response == "" or not selected_ensembles: 124 | return go.Figure() 125 | 126 | if misfits_type == "Summary": 127 | data_dict = {} 128 | colors = {} 129 | for index, ensemble_id in enumerate(selected_ensembles): 130 | ensemble = load_ensemble(parent, ensemble_id) 131 | summary_df = ensemble.responses[response].summary_misfits_df( 132 | selection=None 133 | ) # What about selections? 134 | if summary_df is not None: 135 | data_dict[ensemble.name] = summary_df.transpose() 136 | colors[ensemble.name] = assets.get_color(index=index) 137 | if data_dict: 138 | plot = MultiHistogramPlotModel( 139 | data_dict, 140 | names={name: name for name in data_dict}, 141 | colors=colors, 142 | hist=True, 143 | kde=False, 144 | ) 145 | return plot.repr 146 | 147 | def _generate_plot(ensemble_id: str, color: str) -> ResponsePlotModel: 148 | ensemble = load_ensemble(parent, ensemble_id) 149 | resp = ensemble.responses[str(response)] 150 | plot = _create_misfits_plot(resp, [], color, ensemble.name) 151 | return plot 152 | 153 | response_plots = [ 154 | _generate_plot(ensemble_id, assets.get_color(index=index)) 155 | for index, ensemble_id in enumerate(selected_ensembles) 156 | ] 157 | 158 | fig = go.Figure() 159 | for plt in response_plots: 160 | for trace in plt.repr.data: 161 | fig.add_trace(trace) 162 | fig.update_yaxes(type=yaxis_type) 163 | return fig 164 | -------------------------------------------------------------------------------- /webviz_ert/controllers/ensemble_selector_controller.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Any 2 | from webviz_ert.plugins import WebvizErtPluginABC 3 | import dash 4 | from dash.dependencies import Input, Output, State 5 | from webviz_ert.data_loader import get_ensembles, refresh_data 6 | from webviz_ert.models import load_ensemble 7 | 8 | 9 | def _get_non_selected_options(store: Dict[str, List]) -> List[Dict[str, str]]: 10 | _options = [] 11 | for option in store["options"]: 12 | if option not in store["selected"]: 13 | _options.append(option) 14 | return _options 15 | 16 | 17 | def _setup_ensemble_selection_store(parent: WebvizErtPluginABC) -> Dict[str, List]: 18 | stored_selection = parent.load_state(key="ensembles", default=[]) 19 | ensemble_selection_store: Dict[str, List] = {"options": [], "selected": []} 20 | 21 | if not parent.get_ensembles(): 22 | ensemble_dict = get_ensembles(project_id=parent.project_identifier) 23 | for ensemble_schema in ensemble_dict: 24 | ensemble_id = ensemble_schema["id"] 25 | load_ensemble(parent, ensemble_id) 26 | 27 | for ens_id, ensemble in parent.get_ensembles().items(): 28 | element = {"label": ensemble.name, "value": ensemble.id} 29 | ensemble_selection_store["options"].append(element) 30 | if ensemble.name in stored_selection: 31 | ensemble_selection_store["selected"].append(element) 32 | 33 | return ensemble_selection_store 34 | 35 | 36 | def ensemble_list_selector_controller( 37 | parent: WebvizErtPluginABC, app: dash.Dash 38 | ) -> None: 39 | @app.callback( 40 | [ 41 | Output(parent.uuid("selected-ensemble-dropdown"), "options"), 42 | Output(parent.uuid("selected-ensemble-dropdown"), "value"), 43 | Output(parent.uuid("ensemble-multi-selector"), "options"), 44 | Output(parent.uuid("ensemble-multi-selector"), "value"), 45 | Output(parent.uuid("ensemble-selection-store"), "data"), 46 | ], 47 | [ 48 | Input(parent.uuid("ensemble-multi-selector"), "value"), 49 | Input(parent.uuid("selected-ensemble-dropdown"), "value"), 50 | Input(parent.uuid(f"ensemble-refresh-button"), "n_clicks"), 51 | ], 52 | [ 53 | State(parent.uuid("selected-ensemble-dropdown"), "options"), 54 | State(parent.uuid("selected-ensemble-dropdown"), "value"), 55 | State(parent.uuid("ensemble-multi-selector"), "options"), 56 | State(parent.uuid("ensemble-selection-store"), "data"), 57 | ], 58 | ) 59 | def set_callback( 60 | _input_ensemble_selector: List[str], 61 | _: List[str], 62 | _btn_click: int, 63 | selected_ens_options: List[Dict], 64 | selected_ens_value: List[str], 65 | ens_selector_options: List[Dict[str, str]], 66 | ensemble_selection_store: Dict[str, List], 67 | ) -> List[Any]: 68 | ctx = dash.callback_context 69 | triggered_id = ctx.triggered[0]["prop_id"].split(".")[0] 70 | if not triggered_id and not ensemble_selection_store: 71 | ensemble_selection_store = _setup_ensemble_selection_store(parent) 72 | 73 | if triggered_id == parent.uuid("ensemble-multi-selector"): 74 | selected_list_elem = _input_ensemble_selector[0] 75 | element = next( 76 | op for op in ens_selector_options if op["value"] == selected_list_elem 77 | ) 78 | ensemble_selection_store["selected"].append(element) 79 | parent.save_state( 80 | "ensembles", 81 | [el["label"] for el in ensemble_selection_store["selected"]], 82 | ) 83 | 84 | if triggered_id == parent.uuid("selected-ensemble-dropdown"): 85 | for element in [ 86 | op 87 | for op in selected_ens_options 88 | if op["value"] not in selected_ens_value 89 | ]: 90 | ensemble_selection_store["selected"].remove(element) 91 | parent.save_state( 92 | "ensembles", 93 | [el["label"] for el in ensemble_selection_store["selected"]], 94 | ) 95 | 96 | if triggered_id == parent.uuid(f"ensemble-refresh-button"): 97 | parent.clear_ensembles() 98 | refresh_data(project_id=parent.project_identifier) 99 | parent.save_state("ensembles", []) 100 | ensemble_selection_store = _setup_ensemble_selection_store(parent) 101 | 102 | ens_selector_options = _get_non_selected_options(ensemble_selection_store) 103 | selected_ens_options = ensemble_selection_store["selected"] 104 | selected_ens_value = [option["value"] for option in selected_ens_options] 105 | 106 | return [ 107 | selected_ens_options, 108 | selected_ens_value, 109 | ens_selector_options, 110 | [], 111 | ensemble_selection_store, 112 | ] 113 | 114 | container_ensemble_selector_multi_id = parent.uuid( 115 | "container-ensemble-selector-multi" 116 | ) 117 | parameter_selector_button_id = parent.uuid(f"parameter-selector-button") 118 | 119 | @app.callback( 120 | [ 121 | Output(container_ensemble_selector_multi_id, "className"), 122 | Output(parameter_selector_button_id, "children"), 123 | ], 124 | [ 125 | Input(parameter_selector_button_id, "n_clicks"), 126 | Input(parameter_selector_button_id, "children"), 127 | ], 128 | [ 129 | State(container_ensemble_selector_multi_id, "className"), 130 | ], 131 | ) 132 | def toggle_selector_visibility( 133 | _: int, button_text: str, class_name: str 134 | ) -> List[str]: 135 | ctx = dash.callback_context 136 | triggered_id = ctx.triggered[0]["prop_id"].split(".")[0] 137 | if triggered_id == parameter_selector_button_id: 138 | if class_name == "ert-ensemble-selector-container-hide": 139 | class_name = "ert-ensemble-selector-container-show" 140 | button_text = "Hide Selectors" 141 | else: 142 | class_name = "ert-ensemble-selector-container-hide" 143 | button_text = "Show Selectors" 144 | return [class_name, button_text] 145 | -------------------------------------------------------------------------------- /webviz_ert/controllers/parameter_selector_controller.py: -------------------------------------------------------------------------------- 1 | import dash 2 | 3 | from typing import List, Any, Tuple, Dict, Optional 4 | from dash.exceptions import PreventUpdate 5 | from dash.dependencies import Input, Output, State 6 | 7 | from webviz_ert.plugins import WebvizErtPluginABC 8 | from webviz_ert.models import ( 9 | load_ensemble, 10 | ) 11 | from webviz_ert.controllers import parameter_options, response_options 12 | from webviz_ert.models.data_model import DataType 13 | 14 | 15 | def _filter_match(_filter: str, key: str) -> bool: 16 | return _filter.lower() in key.lower() 17 | 18 | 19 | def parameter_selector_controller( 20 | parent: WebvizErtPluginABC, 21 | app: dash.Dash, 22 | data_type: DataType, 23 | union_keys: bool = True, 24 | extra_input: bool = False, 25 | ) -> None: 26 | parameter_selector_multi_id = parent.uuid(f"parameter-selector-multi-{data_type}") 27 | parameter_selector_filter_id = parent.uuid(f"parameter-selector-filter-{data_type}") 28 | parameter_deactivator_id = parent.uuid(f"parameter-deactivator-{data_type}") 29 | parameter_selection_store_id = parent.uuid(f"parameter-selection-store-{data_type}") 30 | options_inputs = [ 31 | Input(parent.uuid("selected-ensemble-dropdown"), "value"), 32 | Input(parameter_selector_filter_id, "value"), 33 | Input(parameter_deactivator_id, "value"), 34 | ] 35 | if extra_input: 36 | options_inputs.extend( 37 | [Input(parent.uuid("response-observations-check"), "value")] 38 | ) 39 | 40 | @app.callback( 41 | [ 42 | Output(parameter_selector_multi_id, "options"), 43 | Output(parameter_selector_multi_id, "value"), 44 | ], 45 | options_inputs, 46 | ) 47 | def update_parameters_options( 48 | selected_ensembles: List[str], 49 | filter_search: str, 50 | selected: Optional[List[str]], 51 | *args: List[str], 52 | ) -> Tuple[List[Dict], Optional[List[str]]]: 53 | if not selected_ensembles: 54 | # Reset selection list 55 | return [], [] 56 | 57 | response_filter = [] 58 | if extra_input: 59 | response_filter = args[0] 60 | selected_set = set() if not selected else set(selected) 61 | ensembles = [ 62 | load_ensemble(parent, ensemble_id) for ensemble_id in selected_ensembles 63 | ] 64 | if data_type == DataType.PARAMETER: 65 | options = parameter_options(ensembles, union_keys=union_keys) 66 | elif data_type == DataType.RESPONSE: 67 | options = response_options(response_filter, ensembles) 68 | else: 69 | raise ValueError(f"Undefined parameter type {data_type}") 70 | 71 | options = options.difference(selected_set) 72 | if filter_search: 73 | options = {name for name in options if _filter_match(filter_search, name)} 74 | 75 | return [{"label": name, "value": name} for name in sorted(options)], selected 76 | 77 | @app.callback( 78 | Output(parameter_selection_store_id, "data"), 79 | [ 80 | Input(parameter_selector_multi_id, "value"), 81 | Input(parameter_selector_filter_id, "n_submit"), 82 | Input(parent.uuid("selected-ensemble-dropdown"), "value"), 83 | ], 84 | State(parameter_deactivator_id, "value"), 85 | ) 86 | def update_parameter_selection( 87 | parameters: List[str], 88 | _: int, 89 | selected_ensembles: List[str], 90 | selected_params: Optional[List[str]], 91 | ) -> Optional[List[str]]: 92 | stored_parameters = None 93 | ctx = dash.callback_context 94 | triggered_id = ctx.triggered[0]["prop_id"].split(".")[0] 95 | if triggered_id == parameter_selector_filter_id: 96 | # Prevent selecting everything from the search result on enter 97 | raise PreventUpdate 98 | elif triggered_id == parent.uuid("selected-ensemble-dropdown"): 99 | if selected_ensembles is None or selected_ensembles == []: 100 | stored_parameters = [] 101 | else: 102 | raise PreventUpdate 103 | elif triggered_id == parameter_selector_multi_id: 104 | selected_params = [] if not selected_params else selected_params 105 | parameters = ( 106 | [] 107 | if not parameters 108 | else [ 109 | parameter 110 | for parameter in parameters 111 | if parameter not in selected_params 112 | ] 113 | ) 114 | stored_parameters = selected_params + parameters 115 | 116 | parent.save_state(f"{data_type}", stored_parameters) 117 | 118 | return stored_parameters 119 | 120 | @app.callback( 121 | [ 122 | Output(parameter_deactivator_id, "options"), 123 | Output(parameter_deactivator_id, "value"), 124 | ], 125 | [ 126 | Input(parameter_selection_store_id, "modified_timestamp"), 127 | ], 128 | [ 129 | State(parameter_selection_store_id, "data"), 130 | ], 131 | ) 132 | def update_parameter_options( 133 | _: Any, shown_parameters: Optional[List[str]] 134 | ) -> Tuple[List[Dict], List[str]]: 135 | shown_parameters = [] if not shown_parameters else shown_parameters 136 | selected_opts = [{"label": param, "value": param} for param in shown_parameters] 137 | return selected_opts, shown_parameters 138 | 139 | container_parameter_selector_multi_id = parent.uuid( 140 | f"container-parameter-selector-multi-{data_type}" 141 | ) 142 | parameter_selector_button_id = parent.uuid(f"parameter-selector-button") 143 | 144 | @app.callback( 145 | Output(container_parameter_selector_multi_id, "className"), 146 | [ 147 | Input(parameter_selector_button_id, "n_clicks"), 148 | ], 149 | [ 150 | State(container_parameter_selector_multi_id, "className"), 151 | ], 152 | ) 153 | def toggle_selector_visibility(_: int, class_name: str) -> str: 154 | ctx = dash.callback_context 155 | triggered_id = ctx.triggered[0]["prop_id"].split(".")[0] 156 | if triggered_id == parameter_selector_button_id: 157 | if class_name == "ert-parameter-selector-container-hide": 158 | class_name = "ert-parameter-selector-container-show" 159 | else: 160 | class_name = "ert-parameter-selector-container-hide" 161 | return class_name 162 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from requests import HTTPError 3 | import dash 4 | from selenium.webdriver.chrome.options import Options 5 | from selenium.common.exceptions import TimeoutException 6 | 7 | from tests.data.snake_oil_data import ensembles_response 8 | 9 | 10 | def pytest_addoption(parser): 11 | parser.addoption( 12 | "--skip-browser-tests", 13 | action="store_true", 14 | default=False, 15 | help="This option allows skipping tests that depend on chromedriver", 16 | ) 17 | 18 | 19 | def pytest_configure(config): 20 | config.addinivalue_line( 21 | "markers", "browser_test: mark test as chromedriver dependent" 22 | ) 23 | 24 | 25 | def pytest_collection_modifyitems(config, items): 26 | skip_browser_tests = pytest.mark.skip( 27 | reason="chromedriver missing in PATH or intentionally skipped" 28 | ) 29 | browser_tests = [item for item in items if "browser_test" in item.keywords] 30 | if config.getoption("--skip-browser-tests"): 31 | for item in browser_tests: 32 | item.add_marker(skip_browser_tests) 33 | 34 | 35 | def pytest_setup_options(): 36 | options = Options() 37 | options.add_argument("--headless") 38 | options.add_argument("--no-sandbox") 39 | options.add_argument("--disable-dev-shm-usage") 40 | options.add_argument("--disable-setuid-sandbox") 41 | return options 42 | 43 | 44 | @pytest.fixture 45 | def mock_data(mocker): 46 | mocker.patch( 47 | "webviz_ert.data_loader.get_connection_info", 48 | side_effect=lambda _: {"baseurl": "http://127.0.0.1:5000", "auth": ""}, 49 | ) 50 | 51 | mocker.patch("webviz_ert.data_loader._requests_get", side_effect=_requests_get) 52 | mocker.patch("webviz_ert.data_loader._requests_post", side_effect=_requests_post) 53 | 54 | 55 | class _MockResponse: 56 | def __init__(self, url, data, status_code): 57 | self.url = url 58 | self.data = data 59 | self.status_code = status_code 60 | 61 | def json(self): 62 | return self.data 63 | 64 | @property 65 | def text(self): 66 | return self.data 67 | 68 | @property 69 | def raw(self): 70 | return self.data 71 | 72 | @property 73 | def content(self): 74 | return self.data 75 | 76 | def raise_for_status(self): 77 | if self.status_code == 400: 78 | raise HTTPError( 79 | "Mocked requests raised HTTPError 400 due to missing data in " 80 | "test-data set!\n" 81 | f"{self.url}" 82 | ) 83 | 84 | 85 | def _requests_get(url, **kwargs): 86 | if kwargs.get("params") is not None: 87 | url += "?" 88 | for param, value in kwargs["params"].items(): 89 | url += f"{param}={value}" 90 | if url in ensembles_response: 91 | return _MockResponse(url, ensembles_response[url], 200) 92 | return _MockResponse(url, {}, 400) 93 | 94 | 95 | def _requests_post(url, **kwargs): 96 | if url in ensembles_response: 97 | return _MockResponse(url, ensembles_response[url], 200) 98 | return _MockResponse(url, {}, 400) 99 | 100 | 101 | def select_first(dash_duo, selector): 102 | options = dash_duo.find_elements(selector + " option") 103 | if not options: 104 | raise AssertionError(f"No selection option for selector {selector}") 105 | text = options[0].text 106 | options[0].click() 107 | # TODO remove wait? 108 | wait_a_bit(dash_duo, time_seconds=0.5) 109 | return text 110 | 111 | 112 | def select_by_name(dash_duo, selector, name): 113 | dash_duo.wait_for_contains_text(selector, name) 114 | options = dash_duo.find_elements(selector + " option") 115 | if not options: 116 | raise AssertionError(f"No `option`s under selector {selector}") 117 | for option in options: 118 | if option.text == name: 119 | option.click() 120 | # TODO remove wait? 121 | wait_a_bit(dash_duo, time_seconds=0.5) 122 | return name 123 | raise AssertionError(f"Option {name} not available in {selector}") 124 | 125 | 126 | def get_options(dash_duo, selector): 127 | parameter_selector_input = dash_duo.find_element(selector) 128 | return parameter_selector_input.text.split("\n") 129 | 130 | 131 | def setup_plugin( 132 | dash_duo, 133 | name, 134 | plugin_class, 135 | window_size=(630, 2000), 136 | project_identifier=None, 137 | beta: bool = False, 138 | ): 139 | app = dash.Dash(name) 140 | plugin = plugin_class(app, project_identifier=project_identifier, beta=beta) 141 | app.layout = plugin.layout 142 | dash_duo.start_server(app) 143 | windowsize = window_size 144 | dash_duo.driver.set_window_size(*windowsize) 145 | return plugin 146 | 147 | 148 | def select_ensemble(dash_duo, plugin, wanted_ensemble_name=None): 149 | """tries to select, i.e. click, the ensemble given by ensemble_name, and 150 | clicks the first one if no name is given. It returns the name of the 151 | selected ensemble.""" 152 | ensemble_selector_id = plugin.uuid("ensemble-multi-selector") 153 | if not wanted_ensemble_name: 154 | first_ensemble_name = select_first(dash_duo, "#" + ensemble_selector_id) 155 | dash_duo.wait_for_contains_text( 156 | "#" + plugin.uuid("selected-ensemble-dropdown"), 157 | first_ensemble_name, 158 | timeout=4, 159 | ) 160 | return first_ensemble_name 161 | 162 | options_selector_prefix = f"#{ensemble_selector_id}" 163 | select_by_name(dash_duo, options_selector_prefix, wanted_ensemble_name) 164 | dash_duo.wait_for_contains_text( 165 | "#" + plugin.uuid("selected-ensemble-dropdown"), 166 | wanted_ensemble_name, 167 | timeout=4, 168 | ) 169 | return wanted_ensemble_name 170 | 171 | 172 | def select_response(dash_duo, plugin, response_name=None, wait_for_plot=True) -> str: 173 | response_selector_id = f'#{plugin.uuid("parameter-selector-multi-resp")}' 174 | if response_name is None: 175 | response_name = select_first(dash_duo, response_selector_id) 176 | else: 177 | select_by_name( 178 | dash_duo, 179 | response_selector_id, 180 | response_name, 181 | ) 182 | if wait_for_plot: 183 | dash_duo.wait_for_element(f'[id="{plugin.uuid(response_name)}"]') 184 | return response_name 185 | 186 | 187 | def select_parameter(dash_duo, plugin, parameter_name=None, wait_for_plot=True) -> str: 188 | parameter_selector_id = f'#{plugin.uuid("parameter-selector-multi-param")}' 189 | if parameter_name is None: 190 | parameter_name = select_first(dash_duo, parameter_selector_id) 191 | else: 192 | select_by_name( 193 | dash_duo, 194 | parameter_selector_id, 195 | parameter_name, 196 | ) 197 | if wait_for_plot: 198 | dash_duo.wait_for_element(f'[id="{plugin.uuid(parameter_name)}"]') 199 | return parameter_name 200 | 201 | 202 | def wait_a_bit(dash_duo, time_seconds=0.1): 203 | try: 204 | dash_duo.wait_for_element(".foo-elderberries-baarrrrr", timeout=time_seconds) 205 | except TimeoutException: 206 | pass 207 | 208 | 209 | def verify_key_in_dropdown(dash_duo, selector, key): 210 | verify_keys_in_dropdown(dash_duo, selector, [key]) 211 | 212 | 213 | def verify_keys_in_dropdown(dash_duo, selector, keys): 214 | dropdown = dash_duo.find_element(f"#{selector}") 215 | dropdown.click() 216 | for key in keys: 217 | dash_duo.wait_for_contains_text(f"#{selector} div.Select-menu-outer", key) 218 | -------------------------------------------------------------------------------- /webviz_ert/controllers/multi_response_controller.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import dash 3 | import pandas as pd 4 | import datetime 5 | import plotly.graph_objects as go 6 | import webviz_ert.assets as assets 7 | 8 | from copy import deepcopy 9 | from typing import List, Dict, Union, Any, Optional, Type, Tuple, TYPE_CHECKING 10 | from dash.dependencies import Input, Output, State, ALL, MATCH 11 | from dash.exceptions import PreventUpdate 12 | from webviz_ert.plugins import WebvizErtPluginABC 13 | from webviz_ert.models import ( 14 | ResponsePlotModel, 15 | Response, 16 | PlotModel, 17 | load_ensemble, 18 | AxisType, 19 | ) 20 | 21 | if TYPE_CHECKING: 22 | from .ensemble_model import EnsembleModel 23 | 24 | logger = logging.getLogger() 25 | 26 | 27 | def _get_realizations_plots( 28 | realizations_df: pd.DataFrame, 29 | x_axis: Optional[List[Union[int, str, datetime.datetime]]], 30 | color: str, 31 | style: Optional[Dict] = None, 32 | ensemble_name: str = "", 33 | ) -> List[PlotModel]: 34 | if style: 35 | _style = style 36 | else: 37 | _style = assets.ERTSTYLE["response-plot"]["response"].copy() 38 | _style.update({"line": {"color": color}}) 39 | _style.update({"marker": {"color": color}}) 40 | realizations_data = list() 41 | for idx, realization in enumerate(realizations_df): 42 | plot = PlotModel( 43 | x_axis=x_axis, 44 | y_axis=realizations_df[realization].values, 45 | text=f"Realization: {realization} Ensemble: {ensemble_name}", 46 | name=ensemble_name, 47 | legendgroup=ensemble_name, 48 | showlegend=False if idx > 0 else True, 49 | **_style, 50 | ) 51 | realizations_data.append(plot) 52 | return realizations_data 53 | 54 | 55 | def _get_realizations_statistics_plots( 56 | df_response: pd.DataFrame, 57 | x_axis: Optional[List[Union[int, str, datetime.datetime]]], 58 | color: str, 59 | ensemble_name: str = "", 60 | ) -> List[PlotModel]: 61 | data = df_response 62 | p10 = data.quantile(0.1, axis=1) 63 | p90 = data.quantile(0.9, axis=1) 64 | _mean = data.mean(axis=1) 65 | style = deepcopy(assets.ERTSTYLE["response-plot"]["statistics"]) 66 | style["line"]["color"] = color 67 | style_mean = deepcopy(style) 68 | style_mean["line"]["dash"] = "solid" 69 | mean_data = PlotModel( 70 | x_axis=x_axis, 71 | y_axis=_mean, 72 | text=f"Mean", 73 | name=f"Mean {ensemble_name}", 74 | **style_mean, 75 | ) 76 | lower_std_data = PlotModel( 77 | x_axis=x_axis, y_axis=p10, text="p10 quantile", name="p10 quantile", **style 78 | ) 79 | upper_std_data = PlotModel( 80 | x_axis=x_axis, y_axis=p90, text="p90 quantile", name="p90 quantile", **style 81 | ) 82 | return [mean_data, lower_std_data, upper_std_data] 83 | 84 | 85 | def _get_observation_plots( 86 | observation_df: pd.DataFrame, 87 | metadata: Optional[List[str]] = None, 88 | ensemble: str = "", 89 | ) -> PlotModel: 90 | data = observation_df["values"] 91 | stds = observation_df["std"] 92 | x_axis = observation_df["x_axis"] 93 | attributes = observation_df["attributes"] 94 | active_mask = observation_df["active"] 95 | 96 | style = deepcopy(assets.ERTSTYLE["response-plot"]["observation"]) 97 | color = [style["color"] if active else "rgb(0, 0, 0)" for active in active_mask] 98 | style["marker"]["color"] = color 99 | 100 | observation_data = PlotModel( 101 | x_axis=x_axis, 102 | y_axis=data, 103 | text=attributes, 104 | name=f"Observation_{ensemble}", 105 | error_y=dict( 106 | type="data", # value of error bar given in data coordinates 107 | array=stds.values, 108 | visible=True, 109 | ), 110 | meta=metadata, 111 | **style, 112 | ) 113 | return observation_data 114 | 115 | 116 | def _create_response_plot( 117 | response: Response, 118 | plot_type: str, 119 | selected_realizations: List[int], 120 | color: str, 121 | style: Optional[Dict] = None, 122 | ensemble_name: str = "", 123 | ) -> ResponsePlotModel: 124 | x_axis = response.axis 125 | if plot_type == "Statistics": 126 | realizations = _get_realizations_statistics_plots( 127 | response.data_df(selected_realizations), 128 | x_axis, 129 | color=color, 130 | ensemble_name=ensemble_name, 131 | ) 132 | else: 133 | realizations = _get_realizations_plots( 134 | response.data_df(selected_realizations), 135 | x_axis, 136 | color=color, 137 | style=style, 138 | ensemble_name=ensemble_name, 139 | ) 140 | if response.observations: 141 | observations = [ 142 | _get_observation_plots(obs.data_df(), ensemble=ensemble_name) 143 | for obs in response.observations 144 | ] 145 | else: 146 | observations = [] 147 | 148 | ensemble_plot = ResponsePlotModel(realizations, observations, dict()) 149 | return ensemble_plot 150 | 151 | 152 | def multi_response_controller(parent: WebvizErtPluginABC, app: dash.Dash) -> None: 153 | @app.callback( 154 | Output( 155 | { 156 | "index": MATCH, 157 | "id": parent.uuid("response-graphic"), 158 | "type": parent.uuid("graph"), 159 | }, 160 | "figure", 161 | ), 162 | [ 163 | Input({"index": MATCH, "type": parent.uuid("plot-type")}, "value"), 164 | Input(parent.uuid("ensemble-selection-store"), "modified_timestamp"), 165 | ], 166 | [ 167 | State(parent.uuid("selected-ensemble-dropdown"), "value"), 168 | State({"index": MATCH, "type": parent.uuid("response-id-store")}, "data"), 169 | ], 170 | ) 171 | def update_graph( 172 | plot_type: str, 173 | _: Any, 174 | selected_ensembles: List[str], 175 | response: Optional[str], 176 | ) -> go.Figure: 177 | if not response or not selected_ensembles: 178 | raise PreventUpdate 179 | 180 | def _generate_plot( 181 | ensemble: "EnsembleModel", color: str 182 | ) -> Optional[ResponsePlotModel]: 183 | if response not in ensemble.responses: 184 | return None 185 | plot = _create_response_plot( 186 | ensemble.responses[response], 187 | plot_type, 188 | [], 189 | color, 190 | ensemble_name=ensemble.name, 191 | ) 192 | return plot 193 | 194 | loaded_ensembles = [ 195 | load_ensemble(parent, ensemble_id) for ensemble_id in selected_ensembles 196 | ] 197 | 198 | response_plots = [ 199 | _generate_plot(ensemble, assets.get_color(index=index)) 200 | for index, ensemble in enumerate(loaded_ensembles) 201 | ] 202 | 203 | x_axis_label = axis_label_for_ensemble_response(loaded_ensembles[0], response) 204 | 205 | fig = go.Figure() 206 | for plot in filter(None, response_plots): 207 | for realization in plot._realization_plots: 208 | fig.add_trace(realization.repr) 209 | for plot in filter(None, response_plots): 210 | for observation in plot._observations: 211 | fig.add_trace(observation.repr) 212 | fig.update_layout(assets.ERTSTYLE["figure"]["layout"]) 213 | fig.update_layout({"xaxis": {"title": {"text": x_axis_label}}}) 214 | fig.update_layout(assets.ERTSTYLE["figure"]["layout-value-y-axis-label"]) 215 | return fig 216 | 217 | 218 | def axis_label_for_ensemble_response( 219 | ensemble: "EnsembleModel", response_name: str 220 | ) -> str: 221 | response: Response = ensemble.responses[response_name] 222 | if response.axis_type == AxisType.TIMESTAMP: 223 | return "Date" 224 | return "Index" 225 | -------------------------------------------------------------------------------- /tests/plots/test_controller.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import numpy as np 3 | from webviz_ert.controllers.multi_response_controller import ( 4 | _get_observation_plots, 5 | _get_realizations_plots, 6 | _get_realizations_statistics_plots, 7 | ) 8 | from webviz_ert.controllers.observation_response_controller import ( 9 | _get_univariate_misfits_boxplots, 10 | ) 11 | 12 | from webviz_ert.data_loader import get_ensembles 13 | from webviz_ert.models import EnsembleModel, PriorModel 14 | from webviz_ert.models import ( 15 | HistogramPlotModel, 16 | MultiHistogramPlotModel, 17 | BoxPlotModel, 18 | ParallelCoordinatesPlotModel, 19 | BarChartPlotModel, 20 | ) 21 | import webviz_ert.assets as assets 22 | 23 | 24 | def test_observation_plot_representation(): 25 | observation_df = pd.DataFrame( 26 | data={ 27 | "values": [2.85325093, 7.20311703, 21.38648991, 31.51455593, 53.56766604], 28 | "std": [0.1, 1.1, 4.1, 9.1, 16.1], 29 | "x_axis": [0, 2, 4, 6, 8], 30 | "attributes": "Key1 Value1
Key2 Value2
", 31 | "active": [True, False, True, False, False], 32 | } 33 | ) 34 | 35 | plot = _get_observation_plots(observation_df) 36 | 37 | assert "mode" in plot.repr 38 | 39 | np.testing.assert_equal(observation_df["x_axis"].values, plot.repr.x) 40 | assert len(plot.repr.y) == len(observation_df) 41 | 42 | np.testing.assert_equal(plot.repr.y, observation_df["values"].values) 43 | np.testing.assert_equal(plot.repr.error_y.array, observation_df["std"].values) 44 | np.testing.assert_equal(plot.repr.text, observation_df["attributes"].values) 45 | np.testing.assert_equal( 46 | plot.repr.marker.color, 47 | ( 48 | "rgb(176, 28, 52)", 49 | "rgb(0, 0, 0)", 50 | "rgb(176, 28, 52)", 51 | "rgb(0, 0, 0)", 52 | "rgb(0, 0, 0)", 53 | ), 54 | ) 55 | 56 | 57 | def test_realizations_plot_representation(): 58 | data = np.random.rand(200).reshape(-1, 20) 59 | realization_df = pd.DataFrame(data=data, index=range(10), columns=range(20)) 60 | x_axis = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] 61 | plots = _get_realizations_plots( 62 | realization_df, x_axis, assets.ERTSTYLE["ensemble-selector"]["color_wheel"][0] 63 | ) 64 | assert len(plots) == 20 65 | for idx, plot in enumerate(plots): 66 | np.testing.assert_equal(x_axis, plot.repr.x) 67 | np.testing.assert_equal(plot.repr.y, realization_df[idx].values) 68 | 69 | 70 | def test_realizations_statistics_plot_representation(): 71 | data = np.random.rand(200).reshape(-1, 20) 72 | realization_df = pd.DataFrame(data=data, index=range(10), columns=range(20)) 73 | x_axis = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] 74 | plots = _get_realizations_statistics_plots( 75 | realization_df, x_axis, assets.ERTSTYLE["ensemble-selector"]["color_wheel"][0] 76 | ) 77 | 78 | assert len(plots) == 3 79 | assert "mode" in plots[0].repr 80 | for plot in plots: 81 | np.testing.assert_equal(x_axis, plot.repr.x) 82 | 83 | np.testing.assert_equal(plots[0].repr.y, np.mean(data, axis=1)) 84 | np.testing.assert_equal(plots[1].repr.y, np.quantile(data, 0.1, axis=1)) 85 | np.testing.assert_equal(plots[2].repr.y, np.quantile(data, 0.9, axis=1)) 86 | 87 | 88 | def test_histogram_plot_representation(): 89 | data = np.random.rand(20).reshape(-1, 20) 90 | data_df = pd.DataFrame(data=data, index=range(1), columns=range(20)) 91 | data_df.index.name = "key_name" 92 | 93 | plot = HistogramPlotModel(data_df, hist=True, kde=False) 94 | plot.selection = range(5) 95 | plot = plot.repr 96 | np.testing.assert_equal(plot.data[0].x, data.flatten()[:5]) 97 | assert plot.data[0].histnorm == "probability density" 98 | assert plot.data[0].autobinx == False 99 | 100 | 101 | def test_multi_histogram_plot_representation(): 102 | data_dict = {} 103 | name_dict = {} 104 | colors_dict = {} 105 | 106 | ensemble_names = ["default", "update_1", "update_2"] 107 | colors = assets.ERTSTYLE["ensemble-selector"]["color_wheel"] 108 | for ensemble_date, (ensemble_name, color) in enumerate( 109 | zip(ensemble_names, colors[: len(ensemble_names)]) 110 | ): 111 | key = f"{ensemble_date}, {ensemble_name}" 112 | data = np.random.rand(20).reshape(-1, 20) 113 | data_df = pd.DataFrame(data=data, index=range(1), columns=range(20)) 114 | data_df.index.name = "KEY_NAME" 115 | data_dict[key] = data_df 116 | name_dict[key] = f"{ensemble_name}" 117 | colors_dict[key] = color 118 | priors = { 119 | (0, "default"): (PriorModel("uniform", ["std", "mean"], [0, 1]), colors[0]) 120 | } 121 | 122 | plot = MultiHistogramPlotModel( 123 | data_dict, name_dict, colors_dict, hist=True, kde=False 124 | ) 125 | assert plot.bin_count == 4 126 | 127 | plot = MultiHistogramPlotModel( 128 | data_dict, 129 | name_dict, 130 | colors_dict, 131 | hist=True, 132 | kde=False, 133 | priors=priors, 134 | bin_count=10, 135 | ) 136 | assert plot.bin_count == 10 137 | plot = plot.repr 138 | for idx, ensemble_name in enumerate(ensemble_names): 139 | key = f"{idx}, {ensemble_name}" 140 | np.testing.assert_equal(plot.data[idx].x, data_dict[key].values.flatten()) 141 | assert plot.data[idx].histnorm == "probability density" 142 | assert plot.data[idx].autobinx == False 143 | assert plot.data[idx].marker.color == colors_dict[key] 144 | assert plot.data[idx].name == ensemble_name 145 | 146 | assert plot.data[-1].name == "(0, 'default')-prior" 147 | 148 | 149 | def test_parallel_coordinates_representation(): 150 | data_dict = {} 151 | colors_dict = {} 152 | 153 | ensemble_names = ["default", "update_1", "update_2"] 154 | colors = assets.ERTSTYLE["ensemble-selector"]["color_wheel"] 155 | for idx, (ensemble_name, color) in enumerate( 156 | zip(ensemble_names, colors[: len(ensemble_names)]) 157 | ): 158 | key = f"{idx}, {ensemble_name}" 159 | data = np.random.rand(50).reshape(-1, 5) 160 | data_df = pd.DataFrame(data=data, columns=[f"PARAM_{i}" for i in range(5)]) 161 | data_df["ensemble_id"] = idx 162 | data_dict[key] = data_df 163 | colors_dict[key] = color 164 | 165 | plot = ParallelCoordinatesPlotModel(data_dict, colors_dict) 166 | plot = plot.repr 167 | 168 | assert len(plot.data[0].dimensions) == 5 169 | for idx, ensemble_name in enumerate(ensemble_names): 170 | key = f"{idx}, {ensemble_name}" 171 | assert plot.data[0].dimensions[idx].label == f"PARAM_{idx}" 172 | assert len(plot.data[0].dimensions[idx].values) == 10 * len(ensemble_names) 173 | assert plot.data[0].labelangle == 45 174 | assert len(plot.data[0].line.color) == 10 * len(ensemble_names) 175 | 176 | 177 | def test_univariate_misfits_boxplot_representation(): 178 | data = np.random.rand(200).reshape(-1, 20) 179 | missfits_df = pd.DataFrame(data=data, index=range(10), columns=range(20)) 180 | ensemble_name = "test-ensemble" 181 | plots = _get_univariate_misfits_boxplots( 182 | missfits_df.copy(), ensemble_name=ensemble_name, color="rgb(255,0,0)" 183 | ) 184 | assert len(plots) == 20 185 | for id_plot, plot in enumerate(plots): 186 | np.testing.assert_equal(0.3, plot.repr.jitter) 187 | np.testing.assert_equal("all", plot.repr.boxpoints) 188 | x_pos = missfits_df.columns[id_plot] 189 | if isinstance(x_pos, int): 190 | name = f"Value {x_pos}" 191 | else: 192 | name = f"{x_pos} - {ensemble_name}" 193 | assert name == plot.repr.name 194 | 195 | 196 | def test_boxplot_representation(): 197 | data = np.random.rand(10) 198 | data_df = pd.DataFrame(data=data, index=range(10)) 199 | 200 | plot = BoxPlotModel( 201 | y_axis=data_df.values, 202 | name="Boxplot@Location5", 203 | color=assets.ERTSTYLE["ensemble-selector"]["color_wheel"][0], 204 | ) 205 | plot = plot.repr 206 | np.testing.assert_equal(plot.y.flatten(), data) 207 | assert plot.boxpoints == "all" 208 | assert plot.name == "Boxplot@Location5" 209 | 210 | 211 | def test_barchart_representation(): 212 | data_dict = {} 213 | colors_dict = {} 214 | 215 | ensemble_names = ["default", "update_1", "update_2"] 216 | colors = assets.ERTSTYLE["ensemble-selector"]["color_wheel"] 217 | param_num = 5 218 | for idx, (ensemble_name, color) in enumerate( 219 | zip(ensemble_names, colors[: len(ensemble_names)]) 220 | ): 221 | key = f"{idx}, {ensemble_name}" 222 | data = np.random.rand(param_num) 223 | data_df = pd.DataFrame( 224 | data=data, index=[f"PARAM_{i}" for i in range(param_num)] 225 | ) 226 | data_dict[key] = data_df 227 | colors_dict[key] = color 228 | 229 | plot = BarChartPlotModel(data_dict, colors_dict) 230 | plot = plot.repr 231 | 232 | for idx, ensemble_name in enumerate(ensemble_names): 233 | key = f"{idx}, {ensemble_name}" 234 | assert plot.data[idx].name == key 235 | np.testing.assert_equal( 236 | plot.data[idx].y, [f"PARAM_{i}" for i in range(param_num)] 237 | ) 238 | assert plot.data[idx].orientation == "h" 239 | assert plot.data[idx].x.shape == (param_num, 1) 240 | assert plot.data[idx].marker.color == colors[idx] 241 | -------------------------------------------------------------------------------- /webviz_ert/plugins/_response_correlation.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import dash_daq 3 | import webviz_ert.assets as assets 4 | import webviz_ert.models 5 | import webviz_ert.controllers 6 | import dash_bootstrap_components as dbc 7 | from dash.development.base_component import Component 8 | from typing import List, Dict 9 | from webviz_ert.views import ( 10 | ensemble_selector_list, 11 | correlation_view, 12 | parameter_selector_view, 13 | ) 14 | from webviz_ert.models.data_model import DataType 15 | from webviz_ert.plugins import WebvizErtPluginABC 16 | 17 | 18 | class ResponseCorrelation(WebvizErtPluginABC): 19 | def __init__(self, app: dash.Dash, project_identifier: str, beta: bool = False): 20 | super().__init__(app, project_identifier) 21 | self.set_callbacks(app) 22 | self.beta = beta 23 | 24 | @property 25 | def tour_steps(self) -> List[Dict[str, str]]: 26 | steps = [ 27 | { 28 | "id": self.uuid("obs_index_selector_container"), 29 | "content": ( 30 | "Select preferred observation indexes by drawing a rectangle on the figure" 31 | ), 32 | }, 33 | { 34 | "id": self.uuid("info-text"), 35 | "content": ( 36 | "The currently active response, parameter, and x_index / " 37 | "timestamp" 38 | ), 39 | }, 40 | { 41 | "id": self.uuid("response-overview"), 42 | "content": "Visualization of the currently active response", 43 | }, 44 | { 45 | "id": self.uuid("response-scatterplot"), 46 | "content": ( 47 | "Scatterplot visualization of function values between currently active " 48 | "response and currently active parameter. " 49 | "It is accompanied by distribution plots for both selections. " 50 | ), 51 | }, 52 | { 53 | "id": self.uuid("response-heatmap"), 54 | "content": ( 55 | "Heatmap based representation of correlation (-1, 1) among all selected responses " 56 | "and parameters, for currently selected ensembles in column-wise fashion." 57 | "One can select a currently active response at an observation index and parameter " 58 | "by clicking directly on a heatmap. " 59 | ), 60 | }, 61 | { 62 | "id": self.uuid("response-correlation"), 63 | "content": ( 64 | "Correlation BarChart for the currently active response (from heatmap) " 65 | "and parameters in a descending order. " 66 | ), 67 | }, 68 | { 69 | "id": self.uuid("correlation-metric"), 70 | "content": ( 71 | "Which metric to use, all views are automatically updated. " 72 | ), 73 | }, 74 | { 75 | "id": self.uuid("sort-parameters"), 76 | "content": ( 77 | "Option for toggling sorting of parameters from sorting " 78 | "by correlation to alphabetical; affects heatmap and " 79 | "tornado plots." 80 | ), 81 | }, 82 | { 83 | "id": self.uuid("hide-hover"), 84 | "content": ( 85 | "Option for toggling visibility of hover info text in " "heatmap." 86 | ), 87 | }, 88 | ] 89 | 90 | return steps 91 | 92 | @property 93 | def layout(self) -> Component: 94 | return dash.html.Div( 95 | [ 96 | dash.html.Div( 97 | children=[ 98 | dash.html.P( 99 | [ 100 | "This page is considered a ", 101 | dash.html.B("beta"), 102 | " version and could be changed or removed. You are encouraged to use it and give feedback regarding functionality and / or bugs.", 103 | ], 104 | className="ert-beta-warning", 105 | id=self.uuid("beta-warning"), 106 | ) 107 | ], 108 | hidden=not self.beta, 109 | ), 110 | dash.dcc.Store( 111 | id=self.uuid("correlation-store-xindex"), 112 | data={}, 113 | storage_type="session", 114 | ), 115 | dash.dcc.Store(id=self.uuid("correlation-store-selected-obs"), data={}), 116 | dash.dcc.Store( 117 | id=self.uuid("correlation-store-obs-range"), 118 | data=self.load_state("correlation-store-obs-range", {}), 119 | storage_type="session", 120 | ), 121 | dash.dcc.Store( 122 | id=self.uuid("correlation-store-active-resp-param"), 123 | data=self.load_state( 124 | "active_correlation", 125 | {"parameter": None, "response": None}, 126 | ), 127 | storage_type="session", 128 | ), 129 | dbc.Row( 130 | [ 131 | dbc.Col( 132 | id=self.uuid("ensemble-content"), 133 | children=ensemble_selector_list(parent=self), 134 | width=4, 135 | ), 136 | dbc.Col( 137 | [ 138 | parameter_selector_view( 139 | self, 140 | data_type=DataType.RESPONSE, 141 | titleLabel="Responses", 142 | ), 143 | dash.dcc.Checklist( 144 | id=self.uuid("response-observations-check"), 145 | options=[ 146 | { 147 | "label": "Show only responses with observations", 148 | "value": "obs", 149 | }, 150 | ], 151 | value=["obs"], 152 | style={"display": "none"}, 153 | ), 154 | ], 155 | width=4, 156 | ), 157 | dbc.Col( 158 | [ 159 | parameter_selector_view( 160 | self, 161 | data_type=DataType.PARAMETER, 162 | titleLabel="Parameters", 163 | ), 164 | ], 165 | width=4, 166 | ), 167 | ], 168 | ), 169 | dbc.Row( 170 | [ 171 | dbc.Col( 172 | [ 173 | dash.html.Span("Observation index selector"), 174 | correlation_view( 175 | id_view=self.uuid("obs_index_selector") 176 | ), 177 | ], 178 | className="active-info", 179 | style={"min-height": "10px", "padding": "20px"}, 180 | id=self.uuid("obs_index_selector_container"), 181 | ) 182 | ] 183 | ), 184 | dbc.Row( 185 | [ 186 | dbc.Col( 187 | id=self.uuid("info-text"), 188 | className="active-info", 189 | children=[dash.html.Span("INFO")], 190 | ), 191 | ] 192 | ), 193 | dbc.Row( 194 | [ 195 | dbc.Col( 196 | correlation_view( 197 | id_view=self.uuid("response-overview"), 198 | ), 199 | style=assets.ERTSTYLE["dbc-column"], 200 | ), 201 | dbc.Col( 202 | correlation_view( 203 | id_view=self.uuid("response-scatterplot"), 204 | ), 205 | style=assets.ERTSTYLE["dbc-column"], 206 | ), 207 | ] 208 | ), 209 | dbc.Row( 210 | [ 211 | dbc.Col( 212 | correlation_view( 213 | id_view=self.uuid("response-correlation"), 214 | ), 215 | style=assets.ERTSTYLE["dbc-column"], 216 | ), 217 | dbc.Col( 218 | correlation_view( 219 | id_view=self.uuid("response-heatmap"), 220 | ), 221 | style=assets.ERTSTYLE["dbc-column"], 222 | ), 223 | ] 224 | ), 225 | dbc.Row( 226 | children=[ 227 | dbc.Col( 228 | dash.dcc.RadioItems( 229 | id=self.uuid("correlation-metric"), 230 | options=[ 231 | {"label": "spearman", "value": "spearman"}, 232 | {"label": "pearson", "value": "pearson"}, 233 | ], 234 | value="pearson", 235 | ), 236 | className="correlation-option", 237 | width="auto", 238 | ), 239 | dbc.Col( 240 | children=[ 241 | dash_daq.BooleanSwitch( 242 | id=self.uuid("sort-parameters"), 243 | on=True, 244 | label="Sort parameters by correlation", 245 | ), 246 | dash_daq.BooleanSwitch( 247 | id=self.uuid("hide-hover"), 248 | on=False, 249 | label="Hide heatmap hover info", 250 | ), 251 | ], 252 | className="heatmap-options", 253 | width="auto", 254 | ), 255 | ], 256 | justify="center", 257 | ), 258 | ] 259 | ) 260 | 261 | def set_callbacks(self, app: dash.Dash) -> None: 262 | webviz_ert.controllers.ensemble_list_selector_controller(self, app) 263 | webviz_ert.controllers.parameter_selector_controller( 264 | self, app, data_type=DataType.PARAMETER, union_keys=False 265 | ) 266 | webviz_ert.controllers.parameter_selector_controller( 267 | self, app, data_type=DataType.RESPONSE, extra_input=True 268 | ) 269 | webviz_ert.controllers.response_correlation_controller(self, app) 270 | -------------------------------------------------------------------------------- /tests/data/snake_oil_data.py: -------------------------------------------------------------------------------- 1 | import io 2 | import pandas as pd 3 | 4 | from webviz_ert.data_loader import escape 5 | 6 | 7 | class DataContent: 8 | def __init__(self, content): 9 | self._content = content.encode() 10 | 11 | @property 12 | def content(self): 13 | return self._content 14 | 15 | 16 | def to_parquet_helper(dataframe: pd.DataFrame) -> bytes: 17 | stream = io.BytesIO() 18 | dataframe.to_parquet(stream) 19 | return stream.getvalue() 20 | 21 | 22 | all_ensemble_names = [ 23 | "default", 24 | "default3", 25 | "default_smoother_update", 26 | "nr_42", 27 | ] 28 | 29 | _experiment_1_metadata = { 30 | "name": "default", 31 | "id": 1, 32 | "ensemble_ids": [1, 2, 3, 42], 33 | "priors": { 34 | "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE": { 35 | "function": "UNIFORM", 36 | "parameter_names": ["MIN", "MAX"], 37 | "parameter_values": [0.2, 0.7], 38 | }, 39 | "SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE": { 40 | "function": "UNIFORM", 41 | "parameter_names": ["MIN", "MAX"], 42 | "parameter_values": [0.2, 0.7], 43 | }, 44 | }, 45 | "parameters": { 46 | "SNAKE_OIL_PARAM": [ 47 | { 48 | "key": "SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE", 49 | "transformation": "UNIFORM", 50 | "dimensionality": 1, 51 | "userdata": {"data_origin": "GEN_KW"}, 52 | }, 53 | { 54 | "key": "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE", 55 | "transformation": "UNIFORM", 56 | "dimensionality": 1, 57 | "userdata": {"data_origin": "GEN_KW"}, 58 | }, 59 | ] 60 | }, 61 | "responses": { 62 | "summary": [ 63 | {"response_type": "summary", "response_key": "FGPT", "filter_on": None}, 64 | {"response_type": "summary", "response_key": "WOPR:OP1", "filter_on": None}, 65 | {"response_type": "summary", "response_key": "FOPR", "filter_on": None}, 66 | ], 67 | "gen_data": [ 68 | { 69 | "response_type": "gen_data", 70 | "response_key": "SNAKE_OIL_GPR_DIFF", 71 | "filter_on": {"report_step": [199]}, 72 | } 73 | ], 74 | }, 75 | "observations": { 76 | "summary": { 77 | "FOPR": ["FOPR"], 78 | "WOPR:OP1": [ 79 | "WOPR_OP1_108", 80 | "WOPR_OP1_9", 81 | "WOPR_OP1_144", 82 | "WOPR_OP1_190", 83 | "WOPR_OP1_36", 84 | "WOPR_OP1_72", 85 | ], 86 | } 87 | }, 88 | "userdata": {}, 89 | } 90 | 91 | ensembles_response = { 92 | "http://127.0.0.1:5000/updates/facade": "OK", 93 | "http://127.0.0.1:5000/experiments": [ 94 | _experiment_1_metadata, 95 | ], 96 | "http://127.0.0.1:5000/experiments/1": _experiment_1_metadata, 97 | "http://127.0.0.1:5000/ensembles/1": { 98 | "child_ensemble_ids": [2], 99 | "experiment_id": 1, 100 | "parent_ensemble_id": None, 101 | "id": 1, 102 | "size": 1, 103 | "active_realizations": [0], 104 | "userdata": {"name": "default"}, 105 | }, 106 | "http://127.0.0.1:5000/ensembles/1/parameters": [ 107 | {"name": "BPR_138_PERSISTENCE", "labels": []}, 108 | {"name": "OP1_DIVERGENCE_SCALE", "labels": []}, 109 | ], 110 | "http://127.0.0.1:5000/ensembles/1/responses": { 111 | "SNAKE_OIL_GPR_DIFF": { 112 | "name": "SNAKE_OIL_GPR_DIFF", 113 | "id": "SNAKE_OIL_GPR_DIFF", 114 | "has_observations": False, 115 | }, 116 | }, 117 | "http://127.0.0.1:5000/ensembles/2": { 118 | "experiment_id": 1, 119 | "child_ensemble_ids": [], 120 | "parent_ensemble_id": 1, 121 | "id": 2, 122 | "size": 1, 123 | "active_realizations": [0], 124 | "userdata": {"name": "default_smoother_update"}, 125 | }, 126 | "http://127.0.0.1:5000/ensembles/2/parameters": [ 127 | {"name": "test_parameter_1", "labels": []}, 128 | {"name": "test_parameter_11", "labels": []}, 129 | {"name": "BPR_138_PERSISTENCE", "labels": []}, 130 | ], 131 | "http://127.0.0.1:5000/ensembles/2/responses": { 132 | "SNAKE_OIL_GPR_DIFF": { 133 | "name": "SNAKE_OIL_GPR_DIFF", 134 | "id": "SNAKE_OIL_GPR_DIFF", 135 | }, 136 | }, 137 | "http://127.0.0.1:5000/ensembles/3": { 138 | "experiment_id": 1, 139 | "child_ensemble_ids": [], 140 | "name": "default3", 141 | "parent_ensemble_id": None, 142 | "id": 3, 143 | "size": 1, 144 | "active_realizations": [0], 145 | "userdata": {"name": "default3"}, 146 | }, 147 | "http://127.0.0.1:5000/ensembles/3/responses": { 148 | "SNAKE_OIL_GPR_DIFF": { 149 | "name": "SNAKE_OIL_GPR_DIFF", 150 | "id": "SNAKE_OIL_GPR_DIFF", 151 | "has_observations": False, 152 | }, 153 | "FGPT": { 154 | "name": "FGPT", 155 | "id": "FGPT", 156 | "has_observations": False, 157 | }, 158 | "FOPR": { 159 | "name": "FOPR", 160 | "id": "FOPR", 161 | "has_observations": True, 162 | }, 163 | "WOPR:OP1": { 164 | "name": "WOPR:OP1", 165 | "id": "WOPR:OP1", 166 | "has_observations": True, 167 | }, 168 | }, 169 | "http://127.0.0.1:5000/ensembles/3/parameters": [ 170 | {"name": "BPR_138_PERSISTENCE", "labels": []}, 171 | {"name": "OP1_DIVERGENCE_SCALE", "labels": []}, 172 | ], 173 | "http://127.0.0.1:5000/ensembles/4": { 174 | "experiment_id": 1, 175 | "child_ensemble_ids": [], 176 | "parent_ensemble_id": None, 177 | "id": 4, 178 | "size": 1, 179 | "active_realizations": [0], 180 | "userdata": {"name": "default4"}, 181 | }, 182 | "http://127.0.0.1:5000/ensembles/4/responses": { 183 | "SNAKE_OIL_GPR_DIFF": { 184 | "name": "SNAKE_OIL_GPR_DIFF", 185 | "id": "SNAKE_OIL_GPR_DIFF", 186 | }, 187 | "FOPR": { 188 | "name": "FOPR", 189 | "id": "FOPR", 190 | "has_observations": True, 191 | }, 192 | }, 193 | "http://127.0.0.1:5000/ensembles/1/responses/SNAKE_OIL_GPR_DIFF/observations?realization_index=0": [], 194 | "http://127.0.0.1:5000/ensembles/3/responses/SNAKE_OIL_GPR_DIFF?realization_index=0": pd.DataFrame( 195 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 196 | columns=[0], 197 | index=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 198 | ) 199 | .transpose() 200 | .to_csv() 201 | .encode(), 202 | "http://127.0.0.1:5000/ensembles/3/responses/SNAKE_OIL_GPR_DIFF/observations?realization_index=0": [], 203 | "http://127.0.0.1:5000/ensembles/3/responses/FOPR/observations?realization_index=0": [ 204 | { 205 | "x_axis": ["2010-01-10 00:00:00", "2010-04-10 00:00:00"], 206 | "errors": [4, 2], 207 | "values": [0.42, 0.24], 208 | "name": "FOPR", 209 | } 210 | ], 211 | "http://127.0.0.1:5000/ensembles/3/responses/FOPR": to_parquet_helper( 212 | pd.DataFrame( 213 | [0.24, 0.13, 0.22, 0.36, 0.21, 0.54, 0.12, 0.16, 0.23, 0.18], 214 | index=[ 215 | "2010-01-10 00:00:00", 216 | "2010-02-10 00:00:00", 217 | "2010-03-10 00:00:00", 218 | "2010-04-10 00:00:00", 219 | "2010-05-10 00:00:00", 220 | "2010-06-10 00:00:00", 221 | "2010-07-10 00:00:00", 222 | "2010-08-10 00:00:00", 223 | "2010-09-10 00:00:00", 224 | "2010-10-10 00:00:00", 225 | ], 226 | columns=[0], 227 | ).transpose() 228 | ), 229 | "http://127.0.0.1:5000/ensembles/3/responses/FGPT/observations?realization_index=0": [], 230 | "http://127.0.0.1:5000/ensembles/3/responses/FGPT": to_parquet_helper( 231 | pd.DataFrame( 232 | [0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 8.1, 9.1], 233 | columns=[0], 234 | index=["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"], 235 | ).transpose() 236 | ), 237 | "http://127.0.0.1:5000/ensembles/3/responses/WOPR%253AOP1": to_parquet_helper( 238 | pd.DataFrame( 239 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 240 | columns=[0], 241 | index=["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"], 242 | ).transpose() 243 | ), 244 | "http://127.0.0.1:5000/ensembles/3/responses/WOPR%253AOP1/observations?realization_index=0": [ 245 | { 246 | "x_axis": [1, 4], 247 | "errors": [1, 1], 248 | "values": [1, 5], 249 | "name": "WOPR:OP1", 250 | } 251 | ], 252 | "http://127.0.0.1:5000/ensembles/4/responses/SNAKE_OIL_GPR_DIFF?realization_index=0": pd.DataFrame( 253 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 254 | columns=[0], 255 | index=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 256 | ) 257 | .transpose() 258 | .to_csv() 259 | .encode(), 260 | "http://127.0.0.1:5000/ensembles/4/responses/SNAKE_OIL_GPR_DIFF/observations?realization_index=0": [], 261 | "http://127.0.0.1:5000/ensembles/4/responses/FOPR?realization_index=0": pd.DataFrame( 262 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 263 | columns=[0], 264 | index=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 265 | ) 266 | .transpose() 267 | .to_csv() 268 | .encode(), 269 | "http://127.0.0.1:5000/ensembles/4/responses/FOPR/observations?realization_index=0": [ 270 | { 271 | "x_axis": {"data": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]}, 272 | "errors": {"data": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]}, 273 | "values": {"data": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]}, 274 | "name": "FOPR", 275 | } 276 | ], 277 | } 278 | 279 | 280 | ensembles_response[ 281 | "http://127.0.0.1:5000/ensembles/1/responses/SNAKE_OIL_GPR_DIFF?realization_index=0" 282 | ] = to_parquet_helper( 283 | pd.DataFrame( 284 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 285 | columns=["0"], 286 | index=["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"], 287 | ).transpose() 288 | ) 289 | 290 | ensembles_response[ 291 | 'http://127.0.0.1:5000/ensembles/1/responses/SNAKE_OIL_GPR_DIFF?filter_on={"report_step": "199"}' 292 | ] = to_parquet_helper( 293 | pd.DataFrame( 294 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 295 | columns=["0"], 296 | index=["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"], 297 | ).transpose() 298 | ) 299 | 300 | ensembles_response[ 301 | "http://127.0.0.1:5000/ensembles/1/parameters/OP1_DIVERGENCE_SCALE" 302 | ] = to_parquet_helper( 303 | pd.DataFrame( 304 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 305 | columns=["0"], 306 | index=["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"], 307 | ).transpose() 308 | ) 309 | 310 | 311 | ensembles_response[ 312 | "http://127.0.0.1:5000/ensembles/1/parameters/BPR_138_PERSISTENCE" 313 | ] = to_parquet_helper( 314 | pd.DataFrame( 315 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 316 | columns=["0"], 317 | index=["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"], 318 | ).transpose() 319 | ) 320 | 321 | ensembles_response.update( 322 | { 323 | "http://127.0.0.1:5000/ensembles/42": { 324 | "child_ensemble_ids": [], 325 | "experiment_id": 1, 326 | "parent_ensemble_id": None, 327 | "id": 42, 328 | "name": "nr_42", 329 | "size": 1, 330 | "active_realizations": [0], 331 | "userdata": {"name": "nr_42"}, 332 | }, 333 | "http://127.0.0.1:5000/ensembles/42/parameters": [ 334 | [ 335 | {"name": "SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE", "labels": []}, 336 | {"name": "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE", "labels": []}, 337 | ] 338 | ], 339 | "http://127.0.0.1:5000/ensembles/42/responses": { 340 | "SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE": { 341 | "name": "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE", 342 | "id": "SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE", 343 | }, 344 | "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE": { 345 | "name": "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE", 346 | "id": "SNAKE_OIL_PARAM:BPR_138_PERSISTENCE", 347 | }, 348 | }, 349 | } 350 | ) 351 | 352 | 353 | ensembles_response[ 354 | f"http://127.0.0.1:5000/ensembles/42/parameters/{escape('SNAKE_OIL_PARAM:OP1_DIVERGENCE_SCALE')}?" 355 | ] = to_parquet_helper( 356 | pd.DataFrame( 357 | [0.1, 1.1, 2.1], 358 | columns=["0"], 359 | index=["0", "1", "2"], 360 | ).transpose() 361 | ) 362 | ensembles_response[ 363 | f"http://127.0.0.1:5000/ensembles/42/parameters/{escape('SNAKE_OIL_PARAM:BPR_138_PERSISTENCE')}?" 364 | ] = to_parquet_helper( 365 | pd.DataFrame( 366 | [0.01, 1.01, 2.01], 367 | columns=["a"], 368 | index=["0", "1", "2"], 369 | ).transpose() 370 | ) 371 | --------------------------------------------------------------------------------