Source code for layout.array_layout_utils

"""Retrieve, merge, and write layout dictionaries."""

import ast
import logging
from pathlib import Path

import astropy.units as u
from astropy.table import QTable, Table

import simtools.utils.general as gen
import simtools.version as simtools_version
from simtools.context import RunContext
from simtools.data_model.model_data_writer import ModelDataWriter
from simtools.io import ascii_handler
from simtools.layout.array_layout_sources import (
    get_array_layouts_from_file,
    get_array_layouts_from_model_repository,
    get_array_layouts_from_parameter_file,
    get_array_layouts_using_telescope_lists_from_model_repository,
)
from simtools.utils import names

_logger = logging.getLogger(__name__)
SUBARRAY_IDS_FILE_NAME = "subarray-ids.json"


[docs] def retrieve_ctao_array_layouts(site, repository_url, branch_name="main"): """ Retrieve array layouts from the CTAO common identifiers repository. Parameters ---------- site : str Site identifier. repository_url : str URL or path to CTAO common identifiers. branch_name : str Repository branch to use. Returns ------- tuple Array layouts for the site (list of dicts) and ``subarray-ids.json`` metadata (list). """ _logger.info(f"Retrieving array layouts from {repository_url} on branch {branch_name}.") is_url = gen.is_url(repository_url) if is_url: array_element_ids = ascii_handler.collect_data_from_git( file_name="array-element-ids.json", git_repository=repository_url, git_branch=branch_name, ) sub_arrays = ascii_handler.collect_data_from_git( file_name=SUBARRAY_IDS_FILE_NAME, git_repository=repository_url, git_branch=branch_name, ) else: array_element_ids = ascii_handler.collect_data_from_file( Path(repository_url) / "array-element-ids.json" ) sub_arrays = ascii_handler.collect_data_from_file( Path(repository_url) / SUBARRAY_IDS_FILE_NAME ) layouts = _get_ctao_layouts_per_site(site, sub_arrays, array_element_ids) associated_data = _build_associated_data_from_metadata( sub_arrays.get("metadata", {}), url=repository_url if is_url else None, branch=branch_name, ) return layouts, associated_data
def _build_associated_data_from_metadata(metadata, url=None, branch=None): """Return associated_data entries derived from subarray-ids.json metadata.""" if product_id := metadata.get("CTA PRODUCT ID"): return [ { "id": product_id, "description": "CTAO subarray identifiers", "filename": SUBARRAY_IDS_FILE_NAME, "url": url, "branch": branch, } ] return [] def _get_ctao_layouts_per_site(site, sub_arrays, array_element_ids): """Get array layouts for CTAO sites (sub-arrays whose elements belong to ``site``).""" layouts_per_site = [] for array in sub_arrays.get("subarrays", []): elements = [] for ids in array.get("array_element_ids", []): element_name = _get_ctao_array_element_name(ids, array_element_ids) if names.get_site_from_array_element_name(element_name) != site: break elements.append(element_name) if len(elements) > 0: layouts_per_site.append({"name": array.get("name"), "elements": elements}) _logger.info(f"CTAO array layout definition: {layouts_per_site}") return layouts_per_site def _get_ctao_array_element_name(ids, array_element_ids): """Return array element name for common identifier.""" for element in array_element_ids.get("array_elements", []): if element.get("id") == ids: return element.get("name") return None
[docs] def merge_array_layouts(layouts_1, layouts_2): """ Compare two array layout dictionaries and merge them. Parameters ---------- layouts_1 : dict Array layout dictionary 1. layouts_2 : dict Array layout dictionary 2. Returns ------- dict Merged array layout dictionary based on layout_1. """ merged_layout = layouts_1 for layout_2 in layouts_2: normalized_name = _normalize_array_layout_name(layout_2["name"]) layout_found = False for layout_1 in layouts_1.get("value", {}): if sorted(layout_1["elements"]) == sorted(layout_2["elements"]): _logger.info( f"Equal telescope list: simtools '{layout_1['name']}' " f"and CTAO '{layout_2['name']}'" ) layout_1["name"] = normalized_name layout_found = True if not layout_found: merged_layout["value"].append( {"name": normalized_name, "elements": layout_2["elements"]} ) _logger.info(f"Adding {normalized_name} with {layout_2['elements']}") merged_layout["value"] = _deduplicate_and_normalize_layouts(merged_layout.get("value", [])) return merged_layout
def _normalize_array_layout_name(name): """Normalize layout name by replacing blanks with dashes.""" return str(name).replace(" ", "-") def _deduplicate_and_normalize_layouts(layouts): """Deduplicate layouts by telescope set and normalize all layout names.""" deduplicated_layouts = [] seen_element_sets = set() for layout in layouts: element_key = tuple(sorted(layout.get("elements", []))) if element_key in seen_element_sets: _logger.info( f"Skipping duplicate layout {layout.get('name')} with elements {element_key}" ) continue deduplicated_layouts.append( { "name": _normalize_array_layout_name(layout.get("name")), "elements": layout.get("elements", []), } ) seen_element_sets.add(element_key) return deduplicated_layouts
[docs] def write_array_layouts(array_layouts, args_dict, associated_data=None, *, context=None): """ Write array layouts as model parameter. Parameters ---------- array_layouts : dict Array layouts to be written. args_dict : dict Command line arguments. associated_data : list, optional Associated data entries to include in the metadata. context : RunContext, optional Run context (defaults to the environment). """ site = args_dict.get("site") or array_layouts.get("site") _logger.info(f"Writing updated array layouts to the model repository for site {site}.") context = context or RunContext.from_environment() output_file = context.paths.get_output_file( f"array_layouts-{args_dict['updated_parameter_version']}.json", sub_dir="array_layouts", ) metadata_input_dict = dict(args_dict) for entry in associated_data or []: metadata_input_dict.setdefault("associated_data", []).append(entry) ModelDataWriter.write_model_parameter( parameter_name="array_layouts", value=array_layouts["value"], instrument=f"OBS-{site}", parameter_version=args_dict.get("updated_parameter_version"), output_file=output_file, metadata_input_dict=metadata_input_dict, context=context, )
[docs] def validate_array_layouts_with_model_repository(production_table, array_layouts): """ Validate that all telescopes in the array layouts exist in the production table. Parameters ---------- production_table : dict Production table from the model repository. array_layouts : dict Array layouts to be validated. Returns ------- dict Validated array layouts. Raises ------ ValueError If array elements are not in the production table. """ model_elements = set(production_table.get("parameters", {}).keys()) invalid_array_elements = [ e for layout in array_layouts.get("value", []) for e in layout.get("elements", []) if e not in model_elements ] if invalid_array_elements: raise ValueError(f"Invalid array elements found: {invalid_array_elements}. ") return array_layouts
[docs] def get_array_layout_elements(array_layouts, layout_name): """ Return the elements of a named layout in an array-layout parameter. Parameters ---------- array_layouts : dict Canonical ``array_layouts`` model parameter. layout_name : str Name of the reference layout. Returns ------- list Elements in the named layout, in stored order. Raises ------ ValueError If the layout does not exist or has no elements. """ for layout in array_layouts.get("value", []): if layout.get("name") == layout_name: elements = layout.get("elements", []) if not elements: raise ValueError(f"Reference array layout '{layout_name}' has no elements.") return list(elements) raise ValueError(f"Reference array layout '{layout_name}' not found.")
[docs] def validate_array_layout_subset_of_reference( layout_name, elements, reference_elements, reference_layout_name ): """ Validate and return one array layout constrained by a reference layout. Parameters ---------- layout_name : str Name of the new layout. elements : list Telescope names selected for the new layout. reference_elements : list Telescope names allowed by the reference layout. reference_layout_name : str Name used in validation error messages. Returns ------- dict Canonical one-layout dictionary. Raises ------ ValueError If the name or element list is invalid, has duplicates, or is not a subset of the reference layout. """ if not isinstance(layout_name, str) or not layout_name.strip(): raise ValueError("Array layout name must be a non-empty string.") if not isinstance(elements, list) or not elements: raise ValueError(f"Array layout '{layout_name}' requires a non-empty telescope list.") if len(elements) != len(set(elements)): raise ValueError(f"Array layout '{layout_name}' contains duplicate telescopes.") reference_set = set(reference_elements) invalid_elements = [element for element in elements if element not in reference_set] if invalid_elements: raise ValueError( f"Array layout '{layout_name}' contains elements outside reference layout " f"'{reference_layout_name}': {invalid_elements}." ) return {"name": layout_name, "elements": list(elements)}
[docs] def prepare_array_layouts_for_submission(model_reader, args_dict): """ Read or construct array layouts for the submit application. The legacy file input is returned unchanged. Direct input reads the base parameter, validates the requested layout against its reference layout, and adds the new layout to the base value. Parameters ---------- model_reader : SimulationModelReader Configured source-neutral simulation-model reader. args_dict : dict Parsed application arguments. Returns ------- tuple Array-layout parameter and the single model version used for validation. Raises ------ ValueError If the input mode or required direct-layout arguments are invalid. """ file_input = args_dict.get("array_layouts") layout_name = args_dict.get("array_layout_name") element_list = args_dict.get("array_element_list") if file_input: if layout_name or element_list: raise ValueError( "Use either array_layouts or array_layout_name/array_element_list, not both." ) return ascii_handler.collect_data_from_file(file_input), _get_single_model_version( args_dict.get("model_version") ) missing = [ name for name in ( "array_layout_name", "array_element_list", "site", "model_version", "parameter_version", "updated_parameter_version", ) if not args_dict.get(name) ] if missing: raise ValueError("Direct layout input requires: " + ", ".join(missing)) model_version = _get_single_model_version(args_dict["model_version"]) base_layouts = model_reader.get_model_parameter( parameter="array_layouts", site=args_dict["site"], array_element_name=None, parameter_version=args_dict["parameter_version"], )["array_layouts"] parameter_site = base_layouts.get("site") if parameter_site and parameter_site != args_dict["site"]: raise ValueError( f"Array-layout parameter site '{parameter_site}' does not match requested site " f"'{args_dict['site']}'." ) reference_name = args_dict.get("reference_array_layout", "hyper_array") new_layout = validate_array_layout_subset_of_reference( layout_name=layout_name, elements=element_list, reference_elements=get_array_layout_elements(base_layouts, reference_name), reference_layout_name=reference_name, ) normalized_name = _normalize_array_layout_name(new_layout["name"]) existing_names = { _normalize_array_layout_name(layout.get("name")) for layout in base_layouts.get("value", []) } if normalized_name in existing_names: raise ValueError(f"Array layout '{new_layout['name']}' already exists.") base_layouts.setdefault("value", []).append( {"name": normalized_name, "elements": new_layout["elements"]} ) return base_layouts, model_version
def _get_single_model_version(model_version): """Return the single model version accepted by array-layout submission.""" if isinstance(model_version, list): if len(model_version) != 1: raise ValueError("submit-array-layouts accepts exactly one model version.") return model_version[0] return model_version
[docs] def read_layouts(args_dict, model_reader=None, context=None): """ Read array layouts from the model repository or parameter file. Parameters ---------- args_dict : dict Dictionary with command line arguments. model_reader : SimulationModelReader, optional Model reader. context : RunContext, optional Run context. Returns ------- tuple List of array layouts and the background layout (or None). Raises ------ ValueError If array_layout_name_from_parameter_file is given without array_layout_parameter_file. """ if args_dict.get("array_layout_name_from_parameter_file") and not args_dict.get( "array_layout_parameter_file" ): raise ValueError( "array_layout_name_from_parameter_file requires array_layout_parameter_file." ) background_layout = None ignore_software_version = args_dict.get("ignore_software_version", False) if args_dict.get("array_layout_name_background"): background_layout = get_array_layouts_from_model_repository( args_dict["array_layout_name_background"], args_dict["site"], args_dict["model_version"], args_dict["coordinate_system"], ignore_software_version, model_reader=model_reader, context=context, )["array_elements"] if args_dict["array_layout_parameter_file"] is not None: _logger.info("Plotting array from parameter file(s).") return get_array_layouts_from_parameter_file( args_dict["array_layout_parameter_file"], args_dict["model_version"], args_dict["coordinate_system"], args_dict.get("array_layout_name_from_parameter_file"), context=context, ), background_layout if args_dict["array_layout_name"] is not None or args_dict["plot_all_layouts"]: _logger.info("Plotting array from the model repository using layout array name(s).") layouts = get_array_layouts_from_model_repository( args_dict["array_layout_name"], args_dict["site"], args_dict["model_version"], args_dict["coordinate_system"], ignore_software_version, model_reader=model_reader, context=context, ) if isinstance(layouts, list): return layouts, background_layout return [layouts], background_layout if args_dict["array_layout_file"] is not None: _logger.info("Plotting array from telescope table file(s).") return ( get_array_layouts_from_file(args_dict["array_layout_file"], context=context), background_layout, ) if args_dict["array_element_list"] is not None: _logger.info("Plotting array from list of array elements.") return get_array_layouts_using_telescope_lists_from_model_repository( [args_dict["array_element_list"]], args_dict["site"], args_dict["model_version"], args_dict["coordinate_system"], ignore_software_version, model_reader=model_reader, context=context, ), background_layout return [], background_layout
def _create_star_array(tel_name, pos_x, pos_y, pos_z, n_telescopes, tel_type, site, distance): """Create star-shaped array positions along the x and y axes.""" axis_sequence = ["x", "y", "-x", "-y"] step = 1 for i in range(n_telescopes): tel_name.append( names.generate_array_element_name_from_type_site_id(tel_type, site, f"{i + 1:02d}") ) dist = distance * step x, y = { "x": (dist, 0 * u.m), "-x": (-dist, 0 * u.m), "y": (0 * u.m, dist), "-y": (0 * u.m, -dist), }[axis_sequence[i % 4]] pos_x.append(x) pos_y.append(y) pos_z.append(0 * u.m) if (i + 1) % 4 == 0: step += 1 def _create_square_array(tel_name, pos_x, pos_y, pos_z, n_tel, tel_type, site, distance): """Create square array positions (1 or 4 telescopes).""" if n_tel == 1: tel_name.append(names.generate_array_element_name_from_type_site_id(tel_type, site, "01")) pos_x.append(0 * u.m) pos_y.append(0 * u.m) pos_z.append(0 * u.m) elif n_tel == 4: for i in range(1, 5): tel_name.append( names.generate_array_element_name_from_type_site_id(tel_type, site, f"0{i}") ) pos_x.append(distance * (-1) ** (i // 2)) pos_y.append(distance * (-1) ** (i % 2)) pos_z.append(0 * u.m) else: raise ValueError(f"Unsupported number of telescopes for square array: {n_tel}.")
[docs] def create_regular_array( array_name, site, n_telescopes, telescope_type, telescope_distance, shape="square" ): """ Create a regular array layout table. Parameters ---------- array_name : str Name of the regular array (e.g. "4MST"). site : str Site identifier. n_telescopes : int Number of telescopes in the array. telescope_type : str Type of telescope (e.g. "MST"). telescope_distance : Quantity Distance between telescopes in the array. shape : str Shape of the array: "square" or "star" (default: "square"). Returns ------- astropy.table.Table Table with the regular array layout. Raises ------ ValueError If the shape is not supported. """ builders = {"square": _create_square_array, "star": _create_star_array} if shape not in builders: raise ValueError(f"Unsupported array shape: {shape}. Allowed: 'square' or 'star'.") tel_name, pos_x, pos_y, pos_z = [], [], [], [] builders[shape]( tel_name, pos_x, pos_y, pos_z, n_telescopes, telescope_type, site, telescope_distance ) table = QTable(meta={"array_name": array_name, "site": site}) table["telescope_name"] = tel_name table["position_x"] = pos_x table["position_y"] = pos_y table["position_z"] = pos_z table.sort("telescope_name") _logger.info(f"Regular array layout table:\n{table}") return table
[docs] def write_array_elements_from_file_to_repository( coordinate_system, input_file, repository_path, parameter_version, context=None ): """ Read array elements from file and write their positions to the model repository. Writes one model parameter file per array element. Parameters ---------- coordinate_system : str Coordinate system of array element positions (utm or ground). input_file : str or Path Path to input file with array element positions. repository_path : str or Path Path to model repository. parameter_version : str Parameter version to use when writing to repository. context : RunContext, optional Run context. Notes ----- Writes files directly and does not query the model repository for an existing version. """ repository_path = Path(repository_path) array_elements = Table.read(input_file) if coordinate_system == "ground": parameter_name = "array_element_position_ground" columns = ("position_x", "position_y", "position_z") elif coordinate_system == "utm": parameter_name = "array_element_position_utm" columns = ("utm_east", "utm_north", "altitude") else: raise ValueError( f"Unsupported coordinate system: {coordinate_system}. Allowed are 'utm' and 'ground'." ) x, y, alt = (array_elements[c].quantity.to(u.m).value for c in columns) for i, row in enumerate(array_elements): instrument = ( row["telescope_name"] if "telescope_name" in array_elements.colnames else f"{row['asset_code']}-{row['sequence_number']}" ) output_path = repository_path / f"{instrument}" output_path.mkdir(parents=True, exist_ok=True) _logger.info(f"Writing array element positions ({coordinate_system}) to {output_path}") ModelDataWriter.write_model_parameter( parameter_name=parameter_name, instrument=instrument, value=f"{x[i]} {y[i]} {alt[i]}", unit="m", parameter_version=parameter_version, output_path=output_path, output_file=f"{parameter_name}.json", check_for_existing_parameter=False, context=context, )
[docs] def write_array_elements_info_yaml( array_table, site, model_version, output_file, parameter_version="2.0.0" ): """ Write YAML file for array layout in a format compatible with overwrite. Parameters ---------- array_table : astropy.table.Table Table with telescope positions. site : str Site identifier (e.g., 'North' or 'South'). model_version : str Model version. output_file : Path Path to the output YAML file. parameter_version : str Parameter version to use in the YAML file. """ telescope_names = [str(name) for name in array_table["telescope_name"]] array_layout = { "version": parameter_version, "value": [{"name": array_table.meta["array_name"], "elements": telescope_names}], "unit": None, } data = { "model_version": model_version, "model_update": "patch_update", "model_version_history": [model_version], "description": f"Regular array layout: {array_table.meta['array_name']}", "changes": {f"OBS-{site}": {"array_layouts": array_layout}}, } for tel_name, pos_x, pos_y, pos_z in zip( telescope_names, array_table["position_x"], array_table["position_y"], array_table["position_z"], ): data["changes"][tel_name] = { "array_element_position_ground": { "version": parameter_version, "value": [ float(pos_x.to(u.m).value), float(pos_y.to(u.m).value), float(pos_z.to(u.m).value), ], "unit": "m", } } ascii_handler.write_data_to_file(data, output_file)
[docs] def resolve_array_layout_name(array_layout_name, model_version): """ Resolve array layout configuration for a specific model version. Parameters ---------- array_layout_name : str or dict or list of str or list of dict Array layout name(s) or configuration(s) to resolve. model_version : str Model version to resolve for. Returns ------- str or dict or list The resolved array layout name(s) or configuration. """ if isinstance(array_layout_name, list) and len(array_layout_name) == 1: array_layout_name = array_layout_name[0] if isinstance(array_layout_name, str) and array_layout_name.strip().startswith("{"): try: parsed_layout = ast.literal_eval(array_layout_name) if isinstance(parsed_layout, dict): array_layout_name = parsed_layout except SyntaxError, ValueError: return array_layout_name if not isinstance(array_layout_name, dict) or list(array_layout_name) != ["by_version"]: return array_layout_name resolved = simtools_version.resolve_by_version( {"array_layout_name": array_layout_name}, model_version ) return resolved["array_layout_name"]