#!/usr/bin/python3
"""Functions for plotting mirror panel layout information."""
import logging
from pathlib import Path
import numpy as np
from simtools.visualization import visualize
from simtools.visualization.matplotlib_backend import lazy_module
from simtools.visualization.matplotlib_backend import pyplot as plt
logger = logging.getLogger(__name__)
mcolors = lazy_module("matplotlib.colors")
mpatches = lazy_module("matplotlib.patches")
collections = lazy_module("matplotlib.collections")
PATCH_STYLE = {"alpha": 0.8, "edgecolor": "black", "facecolor": "dodgerblue"}
LABEL_STYLE = {"ha": "center", "va": "center", "fontsize": 10, "color": "white", "weight": "bold"}
STATS_BOX_STYLE = {"boxstyle": "round", "facecolor": "wheat", "alpha": 0.8}
OBSERVER_TEXT = "for an observer facing the mirrors"
[docs]
def plot(config, output_file, data_file_path, mirrors=None):
"""
Plot mirror panel layout based on configuration.
Parameters
----------
config : dict
Keys ``parameter`` (e.g., "mirror_list", "primary_mirror_segmentation") and
``telescope`` (e.g., "LSTN-01").
output_file : str or Path
Path where to save the plot (without extension).
data_file_path : str or Path
Exported mirror or segmentation file.
mirrors : Mirrors, optional
Parsed mirror data; required for mirror lists.
"""
data_file_path = Path(data_file_path)
parameter_type = config["parameter"]
telescope = config["telescope"]
if parameter_type in ("primary_mirror_segmentation", "secondary_mirror_segmentation"):
plotters = {
"ring": plot_mirror_ring_segmentation,
"shape": plot_mirror_shape_segmentation,
"standard": plot_mirror_segmentation,
}
fig = plotters[_detect_segmentation_type(data_file_path)](
data_file_path=data_file_path,
telescope_model_name=telescope,
parameter_type=parameter_type,
)
else:
if mirrors is None:
raise ValueError("Mirror layout plotting requires parsed mirror data")
fig = plot_mirror_layout(
mirrors=mirrors, mirror_file_path=data_file_path, telescope_model_name=telescope
)
visualize.save_figure(fig, output_file)
plt.close(fig)
def _rotate_coordinates_clockwise_90(x_pos, y_pos):
"""
Rotate coordinates 90 degrees clockwise for the observer-facing view.
Any transformation applied to one mirror list must be applied consistently to all
primary mirror lists, otherwise sim_telarray mirror configurations are wrong.
"""
return y_pos, -x_pos
def _detect_segmentation_type(data_file_path):
"""Return "ring", "shape" or "standard" from the first data line of the file."""
with open(data_file_path, encoding="utf-8") as f:
for line in f:
line_lower = line.strip().lower()
if line_lower.startswith("#") or not line_lower:
continue
if line_lower.startswith("ring"):
return "ring"
if line_lower.startswith(("hex", "yhex")):
return "shape"
return "standard"
[docs]
def plot_mirror_layout(mirrors, mirror_file_path, telescope_model_name):
"""
Plot the mirror panel layout from a Mirrors object.
Parameters
----------
mirrors : Mirrors
Mirror panel positions, diameters, focal lengths and shape types.
mirror_file_path : Path or str
Mirror list file (its header provides the statistics).
telescope_model_name : str
Telescope model name (e.g., "LSTN-01", "MSTN-01").
Returns
-------
matplotlib.figure.Figure
The generated figure object.
"""
logger.info(f"Plotting mirror layout for {telescope_model_name}")
fig, ax = plt.subplots(figsize=(10, 10))
x_pos, y_pos = _rotate_coordinates_clockwise_90(
mirrors.mirror_table["mirror_x"].to("cm").value,
mirrors.mirror_table["mirror_y"].to("cm").value,
)
diameter = mirrors.mirror_diameter.to("cm").value
focal_lengths = mirrors.mirror_table["focal_length"].to("cm").value
mirror_ids = (
mirrors.mirror_table["mirror_panel_id"]
if "mirror_panel_id" in mirrors.mirror_table.colnames
else list(range(len(x_pos)))
)
# MST mirrors are numbered from 1 at the bottom to N at the top
if telescope_model_name and "MST" in telescope_model_name.upper():
mirror_ids = [len(mirror_ids) - mid for mid in mirror_ids]
_draw_panels(
ax,
x_pos,
y_pos,
diameter,
mirrors.shape_type,
focal_lengths,
mirror_ids,
cmap="viridis",
linewidth=0.5,
max_labels=20,
colorbar_label="Focal length [cm]",
)
_add_mirror_statistics(ax, mirrors, mirror_file_path, x_pos, y_pos, diameter)
return fig
[docs]
def plot_mirror_segmentation(data_file_path, telescope_model_name, parameter_type):
"""
Plot mirror segmentation layout from a standard segmentation file.
Parameters
----------
data_file_path : Path or str
Segmentation file with positions, diameters, shape types and segment IDs.
telescope_model_name : str
Telescope model name (e.g., "LSTN-01").
parameter_type : str
Segmentation parameter (e.g., "primary_mirror_segmentation").
Returns
-------
matplotlib.figure.Figure
The generated figure object.
"""
logger.info(f"Plotting {parameter_type} for {telescope_model_name}")
data = _read_segmentation_file(data_file_path)
fig, ax = plt.subplots(figsize=(10, 10))
x_pos, y_pos = _rotate_coordinates_clockwise_90(data["x"], data["y"])
diameter = data["diameter"]
segment_ids = data["segment_ids"]
_draw_panels(
ax,
x_pos,
y_pos,
diameter,
data["shape_type"],
segment_ids,
segment_ids,
cmap="tab20",
linewidth=0.8,
max_labels=30,
colorbar_label="Segment ID",
)
_add_stats_box(
ax,
f"Number of segments: {len(x_pos)}\n"
f"Number of segment groups: {len(set(segment_ids))}\n"
f"Segment diameter: {diameter:.1f} cm",
)
return fig
def _draw_panels(
ax,
x_pos,
y_pos,
diameter,
shape_type,
colors,
label_ids,
*,
cmap,
linewidth,
max_labels,
colorbar_label,
):
"""Draw hexagonal panels coloured by ``colors``, with labels, axes and a colour bar."""
patches = [
_create_single_mirror_patch(x, y, diameter, shape_type) for x, y in zip(x_pos, y_pos)
]
collection = collections.PatchCollection(
patches, cmap=cmap, edgecolor="black", linewidth=linewidth
)
collection.set_array(np.array(list(colors)))
ax.add_collection(collection)
_add_mirror_labels(ax, x_pos, y_pos, label_ids, max_labels=max_labels)
_configure_mirror_plot(ax, x_pos, y_pos)
plt.colorbar(collection, ax=ax, pad=0.02).set_label(colorbar_label, fontsize=14)
def _split_segmentation_line(line):
"""Return the whitespace-separated fields of a data line, or None for comments and blanks."""
line = line.strip()
if not line or line.startswith("#"):
return None
parts = line.split()
return parts if len(parts) >= 5 else None
def _parse_segment_id(parts):
"""Return the integer segment ID from the eighth column, or None if the column is absent."""
if len(parts) < 8:
return None
seg_id_str = parts[7].split("=")[-1] if "=" in parts[7] else parts[7]
return int("".join(filter(str.isdigit, seg_id_str)))
def _read_segmentation_file(data_file_path):
"""Read a standard segmentation file into positions, diameter, shape type and segment IDs."""
x_pos, y_pos, segment_ids = [], [], []
diameter = shape_type = None
with open(data_file_path, encoding="utf-8") as f:
for line in f:
parts = _split_segmentation_line(line)
if parts is None:
continue
try:
x_pos.append(float(parts[0]))
y_pos.append(float(parts[1]))
except ValueError:
continue
diameter = float(parts[2]) if diameter is None else diameter
shape_type = int(parts[4]) if shape_type is None else shape_type
segment_id = _parse_segment_id(parts)
segment_ids.append(len(segment_ids) if segment_id is None else segment_id)
if len(x_pos) == 0:
logger.warning(f"No valid numeric data found in segmentation file: {data_file_path}")
return {
"x": np.array(x_pos),
"y": np.array(y_pos),
"diameter": diameter if diameter is not None else 150.0,
"shape_type": shape_type if shape_type is not None else 3,
"segment_ids": segment_ids,
}
def _create_single_mirror_patch(x, y, diameter, shape_type):
"""Create a hexagonal patch for a mirror panel, rotated to the observer-facing view."""
base_orientation = 0 if shape_type == 1 else np.pi / 2
# Rotate hexagon counter-clockwise to compensate for clockwise coordinate rotation
orientation = base_orientation - np.pi / 2
return mpatches.RegularPolygon(
(x, y), numVertices=6, radius=diameter / np.sqrt(3), orientation=orientation
)
def _add_mirror_labels(ax, x_pos, y_pos, mirror_ids, max_labels=20):
"""Add panel ID labels (at most ``max_labels``, sorted by ID)."""
mirror_data = sorted(zip(mirror_ids, x_pos, y_pos), key=lambda item: item[0])
for mid, x, y in mirror_data[:max_labels]:
ax.text(x, y, str(mid), ha="center", va="center", fontsize=6, color="white", weight="bold")
def _configure_mirror_plot(ax, x_pos, y_pos):
"""Set aspect, limits, labels and observer note of a mirror panel plot."""
ax.set_aspect("equal")
if len(x_pos) == 0 or len(y_pos) == 0:
logger.warning("No valid mirror data found for plotting")
ax.set_xlim(-1000, 1000)
ax.set_ylim(-1000, 1000)
ax.text(0, 0, "No valid mirror data", ha="center", va="center", fontsize=14, color="red")
return
x_min, x_max = np.min(x_pos), np.max(x_pos)
y_min, y_max = np.min(y_pos), np.max(y_pos)
x_padding = max((x_max - x_min) * 0.15, abs(x_min) * 0.15, 1.0)
y_padding = max((y_max - y_min) * 0.15, abs(y_min) * 0.15, 1.0)
ax.set_xlim(x_min - x_padding, x_max + x_padding)
ax.set_ylim(y_min - y_padding, y_max + y_padding)
_decorate_panel_axes(ax)
def _decorate_panel_axes(ax):
"""Add axis labels, grid, tick sizes and the observer note."""
plt.xlabel("X position [cm]", fontsize=14)
plt.ylabel("Y position [cm]", fontsize=14)
plt.grid(True, alpha=0.3)
plt.tick_params(axis="both", which="major", labelsize=12)
_add_observer_text(ax)
def _add_observer_text(ax):
"""Add the observer note in the lower left corner."""
ax.text(
0.02,
0.02,
OBSERVER_TEXT,
transform=ax.transAxes,
fontsize=10,
ha="left",
va="bottom",
style="italic",
color="black",
)
def _add_stats_box(ax, text):
"""Add a statistics box in the upper left corner."""
ax.text(
0.02,
0.98,
text,
transform=ax.transAxes,
fontsize=11,
verticalalignment="top",
bbox=STATS_BOX_STYLE,
)
def _extract_float_after_keyword(line, keyword):
"""Extract the first float after ``keyword`` in ``line``; None if not found."""
if keyword not in line:
return None
try:
part = line.split(keyword, 1)[1] if keyword == "=" else line.split(keyword)[-1]
return float(part.strip().split()[0])
except ValueError, IndexError:
return None
def _read_mirror_file_metadata(mirror_file_path):
"""Read Rmax and total surface area from the mirror file header."""
metadata = {}
patterns = [
(("Total surface area:", "Total mirror surface area:"), ":", "total_surface_area"),
(("Rmax =", "Rmax="), "=", "rmax"),
(("mirrors are inside a radius of",), "of", "rmax"),
]
try:
with open(mirror_file_path, encoding="utf-8") as f:
for line in f:
if not line.startswith("#"):
break
for triggers, keyword, key in patterns:
if key not in metadata and any(t in line for t in triggers):
if (val := _extract_float_after_keyword(line, keyword)) is not None:
metadata[key] = val
break
except OSError as e:
logger.warning(f"Could not read mirror file metadata: {e}")
return metadata
def _add_mirror_statistics(ax, mirrors, mirror_file_path, x_pos, y_pos, diameter):
"""Add the mirror statistics box (header values, else computed from the layout)."""
n_mirrors = mirrors.number_of_mirrors
metadata = _read_mirror_file_metadata(mirror_file_path)
max_radius = metadata.get("rmax")
total_area = metadata.get("total_surface_area")
if max_radius is None:
max_radius = np.sqrt(np.max(x_pos**2 + y_pos**2)) / 100.0
if total_area is None:
panel_area = 3 * np.sqrt(3) / 2 * (diameter / 200.0) ** 2
total_area = n_mirrors * panel_area
_add_stats_box(
ax,
f"Number of mirrors: {n_mirrors}\n"
f"Mirror diameter: {diameter:.1f} cm\n"
f"Max radius: {max_radius:.2f} m\n"
f"Total surface area: {total_area:.2f} $m^{2}$",
)
def _read_ring_segmentation_data(data_file_path):
"""Read ``ring <nseg> <rmin> <rmax> <dphi> <phi0>`` lines."""
rings = []
with open(data_file_path, encoding="utf-8") as f:
for line in f:
if not line.startswith(("#", "%")) and line.lower().startswith("ring"):
parts = [part.strip() for part in line.split()]
rings.append(
{
"nseg": int(parts[1]),
"rmin": float(parts[2]),
"rmax": float(parts[3]),
"dphi": float(parts[4]),
"phi0": float(parts[5]),
}
)
return rings
def _plot_single_ring(ax, ring, cmap, color_index):
"""Fill the segments of one ring; 0.3 degree gaps separate neighbouring segments."""
if ring["nseg"] <= 1:
return
dphi_rad = ring["dphi"] * np.pi / 180
phi0_rad = ring["phi0"] * np.pi / 180
gap_rad = 0.3 * np.pi / 180
for i in range(ring["nseg"]):
theta_i = i * dphi_rad + phi0_rad
theta_seg = np.linspace(theta_i + gap_rad, theta_i + dphi_rad - gap_rad, 100)
color = cmap((color_index + i % 10) / 20.0)
ax.fill_between(theta_seg, ring["rmin"], ring["rmax"], color=color, alpha=0.8)
def _add_ring_radius_label(ax, angle, radius, label_text):
"""Add a radius label at the given angle and radius."""
ax.text(
angle,
radius,
label_text,
ha="center",
va="center",
fontsize=9,
color="red",
weight="bold",
bbox={"boxstyle": "round,pad=0.3", "facecolor": "white", "alpha": 0.8},
)
[docs]
def plot_mirror_ring_segmentation(data_file_path, telescope_model_name, parameter_type):
"""
Plot mirror ring segmentation layout (polar view).
Parameters
----------
data_file_path : Path or str
Segmentation file with ``ring <nseg> <rmin> <rmax> <dphi> <phi0>`` lines.
telescope_model_name : str
Telescope model name (e.g., "LSTN-01").
parameter_type : str
Segmentation parameter (e.g., "primary_mirror_segmentation").
Returns
-------
matplotlib.figure.Figure or None
The generated figure object, or None if no ring data is found.
"""
logger.info(f"Plotting ring {parameter_type} for {telescope_model_name}")
rings = _read_ring_segmentation_data(data_file_path)
if not rings:
logger.warning(f"No ring data found in {data_file_path}")
return None
fig, ax = plt.subplots(subplot_kw={"projection": "polar"}, figsize=(10, 10))
cmap = mcolors.LinearSegmentedColormap.from_list("mirror_blue", ["#deebf7", "#3182bd"])
for i, ring in enumerate(rings):
_plot_single_ring(ax, ring, cmap, color_index=i * 10)
max_radius = max(ring["rmax"] for ring in rings)
label_padding = max_radius * 0.04
ax.set_ylim([0, max_radius + label_padding])
ax.set_yticklabels([])
ax.set_rgrids([])
ax.spines["polar"].set_visible(False)
label_angle = 30 * np.pi / 180
theta_full = np.linspace(0, 2 * np.pi, 360)
for ring in rings:
for radius in (ring["rmin"], ring["rmax"]):
ax.plot(theta_full, np.repeat(radius, 360), ":", color="gray", lw=0.8, alpha=0.5)
for radius in (ring["rmin"], ring["rmax"]):
_add_ring_radius_label(ax, label_angle, radius, f"{radius:.3f}")
ax.text(
label_angle,
max_radius + label_padding * 2.5,
"[cm]",
ha="center",
va="center",
fontsize=10,
weight="bold",
color="red",
)
if len(rings) == 2:
stats_text = (
f"Inner ring segments: {rings[0]['nseg']}\nOuter ring segments: {rings[1]['nseg']}"
)
else:
stats_text = "\n".join(
f"Ring {i + 1} segments: {ring['nseg']}" for i, ring in enumerate(rings)
)
_add_stats_box(ax, stats_text)
_add_observer_text(ax)
plt.tight_layout()
return fig
def _parse_segment_id_header(line):
"""Return the segment ID given on a ``segment id`` header line (0 if unreadable)."""
try:
return int(line.split()[-1])
except ValueError, IndexError:
return 0
def _read_shape_segmentation_file(data_file_path):
"""Read hex and yhex shape entries; returns ``(shape_segments, segment_ids)``."""
shape_segments, segment_ids = [], []
current_segment_id = 0
with open(data_file_path, encoding="utf-8") as f:
for line in f:
line_stripped = line.strip()
if "segment id" in line_stripped.lower():
current_segment_id = _parse_segment_id_header(line_stripped)
continue
if not line_stripped or line_stripped.startswith(("#", "%")):
continue
entries = line_stripped.split()
if line_stripped.lower().startswith(("hex", "yhex")) and len(entries) >= 5:
shape_segments.append(
{
"x": float(entries[2]),
"y": float(entries[3]),
"diameter": float(entries[4]),
"rotation": float(entries[5]) if len(entries) > 5 else 0.0,
}
)
segment_ids.append(
current_segment_id if current_segment_id > 0 else len(shape_segments)
)
return shape_segments, segment_ids
def _create_shape_patches(ax, shape_segments, segment_ids):
"""Create hexagon patches and their labels; returns ``(patches, maximum_radius)``."""
patches, maximum_radius = [], 0
for i_seg, seg in enumerate(shape_segments):
x, y, diam, rot = seg["x"], seg["y"], seg["diameter"], seg["rotation"]
maximum_radius = max(maximum_radius, abs(x) + diam / 2, abs(y) + diam / 2)
patches.append(
mpatches.RegularPolygon(
(x, y),
numVertices=6,
radius=diam / np.sqrt(3),
orientation=np.deg2rad(rot),
**PATCH_STYLE,
)
)
label = segment_ids[i_seg] if i_seg < len(segment_ids) else i_seg + 1
ax.text(x, y, str(label), **LABEL_STYLE)
return patches, maximum_radius
[docs]
def plot_mirror_shape_segmentation(data_file_path, telescope_model_name, parameter_type):
"""
Plot mirror shape segmentation layout (explicit hex and yhex shapes).
Parameters
----------
data_file_path : Path or str
Segmentation file with explicit shape definitions (hex, yhex).
telescope_model_name : str
Telescope model name (e.g., "LSTN-01", "MSTN-design").
parameter_type : str
Segmentation parameter (e.g., "primary_mirror_segmentation").
Returns
-------
matplotlib.figure.Figure
The generated figure object.
"""
logger.info(f"Plotting shape {parameter_type} for {telescope_model_name}")
shape_segments, segment_ids = _read_shape_segmentation_file(data_file_path)
for seg in shape_segments:
seg["x"], seg["y"] = _rotate_coordinates_clockwise_90(seg["x"], seg["y"])
seg["rotation"] = seg["rotation"] - 90
fig, ax = plt.subplots(figsize=(10, 10))
all_patches, maximum_radius = _create_shape_patches(ax, shape_segments, segment_ids)
ax.add_collection(collections.PatchCollection(all_patches, match_original=True))
ax.set_aspect("equal")
padding = maximum_radius * 0.1 if maximum_radius > 0 else 100
ax.set_xlim(-maximum_radius - padding, maximum_radius + padding)
ax.set_ylim(-maximum_radius - padding, maximum_radius + padding)
_decorate_panel_axes(ax)
if segment_ids and shape_segments:
stats_text = f"Number of segments: {len(set(segment_ids))}"
elif shape_segments:
stats_text = f"Number of segments: {len(shape_segments)}"
else:
stats_text = "No segment data"
_add_stats_box(ax, stats_text)
return fig