"""Name utilities for array elements, sites, and model parameters.
Naming in simtools:
* 'site': South or North
* 'array element': e.g., LSTN-01, MSTN-01, ...
* 'array element type': e.g., LSTN, MSTN, ...
* 'array element ID': e.g., 01, 02, ...
* 'array element design type': e.g., design, test
* 'instrument class key': e.g., telescope, camera, structure
* 'model repository collection': e.g., telescopes, sites, calibration_devices
"""
import json
import logging
import re
from functools import cache
from pathlib import Path
import yaml
from simtools.constants import (
RESOURCE_PATH,
SIM_TELARRAY_INCLUDE_FILENAME_MAX_LENGTH,
)
_logger = logging.getLogger(__name__)
[docs]
@cache
def array_elements():
"""
Get array elements and their properties.
Returns
-------
dict
Array elements.
"""
# for efficiency reason, no functions from simtools.utils.general are used here
with open(Path(RESOURCE_PATH) / "array_elements.yml", encoding="utf-8") as file:
return yaml.safe_load(file)["data"]
[docs]
@cache
def array_element_common_identifiers():
"""
Get array element IDs from CTAO common identifier.
Returns
-------
dict, dict
Dictionary mapping array element names to their IDs and vice versa.
"""
# for efficiency reason, no functions from simtools.utils.general are used here
id_to_name = {}
with open(Path(RESOURCE_PATH) / "array-element-ids.json", encoding="utf-8") as file:
data = json.load(file)
id_to_name = {e["id"]: e["name"] for e in data["array_elements"]}
name_to_id = {e["name"]: e["id"] for e in data["array_elements"]}
return id_to_name, name_to_id
[docs]
@cache
def site_names():
"""
Get site names.
The list of sites is derived from the sites listed in array element definition file.
Return a dictionary for compatibility with the validation '_validate_name' routine.
Returns
-------
dict
Site names.
"""
return {
site: [site.lower()]
for entry in array_elements().values()
for site in (entry["site"] if isinstance(entry["site"], list) else [entry["site"]])
}
[docs]
@cache
def array_element_design_types(array_element_type):
"""
Get array element design type (e.g., 'design' or 'flashcam').
Default values are ['design', 'test'].
Parameters
----------
array_element_type
Array element type
Returns
-------
list
Array element design types.
"""
default_types = ["design", "test"]
if array_element_type is None:
return default_types
try:
return array_elements()[array_element_type].get("design_types", default_types)
except KeyError as exc:
raise ValueError(f"Invalid name {array_element_type}") from exc
[docs]
def is_design_type(array_element_name):
"""
Check if array element is a design type (e.g., "MSTS-FlashCam" or "LSTN-design").
Parameters
----------
array_element_name: str
Array element name.
Returns
-------
bool
True if array element is a design type.
"""
return get_array_element_id_from_name(array_element_name) in array_element_design_types(
get_array_element_type_from_name(array_element_name)
)
[docs]
def matches_array_element_name_or_design_type(element_name, reference_element_name):
"""
Check whether an array element name matches a reference exactly or by design type equivalence.
Parameters
----------
element_name: str
Candidate element name to validate and compare (e.g., "MSTN-01" or "MSTx-FlashCam").
reference_element_name: str
Reference array element name used for comparison (e.g., "MSTN-01" or "MSTN-FlashCam").
Returns
-------
bool
True if both names are identical, or if they belong to the same design type.
"""
if element_name is None or reference_element_name is None:
return False
try:
validated_element_name = validate_array_element_name(element_name)
validated_reference_element_name = validate_array_element_name(reference_element_name)
except ValueError:
return False
if validated_element_name == validated_reference_element_name:
return True
if "-" not in validated_element_name or "-" not in validated_reference_element_name:
return False
return is_design_type(validated_element_name) and (
get_array_element_type_from_name(validated_element_name)
== get_array_element_type_from_name(validated_reference_element_name)
)
[docs]
def validate_array_element_id_name(array_element_id, array_element_type=None):
"""
Validate array element ID.
Allowed IDs are
- design types (for design array elements or testing)
- array element ID (e.g., 1, 5, 15)
- test (for testing)
Parameters
----------
name: str or int
Array element ID name.
array_element_type: str
Array element type (e.g., LSTN, MSTN).
Returns
-------
str
Validated array element ID (added leading zeros, e.g., 1 is converted to 01).
Raises
------
ValueError
If name is not valid.
"""
if isinstance(array_element_id, int) or array_element_id.isdigit():
return f"{int(array_element_id):02d}"
if array_element_id in array_element_design_types(array_element_type):
return str(array_element_id)
raise ValueError(f"Invalid array element ID name {array_element_id}")
[docs]
def validate_site_name(site_name):
"""
Validate site name.
Parameters
----------
site_name: str
Site name.
Returns
-------
str
Validated name.
"""
return _validate_name(site_name, site_names())
def _validate_name(name, all_names):
"""
Validate name given the all_names options.
For each key in all_names, a list of options is given.
If name is in this list, the key name is returned.
Parameters
----------
name: str
Name to validate.
all_names: dict
Dictionary with valid names.
Returns
-------
str
Validated name.
Raises
------
ValueError
If name is not valid.
"""
for key in all_names.keys():
if isinstance(all_names[key], list) and name.lower() in [
item.lower() for item in all_names[key]
]:
return key
if name.lower() == key.lower():
return key
msg = f"Invalid name {name}"
raise ValueError(msg)
[docs]
def validate_array_element_type(array_element_type):
"""
Validate array element type (e.g., LSTN, MSTN).
Parameters
----------
array_element_type: str
Array element type.
Returns
-------
str
Validated name.
"""
return _validate_name(array_element_type, array_elements())
[docs]
def validate_array_element_name(array_element_name):
"""
Validate array element name (e.g., MSTx-NectarCam, MSTN-01).
Forgiving validation, is it allows also to give a site name (e.g., OBS-North).
Parameters
----------
array_element_name: str
Array element name.
Returns
-------
str
Validated name.
"""
try:
_array_element_type, _array_element_id = array_element_name.split("-")
except ValueError as exc:
msg = f"Invalid name {array_element_name}"
raise ValueError(msg) from exc
if _array_element_type == "OBS":
return f"OBS-{validate_site_name(_array_element_id)}"
return (
_validate_name(_array_element_type, array_elements())
+ "-"
+ validate_array_element_id_name(_array_element_id, _array_element_type)
)
[docs]
def generate_array_element_name_from_type_site_id(array_element_type, site, array_element_id):
"""
Generate a new array element name from array element type, site, and array element ID.
Parameters
----------
array_element_type: str
Array element type.
site: str
Site name.
array_element_id: str
Array element ID.
Returns
-------
str
Array element name.
"""
_short_site = validate_site_name(site)[0]
_val_id = validate_array_element_id_name(array_element_id, array_element_type)
return f"{array_element_type}{_short_site}-{_val_id}"
[docs]
def get_array_element_type_from_name(array_element_name):
"""
Get array element type from array element name (e.g "MSTN" from "MSTN-01").
For sites, return site name.
Parameters
----------
array_element_name: str
Array element name
Returns
-------
str
Array element type.
"""
try: # e.g. instrument is 'North' as given for the site parameters
return validate_site_name(array_element_name)
except ValueError: # any other telescope or calibration device
if array_element_name.startswith("OBS-"):
return validate_site_name(array_element_name.split("-", maxsplit=1)[1])
return _validate_name(array_element_name.split("-")[0], array_elements())
[docs]
def get_array_element_id_from_name(array_element_name):
"""
Get array element ID from array element name, (e.g. "01" from "MSTN-01").
Parameters
----------
array_element_name: str
Array element name
Returns
-------
str
Array element ID.
"""
try:
return validate_array_element_id_name(
array_element_name.split("-")[1], array_element_name.split("-")[0]
)
except IndexError as exc:
raise ValueError(f"Invalid name {array_element_name}") from exc
[docs]
def get_common_identifier_from_array_element_name(array_element_name, default_return=None):
"""
Get numerical common identifier from array element name as used by CTAO.
Common identifiers are numerical IDs used by the CTAO ACADA and DPPS systems.
Parameters
----------
array_element_name: str
Array element name (e.g. LSTN-01)
Returns
-------
int
Common identifier.
"""
_, name_to_id = array_element_common_identifiers()
try:
return name_to_id[array_element_name]
except KeyError as exc:
if default_return is not None:
return default_return
raise ValueError(f"Unknown array element name {array_element_name}") from exc
[docs]
def get_array_element_name_from_common_identifier(common_identifier):
"""
Get array element name from common identifier as used by CTAO.
Common identifiers are numerical IDs used by the CTAO ACADA and DPPS systems.
Parameters
----------
common_identifier: int
Common identifier.
Returns
-------
str
Array element name.
"""
id_to_name, _ = array_element_common_identifiers()
try:
return id_to_name[common_identifier]
except KeyError as exc:
raise ValueError(f"Unknown common identifier {common_identifier}") from exc
[docs]
def normalize_array_element_identifier(array_element_identifier):
"""
Normalize an array element identifier to an array element name when possible.
Parameters
----------
array_element_identifier : str or int
Array element identifier. Can be a CTAO common numerical identifier
(used by ACADA/DPPS) or already an array element name.
Returns
-------
str
Normalized array element identifier.
"""
identifier_str = str(array_element_identifier).strip()
if not identifier_str.lstrip("+-").isdigit():
return identifier_str
common_identifier = int(identifier_str)
try:
return get_array_element_name_from_common_identifier(common_identifier)
except ValueError:
return identifier_str
[docs]
def normalize_array_element_identifier_container(array_element_identifiers):
"""
Normalize a container of array element identifiers.
Parameters
----------
array_element_identifiers : str or list or tuple or set or None
Array element identifiers, possibly encoded as a JSON list string
(for example the serialized content of an ECSV ``subtype: json`` field).
Returns
-------
list
List of normalized array element identifiers.
"""
if array_element_identifiers is None:
return []
normalized_input = array_element_identifiers
if isinstance(array_element_identifiers, str):
identifiers_str = array_element_identifiers.strip()
if identifiers_str.startswith("[") or identifiers_str.endswith("]"):
try:
normalized_input = json.loads(identifiers_str)
except json.JSONDecodeError:
raise ValueError(
"Invalid JSON list string for array element identifiers: "
f"{array_element_identifiers}"
) from None
if not isinstance(normalized_input, list):
raise ValueError(
"Invalid JSON list string for array element identifiers: "
f"{array_element_identifiers}"
)
else:
normalized_input = [array_element_identifiers]
elif not isinstance(array_element_identifiers, (list, tuple, set)):
normalized_input = [array_element_identifiers]
return [normalize_array_element_identifier(identifier) for identifier in normalized_input]
[docs]
def get_list_of_array_element_types(
array_element_class="telescopes", site=None, observatory="CTAO"
):
"""
Get list of array element types (e.g., ["LSTN", "MSTN"] for the Northern site).
Parameters
----------
array_element_class: str
Array element class
site: str
Site name (e.g., South or North).
Returns
-------
list
List of array element types.
"""
return sorted(
[
key
for key, value in array_elements().items()
if value["collection"] == array_element_class
and (site is None or value["site"] == site)
and (observatory is None or value["observatory"] == observatory)
]
)
[docs]
def get_site_from_array_element_name(array_element_name):
"""
Get site name from array element name (e.g., "South" from "MSTS-01").
Parameters
----------
array_element_name: str
Array element name.
Returns
-------
str, list
Site name(s).
"""
if array_element_name is None:
return None
try: # e.g. instrument is 'North' as given for the site parameters
if array_element_name.startswith("OBS"):
return validate_site_name(array_element_name.split("-")[1])
return validate_site_name(array_element_name)
except ValueError: # e.g. instrument is 'LSTN' as given for the array element types
return array_elements()[get_array_element_type_from_name(array_element_name)]["site"]
[docs]
def get_collection_name_from_array_element_name(array_element_name, array_elements_only=True):
"""
Get collection name (e.g., telescopes, calibration_devices) of an array element from its name.
Parameters
----------
array_element_name: str
Array element name (e.g. LSTN-01)
array_elements_only: bool
If True, only array elements are considered (e.g. "OBS-North" will raise a ValueError).
Returns
-------
str
Collection name .
Raises
------
ValueError
If name is not a valid array element name.
"""
try:
return array_elements()[get_array_element_type_from_name(array_element_name)]["collection"]
except (ValueError, KeyError) as exc:
if array_elements_only:
raise ValueError(f"Invalid array element name {array_element_name}") from exc
try:
if array_element_name.startswith("OBS") or validate_site_name(array_element_name):
return "sites"
except ValueError:
pass
if array_element_name in {
"configuration_sim_telarray",
"configuration_corsika",
"Dummy-Telescope",
}:
return array_element_name
raise ValueError(f"Invalid array element name {array_element_name}")
[docs]
def sim_telarray_config_file_name(site, array_name=None, telescope_model_name=None):
"""
sim_telarray configuration file name for an array or a telescope.
Parameters
----------
site: str
South or North.
array_name: str
Array name (e.g., CTAO-North-4-LSTs-1-MSTs)
telescope_model_name: str
Telescope name (e.g., LSTN-01, MSTS-05)
Returns
-------
str
Configuration file name.
"""
name = "CTAO"
if array_name:
name += f"-{site}-{array_name}"
if telescope_model_name:
name += f"-{telescope_model_name}"
name += ".cfg"
# Telescope config files are included by the array config and parsed by sim_telarray
# through a fixed-size getword buffer.
if telescope_model_name is not None and len(name) > SIM_TELARRAY_INCLUDE_FILENAME_MAX_LENGTH:
raise ValueError(
f"Generated configuration file name {name} exceeds the maximum length of "
f"{SIM_TELARRAY_INCLUDE_FILENAME_MAX_LENGTH} characters."
)
return name
[docs]
def simtel_single_mirror_list_file_name(
site, telescope_model_name, model_version, mirror_number, label
):
"""
sim_telarray mirror list file with a single mirror.
Parameters
----------
site: str
South or North.
telescope_model_name: str
North-LST-1, South-MST-FlashCam, ...
model_version: str
Version of the model.
mirror_number: int
Mirror number.
label: str
Instance label.
Returns
-------
str
File name.
"""
name = f"CTA-single-mirror-list-{site}-{telescope_model_name}-{model_version}"
name += f"-mirror{mirror_number}"
name += f"_{label}" if label is not None else ""
name += ".dat"
return name
[docs]
def generate_file_name(
file_type,
suffix,
site,
telescope_model_name,
zenith_angle,
azimuth_angle=None,
off_axis_angle=None,
off_axis_x=None,
off_axis_y=None,
source_distance=None,
mirror_number=None,
label=None,
extra_label=None,
):
"""
Generate a file name for output, config, or plotting.
Used e.g., to generate camera_efficiency and ray_tracing output files.
Parameters
----------
file_type: str
Type of file (e.g., config, output, plot)
suffix: str
File suffix
site: str
South or North.
telescope_model_name: str
LSTN-01, MSTS-01, ...
zenith_angle: float
Zenith angle (deg).
azimuth_angle: float
Azimuth angle (deg).
off_axis_angle: float
Off-axis angle (deg). Use off_axis_x and off_axis_y for (x, y) offsets.
off_axis_x: float
X component of off-axis offset (deg).
off_axis_y: float
Y component of off-axis offset (deg).
source_distance: float
Source distance (km).
mirror_number: int
Mirror number.
label: str
Instance label.
extra_label: str
Extra label.
Returns
-------
str
File name.
"""
name = f"{file_type}_{site}_{telescope_model_name}"
name += f"_d{source_distance:.1f}km" if source_distance is not None else ""
name += f"_za{float(zenith_angle):.1f}deg"
# Handle both 1D (r) and 2D (x,y) off_axis_angle offsets.
if off_axis_x is not None and off_axis_y is not None:
name += f"_off_x{off_axis_x:+.3f}_y{off_axis_y:+.3f}deg"
elif off_axis_angle is not None:
name += f"_off{off_axis_angle:.3f}deg"
name += f"_azm{round(azimuth_angle):03}deg" if azimuth_angle is not None else ""
name += f"_mirror{mirror_number}" if mirror_number is not None else ""
name += f"_{label}" if label is not None else ""
name += f"_{extra_label}" if extra_label is not None else ""
name += f"{suffix}"
return name
[docs]
def file_name_with_version(file_name, suffix):
"""
Return a file name including a semantic version with the correct suffix.
Replaces 'Path.suffix()', which removes trailing numbers (and therefore version numbers).
Parameters
----------
file_name: str
File name.
suffix: str
File suffix.
Returns
-------
Path
File name with version number.
"""
if file_name is None or suffix is None:
return None
file_name = str(file_name)
if re.search(r"\d{1,8}\.\d{1,8}\.\d{1,8}\Z", file_name):
return Path(file_name + suffix)
return Path(file_name).with_suffix(suffix)
[docs]
def is_two_mirror_telescope(telescope_model_name):
"""Return True for two-mirror telescopes (SST and SCT)."""
tel_type = get_array_element_type_from_name(telescope_model_name)
return "SST" in tel_type or "SCT" in tel_type