diff --git a/physics/numerical_aperture.py b/physics/numerical_aperture.py new file mode 100644 index 0000000..54c9265 --- /dev/null +++ b/physics/numerical_aperture.py @@ -0,0 +1,82 @@ +"""Core step-index optical-fiber calculations. + +Angles returned by this module are in radians. The incidence angle used by +``is_total_internal_reflection`` is measured from the interface normal. +""" + +import math +from numbers import Real + + +def _validate_refractive_indices(n_core: float, n_cladding: float) -> None: + """Validate refractive indices for a core-cladding interface.""" + if ( + isinstance(n_core, bool) + or isinstance(n_cladding, bool) + or not isinstance(n_core, Real) + or not isinstance(n_cladding, Real) + or not math.isfinite(n_core) + or not math.isfinite(n_cladding) + or n_core <= n_cladding + or n_cladding <= 0 + ): + raise ValueError("Require finite refractive indices n_core > n_cladding > 0.") + + +def _validate_angle(angle: float, name: str, *, allow_zero: bool = True) -> None: + """Validate an angle measured in radians at an optical interface.""" + if ( + isinstance(angle, bool) + or not isinstance(angle, Real) + or not math.isfinite(angle) + or angle < 0 + or angle > math.pi / 2 + or (not allow_zero and angle == 0) + ): + raise ValueError(f"{name} must be a finite angle in the range (0, pi/2].") + + +def calculate_numerical_aperture(n_core: float, n_cladding: float) -> float: + """Return the dimensionless numerical aperture for air outside the fiber. + + The step-index relation is ``NA = sqrt(n_core**2 - n_cladding**2)``. + Refractive indices are dimensionless and must satisfy + ``n_core > n_cladding > 0``. + """ + _validate_refractive_indices(n_core, n_cladding) + return math.sqrt(n_core**2 - n_cladding**2) + + +def calculate_critical_angle(n_core: float, n_cladding: float) -> float: + """Return the core-cladding critical angle in radians. + + The angle is measured from the normal to the interface and is + ``asin(n_cladding / n_core)``. + """ + _validate_refractive_indices(n_core, n_cladding) + return math.asin(n_cladding / n_core) + + +def calculate_acceptance_angle(n_core: float, n_cladding: float) -> float: + """Return the maximum air acceptance angle from the fiber axis, in radians. + + For an external medium of air (refractive index 1), this is + ``asin(NA)``. A numerical aperture greater than one cannot produce a + real acceptance angle in air and is rejected rather than clipped. + """ + numerical_aperture = calculate_numerical_aperture(n_core, n_cladding) + if numerical_aperture > 1: + raise ValueError("Numerical aperture must not exceed one for air.") + return math.asin(numerical_aperture) + + +def is_total_internal_reflection(incidence_angle: float, critical_angle: float) -> bool: + """Return whether an incidence angle produces total internal reflection. + + Both angles are in radians and measured from the interface normal. + TIR occurs strictly when ``incidence_angle > critical_angle``; equality + is the limiting grazing-refraction case, not TIR. + """ + _validate_angle(incidence_angle, "Incidence angle") + _validate_angle(critical_angle, "Critical angle", allow_zero=False) + return incidence_angle > critical_angle diff --git a/tests/test_numerical_aperture.py b/tests/test_numerical_aperture.py new file mode 100644 index 0000000..3042996 --- /dev/null +++ b/tests/test_numerical_aperture.py @@ -0,0 +1,85 @@ +"""Tests for core optical-fiber calculations.""" + +import math + +import pytest + +from physics.numerical_aperture import ( + calculate_acceptance_angle, + calculate_critical_angle, + calculate_numerical_aperture, + is_total_internal_reflection, +) + + +def test_core_calculations_match_reference_values(): + n_core, n_cladding = 1.5, 1.4 + + assert calculate_numerical_aperture(n_core, n_cladding) == pytest.approx( + math.sqrt(0.29) + ) + assert calculate_critical_angle(n_core, n_cladding) == pytest.approx( + math.asin(1.4 / 1.5) + ) + assert calculate_acceptance_angle(n_core, n_cladding) == pytest.approx( + math.asin(math.sqrt(0.29)) + ) + + +@pytest.mark.parametrize( + "n_core,n_cladding", + [ + (0, 1.0), + (-1.0, 1.0), + (1.0, 0), + (1.4, 1.5), + (1.5, 1.5), + (float("nan"), 1.4), + (1.5, float("inf")), + ("1.5", 1.4), + (True, 1.4), + ], +) +def test_refractive_index_validation(n_core, n_cladding): + with pytest.raises(ValueError): + calculate_numerical_aperture(n_core, n_cladding) + + with pytest.raises(ValueError): + calculate_critical_angle(n_core, n_cladding) + + +def test_acceptance_angle_rejects_impossible_air_angle(): + with pytest.raises(ValueError): + calculate_acceptance_angle(2.0, 1.0) + + +@pytest.mark.parametrize( + "incidence_angle,expected", + [ + (0.5, False), + (math.asin(1.4 / 1.5), False), + (1.3, True), + (math.pi / 2, True), + ], +) +def test_total_internal_reflection_boundary(incidence_angle, expected): + critical_angle = calculate_critical_angle(1.5, 1.4) + assert is_total_internal_reflection(incidence_angle, critical_angle) is expected + + +@pytest.mark.parametrize( + "incidence_angle,critical_angle", + [ + (-0.1, 1.0), + (math.pi / 2 + 0.1, 1.0), + (float("nan"), 1.0), + (1.0, 0.0), + (1.0, float("inf")), + (True, 1.0), + ], +) +def test_total_internal_reflection_rejects_invalid_angles( + incidence_angle, critical_angle +): + with pytest.raises(ValueError): + is_total_internal_reflection(incidence_angle, critical_angle)