gammasimtools 0.24.0__py3-none-any.whl → 0.25.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 (59) hide show
  1. {gammasimtools-0.24.0.dist-info → gammasimtools-0.25.0.dist-info}/METADATA +1 -1
  2. {gammasimtools-0.24.0.dist-info → gammasimtools-0.25.0.dist-info}/RECORD +58 -55
  3. {gammasimtools-0.24.0.dist-info → gammasimtools-0.25.0.dist-info}/entry_points.txt +1 -0
  4. simtools/_version.py +2 -2
  5. simtools/application_control.py +50 -0
  6. simtools/applications/derive_psf_parameters.py +5 -0
  7. simtools/applications/derive_pulse_shape_parameters.py +195 -0
  8. simtools/applications/plot_array_layout.py +63 -1
  9. simtools/applications/simulate_flasher.py +3 -2
  10. simtools/applications/simulate_pedestals.py +1 -1
  11. simtools/applications/simulate_prod.py +8 -23
  12. simtools/applications/simulate_prod_htcondor_generator.py +7 -0
  13. simtools/applications/submit_array_layouts.py +5 -3
  14. simtools/applications/validate_file_using_schema.py +49 -123
  15. simtools/configuration/commandline_parser.py +8 -6
  16. simtools/corsika/corsika_config.py +197 -87
  17. simtools/data_model/model_data_writer.py +14 -2
  18. simtools/data_model/schema.py +112 -5
  19. simtools/data_model/validate_data.py +82 -48
  20. simtools/db/db_model_upload.py +2 -1
  21. simtools/db/mongo_db.py +133 -42
  22. simtools/dependencies.py +5 -9
  23. simtools/io/eventio_handler.py +128 -0
  24. simtools/job_execution/htcondor_script_generator.py +0 -2
  25. simtools/layout/array_layout_utils.py +1 -1
  26. simtools/model/array_model.py +36 -5
  27. simtools/model/model_parameter.py +0 -1
  28. simtools/model/model_repository.py +18 -5
  29. simtools/ray_tracing/psf_analysis.py +11 -8
  30. simtools/ray_tracing/psf_parameter_optimisation.py +822 -679
  31. simtools/reporting/docs_read_parameters.py +69 -9
  32. simtools/runners/corsika_runner.py +12 -3
  33. simtools/runners/corsika_simtel_runner.py +6 -0
  34. simtools/runners/runner_services.py +17 -7
  35. simtools/runners/simtel_runner.py +12 -54
  36. simtools/schemas/model_parameters/flasher_pulse_exp_decay.schema.yml +2 -0
  37. simtools/schemas/model_parameters/flasher_pulse_shape.schema.yml +50 -0
  38. simtools/schemas/model_parameters/flasher_pulse_width.schema.yml +2 -0
  39. simtools/schemas/simulation_models_info.schema.yml +2 -0
  40. simtools/simtel/pulse_shapes.py +268 -0
  41. simtools/simtel/simtel_config_writer.py +82 -1
  42. simtools/simtel/simtel_io_event_writer.py +2 -2
  43. simtools/simtel/simulator_array.py +58 -12
  44. simtools/simtel/simulator_light_emission.py +45 -8
  45. simtools/simulator.py +361 -347
  46. simtools/testing/assertions.py +62 -6
  47. simtools/testing/configuration.py +1 -1
  48. simtools/testing/log_inspector.py +4 -1
  49. simtools/testing/sim_telarray_metadata.py +1 -1
  50. simtools/testing/validate_output.py +44 -9
  51. simtools/utils/names.py +2 -4
  52. simtools/version.py +37 -0
  53. simtools/visualization/legend_handlers.py +14 -4
  54. simtools/visualization/plot_array_layout.py +229 -33
  55. simtools/visualization/plot_mirrors.py +837 -0
  56. simtools/simtel/simtel_io_file_info.py +0 -62
  57. {gammasimtools-0.24.0.dist-info → gammasimtools-0.25.0.dist-info}/WHEEL +0 -0
  58. {gammasimtools-0.24.0.dist-info → gammasimtools-0.25.0.dist-info}/licenses/LICENSE +0 -0
  59. {gammasimtools-0.24.0.dist-info → gammasimtools-0.25.0.dist-info}/top_level.txt +0 -0
@@ -7,12 +7,9 @@ import numpy as np
7
7
  from astropy import units as u
8
8
 
9
9
  from simtools.corsika.primary_particle import PrimaryParticle
10
- from simtools.io import io_handler
10
+ from simtools.io import eventio_handler, io_handler
11
11
  from simtools.model.model_parameter import ModelParameter
12
-
13
-
14
- class InvalidCorsikaInputError(Exception):
15
- """Exception for invalid corsika input."""
12
+ from simtools.utils import general as gen
16
13
 
17
14
 
18
15
  class CorsikaConfig:
@@ -45,53 +42,80 @@ class CorsikaConfig:
45
42
  self._logger.debug("Init CorsikaConfig")
46
43
 
47
44
  self.label = label
48
- self.zenith_angle = None
49
- self.azimuth_angle = None
45
+ self.shower_events = self.mc_events = None
46
+ self.zenith_angle = self.azimuth_angle = None
47
+ self.curved_atmosphere_min_zenith_angle = None
50
48
  self._run_number = None
51
49
  self.config_file_path = None
52
50
  self.primary_particle = args_dict # see setter for primary_particle
51
+ self.use_curved_atmosphere = args_dict # see setter for use_curved_atmosphere
53
52
  self.dummy_simulations = dummy_simulations
54
53
 
55
54
  self.io_handler = io_handler.IOHandler()
56
55
  self.array_model = array_model
57
- self.config = self.fill_corsika_configuration(args_dict, db_config)
56
+ self.config = self._fill_corsika_configuration(args_dict, db_config)
57
+ self._initialize_from_config(args_dict)
58
58
  self.is_file_updated = False
59
59
 
60
- def __repr__(self):
61
- """CorsikaConfig class representation."""
62
- return (
63
- f"<class {self.__class__.__name__}> "
64
- f"(site={self.array_model.site}, "
65
- f"layout={self.array_model.layout_name}, label={self.label})"
66
- )
67
-
68
60
  @property
69
61
  def primary_particle(self):
70
62
  """Primary particle."""
71
63
  return self._primary_particle
72
64
 
73
65
  @primary_particle.setter
74
- def primary_particle(self, args_dict):
66
+ def primary_particle(self, args):
75
67
  """
76
- Set primary particle from input dictionary.
68
+ Set primary particle from input dictionary or CORSIKA 7 particle ID.
77
69
 
78
70
  This is to make sure that when setting the primary particle,
79
71
  we get the full PrimaryParticle object expected.
80
72
 
81
73
  Parameters
82
74
  ----------
83
- args_dict: dict
75
+ args: dict, corsika particle ID, or None
84
76
  Configuration dictionary
85
77
  """
86
- self._primary_particle = self._set_primary_particle(args_dict)
78
+ if (
79
+ isinstance(args, dict)
80
+ and args.get("primary_id_type") is not None
81
+ and args.get("primary") is not None
82
+ ):
83
+ self._primary_particle = PrimaryParticle(
84
+ particle_id_type=args.get("primary_id_type"), particle_id=args.get("primary")
85
+ )
86
+ elif isinstance(args, int):
87
+ self._primary_particle = PrimaryParticle(
88
+ particle_id_type="corsika7_id", particle_id=args
89
+ )
90
+ else:
91
+ self._primary_particle = PrimaryParticle()
92
+
93
+ @property
94
+ def use_curved_atmosphere(self):
95
+ """Check if zenith angle condition for curved atmosphere usage for CORSIKA is met."""
96
+ return self._use_curved_atmosphere
97
+
98
+ @use_curved_atmosphere.setter
99
+ def use_curved_atmosphere(self, args):
100
+ """Check if zenith angle condition for curved atmosphere usage for CORSIKA is met."""
101
+ self._use_curved_atmosphere = False
102
+ if isinstance(args, bool):
103
+ self._use_curved_atmosphere = args
104
+ elif isinstance(args, dict):
105
+ try:
106
+ self._use_curved_atmosphere = (
107
+ args.get("zenith_angle", 0.0 * u.deg).to("deg").value
108
+ > args["curved_atmosphere_min_zenith_angle"].to("deg").value
109
+ )
110
+ except KeyError:
111
+ self._use_curved_atmosphere = False
87
112
 
88
- def fill_corsika_configuration(self, args_dict, db_config=None):
113
+ def _fill_corsika_configuration(self, args_dict, db_config=None):
89
114
  """
90
115
  Fill CORSIKA configuration.
91
116
 
92
- Dictionary keys are CORSIKA parameter names.
93
- Values are converted to CORSIKA-consistent units.
94
-
117
+ Dictionary keys are CORSIKA parameter names. Values are converted to
118
+ CORSIKA-consistent units.
95
119
 
96
120
  Parameters
97
121
  ----------
@@ -108,31 +132,34 @@ class CorsikaConfig:
108
132
  if args_dict is None:
109
133
  return {}
110
134
 
111
- self.is_file_updated = False
112
- self.azimuth_angle = int(args_dict.get("azimuth_angle", 0.0 * u.deg).to("deg").value)
113
- self.zenith_angle = int(args_dict.get("zenith_angle", 0.0 * u.deg).to("deg").value)
114
-
115
- self._logger.debug(
116
- f"Setting CORSIKA parameters from database ({args_dict['model_version']})"
117
- )
118
-
119
135
  config = {}
120
136
  if self.dummy_simulations:
121
- config["USER_INPUT"] = self._corsika_configuration_for_dummy_simulations()
137
+ config["USER_INPUT"] = self._corsika_configuration_for_dummy_simulations(args_dict)
138
+ elif args_dict.get("corsika_file", None) is not None:
139
+ config["USER_INPUT"] = self._corsika_configuration_from_corsika_file(
140
+ args_dict["corsika_file"]
141
+ )
122
142
  else:
123
143
  config["USER_INPUT"] = self._corsika_configuration_from_user_input(args_dict)
124
144
 
145
+ config.update(
146
+ self._fill_corsika_configuration_from_db(
147
+ gen.ensure_iterable(args_dict.get("model_version")), db_config
148
+ )
149
+ )
150
+ return config
151
+
152
+ def _fill_corsika_configuration_from_db(self, model_versions, db_config):
153
+ """Fill CORSIKA configuration from database."""
154
+ config = {}
125
155
  if db_config is None: # all following parameter require DB
126
156
  return config
127
157
 
128
- # If the user provided multiple model versions, we take the first one
129
- # because for CORSIKA config we need only one and it doesn't matter which
130
- model_versions = args_dict.get("model_version", None)
131
- if not isinstance(model_versions, list):
132
- model_versions = [model_versions]
158
+ # For multiple model versions, check that CORSIKA parameters are identical
133
159
  self.assert_corsika_configurations_match(model_versions, db_config=db_config)
134
160
  model_version = model_versions[0]
135
- self._logger.debug(f"Using model version {model_version} for CORSIKA parameters")
161
+
162
+ self._logger.debug(f"Using model version {model_version} for CORSIKA parameters from DB")
136
163
  db_model_parameters = ModelParameter(db_config=db_config, model_version=model_version)
137
164
  parameters_from_db = db_model_parameters.get_simulation_software_parameters("corsika")
138
165
 
@@ -144,9 +171,41 @@ class CorsikaConfig:
144
171
  )
145
172
  config["DEBUGGING_OUTPUT_PARAMETERS"] = self._corsika_configuration_debugging_parameters()
146
173
  config["IACT_PARAMETERS"] = self._corsika_configuration_iact_parameters(parameters_from_db)
147
-
148
174
  return config
149
175
 
176
+ def _initialize_from_config(self, args_dict):
177
+ """
178
+ Initialize additional parameters either from command line args or from derived config.
179
+
180
+ Takes into account that in the case of a given CORSIKA input file, some parameters are read
181
+ from the file instead of the command line args.
182
+
183
+ """
184
+ self.primary_particle = int(self.config.get("USER_INPUT", {}).get("PRMPAR", [1])[0])
185
+ self.shower_events = int(self.config.get("USER_INPUT", {}).get("NSHOW", [0])[0])
186
+ self.mc_events = int(
187
+ self.shower_events * self.config.get("USER_INPUT", {}).get("CSCAT", [1])[0]
188
+ )
189
+
190
+ if args_dict.get("corsika_file", None) is not None:
191
+ azimuth = self._rotate_azimuth_by_180deg(
192
+ 0.5 * (self.config["USER_INPUT"]["PHIP"][0] + self.config["USER_INPUT"]["PHIP"][1]),
193
+ invert_operation=True,
194
+ )
195
+ zenith = 0.5 * (
196
+ self.config["USER_INPUT"]["THETAP"][0] + self.config["USER_INPUT"]["THETAP"][1]
197
+ )
198
+ else:
199
+ azimuth = args_dict.get("azimuth_angle", 0.0 * u.deg).to("deg").value
200
+ zenith = args_dict.get("zenith_angle", 20.0 * u.deg).to("deg").value
201
+
202
+ self.azimuth_angle = round(azimuth)
203
+ self.zenith_angle = round(zenith)
204
+
205
+ self.curved_atmosphere_min_zenith_angle = (
206
+ args_dict.get("curved_atmosphere_min_zenith_angle", 90.0 * u.deg).to("deg").value
207
+ )
208
+
150
209
  def assert_corsika_configurations_match(self, model_versions, db_config=None):
151
210
  """
152
211
  Assert that CORSIKA configurations match across all model versions.
@@ -193,13 +252,13 @@ class CorsikaConfig:
193
252
  f" {model_versions[i]}: {current_value}\n"
194
253
  f" {model_versions[i + 1]}: {next_value}"
195
254
  )
196
- raise InvalidCorsikaInputError(
255
+ raise ValueError(
197
256
  f"CORSIKA parameter '{key}' differs between model versions "
198
257
  f"{model_versions[i]} and {model_versions[i + 1]}. "
199
258
  f"Values are {current_value} and {next_value} respectively."
200
259
  )
201
260
 
202
- def _corsika_configuration_for_dummy_simulations(self):
261
+ def _corsika_configuration_for_dummy_simulations(self, args_dict):
203
262
  """
204
263
  Return CORSIKA configuration for dummy simulations.
205
264
 
@@ -211,18 +270,75 @@ class CorsikaConfig:
211
270
  dict
212
271
  Dictionary with CORSIKA parameters for dummy simulations.
213
272
  """
273
+ theta, phi = self._get_corsika_theta_phi(args_dict)
214
274
  return {
215
275
  "EVTNR": [1],
216
276
  "NSHOW": [1],
217
277
  "PRMPAR": [1], # CORSIKA ID 1 for primary gamma
218
278
  "ESLOPE": [-2.0],
219
279
  "ERANGE": [0.1, 0.1],
220
- "THETAP": [20.0, 20.0],
221
- "PHIP": [0.0, 0.0],
280
+ "THETAP": [theta, theta],
281
+ "PHIP": [phi, phi],
222
282
  "VIEWCONE": [0.0, 0.0],
223
283
  "CSCAT": [1, 0.0, 10.0],
224
284
  }
225
285
 
286
+ def _corsika_configuration_from_corsika_file(self, corsika_input_file):
287
+ """
288
+ Get CORSIKA configuration run header of provided input files.
289
+
290
+ Reads configuration from the run and event headers from the CORSIKA input file
291
+ (unfortunately quite fine tuned to the pycorsikaio run and event
292
+ header implementation).
293
+
294
+ Parameters
295
+ ----------
296
+ corsika_input_file : str, path
297
+ Path to the CORSIKA input file.
298
+
299
+ Returns
300
+ -------
301
+ dict
302
+ Dictionary with CORSIKA parameters from input file.
303
+ """
304
+ run_header, event_header = eventio_handler.get_corsika_run_and_event_headers(
305
+ corsika_input_file
306
+ )
307
+ self._logger.debug(f"CORSIKA run header from {corsika_input_file}")
308
+
309
+ def to_float32(value):
310
+ """Convert value to numpy float32."""
311
+ return np.float32(value) if value is not None else 0.0
312
+
313
+ def to_int32(value):
314
+ """Convert value to numpy int32."""
315
+ return np.int32(value) if value is not None else 0
316
+
317
+ if run_header["n_observation_levels"] > 0:
318
+ self._check_altitude_and_site(run_header["observation_height"][0])
319
+
320
+ return {
321
+ "EVTNR": [to_int32(event_header["event_number"])],
322
+ "NSHOW": [to_int32(run_header["n_showers"])],
323
+ "PRMPAR": [to_int32(event_header["particle_id"])],
324
+ "ESLOPE": [to_float32(run_header["energy_spectrum_slope"])],
325
+ "ERANGE": [to_float32(run_header["energy_min"]), to_float32(run_header["energy_max"])],
326
+ "THETAP": [
327
+ to_float32(event_header["theta_min"]),
328
+ to_float32(event_header["theta_max"]),
329
+ ],
330
+ "PHIP": [to_float32(event_header["phi_min"]), to_float32(event_header["phi_max"])],
331
+ "VIEWCONE": [
332
+ to_float32(event_header["viewcone_inner_angle"]),
333
+ to_float32(event_header["viewcone_outer_angle"]),
334
+ ],
335
+ "CSCAT": [
336
+ to_int32(event_header["n_reuse"]),
337
+ to_float32(event_header["reuse_x"]),
338
+ to_float32(event_header["reuse_y"]),
339
+ ],
340
+ }
341
+
226
342
  def _corsika_configuration_from_user_input(self, args_dict):
227
343
  """
228
344
  Get CORSIKA configuration from user input.
@@ -237,6 +353,7 @@ class CorsikaConfig:
237
353
  dict
238
354
  Dictionary with CORSIKA parameters.
239
355
  """
356
+ theta, phi = self._get_corsika_theta_phi(args_dict)
240
357
  return {
241
358
  "EVTNR": [args_dict["event_number_first_shower"]],
242
359
  "NSHOW": [args_dict["nshow"]],
@@ -246,24 +363,8 @@ class CorsikaConfig:
246
363
  args_dict["energy_range"][0].to("GeV").value,
247
364
  args_dict["energy_range"][1].to("GeV").value,
248
365
  ],
249
- "THETAP": [
250
- float(args_dict["zenith_angle"].to("deg").value),
251
- float(args_dict["zenith_angle"].to("deg").value),
252
- ],
253
- "PHIP": [
254
- self._rotate_azimuth_by_180deg(
255
- args_dict["azimuth_angle"].to("deg").value,
256
- correct_for_geomagnetic_field_alignment=args_dict[
257
- "correct_for_b_field_alignment"
258
- ],
259
- ),
260
- self._rotate_azimuth_by_180deg(
261
- args_dict["azimuth_angle"].to("deg").value,
262
- correct_for_geomagnetic_field_alignment=args_dict[
263
- "correct_for_b_field_alignment"
264
- ],
265
- ),
266
- ],
366
+ "THETAP": [theta, theta],
367
+ "PHIP": [phi, phi],
267
368
  "VIEWCONE": [
268
369
  args_dict["view_cone"][0].to("deg").value,
269
370
  args_dict["view_cone"][1].to("deg").value,
@@ -275,6 +376,26 @@ class CorsikaConfig:
275
376
  ],
276
377
  }
277
378
 
379
+ def _check_altitude_and_site(self, observation_height):
380
+ """Check that observation height from CORSIKA file matches site model."""
381
+ site_altitude = self.array_model.site_model.get_parameter_value("corsika_observation_level")
382
+ if not np.isclose(observation_height / 1.0e2, site_altitude, atol=1.0):
383
+ raise ValueError(
384
+ "Observatory altitude does not match CORSIKA file observation height: "
385
+ f"{site_altitude} m (site model) != {observation_height / 1.0e2} m (CORSIKA file)"
386
+ )
387
+
388
+ def _get_corsika_theta_phi(self, args_dict):
389
+ """Get CORSIKA theta and phi angles from args_dict."""
390
+ theta = args_dict.get("zenith_angle", 20.0 * u.deg).to("deg").value
391
+ phi = self._rotate_azimuth_by_180deg(
392
+ args_dict.get("azimuth_angle", 0.0 * u.deg).to("deg").value,
393
+ correct_for_geomagnetic_field_alignment=args_dict.get(
394
+ "correct_for_b_field_alignment", True
395
+ ),
396
+ )
397
+ return theta, phi
398
+
278
399
  def _corsika_configuration_interaction_flags(self, parameters_from_db):
279
400
  """
280
401
  Return CORSIKA interaction flags / parameters.
@@ -298,7 +419,8 @@ class CorsikaConfig:
298
419
  parameters_from_db["corsika_starting_grammage"]
299
420
  )
300
421
  ]
301
- parameters["TSTART"] = ["T"]
422
+ if not self.use_curved_atmosphere:
423
+ parameters["TSTART"] = ["T"]
302
424
  parameters["ECUTS"] = self._input_config_corsika_particle_kinetic_energy_cutoff(
303
425
  parameters_from_db["corsika_particle_kinetic_energy_cutoff"]
304
426
  )
@@ -444,16 +566,23 @@ class CorsikaConfig:
444
566
  return f"{int(value)}MB"
445
567
  return f"{int(entry['value'] * u.Unit(entry['unit']).to('byte'))}"
446
568
 
447
- def _rotate_azimuth_by_180deg(self, az, correct_for_geomagnetic_field_alignment=True):
569
+ def _rotate_azimuth_by_180deg(
570
+ self, az, correct_for_geomagnetic_field_alignment=True, invert_operation=False
571
+ ):
448
572
  """
449
573
  Convert azimuth angle to the CORSIKA coordinate system.
450
574
 
575
+ Corresponds to a rotation by 180 degrees, and optionally a correction for the
576
+ for the differences between the geographic and geomagnetic north pole.
577
+
451
578
  Parameters
452
579
  ----------
453
580
  az: float
454
581
  Azimuth angle in degrees.
455
582
  correct_for_geomagnetic_field_alignment: bool
456
583
  Whether to correct for the geomagnetic field alignment.
584
+ invert_operation: bool
585
+ Whether to invert the operation (i.e., convert from CORSIKA to geographic system).
457
586
 
458
587
  Returns
459
588
  -------
@@ -463,6 +592,8 @@ class CorsikaConfig:
463
592
  b_field_declination = 0
464
593
  if correct_for_geomagnetic_field_alignment:
465
594
  b_field_declination = self.array_model.site_model.get_parameter_value("geomag_rotation")
595
+ if invert_operation:
596
+ return (az - 180 - b_field_declination) % 360
466
597
  return (az + 180 + b_field_declination) % 360
467
598
 
468
599
  @property
@@ -470,27 +601,6 @@ class CorsikaConfig:
470
601
  """Primary particle name."""
471
602
  return self.primary_particle.name
472
603
 
473
- def _set_primary_particle(self, args_dict):
474
- """
475
- Set primary particle from input dictionary.
476
-
477
- Parameters
478
- ----------
479
- args_dict: dict
480
- Input dictionary.
481
-
482
- Returns
483
- -------
484
- PrimaryParticle
485
- Primary particle.
486
-
487
- """
488
- if not args_dict or args_dict.get("primary_id_type") is None:
489
- return PrimaryParticle()
490
- return PrimaryParticle(
491
- particle_id_type=args_dict.get("primary_id_type"), particle_id=args_dict.get("primary")
492
- )
493
-
494
604
  def get_config_parameter(self, par_name):
495
605
  """
496
606
  Get value of CORSIKA configuration parameter.
@@ -674,7 +784,7 @@ class CorsikaConfig:
674
784
 
675
785
  base_name = (
676
786
  f"{self.primary_particle.name}_{run_number_in_file_name}"
677
- f"za{int(self.get_config_parameter('THETAP')[0]):03}deg_"
787
+ f"za{int(self.get_config_parameter('THETAP')[0]):02}deg_"
678
788
  f"azm{self.azimuth_angle:03}deg{view_cone}_"
679
789
  f"{self.array_model.site}_{self.array_model.layout_name}_"
680
790
  f"{self.array_model.model_version}{file_label}"
@@ -685,7 +795,7 @@ class CorsikaConfig:
685
795
  if file_type == "config":
686
796
  return f"corsika_config_{base_name}.input"
687
797
  if file_type == "output_generic":
688
- return f"{base_name}.zst"
798
+ return f"{base_name}.corsika.zst"
689
799
  if file_type == "multipipe":
690
800
  return f"multi_cta-{self.array_model.site}-{self.array_model.layout_name}.cfg"
691
801
 
@@ -101,6 +101,7 @@ class ModelDataWriter:
101
101
  db_config=None,
102
102
  unit=None,
103
103
  meta_parameter=False,
104
+ model_parameter_schema_version=None,
104
105
  ):
105
106
  """
106
107
  Generate DB-style model parameter dict and write it to json file.
@@ -125,6 +126,10 @@ class ModelDataWriter:
125
126
  Database configuration. If not None, check if parameter with the same version exists.
126
127
  unit: str
127
128
  Unit of the parameter value (if applicable and value is not of type astropy Quantity).
129
+ meta_parameter: bool
130
+ Setting for meta parameter flag.
131
+ model_parameter_schema_version: str, None
132
+ Version of the model parameter schema (if None, use schema version from schema dict).
128
133
 
129
134
  Returns
130
135
  -------
@@ -158,6 +163,7 @@ class ModelDataWriter:
158
163
  instrument,
159
164
  parameter_version,
160
165
  unique_id,
166
+ model_parameter_schema_version=model_parameter_schema_version,
161
167
  unit=unit,
162
168
  meta_parameter=meta_parameter,
163
169
  )
@@ -211,6 +217,7 @@ class ModelDataWriter:
211
217
  schema_version=None,
212
218
  unit=None,
213
219
  meta_parameter=False,
220
+ model_parameter_schema_version=None,
214
221
  ):
215
222
  """
216
223
  Get validated parameter dictionary.
@@ -233,6 +240,8 @@ class ModelDataWriter:
233
240
  Unit of the parameter value (if applicable and value is not an astropy Quantity).
234
241
  meta_parameter: bool
235
242
  Setting for meta parameter flag.
243
+ model_parameter_schema_version: str, None
244
+ Version of the model parameter schema (if None, use schema version from schema dict).
236
245
 
237
246
  Returns
238
247
  -------
@@ -240,7 +249,9 @@ class ModelDataWriter:
240
249
  Validated parameter dictionary.
241
250
  """
242
251
  self._logger.debug(f"Getting validated parameter dictionary for {instrument}")
243
- self.schema_dict, schema_file = self._read_schema_dict(parameter_name, schema_version)
252
+ self.schema_dict, schema_file = self._read_schema_dict(
253
+ parameter_name, model_parameter_schema_version
254
+ )
244
255
 
245
256
  if unit is None:
246
257
  value, unit = value_conversion.split_value_and_unit(value)
@@ -257,7 +268,8 @@ class ModelDataWriter:
257
268
  "type": self._get_parameter_type(),
258
269
  "file": self._parameter_is_a_file(),
259
270
  "meta_parameter": meta_parameter,
260
- "model_parameter_schema_version": self.schema_dict.get("schema_version", "0.1.0"),
271
+ "model_parameter_schema_version": model_parameter_schema_version
272
+ or self.schema_dict.get("schema_version", "0.1.0"),
261
273
  }
262
274
  return self.validate_and_transform(
263
275
  product_data_dict=data_dict,
@@ -146,11 +146,10 @@ def validate_dict_using_schema(
146
146
 
147
147
  def _validate_meta_schema_url(data):
148
148
  """Validate meta_schema_url if present in data."""
149
- if (
150
- isinstance(data, dict)
151
- and data.get("meta_schema_url")
152
- and not gen.url_exists(data["meta_schema_url"])
153
- ):
149
+ if not isinstance(data, dict):
150
+ return
151
+
152
+ if data.get("meta_schema_url") is not None and not gen.url_exists(data["meta_schema_url"]):
154
153
  raise FileNotFoundError(f"Meta schema URL does not exist: {data['meta_schema_url']}")
155
154
 
156
155
 
@@ -355,3 +354,111 @@ def validate_deprecation_and_version(data, software_name=None, ignore_software_v
355
354
  _logger.warning(f"{msg}, but version check is ignored.")
356
355
  else:
357
356
  raise ValueError(msg)
357
+
358
+
359
+ def validate_schema_from_files(
360
+ file_directory, file_name=None, schema_file=None, ignore_software_version=False
361
+ ):
362
+ """
363
+ Validate a schema file or several files in a directory.
364
+
365
+ Files to be validated are taken from file_directory and file_name pattern.
366
+ The schema is either given as command line argument, read from the meta_schema_url or from
367
+ the metadata section of the data dictionary.
368
+
369
+ Parameters
370
+ ----------
371
+ file_directory : str or Path, optional
372
+ Directory with files to be validated.
373
+ file_name : str or Path, optional
374
+ File name pattern to be validated.
375
+ schema_file : str, optional
376
+ Schema file name provided directly.
377
+ ignore_software_version : bool
378
+ If True, ignore software version check.
379
+ """
380
+ if file_directory and file_name:
381
+ file_list = sorted(Path(file_directory).rglob(file_name))
382
+ else:
383
+ file_list = [Path(file_name)] if file_name else []
384
+
385
+ for _file_name in file_list:
386
+ try:
387
+ data = ascii_handler.collect_data_from_file(file_name=_file_name)
388
+ except FileNotFoundError as exc:
389
+ raise FileNotFoundError(f"Error reading schema file from {_file_name}") from exc
390
+ data = data if isinstance(data, list) else [data]
391
+ try:
392
+ for data_dict in data:
393
+ validate_dict_using_schema(
394
+ data_dict,
395
+ _get_schema_file_name(schema_file, _file_name, data_dict),
396
+ ignore_software_version=ignore_software_version,
397
+ )
398
+ except Exception as exc:
399
+ raise ValueError(f"Validation of file {_file_name} failed") from exc
400
+ _logger.info(f"Successful validation of file {_file_name}")
401
+
402
+
403
+ def _get_schema_file_name(schema_file=None, file_name=None, data_dict=None):
404
+ """
405
+ Get schema file name from metadata, data dict, or from file.
406
+
407
+ Parameters
408
+ ----------
409
+ schema_file : str, optional
410
+ Schema file name provided directly.
411
+ file_name : str or Path, optional
412
+ File name to extract schema information from.
413
+ data_dict : dict, optional
414
+ Dictionary with metaschema information.
415
+
416
+ Returns
417
+ -------
418
+ str or None
419
+ Schema file name.
420
+ """
421
+ if schema_file is not None:
422
+ return schema_file
423
+
424
+ if data_dict and (url := data_dict.get("meta_schema_url")):
425
+ return url
426
+
427
+ if file_name:
428
+ return _extract_schema_from_file(file_name)
429
+
430
+ return None
431
+
432
+
433
+ def _extract_schema_url_from_metadata_dict(metadata, observatory="cta"):
434
+ """Extract schema URL from metadata dictionary."""
435
+ for key in (observatory, observatory.lower()):
436
+ url = metadata.get(key, {}).get("product", {}).get("data", {}).get("model", {}).get("url")
437
+ if url:
438
+ return url
439
+ return None
440
+
441
+
442
+ def _extract_schema_from_file(file_name, observatory="cta"):
443
+ """
444
+ Extract schema file name from a metadata or data file.
445
+
446
+ Parameters
447
+ ----------
448
+ file_name : str or Path
449
+ File name to extract schema information from.
450
+ observatory : str
451
+ Observatory name (default: "cta").
452
+
453
+ Returns
454
+ -------
455
+ str or None
456
+ Schema file name or None if not found.
457
+
458
+ """
459
+ try:
460
+ metadata = ascii_handler.collect_data_from_file(file_name=file_name, yaml_document=0)
461
+ except FileNotFoundError:
462
+ return None
463
+
464
+ return _extract_schema_url_from_metadata_dict(metadata, observatory)