# 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