ert 19.0.0rc1__py3-none-any.whl → 19.0.0rc3__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.
Files changed (48) hide show
  1. ert/__main__.py +63 -94
  2. ert/analysis/_es_update.py +14 -11
  3. ert/config/_create_observation_dataframes.py +262 -23
  4. ert/config/_observations.py +153 -181
  5. ert/config/_read_summary.py +5 -4
  6. ert/config/ert_config.py +56 -1
  7. ert/config/parsing/observations_parser.py +0 -6
  8. ert/config/rft_config.py +1 -1
  9. ert/dark_storage/compute/__init__.py +0 -0
  10. ert/dark_storage/compute/misfits.py +42 -0
  11. ert/dark_storage/endpoints/__init__.py +2 -0
  12. ert/dark_storage/endpoints/compute/__init__.py +0 -0
  13. ert/dark_storage/endpoints/compute/misfits.py +95 -0
  14. ert/dark_storage/endpoints/experiments.py +3 -0
  15. ert/dark_storage/json_schema/experiment.py +1 -0
  16. ert/gui/main_window.py +0 -2
  17. ert/gui/tools/manage_experiments/export_dialog.py +0 -4
  18. ert/gui/tools/manage_experiments/storage_info_widget.py +5 -1
  19. ert/gui/tools/plot/plot_api.py +10 -10
  20. ert/gui/tools/plot/plot_widget.py +0 -5
  21. ert/gui/tools/plot/plot_window.py +1 -1
  22. ert/services/__init__.py +3 -7
  23. ert/services/_base_service.py +387 -0
  24. ert/services/_storage_main.py +22 -59
  25. ert/services/ert_server.py +24 -186
  26. ert/services/webviz_ert_service.py +20 -0
  27. ert/shared/storage/command.py +38 -0
  28. ert/shared/storage/extraction.py +42 -0
  29. ert/shared/version.py +3 -3
  30. ert/storage/local_ensemble.py +95 -2
  31. ert/storage/local_experiment.py +16 -0
  32. ert/storage/local_storage.py +1 -3
  33. ert/utils/__init__.py +0 -20
  34. {ert-19.0.0rc1.dist-info → ert-19.0.0rc3.dist-info}/METADATA +2 -2
  35. {ert-19.0.0rc1.dist-info → ert-19.0.0rc3.dist-info}/RECORD +46 -41
  36. {ert-19.0.0rc1.dist-info → ert-19.0.0rc3.dist-info}/WHEEL +1 -1
  37. everest/bin/everest_script.py +5 -5
  38. everest/bin/kill_script.py +2 -2
  39. everest/bin/monitor_script.py +2 -2
  40. everest/bin/utils.py +4 -4
  41. everest/detached/everserver.py +6 -6
  42. everest/gui/main_window.py +2 -2
  43. everest/util/__init__.py +19 -1
  44. ert/config/observation_config_migrations.py +0 -793
  45. ert/storage/migration/to22.py +0 -18
  46. {ert-19.0.0rc1.dist-info → ert-19.0.0rc3.dist-info}/entry_points.txt +0 -0
  47. {ert-19.0.0rc1.dist-info → ert-19.0.0rc3.dist-info}/licenses/COPYING +0 -0
  48. {ert-19.0.0rc1.dist-info → ert-19.0.0rc3.dist-info}/top_level.txt +0 -0
ert/__main__.py CHANGED
@@ -8,12 +8,10 @@ import multiprocessing
8
8
  import os
9
9
  import re
10
10
  import resource
11
- import shutil
12
11
  import sys
13
12
  import warnings
14
13
  from argparse import ArgumentParser, ArgumentTypeError
15
14
  from collections.abc import Sequence
16
- from datetime import datetime
17
15
  from pathlib import Path
18
16
  from typing import Any
19
17
  from uuid import UUID
@@ -26,9 +24,6 @@ from _ert.threading import set_signal_handler
26
24
  from ert.base_model_context import use_runtime_plugins
27
25
  from ert.cli.main import ErtCliError, run_cli
28
26
  from ert.config import ConfigValidationError, ErtConfig, lint_file
29
- from ert.config.observation_config_migrations import (
30
- remove_refcase_and_time_map_dependence_from_obs_config,
31
- )
32
27
  from ert.logging import LOGGING_CONFIG
33
28
  from ert.mode_definitions import (
34
29
  ENIF_MODE,
@@ -41,9 +36,9 @@ from ert.mode_definitions import (
41
36
  from ert.namespace import Namespace
42
37
  from ert.plugins import ErtRuntimePlugins, get_site_plugins, setup_site_logging
43
38
  from ert.run_models.multiple_data_assimilation import MultipleDataAssimilationConfig
44
- from ert.services import ErtServer
45
- from ert.services._storage_main import add_parser_options as ert_api_add_parser_options
39
+ from ert.services import ErtServer, WebvizErt
46
40
  from ert.shared.status.utils import get_ert_memory_usage
41
+ from ert.shared.storage.command import add_parser_options as ert_api_add_parser_options
47
42
  from ert.storage import ErtStorageException, ErtStoragePermissionError
48
43
  from ert.trace import trace, tracer
49
44
  from ert.validation import (
@@ -58,66 +53,6 @@ from ert.validation import (
58
53
  logger = logging.getLogger(__name__)
59
54
 
60
55
 
61
- def run_convert_observations(
62
- args: Namespace, _: ErtRuntimePlugins | None = None
63
- ) -> None:
64
- changes = remove_refcase_and_time_map_dependence_from_obs_config(args.config)
65
-
66
- if changes is None or changes.is_empty():
67
- logger.info("convert_observations did not make any changes")
68
- print(
69
- "No observations dependent on TIME_MAP / REFCASE found, you can "
70
- "safely remove TIME_MAP / REFCASE and the "
71
- "corresponding files from ERT config."
72
- )
73
- return
74
-
75
- obs_config_to_edit_path = changes.obs_config_path + ".updated"
76
- print(
77
- f"Making copy of obs config "
78
- f"@ {changes.obs_config_path} -> {obs_config_to_edit_path}"
79
- )
80
-
81
- shutil.copy(changes.obs_config_path, obs_config_to_edit_path)
82
- print(f"Applying change to obs config @ {obs_config_to_edit_path}...")
83
- changes.apply_to_file(Path(obs_config_to_edit_path))
84
- convert_observations_trace = ""
85
- for history_change in changes.history_changes:
86
- convert_observations_trace += (
87
- f"History obs {history_change.source_observation.name} "
88
- f"-> {len(history_change.summary_obs_declarations)} summary observations\n"
89
- )
90
-
91
- for gen_obs_change in changes.general_obs_changes:
92
- convert_observations_trace += (
93
- f"General obs {gen_obs_change.source_observation.name}, changing "
94
- f"DATE {gen_obs_change.source_observation.date} "
95
- f"to RESTART={gen_obs_change.restart}\n"
96
- )
97
- for summary_change in changes.summary_obs_changes:
98
- convert_observations_trace += (
99
- f"Summary obs {summary_change.source_observation.name}, changing "
100
- f"RESTART {summary_change.source_observation.restart} "
101
- f"to DATE={summary_change.date}\n"
102
- )
103
-
104
- logger.info(f"convert_observations trace: \n {convert_observations_trace}")
105
- print(convert_observations_trace)
106
-
107
- os.rename(
108
- changes.obs_config_path,
109
- f"{changes.obs_config_path}-{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.old",
110
- )
111
- os.rename(obs_config_to_edit_path, changes.obs_config_path)
112
- msg = (
113
- f"Observation changes applied to {changes.obs_config_path}. The old "
114
- f"observations file is now at {changes.obs_config_path}.old and can be "
115
- f"safely deleted if the new one works."
116
- )
117
- print(msg)
118
- logger.info(msg)
119
-
120
-
121
56
  def run_ert_storage(args: Namespace, _: ErtRuntimePlugins | None = None) -> None:
122
57
  with ErtServer.start_server(
123
58
  verbose=True,
@@ -128,24 +63,67 @@ def run_ert_storage(args: Namespace, _: ErtRuntimePlugins | None = None) -> None
128
63
 
129
64
 
130
65
  def run_webviz_ert(args: Namespace, _: ErtRuntimePlugins | None = None) -> None:
66
+ try:
67
+ import webviz_ert # type: ignore # noqa
68
+ except ImportError as err:
69
+ raise ValueError(
70
+ "Running `ert vis` requires that webviz_ert is installed"
71
+ ) from err
72
+
73
+ kwargs: dict[str, Any] = {"verbose": args.verbose}
74
+ ert_config = ErtConfig.with_plugins(get_site_plugins()).from_file(args.config)
75
+
76
+ os.chdir(ert_config.config_path)
77
+ ens_path = ert_config.ens_path
78
+
79
+ # Changing current working directory means we need to
80
+ # only use the base name of the config file path
81
+ kwargs["ert_config"] = os.path.basename(args.config)
82
+ kwargs["project"] = os.path.abspath(ens_path)
83
+
131
84
  yellow = "\x1b[33m"
132
85
  green = "\x1b[32m"
133
86
  bold = "\x1b[1m"
134
87
  reset = "\x1b[0m"
135
88
 
136
- print(
137
- f"""
89
+ try:
90
+ with ErtServer.init_service(project=Path(ens_path).absolute()) as storage:
91
+ storage.wait_until_ready()
92
+ print(
93
+ f"""
138
94
  ---------------------------------------------------------------
139
95
 
140
- {yellow}{bold}Webviz-ERT is removed.
96
+ {yellow}{bold}Webviz-ERT is deprecated and will be removed in the near future{reset}
141
97
 
142
98
  {green}{bold}Plotting capabilities provided by Webviz-ERT are now available
143
99
  using the ERT plotter{reset}
144
100
 
101
+ ---------------------------------------------------------------
102
+
103
+ Starting up Webviz-ERT. This might take more than a minute.
104
+
145
105
  ---------------------------------------------------------------
146
106
  """
147
- )
148
- logger.info("Show Webviz-ert removal warning")
107
+ )
108
+ logger.info("Show Webviz-ert deprecation warning")
109
+ webviz_kwargs = {
110
+ "experimental_mode": args.experimental_mode,
111
+ "verbose": args.verbose,
112
+ "title": kwargs.get("ert_config", "ERT - Visualization tool"),
113
+ "project": kwargs.get("project", os.getcwd()),
114
+ }
115
+ with WebvizErt.start_server(**webviz_kwargs) as webviz_ert_server:
116
+ webviz_ert_server.wait()
117
+ except PermissionError as pe:
118
+ print(f"Error: {pe}", file=sys.stderr)
119
+ print(
120
+ "Cannot start or connect to storage service due to permission issues.",
121
+ file=sys.stderr,
122
+ )
123
+ print(
124
+ "This is most likely due to another user starting ERT using this storage",
125
+ file=sys.stderr,
126
+ )
149
127
 
150
128
 
151
129
  def strip_error_message_and_raise_exception(validated: ValidationStatus) -> None:
@@ -339,6 +317,19 @@ def get_ert_parser(parser: ArgumentParser | None = None) -> ArgumentParser:
339
317
  ert_api_parser.set_defaults(func=run_ert_storage)
340
318
  ert_api_add_parser_options(ert_api_parser)
341
319
 
320
+ ert_vis_parser = subparsers.add_parser(
321
+ "vis",
322
+ description="Launch webviz-driven visualization tool.",
323
+ )
324
+ ert_vis_parser.set_defaults(func=run_webviz_ert)
325
+ ert_vis_parser.add_argument("--name", "-n", type=str, default="Webviz-ERT")
326
+ ert_vis_parser.add_argument(
327
+ "--experimental-mode",
328
+ action="store_true",
329
+ help="Feature flag for enabling experimental plugins",
330
+ )
331
+ ert_api_add_parser_options(ert_vis_parser) # ert vis shares args with ert api
332
+
342
333
  # test_run_parser
343
334
  test_run_description = f"Run '{TEST_RUN_MODE}' in cli"
344
335
  test_run_parser = subparsers.add_parser(
@@ -573,28 +564,6 @@ def get_ert_parser(parser: ArgumentParser | None = None) -> ArgumentParser:
573
564
  "--ensemble", help="Which ensemble to use", default=None
574
565
  )
575
566
 
576
- # convert_observations_parser
577
- convert_obs_parser = subparsers.add_parser(
578
- "convert_observations",
579
- help=(
580
- "Convert HISTORY_OBSERVATION to SUMMARY_OBSERVATION and "
581
- "remove REFCASE and TIME_MAP from ERT config."
582
- ),
583
- description=(
584
- "Convert HISTORY_OBSERVATION to SUMMARY_OBSERVATION, "
585
- "and embed REFCASE and TIME_MAP into observations"
586
- ),
587
- )
588
- convert_obs_parser.set_defaults(func=run_convert_observations)
589
- convert_obs_parser.add_argument(
590
- "config",
591
- type=valid_file,
592
- help="Path to ERT config file",
593
- )
594
- convert_obs_parser.add_argument(
595
- "--verbose", action="store_true", help="Show verbose output.", default=False
596
- )
597
-
598
567
  # Common arguments/defaults for all non-gui modes
599
568
  for cli_parser in [
600
569
  test_run_parser,
@@ -323,13 +323,16 @@ def analysis_ES(
323
323
  logger.info(log_msg)
324
324
  progress_callback(AnalysisStatusEvent(msg=log_msg))
325
325
 
326
- if (param_count := (~non_zero_variance_mask).sum()) > 0:
327
- log_msg = (
328
- f"There are {param_count} parameters with 0 variance "
329
- f"that will not be updated."
330
- )
331
- logger.info(log_msg)
332
- progress_callback(AnalysisStatusEvent(msg=log_msg))
326
+ log_msg = f"There are {num_obs} responses and {ensemble_size} realizations."
327
+ logger.info(log_msg)
328
+ progress_callback(AnalysisStatusEvent(msg=log_msg))
329
+
330
+ log_msg = (
331
+ f"There are {(~non_zero_variance_mask).sum()} parameters with 0 variance "
332
+ f"that will not be updated."
333
+ )
334
+ logger.info(log_msg)
335
+ progress_callback(AnalysisStatusEvent(msg=log_msg))
333
336
 
334
337
  if module.localization:
335
338
  config_node = source_ensemble.experiment.parameter_configuration[
@@ -379,16 +382,16 @@ def analysis_ES(
379
382
  )
380
383
 
381
384
  else:
382
- log_msg = f"There are {num_obs} responses and {ensemble_size} realizations."
383
- logger.info(log_msg)
384
- progress_callback(AnalysisStatusEvent(msg=log_msg))
385
-
386
385
  # In-place multiplication is not yet supported, therefore avoiding @=
387
386
  param_ensemble_array[non_zero_variance_mask] = param_ensemble_array[ # noqa: PLR6104
388
387
  non_zero_variance_mask
389
388
  ] @ T.astype(param_ensemble_array.dtype)
390
389
 
390
+ log_msg = f"Storing data for {param_group}.."
391
+ logger.info(log_msg)
392
+ progress_callback(AnalysisStatusEvent(msg=log_msg))
391
393
  start = time.time()
394
+
392
395
  target_ensemble.save_parameters_numpy(
393
396
  param_ensemble_array, param_group, iens_active_index
394
397
  )
@@ -2,19 +2,21 @@ from __future__ import annotations
2
2
 
3
3
  from collections import defaultdict
4
4
  from collections.abc import Sequence
5
- from datetime import datetime
6
- from typing import assert_never
5
+ from datetime import datetime, timedelta
6
+ from typing import TYPE_CHECKING, Any, assert_never
7
7
 
8
8
  import numpy as np
9
9
  import polars as pl
10
- from numpy import typing as npt
10
+ from resfo_utilities import history_key
11
11
 
12
12
  from ert.validation import rangestring_to_list
13
13
 
14
14
  from ._observations import (
15
15
  ErrorModes,
16
16
  GeneralObservation,
17
+ HistoryObservation,
17
18
  Observation,
19
+ ObservationDate,
18
20
  ObservationError,
19
21
  RFTObservation,
20
22
  SummaryObservation,
@@ -23,31 +25,59 @@ from .gen_data_config import GenDataConfig
23
25
  from .parsing import (
24
26
  ConfigWarning,
25
27
  ErrorInfo,
28
+ HistorySource,
26
29
  ObservationConfigError,
27
30
  )
31
+ from .refcase import Refcase
28
32
  from .rft_config import RFTConfig
29
33
 
34
+ if TYPE_CHECKING:
35
+ import numpy.typing as npt
36
+
37
+
38
+ DEFAULT_TIME_DELTA = timedelta(seconds=30)
30
39
  DEFAULT_LOCATION_RANGE_M = 3000
31
40
 
32
41
 
33
42
  def create_observation_dataframes(
34
43
  observations: Sequence[Observation],
44
+ refcase: Refcase | None,
35
45
  gen_data_config: GenDataConfig | None,
36
46
  rft_config: RFTConfig | None,
47
+ time_map: list[datetime] | None,
48
+ history: HistorySource,
37
49
  ) -> dict[str, pl.DataFrame]:
38
50
  if not observations:
39
51
  return {}
52
+ obs_time_list: list[datetime] = []
53
+ if refcase is not None:
54
+ obs_time_list = refcase.all_dates
55
+ elif time_map is not None:
56
+ obs_time_list = time_map
40
57
 
58
+ time_len = len(obs_time_list)
41
59
  config_errors: list[ErrorInfo] = []
42
60
  grouped: dict[str, list[pl.DataFrame]] = defaultdict(list)
43
61
  for obs in observations:
44
62
  try:
45
63
  match obs:
64
+ case HistoryObservation():
65
+ grouped["summary"].append(
66
+ _handle_history_observation(
67
+ refcase,
68
+ obs,
69
+ obs.name,
70
+ history,
71
+ time_len,
72
+ )
73
+ )
46
74
  case SummaryObservation():
47
75
  grouped["summary"].append(
48
76
  _handle_summary_observation(
49
77
  obs,
50
78
  obs.name,
79
+ obs_time_list,
80
+ bool(refcase),
51
81
  )
52
82
  )
53
83
  case GeneralObservation():
@@ -56,6 +86,8 @@ def create_observation_dataframes(
56
86
  gen_data_config,
57
87
  obs,
58
88
  obs.name,
89
+ obs_time_list,
90
+ bool(refcase),
59
91
  )
60
92
  )
61
93
  case RFTObservation():
@@ -105,6 +137,92 @@ def _handle_error_mode(
105
137
  assert_never(default)
106
138
 
107
139
 
140
+ def _handle_history_observation(
141
+ refcase: Refcase | None,
142
+ history_observation: HistoryObservation,
143
+ summary_key: str,
144
+ history_type: HistorySource,
145
+ time_len: int,
146
+ ) -> pl.DataFrame:
147
+ if refcase is None:
148
+ raise ObservationConfigError.with_context(
149
+ "REFCASE is required for HISTORY_OBSERVATION", summary_key
150
+ )
151
+
152
+ if history_type == HistorySource.REFCASE_HISTORY:
153
+ local_key = history_key(summary_key)
154
+ else:
155
+ local_key = summary_key
156
+ if local_key not in refcase.keys:
157
+ raise ObservationConfigError.with_context(
158
+ f"Key {local_key!r} is not present in refcase", summary_key
159
+ )
160
+ values = refcase.values[refcase.keys.index(local_key)]
161
+ std_dev = _handle_error_mode(values, history_observation)
162
+ for segment in history_observation.segments:
163
+ start = segment.start
164
+ stop = segment.stop
165
+ if start < 0:
166
+ ConfigWarning.warn(
167
+ f"Segment {segment.name} out of bounds."
168
+ " Truncating start of segment to 0.",
169
+ segment.name,
170
+ )
171
+ start = 0
172
+ if stop >= time_len:
173
+ ConfigWarning.warn(
174
+ f"Segment {segment.name} out of bounds. Truncating"
175
+ f" end of segment to {time_len - 1}.",
176
+ segment.name,
177
+ )
178
+ stop = time_len - 1
179
+ if start > stop:
180
+ ConfigWarning.warn(
181
+ f"Segment {segment.name} start after stop. Truncating"
182
+ f" end of segment to {start}.",
183
+ segment.name,
184
+ )
185
+ stop = start
186
+ if np.size(std_dev[start:stop]) == 0:
187
+ ConfigWarning.warn(
188
+ f"Segment {segment.name} does not"
189
+ " contain any time steps. The interval "
190
+ f"[{start}, {stop}) does not intersect with steps in the"
191
+ "time map.",
192
+ segment.name,
193
+ )
194
+ std_dev[start:stop] = _handle_error_mode(values[start:stop], segment)
195
+ dates_series = pl.Series(refcase.dates).dt.cast_time_unit("ms")
196
+ if (std_dev <= 0).any():
197
+ raise ObservationConfigError.with_context(
198
+ "Observation uncertainty must be strictly > 0", summary_key
199
+ ) from None
200
+
201
+ return pl.DataFrame(
202
+ {
203
+ "response_key": summary_key,
204
+ "observation_key": summary_key,
205
+ "time": dates_series,
206
+ "observations": pl.Series(values, dtype=pl.Float32),
207
+ "std": pl.Series(std_dev, dtype=pl.Float32),
208
+ }
209
+ )
210
+
211
+
212
+ def _get_time(
213
+ date_dict: ObservationDate, start_time: datetime, context: Any = None
214
+ ) -> tuple[datetime, str]:
215
+ if date_dict.date is not None:
216
+ return _parse_date(date_dict.date), f"DATE={date_dict.date}"
217
+ if date_dict.days is not None:
218
+ days = date_dict.days
219
+ return start_time + timedelta(days=days), f"DAYS={days}"
220
+ if date_dict.hours is not None:
221
+ hours = date_dict.hours
222
+ return start_time + timedelta(hours=hours), f"HOURS={hours}"
223
+ raise ObservationConfigError.with_context("Missing time specifier", context=context)
224
+
225
+
108
226
  def _parse_date(date_str: str) -> datetime:
109
227
  try:
110
228
  return datetime.fromisoformat(date_str)
@@ -125,50 +243,171 @@ def _parse_date(date_str: str) -> datetime:
125
243
  return date
126
244
 
127
245
 
246
+ def _find_nearest(
247
+ time_map: list[datetime],
248
+ time: datetime,
249
+ threshold: timedelta = DEFAULT_TIME_DELTA,
250
+ ) -> int:
251
+ nearest_index = -1
252
+ nearest_diff = None
253
+ for i, t in enumerate(time_map):
254
+ diff = abs(time - t)
255
+ if diff < threshold and (nearest_diff is None or nearest_diff > diff):
256
+ nearest_diff = diff
257
+ nearest_index = i
258
+ if nearest_diff is None:
259
+ raise IndexError(f"{time} is not in the time map")
260
+ return nearest_index
261
+
262
+
263
+ def _get_restart(
264
+ date_dict: ObservationDate,
265
+ obs_name: str,
266
+ time_map: list[datetime],
267
+ has_refcase: bool,
268
+ ) -> int:
269
+ if date_dict.restart is not None:
270
+ return date_dict.restart
271
+ if not time_map:
272
+ raise ObservationConfigError.with_context(
273
+ f"Missing REFCASE or TIME_MAP for observations: {obs_name}",
274
+ obs_name,
275
+ )
276
+
277
+ time, date_str = _get_time(date_dict, time_map[0], context=obs_name)
278
+
279
+ try:
280
+ return _find_nearest(time_map, time)
281
+ except IndexError as err:
282
+ raise ObservationConfigError.with_context(
283
+ f"Could not find {time} ({date_str}) in "
284
+ f"the time map for observations {obs_name}. "
285
+ + (
286
+ "The time map is set from the REFCASE keyword. Either "
287
+ "the REFCASE has an incorrect/missing date, or the observation "
288
+ "is given an incorrect date.)"
289
+ if has_refcase
290
+ else "(The time map is set from the TIME_MAP "
291
+ "keyword. Either the time map file has an "
292
+ "incorrect/missing date, or the observation is given an "
293
+ "incorrect date."
294
+ ),
295
+ obs_name,
296
+ ) from err
297
+
298
+
128
299
  def _has_localization(summary_dict: SummaryObservation) -> bool:
129
- return summary_dict.location_x is not None and summary_dict.location_y is not None
300
+ return any(
301
+ [
302
+ summary_dict.location_x is not None,
303
+ summary_dict.location_y is not None,
304
+ summary_dict.location_range is not None,
305
+ ]
306
+ )
307
+
308
+
309
+ def _validate_localization_values(summary_dict: SummaryObservation) -> None:
310
+ """The user must provide LOCATION_X and LOCATION_Y to use localization, while
311
+ unprovided LOCATION_RANGE should default to some value.
312
+
313
+ This method assumes the summary dict contains at least one LOCATION key.
314
+ """
315
+ if summary_dict.location_x is None or summary_dict.location_y is None:
316
+ loc_values = {
317
+ "LOCATION_X": summary_dict.location_x,
318
+ "LOCATION_Y": summary_dict.location_y,
319
+ "LOCATION_RANGE": summary_dict.location_range,
320
+ }
321
+ provided_loc_values = {k: v for k, v in loc_values.items() if v is not None}
322
+
323
+ provided_loc_values_string = ", ".join(
324
+ key.upper() for key in provided_loc_values
325
+ )
326
+ raise ObservationConfigError.with_context(
327
+ f"Localization for observation {summary_dict.name} is misconfigured.\n"
328
+ f"Only {provided_loc_values_string} were provided. To enable "
329
+ f"localization for an observation, ensure that both LOCATION_X and "
330
+ f"LOCATION_Y are defined - or remove LOCATION keywords to disable "
331
+ f"localization.",
332
+ summary_dict,
333
+ )
130
334
 
131
335
 
132
336
  def _handle_summary_observation(
133
337
  summary_dict: SummaryObservation,
134
338
  obs_key: str,
339
+ time_map: list[datetime],
340
+ has_refcase: bool,
135
341
  ) -> pl.DataFrame:
136
342
  summary_key = summary_dict.key
137
343
  value = summary_dict.value
138
344
  std_dev = float(_handle_error_mode(np.array(value), summary_dict))
139
- date = _parse_date(summary_dict.date)
140
345
 
346
+ if summary_dict.restart and not (time_map or has_refcase):
347
+ raise ObservationConfigError.with_context(
348
+ "Keyword 'RESTART' requires either TIME_MAP or REFCASE", context=obs_key
349
+ )
350
+
351
+ if summary_dict.date is not None and not time_map:
352
+ # We special case when the user has provided date in SUMMARY_OBS
353
+ # and not REFCASE or time_map so that we don't change current behavior.
354
+ date = _parse_date(summary_dict.date)
355
+ restart = None
356
+ else:
357
+ restart = _get_restart(summary_dict, obs_key, time_map, has_refcase)
358
+ date = time_map[restart]
359
+
360
+ if restart == 0:
361
+ raise ObservationConfigError.with_context(
362
+ "It is unfortunately not possible to use summary "
363
+ "observations from the start of the simulation. "
364
+ f"Problem with observation {obs_key}"
365
+ f"{' at ' + str(_get_time(summary_dict, time_map[0], obs_key)) if summary_dict.restart is None else ''}", # noqa: E501
366
+ obs_key,
367
+ )
141
368
  if std_dev <= 0:
142
369
  raise ObservationConfigError.with_context(
143
370
  "Observation uncertainty must be strictly > 0", summary_key
144
371
  ) from None
145
372
 
146
- location_range = (
147
- summary_dict.location_range or DEFAULT_LOCATION_RANGE_M
148
- if _has_localization(summary_dict)
149
- else None
150
- )
373
+ data_dict = {
374
+ "response_key": [summary_key],
375
+ "observation_key": [obs_key],
376
+ "time": pl.Series([date]).dt.cast_time_unit("ms"),
377
+ "observations": pl.Series([value], dtype=pl.Float32),
378
+ "std": pl.Series([std_dev], dtype=pl.Float32),
379
+ }
380
+
381
+ if _has_localization(summary_dict):
382
+ _validate_localization_values(summary_dict)
383
+ data_dict["location_x"] = summary_dict.location_x
384
+ data_dict["location_y"] = summary_dict.location_y
385
+ data_dict["location_range"] = (
386
+ summary_dict.location_range or DEFAULT_LOCATION_RANGE_M
387
+ )
151
388
 
152
- return pl.DataFrame(
153
- {
154
- "response_key": [summary_key],
155
- "observation_key": [obs_key],
156
- "time": pl.Series([date]).dt.cast_time_unit("ms"),
157
- "observations": pl.Series([value], dtype=pl.Float32),
158
- "std": pl.Series([std_dev], dtype=pl.Float32),
159
- "location_x": pl.Series([summary_dict.location_x], dtype=pl.Float32),
160
- "location_y": pl.Series([summary_dict.location_y], dtype=pl.Float32),
161
- "location_range": pl.Series([location_range], dtype=pl.Float32),
162
- }
163
- )
389
+ return pl.DataFrame(data_dict)
164
390
 
165
391
 
166
392
  def _handle_general_observation(
167
393
  gen_data_config: GenDataConfig | None,
168
394
  general_observation: GeneralObservation,
169
395
  obs_key: str,
396
+ time_map: list[datetime],
397
+ has_refcase: bool,
170
398
  ) -> pl.DataFrame:
171
399
  response_key = general_observation.data
400
+
401
+ if all(
402
+ getattr(general_observation, key) is None
403
+ for key in ["restart", "date", "days", "hours"]
404
+ ):
405
+ # The user has not provided RESTART or DATE, this is legal
406
+ # for GEN_DATA, so we default it to None
407
+ restart = None
408
+ else:
409
+ restart = _get_restart(general_observation, obs_key, time_map, has_refcase)
410
+
172
411
  if gen_data_config is None or response_key not in gen_data_config.keys:
173
412
  raise ObservationConfigError.with_context(
174
413
  f"Problem with GENERAL_OBSERVATION {obs_key}:"
@@ -176,7 +415,7 @@ def _handle_general_observation(
176
415
  response_key,
177
416
  )
178
417
  assert isinstance(gen_data_config, GenDataConfig)
179
- restart = general_observation.restart
418
+
180
419
  _, report_steps = gen_data_config.get_args_for_key(response_key)
181
420
 
182
421
  response_report_steps = [] if report_steps is None else report_steps