Skip to content

Instantly share code, notes, and snippets.

@soswow
Last active June 13, 2025 11:35
Show Gist options
  • Save soswow/3e791fd3944ff414f893175a1d6f64fc to your computer and use it in GitHub Desktop.
Save soswow/3e791fd3944ff414f893175a1d6f64fc to your computer and use it in GitHub Desktop.
Some vibe coding I've been doing around Lambertian reflectance and how to position lights in front of a surface such that it illuminates it perfectly with least amount of contrast (diff between darket and lightest). IT was covered in three videos so far: https://youtu.be/O_toTbA5PNc https://youtu.be/-O-LlncUULM https://youtu.be/PtKmvTEz1Og
import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import minimize
from irradiance_studies_3d_corners import calc_irradiance_points
import time
from enum import Enum
class VisualizationMode(Enum):
OPTIMIZATION = "optimization" # Original optimization mode
CONTRAST_MAP = "contrast_map" # 2D contrast map at fixed Z
TRAJECTORY_3D = "trajectory_3d" # 3D trajectory of best points
# Visualization mode control
MODE = VisualizationMode.TRAJECTORY_3D
FIXED_Z = 4.0 # Fixed Z height for contrast map
GRID_POINTS = 100 # Number of points in each dimension for contrast map
def calc_irradiance_batch(point_sources_batch, a_receiver=0.5, sample_points=100):
"""
Vectorized irradiance computation for multiple 4-point Lambertian configurations.
Assumes all sources are above the receiver (i.e., no source at Z=0).
Parameters:
point_sources_batch : ndarray of shape (N, 4, 4)
Each entry is a configuration of 4 sources with (x, y, z, intensity)
a_receiver : float
Half-size of square receiver (centered at origin in XY plane at Z=0)
sample_points : int
Number of sampling points along X and Y axis
Returns:
Z_batch : ndarray of shape (N, sample_points, sample_points)
Irradiance maps for each configuration
"""
import numpy as np
N = point_sources_batch.shape[0]
grid = np.linspace(-a_receiver, a_receiver, sample_points)
X, Y = np.meshgrid(grid, grid) # Shape: (H, W)
H, W = X.shape
# Expand grid to match source batch
X = X[None, :, :] # shape (1, H, W)
Y = Y[None, :, :] # shape (1, H, W)
Z_total = np.zeros((N, H, W))
for s in range(4):
xs = point_sources_batch[:, s, 0][:, None, None] # (N,1,1)
ys = point_sources_batch[:, s, 1][:, None, None]
zs = point_sources_batch[:, s, 2][:, None, None]
intensities = point_sources_batch[:, s, 3][:, None, None]
dx = X - xs
dy = Y - ys
dz = zs
r = np.sqrt(dx**2 + dy**2 + dz**2) # Always > 0
contrib = (intensities * dz / r) / (r**2)
Z_total += contrib
Z_total /= np.pi
return Z_total
# Objective function: contrast ratio = (max - min) / max
def contrast_ratio(params, a_receiver=0.5, sample_points=100):
x, y, z = params
# Enforce symmetry: 4 point sources mirrored across both axes
sources = [
( x, y, z),
(-x, y, z),
( x, -y, z),
(-x, -y, z)
]
_, _, Z = calc_irradiance_points(sources, a_receiver, sample_points)
max_irr = np.max(Z)
min_irr = np.min(Z)
return (max_irr - min_irr) / max_irr if max_irr != 0 else 1
if MODE == VisualizationMode.OPTIMIZATION:
# Original optimization mode
# Initial guess for (x, y, z)
initial_guess = [2.0, 2.0, 4.0]
# Define bounds: x, y ∈ [-3, 3], z ∈ [3, 5]
bounds = [(-4, 4), (-4, 4), (3, 5)]
# Perform optimization
result = minimize(contrast_ratio, initial_guess, bounds=bounds, method='L-BFGS-B')
# Get optimized symmetric point sources
x_opt, y_opt, z_opt = result.x
optimized_sources = [
( x_opt, y_opt, z_opt),
(-x_opt, y_opt, z_opt),
( x_opt, -y_opt, z_opt),
(-x_opt, -y_opt, z_opt)
]
# Print results
print("\nOptimized source coordinates:")
for i, (x, y, z) in enumerate(optimized_sources):
print(f"Source {i+1}: (x={x:.3f}, y={y:.3f}, z={z:.3f})")
print(f"\nContrast ratio: {result.fun * 100:.6f}%")
# Calculate angle between XY plane and line from origin to optimized point
# Using first quadrant point (x_opt, y_opt, z_opt)
horizontal_dist = np.sqrt(x_opt**2 + y_opt**2)
angle_rad = np.arctan2(z_opt, horizontal_dist)
angle_deg = np.degrees(angle_rad)
print(f"\nAngle between XY plane and optimized point: {angle_deg:.2f}°")
# Plot final irradiance distribution
X, Y, Z = calc_irradiance_points(optimized_sources, a_receiver=0.5, sample_points=200)
plt.figure(figsize=(6,5))
plt.pcolormesh(X, Y, Z, shading='auto', cmap='viridis')
plt.colorbar(label='Irradiance')
plt.title(f"Irradiance Map (Contrast: {(result.fun * 100):.6f}%)")
plt.xlabel("X")
plt.ylabel("Y")
plt.gca().set_aspect('equal')
plt.show()
def get_contrast_map(z):
# Generate grid of candidate positions (only for one quadrant due to symmetry)
x_range = np.linspace(0.01, 3.0, GRID_POINTS)
y_range = np.linspace(0.01, 3.0, GRID_POINTS)
Xg, Yg = np.meshgrid(x_range, y_range)
# Flatten to create batch of light configurations
X_flat = Xg.ravel()
Y_flat = Yg.ravel()
FIXED_Z = z
sources_batch = []
for x, y in zip(X_flat, Y_flat):
sources = [
( x, y, FIXED_Z, 1.0),
(-x, y, FIXED_Z, 1.0),
( x, -y, FIXED_Z, 1.0),
(-x, -y, FIXED_Z, 1.0)
]
sources_batch.append(sources)
sources_batch = np.array(sources_batch) # shape: (N, 4, 4)
# Vectorized batch irradiance computation
Z_maps = calc_irradiance_batch(sources_batch, a_receiver=0.5, sample_points=50) # Try 50 for speed test
# Compute contrast per irradiance map
max_irr = np.max(Z_maps, axis=(1, 2))
min_irr = np.min(Z_maps, axis=(1, 2))
contrast = (max_irr - min_irr) / max_irr
# Reshape back to grid
contrast_grid = contrast.reshape(GRID_POINTS, GRID_POINTS)
# Create full grid by mirroring across both axes
full_contrast = np.zeros((GRID_POINTS*2, GRID_POINTS*2))
# First quadrant (original)
full_contrast[GRID_POINTS:, GRID_POINTS:] = contrast_grid
# Second quadrant (mirror across Y)
full_contrast[GRID_POINTS:, :GRID_POINTS] = np.fliplr(contrast_grid)
# Third quadrant (mirror across both)
full_contrast[:GRID_POINTS, :GRID_POINTS] = np.flipud(np.fliplr(contrast_grid))
# Fourth quadrant (mirror across X)
full_contrast[:GRID_POINTS, GRID_POINTS:] = np.flipud(contrast_grid)
# Create full coordinate grid
full_x = np.linspace(-3, 3, GRID_POINTS*2)
full_y = np.linspace(-3, 3, GRID_POINTS*2)
X_full, Y_full = np.meshgrid(full_x, full_y)
return X_full, Y_full, full_contrast, contrast_grid, Xg, Yg
if MODE == VisualizationMode.TRAJECTORY_3D:
# Calculate best points across Z heights
z_heights = np.arange(1, 5, 0.2)
best_points = []
contrast_values = []
print("\nCalculating best points across Z heights...")
start_time = time.time()
for z in z_heights:
_, _, _, contrast_grid, Xg, Yg = get_contrast_map(z)
best_idx = np.unravel_index(np.argmin(contrast_grid), contrast_grid.shape)
best_x, best_y = Xg[best_idx], Yg[best_idx]
best_points.append((best_x, best_y, z))
contrast_values.append(contrast_grid[best_idx])
print(f"Z={z:.1f}: Best point at ({best_x:.3f}, {best_y:.3f}) with contrast {contrast_grid[best_idx]:.6f}")
duration = time.time() - start_time
print(f"\nCalculation time: {duration:.2f} seconds")
print(f"Number of Z heights: {len(z_heights)}")
print(f"Average time per height: {duration/len(z_heights):.2f} seconds")
# Plot 3D trajectory
fig = plt.figure(figsize=(10, 8))
ax = fig.add_subplot(111, projection='3d')
# Extract coordinates
x_coords = [p[0] for p in best_points]
y_coords = [p[1] for p in best_points]
z_coords = [p[2] for p in best_points]
# Normalize contrast values for point sizes (scale between 20 and 200)
contrast_array = np.array(contrast_values)
min_contrast = np.min(contrast_array)
max_contrast = np.max(contrast_array)
point_sizes = 20 + 180 * (contrast_array - min_contrast) / (max_contrast - min_contrast)
# Plot points and connecting lines
ax.plot(x_coords, y_coords, z_coords, 'b-', label='Best point trajectory')
scatter = ax.scatter(x_coords, y_coords, z_coords, c=contrast_array,
cmap='viridis', marker='o', s=point_sizes)
# Plot mirrored points for symmetry
ax.plot(-np.array(x_coords), y_coords, z_coords, 'b-')
ax.plot(x_coords, -np.array(y_coords), z_coords, 'b-')
ax.plot(-np.array(x_coords), -np.array(y_coords), z_coords, 'b-')
# Plot mirrored points with same sizes
ax.scatter(-np.array(x_coords), y_coords, z_coords, c=contrast_array,
cmap='viridis', marker='o', s=point_sizes)
ax.scatter(x_coords, -np.array(y_coords), z_coords, c=contrast_array,
cmap='viridis', marker='o', s=point_sizes)
ax.scatter(-np.array(x_coords), -np.array(y_coords), z_coords, c=contrast_array,
cmap='viridis', marker='o', s=point_sizes)
# Add colorbar
cbar = plt.colorbar(scatter)
cbar.set_label('Contrast Ratio')
# Add labels and title
ax.set_xlabel('X')
ax.set_ylabel('Y')
ax.set_zlabel('Z')
ax.set_title('Best Point Trajectory Across Z Heights\n(Point size ∝ Contrast)')
# Set equal aspect ratio
ax.set_box_aspect([1,1,1])
plt.show()
if MODE == VisualizationMode.CONTRAST_MAP:
# Start timing
start_time = time.time()
X_full, Y_full, full_contrast, contrast_grid, Xg, Yg = get_contrast_map(FIXED_Z)
# End timing and print stats
duration = time.time() - start_time
print(f"\nCalculation time: {duration:.2f} seconds")
print(f"Grid size: {GRID_POINTS}x{GRID_POINTS} = {GRID_POINTS * GRID_POINTS} points")
print(f"Average time per point: {duration / (GRID_POINTS * GRID_POINTS) * 1000:.2f} ms")
# Plot contrast map using mirrored results
plt.figure(figsize=(8, 6))
plt.pcolormesh(X_full, Y_full, full_contrast, shading='auto', cmap='viridis')
plt.colorbar(label='Contrast Ratio')
plt.title(f'Contrast Map at Z={FIXED_Z}')
plt.xlabel('X')
plt.ylabel('Y')
plt.gca().set_aspect('equal')
# Mark the best (lowest contrast) point and its mirrors
best_idx = np.unravel_index(np.argmin(contrast_grid), contrast_grid.shape)
best_x, best_y = Xg[best_idx], Yg[best_idx]
plt.plot(best_x, best_y, 'r*', markersize=10, label=f'Best: ({best_x:.3f}, {best_y:.3f})')
plt.plot(-best_x, best_y, 'r*', markersize=10)
plt.plot(best_x, -best_y, 'r*', markersize=10)
plt.plot(-best_x, -best_y, 'r*', markersize=10)
plt.legend()
plt.show()
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.widgets import Slider, Button, CheckButtons
def calc_irradiance_points(point_sources, a_receiver=0.5, sample_points=100):
"""
Calculate irradiance on a square XY-plane receiver at Z=0 from Lambertian point sources.
Parameters:
point_sources : list of tuples
Each tuple is (x, y, z [, intensity]) specifying a source's position and optional intensity.
If intensity is omitted, it defaults to 1.0.
a_receiver : float
Half-width of square receiver centered at origin (default = 0.5, i.e., 1x1 square).
sample_points : int
Number of points per axis in the sampling grid (higher = finer detail).
Returns:
X, Y : 2D arrays containing coordinates of sampling grid on receiver plane
Z : 2D array containing irradiance values at each sample point
"""
# Create uniform grid of (x, y) sample points across the receiver
x = np.linspace(-a_receiver, a_receiver, sample_points)
y = np.linspace(-a_receiver, a_receiver, sample_points)
X, Y = np.meshgrid(x, y) # X and Y shape: (sample_points, sample_points)
# Initialize irradiance array (same shape as grid)
Z = np.zeros_like(X)
# Loop over all point sources
for source in point_sources:
# Unpack source position and optional intensity
if len(source) == 3:
x_s, y_s, z_s = source
intensity = 1.0 # Default intensity
else:
x_s, y_s, z_s, intensity = source
# Compute distances from source to each point on the receiver plane
dx = X - x_s # horizontal distance in X direction
dy = Y - y_s # horizontal distance in Y direction
dz = z_s # vertical height from receiver to source (constant)
# Euclidean distance from each receiver point to the source
r = np.sqrt(dx**2 + dy**2 + dz**2)
# Avoid division by zero (i.e., directly under a source)
mask = r > 0
# Lambertian irradiance contribution: (cosθ / r²) = (dz / r) / r²
Z[mask] += intensity * (dz / r[mask]) / (r[mask]**2)
# Normalize by Lambertian factor (1 / π)
Z /= np.pi
return X, Y, Z
def calculate_irradiance(x_receiver: np.ndarray, x_c: float, z_c: float, line_length: float) -> np.ndarray:
"""
Calculate irradiance at each point of the receiver surface from two point sources.
Parameters:
x_receiver : array
x-coordinates of receiver surface points
x_c, z_c : float
Coordinates of point source center
line_length : float
Distance between the two point sources (will place points at ±line_length/2 from center)
Returns:
array of irradiance values at each receiver point
"""
# Calculate positions of the two point sources
y1 = -line_length/2
y2 = line_length/2
# Ensure x_receiver is 1D
x_r = np.asarray(x_receiver).flatten()
# Calculate distances for first point
dx1 = x_r - x_c
dy1 = y1
dz1 = z_c
r1 = np.sqrt(dx1*dx1 + dy1*dy1 + dz1*dz1)
# Calculate distances for second point
dx2 = x_r - x_c
dy2 = y2
dz2 = z_c
r2 = np.sqrt(dx2*dx2 + dy2*dy2 + dz2*dz2)
# Calculate irradiance from both points
irradiance1 = np.zeros_like(r1)
irradiance2 = np.zeros_like(r2)
# Avoid division by zero
np.divide(dz1, r1, out=irradiance1, where=r1 > 0)
np.divide(irradiance1, r1*r1, out=irradiance1, where=r1 > 0)
np.divide(dz2, r2, out=irradiance2, where=r2 > 0)
np.divide(irradiance2, r2*r2, out=irradiance2, where=r2 > 0)
# Sum contributions from both points and normalize
return (irradiance1 + irradiance2) / (2 * np.pi)
def plot_top_down(x_c, z_c, x_c_mirrored, z_c_mirrored, a_receiver, ax,
show_both, auto_scale=True, irradiance_max=1.0, irradiance_min=0.0, line_length=0.0):
"""
Plot top-down view of the receiver plane and irradiance from two point sources
at the ends of a former line source.
Parameters:
x_c, z_c : float
Coordinates of primary point source center
x_c_mirrored, z_c_mirrored : float
Coordinates of mirrored point source center
a_receiver : float
Half-width of receiver surface (receiver is square)
ax : matplotlib.axes.Axes
The axes to plot on
show_both : bool
Whether to show both sets of sources
auto_scale : bool
Whether to automatically scale irradiance axis
irradiance_max, irradiance_min : float
Manual irradiance scale limits
line_length : float
Distance between two point sources (centered around Y=0)
"""
ax.clear()
# Calculate point source positions
y_offsets = np.array([-line_length/2, line_length/2])
point_sources = []
# Add primary point sources
for y_l in y_offsets:
point_sources.append((x_c, y_l, z_c))
# Add mirrored point sources if show_both is True
if show_both:
for y_l in y_offsets:
point_sources.append((x_c_mirrored, y_l, z_c_mirrored))
# Print point source coordinates
print("\nPoint source coordinates:")
for i, (x, y, z) in enumerate(point_sources):
print(f"Source {i+1}: (x={x:.3f}, y={y:.3f}, z={z:.3f})")
# Calculate irradiance distribution
X, Y, Z = calc_irradiance_points(point_sources, a_receiver)
im = ax.pcolormesh(X, Y, Z, shading='auto', cmap='viridis')
if auto_scale:
min_irr = max(irradiance_min, np.min(Z))
max_irr = np.max(Z)
padding = (max_irr - min_irr) * 0.1
im.set_clim(min_irr - padding, max_irr + padding)
else:
im.set_clim(irradiance_min, irradiance_max)
# Update colorbar
if not hasattr(ax, 'colorbar'):
ax.colorbar = plt.colorbar(im, ax=ax, label='Irradiance')
else:
ax.colorbar.mappable = im
ax.colorbar.update_normal(im)
ax.colorbar.ax.figure.canvas.draw_idle()
# Plot point sources
ax.scatter([x_c, x_c], y_offsets, c='red', s=100, label='Point sources')
if show_both:
ax.scatter([x_c_mirrored, x_c_mirrored], y_offsets, c='red', s=100)
ax.plot([-a_receiver, a_receiver, a_receiver, -a_receiver, -a_receiver],
[-a_receiver, -a_receiver, a_receiver, a_receiver, -a_receiver],
'b-', linewidth=2, label='Receiver plane')
ax.set_xlabel('X')
ax.set_ylabel('Y')
# Calculate and show the difference in the title
if auto_scale:
min_irr = max(irradiance_min, np.min(Z))
max_irr = np.max(Z)
diff = (max_irr - min_irr) / max_irr if max_irr > 0 else 0
else:
diff = (irradiance_max - irradiance_min) / irradiance_max if irradiance_max > 0 else 0
ax.set_title(f'Top View (contrast ratio: {(diff * 100):.6f}%)')
ax.grid(True)
ax.set_aspect('equal')
ax.set_xlim(-a_receiver*1.2, a_receiver*1.2)
ax.set_ylim(-a_receiver*1.2, a_receiver*1.2)
plt.subplots_adjust(right=0.85)
def plot_surfaces(angle_deg, a_receiver, distance, ax_side, ax_top, show_legend=True, show_both=True, xy_scale=1.0, irradiance_max=1.0, irradiance_min=0.0, auto_scale=True, line_length=0.0):
"""
Plot side view and top view of the system.
Parameters:
angle_deg : float
Angle between surfaces in degrees (positive means emitter tilted up)
a_receiver : float
Half-width of receiver surface
distance : float
Distance between centers of emitter and receiver
ax_side : matplotlib.axes.Axes
The axes for side view
ax_top : matplotlib.axes.Axes
The axes for top view
show_legend : bool
Whether to show the legend
show_both : bool
Whether to show both point sources
xy_scale : float
Scale factor for X and Y axes in side view
irradiance_max : float
Maximum irradiance value for manual scale
irradiance_min : float
Minimum irradiance value for manual scale
auto_scale : bool
Whether to automatically scale irradiance axis to show full range
line_length : float
Distance between the two point sources
"""
# Clear the axes
ax_side.clear()
# Remove old twin axis if it exists
if hasattr(ax_side, 'twin_axis'):
try:
ax_side.twin_axis.remove()
except KeyError:
pass # Ignore if the axis was already removed
# Create twin axis for irradiance
ax2 = ax_side.twinx()
ax_side.twin_axis = ax2 # Store reference to twin axis
# Convert angles to radians
angle_rad = np.radians(angle_deg)
ref_angle_rad = np.radians(90+angle_deg)
# Receiver surface
x_receiver = np.linspace(-a_receiver, a_receiver, 100)
z_receiver = np.zeros_like(x_receiver) # z is 0 for receiver
# Center of receiver is at (0, 0)
# Calculate point source positions (original and mirrored)
x_c = distance * np.cos(ref_angle_rad)
z_c = distance * np.sin(ref_angle_rad) # This is now z instead of y
x_c_mirrored = -x_c # Mirror over X-axis
z_c_mirrored = z_c # Z position stays the same
# Calculate irradiance from both point sources
irradiance1 = calculate_irradiance(x_receiver, x_c, z_c, line_length)
irradiance2 = calculate_irradiance(x_receiver, x_c_mirrored, z_c_mirrored, line_length) # Mirrored point source
irradiance = irradiance1 + irradiance2 if show_both else irradiance1
# Plot side view
ax_side.plot(x_receiver, z_receiver, 'b-', linewidth=3, label='Receiver surface')
# Plot original point sources (as points in side view)
ax_side.scatter([x_c, x_c], [z_c, z_c], c='red', s=100, label=f'Point sources ({angle_deg:.1f}°)')
# Plot mirrored point sources if show_both is True
if show_both:
ax_side.scatter([x_c_mirrored, x_c_mirrored], [z_c_mirrored, z_c_mirrored], c='red', s=100)
# Plot the reference lines between centers
ax_side.plot([0, x_c], [0, z_c], 'k--', label=f'Reference line ({90+angle_deg:.1f}°)')
if show_both:
ax_side.plot([0, x_c_mirrored], [0, z_c_mirrored], 'k--')
# Plot center points
if show_both:
ax_side.scatter([0, x_c, x_c_mirrored], [0, z_c, z_c_mirrored],
c=['blue', 'red', 'red'], s=100,
label='Center points (receiver, emitters)')
else:
ax_side.scatter([0, x_c], [0, z_c],
c=['blue', 'red'], s=100,
label='Center points (receiver, emitter)')
# Plot irradiance
ax2.plot(x_receiver, irradiance, 'g-', label='Irradiance', alpha=0.7)
ax2.set_ylabel('Irradiance (arbitrary units)', color='g')
ax2.tick_params(axis='y', labelcolor='g')
# Get axes dimensions in pixels
bbox = ax_side.get_window_extent().transformed(fig.dpi_scale_trans.inverted())
width_pixels = bbox.width * fig.dpi
height_pixels = bbox.height * fig.dpi
xy_ratio = width_pixels / height_pixels
# Set fixed ranges for X and Z axes
ax_side.set_xlim(-5 / xy_scale * xy_ratio/2, 5 / xy_scale * xy_ratio/2)
ax_side.set_ylim(0, 5 / xy_scale)
# Set irradiance axis limits based on auto_scale
if auto_scale:
# Use actual min and max values with padding
min_irradiance = max(irradiance_min, np.min(irradiance))
max_irradiance = np.max(irradiance)
padding = (max_irradiance - min_irradiance) * 0.1 # 10% padding
ax2.set_ylim(min_irradiance - padding, max_irradiance + padding)
else:
ax2.set_ylim(irradiance_min, irradiance_max)
# Move irradiance axis to the right
ax2.spines['right'].set_position(('outward', 0))
ax_side.set_xlabel('X')
ax_side.set_ylabel('Z')
ax_side.grid(True)
# Handle legend visibility
if show_legend:
# Combine legends
lines1, labels1 = ax_side.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax_side.legend(lines1 + lines2, labels1 + labels2, loc='upper right')
else:
# Remove legends from both axes
if ax_side.get_legend() is not None:
ax_side.get_legend().remove()
if ax2.get_legend() is not None:
ax2.get_legend().remove()
ax_side.set_title(f'Side View - Point sources at {angle_deg:.1f}°')
# Plot top view
plot_top_down(x_c, z_c, x_c_mirrored, z_c_mirrored, a_receiver, ax_top, show_both, auto_scale, irradiance_max, irradiance_min, line_length)
def create_controls():
"""Create or recreate all controls"""
global angle_slider, a_receiver_slider, distance_slider, xy_scale_slider, irradiance_max_slider, irradiance_min_slider, line_length_slider
global legend_checkbox, both_emitters_checkbox, auto_scale_checkbox, reset_button
# Create axes
ax_side = plt.axes([0.1, 0.5, 0.35, 0.4])
ax_top = plt.axes([0.55, 0.5, 0.35, 0.4])
# Create sliders
ax_angle = plt.axes([0.25, 0.35, 0.65, 0.03])
ax_receiver = plt.axes([0.25, 0.30, 0.65, 0.03])
ax_distance = plt.axes([0.25, 0.25, 0.65, 0.03])
ax_xy_scale = plt.axes([0.25, 0.20, 0.65, 0.03])
ax_irradiance_scale = plt.axes([0.25, 0.15, 0.65, 0.03])
ax_irradiance_min = plt.axes([0.25, 0.10, 0.65, 0.03])
ax_line_length = plt.axes([0.25, 0.05, 0.65, 0.03])
angle_slider = Slider(ax_angle, 'Emitter Angle (deg)', -90, 90, valinit=angle_init)
a_receiver_slider = Slider(ax_receiver, 'Receiver half-width', 0.1, 2.0, valinit=a_receiver_init)
distance_slider = Slider(ax_distance, 'Distance between centers', 0.5, 8.0, valinit=distance_init)
xy_scale_slider = Slider(ax_xy_scale, 'X/Y Scale', 0.1, 10.0, valinit=xy_scale_init)
irradiance_max_slider = Slider(ax_irradiance_scale, 'Max Irradiance', 0.01, 1.0, valinit=irradiance_max_init)
irradiance_min_slider = Slider(ax_irradiance_min, 'Min Irradiance', 0.0, 0.15, valinit=irradiance_min_init)
line_length_slider = Slider(ax_line_length, 'Line Length', 0.0, 6.0, valinit=line_length_init)
# Create checkboxes
ax_legend = plt.axes([0.8, 0.025, 0.1, 0.04])
legend_checkbox = CheckButtons(ax_legend, ['Show Legend'], [show_legend_init])
ax_both = plt.axes([0.65, 0.025, 0.1, 0.04])
both_emitters_checkbox = CheckButtons(ax_both, ['Both Emitters'], [show_both_init])
ax_auto_scale = plt.axes([0.5, 0.025, 0.1, 0.04])
auto_scale_checkbox = CheckButtons(ax_auto_scale, ['Auto Scale'], [auto_scale_init])
# Create reset button
reset_ax = plt.axes([0.35, 0.025, 0.1, 0.04])
reset_button = Button(reset_ax, 'Reset', color='lightgoldenrodyellow', hovercolor='0.975')
return ax_side, ax_top
def update(val):
"""Update the plot when sliders change"""
# Get current values from all controls
angle = angle_slider.val
a_rec = a_receiver_slider.val
dist = distance_slider.val
xy_scale_val = xy_scale_slider.val
irr_max_val = irradiance_max_slider.val
irr_min_val = irradiance_min_slider.val
line_length = line_length_slider.val
show_legend = legend_checkbox.get_status()[0]
show_both = both_emitters_checkbox.get_status()[0]
auto_scale = auto_scale_checkbox.get_status()[0]
# Disable/enable irradiance scale slider based on auto_scale
irradiance_max_slider.active = not auto_scale
irradiance_min_slider.active = not auto_scale
# Clear both plot areas
ax_side.clear()
ax_top.clear()
# Plot the surfaces
plot_surfaces(angle, a_rec, dist, ax_side, ax_top,
show_legend=show_legend,
show_both=show_both,
xy_scale=xy_scale_val,
irradiance_max=irr_max_val,
irradiance_min=irr_min_val,
auto_scale=auto_scale,
line_length=line_length)
# Force redraw
fig.canvas.draw_idle()
def reset(event):
"""Reset all controls to initial values"""
global angle_slider, a_receiver_slider, distance_slider, xy_scale_slider, irradiance_max_slider, irradiance_min_slider, line_length_slider
global legend_checkbox, both_emitters_checkbox, auto_scale_checkbox
angle_slider.reset()
a_receiver_slider.reset()
distance_slider.reset()
xy_scale_slider.reset()
irradiance_max_slider.reset()
irradiance_min_slider.reset()
line_length_slider.reset()
legend_checkbox.set_active(0) if not show_legend_init else None
both_emitters_checkbox.set_active(0) if not show_both_init else None
auto_scale_checkbox.set_active(0) if not auto_scale_init else None
update(None) # Force an update after reset
if __name__ == '__main__':
# Create the figure
fig = plt.figure(figsize=(15, 8))
# Initial values
angle_init = -30
a_receiver_init = 1.0
distance_init = 4.0
xy_scale_init = 3.5
irradiance_max_init = 0.039
irradiance_min_init = 0.031
line_length_init = 4
show_legend_init = False
show_both_init = True
auto_scale_init = True
# Global variables for sliders and controls
angle_slider = None
a_receiver_slider = None
distance_slider = None
xy_scale_slider = None
irradiance_max_slider = None
irradiance_min_slider = None
line_length_slider = None
legend_checkbox = None
both_emitters_checkbox = None
auto_scale_checkbox = None
reset_button = None
# Create initial controls
ax_side, ax_top = create_controls()
# Register the update function with each slider and checkbox
angle_slider.on_changed(update)
a_receiver_slider.on_changed(update)
distance_slider.on_changed(update)
xy_scale_slider.on_changed(update)
irradiance_max_slider.on_changed(update)
irradiance_min_slider.on_changed(update)
line_length_slider.on_changed(update)
legend_checkbox.on_clicked(update)
both_emitters_checkbox.on_clicked(update)
auto_scale_checkbox.on_clicked(update)
reset_button.on_clicked(reset)
# Initial plot
plot_surfaces(angle_init, a_receiver_init, distance_init, ax_side, ax_top,
show_legend=show_legend_init,
show_both=show_both_init,
xy_scale=xy_scale_init,
irradiance_max=irradiance_max_init,
irradiance_min=irradiance_min_init,
auto_scale=auto_scale_init,
line_length=line_length_init)
# Set initial state of irradiance scale slider
irradiance_max_slider.active = not auto_scale_init
irradiance_min_slider.active = not auto_scale_init
plt.show()
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.widgets import Slider, Button, CheckButtons
def calculate_irradiance(x_receiver: np.ndarray, x_c: float, z_c: float, line_length: float) -> np.ndarray:
"""
Calculate irradiance at each point of the receiver surface from a line source.
Parameters:
x_receiver : array
x-coordinates of receiver surface points
x_c, z_c : float
Coordinates of line source center
line_length : float
Length of the line source (total length, will extend ±line_length/2 from center)
Returns:
array of irradiance values at each receiver point
"""
n_points = 25
y_points = np.linspace(-line_length/2, line_length/2, n_points)
x_r = np.asarray(x_receiver).flatten()[:, np.newaxis] # Shape: (n_receiver, 1)
y_l = y_points[np.newaxis, :] # Shape: (1, n_points)
dx = x_r - x_c # (n_receiver, n_points)
dy = -y_l # (1, n_points), broadcasted
dz = z_c # Scalar
r = np.sqrt(dx*dx + dy*dy + dz*dz) # Total distance from source point to receiver
irradiance = np.zeros_like(r)
np.divide(dz, r, out=irradiance, where=r > 0)
np.divide(irradiance, r*r, out=irradiance, where=r > 0)
return np.sum(irradiance, axis=1) / (np.pi * n_points)
def plot_top_down(x_c, z_c, x_c_mirrored, z_c_mirrored, a_receiver, ax, show_both, auto_scale=True, irradiance_max=1.0, irradiance_min=0.0, line_length=0.0):
"""
Plot top-down view of the receiver plane and line sources.
Line sources are kept at Y=0, only their X positions vary.
Parameters:
x_c, z_c : float
Coordinates of line source center
x_c_mirrored, z_c_mirrored : float
Coordinates of mirrored line source center
a_receiver : float
Half-width of receiver surface
ax : matplotlib.axes.Axes
The axes to plot on
show_both : bool
Whether to show both line sources
auto_scale : bool
Whether to automatically scale irradiance axis to show full range
irradiance_max : float
Maximum irradiance value for manual scale
irradiance_min : float
Minimum irradiance value for manual scale
line_length : float
Length of the line source
"""
# Clear the axes
ax.clear()
sample_points = 100
# Create a grid of points on the receiver plane
x = np.linspace(-a_receiver, a_receiver, sample_points)
y = np.linspace(-a_receiver, a_receiver, sample_points)
X, Y = np.meshgrid(x, y)
# Create points along the line source
y_points = np.linspace(-line_length/2, line_length/2, sample_points)
# Reshape arrays for broadcasting
X_flat = X.flatten()[:, np.newaxis] # Shape: (n_points^2, 1)
Y_flat = Y.flatten()[:, np.newaxis] # Shape: (n_points^2, 1)
y_l = y_points[np.newaxis, :] # Shape: (1, n_points)
# Calculate distance vectors for all combinations
dx1 = X_flat - x_c # Shape: (n_points^2, n_points)
dy1 = Y_flat - y_l # Shape: (n_points^2, n_points)
dz1 = z_c # Scalar
r1 = np.sqrt(dx1*dx1 + dy1*dy1 + dz1*dz1) # Shape: (n_points^2, n_points)
# Calculate irradiance contribution from first line source
mask1 = r1 > 0
Z1 = np.zeros_like(r1)
Z1[mask1] = (dz1 / r1[mask1]) / (r1[mask1] * r1[mask1])
# Sum contributions from all points along the line
Z = np.sum(Z1, axis=1)
if show_both:
# Calculate for second line source
dx2 = X_flat - x_c_mirrored
dy2 = Y_flat - y_l
dz2 = z_c_mirrored
r2 = np.sqrt(dx2*dx2 + dy2*dy2 + dz2*dz2)
mask2 = r2 > 0
Z2 = np.zeros_like(r2)
Z2[mask2] = (dz2 / r2[mask2]) / (r2[mask2] * r2[mask2])
Z += np.sum(Z2, axis=1)
# Normalize by 1/π factor from Lambertian source and number of points
Z /= (np.pi * sample_points)
# Reshape back to 2D grid
Z = Z.reshape(X.shape)
# Plot the receiver plane with irradiance
im = ax.pcolormesh(X, Y, Z, shading='auto', cmap='viridis')
# Set colorbar limits based on auto_scale
if auto_scale:
# Use actual min and max values with padding
min_irradiance = max(irradiance_min, np.min(Z))
max_irradiance = np.max(Z)
padding = (max_irradiance - min_irradiance) * 0.1 # 10% padding
im.set_clim(min_irradiance - padding, max_irradiance + padding)
else:
im.set_clim(irradiance_min, irradiance_max)
# Add or update colorbar
if not hasattr(ax, 'colorbar'):
ax.colorbar = plt.colorbar(im, ax=ax, label='Irradiance')
else:
ax.colorbar.mappable = im
ax.colorbar.update_normal(im)
ax.colorbar.ax.figure.canvas.draw_idle()
# Plot line sources
ax.plot([x_c, x_c], [-line_length/2, line_length/2], 'r-', linewidth=2, label='Line source')
if show_both:
ax.plot([x_c_mirrored, x_c_mirrored], [-line_length/2, line_length/2], 'r-', linewidth=2)
# Plot receiver outline
ax.plot([-a_receiver, a_receiver, a_receiver, -a_receiver, -a_receiver],
[-a_receiver, -a_receiver, a_receiver, a_receiver, -a_receiver],
'b-', linewidth=2, label='Receiver plane')
ax.set_xlabel('X')
ax.set_ylabel('Y')
# Calculate and show the difference in the title
if auto_scale:
min_irr = max(irradiance_min, np.min(Z))
max_irr = np.max(Z)
diff = max_irr - min_irr
else:
diff = irradiance_max - irradiance_min
ax.set_title(f'Top View (diff: {diff:.6f})')
ax.grid(True)
# Set fixed aspect ratio and limits
ax.set_aspect('equal')
ax.set_xlim(-a_receiver*1.2, a_receiver*1.2)
ax.set_ylim(-a_receiver*1.2, a_receiver*1.2)
# Adjust layout to prevent shrinking
plt.subplots_adjust(right=0.85) # Make room for colorbar
def plot_surfaces(angle_deg, a_receiver, distance, ax_side, ax_top, show_legend=True, show_both=True, xy_scale=1.0, irradiance_max=1.0, irradiance_min=0.0, auto_scale=True, line_length=0.0):
"""
Plot side view and top view of the system.
Parameters:
angle_deg : float
Angle between surfaces in degrees (positive means emitter tilted up)
a_receiver : float
Half-width of receiver surface
distance : float
Distance between centers of emitter and receiver
ax_side : matplotlib.axes.Axes
The axes for side view
ax_top : matplotlib.axes.Axes
The axes for top view
show_legend : bool
Whether to show the legend
show_both : bool
Whether to show both line sources
xy_scale : float
Scale factor for X and Y axes in side view
irradiance_max : float
Maximum irradiance value for manual scale
irradiance_min : float
Minimum irradiance value for manual scale
auto_scale : bool
Whether to automatically scale irradiance axis to show full range
line_length : float
Length of the line source
"""
# Clear the axes
ax_side.clear()
# Remove old twin axis if it exists
if hasattr(ax_side, 'twin_axis'):
try:
ax_side.twin_axis.remove()
except KeyError:
pass # Ignore if the axis was already removed
# Create twin axis for irradiance
ax2 = ax_side.twinx()
ax_side.twin_axis = ax2 # Store reference to twin axis
# Convert angles to radians
angle_rad = np.radians(angle_deg)
ref_angle_rad = np.radians(90+angle_deg)
# Receiver surface
x_receiver = np.linspace(-a_receiver, a_receiver, 100)
z_receiver = np.zeros_like(x_receiver) # z is 0 for receiver
# Center of receiver is at (0, 0)
# Calculate line source positions (original and mirrored)
x_c = distance * np.cos(ref_angle_rad)
z_c = distance * np.sin(ref_angle_rad) # This is now z instead of y
x_c_mirrored = -x_c # Mirror over X-axis
z_c_mirrored = z_c # Z position stays the same
# Calculate irradiance from both line sources
irradiance1 = calculate_irradiance(x_receiver, x_c, z_c, line_length)
irradiance2 = calculate_irradiance(x_receiver, x_c_mirrored, z_c_mirrored, line_length) # Mirrored line source
irradiance = irradiance1 + irradiance2 if show_both else irradiance1
# Plot side view
ax_side.plot(x_receiver, z_receiver, 'b-', linewidth=3, label='Receiver surface')
# Plot original line source (as a point in side view since it extends in Y direction)
ax_side.scatter([x_c], [z_c], c='red', s=100, label=f'Line source ({angle_deg:.1f}°)')
# Plot mirrored line source if show_both is True
if show_both:
ax_side.scatter([x_c_mirrored], [z_c_mirrored], c='red', s=100)
# Plot the reference lines between centers
ax_side.plot([0, x_c], [0, z_c], 'k--', label=f'Reference line ({90+angle_deg:.1f}°)')
if show_both:
ax_side.plot([0, x_c_mirrored], [0, z_c_mirrored], 'k--')
# Plot center points
if show_both:
ax_side.scatter([0, x_c, x_c_mirrored], [0, z_c, z_c_mirrored],
c=['blue', 'red', 'red'], s=100,
label='Center points (receiver, emitters)')
else:
ax_side.scatter([0, x_c], [0, z_c],
c=['blue', 'red'], s=100,
label='Center points (receiver, emitter)')
# Plot irradiance
ax2.plot(x_receiver, irradiance, 'g-', label='Irradiance', alpha=0.7)
ax2.set_ylabel('Irradiance (arbitrary units)', color='g')
ax2.tick_params(axis='y', labelcolor='g')
# Get axes dimensions in pixels
bbox = ax_side.get_window_extent().transformed(fig.dpi_scale_trans.inverted())
width_pixels = bbox.width * fig.dpi
height_pixels = bbox.height * fig.dpi
xy_ratio = width_pixels / height_pixels
# Set fixed ranges for X and Z axes
ax_side.set_xlim(-5 / xy_scale * xy_ratio/2, 5 / xy_scale * xy_ratio/2)
ax_side.set_ylim(0, 5 / xy_scale)
# Set irradiance axis limits based on auto_scale
if auto_scale:
# Use actual min and max values with padding
min_irradiance = max(irradiance_min, np.min(irradiance))
max_irradiance = np.max(irradiance)
ax2.set_ylim(min_irradiance, max_irradiance)
else:
ax2.set_ylim(irradiance_min, irradiance_max)
# Move irradiance axis to the right
ax2.spines['right'].set_position(('outward', 0))
ax_side.set_xlabel('X')
ax_side.set_ylabel('Z')
ax_side.grid(True)
# Handle legend visibility
if show_legend:
# Combine legends
lines1, labels1 = ax_side.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax_side.legend(lines1 + lines2, labels1 + labels2, loc='upper right')
else:
# Remove legends from both axes
if ax_side.get_legend() is not None:
ax_side.get_legend().remove()
if ax2.get_legend() is not None:
ax2.get_legend().remove()
ax_side.set_title(f'Side View - Line source at {angle_deg:.1f}°')
# Plot top view
plot_top_down(x_c, z_c, x_c_mirrored, z_c_mirrored, a_receiver, ax_top, show_both, auto_scale, irradiance_max, irradiance_min, line_length)
# Create the figure
fig = plt.figure(figsize=(15, 8))
# Initial values
angle_init = -27.5
a_receiver_init = 1.0
distance_init = 4.0
xy_scale_init = 3.5
irradiance_max_init = 0.039
irradiance_min_init = 0.031
line_length_init = 0.5
show_legend_init = False
show_both_init = True
auto_scale_init = False
# Global variables for sliders and controls
angle_slider = None
a_receiver_slider = None
distance_slider = None
xy_scale_slider = None
irradiance_max_slider = None
irradiance_min_slider = None
line_length_slider = None
legend_checkbox = None
both_emitters_checkbox = None
auto_scale_checkbox = None
reset_button = None
def create_controls():
"""Create or recreate all controls"""
global angle_slider, a_receiver_slider, distance_slider, xy_scale_slider, irradiance_max_slider, irradiance_min_slider, line_length_slider
global legend_checkbox, both_emitters_checkbox, auto_scale_checkbox, reset_button
# Create axes
ax_side = plt.axes([0.1, 0.5, 0.35, 0.4])
ax_top = plt.axes([0.55, 0.5, 0.35, 0.4])
# Create sliders
ax_angle = plt.axes([0.25, 0.35, 0.65, 0.03])
ax_receiver = plt.axes([0.25, 0.30, 0.65, 0.03])
ax_distance = plt.axes([0.25, 0.25, 0.65, 0.03])
ax_xy_scale = plt.axes([0.25, 0.20, 0.65, 0.03])
ax_irradiance_scale = plt.axes([0.25, 0.15, 0.65, 0.03])
ax_irradiance_min = plt.axes([0.25, 0.10, 0.65, 0.03])
ax_line_length = plt.axes([0.25, 0.05, 0.65, 0.03])
angle_slider = Slider(ax_angle, 'Emitter Angle (deg)', -90, 90, valinit=angle_init)
a_receiver_slider = Slider(ax_receiver, 'Receiver half-width', 0.1, 2.0, valinit=a_receiver_init)
distance_slider = Slider(ax_distance, 'Distance between centers', 0.5, 8.0, valinit=distance_init)
xy_scale_slider = Slider(ax_xy_scale, 'X/Y Scale', 0.1, 10.0, valinit=xy_scale_init)
irradiance_max_slider = Slider(ax_irradiance_scale, 'Max Irradiance', 0.01, 1.0, valinit=irradiance_max_init)
irradiance_min_slider = Slider(ax_irradiance_min, 'Min Irradiance', 0.0, 0.15, valinit=irradiance_min_init)
line_length_slider = Slider(ax_line_length, 'Line Length', 0.0, 2.0, valinit=line_length_init)
# Create checkboxes
ax_legend = plt.axes([0.8, 0.025, 0.1, 0.04])
legend_checkbox = CheckButtons(ax_legend, ['Show Legend'], [show_legend_init])
ax_both = plt.axes([0.65, 0.025, 0.1, 0.04])
both_emitters_checkbox = CheckButtons(ax_both, ['Both Emitters'], [show_both_init])
ax_auto_scale = plt.axes([0.5, 0.025, 0.1, 0.04])
auto_scale_checkbox = CheckButtons(ax_auto_scale, ['Auto Scale'], [auto_scale_init])
# Create reset button
reset_ax = plt.axes([0.35, 0.025, 0.1, 0.04])
reset_button = Button(reset_ax, 'Reset', color='lightgoldenrodyellow', hovercolor='0.975')
return ax_side, ax_top
def update(val):
"""Update the plot when sliders change"""
# Get current values from all controls
angle = angle_slider.val
a_rec = a_receiver_slider.val
dist = distance_slider.val
xy_scale_val = xy_scale_slider.val
irr_max_val = irradiance_max_slider.val
irr_min_val = irradiance_min_slider.val
line_length = line_length_slider.val
show_legend = legend_checkbox.get_status()[0]
show_both = both_emitters_checkbox.get_status()[0]
auto_scale = auto_scale_checkbox.get_status()[0]
# Disable/enable irradiance scale slider based on auto_scale
irradiance_max_slider.active = not auto_scale
irradiance_min_slider.active = not auto_scale
# Clear both plot areas
ax_side.clear()
ax_top.clear()
# Plot the surfaces
plot_surfaces(angle, a_rec, dist, ax_side, ax_top,
show_legend=show_legend,
show_both=show_both,
xy_scale=xy_scale_val,
irradiance_max=irr_max_val,
irradiance_min=irr_min_val,
auto_scale=auto_scale,
line_length=line_length)
# Force redraw
fig.canvas.draw_idle()
def reset(event):
"""Reset all controls to initial values"""
global angle_slider, a_receiver_slider, distance_slider, xy_scale_slider, irradiance_max_slider, irradiance_min_slider, line_length_slider
global legend_checkbox, both_emitters_checkbox, auto_scale_checkbox
angle_slider.reset()
a_receiver_slider.reset()
distance_slider.reset()
xy_scale_slider.reset()
irradiance_max_slider.reset()
irradiance_min_slider.reset()
line_length_slider.reset()
legend_checkbox.set_active(0) if not show_legend_init else None
both_emitters_checkbox.set_active(0) if not show_both_init else None
auto_scale_checkbox.set_active(0) if not auto_scale_init else None
update(None) # Force an update after reset
# Create initial controls
ax_side, ax_top = create_controls()
# Register the update function with each slider and checkbox
angle_slider.on_changed(update)
a_receiver_slider.on_changed(update)
distance_slider.on_changed(update)
xy_scale_slider.on_changed(update)
irradiance_max_slider.on_changed(update)
irradiance_min_slider.on_changed(update)
line_length_slider.on_changed(update)
legend_checkbox.on_clicked(update)
both_emitters_checkbox.on_clicked(update)
auto_scale_checkbox.on_clicked(update)
reset_button.on_clicked(reset)
# Initial plot
plot_surfaces(angle_init, a_receiver_init, distance_init, ax_side, ax_top,
show_legend=show_legend_init,
show_both=show_both_init,
xy_scale=xy_scale_init,
irradiance_max=irradiance_max_init,
irradiance_min=irradiance_min_init,
auto_scale=auto_scale_init,
line_length=line_length_init)
# Set initial state of irradiance scale slider
irradiance_max_slider.active = not auto_scale_init
irradiance_min_slider.active = not auto_scale_init
plt.show()
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.widgets import Slider, Button, CheckButtons
def calculate_irradiance(x_receiver: np.ndarray, x_c: float, y_c: float, angle_rad: float, a_emitter: float) -> np.ndarray:
"""
Calculate irradiance at each point of the receiver surface using numerical integration.
This version properly handles tilted emitters by computing the full 2D integral.
Parameters:
x_receiver : array
x-coordinates of receiver surface points
x_c, y_c : float
Coordinates of emitter center
angle_rad : float
Angle of emitter in radians (positive means tilted up)
a_emitter : float
Half-width of emitter surface
Returns:
array of irradiance values at each receiver point
"""
# Vector along emitter (both x and y components needed for tilted emitter)
v_x = a_emitter * np.cos(angle_rad)
v_y = a_emitter * np.sin(angle_rad)
# Emitter endpoints
x_emitter_start = x_c - v_x
y_emitter_start = y_c - v_y
x_emitter_end = x_c + v_x
y_emitter_end = y_c + v_y
# Calculate irradiance at each receiver point
irradiance = np.zeros_like(x_receiver)
# Number of points to sample along emitter for numerical integration
n_samples = 100
emitter_x = np.linspace(x_emitter_start, x_emitter_end, n_samples)
emitter_y = np.linspace(y_emitter_start, y_emitter_end, n_samples)
# For each receiver point
for i, x_r in enumerate(x_receiver):
# For each emitter point
for j in range(n_samples):
# Distance vector from emitter point to receiver point
dx = x_r - emitter_x[j]
dy = -emitter_y[j] # y_r is always 0
r = np.sqrt(dx*dx + dy*dy)
if r > 0: # Avoid division by zero
# Calculate angles for Lambertian cosine terms
# θi: angle between emitter normal and direction to receiver
# θo: angle between receiver normal and direction to emitter
# Emitter normal is perpendicular to emitter direction
emitter_normal_x = -np.sin(angle_rad)
emitter_normal_y = np.cos(angle_rad)
# Direction to receiver point (normalized)
dir_x = dx / r
dir_y = dy / r
# Calculate cosines of angles
cos_theta_i = abs(emitter_normal_x * dir_x + emitter_normal_y * dir_y)
cos_theta_o = abs(dir_y) # Receiver normal is (0,1)
# Add contribution from this emitter point
# Using inverse square law and both cosine terms
irradiance[i] += (cos_theta_i * cos_theta_o) / (r * r)
# Normalize by number of samples and emitter length
# The 1/π factor comes from the Lambertian source
irradiance *= (a_emitter / n_samples) / np.pi
return irradiance
def plot_surfaces(angle_deg, a_emitter, a_receiver, distance, ax, show_legend=True, show_both=True, xy_scale=1.0, irradiance_scale=1.0):
"""
Plot emitter and receiver surfaces with specified angle and sizes.
Includes a second emitter mirrored over the Y-axis.
Parameters:
angle_deg : float
Angle between surfaces in degrees (positive means emitter tilted up)
a_emitter : float
Half-width of emitter surface
a_receiver : float
Half-width of receiver surface
distance : float
Distance between centers of emitter and receiver
ax : matplotlib.axes.Axes
The axes to plot on
show_legend : bool
Whether to show the legend
show_both : bool
Whether to show both emitters or just one
xy_scale : float
Scale factor for X and Y axes (maintains aspect ratio)
irradiance_scale : float
Scale factor for irradiance axis
"""
# Clear the axes
ax.clear()
# Remove old twin axis if it exists
if hasattr(ax, 'twin_axis'):
ax.twin_axis.remove()
# Create twin axis for irradiance
ax2 = ax.twinx()
ax.twin_axis = ax2 # Store reference to twin axis
# Convert angles to radians
angle_rad = np.radians(angle_deg)
ref_angle_rad = np.radians(90+angle_deg)
# Receiver surface
x_receiver = np.linspace(-a_receiver, a_receiver, 100)
y_receiver = np.zeros_like(x_receiver)
# Vector along emitter
v_x = a_emitter * np.cos(angle_rad)
v_y = a_emitter * np.sin(angle_rad)
# Center of receiver is at (0, 0)
# Calculate emitter center positions (original and mirrored)
x_c = distance * np.cos(ref_angle_rad)
y_c = distance * np.sin(ref_angle_rad)
x_c_mirrored = -x_c # Mirror over Y-axis
y_c_mirrored = y_c # Y position stays the same
# Emitter endpoints (original)
x_emitter_start = x_c - v_x
y_emitter_start = y_c - v_y
x_emitter_end = x_c + v_x
y_emitter_end = y_c + v_y
# Emitter endpoints (mirrored)
x_emitter_start_mirrored = x_c_mirrored + v_x # Note the + instead of - due to mirroring
y_emitter_start_mirrored = y_c_mirrored - v_y
x_emitter_end_mirrored = x_c_mirrored - v_x # Note the - instead of + due to mirroring
y_emitter_end_mirrored = y_c_mirrored + v_y
# Calculate irradiance from both emitters
irradiance1 = calculate_irradiance(x_receiver, x_c, y_c, angle_rad, a_emitter)
irradiance2 = calculate_irradiance(x_receiver, x_c_mirrored, y_c_mirrored, -angle_rad, a_emitter) # Negative angle for mirroring
irradiance = irradiance1 + irradiance2 if show_both else irradiance1
# Plot surfaces
ax.plot(x_receiver, y_receiver, 'b-', linewidth=3, label='Receiver surface')
# Plot original emitter
ax.plot([x_emitter_start, x_emitter_end], [y_emitter_start, y_emitter_end],
'r-', linewidth=3, label=f'Emitter surface ({angle_deg:.1f}°)')
# Plot mirrored emitter if show_both is True
if show_both:
ax.plot([x_emitter_start_mirrored, x_emitter_end_mirrored],
[y_emitter_start_mirrored, y_emitter_end_mirrored],
'r-', linewidth=3)
# Plot the reference lines between centers
ax.plot([0, x_c], [0, y_c], 'k--', label=f'Reference line ({90+angle_deg:.1f}°)')
if show_both:
ax.plot([0, x_c_mirrored], [0, y_c_mirrored], 'k--')
# Plot center points
if show_both:
ax.scatter([0, x_c, x_c_mirrored], [0, y_c, y_c_mirrored],
c=['blue', 'red', 'red'], s=100,
label='Center points (receiver, emitters)')
else:
ax.scatter([0, x_c], [0, y_c],
c=['blue', 'red'], s=100,
label='Center points (receiver, emitter)')
# Plot irradiance
ax2.plot(x_receiver, irradiance, 'g-', label='Irradiance', alpha=0.7)
ax2.set_ylabel('Irradiance (arbitrary units)', color='g')
ax2.tick_params(axis='y', labelcolor='g')
# Calculate appropriate limits to show both surfaces
x_min = min(-a_receiver, x_emitter_start, x_emitter_start_mirrored if show_both else float('inf')) - 0.5
x_max = max(a_receiver, x_emitter_end, x_emitter_end_mirrored if show_both else float('-inf')) + 0.5
y_max = max(0, y_emitter_start, y_emitter_end,
y_emitter_start_mirrored if show_both else 0,
y_emitter_end_mirrored if show_both else 0) + 0.5
# Get axes dimensions in pixels
bbox = ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted())
width_pixels = bbox.width * fig.dpi
height_pixels = bbox.height * fig.dpi
xy_ratio = width_pixels / height_pixels
# Apply manual scale factor while maintaining aspect ratio
# scale_factor = min(base_x_scale, base_y_scale) / xy_scale
# Set fixed ranges for X and Y axes
ax.set_xlim(-5 / xy_scale * xy_ratio/2, 5 / xy_scale * xy_ratio/2)
ax.set_ylim(0, 5 / xy_scale)
# Set fixed range for irradiance axis
ax2.set_ylim(0, 1 / irradiance_scale)
# Move irradiance axis to the right
ax2.spines['right'].set_position(('outward', 0))
ax.set_xlabel('X')
ax.set_ylabel('Y')
ax.grid(True)
# Handle legend visibility
if show_legend:
# Combine legends
lines1, labels1 = ax.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax.legend(lines1 + lines2, labels1 + labels2, loc='upper right')
else:
# Remove legends from both axes
if ax.get_legend() is not None:
ax.get_legend().remove()
if ax2.get_legend() is not None:
ax2.get_legend().remove()
ax.set_title(f'Emitter at {angle_deg:.1f}°')
def update(val):
"""Update the plot when sliders change"""
angle = angle_slider.val
a_emit = a_emitter_slider.val
a_rec = a_receiver_slider.val
dist = distance_slider.val
xy_scale_val = xy_scale_slider.val
irr_scale_val = irradiance_scale_slider.val
show_legend = legend_checkbox.get_status()[0] # Get first checkbox state
show_both = both_emitters_checkbox.get_status()[0] # Get second checkbox state
plot_surfaces(angle, a_emit, a_rec, dist, ax,
show_legend=show_legend,
show_both=show_both,
xy_scale=xy_scale_val,
irradiance_scale=irr_scale_val)
fig.canvas.draw_idle()
# Create the figure and axes
fig, ax = plt.subplots(figsize=(10, 8))
plt.subplots_adjust(bottom=0.5) # Make room for additional sliders
# Initial values
angle_init = -45
a_emitter_init = 1.0
a_receiver_init = 1.0
distance_init = 2.0
xy_scale_init = 1.0
irradiance_scale_init = 1.0
# Create sliders
ax_angle = plt.axes([0.25, 0.35, 0.65, 0.03])
ax_emitter = plt.axes([0.25, 0.30, 0.65, 0.03])
ax_receiver = plt.axes([0.25, 0.25, 0.65, 0.03])
ax_distance = plt.axes([0.25, 0.20, 0.65, 0.03])
ax_xy_scale = plt.axes([0.25, 0.15, 0.65, 0.03])
ax_irradiance_scale = plt.axes([0.25, 0.10, 0.65, 0.03])
angle_slider = Slider(ax_angle, 'Emitter Angle (deg)', -90, 90, valinit=angle_init)
a_emitter_slider = Slider(ax_emitter, 'Emitter half-width', 0.1, 2.0, valinit=a_emitter_init)
a_receiver_slider = Slider(ax_receiver, 'Receiver half-width', 0.1, 2.0, valinit=a_receiver_init)
distance_slider = Slider(ax_distance, 'Distance between centers', 0.5, 8.0, valinit=distance_init)
xy_scale_slider = Slider(ax_xy_scale, 'X/Y Scale', 0.1, 10.0, valinit=xy_scale_init)
irradiance_scale_slider = Slider(ax_irradiance_scale, 'Irradiance Scale', 0.1, 60.0, valinit=irradiance_scale_init)
# Create checkboxes for legend and both emitters
ax_legend = plt.axes([0.8, 0.025, 0.1, 0.04])
legend_checkbox = CheckButtons(ax_legend, ['Show Legend'], [True])
ax_both = plt.axes([0.65, 0.025, 0.1, 0.04])
both_emitters_checkbox = CheckButtons(ax_both, ['Both Emitters'], [True])
# Register the update function with each slider
angle_slider.on_changed(update)
a_emitter_slider.on_changed(update)
a_receiver_slider.on_changed(update)
distance_slider.on_changed(update)
xy_scale_slider.on_changed(update)
irradiance_scale_slider.on_changed(update)
legend_checkbox.on_clicked(update)
both_emitters_checkbox.on_clicked(update)
# Add a reset button
reset_ax = plt.axes([0.5, 0.025, 0.1, 0.04])
reset_button = Button(reset_ax, 'Reset', color='lightgoldenrodyellow', hovercolor='0.975')
def reset(event):
angle_slider.reset()
a_emitter_slider.reset()
a_receiver_slider.reset()
distance_slider.reset()
xy_scale_slider.reset()
irradiance_scale_slider.reset()
reset_button.on_clicked(reset)
# Initial plot
plot_surfaces(angle_init, a_emitter_init, a_receiver_init, distance_init, ax)
plt.show()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment