gammasimtools 0.25.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 (138) hide show
  1. {gammasimtools-0.25.0.dist-info → gammasimtools-0.27.0.dist-info}/METADATA +6 -1
  2. {gammasimtools-0.25.0.dist-info → gammasimtools-0.27.0.dist-info}/RECORD +135 -130
  3. {gammasimtools-0.25.0.dist-info → gammasimtools-0.27.0.dist-info}/WHEEL +1 -1
  4. {gammasimtools-0.25.0.dist-info → gammasimtools-0.27.0.dist-info}/entry_points.txt +3 -2
  5. {gammasimtools-0.25.0.dist-info → gammasimtools-0.27.0.dist-info}/licenses/LICENSE +1 -1
  6. simtools/_version.py +2 -2
  7. simtools/application_control.py +35 -7
  8. simtools/applications/convert_geo_coordinates_of_array_elements.py +3 -3
  9. simtools/applications/db_add_file_to_db.py +1 -1
  10. simtools/applications/db_add_simulation_model_from_repository_to_db.py +1 -1
  11. simtools/applications/db_add_value_from_json_to_db.py +1 -1
  12. simtools/applications/db_generate_compound_indexes.py +1 -1
  13. simtools/applications/db_get_array_layouts_from_db.py +3 -7
  14. simtools/applications/db_get_file_from_db.py +1 -1
  15. simtools/applications/db_get_parameter_from_db.py +1 -1
  16. simtools/applications/db_inspect_databases.py +1 -1
  17. simtools/applications/db_upload_model_repository.py +1 -1
  18. simtools/applications/derive_ctao_array_layouts.py +1 -2
  19. simtools/applications/{calculate_incident_angles.py → derive_incident_angle.py} +16 -18
  20. simtools/applications/derive_mirror_rnda.py +112 -180
  21. simtools/applications/derive_psf_parameters.py +0 -1
  22. simtools/applications/derive_pulse_shape_parameters.py +0 -1
  23. simtools/applications/derive_trigger_rates.py +1 -1
  24. simtools/applications/docs_produce_array_element_report.py +2 -8
  25. simtools/applications/docs_produce_calibration_reports.py +1 -3
  26. simtools/applications/docs_produce_model_parameter_reports.py +0 -2
  27. simtools/applications/docs_produce_simulation_configuration_report.py +1 -3
  28. simtools/applications/generate_array_config.py +0 -1
  29. simtools/applications/generate_corsika_histograms.py +79 -229
  30. simtools/applications/generate_regular_arrays.py +76 -69
  31. simtools/applications/generate_simtel_event_data.py +2 -2
  32. simtools/applications/maintain_simulation_model_add_production.py +2 -2
  33. simtools/applications/maintain_simulation_model_write_array_element_positions.py +87 -0
  34. simtools/applications/plot_array_layout.py +5 -111
  35. simtools/applications/plot_simulated_event_distributions.py +57 -0
  36. simtools/applications/plot_tabular_data.py +0 -1
  37. simtools/applications/plot_tabular_data_for_model_parameter.py +1 -6
  38. simtools/applications/production_derive_corsika_limits.py +1 -1
  39. simtools/applications/production_generate_grid.py +0 -1
  40. simtools/applications/run_application.py +1 -1
  41. simtools/applications/simulate_flasher.py +3 -15
  42. simtools/applications/simulate_illuminator.py +2 -11
  43. simtools/applications/simulate_pedestals.py +1 -5
  44. simtools/applications/simulate_prod.py +8 -11
  45. simtools/applications/simulate_prod_htcondor_generator.py +1 -1
  46. simtools/applications/submit_array_layouts.py +2 -4
  47. simtools/applications/submit_data_from_external.py +2 -1
  48. simtools/applications/submit_model_parameter_from_external.py +1 -3
  49. simtools/applications/validate_camera_efficiency.py +28 -28
  50. simtools/applications/validate_camera_fov.py +0 -1
  51. simtools/applications/validate_cumulative_psf.py +1 -5
  52. simtools/applications/validate_optics.py +2 -14
  53. simtools/atmosphere.py +83 -0
  54. simtools/camera/camera_efficiency.py +171 -53
  55. simtools/camera/single_photon_electron_spectrum.py +8 -7
  56. simtools/configuration/commandline_parser.py +82 -11
  57. simtools/configuration/configurator.py +6 -11
  58. simtools/constants.py +5 -0
  59. simtools/corsika/corsika_config.py +100 -202
  60. simtools/corsika/corsika_histograms.py +561 -1708
  61. simtools/corsika/primary_particle.py +1 -1
  62. simtools/data_model/metadata_collector.py +5 -2
  63. simtools/data_model/metadata_model.py +0 -4
  64. simtools/data_model/model_data_writer.py +59 -64
  65. simtools/data_model/schema.py +2 -0
  66. simtools/data_model/validate_data.py +1 -3
  67. simtools/db/db_handler.py +23 -10
  68. simtools/db/mongo_db.py +2 -2
  69. simtools/dependencies.py +81 -38
  70. simtools/io/ascii_handler.py +55 -5
  71. simtools/io/io_handler.py +23 -12
  72. simtools/io/table_handler.py +1 -1
  73. simtools/job_execution/job_manager.py +154 -79
  74. simtools/job_execution/process_pool.py +137 -0
  75. simtools/layout/array_layout.py +4 -13
  76. simtools/layout/array_layout_utils.py +348 -57
  77. simtools/model/array_model.py +23 -63
  78. simtools/model/calibration_model.py +4 -8
  79. simtools/model/legacy_model_parameter.py +134 -0
  80. simtools/model/model_parameter.py +147 -86
  81. simtools/model/model_utils.py +40 -6
  82. simtools/model/site_model.py +4 -8
  83. simtools/model/telescope_model.py +10 -16
  84. simtools/production_configuration/derive_corsika_limits.py +6 -11
  85. simtools/production_configuration/interpolation_handler.py +16 -16
  86. simtools/ray_tracing/incident_angles.py +92 -17
  87. simtools/ray_tracing/mirror_panel_psf.py +338 -222
  88. simtools/ray_tracing/psf_analysis.py +62 -48
  89. simtools/ray_tracing/psf_parameter_optimisation.py +3 -3
  90. simtools/ray_tracing/ray_tracing.py +43 -25
  91. simtools/reporting/docs_auto_report_generator.py +8 -13
  92. simtools/reporting/docs_read_parameters.py +2 -8
  93. simtools/runners/corsika_runner.py +52 -195
  94. simtools/runners/corsika_simtel_runner.py +77 -108
  95. simtools/runners/runner_services.py +214 -213
  96. simtools/runners/simtel_runner.py +27 -160
  97. simtools/runners/simtools_runner.py +11 -73
  98. simtools/schemas/application_workflow.metaschema.yml +8 -0
  99. simtools/settings.py +173 -0
  100. simtools/{io/eventio_handler.py → sim_events/file_info.py} +3 -3
  101. simtools/{simtel/simtel_io_event_histograms.py → sim_events/histograms.py} +25 -15
  102. simtools/{simtel/simtel_io_event_reader.py → sim_events/reader.py} +20 -17
  103. simtools/{simtel/simtel_io_event_writer.py → sim_events/writer.py} +84 -25
  104. simtools/simtel/pulse_shapes.py +7 -2
  105. simtools/simtel/simtel_config_writer.py +79 -91
  106. simtools/simtel/simtel_seeds.py +184 -0
  107. simtools/simtel/simtel_table_reader.py +6 -4
  108. simtools/simtel/simulator_array.py +114 -109
  109. simtools/simtel/simulator_camera_efficiency.py +68 -46
  110. simtools/simtel/simulator_light_emission.py +164 -132
  111. simtools/simtel/simulator_ray_tracing.py +80 -71
  112. simtools/simulator.py +137 -355
  113. simtools/telescope_trigger_rates.py +3 -4
  114. simtools/testing/assertions.py +84 -33
  115. simtools/testing/configuration.py +1 -2
  116. simtools/testing/helpers.py +2 -3
  117. simtools/testing/log_inspector.py +1 -0
  118. simtools/testing/sim_telarray_metadata.py +14 -12
  119. simtools/testing/validate_output.py +121 -42
  120. simtools/utils/general.py +43 -17
  121. simtools/utils/geometry.py +0 -77
  122. simtools/utils/names.py +5 -5
  123. simtools/utils/random.py +36 -0
  124. simtools/visualization/legend_handlers.py +7 -6
  125. simtools/visualization/plot_array_layout.py +91 -16
  126. simtools/visualization/plot_corsika_histograms.py +145 -605
  127. simtools/visualization/plot_incident_angles.py +48 -1
  128. simtools/visualization/plot_mirrors.py +1 -4
  129. simtools/visualization/plot_pixels.py +2 -4
  130. simtools/visualization/plot_psf.py +160 -19
  131. simtools/visualization/plot_simtel_event_histograms.py +4 -4
  132. simtools/visualization/plot_simtel_events.py +6 -11
  133. simtools/visualization/plot_tables.py +8 -19
  134. simtools/visualization/visualize.py +22 -2
  135. simtools/applications/db_development_tools/write_array_elements_positions_to_repository.py +0 -160
  136. simtools/applications/print_version.py +0 -53
  137. simtools/io/hdf5_handler.py +0 -139
  138. {gammasimtools-0.25.0.dist-info → gammasimtools-0.27.0.dist-info}/top_level.txt +0 -0
@@ -8,13 +8,13 @@ from ctao_cr_spectra.definitions import IRFDOC_PROTON_SPECTRUM
8
8
 
9
9
  from simtools.io import ascii_handler, io_handler
10
10
  from simtools.layout.array_layout_utils import get_array_elements_from_db_for_layouts
11
- from simtools.simtel.simtel_io_event_histograms import SimtelIOEventHistograms
11
+ from simtools.sim_events.histograms import EventDataHistograms
12
12
  from simtools.visualization import plot_simtel_event_histograms
13
13
 
14
14
  _logger = logging.getLogger(__name__)
15
15
 
16
16
 
17
- def telescope_trigger_rates(args_dict, db_config):
17
+ def telescope_trigger_rates(args_dict):
18
18
  """
19
19
  Calculate trigger rates for single telescopes or arrays of telescopes.
20
20
 
@@ -27,7 +27,6 @@ def telescope_trigger_rates(args_dict, db_config):
27
27
  args_dict["array_layout_name"],
28
28
  args_dict.get("site"),
29
29
  args_dict.get("model_version"),
30
- db_config,
31
30
  )
32
31
  else:
33
32
  telescope_configs = ascii_handler.collect_data_from_file(args_dict["telescope_ids"])[
@@ -38,7 +37,7 @@ def telescope_trigger_rates(args_dict, db_config):
38
37
  _logger.info(
39
38
  f"Processing file: {args_dict['event_data_file']} with telescope config: {array_name}"
40
39
  )
41
- histograms = SimtelIOEventHistograms(
40
+ histograms = EventDataHistograms(
42
41
  args_dict["event_data_file"], array_name=array_name, telescope_list=telescope_ids
43
42
  )
44
43
  histograms.fill()
@@ -68,8 +68,7 @@ def assert_n_showers_and_energy_range(file):
68
68
  simulation_config = {}
69
69
  with SimTelFile(file, skip_non_triggered=False) as f:
70
70
  simulation_config = f.mc_run_headers[0]
71
- for event in f:
72
- simulated_energies.append(event["mc_shower"]["energy"])
71
+ simulated_energies.extend(event["mc_shower"]["energy"] for event in f)
73
72
 
74
73
  # The relative tolerance is set to 1% because ~0.5% shower simulations do not
75
74
  # succeed, without resulting in an error. This tolerance therefore is not an issue.
@@ -191,54 +190,94 @@ def check_output_from_sim_telarray(file, file_test):
191
190
  return assert_n_showers_and_energy_range(file=file) and assert_output and assert_metadata
192
191
 
193
192
 
194
- def _find_patterns(text, patterns):
195
- """Find patterns in text."""
196
- return {p for p in patterns if p in text}
197
-
198
-
199
- def _read_log(member, tar):
200
- """Read and decode a gzipped log file from a tar archive."""
201
- with tar.extractfile(member) as gz, gzip.open(gz, "rb") as f:
202
- return f.read().decode("utf-8", "ignore")
203
-
204
-
205
193
  def check_simulation_logs(tar_file, file_test):
206
194
  """
207
- Check log files of CORSIKA and sim_telarray for expected output.
195
+ Check simulation logs for wanted and forbidden patterns.
208
196
 
209
197
  Parameters
210
198
  ----------
211
- tar_file: Path
212
- Path to a log file tar package.
213
- file_test: dict
214
- File test description including expected log output.
215
-
216
- Raises
217
- ------
218
- ValueError
219
- If the file is not a tar file.
199
+ tar_file : str
200
+ Path to the tar file.
201
+ file_test : dict
202
+ Dictionary with the test configuration.
203
+
204
+ Returns
205
+ -------
206
+ bool
207
+ True if the logs are correct.
220
208
  """
221
- expected_log = file_test.get("expected_log_output", {})
222
- wanted = expected_log.get("pattern", [])
223
- forbidden = expected_log.get("forbidden_pattern", [])
224
-
225
- if not (wanted or forbidden):
226
- _logger.debug(f"No expected log output provided, skipping checks {file_test}")
209
+ wanted, forbidden = _get_expected_patterns(file_test)
210
+ if wanted is None:
227
211
  return True
228
212
 
229
213
  if not tarfile.is_tarfile(tar_file):
230
- raise ValueError(f"File {tar_file} is not a tar file.")
214
+ raise ValueError(f"{tar_file} is not a tar file")
231
215
 
232
- found, bad = set(), set()
216
+ found_wanted = set()
217
+ found_forbidden = set()
233
218
  with tarfile.open(tar_file, "r:*") as tar:
234
219
  for member in tar.getmembers():
235
220
  if not member.name.endswith(".log.gz"):
236
221
  continue
237
222
  _logger.info(f"Scanning {member.name}")
238
223
  text = _read_log(member, tar)
239
- found |= _find_patterns(text, wanted)
240
- bad |= _find_patterns(text, forbidden)
224
+ found_wanted |= _find_patterns(text, wanted)
225
+ found_forbidden |= _find_patterns(text, forbidden)
226
+
227
+ return _validate_patterns(found_wanted, found_forbidden, wanted)
228
+
229
+
230
+ def check_plain_log(log_file, file_test):
231
+ """
232
+ Check plain log file for wanted and forbidden patterns.
233
+
234
+ Parameters
235
+ ----------
236
+ log_file : str
237
+ Path to the log file.
238
+ file_test : dict
239
+ Dictionary with the test configuration.
240
+
241
+ Returns
242
+ -------
243
+ bool
244
+ True if the logs are correct.
245
+ """
246
+ wanted, forbidden = _get_expected_patterns(file_test)
247
+ if wanted is None:
248
+ return True
249
+
250
+ try:
251
+ with open(log_file, encoding="utf-8") as f:
252
+ text = f.read()
253
+ except FileNotFoundError:
254
+ _logger.error(f"Log file {log_file} not found")
255
+ return False
256
+
257
+ found = _find_patterns(text, wanted)
258
+ bad = _find_patterns(text, forbidden)
259
+
260
+ return _validate_patterns(found, bad, wanted)
261
+
262
+
263
+ def _get_expected_patterns(file_test):
264
+ """Get wanted and forbidden patterns from file test configuration."""
265
+ expected_log = file_test.get("expected_log_output")
266
+ if isinstance(expected_log, dict):
267
+ wanted = expected_log.get("pattern", [])
268
+ forbidden = expected_log.get("forbidden_pattern", [])
269
+ else:
270
+ wanted = file_test.get("pattern", [])
271
+ forbidden = file_test.get("forbidden_pattern", [])
272
+ if not (wanted or forbidden):
273
+ _logger.debug(f"No expected log output provided, skipping checks {file_test}")
274
+ return None, None
241
275
 
276
+ return wanted, forbidden
277
+
278
+
279
+ def _validate_patterns(found, bad, wanted):
280
+ """Validate found patterns against wanted and forbidden ones."""
242
281
  if bad:
243
282
  _logger.error(f"Forbidden patterns found: {list(bad)}")
244
283
  return False
@@ -249,3 +288,15 @@ def check_simulation_logs(tar_file, file_test):
249
288
 
250
289
  _logger.debug(f"All expected patterns found: {wanted}")
251
290
  return True
291
+
292
+
293
+ def _find_patterns(text, patterns):
294
+ """Find patterns in text (case insensitive)."""
295
+ text_lower = text.lower()
296
+ return {p for p in patterns if p.lower() in text_lower}
297
+
298
+
299
+ def _read_log(member, tar):
300
+ """Read and decode a gzipped log file from a tar archive."""
301
+ with tar.extractfile(member) as gz, gzip.open(gz, "rb") as f:
302
+ return f.read().decode("utf-8", "ignore")
@@ -76,8 +76,7 @@ def _read_configs_from_files(config_files):
76
76
  _dict = gen.remove_substring_recursively_from_dict(
77
77
  ascii_handler.collect_data_from_file(file_name=config_file), substring="\n"
78
78
  )
79
- for application in _dict.get("applications", []):
80
- configs.append(application)
79
+ configs.extend(_dict.get("applications", []))
81
80
  return configs
82
81
 
83
82
 
@@ -1,7 +1,6 @@
1
1
  """Helper functions for integration testing."""
2
2
 
3
- import os
4
- from pathlib import Path
3
+ from simtools import settings
5
4
 
6
5
 
7
6
  def skip_camera_efficiency(config):
@@ -21,7 +20,7 @@ def _new_testeff_version():
21
20
 
22
21
  This test checks if the new version is used.
23
22
  """
24
- testeff_path = Path(os.getenv("SIMTOOLS_SIMTEL_PATH")) / "sim_telarray/testeff.c"
23
+ testeff_path = settings.config.sim_telarray_path / "testeff.c"
25
24
  try:
26
25
  with open(testeff_path, encoding="utf-8") as file:
27
26
  file_content = file.read()
@@ -18,6 +18,7 @@ ERROR_PATTERNS = [
18
18
  IGNORE_PATTERNS = [
19
19
  re.compile(r"Falling back to 'utf-8' with errors='ignore'", re.IGNORECASE),
20
20
  re.compile(r"Failed to get user name[^\n]*setting it to UNKNOWN_USER", re.IGNORECASE),
21
+ re.compile(r"adjust_text::Error", re.IGNORECASE),
21
22
  ]
22
23
 
23
24
 
@@ -4,13 +4,13 @@ import logging
4
4
 
5
5
  import numpy as np
6
6
 
7
- from simtools.io.eventio_handler import get_corsika_run_number
7
+ from simtools.sim_events.file_info import get_corsika_run_number
8
8
  from simtools.simtel.simtel_config_reader import SimtelConfigReader
9
- from simtools.simtel.simtel_config_writer import sim_telarray_random_seeds
10
9
  from simtools.simtel.simtel_io_metadata import (
11
10
  get_sim_telarray_telescope_id,
12
11
  read_sim_telarray_metadata,
13
12
  )
13
+ from simtools.utils import random
14
14
 
15
15
  _logger = logging.getLogger(__name__)
16
16
 
@@ -30,7 +30,7 @@ def assert_sim_telarray_metadata(file, array_model):
30
30
  _logger.info(f"Found metadata in sim_telarray file for {len(telescope_meta)} telescopes")
31
31
  site_parameter_mismatch = _assert_model_parameters(global_meta, array_model.site_model)
32
32
  sim_telarray_seed_mismatch = _assert_sim_telarray_seed(
33
- global_meta, array_model.sim_telarray_seeds, file
33
+ global_meta, array_model.sim_telarray_seed, file
34
34
  )
35
35
  if sim_telarray_seed_mismatch:
36
36
  site_parameter_mismatch.append(sim_telarray_seed_mismatch)
@@ -101,7 +101,7 @@ def _assert_model_parameters(metadata, model):
101
101
  return invalid_parameter_list
102
102
 
103
103
 
104
- def _assert_sim_telarray_seed(metadata, sim_telarray_seeds, file=None):
104
+ def _assert_sim_telarray_seed(metadata, sim_telarray_seed, file=None):
105
105
  """
106
106
  Assert that sim_telarray seed matches the values in the sim_telarray metadata.
107
107
 
@@ -111,8 +111,8 @@ def _assert_sim_telarray_seed(metadata, sim_telarray_seeds, file=None):
111
111
  ----------
112
112
  metadata: dict
113
113
  Metadata dictionary.
114
- sim_telarray_seeds: dict
115
- Dictionary of sim_telarray seeds.
114
+ sim_telarray_seed: SimtelSeeds
115
+ sim_telarray seed.
116
116
  file : Path
117
117
  Path to the sim_telarray file.
118
118
 
@@ -122,23 +122,25 @@ def _assert_sim_telarray_seed(metadata, sim_telarray_seeds, file=None):
122
122
  Error message if sim_telarray seeds do not match.
123
123
 
124
124
  """
125
- if not sim_telarray_seeds or not metadata:
125
+ if sim_telarray_seed is None:
126
126
  return None
127
127
 
128
128
  if "instrument_seed" in metadata.keys() and "instrument_instances" in metadata.keys():
129
- if str(metadata.get("instrument_seed")) != str(sim_telarray_seeds.get("seed")):
129
+ if str(metadata.get("instrument_seed")) != str(sim_telarray_seed.instrument_seed):
130
130
  return (
131
131
  "Parameter instrument_seed mismatch between sim_telarray file: "
132
- f"{metadata['instrument_seed']}, and model: {sim_telarray_seeds.get('seed')}"
132
+ f"{metadata['instrument_seed']}, and model: {sim_telarray_seed.instrument_seed}"
133
133
  )
134
134
  _logger.info(
135
135
  f"sim_telarray_seed in sim_telarray file: {metadata['instrument_seed']}, "
136
- f"and model: {sim_telarray_seeds.get('seed')}"
136
+ f"and model: {sim_telarray_seed.instrument_seed}"
137
137
  )
138
138
  if file:
139
139
  run_number_modified = get_corsika_run_number(file) - 1
140
- test_seeds = sim_telarray_random_seeds(
141
- int(metadata["instrument_seed"]), int(metadata["instrument_instances"])
140
+ test_seeds = random.seeds(
141
+ n_seeds=int(metadata["instrument_instances"]),
142
+ max_seed=np.iinfo(np.int32).max,
143
+ fixed_seed=int(metadata["instrument_seed"]),
142
144
  )
143
145
  # no +1 as in sim_telarray (as we count from 0)
144
146
  seed_used = run_number_modified % int(metadata["instrument_instances"])
@@ -13,30 +13,40 @@ from simtools.testing import assertions
13
13
 
14
14
  _logger = logging.getLogger(__name__)
15
15
 
16
+
17
+ def _versions_match(from_command_line, from_config_file):
18
+ """Return True if validations should run for the given versions.
19
+
20
+ Behavior:
21
+ - If no version is provided from the command line, run validations.
22
+ - If a filter is provided from the command line, run only when it matches
23
+ the version(s) from the config file.
24
+ """
25
+ if from_command_line is None:
26
+ return True
27
+
28
+ # Normalize to collections for comparison
29
+ cmd_versions = from_command_line if isinstance(from_command_line, list) else [from_command_line]
30
+ cfg_versions = from_config_file if isinstance(from_config_file, list) else [from_config_file]
31
+
32
+ # Consider a match if any overlap exists
33
+ return any(cv in cmd_versions for cv in cfg_versions)
34
+
35
+
16
36
  # Keys to ignore when comparing sim_telarray configuration files
17
37
  # (e.g., version numbers, system dependent parameters, CORSIKA options)
18
38
  cfg_ignore_keys = [
19
39
  "config_release",
20
40
  "Label",
21
- "simtools_version",
22
- "simtools_model_production_version",
23
- "simtools_build_opt",
24
- "simtools_extra_def",
25
- "simtools_hadronic_model",
26
- "simtools_avx_flag",
27
- "simtools_corsika_version",
28
- "simtools_corsika_opt_patch_version",
29
- "simtools_bernlohr_version",
41
+ "simtools_", # ignore all simtools_ keys - version/build info dependence
30
42
  ]
31
43
 
32
44
 
33
- def validate_application_output(
34
- config, from_command_line=None, from_config_file=None, db_config=None
35
- ):
45
+ def validate_application_output(config, from_command_line=None, from_config_file=None):
36
46
  """
37
47
  Validate application output against expected output.
38
48
 
39
- Expected output is defined in configuration file.
49
+ Expected output is defined in the test configuration file.
40
50
  Some tests run only if the model version from the command line
41
51
  equals the model version from the configuration file.
42
52
 
@@ -60,8 +70,8 @@ def validate_application_output(
60
70
  f"from config file: {from_config_file}"
61
71
  )
62
72
 
63
- if from_command_line == from_config_file:
64
- _validate_output_files(config, integration_test, db_config)
73
+ if _versions_match(from_command_line, from_config_file):
74
+ _validate_output_files(config, integration_test)
65
75
 
66
76
  if "file_type" in integration_test:
67
77
  assert assertions.assert_file_type(
@@ -73,11 +83,13 @@ def validate_application_output(
73
83
  _test_simtel_cfg_files(config, integration_test, from_command_line, from_config_file)
74
84
 
75
85
 
76
- def _validate_output_files(config, integration_test, db_config):
86
+ def _validate_output_files(config, integration_test):
77
87
  """Validate output files."""
78
88
  if "reference_output_file" in integration_test:
79
89
  _validate_reference_output_file(config, integration_test)
80
90
  if "test_output_files" in integration_test:
91
+ if isinstance(integration_test["test_output_files"], dict):
92
+ integration_test["test_output_files"] = [integration_test["test_output_files"]]
81
93
  _validate_output_path_and_file(config, integration_test["test_output_files"])
82
94
  if "output_file" in integration_test:
83
95
  _validate_output_path_and_file(
@@ -85,11 +97,7 @@ def _validate_output_files(config, integration_test, db_config):
85
97
  [{"path_descriptor": "output_path", "file": integration_test["output_file"]}],
86
98
  )
87
99
  if "model_parameter_validation" in integration_test:
88
- _validate_model_parameter_json_file(
89
- config,
90
- integration_test["model_parameter_validation"],
91
- db_config,
92
- )
100
+ _validate_model_parameter_json_file(config, integration_test["model_parameter_validation"])
93
101
 
94
102
 
95
103
  def _test_simtel_cfg_files(config, integration_test, from_command_line, from_config_file):
@@ -110,11 +118,10 @@ def _test_simtel_cfg_files(config, integration_test, from_command_line, from_con
110
118
 
111
119
  def _validate_reference_output_file(config, integration_test):
112
120
  """Compare with reference output file."""
121
+ test_file = integration_test.get("test_output_file") or config["configuration"]["output_file"]
113
122
  assert compare_files(
114
123
  integration_test["reference_output_file"],
115
- Path(config["configuration"]["output_path"]).joinpath(
116
- config["configuration"]["output_file"]
117
- ),
124
+ Path(config["configuration"]["output_path"]).joinpath(test_file),
118
125
  integration_test.get("tolerance", 1.0e-5),
119
126
  integration_test.get("test_columns", None),
120
127
  )
@@ -135,15 +142,20 @@ def _validate_output_path_and_file(config, integration_file_tests):
135
142
  try:
136
143
  assert output_file_path.exists()
137
144
  except AssertionError as exc:
138
- raise AssertionError(f"Output file {output_file_path} does not exist. ") from exc
145
+ raise AssertionError(
146
+ f"Output file {output_file_path} does not exist. "
147
+ f"Directory contents: {list(output_file_path.parent.iterdir())}"
148
+ ) from exc
139
149
 
140
150
  if output_file_path.name.endswith(".simtel.zst"):
141
151
  assert assertions.check_output_from_sim_telarray(output_file_path, file_test)
142
152
  elif output_file_path.name.endswith(".log_hist.tar.gz"):
143
153
  assert assertions.check_simulation_logs(output_file_path, file_test)
154
+ elif output_file_path.suffix == ".log":
155
+ assert assertions.check_plain_log(output_file_path, file_test)
144
156
 
145
157
 
146
- def _validate_model_parameter_json_file(config, model_parameter_validation, db_config):
158
+ def _validate_model_parameter_json_file(config, model_parameter_validation):
147
159
  """
148
160
  Validate model parameter json file and compare it with a reference parameter from the database.
149
161
 
@@ -158,7 +170,7 @@ def _validate_model_parameter_json_file(config, model_parameter_validation, db_c
158
170
 
159
171
  """
160
172
  _logger.info(f"Checking model parameter json file: {model_parameter_validation}")
161
- db = db_handler.DatabaseHandler(db_config=db_config)
173
+ db = db_handler.DatabaseHandler()
162
174
 
163
175
  reference_parameter_name = model_parameter_validation.get("reference_parameter_name")
164
176
 
@@ -178,6 +190,7 @@ def _validate_model_parameter_json_file(config, model_parameter_validation, db_c
178
190
  model_parameter["value"],
179
191
  reference_model_parameter[reference_parameter_name]["value"],
180
192
  model_parameter_validation["tolerance"],
193
+ model_parameter_validation.get("scaling", 1.0),
181
194
  )
182
195
 
183
196
 
@@ -216,12 +229,56 @@ def compare_files(file1, file2, tolerance=1.0e-5, test_columns=None):
216
229
  return False
217
230
 
218
231
 
232
+ def _compare_nested_dicts_with_tolerance(data1, data2, tolerance, is_value_field=False):
233
+ """
234
+ Recursively compare nested dictionaries, applying allclose to "value" fields.
235
+
236
+ Parameters
237
+ ----------
238
+ data1 : dict, list, or scalar
239
+ First data to compare
240
+ data2 : dict, list, or scalar
241
+ Second data to compare
242
+ tolerance : float
243
+ Tolerance for comparing numerical values
244
+ is_value_field : bool
245
+ Whether this data is from a "value" key (applies tolerance to scalars)
246
+
247
+ Returns
248
+ -------
249
+ bool
250
+ True if the data are equal within tolerance
251
+ """
252
+ if isinstance(data1, dict) and isinstance(data2, dict):
253
+ return data1.keys() == data2.keys() and all(
254
+ _compare_nested_dicts_with_tolerance(
255
+ data1[k], data2[k], tolerance, is_value_field=(k == "value")
256
+ )
257
+ for k in data1
258
+ )
259
+
260
+ if isinstance(data1, (list, tuple)) and isinstance(data2, (list, tuple)):
261
+ return len(data1) == len(data2) and all(
262
+ _compare_nested_dicts_with_tolerance(v1, v2, tolerance, is_value_field)
263
+ for v1, v2 in zip(data1, data2)
264
+ )
265
+
266
+ # Apply tolerance if this is a "value" field, otherwise use exact equality
267
+ if is_value_field:
268
+ try:
269
+ return _compare_value_from_parameter_dict(data1, data2, tolerance)
270
+ except (TypeError, ValueError):
271
+ return data1 == data2
272
+ return data1 == data2
273
+
274
+
219
275
  def compare_json_or_yaml_files(file1, file2, tolerance=1.0e-2):
220
276
  """
221
277
  Compare two json or yaml files.
222
278
 
223
279
  Take into account float comparison for sim_telarray string-embedded floats.
224
280
  Allow differences in 'schema_version' field.
281
+ Works recursively for nested dicts with "value" fields on any level.
225
282
 
226
283
  Parameters
227
284
  ----------
@@ -248,24 +305,45 @@ def compare_json_or_yaml_files(file1, file2, tolerance=1.0e-2):
248
305
  if data1 == data2:
249
306
  return True
250
307
 
251
- if data1.keys() != data2.keys():
252
- _logger.error(f"Keys do not match: {data1.keys()} and {data2.keys()}")
253
- return False
254
- _comparison = all(
255
- (
256
- _compare_value_from_parameter_dict(data1[k], data2[k], tolerance)
257
- if k == "value"
258
- else data1[k] == data2[k]
259
- )
260
- for k in data1
308
+ if isinstance(data1, dict) and isinstance(data2, dict):
309
+ if data1.keys() != data2.keys():
310
+ _logger.error(f"Keys do not match: {data1.keys()} and {data2.keys()}")
311
+ return False
312
+
313
+ _comparison = _compare_nested_dicts_with_tolerance(
314
+ data1, data2, tolerance, is_value_field=False
261
315
  )
262
316
  if not _comparison:
263
317
  _logger.error(f"Values do not match: {data1} and {data2} (tolerance: {tolerance})")
264
318
  return _comparison
265
319
 
266
320
 
267
- def _compare_value_from_parameter_dict(data1, data2, tolerance=1.0e-5):
268
- """Compare value fields given in different formats."""
321
+ def _compare_value_from_parameter_dict(data_1, data_2, tolerance=1.0e-5, factor_1=1.0):
322
+ """
323
+ Compare value fields given in different formats.
324
+
325
+ Parameters
326
+ ----------
327
+ data_1 : float, int, str, list, numpy.ndarray
328
+ First value or collection of values to compare. May be a scalar,
329
+ a sequence, a numpy array, or a string representation of a list.
330
+ data_2 : float, int, str, list, numpy.ndarray
331
+ Second value or collection of values to compare, with the same
332
+ allowed formats as ``data_2``.
333
+ tolerance : float, optional
334
+ Relative tolerance used when comparing numerical values via
335
+ ``numpy.allclose``.
336
+ factor1 : float, optional
337
+ Multiplicative factor applied to ``data_1`` before comparison. This
338
+ can be used to account for unit conversions or normalisation
339
+ differences between ``data_1`` and ``data_2``.
340
+
341
+ Returns
342
+ -------
343
+ bool
344
+ True if the two values are considered equal within the given
345
+ tolerance, False otherwise.
346
+ """
269
347
 
270
348
  def _as_list(value):
271
349
  if isinstance(value, str):
@@ -274,12 +352,13 @@ def _compare_value_from_parameter_dict(data1, data2, tolerance=1.0e-5):
274
352
  return value
275
353
  return [value]
276
354
 
277
- _logger.info(f"Comparing values: {data1} and {data2} (tolerance: {tolerance})")
355
+ _logger.info(f"Comparing values: {data_1} and {data_2} (tolerance: {tolerance})")
278
356
 
279
- _as_list_1 = _as_list(data1)
280
- _as_list_2 = _as_list(data2)
357
+ _as_list_1 = _as_list(data_1)
358
+ _as_list_2 = _as_list(data_2)
281
359
  if isinstance(_as_list_1, str):
282
360
  return _as_list_1 == _as_list_2
361
+ _as_list_1 = np.array(_as_list_1) * factor_1
283
362
  return np.allclose(_as_list_1, _as_list_2, rtol=tolerance)
284
363
 
285
364
 
simtools/utils/general.py CHANGED
@@ -11,6 +11,7 @@ import urllib.request
11
11
  from pathlib import Path
12
12
  from urllib.parse import urlparse
13
13
 
14
+ import dotenv
14
15
  import numpy as np
15
16
 
16
17
  _logger = logging.getLogger(__name__)
@@ -581,6 +582,12 @@ def validate_data_type(reference_dtype, value=None, dtype=None, allow_subtypes=T
581
582
  if reference_dtype in ("boolean", "bool"):
582
583
  return _is_valid_boolean_type(dtype, value)
583
584
 
585
+ if reference_dtype == "dict":
586
+ return isinstance(value, dict)
587
+
588
+ if reference_dtype == "list":
589
+ return isinstance(value, list)
590
+
584
591
  return _is_valid_numeric_type(dtype, reference_dtype)
585
592
 
586
593
 
@@ -795,23 +802,6 @@ def find_differences_in_json_objects(obj1, obj2, path=""):
795
802
  return diffs
796
803
 
797
804
 
798
- def clear_default_sim_telarray_cfg_directories(command):
799
- """Prefix the command to clear default sim_telarray configuration directories.
800
-
801
- Parameters
802
- ----------
803
- command: str
804
- Command to be prefixed.
805
-
806
- Returns
807
- -------
808
- str
809
- Prefixed command.
810
-
811
- """
812
- return f"SIM_TELARRAY_CONFIG_PATH='' {command}"
813
-
814
-
815
805
  def get_list_of_files_from_command_line(file_names, suffix_list):
816
806
  """
817
807
  Get a list of files from the command line.
@@ -850,3 +840,39 @@ def get_list_of_files_from_command_line(file_names, suffix_list):
850
840
  def now_date_time_in_isoformat():
851
841
  """Return date and time in isoformat and second accuracy."""
852
842
  return datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")
843
+
844
+
845
+ def load_environment_variables(env_file=".env", env_list=None):
846
+ """
847
+ Load environment variables (from a .env file or directly from the environment).
848
+
849
+ Allow to read a specific list of variables or all variables from the .env file.
850
+
851
+ Parameters
852
+ ----------
853
+ env_file: str
854
+ Path to the .env file.
855
+ env_list: list, optional
856
+ List of environment variables to be read. If None, all variables are read.
857
+
858
+ Returns
859
+ -------
860
+ dict
861
+ Dictionary mapping environment variable names (lowercase, without the
862
+ ``SIMTOOLS_`` prefix) to their cleaned string values.
863
+ """
864
+ dotenv.load_dotenv(env_file or None)
865
+ keys = (
866
+ list(dotenv.dotenv_values(env_file).keys())
867
+ if env_list is None
868
+ else [f"SIMTOOLS_{s.upper()}" for s in env_list]
869
+ )
870
+
871
+ env_values = {}
872
+ for key in keys:
873
+ env_value = os.environ.get(key)
874
+ if env_value is None:
875
+ continue
876
+ cleaned_value = env_value.split("#")[0].strip().replace('"', "").replace("'", "")
877
+ env_values[key.removeprefix("SIMTOOLS_").lower()] = cleaned_value
878
+ return env_values