Source code for lsurf.visualization.fresnel_plots

# The Clear BSD License
#
# Copyright (c) 2026 Tobias Heibges
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted (subject to the limitations in the disclaimer
# below) provided that the following conditions are met:
#
#      * Redistributions of source code must retain the above copyright notice,
#      this list of conditions and the following disclaimer.
#
#      * Redistributions in binary form must reproduce the above copyright
#      notice, this list of conditions and the following disclaimer in the
#      documentation and/or other materials provided with the distribution.
#
#      * Neither the name of the copyright holder nor the names of its
#      contributors may be used to endorse or promote products derived from this
#      software without specific prior written permission.
#
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

"""
Fresnel Reflection Visualization

Functions for plotting Fresnel reflection curves, Brewster angle validation,
and related optical analysis.
"""

from typing import TYPE_CHECKING, Optional

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.figure import Figure

if TYPE_CHECKING:
    from ..utilities.ray_data import RayBatch

from .common import save_figure


[docs] def plot_fresnel_reflection( incident_rays: "RayBatch", reflected_rays: "RayBatch", refracted_rays: Optional["RayBatch"], surface_normal: tuple[float, float, float], surface_angle_deg: float, n1: float, n2: float, wavelength: float, fresnel_func: callable | None = None, figsize: tuple[float, float] = (16, 10), save_path: str | None = None, ) -> Figure: """ Create comprehensive Fresnel reflection visualization. Shows ray paths, intensity distributions, Fresnel curves, and energy summary. Parameters ---------- incident_rays : RayBatch Original incident rays. reflected_rays : RayBatch Reflected rays from surface interaction. refracted_rays : RayBatch, optional Refracted (transmitted) rays. surface_normal : tuple Surface normal vector (nx, ny, nz). surface_angle_deg : float Surface tilt angle in degrees. n1 : float Refractive index of incident medium. n2 : float Refractive index of transmission medium. wavelength : float Optical wavelength in meters. fresnel_func : callable, optional Function to compute Fresnel coefficients: fresnel_func(n1, n2, cos_theta, pol). If not provided, uses surface_roughness.utilities.fresnel.fresnel_coefficients. figsize : tuple Figure size. save_path : str, optional Path to save figure. Returns ------- Figure Matplotlib figure with 6 subplots. """ # Import fresnel function if not provided if fresnel_func is None: from ..utilities.fresnel import fresnel_coefficients fresnel_func = fresnel_coefficients normal = np.array(surface_normal) angle_rad = np.radians(surface_angle_deg) # Calculate powers num_rays = len(incident_rays.positions) incident_power = np.sum(incident_rays.intensities) num_reflected = np.sum(reflected_rays.active) if reflected_rays else 0 reflected_power = ( np.sum(reflected_rays.intensities[reflected_rays.active]) if reflected_rays else 0 ) num_refracted = np.sum(refracted_rays.active) if refracted_rays else 0 refracted_power = ( np.sum(refracted_rays.intensities[refracted_rays.active]) if refracted_rays else 0 ) total_output = reflected_power + refracted_power # Compute Fresnel coefficients at this angle cos_theta = np.cos(angle_rad) R_unpol, T_unpol = fresnel_func(n1, n2, cos_theta, "unpolarized") R_s, T_s = fresnel_func(n1, n2, cos_theta, "s") R_p, T_p = fresnel_func(n1, n2, cos_theta, "p") # Convert to scalar if needed R_unpol = float(R_unpol[0]) if hasattr(R_unpol, "__len__") else float(R_unpol) T_unpol = float(T_unpol[0]) if hasattr(T_unpol, "__len__") else float(T_unpol) R_s = float(R_s[0]) if hasattr(R_s, "__len__") else float(R_s) R_p = float(R_p[0]) if hasattr(R_p, "__len__") else float(R_p) # Create figure fig = plt.figure(figsize=figsize, constrained_layout=True) gs = fig.add_gridspec(2, 3) # 1. Side view (XZ plane) ax1 = fig.add_subplot(gs[0, 0]) ax1.set_title("Side View (XZ Plane)", fontweight="bold") # Plot incident rays ax1.scatter( incident_rays.positions[:, 0] * 1e3, incident_rays.positions[:, 2] * 1e3, c="blue", s=5, alpha=0.5, label="Incident", ) # Plot reflected rays if reflected_rays and num_reflected > 0: ax1.scatter( reflected_rays.positions[:, 0] * 1e3, reflected_rays.positions[:, 2] * 1e3, c="red", s=5, alpha=0.5, label="Reflected", ) # Show ray direction arrows for i in range(0, min(50, len(reflected_rays.positions)), 5): if reflected_rays.active[i]: start = reflected_rays.positions[i] end = start + 0.05 * reflected_rays.directions[i] ax1.plot( [start[0] * 1e3, end[0] * 1e3], [start[2] * 1e3, end[2] * 1e3], "r-", alpha=0.3, linewidth=0.5, ) # Plot refracted rays if refracted_rays and num_refracted > 0: ax1.scatter( refracted_rays.positions[:, 0] * 1e3, refracted_rays.positions[:, 2] * 1e3, c="green", s=5, alpha=0.5, label="Refracted", ) for i in range(0, min(50, len(refracted_rays.positions)), 5): if refracted_rays.active[i]: start = refracted_rays.positions[i] end = start + 0.05 * refracted_rays.directions[i] ax1.plot( [start[0] * 1e3, end[0] * 1e3], [start[2] * 1e3, end[2] * 1e3], "g-", alpha=0.3, linewidth=0.5, ) # Draw surface line x_range = np.linspace(-20, 20, 100) z_surface = -x_range * np.tan(angle_rad) ax1.plot(x_range, z_surface, "k-", linewidth=2, label="Surface") # Draw normal vector normal_end = 15e-3 * normal ax1.arrow( 0, 0, normal_end[0] * 1e3, normal_end[2] * 1e3, head_width=2, head_length=2, fc="black", ec="black", linewidth=2, ) ax1.text( normal_end[0] * 1e3 + 2, normal_end[2] * 1e3 + 2, "n", fontsize=12, fontweight="bold", ) ax1.set_xlabel("X (mm)") ax1.set_ylabel("Z (mm)") ax1.legend() ax1.grid(True, alpha=0.3) ax1.set_aspect("equal") ax1.set_xlim(-15, 15) ax1.set_ylim(-60, 20) # 2. Top view (XY plane) ax2 = fig.add_subplot(gs[0, 1]) ax2.set_title("Top View (XY Plane)", fontweight="bold") if reflected_rays and num_reflected > 0: scatter = ax2.scatter( reflected_rays.positions[:, 0] * 1e3, reflected_rays.positions[:, 1] * 1e3, c=reflected_rays.intensities, s=20, cmap="hot", alpha=0.6, ) plt.colorbar(scatter, ax=ax2, label="Intensity") ax2.axhline(0, color="k", linewidth=2, label="Surface") ax2.set_xlabel("X (mm)") ax2.set_ylabel("Y (mm)") ax2.legend() ax2.grid(True, alpha=0.3) ax2.set_aspect("equal") # 3. Intensity distribution ax3 = fig.add_subplot(gs[0, 2]) ax3.set_title("Intensity Distribution", fontweight="bold") if reflected_rays and num_reflected > 0: intensities_reflected = reflected_rays.intensities[reflected_rays.active] ax3.hist( intensities_reflected, bins=30, alpha=0.7, color="red", label="Reflected" ) if refracted_rays and num_refracted > 0: intensities_refracted = refracted_rays.intensities[refracted_rays.active] ax3.hist( intensities_refracted, bins=30, alpha=0.7, color="green", label="Refracted" ) ax3.set_xlabel("Intensity") ax3.set_ylabel("Count") ax3.legend() ax3.grid(True, alpha=0.3) # 4. Fresnel reflection curves ax4 = fig.add_subplot(gs[1, 0]) ax4.set_title("Fresnel Reflection vs Angle", fontweight="bold") angles = np.linspace(0, 90, 200) cos_angles = np.cos(np.radians(angles)) R_unpol_curve = [] R_s_curve = [] R_p_curve = [] for cos_th in cos_angles: R_u, _ = fresnel_func(n1, n2, cos_th, "unpolarized") R_s_val, _ = fresnel_func(n1, n2, cos_th, "s") R_p_val, _ = fresnel_func(n1, n2, cos_th, "p") R_unpol_curve.append(float(R_u[0]) if hasattr(R_u, "__len__") else float(R_u)) R_s_curve.append( float(R_s_val[0]) if hasattr(R_s_val, "__len__") else float(R_s_val) ) R_p_curve.append( float(R_p_val[0]) if hasattr(R_p_val, "__len__") else float(R_p_val) ) ax4.plot(angles, R_unpol_curve, "b-", label="Unpolarized", linewidth=2) ax4.plot(angles, R_s_curve, "r--", label="s-polarization", linewidth=2) ax4.plot(angles, R_p_curve, "g--", label="p-polarization", linewidth=2) ax4.axvline( surface_angle_deg, color="orange", linestyle=":", linewidth=2, label=f"Current ({surface_angle_deg}°)", ) brewster_angle = np.degrees(np.arctan(n2 / n1)) ax4.axvline( brewster_angle, color="purple", linestyle=":", linewidth=2, label=f"Brewster ({brewster_angle:.1f}°)", ) ax4.set_xlabel("Angle of Incidence (°)") ax4.set_ylabel("Reflectance R") ax4.legend() ax4.grid(True, alpha=0.3) ax4.set_xlim(0, 90) ax4.set_ylim(0, 1) # 5. Direction vectors ax5 = fig.add_subplot(gs[1, 1]) ax5.set_title("Ray Directions (unit vectors)", fontweight="bold") sample_indices = np.random.choice(num_rays, min(100, num_rays), replace=False) # Incident ax5.scatter( incident_rays.directions[sample_indices, 0], incident_rays.directions[sample_indices, 2], c="blue", s=20, alpha=0.5, label="Incident", ) # Reflected if reflected_rays and num_reflected > 0: n_sample = min(100, len(reflected_rays.directions)) sample_reflected = np.random.choice( len(reflected_rays.directions), n_sample, replace=False ) ax5.scatter( reflected_rays.directions[sample_reflected, 0], reflected_rays.directions[sample_reflected, 2], c="red", s=20, alpha=0.5, label="Reflected", ) # Refracted if refracted_rays and num_refracted > 0: n_sample = min(100, len(refracted_rays.directions)) sample_refracted = np.random.choice( len(refracted_rays.directions), n_sample, replace=False ) ax5.scatter( refracted_rays.directions[sample_refracted, 0], refracted_rays.directions[sample_refracted, 2], c="green", s=20, alpha=0.5, label="Refracted", ) # Surface normal ax5.arrow( 0, 0, normal[0], normal[2], head_width=0.1, head_length=0.1, fc="black", ec="black", linewidth=2, ) ax5.text(normal[0] + 0.1, normal[2] + 0.1, "n", fontsize=12, fontweight="bold") ax5.set_xlabel("Direction X") ax5.set_ylabel("Direction Z") ax5.legend() ax5.grid(True, alpha=0.3) ax5.set_aspect("equal") ax5.set_xlim(-1, 1) ax5.set_ylim(-1, 1) # 6. Power summary ax6 = fig.add_subplot(gs[1, 2]) ax6.axis("off") measured_R = reflected_power / total_output if total_output > 0 else 0 energy_error = ( abs(incident_power - total_output) / incident_power * 100 if incident_power > 0 else 0 ) summary_text = f""" SUMMARY ═══════════════════════════ Incident Beam: • Power: {incident_power:.4f} W • Wavelength: {wavelength*1e9:.0f} nm • Rays: {num_rays:,} Surface: • n₁ = {n1:.4f} • n₂ = {n2:.4f} • Angle: {surface_angle_deg}° Fresnel Theory: • R (unpol): {R_unpol:.4f} • T (unpol): {T_unpol:.4f} Simulation Results: • Reflected: {reflected_power:.4f} W • Refracted: {refracted_power:.4f} W • R measured: {measured_R:.4f} Energy Conservation: • Total: {total_output:.4f} W • Error: {energy_error:.2f}% """ ax6.text( 0.1, 0.95, summary_text, transform=ax6.transAxes, fontsize=10, verticalalignment="top", fontfamily="monospace", bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5}, ) # Main title fig.suptitle( f"Fresnel Reflection Analysis at {surface_angle_deg}°", fontsize=16, fontweight="bold", ) if save_path: save_figure(fig, save_path) return fig
def _plot_brewster_main_comparison( ax, angles_theory_deg: np.ndarray, R_s_theory: np.ndarray, R_p_theory: np.ndarray, angles_sim_deg: np.ndarray, R_s_sim: np.ndarray, R_p_sim: np.ndarray, n1: float, n2: float, brewster_angle_deg: float, brewster_sim_deg: float, measured_brewster_deg: float, min_theory_reflection: float, min_sim_reflection: float, ) -> None: """ Plot main Fresnel reflection comparison (theory vs simulation). Parameters ---------- ax : matplotlib.axes.Axes Axes to plot on. angles_theory_deg : ndarray Theory angle array in degrees. R_s_theory : ndarray Theoretical s-polarization reflectance. R_p_theory : ndarray Theoretical p-polarization reflectance. angles_sim_deg : ndarray Simulation angle array in degrees. R_s_sim : ndarray Simulated s-polarization reflectance. R_p_sim : ndarray Simulated p-polarization reflectance. n1 : float Refractive index of incident medium. n2 : float Refractive index of transmission medium. brewster_angle_deg : float Theoretical Brewster angle in degrees. brewster_sim_deg : float Simulated Brewster angle in degrees. measured_brewster_deg : float Measured Brewster angle from theory minimum. min_theory_reflection : float Minimum theoretical R_p value. min_sim_reflection : float Minimum simulated R_p value. """ # Theory curves ax.plot( angles_theory_deg, R_s_theory, "r-", linewidth=2.5, alpha=0.7, label="Theory: s-pol (TE)", ) ax.plot( angles_theory_deg, R_p_theory, "g-", linewidth=2.5, alpha=0.7, label="Theory: p-pol (TM)", ) # Simulated data points ax.plot( angles_sim_deg, R_s_sim, "rs", markersize=6, markeredgewidth=1, markeredgecolor="darkred", markerfacecolor="lightcoral", label="Simulation: s-pol", zorder=5, alpha=0.8, ) ax.plot( angles_sim_deg, R_p_sim, "go", markersize=6, markeredgewidth=1, markeredgecolor="darkgreen", markerfacecolor="lightgreen", label="Simulation: p-pol", zorder=5, alpha=0.8, ) # Mark Brewster angles ax.axvline( brewster_angle_deg, color="orange", linestyle=":", linewidth=2.5, label=f"Theory Brewster ({brewster_angle_deg:.2f}°)", ) ax.axvline( brewster_sim_deg, color="purple", linestyle="--", linewidth=2.5, label=f"Simulated Brewster ({brewster_sim_deg:.0f}°)", ) ax.plot( measured_brewster_deg, min_theory_reflection, "ko", markersize=10, label=f"Theory R_p minimum ({min_theory_reflection:.5f})", ) ax.plot( brewster_sim_deg, min_sim_reflection, "mo", markersize=10, label=f"Sim R_p minimum ({min_sim_reflection:.5f})", ) ax.set_xlabel("Angle of Incidence (degrees)", fontsize=12, fontweight="bold") ax.set_ylabel("Reflectance R", fontsize=12, fontweight="bold") ax.set_title( "Fresnel Reflection: Theory vs Simulation", fontsize=14, fontweight="bold" ) ax.legend(loc="best", fontsize=10) ax.grid(True, alpha=0.3) ax.set_xlim(0, 90) ax.set_ylim(1e-6, 1) ax.set_yscale("log") # Add text box with key info textstr = f"n₁ = {n1:.4f}\nn₂ = {n2:.4f}\nθ_B = {brewster_angle_deg:.2f}°" props = {"boxstyle": "round", "facecolor": "wheat", "alpha": 0.8} ax.text( 0.02, 0.98, textstr, transform=ax.transAxes, fontsize=11, verticalalignment="top", bbox=props, family="monospace", ) def _plot_brewster_zoom( ax, angles_theory_deg: np.ndarray, R_s_theory: np.ndarray, R_p_theory: np.ndarray, angles_sim_deg: np.ndarray, R_s_sim: np.ndarray, R_p_sim: np.ndarray, brewster_angle_deg: float, brewster_sim_deg: float, measured_brewster_deg: float, min_theory_reflection: float, min_sim_reflection: float, zoom_range: float = 10.0, ) -> None: """ Plot zoomed view near Brewster angle. Parameters ---------- ax : matplotlib.axes.Axes Axes to plot on. angles_theory_deg : ndarray Theory angle array in degrees. R_s_theory : ndarray Theoretical s-polarization reflectance. R_p_theory : ndarray Theoretical p-polarization reflectance. angles_sim_deg : ndarray Simulation angle array in degrees. R_s_sim : ndarray Simulated s-polarization reflectance. R_p_sim : ndarray Simulated p-polarization reflectance. brewster_angle_deg : float Theoretical Brewster angle in degrees. brewster_sim_deg : float Simulated Brewster angle in degrees. measured_brewster_deg : float Measured Brewster angle from theory minimum. min_theory_reflection : float Minimum theoretical R_p value. min_sim_reflection : float Minimum simulated R_p value. zoom_range : float, optional Range in degrees around Brewster angle to display. """ zoom_mask = (angles_theory_deg >= brewster_angle_deg - zoom_range) & ( angles_theory_deg <= brewster_angle_deg + zoom_range ) zoom_sim_mask = (angles_sim_deg >= brewster_angle_deg - zoom_range) & ( angles_sim_deg <= brewster_angle_deg + zoom_range ) # Theory ax.plot( angles_theory_deg[zoom_mask], R_s_theory[zoom_mask], "r-", linewidth=2, label="Theory: s-pol", alpha=0.7, ) ax.plot( angles_theory_deg[zoom_mask], R_p_theory[zoom_mask], "g-", linewidth=2, label="Theory: p-pol", alpha=0.7, ) # Simulation ax.plot( angles_sim_deg[zoom_sim_mask], R_s_sim[zoom_sim_mask], "rs", markersize=5, label="Sim: s-pol", ) ax.plot( angles_sim_deg[zoom_sim_mask], R_p_sim[zoom_sim_mask], "go", markersize=5, label="Sim: p-pol", ) ax.axvline( brewster_angle_deg, color="orange", linestyle=":", linewidth=2, label="Theory" ) ax.axvline( brewster_sim_deg, color="purple", linestyle="--", linewidth=2, label="Simulation", ) ax.plot(measured_brewster_deg, min_theory_reflection, "ko", markersize=8) ax.plot(brewster_sim_deg, min_sim_reflection, "mo", markersize=8) ax.set_xlabel("Angle of Incidence (°)", fontsize=10) ax.set_ylabel("Reflectance R", fontsize=10) ax.set_title(f"Zoom: Brewster Angle ± {zoom_range:.0f}°", fontweight="bold") ax.legend(fontsize=9) ax.grid(True, alpha=0.3) def _plot_brewster_polarization_ratio( ax, angles_theory_deg: np.ndarray, R_s: np.ndarray, R_p: np.ndarray, brewster_angle_deg: float = None, **kwargs, ) -> None: """ Plot polarization ratio (R_p / R_s). Parameters ---------- ax : matplotlib.axes.Axes Axes to plot on. angles_theory_deg : ndarray Theory angle array in degrees. R_s : ndarray Theoretical s-polarization reflectance. R_p : ndarray Theoretical p-polarization reflectance. brewster_angle_deg : float Theoretical Brewster angle in degrees. """ ratio = np.where(R_s > 1e-6, R_p / R_s, 0) ax.plot(angles_theory_deg, ratio, **kwargs) if brewster_angle_deg is not None: ax.axvline( brewster_angle_deg, color="orange", linestyle=":", linewidth=2, label="Brewster Angle", ) ax.axhline(0, color="k", linestyle="-", linewidth=0.5) ax.set_xlabel("Angle of Incidence (°)", fontsize=10) ax.set_ylabel(r"$R_p / R_s$", fontsize=10) ax.set_title("Polarization Ratio", fontweight="bold") ax.grid(True, alpha=0.3) ax.set_xlim(0, 90) ax.set_ylim(-0.1, 1.0) def _plot_brewster_polarization_degree( ax, angles_theory_deg: np.ndarray, R_s: np.ndarray, R_p: np.ndarray, brewster_angle_deg: float = None, **kwargs, ) -> None: """ Plot polarization degree ((R_s - R_p) / (R_s + R_p)). Parameters ---------- ax : matplotlib.axes.Axes Axes to plot on. angles_theory_deg : ndarray Theory angle array in degrees. R_s : ndarray Theoretical s-polarization reflectance. R_p : ndarray Theoretical p-polarization reflectance. brewster_angle_deg : float Theoretical Brewster angle in degrees. """ pol_degree = np.where((R_s + R_p) > 1e-6, (R_s - R_p) / (R_s + R_p), 0) ax.plot(angles_theory_deg, pol_degree, **kwargs) if brewster_angle_deg is not None: ax.axvline( brewster_angle_deg, color="orange", linestyle=":", linewidth=2, label="Brewster Angle", ) ax.axhline(0, color="k", linestyle="-", linewidth=0.5) ax.set_xlabel("Angle of Incidence (°)", fontsize=10) ax.set_ylabel(r"$(R_s - R_p) / (R_s + R_p)$", fontsize=10) ax.set_title("Polarization Degree", fontweight="bold") ax.grid(True, alpha=0.3) ax.set_xlim(0, 90) ax.set_ylim(-0.1, 1.0) def _plot_brewster_validation_summary( ax, angles_theory_deg: np.ndarray, R_s_theory: np.ndarray, R_p_theory: np.ndarray, angles_sim_deg: np.ndarray, R_s_sim: np.ndarray, R_p_sim: np.ndarray, brewster_angle_deg: float, brewster_sim_deg: float, measured_brewster_deg: float, min_theory_reflection: float, min_sim_reflection: float, num_rays_per_angle: int, ) -> None: """ Plot validation summary text box. Parameters ---------- ax : matplotlib.axes.Axes Axes to plot on. angles_theory_deg : ndarray Theory angle array in degrees. R_s_theory : ndarray Theoretical s-polarization reflectance. R_p_theory : ndarray Theoretical p-polarization reflectance. angles_sim_deg : ndarray Simulation angle array in degrees. R_s_sim : ndarray Simulated s-polarization reflectance. R_p_sim : ndarray Simulated p-polarization reflectance. brewster_angle_deg : float Theoretical Brewster angle in degrees. brewster_sim_deg : float Simulated Brewster angle in degrees. measured_brewster_deg : float Measured Brewster angle from theory minimum. min_theory_reflection : float Minimum theoretical R_p value. min_sim_reflection : float Minimum simulated R_p value. num_rays_per_angle : int Number of rays used per angle in simulation. """ ax.axis("off") # Get R_s values at Brewster angle min_sim_idx = np.argmin(R_p_sim) theory_brewster_idx = np.argmin(np.abs(angles_theory_deg - brewster_angle_deg)) R_s_at_brewster_theory = R_s_theory[theory_brewster_idx] R_s_at_brewster_sim = R_s_sim[min_sim_idx] if len(R_s_sim) > 0 else 0 # Get R at normal incidence R_at_normal = R_s_theory[0] if len(R_s_theory) > 0 else 0 validation_text = f""" VALIDATION RESULTS ════════════════════════════════ Theoretical: θ_B = arctan(n₂/n₁) θ_B = {brewster_angle_deg:.3f}° Measured from Theory R_p min: θ_B = {measured_brewster_deg:.3f}° Error = {abs(measured_brewster_deg - brewster_angle_deg):.4f}° Simulated Brewster Angle: θ_B = {brewster_sim_deg:.0f}° Error = {abs(brewster_sim_deg - brewster_angle_deg):.3f}° R_p minimum = {min_sim_reflection:.6f} Simulation Coverage: Angles: 0° to {angles_sim_deg[-1]:.0f}° Total: {len(angles_sim_deg)} angles Rays per angle: {num_rays_per_angle} Agreement at θ_B: R_s (theory): {R_s_at_brewster_theory:.5f} R_s (sim): {R_s_at_brewster_sim:.5f} R_p (theory): {min_theory_reflection:.5f} R_p (sim): {min_sim_reflection:.5f} Key Properties: • R_p = 0 at θ_B ✓ • R_s increases monotonically ✓ • R → 1 as θ → 90° ✓ • R(0°) = {R_at_normal:.4f} ✓ Fresnel equations validated ✓ Brewster angle confirmed ✓ Ray simulation matches theory """ ax.text( 0.05, 0.95, validation_text, transform=ax.transAxes, fontsize=9, verticalalignment="top", fontfamily="monospace", bbox={"boxstyle": "round", "facecolor": "lightblue", "alpha": 0.7}, )
[docs] def plot_brewster_validation( angles_theory_deg: np.ndarray, R_s_theory: np.ndarray, R_p_theory: np.ndarray, angles_sim_deg: np.ndarray, R_s_sim: np.ndarray, R_p_sim: np.ndarray, n1: float, n2: float, brewster_angle_deg: float, brewster_sim_deg: float, wavelength: float, num_rays_per_angle: int = 1000, figsize: tuple[float, float] = (16, 10), save_path: str | None = None, ) -> Figure: """ Create Brewster angle validation visualization. Compares theoretical Fresnel curves with ray-traced simulation results. Parameters ---------- angles_theory_deg : ndarray Theory angle array in degrees. R_s_theory : ndarray Theoretical s-polarization reflectance. R_p_theory : ndarray Theoretical p-polarization reflectance. angles_sim_deg : ndarray Simulation angle array in degrees. R_s_sim : ndarray Simulated s-polarization reflectance. R_p_sim : ndarray Simulated p-polarization reflectance. n1 : float Refractive index of incident medium. n2 : float Refractive index of transmission medium. brewster_angle_deg : float Theoretical Brewster angle in degrees. brewster_sim_deg : float Simulated Brewster angle (minimum R_p) in degrees. wavelength : float Optical wavelength in meters. num_rays_per_angle : int Number of rays used per angle in simulation. figsize : tuple Figure size. save_path : str, optional Path to save figure. Returns ------- Figure Matplotlib figure with validation plots. """ # Find minima min_theory_idx = np.argmin(R_p_theory) measured_brewster_deg = angles_theory_deg[min_theory_idx] min_theory_reflection = R_p_theory[min_theory_idx] min_sim_idx = np.argmin(R_p_sim) min_sim_reflection = R_p_sim[min_sim_idx] # Create figure fig = plt.figure(figsize=figsize, constrained_layout=True) gs = fig.add_gridspec(2, 3) # 1. Main Fresnel plot ax1 = fig.add_subplot(gs[0, :]) _plot_brewster_main_comparison( ax1, angles_theory_deg, R_s_theory, R_p_theory, angles_sim_deg, R_s_sim, R_p_sim, n1, n2, brewster_angle_deg, brewster_sim_deg, measured_brewster_deg, min_theory_reflection, min_sim_reflection, ) # 2. Zoom near Brewster angle ax2 = fig.add_subplot(gs[1, 0]) _plot_brewster_zoom( ax2, angles_theory_deg, R_s_theory, R_p_theory, angles_sim_deg, R_s_sim, R_p_sim, brewster_angle_deg, brewster_sim_deg, measured_brewster_deg, min_theory_reflection, min_sim_reflection, ) # 3. Polarization ratio ax3 = fig.add_subplot(gs[1, 1]) _plot_brewster_polarization_ratio( ax3, angles_theory_deg, R_s_theory, R_p_theory, brewster_angle_deg, ) # 4. Validation summary ax4 = fig.add_subplot(gs[1, 2]) _plot_brewster_validation_summary( ax4, angles_theory_deg, R_s_theory, R_p_theory, angles_sim_deg, R_s_sim, R_p_sim, brewster_angle_deg, brewster_sim_deg, measured_brewster_deg, min_theory_reflection, min_sim_reflection, num_rays_per_angle, ) # Main title fig.suptitle( f"Brewster Angle Validation (λ = {wavelength*1e9:.0f} nm)", fontsize=16, fontweight="bold", ) if save_path: save_figure(fig, save_path) return fig