Source code for visualization.plot_pixels

#!/usr/bin/python3
"""Functions for plotting pixel layout information."""

import logging
from pathlib import Path

import astropy.units as u
import matplotlib.colors as mcolors
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.collections import PatchCollection

from simtools.db import db_handler
from simtools.io_operations import io_handler
from simtools.model.model_utils import is_two_mirror_telescope
from simtools.utils import names
from simtools.visualization import legend_handlers as leg_h
from simtools.visualization import visualize

logger = logging.getLogger(__name__)


[docs] def plot(config, output_file, db_config=None): """ Plot pixel layout based on configuration. Parameters ---------- config : dict Configuration dictionary containing: - file_name : str, name of camera config file - column_x : str, x-axis label - column_y : str, y-axis label - parameter_version: str, version of the parameter - telescope : str, name of the telescope output_file : str Path where to save the plot db_config : dict, optional Database configuration. Returns ------- None The function saves the plot to the specified output file. """ db = db_handler.DatabaseHandler(mongo_db_config=db_config) db.export_model_file( parameter=config["parameter"], site=config["site"], array_element_name=config.get("telescope"), parameter_version=config.get("parameter_version"), model_version=config.get("model_version"), export_file_as_table=False, ) data_file_path = Path(io_handler.IOHandler().get_output_directory() / f"{config['file_name']}") fig = plot_pixel_layout_from_file( data_file_path, config["telescope"], pixels_id_to_print=80, ) visualize.save_figure(fig, output_file) plt.close(fig)
[docs] def plot_pixel_layout_from_file(dat_file_path, telescope_model_name, **kwargs): """ Plot the pixel layout from a camera config file. This function reads the pixel configuration from the specified camera config file and generates a plot of the pixel layout for the given telescope model. Parameters ---------- dat_file_path : str or Path Path to the camera config file containing pixel configuration telescope_model_name : str Name/model of the telescope **kwargs pixels_id_to_print : int Number of pixel IDs to print in the plot title : str Plot title xtitle : str X-axis label ytitle : str Y-axis label Returns ------- matplotlib.figure.Figure The generated figure """ logger.info(f"Plotting pixel layout for {telescope_model_name} from {dat_file_path}") pixel_data = _prepare_pixel_data( dat_file_path, telescope_model_name, ) return _create_pixel_plot( pixel_data, telescope_model_name, pixels_id_to_print=kwargs.get("pixels_id_to_print", 50), title=kwargs.get("title"), xtitle=kwargs.get("xtitle"), ytitle=kwargs.get("ytitle"), )
def _prepare_pixel_data(dat_file_path, telescope_model_name): """Prepare pixel data from sim_telarray camera configuration file. This function reads the pixel configuration from the specified camera config file and prepares the data for plotting, including applying any necessary rotations. Parameters ---------- dat_file_path : str or Path Path to the camera config file containing pixel configuration telescope_model_name : str Name/model of the telescope Returns ------- dict Dictionary containing pixel data """ config = _read_pixel_config(dat_file_path) x_pos = np.array(config["x"]) y_pos = np.array(config["y"]) if not is_two_mirror_telescope(telescope_model_name): y_pos = -y_pos rotate_angle = ( config.get("rotate_angle") if config.get("rotate_angle") is not None else (0.0 * u.deg) ) # Apply telescope-specific adjustments if "SST" in telescope_model_name or "SCT" in telescope_model_name: total_rotation = (90 * u.deg) - (rotate_angle) else: total_rotation = (-90 * u.deg) - (rotate_angle) # Apply rotation rot_angle = total_rotation.to(u.rad).value x_rot = x_pos * np.cos(rot_angle) - y_pos * np.sin(rot_angle) y_rot = y_pos * np.cos(rot_angle) + x_pos * np.sin(rot_angle) x_pos, y_pos = x_rot, y_rot return { "x": x_pos, "y": y_pos, "pixel_ids": config["pixel_ids"], "pixels_on": config["pixels_on"], "pixel_shape": config["pixel_shape"], "pixel_diameter": config["pixel_diameter"], "pixel_spacing": config["pixel_spacing"], "module_number": config["module_number"], "module_gap": config["module_gap"], "rotation": total_rotation, } def _create_pixel_plot( pixel_data, telescope_model_name, pixels_id_to_print=50, title=None, xtitle=None, ytitle=None ): """ Create and configure the pixel layout plot. Parameters ---------- pixel_data : dict Dictionary containing pixel configuration data telescope_model_name : str Name of telescope model pixels_id_to_print : int, optional Number of pixel IDs to print, default 50 title : str, optional Plot title xtitle : str, optional X-axis label ytitle : str, optional Y-axis label Returns ------- matplotlib.figure.Figure The generated figure """ fig, ax = plt.subplots(figsize=(8, 8)) # Create patches on_pixels, edge_pixels, off_pixels = _create_pixel_patches( pixel_data["x"], pixel_data["y"], pixel_data["pixel_diameter"], pixel_data["module_number"], pixel_data["module_gap"], pixel_data["pixel_spacing"], pixel_data["pixel_shape"], pixel_data["pixels_on"], pixel_data["pixel_ids"], pixels_id_to_print, telescope_model_name, ) # Combine all patches into a single collection all_patches = on_pixels + edge_pixels + off_pixels facecolors = [ "none" if i < len(on_pixels) else (*mcolors.to_rgb("brown"), 0.5) if i < len(on_pixels) + len(edge_pixels) else "black" for i in range(len(on_pixels) + len(edge_pixels) + len(off_pixels)) ] edgecolors = ( ["black"] * len(on_pixels) + [(*mcolors.to_rgb("black"), 1)] * len(edge_pixels) + ["black"] * len(off_pixels) ) linewidths = [0.2] * len(all_patches) # Add the combined collection ax.add_collection( PatchCollection( all_patches, facecolor=facecolors, edgecolor=edgecolors, linewidth=linewidths, match_original=True, ) ) # Configure plot with titles _configure_plot( ax, pixel_data["x"], pixel_data["y"], rotation=pixel_data["rotation"], title=title, xtitle=xtitle, ytitle=ytitle, ) _add_legend(ax, on_pixels, off_pixels) return fig def _read_pixel_config(dat_file_path): """Read pixel configuration from a camera configuration file. This function reads the pixel configuration from the specified camera config file and returns it as a dictionary. It parses information such as pixel positions, module numbers, and other relevant parameters. Parameters ---------- dat_file_path : str or Path Path to the camera config file containing pixel configuration Returns ------- dict config containing pixel data """ config = { "x": [], "y": [], "pixel_ids": [], "pixels_on": [], "pixel_shape": None, "pixel_diameter": None, "pixel_spacing": None, "module_gap": None, "trigger_groups": [], "rotate_angle": None, "module_number": [], } with open(dat_file_path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue # Parse specific information from the file if line.startswith("Rotate"): # Parse rotation angle from line like "Rotate 10.893" (u.deg) config["rotate_angle"] = float(line.split()[1].strip()) * u.deg elif line.startswith("PixType"): parts = line.split() config["pixel_shape"] = int(parts[5].strip()) config["pixel_diameter"] = float(parts[6].strip()) elif "Pixel spacing is" in line: config["pixel_spacing"] = float(line.split("spacing is")[1].strip().split()[0]) elif "Between modules is an additional gap of" in line: config["module_gap"] = float(line.split("gap of")[1].strip().split()[0]) elif line.startswith("Pixel"): parts = line.split() config["x"].append(float(parts[3].strip())) config["y"].append(float(parts[4].strip())) config["module_number"].append(float(parts[5].strip())) config["pixel_ids"].append(int(parts[1].strip())) config["pixels_on"].append(int(parts[9].strip()) != 0) config["pixel_spacing"] = ( config["pixel_diameter"] if config["pixel_spacing"] is None else config["pixel_spacing"] ) config["module_gap"] = 0.0 if config["module_gap"] is None else config["module_gap"] return config def _create_patch(x, y, diameter, shape): """Create a single matplotlib patch for a pixel. This function creates a matplotlib patch (shape) for a single pixel based on its position, diameter, and shape type. Supported shapes are circles, squares, and hexagons. Parameters ---------- x, y : float Center coordinates of the pixel diameter : float Diameter of the pixel shape : int Pixel shape type: 0: circular 1: hexagonal (flat x) 2: square 3: hexagonal (flat y) Returns ------- matplotlib.patches.Patch The created patch object for the pixel """ if shape == 0: # Circular return mpatches.Circle((x, y), radius=diameter / 2) if shape in (1, 3): # Hexagonal return mpatches.RegularPolygon( (x, y), numVertices=6, radius=diameter / np.sqrt(3), orientation=np.deg2rad(30 if shape == 3 else 0), ) # Square return mpatches.Rectangle((x - diameter / 2, y - diameter / 2), width=diameter, height=diameter) def _is_edge_pixel( x, y, x_pos, y_pos, module_ids, pixel_spacing, module_gap, shape, current_module_id ): """ Determine if a pixel is on the edge based on neighbor count. Parameters ---------- x, y : float Coordinates of the pixel being checked. x_pos, y_pos : array-like Arrays of x and y positions of all pixels. module_ids : array-like Array of module IDs corresponding to each pixel. pixel_spacing : float Center-to-center spacing between pixels. module_gap : float Additional gap between modules. shape : int Pixel shape type (0: circular, 1/3: hexagonal, 2: square). current_module_id : int Module ID of the current pixel. Returns ------- bool True if the pixel is an edge pixel, False otherwise. """ # Determine the maximum number of neighbors based on the pixel shape if shape == 0: # Circular max_neighbors = 8 elif shape in (1, 3): # Hexagonal max_neighbors = 6 elif shape == 2: # Square max_neighbors = 4 else: raise ValueError(f"Unsupported pixel shape: {shape}") neighbor_count = _count_neighbors( x, y, x_pos, y_pos, module_ids, pixel_spacing, module_gap, current_module_id ) # A pixel is an edge pixel if it has fewer neighbors than the maximum return neighbor_count < max_neighbors def _create_pixel_patches( x_pos, y_pos, diameter, module_number, module_gap, spacing, shape, pixels_on, pixel_ids, pixels_id_to_print, telescope_model_name, ): """Create matplotlib patches for different pixel types. This function creates the matplotlib patches (shapes) for all pixels in the layout, categorizing them into "on", "edge", and "off" pixels based on their status and position. Parameters ---------- x_pos, y_pos : array-like X and Y coordinates of the pixel centers diameter : float Diameter of the pixels module_number : array-like Module numbers for each pixel module_gap : float Gap between modules spacing : float Pixel spacing shape : array-like Shape types for each pixel pixels_on : array-like Status indicating if each pixel is "on" pixel_ids : array-like Unique IDs for each pixel pixels_id_to_print : int Number of pixel IDs to print on the plot telescope_model_name : str Name of the telescope model Returns ------- tuple Three lists of patches for "on", "edge", and "off" pixels """ on_pixels, edge_pixels, off_pixels = [], [], [] array_element_type = names.get_array_element_type_from_name(telescope_model_name) font_size = 2 if "SCT" in array_element_type else 4 for i, (x, y) in enumerate(zip(x_pos, y_pos)): patch = _create_patch(x, y, diameter, shape) if pixels_on[i]: if _is_edge_pixel( x, y, x_pos, y_pos, module_number, spacing, module_gap, shape, module_number[i] ): edge_pixels.append(patch) else: on_pixels.append(patch) else: off_pixels.append(patch) if pixel_ids[i] < pixels_id_to_print: plt.text(x, y, pixel_ids[i], ha="center", va="center", fontsize=font_size) return on_pixels, edge_pixels, off_pixels def _count_neighbors(x, y, x_pos, y_pos, module_ids, pixel_spacing, module_gap, current_module_id): """ Count the number of neighboring pixels within the appropriate distance. Parameters ---------- x, y : float Coordinates of the pixel being checked. x_pos, y_pos : array-like Arrays of x and y positions of all pixels. module_ids : array-like Array of module IDs corresponding to each pixel. pixel_spacing : float Center-to-center spacing between pixels. module_gap : float Additional gap between modules. current_module_id : int Module ID of the current pixel. Returns ------- int Number of neighboring pixels. """ count = 0 tolerance = 1e-6 for x2, y2, module_id2 in zip(x_pos, y_pos, module_ids): # Skip the pixel itself if x == x2 and y == y2: continue # Calculate the distance between the current pixel and the potential neighbor dist = np.sqrt((x - x2) ** 2 + (y - y2) ** 2) # Determine max distance based on whether pixels are in same module max_distance = ( pixel_spacing + (0 if current_module_id == module_id2 else module_gap) + tolerance ) * 1.2 if dist <= max_distance: count += 1 return count def _configure_plot( ax, x_pos, y_pos, rotation=0 * u.deg, title=None, xtitle=None, ytitle=None, ): """Configure the plot with titles, labels, and limits. Parameters ---------- ax : matplotlib.axes.Axes The axes to configure x_pos, y_pos : array-like Arrays of x and y positions of pixels rotation : Astropy quantity in degrees, optional Rotation angle in degrees, default 0 title : str, optional Plot title xtitle : str, optional X-axis label ytitle : str, optional Y-axis label Returns ------- None The function modifies the plot axes in place. """ # First set the aspect ratio ax.set_aspect("equal") # Calculate the axis limits x_min, x_max = min(x_pos), max(x_pos) y_min, y_max = min(y_pos), max(y_pos) # Add some padding x_padding = (x_max - x_min) * 0.1 y_padding = (y_max - y_min) * 0.1 # Set limits with padding ax.set_xlim(x_min - x_padding, x_max + x_padding) ax.set_ylim(y_min - y_padding, y_max + y_padding) plt.grid(True) ax.set_axisbelow(True) plt.xlabel(xtitle or "Horizontal scale [cm]", fontsize=18, labelpad=0) plt.ylabel(ytitle or "Vertical scale [cm]", fontsize=18, labelpad=0) ax.set_title( title or "Pixel layout", fontsize=15, y=1.02, ) plt.tick_params(axis="both", which="major", labelsize=15) _add_coordinate_axes(ax, rotation) x_min = min(x_pos) - (max(x_pos) - min(x_pos)) * 0.05 y_min = min(y_pos) - (max(y_pos) - min(y_pos)) * 0.05 ax.text(x_min, y_min, "For an observer facing the camera", fontsize=10, ha="left", va="bottom") def _add_coordinate_axes(ax, rotation=0 * u.deg): """Add coordinate system axes to the plot.""" # Setup dimensions and positions x_min, x_max = ax.get_xlim() y_min, y_max = ax.get_ylim() plot_size = min(x_max - x_min, y_max - y_min) axis_length = plot_size * 0.08 x_origin = x_max - axis_length * 1.0 y_origin_az = y_min + axis_length * 2.5 y_origin_pix = y_min + axis_length * 1.2 arrow_style = { "head_width": axis_length * 0.15, "head_length": axis_length * 0.15, "width": axis_length * 0.02, } arrow_length = 0.6 is_sst = abs(rotation - (90.0 * u.deg)).value < 1.0 az_direction = 1 if is_sst else -1 def add_arrow_label(ox, oy, dx, dy, label, offset, color="black", ha="center", va="center"): """Adding arrows with label.""" ax.arrow(ox, oy, dx, dy, fc=color, ec=color, **arrow_style) if np.sqrt(dx**2 + dy**2) > 0: # If not zero vector dir_unit = np.sqrt(dx**2 + dy**2) ax.text( ox + dx + dx / dir_unit * axis_length * offset, oy + dy + dy / dir_unit * axis_length * offset, label, ha=ha, va=va, color=color, fontsize=10, fontweight="bold", ) # Az-Alt axes az_dx = az_direction * axis_length * arrow_length add_arrow_label( x_origin, y_origin_az, az_dx, 0, "Az", 0.25, "red", ha="left" if az_direction > 0 else "right", ) add_arrow_label( x_origin, y_origin_az, 0, -axis_length * arrow_length, "Alt", 0.25, "red", va="top" ) # Pixel coordinate axes rot_angle = rotation.to(u.rad).value x_direction = -1 if is_sst else 1 x_dir = x_direction * axis_length * arrow_length * np.cos(rot_angle) y_dir = x_direction * axis_length * arrow_length * np.sin(rot_angle) add_arrow_label(x_origin, y_origin_pix, x_dir, y_dir, "$\\mathrm{x}_\\mathrm{pix}$", 0.45) y_dx = axis_length * arrow_length * np.sin(rot_angle) y_dy = -axis_length * arrow_length * np.cos(rot_angle) add_arrow_label(x_origin, y_origin_pix, y_dx, y_dy, "$\\mathrm{y}_\\mathrm{pix}$", 0.45) def _add_legend(ax, on_pixels, off_pixels): """Add legend to the plot.""" legend_objects = [leg_h.PixelObject(), leg_h.EdgePixelObject()] legend_labels = ["Pixel", "Edge pixel"] # Choose handler based on pixel shape is_hex = isinstance(on_pixels[0], mpatches.RegularPolygon) legend_handler_map = { leg_h.PixelObject: leg_h.HexPixelHandler() if is_hex else leg_h.SquarePixelHandler(), leg_h.EdgePixelObject: leg_h.HexEdgePixelHandler() if is_hex else leg_h.SquareEdgePixelHandler(), leg_h.OffPixelObject: leg_h.HexOffPixelHandler() if is_hex else leg_h.SquareOffPixelHandler(), } if off_pixels: legend_objects.append(leg_h.OffPixelObject()) legend_labels.append("Disabled pixel") ax.legend( legend_objects, legend_labels, handler_map=legend_handler_map, prop={"size": 11}, loc="upper right", )