Etendue

This example code calculates the etendue of a pinhole and compares it with a simpler analytical calculation. The distance between the pinhole and the observer is varied, showing that the ray-tracer exhibits the correct limits. The method of calculation is described in Carr, M., Meakins, A., et al. “Description of complex viewing geometries of fusion tomography diagnostics by ray-tracing.” Review of Scientific Instruments 89.8 (2018): 083506.


import numpy as np
import matplotlib.pyplot as plt

from raysect.core import Point3D, Vector3D, rotate_basis, translate, Ray as CoreRay
from raysect.core.math.sampler import DiskSampler3D, RectangleSampler3D, TargettedHemisphereSampler
from raysect.optical import World
from raysect.primitive import Box, Cylinder, Subtract
from raysect.optical.material import AbsorbingSurface, NullMaterial

R_2_PI = 1 / (2 * np.pi)


world = World()

# Setup pinhole
target_plane = Box(Point3D(-10, -10, -0.000001), Point3D(10, 10, 0.000001))
hole = Cylinder(0.001, 0.001, transform=translate(0, 0, -0.0005))
pinhole = Subtract(target_plane, hole, parent=world, material=AbsorbingSurface())

target = Cylinder(0.0012, 0.001, transform=translate(0, 0, -0.0011), parent=world, material=NullMaterial())


def analytic_etendue(area_det, area_slit, distance, alpha, gamma):

    return area_det * area_slit * np.cos(alpha/360 * (2*np.pi)) * np.cos(gamma/360 * (2*np.pi)) / distance**2


def raytraced_etendue(distance, detector_radius=0.001, ray_count=100000, batches=10):

    # generate the transform to the detector position and orientation
    detector_transform = translate(0, 0, distance) * rotate_basis(Vector3D(0, 0, -1), Vector3D(0, -1, 0))

    # generate bounding sphere and convert to local coordinate system
    sphere = target.bounding_sphere()
    spheres = [(sphere.centre.transform(detector_transform), sphere.radius, 1.0)]

    # instance targetted pixel sampler
    targetted_sampler = TargettedHemisphereSampler(spheres)

    point_sampler = DiskSampler3D(detector_radius)

    detector_area = detector_radius**2 * np.pi
    solid_angle = 2 * np.pi
    etendue_sampled = solid_angle * detector_area

    etendues = []
    for i in range(batches):

        # sample pixel origins
        origins = point_sampler(samples=ray_count)

        passed = 0.0
        for origin in origins:

            # obtain targetted vector sample
            direction, pdf = targetted_sampler(origin, pdf=True)
            path_weight = R_2_PI * direction.z/pdf

            origin = origin.transform(detector_transform)
            direction = direction.transform(detector_transform)

            while True:

                # Find the next intersection point of the ray with the world
                intersection = world.hit(CoreRay(origin, direction))

                if intersection is None:
                    passed += 1 * path_weight
                    break

                elif isinstance(intersection.primitive.material, NullMaterial):
                    hit_point = intersection.hit_point.transform(intersection.primitive_to_world)
                    origin = hit_point + direction * 1E-9
                    continue

                else:
                    break

        if passed == 0:
            raise ValueError("Something is wrong with the scene-graph, calculated etendue should not zero.")

        etendue_fraction = passed / ray_count
        etendues.append(etendue_sampled * etendue_fraction)

    etendue = np.mean(etendues)
    etendue_error = np.std(etendues)

    return etendue, etendue_error


area = 0.001**2 * np.pi

detector_etendue = 0.001**2 * np.pi * np.pi  # etendue = A * omega * 1/2


distance_samples = [10**i for i in np.arange(-4, -1.1, 0.10)]

analytic_values = []
raytraced_values = []
raytraced_errors = []

for d in distance_samples:
    analytic_values.append(analytic_etendue(area, area, d, 0, 0))
    value, error = raytraced_etendue(d)
    raytraced_values.append(value)
    raytraced_errors.append(error)

analytic_values = np.array(analytic_values)
raytraced_values = np.array(raytraced_values)
raytraced_errors = np.array(raytraced_errors)

plt.ion()

plt.figure()
ax = plt.gca()
ax.set_xscale("log", nonposx='clip')
ax.set_yscale("log", nonposy='clip')
plt.axhline(y=detector_etendue, linestyle='--', color='k', label='detector etendue')
plt.plot(distance_samples, analytic_values, label='analytic etendue')
plt.errorbar(distance_samples, raytraced_values, raytraced_errors, label='ray-traced etendue')
plt.xlabel('Distance between slit and detector (m)')
plt.ylabel('Etendue (m^2 str)')
plt.title('Ray-traced VS approximate analytic etendue')
plt.legend()

# plt.figure()
# ax = plt.gca()
# ax.set_xscale("log", nonposx='clip')
# plt.errorbar(distance_samples, np.abs(raytraced_values-analytic_values)/raytraced_values, raytraced_errors/raytraced_values)
# plt.xlim(0.001, 0.1)
# plt.ylim(0, 0.5)
# plt.xlabel('Distance between slit and detector (m)')
# plt.ylabel('Fractional error')
# plt.show()


../../_images/etendue_of_pinhole.png