Source code for configuration.commandline_parser

"""Command line parser for applications."""

import argparse
import logging
import re
from pathlib import Path

import astropy.units as u

import simtools.version
from simtools.utils import names

__all__ = [
    "CommandLineParser",
]


[docs] class CommandLineParser(argparse.ArgumentParser): """ Command line parser for applications. Wrapper around standard python argparse.ArgumentParser. Command line arguments should be given in snake_case, e.g. input_meta. Parameters ---------- argparse.ArgumentParser class Object for parsing command line strings into Python objects. For a list of keywords, please\ refer to argparse.ArgumentParser documentation. """
[docs] def initialize_default_arguments( self, paths=True, output=False, simulation_model=None, simulation_configuration=None, db_config=False, job_submission=False, ): """ Initialize default arguments used by all applications (e.g., log level or test flag). Parameters ---------- paths: bool Add path configuration to list of args. output: bool Add output file configuration to list of args. simulation_model: list List of simulation model configuration parameters to add to list of args (use: 'version', 'telescope', 'site') simulation_configuration: dict Dict of simulation software configuration parameters to add to list of args. db_config: bool Add database configuration parameters to list of args. job_submission: bool Add job submission configuration parameters to list of args. """ self.initialize_simulation_model_arguments(simulation_model) self.initialize_simulation_configuration_arguments(simulation_configuration) if job_submission: self.initialize_job_submission_arguments() if db_config: self.initialize_db_config_arguments() if paths: self.initialize_path_arguments() if output: self.initialize_output_arguments() self.initialize_config_files() self.initialize_application_execution_arguments()
[docs] def initialize_config_files(self): """Initialize configuration files.""" _job_group = self.add_argument_group("configuration") _job_group.add_argument( "--config", help="simtools configuration file", default=None, type=str, required=False, ) _job_group.add_argument( "--env_file", help="file with environment variables", default=".env", type=str, required=False, )
[docs] def initialize_path_arguments(self): """Initialize paths.""" _job_group = self.add_argument_group("paths") _job_group.add_argument( "--data_path", help="path pointing towards data directory", type=Path, default="./data/", required=False, ) _job_group.add_argument( "--output_path", help="path pointing towards output directory", type=Path, default="./simtools-output/", required=False, ) _job_group.add_argument( "--use_plain_output_path", help="use plain output path (without the tool name and dates)", action="store_true", required=False, ) _job_group.add_argument( "--model_path", help="path pointing towards simulation model file directory", type=Path, default="./", required=False, ) _job_group.add_argument( "--simtel_path", help="path pointing to sim_telarray installation", type=Path, required=False, )
[docs] def initialize_output_arguments(self): """Initialize application output files(s).""" _job_group = self.add_argument_group("output") _job_group.add_argument( "--output_file", help="output data file", type=str, required=False, ) _job_group.add_argument( "--output_file_format", help="file format of output data", type=str, default="ecsv", required=False, ) _job_group.add_argument( "--skip_output_validation", help="skip output data validation against schema", default=False, required=False, action="store_true", )
[docs] def initialize_application_execution_arguments(self): """Initialize application execution arguments.""" _job_group = self.add_argument_group("execution") _job_group.add_argument( "--test", help="test option for faster execution during development", action="store_true", required=False, ) _job_group.add_argument( "--label", help="job label", required=False, ) _job_group.add_argument( "--log_level", action="store", default="info", help="log level to print", required=False, ) _job_group.add_argument( "--version", action="version", version=f"%(prog)s {simtools.version.__version__}" )
[docs] def initialize_db_config_arguments(self): """Initialize DB configuration parameters.""" _job_group = self.add_argument_group("database configuration") _job_group.add_argument("--db_api_user", help="database user", type=str, required=False) _job_group.add_argument("--db_api_pw", help="database password", type=str, required=False) _job_group.add_argument("--db_api_port", help="database port", type=int, required=False) _job_group.add_argument( "--db_server", help="database server address", type=str, required=False ) _job_group.add_argument( "--db_api_authentication_database", help="database with user info (optional)", type=str, required=False, default="admin", ) _job_group.add_argument( "--db_simulation_model", help="name of simulation model database", type=str.strip, required=False, default=None, ) _job_group.add_argument( "--db_simulation_model_url", help="simulation model repository URL", type=str, required=False, default=None, )
[docs] def initialize_job_submission_arguments(self): """Initialize job submission arguments for simulator.""" _job_group = self.add_argument_group("job submission") _job_group.add_argument( "--submit_engine", help="job submission command", type=str, required=True, choices=[ "qsub", "htcondor", "local", ], default="local", ) _job_group.add_argument( "--submit_options", help="additional options (comma separated) for submission command", type=str, required=False, )
[docs] def initialize_simulation_model_arguments(self, model_options): """ Initialize default arguments for simulation model definition. Note that the model version is always required. Parameters ---------- model_options: list Options to be set: "telescope", "site", "layout", "layout_file" """ if model_options is None: return _job_group = self.add_argument_group("simulation model") _job_group.add_argument( "--model_version", help="model version", type=str, default=None, ) if any( option in model_options for option in ["site", "telescope", "layout", "layout_file"] ): self._add_model_option_site(_job_group) if "telescope" in model_options: _job_group.add_argument( "--telescope", help="telescope model name (e.g., LSTN-01, SSTS-design, ...)", type=self.telescope, ) _job_group.add_argument( "--telescope_model_file", help=( "File with changes to telescope model " " (yaml format; experimental with insufficient validation steps)." ), type=Path, required=False, ) if "layout" in model_options or "layout_file" in model_options: _job_group = self._add_model_option_layout( job_group=_job_group, add_layout_file="layout_file" in model_options, # layout info is always required for layout related tasks with the exception # of listing the available layouts in the DB required="--list_available_layouts" not in self._option_string_actions, )
[docs] def initialize_simulation_configuration_arguments(self, simulation_configuration): """ Initialize default arguments for simulation configuration and simulation software. Parameters ---------- simulation_configuration: dict Dict of simulation software configuration parameters. """ if simulation_configuration is None: return if "software" in simulation_configuration: self._initialize_simulation_software() if "corsika_configuration" in simulation_configuration: self._initialize_simulation_configuration( group_name="simulation configuration", selected_parameters=simulation_configuration["corsika_configuration"], available_parameters=self._get_dictionary_with_corsika_configuration(), ) self._initialize_simulation_configuration( group_name="shower parameters", selected_parameters=simulation_configuration["corsika_configuration"], available_parameters=self._get_dictionary_with_shower_configuration(), )
def _initialize_simulation_software(self): """Initialize simulation software arguments.""" _software_group = self.add_argument_group("simulation software") _software_group.add_argument( "--simulation_software", help="Simulation software steps.", type=str, choices=["corsika", "simtel", "corsika_simtel"], required=True, default="corsika_simtel", ) @staticmethod def _get_dictionary_with_corsika_configuration(): """Return dictionary with CORSIKA configuration parameters.""" from simtools.corsika.primary_particle import PrimaryParticle # pylint: disable=C0415 return { "primary": { "help": ( "Primary particle to simulate. " "(choices for common names: " f"{', '.join(PrimaryParticle.particle_names().keys())}; " "use '--primary_ID_type' to use other particle ID types)." ), "type": str.lower, "required": True, }, "primary_id_type": { "help": "Primary particle ID type", "type": str, "required": False, "choices": ["common_name", "corsika7_id", "pdg_id"], "default": "common_name", }, "azimuth_angle": { "help": ( "Telescope pointing direction in azimuth. " "It can be in degrees between 0 and 360 or one of north, south, east or west. " "North is 0 degrees and the azimuth grows clockwise (East is 90 degrees)." ), "type": CommandLineParser.azimuth_angle, "required": True, }, "zenith_angle": { "help": "Zenith angle in degrees (between 0 and 180).", "type": CommandLineParser.zenith_angle, "required": True, }, "nshow": { "help": "Number of showers per run to simulate.", "type": int, "required": False, }, "run_number_start": { "help": "Run number for the first run.", "type": int, "required": True, "default": 1, }, "number_of_runs": { "help": "Number of runs to be simulated.", "type": int, "required": True, "default": 1, }, "event_number_first_shower": { "help": "Event number of first shower", "type": int, "required": False, "default": 1, }, "correct_for_b_field_alignment": { "help": "Correct for B-field alignment", "action": "store_true", "required": False, "default": True, }, } @staticmethod def _get_dictionary_with_shower_configuration(): """Return dictionary with shower configuration parameters.""" return { "eslope": { "help": "Slope of the energy spectrum.", "type": float, "required": False, "default": -2.0, }, "energy_range": { "help": ( "Energy range of the primary particle (min/max value, e'g', '10 GeV 5 TeV')." ), "type": CommandLineParser.parse_quantity_pair, "required": False, "default": ["3 GeV 330 TeV"], }, "view_cone": { "help": ( "View cone radius for primary arrival directions " "(min/max value, e.g. '0 deg 10 deg')." ), "type": CommandLineParser.parse_quantity_pair, "required": False, "default": ["0 deg 0 deg"], }, "core_scatter": { "help": "Scatter radius for shower cores (number of use; scatter radius).", "type": CommandLineParser.parse_integer_and_quantity, "required": False, "default": ["10 1400 m"], }, } def _initialize_simulation_configuration( self, group_name, selected_parameters, available_parameters ): """ Initialize simulation configuration arguments. Parameters ---------- group_name : str Name of the group of arguments. selected_parameters : list List of selected parameters to be added to the group. available_parameters : dict Dictionary with available parameters and their configuration. """ configuration_group = self.add_argument_group(group_name) if "all" in selected_parameters: selected_parameters = available_parameters.keys() for param in selected_parameters: try: configuration_group.add_argument(f"--{param}", **available_parameters[param]) except KeyError: pass @staticmethod def _add_model_option_layout(job_group, add_layout_file, required=True): """ Add layout option to the job group. Parameters ---------- job_group: argparse.ArgumentParser Job group add_layout_file: bool Add layout file option Returns ------- argparse.ArgumentParser """ _layout_group = job_group.add_mutually_exclusive_group(required=required) _layout_group.add_argument( "--array_layout_name", help="array layout name (e.g., alpha, subsystem_msts)", type=str, required=False, ) _layout_group.add_argument( "--array_element_list", help="list of array elements (e.g., LSTN-01, LSTN-02, MSTN).", nargs="+", type=str, required=False, default=None, ) if add_layout_file: _layout_group.add_argument( "--array_layout_file", help="file(s) with the list of array elements (astropy table format).", nargs="+", type=str, required=False, default=None, ) return job_group def _add_model_option_site(self, job_group): """ Add site option to the job group. Parameters ---------- job_group: argparse.ArgumentParser Job group Returns ------- argparse.ArgumentParser """ job_group.add_argument( "--site", help="site (e.g., North, South)", type=self.site, required=False ) return job_group
[docs] @staticmethod def site(value): """ Argument parser type to check that a valid site name is given. Parameters ---------- value: str site name Returns ------- str Validated site name Raises ------ argparse.ArgumentTypeError for invalid sites """ names.validate_site_name(str(value)) return str(value)
[docs] @staticmethod def telescope(value): """ Argument parser type to check that a valid telescope name is given. Parameters ---------- value: str telescope name Returns ------- str Validated telescope name Raises ------ argparse.ArgumentTypeError for invalid telescope """ names.validate_array_element_name(str(value)) return str(value)
[docs] @staticmethod def efficiency_interval(value): """ Argument parser type to check that value is an efficiency in the interval [0,1]. Parameters ---------- value: float value provided through the command line Returns ------- float Validated efficiency interval Raises ------ argparse.ArgumentTypeError When value is outside of the interval [0,1] """ fvalue = float(value) if fvalue < 0.0 or fvalue > 1.0: raise argparse.ArgumentTypeError(f"{value} outside of allowed [0,1] interval") return fvalue
[docs] @staticmethod def zenith_angle(angle): """ Argument parser type to check that the zenith angle provided is in the interval [0, 180]. We allow here zenith angles larger than 90 degrees in the improbable case such simulations are requested. It is not guaranteed that the actual simulation software supports such angles!. Parameters ---------- angle: float, str, astropy.Quantity zenith angle to verify Returns ------- Astropy.Quantity Validated zenith angle in degrees Raises ------ argparse.ArgumentTypeError When angle is outside of the interval [0, 180] """ logger = logging.getLogger(__name__) try: try: fangle = float(angle) * u.deg except ValueError: fangle = u.Quantity(angle).to("deg") except TypeError as exc: logger.error( "The zenith angle provided is not a valid numerical or astropy.Quantity value." ) raise exc if fangle < 0.0 * u.deg or fangle > 180.0 * u.deg: raise argparse.ArgumentTypeError( f"The provided zenith angle, {angle:.1f}, " "is outside of the allowed [0, 180] interval" ) return fangle
[docs] @staticmethod def azimuth_angle(angle): """ Argument parser type to check that the azimuth angle provided is in the interval [0, 360]. Other allowed options are north, south, east or west which will be translated to an angle where north corresponds to zero. Parameters ---------- angle: float or str azimuth angle to verify or convert Returns ------- Astropy.Quantity Validated/Converted azimuth angle in degrees Raises ------ argparse.ArgumentTypeError When angle is outside of the interval [0, 360] or not in (north, south, east, west) """ logger = logging.getLogger(__name__) try: fangle = float(angle) if fangle < 0.0 or fangle > 360.0: raise argparse.ArgumentTypeError( f"The provided azimuth angle, {angle:.1f}, " "is outside of the allowed [0, 360] interval" ) return fangle * u.deg except ValueError: logger.debug( "The azimuth angle provided is not a valid numeric value. " "Will check if it is an astropy.Quantity instead" ) except TypeError as exc: logger.error("The azimuth angle provided is not a valid numerical or string value.") raise exc try: return u.Quantity(angle).to("deg") except TypeError: logger.debug( "The azimuth angle provided is not a valid astropy.Quantity. " "Will check if it is (north, south, east, west) instead" ) azimuth_map = { "north": 0 * u.deg, "south": 180 * u.deg, "east": 90 * u.deg, "west": 270 * u.deg, } azimuth_angle = angle.lower() if azimuth_angle in azimuth_map: return azimuth_map[azimuth_angle] raise argparse.ArgumentTypeError( "The azimuth angle given as string can only be one of " f"(north, south, east, west), not {angle}. Otherwise use numerical values." )
[docs] @staticmethod def parse_quantity_pair(string): """ Parse a string representing a pair of astropy quantities separated by a space. Args: string: The input string (e.g., "0 deg 1.5 deg"). Returns ------- tuple: A tuple containing two astropy.units.Quantity objects. Raises ------ ValueError: If the string is not formatted correctly (e.g., missing space). """ pattern = r"(\d+\.?\d*)\s*([a-zA-Z]+)" matches = re.findall(pattern, string) if len(matches) != 2: raise ValueError("Input string does not contain exactly two quantities.") return ( u.Quantity(float(matches[0][0]), matches[0][1]), u.Quantity(float(matches[1][0]), matches[1][1]), )
[docs] @staticmethod def parse_integer_and_quantity(input_string): """ Parse a string representing an integer and a quantity with units. This is e.g., used for the 'core_scatter' argument. Parameters ---------- input_string: str The input string (e.g., "5 1500 m") or a tuple converted to string (e.g., "(5, <Quantity 1500 m>)"). Returns ------- tuple: A tuple containing an integer and an astropy.units.Quantity object. Raises ------ ValueError: If the input string does not match the required format. """ # tuple converted to string: "(5, <Quantity 1500 m>)" if all(char in input_string for char in ["(", ")", ","]): pattern = r"\((\d+), <Quantity ([\d.]+) (.+)>\)" match = re.match(pattern, input_string) # string with integer and quantity: "5 1500 m" else: pattern = r"(\d+)\s+(\d+\.?\d*)\s*([a-zA-Z]+)" match = re.match(pattern, input_string.strip()) if not match: raise ValueError("Input string does not contain an integer and a astropy quantity.") return (int(match.group(1)), u.Quantity(float(match.group(2)), match.group(3)))