ert 17.0.0__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/__main__.py +28 -13
- ert/analysis/_enif_update.py +8 -4
- ert/analysis/_es_update.py +19 -6
- ert/analysis/_update_commons.py +16 -6
- ert/cli/main.py +13 -6
- ert/cli/monitor.py +7 -0
- ert/config/__init__.py +15 -6
- ert/config/_create_observation_dataframes.py +117 -20
- ert/config/_get_num_cpu.py +1 -1
- ert/config/_observations.py +91 -2
- ert/config/_read_summary.py +8 -6
- ert/config/design_matrix.py +51 -24
- ert/config/distribution.py +1 -1
- ert/config/ensemble_config.py +9 -17
- ert/config/ert_config.py +103 -19
- ert/config/everest_control.py +234 -0
- ert/config/{everest_objective_config.py → everest_response.py} +24 -15
- ert/config/field.py +96 -84
- ert/config/forward_model_step.py +122 -17
- ert/config/gen_data_config.py +5 -10
- ert/config/gen_kw_config.py +5 -35
- 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_keywords.py +2 -0
- ert/config/parsing/config_schema.py +23 -3
- ert/config/parsing/config_schema_deprecations.py +3 -14
- 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 +4 -5
- 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 +59 -16
- ert/config/workflow_fixtures.py +2 -1
- 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 +1 -1
- ert/dark_storage/compute/misfits.py +11 -7
- ert/dark_storage/endpoints/compute/misfits.py +6 -4
- ert/dark_storage/endpoints/experiment_server.py +12 -9
- ert/dark_storage/endpoints/experiments.py +2 -2
- ert/dark_storage/endpoints/observations.py +8 -6
- ert/dark_storage/endpoints/parameters.py +2 -18
- ert/dark_storage/endpoints/responses.py +24 -5
- 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 +212 -3
- 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/create_experiment_dialog.py +3 -1
- ert/gui/ertwidgets/ensembleselector.py +2 -2
- ert/gui/ertwidgets/models/__init__.py +2 -0
- ert/gui/ertwidgets/models/activerealizationsmodel.py +2 -1
- ert/gui/ertwidgets/models/path_model.py +1 -1
- ert/gui/ertwidgets/models/targetensemblemodel.py +2 -1
- ert/gui/ertwidgets/models/text_model.py +1 -1
- ert/gui/ertwidgets/pathchooser.py +0 -3
- ert/gui/ertwidgets/searchbox.py +13 -4
- ert/gui/{suggestor → ertwidgets/suggestor}/_suggestor_message.py +13 -4
- ert/gui/{suggestor → ertwidgets/suggestor}/suggestor.py +63 -30
- ert/gui/main.py +37 -8
- ert/gui/main_window.py +1 -7
- ert/gui/simulation/ensemble_experiment_panel.py +1 -1
- ert/gui/simulation/ensemble_information_filter_panel.py +1 -1
- ert/gui/simulation/ensemble_smoother_panel.py +1 -1
- ert/gui/simulation/evaluate_ensemble_panel.py +1 -1
- ert/gui/simulation/experiment_panel.py +16 -3
- ert/gui/simulation/manual_update_panel.py +31 -8
- ert/gui/simulation/multiple_data_assimilation_panel.py +12 -8
- ert/gui/simulation/run_dialog.py +27 -20
- ert/gui/simulation/single_test_run_panel.py +2 -2
- ert/gui/summarypanel.py +20 -1
- ert/gui/tools/load_results/load_results_panel.py +1 -1
- ert/gui/tools/manage_experiments/export_dialog.py +136 -0
- ert/gui/tools/manage_experiments/storage_info_widget.py +121 -16
- ert/gui/tools/manage_experiments/storage_widget.py +1 -2
- ert/gui/tools/plot/plot_api.py +37 -25
- ert/gui/tools/plot/plot_widget.py +10 -2
- ert/gui/tools/plot/plot_window.py +38 -18
- 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 +12 -3
- 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/mode_definitions.py +2 -0
- ert/plugins/__init__.py +0 -1
- 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 -2
- ert/plugins/hook_specifications/jobs.py +0 -9
- ert/plugins/plugin_manager.py +6 -33
- ert/resources/forward_models/run_reservoirsimulator.py +8 -3
- ert/resources/shell_scripts/delete_directory.py +2 -2
- ert/run_models/__init__.py +18 -5
- ert/run_models/_create_run_path.py +131 -37
- 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 +159 -46
- 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 +81 -21
- ert/run_models/multiple_data_assimilation.py +22 -11
- ert/run_models/run_model.py +64 -55
- 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/driver.py +37 -0
- ert/scheduler/event.py +3 -1
- ert/scheduler/job.py +23 -13
- ert/scheduler/lsf_driver.py +6 -2
- ert/scheduler/openpbs_driver.py +7 -1
- ert/scheduler/scheduler.py +5 -0
- ert/scheduler/slurm_driver.py +6 -2
- ert/services/__init__.py +2 -2
- ert/services/_base_service.py +37 -20
- 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 +2 -0
- ert/storage/local_ensemble.py +38 -12
- ert/storage/local_experiment.py +8 -16
- ert/storage/local_storage.py +68 -42
- ert/storage/migration/to11.py +1 -1
- 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/to8.py +4 -4
- ert/substitutions.py +12 -28
- ert/validation/active_range.py +7 -7
- ert/validation/rangestring.py +16 -16
- ert/workflow_runner.py +2 -1
- {ert-17.0.0.dist-info → ert-19.0.0rc2.dist-info}/METADATA +9 -8
- {ert-17.0.0.dist-info → ert-19.0.0rc2.dist-info}/RECORD +208 -205
- {ert-17.0.0.dist-info → ert-19.0.0rc2.dist-info}/WHEEL +1 -1
- everest/api/everest_data_api.py +14 -1
- everest/bin/config_branch_script.py +3 -6
- everest/bin/everconfigdump_script.py +1 -9
- everest/bin/everest_script.py +21 -11
- everest/bin/everlint_script.py +0 -2
- everest/bin/kill_script.py +2 -2
- everest/bin/monitor_script.py +2 -2
- everest/bin/utils.py +8 -4
- everest/bin/visualization_script.py +6 -14
- 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 +75 -42
- everest/config/forward_model_config.py +5 -3
- everest/config/install_data_config.py +7 -5
- everest/config/install_job_config.py +7 -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 -49
- everest/config/utils.py +25 -105
- everest/config/validation_utils.py +17 -11
- everest/config_file_loader.py +13 -4
- everest/detached/client.py +3 -3
- everest/detached/everserver.py +7 -8
- everest/everest_storage.py +6 -12
- everest/gui/everest_client.py +2 -3
- everest/gui/main_window.py +2 -2
- everest/optimizer/everest2ropt.py +59 -32
- everest/optimizer/opt_model_transforms.py +12 -13
- everest/optimizer/utils.py +0 -29
- everest/strings.py +0 -5
- ert/config/everest_constraints_config.py +0 -95
- ert/config/ext_param_config.py +0 -106
- 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 -69
- ert/gui/tools/export/exporter.py +0 -36
- ert/services/storage_service.py +0 -127
- everest/config/sampler_config.py +0 -103
- everest/simulator/__init__.py +0 -88
- everest/simulator/everest_to_ert.py +0 -51
- /ert/gui/{suggestor → ertwidgets/suggestor}/__init__.py +0 -0
- /ert/gui/{suggestor → ertwidgets/suggestor}/_colors.py +0 -0
- {ert-17.0.0.dist-info → ert-19.0.0rc2.dist-info}/entry_points.txt +0 -0
- {ert-17.0.0.dist-info → ert-19.0.0rc2.dist-info}/licenses/COPYING +0 -0
- {ert-17.0.0.dist-info → ert-19.0.0rc2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Literal, cast
|
|
4
|
+
|
|
5
|
+
import matplotlib.dates as mdates
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import polars as pl
|
|
9
|
+
import seaborn as sns
|
|
10
|
+
from matplotlib import pyplot as plt
|
|
11
|
+
from matplotlib.lines import Line2D
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import numpy.typing as npt
|
|
15
|
+
from matplotlib.figure import Figure
|
|
16
|
+
|
|
17
|
+
from ert.gui.tools.plot.plot_api import EnsembleObject, PlotApiKeyDefinition
|
|
18
|
+
from ert.gui.tools.plot.plottery import PlotContext
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MisfitsPlot:
|
|
22
|
+
"""
|
|
23
|
+
Visualize signed chi-squared misfits between simulated responses and observations.
|
|
24
|
+
|
|
25
|
+
Layout:
|
|
26
|
+
- X-axis: index for gen data, time for summary
|
|
27
|
+
- Y-axis: signed chi-squared misfits
|
|
28
|
+
- Glyphs: One boxplot per time step / gendata index
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self.dimensionality = 2
|
|
33
|
+
self.requires_observations = True
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def _fade_axis_ticklabels(ax: plt.Axes) -> None:
|
|
37
|
+
"""Apply the same alpha to all tick labels on an axis."""
|
|
38
|
+
for label in (*ax.get_xticklabels(), *ax.get_yticklabels()):
|
|
39
|
+
label.set_alpha(0.4)
|
|
40
|
+
|
|
41
|
+
ax.xaxis.label.set_alpha(0.4)
|
|
42
|
+
ax.yaxis.label.set_alpha(0.4)
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _draw_legend(
|
|
46
|
+
figure: Figure,
|
|
47
|
+
ensemble_colors: dict[tuple[str, str], str],
|
|
48
|
+
sorted_ensemble_keys: list[tuple[str, str]],
|
|
49
|
+
) -> None:
|
|
50
|
+
legend_handles = [
|
|
51
|
+
Line2D(
|
|
52
|
+
[],
|
|
53
|
+
[],
|
|
54
|
+
marker="s",
|
|
55
|
+
linestyle="None",
|
|
56
|
+
color=ensemble_colors[key],
|
|
57
|
+
label=key[0],
|
|
58
|
+
)
|
|
59
|
+
for key in sorted_ensemble_keys
|
|
60
|
+
]
|
|
61
|
+
figure.legend(
|
|
62
|
+
handles=legend_handles,
|
|
63
|
+
loc="upper center",
|
|
64
|
+
bbox_to_anchor=(0.5, 0.93),
|
|
65
|
+
ncol=min(len(legend_handles), 4),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _make_ensemble_colors(
|
|
70
|
+
sorted_ensemble_keys: list[tuple[str, str]],
|
|
71
|
+
) -> dict[tuple[str, str], str]:
|
|
72
|
+
"""
|
|
73
|
+
Build a consistent color map and figure-level legend
|
|
74
|
+
used by all misfit plots
|
|
75
|
+
"""
|
|
76
|
+
colors = plt.rcParams["axes.prop_cycle"].by_key()["color"]
|
|
77
|
+
return {
|
|
78
|
+
key: colors[i % len(colors)] for i, key in enumerate(sorted_ensemble_keys)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _compute_misfits_padded_minmax(
|
|
83
|
+
misfits_df: pl.DataFrame, relative_pad_y_axis: float
|
|
84
|
+
) -> tuple[float, float]:
|
|
85
|
+
y_min, y_max = (
|
|
86
|
+
cast(float, misfits_df["misfit"].min()),
|
|
87
|
+
cast(float, misfits_df["misfit"].max()),
|
|
88
|
+
)
|
|
89
|
+
abs_pad_y = (y_max - y_min) * relative_pad_y_axis
|
|
90
|
+
y_min -= abs_pad_y
|
|
91
|
+
y_max += abs_pad_y
|
|
92
|
+
|
|
93
|
+
return y_min, y_max
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _wide_pandas_to_long_polars_with_misfits(
|
|
97
|
+
ensemble_to_data_map: dict[tuple[str, str], pd.DataFrame],
|
|
98
|
+
observation_data: pd.DataFrame,
|
|
99
|
+
response_type: Literal["summary", "gen_data"],
|
|
100
|
+
) -> dict[tuple[str, str], pl.DataFrame]:
|
|
101
|
+
if response_type == "summary":
|
|
102
|
+
key_index_with_correct_dtype = pl.col("key_index").str.to_datetime(
|
|
103
|
+
strict=False
|
|
104
|
+
)
|
|
105
|
+
elif response_type == "gen_data":
|
|
106
|
+
key_index_with_correct_dtype = (
|
|
107
|
+
pl.col("key_index").cast(pl.Float32).cast(pl.UInt16)
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
raise ValueError(f"Unsupported response_type: {response_type}")
|
|
111
|
+
|
|
112
|
+
obs_df = (
|
|
113
|
+
pl.from_pandas(observation_data.T)
|
|
114
|
+
.rename({"OBS": "observation", "STD": "error"})
|
|
115
|
+
.with_columns(pl.col("key_index").cast(pl.String))
|
|
116
|
+
.with_columns(key_index_with_correct_dtype)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
ens_key: (
|
|
121
|
+
pl.from_pandas(df, include_index=True)
|
|
122
|
+
.unpivot(
|
|
123
|
+
index=df.index.name,
|
|
124
|
+
variable_name="key_index",
|
|
125
|
+
value_name="response",
|
|
126
|
+
)
|
|
127
|
+
.with_columns(key_index_with_correct_dtype)
|
|
128
|
+
.join(obs_df, on="key_index", how="inner")
|
|
129
|
+
.with_columns(
|
|
130
|
+
(pl.col("response") - pl.col("observation")).alias("residual")
|
|
131
|
+
)
|
|
132
|
+
.with_columns(
|
|
133
|
+
(
|
|
134
|
+
pl.col("residual").sign()
|
|
135
|
+
* (pl.col("residual") / pl.col("error")).pow(2)
|
|
136
|
+
).alias("misfit")
|
|
137
|
+
)
|
|
138
|
+
.drop("residual")
|
|
139
|
+
)
|
|
140
|
+
for ens_key, df in ensemble_to_data_map.items()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def plot(
|
|
144
|
+
self,
|
|
145
|
+
figure: Figure,
|
|
146
|
+
plot_context: PlotContext,
|
|
147
|
+
ensemble_to_data_map: dict[EnsembleObject, pd.DataFrame],
|
|
148
|
+
observation_data: pd.DataFrame,
|
|
149
|
+
std_dev_images: dict[str, npt.NDArray[np.float32]],
|
|
150
|
+
key_def: PlotApiKeyDefinition | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
assert key_def is not None
|
|
153
|
+
response_type = key_def.metadata["data_origin"]
|
|
154
|
+
data_with_misfits = self._wide_pandas_to_long_polars_with_misfits(
|
|
155
|
+
{(eo.name, eo.id): df for eo, df in ensemble_to_data_map.items()},
|
|
156
|
+
observation_data,
|
|
157
|
+
response_type,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if response_type == "summary":
|
|
161
|
+
self._plot_summary_misfits_boxplots(
|
|
162
|
+
figure,
|
|
163
|
+
data_with_misfits,
|
|
164
|
+
plot_context,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
elif response_type == "gen_data":
|
|
168
|
+
self._plot_gendata_misfits(
|
|
169
|
+
figure,
|
|
170
|
+
data_with_misfits,
|
|
171
|
+
plot_context,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def _plot_gendata_misfits(
|
|
175
|
+
self,
|
|
176
|
+
figure: Figure,
|
|
177
|
+
data_with_misfits: dict[tuple[str, str], pl.DataFrame],
|
|
178
|
+
plot_context: PlotContext,
|
|
179
|
+
) -> None:
|
|
180
|
+
# Only plot ensembles with data (i.e., they pertain to an experiment
|
|
181
|
+
# with observations, and there are responses towards those in the ens)
|
|
182
|
+
ensemble_to_misfit_df = {
|
|
183
|
+
k: v for k, v in data_with_misfits.items() if not v.is_empty()
|
|
184
|
+
}
|
|
185
|
+
sorted_ensemble_keys = sorted(ensemble_to_misfit_df.keys())
|
|
186
|
+
|
|
187
|
+
all_misfits = pl.concat(
|
|
188
|
+
[
|
|
189
|
+
df.with_columns(pl.lit(key).alias("ensemble_key"))
|
|
190
|
+
for key, df in ensemble_to_misfit_df.items()
|
|
191
|
+
]
|
|
192
|
+
).select(["realization", "key_index", "misfit", "ensemble_key"])
|
|
193
|
+
|
|
194
|
+
distinct_gendata_index = all_misfits["key_index"].unique().sort().to_list()
|
|
195
|
+
num_gendata_index = len(distinct_gendata_index)
|
|
196
|
+
|
|
197
|
+
color_map = self._make_ensemble_colors(sorted_ensemble_keys)
|
|
198
|
+
self._draw_legend(
|
|
199
|
+
figure=figure,
|
|
200
|
+
ensemble_colors=color_map,
|
|
201
|
+
sorted_ensemble_keys=sorted_ensemble_keys,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
y_min, y_max = self._compute_misfits_padded_minmax(all_misfits, 0.05)
|
|
205
|
+
|
|
206
|
+
# Create subplot grid (2 rows, N columns)
|
|
207
|
+
axes = figure.subplots(
|
|
208
|
+
nrows=2, ncols=num_gendata_index, sharex="col", sharey=True
|
|
209
|
+
)
|
|
210
|
+
axes = (
|
|
211
|
+
axes.reshape(2, num_gendata_index)
|
|
212
|
+
if num_gendata_index > 1
|
|
213
|
+
else np.array([[axes[0]], [axes[1]]])
|
|
214
|
+
)
|
|
215
|
+
axes_top, axes_bottom = axes[0, :], axes[1, :]
|
|
216
|
+
|
|
217
|
+
x_positions = np.arange(len(sorted_ensemble_keys))
|
|
218
|
+
box_width_relative = 0.6
|
|
219
|
+
|
|
220
|
+
for col_idx, key_index in enumerate(distinct_gendata_index):
|
|
221
|
+
ax_top, ax_bottom = axes_top[col_idx], axes_bottom[col_idx]
|
|
222
|
+
|
|
223
|
+
for ens_idx, ens_key in enumerate(sorted_ensemble_keys):
|
|
224
|
+
color = color_map.get(ens_key, "C0")
|
|
225
|
+
|
|
226
|
+
# Filter for the specific key and ensemble
|
|
227
|
+
mis_vals = all_misfits.filter(
|
|
228
|
+
(pl.col("key_index") == key_index)
|
|
229
|
+
& (pl.col("ensemble_key") == ens_key)
|
|
230
|
+
)["misfit"].to_numpy()
|
|
231
|
+
|
|
232
|
+
if mis_vals.size == 0:
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
x_center = x_positions[ens_idx]
|
|
236
|
+
|
|
237
|
+
# Top: Boxplot
|
|
238
|
+
ax_top.boxplot(
|
|
239
|
+
mis_vals,
|
|
240
|
+
positions=[x_center],
|
|
241
|
+
widths=box_width_relative,
|
|
242
|
+
patch_artist=True,
|
|
243
|
+
showfliers=False,
|
|
244
|
+
boxprops={"facecolor": color, "alpha": 0.35},
|
|
245
|
+
whiskerprops={"color": color, "alpha": 0.8},
|
|
246
|
+
capprops={"color": color, "alpha": 0.8},
|
|
247
|
+
medianprops={"color": color, "alpha": 0.8},
|
|
248
|
+
)
|
|
249
|
+
ax_top.plot(x_center, np.mean(mis_vals), "o", markersize=4, color=color)
|
|
250
|
+
|
|
251
|
+
# Bottom: Strip plot with dynamic marker size
|
|
252
|
+
num_points = len(mis_vals)
|
|
253
|
+
|
|
254
|
+
if num_points >= 200:
|
|
255
|
+
marker_size = 2
|
|
256
|
+
elif num_points >= 100:
|
|
257
|
+
marker_size = 3
|
|
258
|
+
else:
|
|
259
|
+
marker_size = 4
|
|
260
|
+
|
|
261
|
+
# Use stripplot as a robust alternative for dense data
|
|
262
|
+
sns.stripplot(
|
|
263
|
+
x=[x_center] * num_points, # Plot all points at the same x-center
|
|
264
|
+
y=mis_vals,
|
|
265
|
+
ax=ax_bottom,
|
|
266
|
+
color=color,
|
|
267
|
+
size=marker_size,
|
|
268
|
+
alpha=0.35,
|
|
269
|
+
jitter=True, # Explicitly add jitter
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Axis/spine styling
|
|
273
|
+
(n_rows, n_cols) = axes.shape
|
|
274
|
+
for r_idx in range(n_rows):
|
|
275
|
+
for c_idx in range(n_cols):
|
|
276
|
+
ax = axes[r_idx, c_idx]
|
|
277
|
+
is_first_col = c_idx == 0
|
|
278
|
+
is_bottom_row = r_idx == (n_rows - 1)
|
|
279
|
+
|
|
280
|
+
# Common styling
|
|
281
|
+
ax.set(ylim=(y_min, y_max))
|
|
282
|
+
ax.axhline(0.0, color="black", linewidth=0.5, alpha=0.5)
|
|
283
|
+
ax.grid(True, axis="y", linestyle=":", alpha=0.4)
|
|
284
|
+
self._fade_axis_ticklabels(ax)
|
|
285
|
+
|
|
286
|
+
# Spines
|
|
287
|
+
ax.spines["top"].set_visible(False)
|
|
288
|
+
ax.spines["right"].set_visible(False)
|
|
289
|
+
ax.spines["left"].set_visible(is_first_col)
|
|
290
|
+
ax.spines["bottom"].set_visible(is_bottom_row)
|
|
291
|
+
|
|
292
|
+
# Ticks
|
|
293
|
+
ax.set_xticks(x_positions, labels=[])
|
|
294
|
+
if not is_bottom_row:
|
|
295
|
+
ax.tick_params(axis="x", which="both", bottom=False)
|
|
296
|
+
if not is_first_col:
|
|
297
|
+
ax.tick_params(axis="y", which="both", left=False)
|
|
298
|
+
|
|
299
|
+
for ax, key_val in zip(axes_bottom, distinct_gendata_index, strict=True):
|
|
300
|
+
ax.set_xlabel(f"index={int(key_val)}", rotation=25, ha="right")
|
|
301
|
+
|
|
302
|
+
figure.suptitle(
|
|
303
|
+
f"{plot_context.key()} (Signed Chi-squared misfits per index)",
|
|
304
|
+
fontsize=14,
|
|
305
|
+
y=0.98,
|
|
306
|
+
)
|
|
307
|
+
figure.tight_layout(rect=(0.02, 0.02, 0.98, 0.88))
|
|
308
|
+
|
|
309
|
+
def _plot_summary_misfits_boxplots(
|
|
310
|
+
self,
|
|
311
|
+
figure: Figure,
|
|
312
|
+
data_with_misfits: dict[tuple[str, str], pl.DataFrame],
|
|
313
|
+
plot_context: PlotContext,
|
|
314
|
+
) -> None:
|
|
315
|
+
# Calculate shared y-axis limits from all misfits
|
|
316
|
+
all_misfits = pl.concat(
|
|
317
|
+
[df.select("misfit") for df in data_with_misfits.values()]
|
|
318
|
+
)
|
|
319
|
+
y_min, y_max = self._compute_misfits_padded_minmax(all_misfits, 0.05)
|
|
320
|
+
|
|
321
|
+
# Prepare ensemble colors and draw the legend
|
|
322
|
+
sorted_ensemble_keys = sorted(data_with_misfits.keys())
|
|
323
|
+
color_map = self._make_ensemble_colors(sorted_ensemble_keys)
|
|
324
|
+
self._draw_legend(
|
|
325
|
+
figure=figure,
|
|
326
|
+
ensemble_colors=color_map,
|
|
327
|
+
sorted_ensemble_keys=sorted_ensemble_keys,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Create all subplots at once with shared axes
|
|
331
|
+
n_ens = len(sorted_ensemble_keys)
|
|
332
|
+
axes = figure.subplots(nrows=n_ens, ncols=1, sharex=True, sharey=True)
|
|
333
|
+
axes = [axes] if n_ens == 1 else axes.tolist()
|
|
334
|
+
axes[0].set_ylim(y_min, y_max)
|
|
335
|
+
|
|
336
|
+
for ax, ensemble_key in zip(axes, sorted_ensemble_keys, strict=True):
|
|
337
|
+
df = data_with_misfits[ensemble_key]
|
|
338
|
+
if df.is_empty():
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
df = df.select(["key_index", "misfit"]).sort("key_index")
|
|
342
|
+
times_py = df["key_index"].unique(maintain_order=True).to_list()
|
|
343
|
+
positions = mdates.date2num(times_py) # type: ignore[no-untyped-call]
|
|
344
|
+
|
|
345
|
+
# Calculate dynamic box width based on time spacing
|
|
346
|
+
min_dt = np.min(np.diff(positions)) if len(positions) > 1 else 1.0
|
|
347
|
+
|
|
348
|
+
# multiplier to downsize outlier sizes etc
|
|
349
|
+
# (without this, outliers, whiskers etc are sized way
|
|
350
|
+
# out of proportion when there are many tiny boxplots)
|
|
351
|
+
many_boxes_factor = min(1, len(times_py) / 50)
|
|
352
|
+
box_width = min_dt * (0.7 - 0.3 * (1 - many_boxes_factor))
|
|
353
|
+
|
|
354
|
+
# One boxplot per time step
|
|
355
|
+
grouped_misfits = df.group_by("key_index", maintain_order=True).agg(
|
|
356
|
+
pl.col("misfit")
|
|
357
|
+
)
|
|
358
|
+
data_for_boxes = [
|
|
359
|
+
s.to_numpy() if s.len() > 0 else np.array([np.nan])
|
|
360
|
+
for s in grouped_misfits["misfit"]
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
color = color_map.get(ensemble_key)
|
|
364
|
+
|
|
365
|
+
# Draw the boxplots with inlined styles
|
|
366
|
+
bp = ax.boxplot(
|
|
367
|
+
data_for_boxes,
|
|
368
|
+
positions=positions,
|
|
369
|
+
widths=box_width,
|
|
370
|
+
whis=(5, 95),
|
|
371
|
+
showfliers=True,
|
|
372
|
+
manage_ticks=False,
|
|
373
|
+
patch_artist=True,
|
|
374
|
+
boxprops={
|
|
375
|
+
"facecolor": color,
|
|
376
|
+
"alpha": 0.18,
|
|
377
|
+
"edgecolor": color,
|
|
378
|
+
"linewidth": 0.7,
|
|
379
|
+
},
|
|
380
|
+
whiskerprops={"color": color, "alpha": 0.6, "linewidth": 0.7},
|
|
381
|
+
capprops={"color": color, "alpha": 0.6, "linewidth": 0.7},
|
|
382
|
+
medianprops={"color": color, "linewidth": 0.6, "alpha": 0.9},
|
|
383
|
+
flierprops={
|
|
384
|
+
"marker": "o",
|
|
385
|
+
"markersize": min(6, box_width * (0.4 - (0.2 * many_boxes_factor))),
|
|
386
|
+
"alpha": 0.7,
|
|
387
|
+
"markeredgewidth": 0.3 + (0.4 * (1 - many_boxes_factor)),
|
|
388
|
+
"markeredgecolor": color,
|
|
389
|
+
"markerfacecolor": "none",
|
|
390
|
+
},
|
|
391
|
+
)
|
|
392
|
+
plt.setp(bp["fliers"], zorder=1.5) # Put fliers behind other elements
|
|
393
|
+
|
|
394
|
+
# Add ensemble name text and a horizontal line at y=0
|
|
395
|
+
ax.text(
|
|
396
|
+
0.01,
|
|
397
|
+
0.85,
|
|
398
|
+
f"{ensemble_key[0]}",
|
|
399
|
+
transform=ax.transAxes,
|
|
400
|
+
ha="left",
|
|
401
|
+
va="center",
|
|
402
|
+
fontsize=9,
|
|
403
|
+
alpha=0.75,
|
|
404
|
+
)
|
|
405
|
+
ax.axhline(0.0, color="black", linewidth=0.8, alpha=0.4, zorder=0)
|
|
406
|
+
|
|
407
|
+
# Apply common styling to all axes
|
|
408
|
+
for ax in axes:
|
|
409
|
+
ax.spines["top"].set_visible(False)
|
|
410
|
+
ax.spines["right"].set_visible(False)
|
|
411
|
+
ax.spines["left"].set_visible(True)
|
|
412
|
+
ax.tick_params(axis="y", labelsize=10, width=0.5, length=3)
|
|
413
|
+
ax.grid(True, axis="y", linestyle=":", linewidth=0.5, alpha=0.75)
|
|
414
|
+
self._fade_axis_ticklabels(ax)
|
|
415
|
+
|
|
416
|
+
# Hide the x-axis on all but the last plot
|
|
417
|
+
for ax in axes[:-1]:
|
|
418
|
+
ax.spines["bottom"].set_visible(False)
|
|
419
|
+
ax.tick_params(axis="x", which="both", bottom=False, labelbottom=False)
|
|
420
|
+
|
|
421
|
+
# Style the x-axis only on the last plot
|
|
422
|
+
bottom_ax = axes[-1]
|
|
423
|
+
bottom_ax.spines["bottom"].set_visible(True)
|
|
424
|
+
bottom_ax.xaxis.set_major_locator(mdates.AutoDateLocator()) # type: ignore[no-untyped-call]
|
|
425
|
+
bottom_ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d")) # type: ignore[no-untyped-call]
|
|
426
|
+
|
|
427
|
+
bottom_ax.tick_params(axis="x", labelsize=8, width=0.5, length=3)
|
|
428
|
+
plt.setp(bottom_ax.get_xticklabels(), rotation=25, ha="right")
|
|
429
|
+
|
|
430
|
+
figure.suptitle(
|
|
431
|
+
f"{plot_context.key()} (Signed Chi-squared misfits over time)",
|
|
432
|
+
fontsize=14,
|
|
433
|
+
y=0.97,
|
|
434
|
+
alpha=0.9,
|
|
435
|
+
)
|
|
436
|
+
figure.tight_layout(rect=(0.02, 0.02, 0.98, 0.92))
|
|
@@ -27,7 +27,10 @@ def plotObservations(
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def _plotObservations(
|
|
30
|
-
axes: Axes,
|
|
30
|
+
axes: Axes,
|
|
31
|
+
plot_config: PlotConfig,
|
|
32
|
+
data: DataFrame,
|
|
33
|
+
value_column: str,
|
|
31
34
|
) -> None:
|
|
32
35
|
"""
|
|
33
36
|
Observations are always plotted on top. z-order set to 1000
|
|
@@ -46,11 +49,22 @@ def _plotObservations(
|
|
|
46
49
|
# line style set to 'off' toggles errorbar visibility
|
|
47
50
|
if not style.line_style:
|
|
48
51
|
style.width = 0
|
|
52
|
+
if plot_config.depth_y_axis:
|
|
53
|
+
errorbar_data = {
|
|
54
|
+
"x": data.loc["OBS"].to_numpy(),
|
|
55
|
+
"y": data.loc["key_index"].to_numpy(),
|
|
56
|
+
"xerr": data.loc["STD"].to_numpy(),
|
|
57
|
+
}
|
|
58
|
+
axes.yaxis.set_inverted(True)
|
|
59
|
+
else:
|
|
60
|
+
errorbar_data = {
|
|
61
|
+
"x": data.loc["key_index"].to_numpy(),
|
|
62
|
+
"y": data.loc["OBS"].to_numpy(),
|
|
63
|
+
"yerr": data.loc["STD"].to_numpy(),
|
|
64
|
+
}
|
|
49
65
|
|
|
50
66
|
axes.errorbar(
|
|
51
|
-
|
|
52
|
-
y=data.loc["OBS"].values,
|
|
53
|
-
yerr=data.loc["STD"].values,
|
|
67
|
+
**errorbar_data,
|
|
54
68
|
fmt=style.line_style,
|
|
55
69
|
ecolor=style.color,
|
|
56
70
|
color=style.color,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import TYPE_CHECKING
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
4
|
|
|
5
5
|
import numpy as np
|
|
6
6
|
from matplotlib.lines import Line2D
|
|
@@ -18,12 +18,13 @@ if TYPE_CHECKING:
|
|
|
18
18
|
from matplotlib.axes import Axes
|
|
19
19
|
from matplotlib.figure import Figure
|
|
20
20
|
|
|
21
|
-
from ert.gui.tools.plot.plot_api import EnsembleObject
|
|
21
|
+
from ert.gui.tools.plot.plot_api import EnsembleObject, PlotApiKeyDefinition
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
class StatisticsPlot:
|
|
25
25
|
def __init__(self) -> None:
|
|
26
26
|
self.dimensionality = 2
|
|
27
|
+
self.requires_observations = False
|
|
27
28
|
|
|
28
29
|
@staticmethod
|
|
29
30
|
def plot(
|
|
@@ -32,6 +33,7 @@ class StatisticsPlot:
|
|
|
32
33
|
ensemble_to_data_map: dict[EnsembleObject, DataFrame],
|
|
33
34
|
observation_data: DataFrame,
|
|
34
35
|
std_dev_images: dict[str, npt.NDArray[np.float32]],
|
|
36
|
+
key_def: PlotApiKeyDefinition | None = None,
|
|
35
37
|
) -> None:
|
|
36
38
|
config = plot_context.plotConfig()
|
|
37
39
|
axes = figure.add_subplot(111)
|
|
@@ -126,10 +128,18 @@ def _plotPercentiles(
|
|
|
126
128
|
axes: Axes, plot_config: PlotConfig, data: DataFrame, ensemble_label: str
|
|
127
129
|
) -> None:
|
|
128
130
|
style = plot_config.getStatisticsStyle("mean")
|
|
131
|
+
if plot_config.depth_y_axis:
|
|
132
|
+
axes.yaxis.set_inverted(True)
|
|
133
|
+
|
|
134
|
+
def xy_order(x: Any, y: Any) -> tuple[Any, Any]:
|
|
135
|
+
if plot_config.depth_y_axis:
|
|
136
|
+
return (y, x)
|
|
137
|
+
else:
|
|
138
|
+
return (x, y)
|
|
139
|
+
|
|
129
140
|
if style.isVisible():
|
|
130
141
|
axes.plot(
|
|
131
|
-
data.index.values,
|
|
132
|
-
data["Mean"].values,
|
|
142
|
+
*xy_order(data.index.values, data["Mean"].values),
|
|
133
143
|
alpha=style.alpha,
|
|
134
144
|
linestyle=style.line_style,
|
|
135
145
|
color=style.color,
|
|
@@ -141,8 +151,7 @@ def _plotPercentiles(
|
|
|
141
151
|
style = plot_config.getStatisticsStyle("p50")
|
|
142
152
|
if style.isVisible():
|
|
143
153
|
axes.plot(
|
|
144
|
-
data.index.values,
|
|
145
|
-
data["p50"].values,
|
|
154
|
+
*xy_order(data.index.values, data["p50"].values),
|
|
146
155
|
alpha=style.alpha,
|
|
147
156
|
linestyle=style.line_style,
|
|
148
157
|
color=style.color,
|
|
@@ -153,7 +162,13 @@ def _plotPercentiles(
|
|
|
153
162
|
|
|
154
163
|
style = plot_config.getStatisticsStyle("std")
|
|
155
164
|
_plotPercentile(
|
|
156
|
-
axes,
|
|
165
|
+
axes,
|
|
166
|
+
style,
|
|
167
|
+
data.index.values,
|
|
168
|
+
data["std+"].values,
|
|
169
|
+
data["std-"].values,
|
|
170
|
+
0.5,
|
|
171
|
+
plot_config.depth_y_axis,
|
|
157
172
|
)
|
|
158
173
|
|
|
159
174
|
style = plot_config.getStatisticsStyle("min-max")
|
|
@@ -164,16 +179,29 @@ def _plotPercentiles(
|
|
|
164
179
|
data["Maximum"].values,
|
|
165
180
|
data["Minimum"].values,
|
|
166
181
|
0.5,
|
|
182
|
+
plot_config.depth_y_axis,
|
|
167
183
|
)
|
|
168
184
|
|
|
169
185
|
style = plot_config.getStatisticsStyle("p10-p90")
|
|
170
186
|
_plotPercentile(
|
|
171
|
-
axes,
|
|
187
|
+
axes,
|
|
188
|
+
style,
|
|
189
|
+
data.index.values,
|
|
190
|
+
data["p90"].values,
|
|
191
|
+
data["p10"].values,
|
|
192
|
+
0.5,
|
|
193
|
+
plot_config.depth_y_axis,
|
|
172
194
|
)
|
|
173
195
|
|
|
174
196
|
style = plot_config.getStatisticsStyle("p33-p67")
|
|
175
197
|
_plotPercentile(
|
|
176
|
-
axes,
|
|
198
|
+
axes,
|
|
199
|
+
style,
|
|
200
|
+
data.index.values,
|
|
201
|
+
data["p67"].values,
|
|
202
|
+
data["p33"].values,
|
|
203
|
+
0.5,
|
|
204
|
+
plot_config.depth_y_axis,
|
|
177
205
|
)
|
|
178
206
|
|
|
179
207
|
|
|
@@ -184,24 +212,39 @@ def _plotPercentile(
|
|
|
184
212
|
top_line_data: npt.ArrayLike,
|
|
185
213
|
bottom_line_data: npt.ArrayLike,
|
|
186
214
|
alpha_multiplier: float,
|
|
215
|
+
inverted: bool = False,
|
|
187
216
|
) -> None:
|
|
188
217
|
alpha = style.alpha
|
|
189
218
|
line_style = style.line_style
|
|
190
219
|
color = style.color
|
|
191
220
|
marker = style.marker
|
|
192
221
|
|
|
222
|
+
def xy_order(x: Any, y: Any) -> tuple[Any, Any]:
|
|
223
|
+
if inverted:
|
|
224
|
+
return (y, x)
|
|
225
|
+
else:
|
|
226
|
+
return (x, y)
|
|
227
|
+
|
|
193
228
|
if line_style == "#":
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
229
|
+
if inverted:
|
|
230
|
+
axes.fill_betweenx(
|
|
231
|
+
index_values,
|
|
232
|
+
bottom_line_data,
|
|
233
|
+
top_line_data,
|
|
234
|
+
alpha=alpha * alpha_multiplier,
|
|
235
|
+
color=color,
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
axes.fill_between(
|
|
239
|
+
index_values,
|
|
240
|
+
bottom_line_data,
|
|
241
|
+
top_line_data,
|
|
242
|
+
alpha=alpha * alpha_multiplier,
|
|
243
|
+
color=color,
|
|
244
|
+
)
|
|
201
245
|
elif style.isVisible():
|
|
202
246
|
axes.plot(
|
|
203
|
-
index_values,
|
|
204
|
-
bottom_line_data,
|
|
247
|
+
*xy_order(index_values, bottom_line_data),
|
|
205
248
|
alpha=alpha,
|
|
206
249
|
linestyle=line_style,
|
|
207
250
|
color=color,
|
|
@@ -210,8 +253,7 @@ def _plotPercentile(
|
|
|
210
253
|
markersize=style.size,
|
|
211
254
|
)
|
|
212
255
|
axes.plot(
|
|
213
|
-
index_values,
|
|
214
|
-
top_line_data,
|
|
256
|
+
*xy_order(index_values, top_line_data),
|
|
215
257
|
alpha=alpha,
|
|
216
258
|
linestyle=line_style,
|
|
217
259
|
color=color,
|
|
@@ -10,13 +10,14 @@ from matplotlib.figure import Figure
|
|
|
10
10
|
from mpl_toolkits.axes_grid1 import make_axes_locatable
|
|
11
11
|
|
|
12
12
|
if TYPE_CHECKING:
|
|
13
|
-
from ert.gui.tools.plot.plot_api import EnsembleObject
|
|
13
|
+
from ert.gui.tools.plot.plot_api import EnsembleObject, PlotApiKeyDefinition
|
|
14
14
|
from ert.gui.tools.plot.plottery import PlotContext
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class StdDevPlot:
|
|
18
18
|
def __init__(self) -> None:
|
|
19
19
|
self.dimensionality = 3
|
|
20
|
+
self.requires_observations = False
|
|
20
21
|
|
|
21
22
|
def plot(
|
|
22
23
|
self,
|
|
@@ -25,6 +26,7 @@ class StdDevPlot:
|
|
|
25
26
|
ensemble_to_data_map: dict[EnsembleObject, pd.DataFrame],
|
|
26
27
|
observation_data: pd.DataFrame,
|
|
27
28
|
std_dev_data: dict[str, npt.NDArray[np.float32]],
|
|
29
|
+
key_def: PlotApiKeyDefinition | None = None,
|
|
28
30
|
) -> None:
|
|
29
31
|
ensemble_count = len(plot_context.ensembles())
|
|
30
32
|
layer = plot_context.layer
|
ert/mode_definitions.py
CHANGED
|
@@ -6,6 +6,7 @@ TEST_RUN_MODE = "test_run"
|
|
|
6
6
|
WORKFLOW_MODE = "workflow"
|
|
7
7
|
EVALUATE_ENSEMBLE_MODE = "evaluate_ensemble"
|
|
8
8
|
MANUAL_UPDATE_MODE = "manual_update"
|
|
9
|
+
MANUAL_ENIF_UPDATE_MODE = "manual_enif_update"
|
|
9
10
|
|
|
10
11
|
MODULE_MODE = {
|
|
11
12
|
"EnsembleInformationFilter": ENIF_MODE,
|
|
@@ -15,4 +16,5 @@ MODULE_MODE = {
|
|
|
15
16
|
"SingleTestRun": TEST_RUN_MODE,
|
|
16
17
|
"EvaluateEnsemble": EVALUATE_ENSEMBLE_MODE,
|
|
17
18
|
"ManualUpdate": MANUAL_UPDATE_MODE,
|
|
19
|
+
"ManualEnIFUpdate": MANUAL_ENIF_UPDATE_MODE,
|
|
18
20
|
}
|