gammasimtools 0.26.0__py3-none-any.whl → 0.27.0__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 (70) hide show
  1. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/METADATA +5 -1
  2. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/RECORD +70 -66
  3. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/WHEEL +1 -1
  4. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/entry_points.txt +1 -1
  5. simtools/_version.py +2 -2
  6. simtools/applications/convert_geo_coordinates_of_array_elements.py +2 -1
  7. simtools/applications/db_get_array_layouts_from_db.py +1 -1
  8. simtools/applications/{calculate_incident_angles.py → derive_incident_angle.py} +16 -16
  9. simtools/applications/derive_mirror_rnda.py +111 -177
  10. simtools/applications/generate_corsika_histograms.py +38 -1
  11. simtools/applications/generate_regular_arrays.py +73 -36
  12. simtools/applications/simulate_flasher.py +3 -13
  13. simtools/applications/simulate_illuminator.py +2 -10
  14. simtools/applications/simulate_pedestals.py +1 -1
  15. simtools/applications/simulate_prod.py +8 -7
  16. simtools/applications/submit_data_from_external.py +2 -1
  17. simtools/applications/validate_camera_efficiency.py +28 -27
  18. simtools/applications/validate_cumulative_psf.py +1 -3
  19. simtools/applications/validate_optics.py +2 -1
  20. simtools/atmosphere.py +83 -0
  21. simtools/camera/camera_efficiency.py +171 -48
  22. simtools/camera/single_photon_electron_spectrum.py +6 -6
  23. simtools/configuration/commandline_parser.py +47 -9
  24. simtools/constants.py +5 -0
  25. simtools/corsika/corsika_config.py +88 -185
  26. simtools/corsika/corsika_histograms.py +246 -69
  27. simtools/data_model/model_data_writer.py +46 -49
  28. simtools/data_model/schema.py +2 -0
  29. simtools/db/db_handler.py +4 -2
  30. simtools/db/mongo_db.py +2 -2
  31. simtools/io/ascii_handler.py +51 -3
  32. simtools/io/io_handler.py +23 -12
  33. simtools/job_execution/job_manager.py +154 -79
  34. simtools/job_execution/process_pool.py +137 -0
  35. simtools/layout/array_layout.py +0 -1
  36. simtools/layout/array_layout_utils.py +143 -21
  37. simtools/model/array_model.py +22 -50
  38. simtools/model/calibration_model.py +4 -4
  39. simtools/model/model_parameter.py +123 -73
  40. simtools/model/model_utils.py +40 -1
  41. simtools/model/site_model.py +4 -4
  42. simtools/model/telescope_model.py +4 -5
  43. simtools/ray_tracing/incident_angles.py +87 -6
  44. simtools/ray_tracing/mirror_panel_psf.py +337 -217
  45. simtools/ray_tracing/psf_analysis.py +57 -42
  46. simtools/ray_tracing/psf_parameter_optimisation.py +3 -2
  47. simtools/ray_tracing/ray_tracing.py +37 -10
  48. simtools/runners/corsika_runner.py +52 -191
  49. simtools/runners/corsika_simtel_runner.py +74 -100
  50. simtools/runners/runner_services.py +214 -213
  51. simtools/runners/simtel_runner.py +27 -155
  52. simtools/runners/simtools_runner.py +9 -69
  53. simtools/schemas/application_workflow.metaschema.yml +8 -0
  54. simtools/settings.py +19 -0
  55. simtools/simtel/simtel_config_writer.py +0 -55
  56. simtools/simtel/simtel_seeds.py +184 -0
  57. simtools/simtel/simulator_array.py +115 -103
  58. simtools/simtel/simulator_camera_efficiency.py +66 -42
  59. simtools/simtel/simulator_light_emission.py +110 -123
  60. simtools/simtel/simulator_ray_tracing.py +78 -63
  61. simtools/simulator.py +135 -346
  62. simtools/testing/sim_telarray_metadata.py +13 -11
  63. simtools/testing/validate_output.py +87 -19
  64. simtools/utils/general.py +6 -17
  65. simtools/utils/random.py +36 -0
  66. simtools/visualization/plot_corsika_histograms.py +2 -0
  67. simtools/visualization/plot_incident_angles.py +48 -1
  68. simtools/visualization/plot_psf.py +160 -18
  69. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/licenses/LICENSE +0 -0
  70. {gammasimtools-0.26.0.dist-info → gammasimtools-0.27.0.dist-info}/top_level.txt +0 -0
@@ -1,130 +1,168 @@
1
1
  """Base service methods for simulation runners."""
2
2
 
3
3
  import logging
4
- from pathlib import Path
5
4
 
5
+ from simtools.corsika.corsika_config import CorsikaConfig
6
6
  from simtools.io import io_handler
7
7
 
8
8
  _logger = logging.getLogger(__name__)
9
9
 
10
10
 
11
+ FILES_AND_PATHS = {
12
+ # CORSIKA
13
+ "corsika_input": {
14
+ "suffix": ".corsika.input",
15
+ "sub_dir_type": "run_number",
16
+ },
17
+ "corsika_output": {
18
+ "suffix": ".corsika.zst",
19
+ "sub_dir_type": "run_number",
20
+ },
21
+ "corsika_log": {
22
+ "suffix": ".corsika.log.gz",
23
+ "sub_dir_type": "run_number",
24
+ },
25
+ # Generic iact output
26
+ "iact_output": {
27
+ "suffix": ".iact.gz",
28
+ "sub_dir_type": "run_number",
29
+ },
30
+ # sim_telarray
31
+ "sim_telarray_output": {
32
+ "suffix": ".simtel.zst",
33
+ "sub_dir_type": "run_number",
34
+ },
35
+ "sim_telarray_histogram": {
36
+ "suffix": ".hdata.zst",
37
+ "sub_dir_type": "run_number",
38
+ },
39
+ "sim_telarray_log": {
40
+ "suffix": ".simtel.log.gz",
41
+ "sub_dir_type": "run_number",
42
+ },
43
+ "sim_telarray_event_data": {
44
+ "suffix": ".reduced_event_data.hdf5",
45
+ "sub_dir_type": "run_number",
46
+ },
47
+ # multipipe
48
+ "multi_pipe_config": {
49
+ "suffix": ".multi_pipe.cfg",
50
+ "sub_dir_type": "run_number",
51
+ },
52
+ "multi_pipe_script": {
53
+ "suffix": ".multi_pipe.sh",
54
+ "sub_dir_type": "run_number",
55
+ },
56
+ # job submission
57
+ "sub_out": {
58
+ "suffix": ".out",
59
+ "sub_dir_type": "sub",
60
+ },
61
+ "sub_err": {
62
+ "suffix": ".err",
63
+ "sub_dir_type": "sub",
64
+ },
65
+ "sub_log": {
66
+ "suffix": ".log",
67
+ "sub_dir_type": "sub",
68
+ },
69
+ "sub_script": {
70
+ "suffix": ".sh",
71
+ "sub_dir_type": "sub",
72
+ },
73
+ }
74
+
75
+
76
+ def validate_corsika_run_number(run_number):
77
+ """
78
+ Validate run number and return it.
79
+
80
+ Parameters
81
+ ----------
82
+ run_number: int
83
+ Run number.
84
+
85
+ Returns
86
+ -------
87
+ int
88
+ Run number.
89
+
90
+ Raises
91
+ ------
92
+ ValueError
93
+ If run_number is not a valid value (< 1 or > 999999).
94
+ """
95
+ if not float(run_number).is_integer() or run_number < 1 or run_number > 999999:
96
+ raise ValueError(
97
+ f"Invalid type of run number ({run_number}) - it must be an uint < 1000000."
98
+ )
99
+ return run_number
100
+
101
+
11
102
  class RunnerServices:
12
103
  """
13
- Base services for simulation runners.
104
+ Base service methods for simulation runners.
105
+
106
+ Includes file naming and directory management.
14
107
 
15
108
  Parameters
16
109
  ----------
17
- corsika_config : CorsikaConfig
18
- Configuration parameters for CORSIKA.
110
+ config: CorsikaConfig, dict
111
+ Configuration parameters.
112
+ run_type : str
113
+ Type of simulation runner.
19
114
  label : str
20
115
  Label.
21
116
  """
22
117
 
23
- def __init__(self, corsika_config, label=None):
118
+ def __init__(self, config, run_type, label=None):
24
119
  """Initialize RunnerServices."""
25
120
  self._logger = logging.getLogger(__name__)
26
- self._logger.debug("Init RunnerServices")
27
121
  self.label = label
28
- self.corsika_config = corsika_config
29
- self.directory = {}
122
+ self.config = config
123
+ self.run_type = run_type
124
+ self.directory = self.load_data_directory()
30
125
 
31
- def _get_info_for_file_name(self, run_number, calibration_run_mode=None):
126
+ def load_data_directory(self):
32
127
  """
33
- Return dictionary for building names for simulation output files.
34
-
35
- Parameters
36
- ----------
37
- run_number : int
38
- Run number.
39
- calibration_run_mode: str
40
- Calibration run mode.
128
+ Create and return directory for the given run type.
41
129
 
42
130
  Returns
43
131
  -------
44
- dict
45
- Dictionary with the keys or building the file names for simulation output files.
46
- """
47
- _vc_high = self.corsika_config.get_config_parameter("VIEWCONE")[1]
48
- if calibration_run_mode is not None and calibration_run_mode != "":
49
- primary_name = calibration_run_mode
50
- else:
51
- primary_name = self.corsika_config.primary
52
- if primary_name == "gamma" and _vc_high > 0:
53
- primary_name = "gamma_diffuse"
54
- return {
55
- "run_number": self.corsika_config.validate_run_number(run_number),
56
- "primary": primary_name,
57
- "array_name": self.corsika_config.array_model.layout_name,
58
- "site": self.corsika_config.array_model.site,
59
- "label": self.label,
60
- "model_version": self.corsika_config.array_model.model_version,
61
- "zenith": self.corsika_config.zenith_angle,
62
- "azimuth": self.corsika_config.azimuth_angle,
63
- }
64
-
65
- @staticmethod
66
- def _get_simulation_software_list(simulation_software):
132
+ Path
133
+ Path to the created directory.
67
134
  """
68
- Return a list of simulation software based on the input string.
135
+ ioh = io_handler.IOHandler()
136
+ directory = ioh.get_output_directory(self.run_type)
137
+ self._logger.debug(f"Data directories for {self.run_type}: {directory}")
69
138
 
70
- Args:
71
- simulation_software: String representing the desired software.
139
+ return directory
72
140
 
73
- Returns
74
- -------
75
- List of simulation software names.
141
+ def load_files(self, run_number=None):
76
142
  """
77
- software_map = {
78
- "corsika": ["corsika"],
79
- "sim_telarray": ["sim_telarray"],
80
- "corsika_sim_telarray": ["corsika", "sim_telarray"],
81
- }
82
- return software_map.get(simulation_software.lower(), [])
83
-
84
- def load_data_directories(self, simulation_software):
85
- """
86
- Create and return directories for output, data, log and input.
143
+ Load files required for the simulation run.
87
144
 
88
145
  Parameters
89
146
  ----------
90
- simulation_software : str
91
- Simulation software to be used.
147
+ run_number: int
148
+ Run number.
92
149
 
93
150
  Returns
94
151
  -------
95
152
  dict
96
- Dictionary containing paths requires for simulation configuration.
97
- """
98
- ioh = io_handler.IOHandler()
99
- self.directory["output"] = ioh.get_output_directory()
100
- _logger.debug(f"Creating output dir {self.directory['output']}")
101
- for dir_name in ["sub_scripts", "sub_logs"]:
102
- self.directory[dir_name] = ioh.get_output_directory(dir_name)
103
- for _simulation_software in self._get_simulation_software_list(simulation_software):
104
- for dir_name in ["data", "inputs", "logs"]:
105
- self.directory[dir_name] = ioh.get_output_directory(
106
- [_simulation_software, dir_name]
107
- )
108
- self._logger.debug(f"Data directories for {simulation_software}: {self.directory}")
109
- return self.directory
110
-
111
- def has_file(self, file_type, run_number=None, mode="out"):
153
+ Dictionary containing paths to files required for the simulation run.
112
154
  """
113
- Check that the file of file_type for the specified run number exists.
155
+ run_files = {}
156
+ for key in FILES_AND_PATHS:
157
+ if key.startswith(self.run_type.lower()):
158
+ run_files[key] = self.get_file_name(file_type=key, run_number=run_number)
114
159
 
115
- Parameters
116
- ----------
117
- file_type: str
118
- File type to check.
119
- run_number: int
120
- Run number.
160
+ for key, file_path in run_files.items():
161
+ self._logger.debug(f"{key}: {file_path}")
121
162
 
122
- """
123
- run_sub_file = self.get_file_name(file_type, run_number=run_number, mode=mode)
124
- _logger.debug(f"Checking if {run_sub_file} exists")
125
- return Path(run_sub_file).is_file()
163
+ return run_files
126
164
 
127
- def _get_file_basename(self, run_number, calibration_run_mode):
165
+ def _get_file_basename(self, run_number, is_multi_pipe=False):
128
166
  """
129
167
  Get the base name for the simulation files.
130
168
 
@@ -132,118 +170,87 @@ class RunnerServices:
132
170
  ----------
133
171
  run_number: int
134
172
  Run number.
135
- calibration_run_mode: str
136
- Calibration run mode.
173
+ file_type: str
174
+ File type.
137
175
 
138
176
  Returns
139
177
  -------
140
178
  str
141
179
  Base name for the simulation files.
142
180
  """
143
- info_for_file_name = self._get_info_for_file_name(run_number, calibration_run_mode)
144
- file_label = f"_{info_for_file_name['label']}" if info_for_file_name.get("label") else ""
145
- zenith = self.corsika_config.get_config_parameter("THETAP")[0]
146
- azimuth = self.corsika_config.azimuth_angle
147
- run_number_string = self._get_run_number_string(info_for_file_name["run_number"])
148
- prefix = (
149
- f"{info_for_file_name['primary']}_{run_number_string}_"
150
- if info_for_file_name["primary"]
151
- else f"{run_number_string}_"
152
- )
181
+ if isinstance(self.config, CorsikaConfig):
182
+ return self._get_file_base_name_from_corsika_config(run_number, is_multi_pipe)
183
+ if isinstance(self.config, dict):
184
+ return self._get_file_base_name_from_core_config()
185
+ raise ValueError(f"Invalid configuration type: {type(self.config)}")
186
+
187
+ def _get_file_base_name_from_core_config(self):
188
+ """Get file base name from core configuration."""
189
+ cfg = self.config
190
+ zenith_angle = cfg.get("zenith_angle")
191
+ azimuth_angle = cfg.get("azimuth_angle")
192
+
193
+ parts = [
194
+ f"{cfg['run_mode']}_" if cfg.get("run_mode") else "",
195
+ f"{self._get_run_number_string(cfg.get('run_number'))}_" if "run_number" in cfg else "",
196
+ f"za{round(zenith_angle):02}deg_" if isinstance(zenith_angle, (int, float)) else "",
197
+ f"azm{round(azimuth_angle):03}deg_" if isinstance(azimuth_angle, (int, float)) else "",
198
+ f"{cfg['site']}_" if cfg.get("site") else "",
199
+ f"{cfg['layout']}_" if cfg.get("layout") else "",
200
+ cfg.get("model_version", ""),
201
+ f"_{self.label}" if self.label else "",
202
+ ]
203
+ return "".join(parts)
204
+
205
+ def _get_file_base_name_from_corsika_config(self, run_number, is_multi_pipe=False):
206
+ """Get file base name from CORSIKA configuration."""
207
+ zenith = self.config.get_config_parameter("THETAP")[0]
208
+ vc_high = self.config.get_config_parameter("VIEWCONE")[1]
209
+
210
+ if self.config.run_mode is not None and self.config.run_mode != "":
211
+ primary_name = self.config.run_mode
212
+ else:
213
+ primary_name = self.config.primary_particle.name
214
+ if primary_name == "gamma" and vc_high > 0:
215
+ primary_name = "gamma_diffuse"
216
+
217
+ file_label = f"_{self.label}" if self.label else ""
218
+ run_number_string = self._get_run_number_string(run_number)
219
+
220
+ prefix = f"{primary_name}_{run_number_string}_" if primary_name else f"{run_number_string}_"
153
221
  return (
154
222
  prefix
155
- + f"za{round(zenith):02}deg_azm{azimuth:03}deg_"
156
- + f"{info_for_file_name['site']}_{info_for_file_name['array_name']}_"
157
- + f"{info_for_file_name['model_version']}{file_label}"
223
+ + f"za{round(zenith):02}deg_"
224
+ + f"azm{self.config.azimuth_angle:03}deg_"
225
+ + f"{self.config.array_model.site}_"
226
+ + f"{self.config.array_model.layout_name}_"
227
+ + (self.config.array_model.model_version if not is_multi_pipe else "")
228
+ + file_label
158
229
  )
159
230
 
160
- def _get_log_file_path(self, file_type, file_name):
161
- """
162
- Return path for log files.
163
-
164
- Parameters
165
- ----------
166
- file_type : str
167
- File type.
168
- file_name : str
169
- File name.
170
-
171
- Returns
172
- -------
173
- Path
174
- Path for log files.
175
- """
176
- log_suffixes = {
177
- "log": ".log.gz",
178
- "histogram": ".hdata.zst",
179
- "corsika_log": ".corsika.log.gz",
180
- }
181
- return self.directory["logs"].joinpath(f"{file_name}{log_suffixes[file_type]}")
182
-
183
- def _get_data_file_path(self, file_type, file_name, run_number):
231
+ def _get_sub_directory(self, run_number, dir_path):
184
232
  """
185
- Return path for data files.
233
+ Return sub directory with / without run number.
186
234
 
187
235
  Parameters
188
236
  ----------
189
- file_type : str
190
- File type.
191
- file_name : str
192
- File name.
193
- run_number : int
237
+ run_number: int
194
238
  Run number.
239
+ dir_path: Path
240
+ Parent directory path.
195
241
 
196
242
  Returns
197
243
  -------
198
244
  Path
199
- Path for data files.
200
- """
201
- data_suffixes = {
202
- "output": ".zst",
203
- "corsika_output": ".corsika.zst",
204
- "simtel_output": ".simtel.zst",
205
- "event_data": ".reduced_event_data.hdf5",
206
- }
207
- run_dir = self._get_run_number_string(run_number)
208
- data_run_dir = self.directory["data"].joinpath(run_dir)
209
- data_run_dir.mkdir(parents=True, exist_ok=True)
210
- return data_run_dir.joinpath(f"{file_name}{data_suffixes[file_type]}")
211
-
212
- def _get_sub_file_path(self, file_type, file_name, mode):
245
+ Child directory path.
213
246
  """
214
- Return path for submission files.
247
+ sub_dir = dir_path / self._get_run_number_string(run_number)
248
+ sub_dir.mkdir(parents=True, exist_ok=True)
249
+ return sub_dir
215
250
 
216
- Parameters
217
- ----------
218
- file_type : str
219
- File type.
220
- file_name : str
221
- File name.
222
- mode : str
223
- Mode (out or err).
224
-
225
- Returns
226
- -------
227
- Path
228
- Path for submission files.
229
- """
230
- suffix = ".log" if file_type == "sub_log" else ".sh"
231
- if mode and mode != "":
232
- suffix = f".{mode}"
233
- sub_log_file_dir = self.directory["output"].joinpath(f"{file_type}s")
234
- sub_log_file_dir.mkdir(parents=True, exist_ok=True)
235
- return sub_log_file_dir.joinpath(f"sub_{file_name}{suffix}")
236
-
237
- def get_file_name(
238
- self,
239
- file_type,
240
- run_number=None,
241
- mode=None,
242
- calibration_run_mode=None,
243
- _model_version_index=0,
244
- ): # pylint: disable=unused-argument
251
+ def get_file_name(self, file_type, run_number=None):
245
252
  """
246
- Get a CORSIKA/sim_telarray style file name for various log and data file types.
253
+ Get a file name depending on file type and run number.
247
254
 
248
255
  Parameters
249
256
  ----------
@@ -251,15 +258,6 @@ class RunnerServices:
251
258
  The type of file (determines the file suffix).
252
259
  run_number : int
253
260
  Run number.
254
- mode: str
255
- out or err (optional, relevant only for sub_log).
256
- calibration_run_mode: str
257
- Calibration run mode.
258
- model_version_index: int
259
- Index of the model version.
260
- This is not used here, but in other implementations of this function is
261
- used to select the correct simulator_array instance in case
262
- multiple array models are simulated.
263
261
 
264
262
  Returns
265
263
  -------
@@ -271,23 +269,23 @@ class RunnerServices:
271
269
  ValueError
272
270
  If file_type is unknown.
273
271
  """
274
- file_name = self._get_file_basename(run_number, calibration_run_mode)
275
-
276
- if file_type in ["log", "histogram", "corsika_log"]:
277
- return self._get_log_file_path(file_type, file_name)
278
-
279
- if file_type in ["output", "corsika_output", "simtel_output", "event_data"]:
280
- return self._get_data_file_path(file_type, file_name, run_number)
272
+ file_name = self._get_file_basename(run_number, file_type.startswith("multi_pipe"))
281
273
 
282
- if file_type in ("sub_log", "sub_script"):
283
- return self._get_sub_file_path(file_type, file_name, mode)
274
+ try:
275
+ desc = FILES_AND_PATHS[file_type]
276
+ except KeyError as exc:
277
+ raise ValueError(f"Unknown file type: {file_type}") from exc
284
278
 
285
- raise ValueError(f"The requested file type ({file_type}) is unknown")
279
+ if desc["sub_dir_type"] == "run_number":
280
+ dir_path = self._get_sub_directory(run_number, self.directory)
281
+ else:
282
+ dir_path = self.directory
283
+ return dir_path / f"{file_name}{desc['suffix']}"
286
284
 
287
285
  @staticmethod
288
286
  def _get_run_number_string(run_number):
289
287
  """
290
- Get run number string as used for the simulation file names(ex. run000014).
288
+ Get run number string as used for the simulation file names (ex. run000014).
291
289
 
292
290
  Parameters
293
291
  ----------
@@ -299,37 +297,40 @@ class RunnerServices:
299
297
  str
300
298
  Run number string.
301
299
  """
302
- nn = str(run_number)
303
- if len(nn) > 6:
304
- raise ValueError("Run number cannot have more than 6 digits")
305
- return "run" + nn.zfill(6)
300
+ if run_number is None:
301
+ return ""
302
+ return f"run{validate_corsika_run_number(run_number):06d}"
306
303
 
307
- def get_resources(self, run_number=None):
304
+ def get_resources(self, sub_out_file):
308
305
  """
309
306
  Read run time of job from last line of submission log file.
310
307
 
311
308
  Parameters
312
309
  ----------
313
- run_number: int
314
- Run number.
310
+ sub_out_file: str or Path
311
+ Path to the submission output file.
315
312
 
316
313
  Returns
317
314
  -------
318
315
  dict
319
316
  run time and number of simulated events
320
317
  """
321
- sub_log_file = self.get_file_name(file_type="sub_log", run_number=run_number, mode="out")
322
- _logger.debug(f"Reading resources from {sub_log_file}")
318
+ _logger.debug(f"Reading resources from {sub_out_file}")
323
319
 
324
- _resources = {}
325
- _resources["runtime"] = None
326
- with open(sub_log_file, encoding="utf-8") as file:
327
- for line in reversed(list(file)):
320
+ runtime = None
321
+ with open(sub_out_file, encoding="utf-8") as f:
322
+ for line in reversed(f.readlines()):
328
323
  if "RUNTIME" in line:
329
- _resources["runtime"] = int(line.split()[1])
324
+ runtime = int(line.split()[1])
330
325
  break
331
326
 
332
- if _resources["runtime"] is None:
327
+ if runtime is None:
333
328
  _logger.debug("RUNTIME was not found in run log file")
334
- _resources["n_events"] = int(self.corsika_config.get_config_parameter("NSHOW"))
335
- return _resources
329
+
330
+ if isinstance(self.config, CorsikaConfig):
331
+ return {
332
+ "runtime": runtime,
333
+ "n_events": int(self.config.get_config_parameter("NSHOW")),
334
+ }
335
+ self._logger.warning("Number of events cannot be determined from non-CORSIKA config.")
336
+ return {"runtime": runtime, "n_events": None}