ert 16.0.9__py3-none-any.whl → 19.0.0rc2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- _ert/events.py +19 -2
- _ert/forward_model_runner/client.py +6 -2
- _ert/forward_model_runner/fm_dispatch.py +9 -6
- _ert/forward_model_runner/reporting/event.py +1 -0
- _ert/forward_model_runner/runner.py +1 -2
- _ert/utils.py +12 -0
- ert/__main__.py +58 -38
- ert/analysis/_enif_update.py +8 -4
- ert/analysis/_es_update.py +19 -6
- ert/analysis/_update_commons.py +16 -6
- ert/base_model_context.py +1 -1
- ert/cli/main.py +17 -12
- ert/cli/monitor.py +7 -0
- ert/config/__init__.py +17 -6
- ert/config/_create_observation_dataframes.py +118 -21
- ert/config/_get_num_cpu.py +1 -1
- ert/config/_observations.py +91 -2
- ert/config/_read_summary.py +74 -328
- ert/config/design_matrix.py +62 -23
- ert/config/distribution.py +1 -1
- ert/config/ensemble_config.py +9 -17
- ert/config/ert_config.py +155 -58
- ert/config/everest_control.py +234 -0
- ert/config/{everest_constraints_config.py → everest_response.py} +27 -15
- ert/config/field.py +99 -90
- ert/config/forward_model_step.py +122 -17
- ert/config/gen_data_config.py +5 -10
- ert/config/gen_kw_config.py +11 -41
- ert/config/known_response_types.py +14 -0
- ert/config/parameter_config.py +1 -33
- ert/config/parsing/_option_dict.py +10 -2
- ert/config/parsing/config_errors.py +1 -1
- ert/config/parsing/config_keywords.py +2 -1
- ert/config/parsing/config_schema.py +23 -11
- ert/config/parsing/config_schema_deprecations.py +3 -3
- ert/config/parsing/config_schema_item.py +26 -11
- ert/config/parsing/context_values.py +3 -3
- ert/config/parsing/file_context_token.py +1 -1
- ert/config/parsing/observations_parser.py +6 -2
- ert/config/parsing/queue_system.py +9 -0
- ert/config/parsing/schema_item_type.py +1 -0
- ert/config/queue_config.py +42 -50
- ert/config/response_config.py +0 -8
- ert/config/rft_config.py +275 -0
- ert/config/summary_config.py +3 -8
- ert/config/surface_config.py +73 -26
- ert/config/workflow_fixtures.py +2 -1
- ert/config/workflow_job.py +135 -54
- ert/dark_storage/client/__init__.py +2 -2
- ert/dark_storage/client/_session.py +4 -4
- ert/dark_storage/client/client.py +2 -2
- ert/dark_storage/common.py +12 -3
- ert/dark_storage/compute/misfits.py +11 -7
- ert/dark_storage/endpoints/compute/misfits.py +6 -4
- ert/dark_storage/endpoints/ensembles.py +4 -0
- ert/dark_storage/endpoints/experiment_server.py +30 -24
- ert/dark_storage/endpoints/experiments.py +2 -2
- ert/dark_storage/endpoints/observations.py +8 -6
- ert/dark_storage/endpoints/parameters.py +4 -12
- ert/dark_storage/endpoints/responses.py +24 -5
- ert/dark_storage/json_schema/ensemble.py +3 -0
- ert/dark_storage/json_schema/experiment.py +1 -1
- ert/data/_measured_data.py +6 -5
- ert/ensemble_evaluator/__init__.py +8 -1
- ert/ensemble_evaluator/config.py +2 -1
- ert/ensemble_evaluator/evaluator.py +81 -29
- ert/ensemble_evaluator/event.py +6 -0
- ert/ensemble_evaluator/snapshot.py +3 -1
- ert/ensemble_evaluator/state.py +1 -0
- ert/field_utils/__init__.py +8 -0
- ert/field_utils/field_utils.py +228 -15
- ert/field_utils/grdecl_io.py +1 -1
- ert/field_utils/roff_io.py +1 -1
- ert/gui/__init__.py +5 -2
- ert/gui/ertnotifier.py +1 -1
- ert/gui/ertwidgets/__init__.py +23 -16
- ert/gui/ertwidgets/analysismoduleedit.py +2 -2
- ert/gui/ertwidgets/checklist.py +1 -1
- ert/gui/ertwidgets/closabledialog.py +2 -0
- ert/gui/ertwidgets/copyablelabel.py +2 -0
- ert/gui/ertwidgets/create_experiment_dialog.py +3 -1
- ert/gui/ertwidgets/ensembleselector.py +2 -2
- ert/gui/ertwidgets/listeditbox.py +2 -0
- ert/gui/ertwidgets/models/__init__.py +2 -0
- ert/gui/ertwidgets/models/activerealizationsmodel.py +5 -1
- ert/gui/ertwidgets/models/path_model.py +1 -1
- ert/gui/ertwidgets/models/targetensemblemodel.py +5 -1
- ert/gui/ertwidgets/models/text_model.py +4 -1
- ert/gui/ertwidgets/pathchooser.py +0 -3
- ert/gui/ertwidgets/searchbox.py +17 -4
- ert/gui/ertwidgets/stringbox.py +2 -0
- ert/gui/{suggestor → ertwidgets/suggestor}/_suggestor_message.py +13 -4
- ert/gui/{suggestor → ertwidgets/suggestor}/suggestor.py +63 -30
- ert/gui/main.py +41 -13
- ert/gui/main_window.py +3 -7
- ert/gui/model/fm_step_list.py +3 -0
- ert/gui/model/real_list.py +1 -0
- ert/gui/model/snapshot.py +1 -0
- ert/gui/simulation/combobox_with_description.py +3 -0
- ert/gui/simulation/ensemble_experiment_panel.py +8 -2
- ert/gui/simulation/ensemble_information_filter_panel.py +7 -2
- ert/gui/simulation/ensemble_smoother_panel.py +8 -2
- ert/gui/simulation/evaluate_ensemble_panel.py +17 -7
- ert/gui/simulation/experiment_panel.py +18 -6
- ert/gui/simulation/manual_update_panel.py +35 -10
- ert/gui/simulation/multiple_data_assimilation_panel.py +13 -9
- ert/gui/simulation/run_dialog.py +47 -20
- ert/gui/simulation/single_test_run_panel.py +6 -3
- ert/gui/simulation/view/progress_widget.py +2 -0
- ert/gui/simulation/view/realization.py +5 -1
- ert/gui/simulation/view/update.py +2 -0
- ert/gui/summarypanel.py +20 -1
- ert/gui/tools/event_viewer/panel.py +3 -4
- ert/gui/tools/event_viewer/tool.py +2 -0
- ert/gui/tools/load_results/load_results_panel.py +1 -1
- ert/gui/tools/load_results/load_results_tool.py +2 -0
- ert/gui/tools/manage_experiments/export_dialog.py +136 -0
- ert/gui/tools/manage_experiments/manage_experiments_panel.py +2 -0
- ert/gui/tools/manage_experiments/storage_info_widget.py +121 -16
- ert/gui/tools/manage_experiments/storage_widget.py +4 -3
- ert/gui/tools/plot/customize/color_chooser.py +5 -2
- ert/gui/tools/plot/customize/customize_plot_dialog.py +2 -0
- ert/gui/tools/plot/customize/default_customization_view.py +4 -0
- ert/gui/tools/plot/customize/limits_customization_view.py +3 -0
- ert/gui/tools/plot/customize/statistics_customization_view.py +3 -0
- ert/gui/tools/plot/customize/style_chooser.py +2 -0
- ert/gui/tools/plot/customize/style_customization_view.py +3 -0
- ert/gui/tools/plot/data_type_keys_widget.py +2 -0
- ert/gui/tools/plot/data_type_proxy_model.py +3 -0
- ert/gui/tools/plot/plot_api.py +50 -28
- ert/gui/tools/plot/plot_ensemble_selection_widget.py +17 -10
- ert/gui/tools/plot/plot_widget.py +15 -2
- ert/gui/tools/plot/plot_window.py +41 -19
- ert/gui/tools/plot/plottery/plot_config.py +2 -0
- ert/gui/tools/plot/plottery/plot_context.py +14 -0
- ert/gui/tools/plot/plottery/plots/__init__.py +2 -0
- ert/gui/tools/plot/plottery/plots/cesp.py +3 -1
- ert/gui/tools/plot/plottery/plots/distribution.py +6 -1
- ert/gui/tools/plot/plottery/plots/ensemble.py +13 -5
- ert/gui/tools/plot/plottery/plots/gaussian_kde.py +12 -2
- ert/gui/tools/plot/plottery/plots/histogram.py +3 -1
- ert/gui/tools/plot/plottery/plots/misfits.py +436 -0
- ert/gui/tools/plot/plottery/plots/observations.py +18 -4
- ert/gui/tools/plot/plottery/plots/statistics.py +62 -20
- ert/gui/tools/plot/plottery/plots/std_dev.py +3 -1
- ert/gui/tools/plot/widgets/clearable_line_edit.py +9 -0
- ert/gui/tools/plot/widgets/filter_popup.py +2 -0
- ert/gui/tools/plot/widgets/filterable_kw_list_model.py +3 -0
- ert/gui/tools/plugins/plugin.py +1 -1
- ert/gui/tools/plugins/plugins_tool.py +2 -0
- ert/gui/tools/plugins/process_job_dialog.py +3 -0
- ert/gui/tools/workflows/workflow_dialog.py +2 -0
- ert/gui/tools/workflows/workflows_tool.py +2 -0
- ert/libres_facade.py +5 -7
- ert/logging/__init__.py +4 -1
- ert/mode_definitions.py +2 -0
- ert/plugins/__init__.py +4 -6
- ert/plugins/hook_implementations/workflows/csv_export.py +2 -3
- ert/plugins/hook_implementations/workflows/gen_data_rft_export.py +10 -2
- ert/plugins/hook_specifications/__init__.py +0 -10
- ert/plugins/hook_specifications/jobs.py +0 -9
- ert/plugins/plugin_manager.py +53 -124
- ert/resources/forward_models/run_reservoirsimulator.py +8 -4
- ert/resources/forward_models/template_render.py +10 -10
- ert/resources/shell_scripts/delete_directory.py +2 -2
- ert/run_models/__init__.py +24 -6
- ert/run_models/_create_run_path.py +133 -38
- ert/run_models/ensemble_experiment.py +10 -4
- ert/run_models/ensemble_information_filter.py +8 -1
- ert/run_models/ensemble_smoother.py +9 -3
- ert/run_models/evaluate_ensemble.py +8 -6
- ert/run_models/event.py +7 -3
- ert/run_models/everest_run_model.py +337 -113
- ert/run_models/initial_ensemble_run_model.py +25 -24
- ert/run_models/manual_update.py +6 -3
- ert/run_models/manual_update_enif.py +37 -0
- ert/run_models/model_factory.py +78 -18
- ert/run_models/multiple_data_assimilation.py +22 -11
- ert/run_models/run_model.py +72 -73
- ert/run_models/single_test_run.py +7 -4
- ert/run_models/update_run_model.py +4 -2
- ert/runpaths.py +5 -6
- ert/sample_prior.py +9 -4
- ert/scheduler/__init__.py +10 -5
- ert/scheduler/driver.py +40 -0
- ert/scheduler/event.py +3 -1
- ert/scheduler/job.py +23 -13
- ert/scheduler/lsf_driver.py +15 -5
- ert/scheduler/openpbs_driver.py +10 -4
- ert/scheduler/scheduler.py +5 -0
- ert/scheduler/slurm_driver.py +20 -5
- ert/services/__init__.py +2 -2
- ert/services/_base_service.py +37 -20
- ert/services/_storage_main.py +20 -18
- ert/services/ert_server.py +317 -0
- ert/shared/_doc_utils/__init__.py +4 -2
- ert/shared/_doc_utils/ert_jobs.py +1 -4
- ert/shared/net_utils.py +43 -18
- ert/shared/storage/connection.py +3 -3
- ert/shared/version.py +3 -3
- ert/storage/__init__.py +14 -1
- ert/storage/local_ensemble.py +44 -13
- ert/storage/local_experiment.py +54 -34
- ert/storage/local_storage.py +90 -58
- ert/storage/migration/to10.py +3 -2
- ert/storage/migration/to11.py +9 -10
- ert/storage/migration/to12.py +19 -20
- ert/storage/migration/to13.py +28 -27
- ert/storage/migration/to14.py +3 -3
- ert/storage/migration/to15.py +25 -0
- ert/storage/migration/to16.py +38 -0
- ert/storage/migration/to17.py +42 -0
- ert/storage/migration/to18.py +11 -0
- ert/storage/migration/to19.py +34 -0
- ert/storage/migration/to20.py +23 -0
- ert/storage/migration/to21.py +25 -0
- ert/storage/migration/to6.py +3 -2
- ert/storage/migration/to7.py +12 -13
- ert/storage/migration/to8.py +9 -11
- ert/storage/migration/to9.py +5 -4
- ert/storage/realization_storage_state.py +7 -7
- ert/substitutions.py +12 -28
- ert/validation/active_range.py +7 -7
- ert/validation/ensemble_realizations_argument.py +4 -2
- ert/validation/rangestring.py +16 -16
- ert/workflow_runner.py +6 -3
- {ert-16.0.9.dist-info → ert-19.0.0rc2.dist-info}/METADATA +21 -15
- ert-19.0.0rc2.dist-info/RECORD +524 -0
- {ert-16.0.9.dist-info → ert-19.0.0rc2.dist-info}/WHEEL +1 -1
- everest/api/everest_data_api.py +14 -1
- everest/assets/everest_logo.svg +406 -0
- everest/bin/config_branch_script.py +30 -14
- everest/bin/everconfigdump_script.py +2 -10
- everest/bin/everest_script.py +53 -33
- everest/bin/everlint_script.py +3 -5
- everest/bin/kill_script.py +7 -5
- everest/bin/main.py +11 -24
- everest/bin/monitor_script.py +64 -35
- everest/bin/utils.py +58 -43
- everest/bin/visualization_script.py +23 -13
- everest/config/__init__.py +4 -1
- everest/config/control_config.py +81 -6
- everest/config/control_variable_config.py +4 -3
- everest/config/everest_config.py +102 -79
- everest/config/forward_model_config.py +5 -3
- everest/config/install_data_config.py +7 -5
- everest/config/install_job_config.py +45 -3
- everest/config/install_template_config.py +3 -3
- everest/config/optimization_config.py +19 -6
- everest/config/output_constraint_config.py +8 -2
- everest/config/server_config.py +6 -55
- everest/config/simulator_config.py +62 -17
- everest/config/utils.py +25 -105
- everest/config/validation_utils.py +34 -15
- everest/config_file_loader.py +30 -21
- everest/detached/__init__.py +0 -6
- everest/detached/client.py +7 -52
- everest/detached/everserver.py +19 -45
- everest/everest_storage.py +24 -40
- everest/gui/everest_client.py +2 -3
- everest/gui/main_window.py +2 -2
- everest/optimizer/everest2ropt.py +68 -42
- everest/optimizer/opt_model_transforms.py +15 -20
- everest/optimizer/utils.py +0 -29
- everest/plugins/hook_specs.py +0 -24
- everest/strings.py +1 -6
- everest/util/__init__.py +3 -1
- ert/config/everest_objective_config.py +0 -95
- ert/config/ext_param_config.py +0 -107
- ert/gui/tools/export/__init__.py +0 -3
- ert/gui/tools/export/export_panel.py +0 -83
- ert/gui/tools/export/export_tool.py +0 -67
- ert/gui/tools/export/exporter.py +0 -36
- ert/plugins/hook_specifications/ecl_config.py +0 -29
- ert/services/storage_service.py +0 -127
- ert/summary_key_type.py +0 -234
- ert-16.0.9.dist-info/RECORD +0 -521
- everest/bin/everexport_script.py +0 -53
- everest/config/sampler_config.py +0 -103
- everest/simulator/__init__.py +0 -88
- everest/simulator/everest_to_ert.py +0 -252
- /ert/gui/{suggestor → ertwidgets/suggestor}/__init__.py +0 -0
- /ert/gui/{suggestor → ertwidgets/suggestor}/_colors.py +0 -0
- {ert-16.0.9.dist-info → ert-19.0.0rc2.dist-info}/entry_points.txt +0 -0
- {ert-16.0.9.dist-info → ert-19.0.0rc2.dist-info}/licenses/COPYING +0 -0
- {ert-16.0.9.dist-info → ert-19.0.0rc2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Iterator, Mapping, MutableMapping
|
|
7
|
+
from dataclasses import field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from textwrap import dedent
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Literal, Self
|
|
11
|
+
|
|
12
|
+
import networkx as nx
|
|
13
|
+
import numpy as np
|
|
14
|
+
import xarray as xr
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
16
|
+
from ropt.workflow import find_sampler_plugin
|
|
17
|
+
|
|
18
|
+
from ert.substitutions import substitute_runpath_name
|
|
19
|
+
|
|
20
|
+
from .parameter_config import ParameterConfig
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import numpy.typing as npt
|
|
24
|
+
|
|
25
|
+
from ert.storage import Ensemble
|
|
26
|
+
|
|
27
|
+
Number = int | float
|
|
28
|
+
DataType = Mapping[str, Number | Mapping[str, Number]]
|
|
29
|
+
MutableDataType = MutableMapping[str, Number | MutableMapping[str, Number]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SamplerConfig(BaseModel):
|
|
33
|
+
backend: str | None = Field(
|
|
34
|
+
default=None,
|
|
35
|
+
description=dedent(
|
|
36
|
+
"""
|
|
37
|
+
[Deprecated]
|
|
38
|
+
|
|
39
|
+
The correct backend will be inferred by the method. If several backends
|
|
40
|
+
have a method named `A`, pick a specific backend `B` by putting `B/A` in
|
|
41
|
+
the `method` field.
|
|
42
|
+
"""
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
method: str = Field(
|
|
46
|
+
default="norm",
|
|
47
|
+
description=dedent(
|
|
48
|
+
"""
|
|
49
|
+
The sampling method or distribution used by the sampler backend.
|
|
50
|
+
|
|
51
|
+
The set of available methods depends on the sampler backend used. By
|
|
52
|
+
default a plugin based on `scipy.stats` is used, implementing the
|
|
53
|
+
following methods:
|
|
54
|
+
|
|
55
|
+
- From Probability Distributions:
|
|
56
|
+
- `norm`: Samples from a standard normal distribution (mean 0,
|
|
57
|
+
standard deviation 1).
|
|
58
|
+
- `truncnorm`: Samples from a truncated normal distribution
|
|
59
|
+
(mean 0, std. dev. 1), truncated to the range `[-1, 1]`.
|
|
60
|
+
- `uniform`: Samples from a uniform distribution in the range
|
|
61
|
+
`[-1, 1]`.
|
|
62
|
+
|
|
63
|
+
- From Quasi-Monte Carlo Sequences:
|
|
64
|
+
- `sobol`: Uses Sobol' sequences.
|
|
65
|
+
- `halton`: Uses Halton sequences.
|
|
66
|
+
- `lhs`: Uses Latin Hypercube Sampling.
|
|
67
|
+
|
|
68
|
+
Note: QMC samples are generated in the unit hypercube `[0, 1]^d`
|
|
69
|
+
and then scaled to the hypercube `[-1, 1]^d`.
|
|
70
|
+
"""
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
options: dict[str, Any] | None = Field(
|
|
74
|
+
default=None,
|
|
75
|
+
description=dedent(
|
|
76
|
+
"""
|
|
77
|
+
Specifies a dict of optional parameters for the sampler backend.
|
|
78
|
+
|
|
79
|
+
This dict of values is passed unchanged to the selected method in
|
|
80
|
+
the backend.
|
|
81
|
+
"""
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
shared: bool | None = Field(
|
|
85
|
+
default=None,
|
|
86
|
+
description=dedent(
|
|
87
|
+
"""
|
|
88
|
+
Whether to share perturbations between realizations.
|
|
89
|
+
"""
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
model_config = ConfigDict(extra="forbid")
|
|
93
|
+
|
|
94
|
+
@model_validator(mode="after")
|
|
95
|
+
def validate_backend_and_method(self) -> Self:
|
|
96
|
+
if self.backend is not None:
|
|
97
|
+
message = (
|
|
98
|
+
"sampler.backend is deprecated. "
|
|
99
|
+
"The correct backend will be inferred by the method. "
|
|
100
|
+
"If several backends have a method named A, you need to pick "
|
|
101
|
+
"a specific backend B by putting B/A in sampler.method."
|
|
102
|
+
)
|
|
103
|
+
print(message)
|
|
104
|
+
# Note: Importing EVEREST.everest
|
|
105
|
+
# leads to circular import, but we still wish to log
|
|
106
|
+
# from "everest" here as per old behavior.
|
|
107
|
+
# Can consider logging this as if from ERT,
|
|
108
|
+
# which is valid if we store SamplerConfig as part of
|
|
109
|
+
# EverestControl configs.
|
|
110
|
+
logging.getLogger("everest").warning(message)
|
|
111
|
+
|
|
112
|
+
# Update the default for backends that are not scipy:
|
|
113
|
+
if (
|
|
114
|
+
self.backend not in {None, "scipy"}
|
|
115
|
+
and "method" not in self.model_fields_set
|
|
116
|
+
):
|
|
117
|
+
self.method = "default"
|
|
118
|
+
|
|
119
|
+
if self.backend is not None:
|
|
120
|
+
self.method = f"{self.backend}/{self.method}"
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
plugin = find_sampler_plugin(f"{self.method}")
|
|
124
|
+
except ValueError:
|
|
125
|
+
raise
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
ert_version = importlib.metadata.version("ert")
|
|
128
|
+
ropt_version = importlib.metadata.version("ropt")
|
|
129
|
+
msg = (
|
|
130
|
+
f"Error while initializing ropt:\n\n{exc}.\n\n"
|
|
131
|
+
"There may a be version mismatch between "
|
|
132
|
+
f"ERT ({ert_version}) and ropt ({ropt_version})\n"
|
|
133
|
+
"If the installation is correct, please report this as a bug."
|
|
134
|
+
)
|
|
135
|
+
raise RuntimeError(msg) from exc
|
|
136
|
+
|
|
137
|
+
if plugin is None:
|
|
138
|
+
raise ValueError(f"Sampler method '{self.method}' not found")
|
|
139
|
+
|
|
140
|
+
self.backend = None
|
|
141
|
+
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class EverestControl(ParameterConfig):
|
|
146
|
+
"""Create an EverestControl for @key with the given @input_keys
|
|
147
|
+
|
|
148
|
+
@input_keys can be either a list of keys as strings or a dict with
|
|
149
|
+
keys as strings and a list of suffixes for each key.
|
|
150
|
+
If a list of strings is given, the order is preserved.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
type: Literal["everest_parameters"] = "everest_parameters"
|
|
154
|
+
input_keys: list[str] = field(default_factory=list)
|
|
155
|
+
forward_init: bool = False
|
|
156
|
+
output_file: str = ""
|
|
157
|
+
forward_init_file: str = ""
|
|
158
|
+
update: bool = False
|
|
159
|
+
types: list[Literal["well_control", "generic_control"]]
|
|
160
|
+
initial_guesses: list[float]
|
|
161
|
+
control_types: list[Literal["real", "integer"]]
|
|
162
|
+
enabled: list[bool]
|
|
163
|
+
min: list[float]
|
|
164
|
+
max: list[float]
|
|
165
|
+
perturbation_types: list[Literal["absolute", "relative"]]
|
|
166
|
+
perturbation_magnitudes: list[float]
|
|
167
|
+
scaled_ranges: list[tuple[float, float]]
|
|
168
|
+
samplers: list[SamplerConfig | None]
|
|
169
|
+
|
|
170
|
+
# Set up for deprecation, but has to live here until support for the
|
|
171
|
+
# "dotdash" notation is removed for everest controls via everest config.
|
|
172
|
+
input_keys_dotdash: list[str] = field(default_factory=list)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def parameter_keys(self) -> list[str]:
|
|
176
|
+
return self.input_keys
|
|
177
|
+
|
|
178
|
+
def read_from_runpath(
|
|
179
|
+
self, run_path: Path, real_nr: int, iteration: int
|
|
180
|
+
) -> xr.Dataset:
|
|
181
|
+
raise NotImplementedError
|
|
182
|
+
|
|
183
|
+
def write_to_runpath(
|
|
184
|
+
self, run_path: Path, real_nr: int, ensemble: Ensemble
|
|
185
|
+
) -> None:
|
|
186
|
+
file_path = run_path / substitute_runpath_name(
|
|
187
|
+
self.output_file, real_nr, ensemble.iteration
|
|
188
|
+
)
|
|
189
|
+
Path.mkdir(file_path.parent, exist_ok=True, parents=True)
|
|
190
|
+
|
|
191
|
+
data: MutableDataType = {}
|
|
192
|
+
for da in ensemble.load_parameters(self.name, real_nr)["values"]:
|
|
193
|
+
assert isinstance(da, xr.DataArray)
|
|
194
|
+
name = str(da.names.values)
|
|
195
|
+
try:
|
|
196
|
+
outer, inner = name.split("\0")
|
|
197
|
+
|
|
198
|
+
if outer not in data:
|
|
199
|
+
data[outer] = {}
|
|
200
|
+
data[outer][inner] = float(da) # type: ignore
|
|
201
|
+
except ValueError:
|
|
202
|
+
data[name] = float(da)
|
|
203
|
+
|
|
204
|
+
file_path.write_text(json.dumps(data), encoding="utf-8")
|
|
205
|
+
|
|
206
|
+
def create_storage_datasets(
|
|
207
|
+
self,
|
|
208
|
+
from_data: npt.NDArray[np.float64],
|
|
209
|
+
iens_active_index: npt.NDArray[np.int_],
|
|
210
|
+
) -> Iterator[tuple[int, xr.Dataset]]:
|
|
211
|
+
for i, realization in enumerate(iens_active_index):
|
|
212
|
+
yield (
|
|
213
|
+
int(realization),
|
|
214
|
+
xr.Dataset(
|
|
215
|
+
{
|
|
216
|
+
"values": ("names", from_data[:, i]),
|
|
217
|
+
"names": [
|
|
218
|
+
x.split(f"{self.name}.")[1].replace(".", "\0")
|
|
219
|
+
for x in self.parameter_keys
|
|
220
|
+
],
|
|
221
|
+
}
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def load_parameters(
|
|
226
|
+
self, ensemble: Ensemble, realizations: npt.NDArray[np.int_]
|
|
227
|
+
) -> npt.NDArray[np.float64]:
|
|
228
|
+
raise NotImplementedError
|
|
229
|
+
|
|
230
|
+
def load_parameter_graph(self) -> nx.Graph[int]:
|
|
231
|
+
raise NotImplementedError
|
|
232
|
+
|
|
233
|
+
def __len__(self) -> int:
|
|
234
|
+
return len(self.input_keys)
|
|
@@ -11,12 +11,21 @@ from .response_config import InvalidResponseFile, ResponseConfig, ResponseMetada
|
|
|
11
11
|
from .responses_index import responses_index
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
class
|
|
14
|
+
class EverestResponse(ResponseConfig):
|
|
15
|
+
"""Base class for Everest response configurations."""
|
|
16
|
+
|
|
17
|
+
has_finalized_keys: bool = True
|
|
18
|
+
scales: list[float | None]
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def primary_key(self) -> list[str]:
|
|
22
|
+
return []
|
|
23
|
+
|
|
15
24
|
@property
|
|
16
25
|
def metadata(self) -> list[ResponseMetadata]:
|
|
17
26
|
return [
|
|
18
27
|
ResponseMetadata(
|
|
19
|
-
response_type=self.
|
|
28
|
+
response_type=self.type,
|
|
20
29
|
response_key=response_key,
|
|
21
30
|
finalized=self.has_finalized_keys,
|
|
22
31
|
filter_on=None,
|
|
@@ -24,10 +33,6 @@ class EverestConstraintsConfig(ResponseConfig):
|
|
|
24
33
|
for response_key in self.keys
|
|
25
34
|
]
|
|
26
35
|
|
|
27
|
-
type: Literal["everest_constraints"] = "everest_constraints"
|
|
28
|
-
name: str = "everest_constraints"
|
|
29
|
-
has_finalized_keys: bool = True
|
|
30
|
-
|
|
31
36
|
@property
|
|
32
37
|
def expected_input_files(self) -> list[str]:
|
|
33
38
|
return self.input_files
|
|
@@ -49,7 +54,6 @@ class EverestConstraintsConfig(ResponseConfig):
|
|
|
49
54
|
)
|
|
50
55
|
|
|
51
56
|
errors = []
|
|
52
|
-
|
|
53
57
|
run_path_ = Path(run_path)
|
|
54
58
|
datasets_per_name = []
|
|
55
59
|
|
|
@@ -72,24 +76,32 @@ class EverestConstraintsConfig(ResponseConfig):
|
|
|
72
76
|
if all(isinstance(err, FileNotFoundError) for err in errors):
|
|
73
77
|
raise FileNotFoundError(
|
|
74
78
|
"Could not find one or more files/directories while reading "
|
|
75
|
-
f"{self.
|
|
79
|
+
f"{self.type}: {','.join([str(err) for err in errors])}"
|
|
76
80
|
)
|
|
77
81
|
else:
|
|
78
82
|
raise InvalidResponseFile(
|
|
79
83
|
"Error reading "
|
|
80
|
-
f"{self.
|
|
84
|
+
f"{self.type}, errors: {','.join([str(err) for err in errors])}"
|
|
81
85
|
)
|
|
82
86
|
|
|
83
87
|
combined = pl.concat(datasets_per_name)
|
|
84
88
|
return combined
|
|
85
89
|
|
|
86
|
-
@property
|
|
87
|
-
def response_type(self) -> str:
|
|
88
|
-
return "everest_constraints"
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
class EverestConstraintsConfig(EverestResponse):
|
|
92
|
+
type: Literal["everest_constraints"] = "everest_constraints"
|
|
93
|
+
targets: list[float | None]
|
|
94
|
+
upper_bounds: list[float]
|
|
95
|
+
lower_bounds: list[float]
|
|
93
96
|
|
|
94
97
|
|
|
95
98
|
responses_index.add_response_type(EverestConstraintsConfig)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class EverestObjectivesConfig(EverestResponse):
|
|
102
|
+
type: Literal["everest_objectives"] = "everest_objectives"
|
|
103
|
+
weights: list[float | None]
|
|
104
|
+
objective_types: list[Literal["mean", "stddev"]]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
responses_index.add_response_type(EverestObjectivesConfig)
|
ert/config/field.py
CHANGED
|
@@ -3,32 +3,33 @@ from __future__ import annotations
|
|
|
3
3
|
import itertools
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
|
-
from collections.abc import Iterator
|
|
7
|
-
from functools import cached_property
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
8
7
|
from pathlib import Path
|
|
9
|
-
from typing import TYPE_CHECKING, Any, Literal, Self, cast, overload
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Final, Literal, Self, cast, overload
|
|
10
9
|
|
|
11
10
|
import networkx as nx
|
|
12
11
|
import numpy as np
|
|
13
12
|
import xarray as xr
|
|
14
|
-
import xtgeo
|
|
13
|
+
import xtgeo
|
|
15
14
|
from pydantic import field_serializer
|
|
16
15
|
|
|
17
16
|
from ert.field_utils import (
|
|
18
17
|
ErtboxParameters,
|
|
19
18
|
FieldFileFormat,
|
|
20
19
|
Shape,
|
|
20
|
+
calc_rho_for_2d_grid_layer,
|
|
21
21
|
calculate_ertbox_parameters,
|
|
22
22
|
get_shape,
|
|
23
23
|
read_field,
|
|
24
|
-
read_mask,
|
|
25
24
|
save_field,
|
|
25
|
+
transform_local_ellipse_angle_to_local_coords,
|
|
26
|
+
transform_positions_to_local_field_coordinates,
|
|
26
27
|
)
|
|
27
28
|
from ert.substitutions import substitute_runpath_name
|
|
28
29
|
from ert.utils import log_duration
|
|
29
30
|
|
|
30
31
|
from ._str_to_bool import str_to_bool
|
|
31
|
-
from .parameter_config import ParameterConfig
|
|
32
|
+
from .parameter_config import ParameterConfig
|
|
32
33
|
from .parsing import ConfigValidationError, ConfigWarning
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING:
|
|
@@ -67,32 +68,9 @@ def create_flattened_cube_graph(px: int, py: int, pz: int) -> nx.Graph[int]:
|
|
|
67
68
|
return G
|
|
68
69
|
|
|
69
70
|
|
|
70
|
-
def adjust_graph_for_masking(
|
|
71
|
-
G: nx.Graph[int], mask: npt.NDArray[np.bool_]
|
|
72
|
-
) -> nx.Graph[int]:
|
|
73
|
-
"""
|
|
74
|
-
Adjust the graph G according to the masking indices.
|
|
75
|
-
Removes nodes specified by the mask and relabels the remaining nodes
|
|
76
|
-
to have consecutive labels from 0 to G.number_of_nodes - 1.
|
|
77
|
-
Parameters:
|
|
78
|
-
- G: The graph to adjust
|
|
79
|
-
- mask: Boolean mask flattened array
|
|
80
|
-
Returns:
|
|
81
|
-
- The adjusted graph
|
|
82
|
-
"""
|
|
83
|
-
# Step 1: Remove nodes specified by mask_indices
|
|
84
|
-
mask_indices = np.where(mask)[0]
|
|
85
|
-
G.remove_nodes_from(mask_indices)
|
|
86
|
-
|
|
87
|
-
# Step 2: Relabel remaining nodes to 0, 1, 2, ..., G.number_of_nodes - 1
|
|
88
|
-
new_labels = {old_label: new_label for new_label, old_label in enumerate(G.nodes())}
|
|
89
|
-
G = nx.relabel_nodes(G, new_labels, copy=True)
|
|
90
|
-
|
|
91
|
-
return G
|
|
92
|
-
|
|
93
|
-
|
|
94
71
|
class Field(ParameterConfig):
|
|
95
72
|
type: Literal["field"] = "field"
|
|
73
|
+
dimensionality: Literal[3] = 3
|
|
96
74
|
ertbox_params: ErtboxParameters
|
|
97
75
|
file_format: FieldFileFormat
|
|
98
76
|
output_transformation: str | None
|
|
@@ -102,31 +80,15 @@ class Field(ParameterConfig):
|
|
|
102
80
|
forward_init_file: str
|
|
103
81
|
output_file: Path
|
|
104
82
|
grid_file: str
|
|
105
|
-
mask_file: Path | None = None
|
|
106
83
|
|
|
107
84
|
@field_serializer("output_file")
|
|
108
85
|
def serialize_output_file(self, path: Path) -> str:
|
|
109
86
|
return str(path)
|
|
110
87
|
|
|
111
|
-
@field_serializer("mask_file")
|
|
112
|
-
def serialize_mask_file(self, path: Path | None) -> str | None:
|
|
113
|
-
return str(path) if path is not None else None
|
|
114
|
-
|
|
115
88
|
@property
|
|
116
89
|
def parameter_keys(self) -> list[str]:
|
|
117
90
|
return []
|
|
118
91
|
|
|
119
|
-
@property
|
|
120
|
-
def metadata(self) -> list[ParameterMetadata]:
|
|
121
|
-
return [
|
|
122
|
-
ParameterMetadata(
|
|
123
|
-
key=self.name,
|
|
124
|
-
transformation=self.output_transformation,
|
|
125
|
-
dimensionality=3,
|
|
126
|
-
userdata={"data_origin": "FIELD", "ertbox_params": self.ertbox_params},
|
|
127
|
-
)
|
|
128
|
-
]
|
|
129
|
-
|
|
130
92
|
@classmethod
|
|
131
93
|
def from_config_list(
|
|
132
94
|
cls,
|
|
@@ -239,11 +201,7 @@ class Field(ParameterConfig):
|
|
|
239
201
|
)
|
|
240
202
|
|
|
241
203
|
def __len__(self) -> int:
|
|
242
|
-
|
|
243
|
-
return self.ertbox_params.nx * self.ertbox_params.ny * self.ertbox_params.nz
|
|
244
|
-
|
|
245
|
-
# Uses int() to convert to standard python int for mypy
|
|
246
|
-
return int(np.size(self.mask) - np.count_nonzero(self.mask))
|
|
204
|
+
return self.ertbox_params.nx * self.ertbox_params.ny * self.ertbox_params.nz
|
|
247
205
|
|
|
248
206
|
@log_duration(_logger, custom_name="load_field")
|
|
249
207
|
def read_from_runpath(
|
|
@@ -258,7 +216,6 @@ class Field(ParameterConfig):
|
|
|
258
216
|
read_field(
|
|
259
217
|
run_path / file_name,
|
|
260
218
|
self.name,
|
|
261
|
-
self.mask,
|
|
262
219
|
Shape(
|
|
263
220
|
self.ertbox_params.nx,
|
|
264
221
|
self.ertbox_params.ny,
|
|
@@ -294,16 +251,15 @@ class Field(ParameterConfig):
|
|
|
294
251
|
from_data: npt.NDArray[np.float64],
|
|
295
252
|
iens_active_index: npt.NDArray[np.int_],
|
|
296
253
|
) -> Iterator[tuple[int, xr.Dataset]]:
|
|
254
|
+
dim_nx, dim_ny, dim_nz = (
|
|
255
|
+
self.ertbox_params.nx,
|
|
256
|
+
self.ertbox_params.ny,
|
|
257
|
+
self.ertbox_params.nz,
|
|
258
|
+
)
|
|
259
|
+
|
|
297
260
|
for i, realization in enumerate(iens_active_index):
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
mask=self.mask,
|
|
301
|
-
fill_value=np.nan,
|
|
302
|
-
dtype=from_data.dtype,
|
|
303
|
-
)
|
|
304
|
-
ma[~ma.mask] = from_data[:, i]
|
|
305
|
-
ma = ma.reshape(self.mask.shape) # type: ignore
|
|
306
|
-
ds = xr.Dataset({"values": (["x", "y", "z"], ma.filled())})
|
|
261
|
+
values = from_data[:, i].reshape((dim_nx, dim_ny, dim_nz))
|
|
262
|
+
ds = xr.Dataset({"values": (["x", "y", "z"], values)})
|
|
307
263
|
yield int(realization), ds
|
|
308
264
|
|
|
309
265
|
def load_parameters(
|
|
@@ -314,7 +270,7 @@ class Field(ParameterConfig):
|
|
|
314
270
|
ensemble_size = len(ds.realizations)
|
|
315
271
|
da = xr.DataArray(
|
|
316
272
|
[
|
|
317
|
-
np.ma.MaskedArray(data=d
|
|
273
|
+
np.ma.MaskedArray(data=d).compressed()
|
|
318
274
|
for d in ds["values"].values.reshape(ensemble_size, -1)
|
|
319
275
|
]
|
|
320
276
|
)
|
|
@@ -328,7 +284,7 @@ class Field(ParameterConfig):
|
|
|
328
284
|
def _transform_data(
|
|
329
285
|
self, data_array: xr.DataArray
|
|
330
286
|
) -> np.ma.MaskedArray[Any, np.dtype[np.float32]]:
|
|
331
|
-
return np.ma.MaskedArray(
|
|
287
|
+
return np.ma.MaskedArray(
|
|
332
288
|
_field_truncate(
|
|
333
289
|
field_transform(
|
|
334
290
|
data_array,
|
|
@@ -337,31 +293,18 @@ class Field(ParameterConfig):
|
|
|
337
293
|
self.truncation_min,
|
|
338
294
|
self.truncation_max,
|
|
339
295
|
),
|
|
340
|
-
self.mask,
|
|
341
296
|
fill_value=np.nan,
|
|
342
297
|
)
|
|
343
298
|
|
|
344
|
-
def
|
|
345
|
-
mask_path = experiment_path / "grid_mask.npy"
|
|
346
|
-
if not mask_path.exists():
|
|
347
|
-
mask, _ = read_mask(self.grid_file)
|
|
348
|
-
np.save(mask_path, mask)
|
|
349
|
-
self.mask_file = mask_path
|
|
350
|
-
|
|
351
|
-
@cached_property
|
|
352
|
-
def mask(self) -> Any:
|
|
353
|
-
if self.mask_file is None:
|
|
354
|
-
raise ValueError(
|
|
355
|
-
"In order to get Field.mask, Field.save_experiment_data has"
|
|
356
|
-
" to be called first"
|
|
357
|
-
)
|
|
358
|
-
return np.load(self.mask_file)
|
|
359
|
-
|
|
360
|
-
def load_parameter_graph(self) -> nx.Graph: # type: ignore
|
|
299
|
+
def load_parameter_graph(self) -> nx.Graph[int]:
|
|
361
300
|
parameter_graph = create_flattened_cube_graph(
|
|
362
301
|
px=self.ertbox_params.nx, py=self.ertbox_params.ny, pz=self.ertbox_params.nz
|
|
363
302
|
)
|
|
364
|
-
|
|
303
|
+
new_labels = {
|
|
304
|
+
old_label: new_label
|
|
305
|
+
for new_label, old_label in enumerate(parameter_graph.nodes())
|
|
306
|
+
}
|
|
307
|
+
return nx.relabel_nodes(parameter_graph, new_labels, copy=True)
|
|
365
308
|
|
|
366
309
|
@property
|
|
367
310
|
def nx(self) -> int:
|
|
@@ -375,8 +318,77 @@ class Field(ParameterConfig):
|
|
|
375
318
|
def nz(self) -> int:
|
|
376
319
|
return self.ertbox_params.nz
|
|
377
320
|
|
|
321
|
+
def calc_rho_for_2d_grid_layer(
|
|
322
|
+
self,
|
|
323
|
+
obs_xpos: npt.NDArray[np.float64],
|
|
324
|
+
obs_ypos: npt.NDArray[np.float64],
|
|
325
|
+
obs_main_range: npt.NDArray[np.float64],
|
|
326
|
+
obs_perp_range: npt.NDArray[np.float64],
|
|
327
|
+
obs_anisotropy_angle: npt.NDArray[np.float64],
|
|
328
|
+
right_handed_grid_indexing: bool = True,
|
|
329
|
+
) -> npt.NDArray[np.float64]:
|
|
330
|
+
"""Function to calculate scaling values to be used in the RHO matrix
|
|
331
|
+
for distance-based localization.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
obs_xpos: x-coordinates in global coordinates of observations
|
|
335
|
+
obs_ypos: y-coordinates in global coordinates of observations
|
|
336
|
+
obs_main_range: Size of influence ellipse main principal direction.
|
|
337
|
+
obs_perp_range: Size of influence ellipse second principal direction.
|
|
338
|
+
obs_anisotropy_angle: Rotation angle anticlock wise of main principal
|
|
339
|
+
direction of influence ellipse relative to global coordinate
|
|
340
|
+
system's x-axis.
|
|
341
|
+
right_handed_grid_indexing: When this is True the field parameters
|
|
342
|
+
grid index order counts J-index down from ny-1 to 0.
|
|
343
|
+
If the value is False, the grid index order is to count J index
|
|
344
|
+
from 0 to ny-1. As standard for 3D field parameters,
|
|
345
|
+
the grid index order follows the right_handed grid indexing.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Scaling values (elements of the RHO matrix) as a numpy array
|
|
349
|
+
of shape=(nx,ny,nobservations)
|
|
350
|
+
|
|
351
|
+
"""
|
|
352
|
+
# Can only be used if ertbox coordinate system is defined
|
|
353
|
+
assert self.ertbox_params.xinc is not None, (
|
|
354
|
+
"Parameter for grid resolution must be defined"
|
|
355
|
+
)
|
|
356
|
+
assert self.ertbox_params.yinc is not None, (
|
|
357
|
+
"Parameter for grid resolution must be defined"
|
|
358
|
+
)
|
|
359
|
+
assert self.ertbox_params.origin is not None, (
|
|
360
|
+
"Parameter for grid origin must be defined"
|
|
361
|
+
)
|
|
362
|
+
assert self.ertbox_params.rotation_angle is not None, (
|
|
363
|
+
"Parameter for grid rotation must be defined"
|
|
364
|
+
)
|
|
365
|
+
# Transform positions of observations into local coordinates
|
|
366
|
+
xpos, ypos = transform_positions_to_local_field_coordinates(
|
|
367
|
+
self.ertbox_params.origin,
|
|
368
|
+
self.ertbox_params.rotation_angle,
|
|
369
|
+
obs_xpos,
|
|
370
|
+
obs_ypos,
|
|
371
|
+
)
|
|
372
|
+
# Transform localization ellipse orientation to local coordinates
|
|
373
|
+
ellipse_rotation = transform_local_ellipse_angle_to_local_coords(
|
|
374
|
+
self.ertbox_params.rotation_angle, obs_anisotropy_angle
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
return calc_rho_for_2d_grid_layer(
|
|
378
|
+
self.ertbox_params.nx,
|
|
379
|
+
self.ertbox_params.ny,
|
|
380
|
+
self.ertbox_params.xinc,
|
|
381
|
+
self.ertbox_params.yinc,
|
|
382
|
+
xpos,
|
|
383
|
+
ypos,
|
|
384
|
+
obs_main_range,
|
|
385
|
+
obs_perp_range,
|
|
386
|
+
ellipse_rotation,
|
|
387
|
+
right_handed_grid_indexing=right_handed_grid_indexing,
|
|
388
|
+
)
|
|
389
|
+
|
|
378
390
|
|
|
379
|
-
TRANSFORM_FUNCTIONS = {
|
|
391
|
+
TRANSFORM_FUNCTIONS: Final[dict[str, Callable[[Any], Any]]] = {
|
|
380
392
|
"LN": np.log,
|
|
381
393
|
"LOG": np.log,
|
|
382
394
|
"LN0": lambda v: np.log(v + 0.000001),
|
|
@@ -407,17 +419,14 @@ def field_transform(
|
|
|
407
419
|
) -> npt.NDArray[np.float32] | xr.DataArray:
|
|
408
420
|
if transform_name is None:
|
|
409
421
|
return data
|
|
410
|
-
return TRANSFORM_FUNCTIONS[transform_name](data)
|
|
422
|
+
return TRANSFORM_FUNCTIONS[transform_name](data)
|
|
411
423
|
|
|
412
424
|
|
|
413
425
|
def _field_truncate(data: npt.ArrayLike, min_: float | None, max_: float | None) -> Any:
|
|
414
426
|
if min_ is not None and max_ is not None:
|
|
415
|
-
|
|
416
|
-
return vfunc(data)
|
|
427
|
+
return np.clip(data, min_, max_)
|
|
417
428
|
elif min_ is not None:
|
|
418
|
-
|
|
419
|
-
return vfunc(data)
|
|
429
|
+
return np.maximum(data, min_)
|
|
420
430
|
elif max_ is not None:
|
|
421
|
-
|
|
422
|
-
return vfunc(data)
|
|
431
|
+
return np.minimum(data, max_)
|
|
423
432
|
return data
|