Skip to content

Commit 69580d5

Browse files
committed
Add HeightProfile with wavelength-dependent material support
1 parent cd38000 commit 69580d5

File tree

9 files changed

+191
-16
lines changed

9 files changed

+191
-16
lines changed

optiland/interactions/phase_interaction_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ def interact_real_rays(self, rays: RealRays) -> RealRays:
6565
k_iz = n1 * k0 * n_i
6666

6767
# 3. Get phase and ambient gradient (grad(f))
68-
phase_val = self.phase_profile.get_phase(x, y)
69-
phi_x, phi_y, phi_z = self.phase_profile.get_gradient(x, y)
68+
phase_val = self.phase_profile.get_phase(x, y, rays.w)
69+
phi_x, phi_y, phi_z = self.phase_profile.get_gradient(x, y, rays.w)
7070
grad_f_x = phi_x
7171
grad_f_y = phi_y
7272
grad_f_z = phi_z

optiland/phase/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
from .base import BasePhaseProfile
44
from .constant import ConstantPhaseProfile
55
from .grid import GridPhaseProfile
6+
from .height_profile import HeightProfile
67
from .linear_grating import LinearGratingPhaseProfile
78
from .radial import RadialPhaseProfile
89

910
__all__ = [
1011
"BasePhaseProfile",
1112
"ConstantPhaseProfile",
1213
"GridPhaseProfile",
14+
"HeightProfile",
1315
"LinearGratingPhaseProfile",
1416
"RadialPhaseProfile",
1517
]

optiland/phase/base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def efficiency(self) -> float:
3737
return 1.0
3838

3939
@abc.abstractmethod
40-
def get_phase(self, x: be.Array, y: be.Array) -> be.Array:
40+
def get_phase(self, x: be.Array, y: be.Array, wavelength: be.Array) -> be.Array:
4141
"""Calculates the phase added by the profile at coordinates (x, y).
4242
4343
Args:
@@ -51,7 +51,7 @@ def get_phase(self, x: be.Array, y: be.Array) -> be.Array:
5151

5252
@abc.abstractmethod
5353
def get_gradient(
54-
self, x: be.Array, y: be.Array
54+
self, x: be.Array, y: be.Array, wavelength: be.Array
5555
) -> tuple[be.Array, be.Array, be.Array]:
5656
"""Calculates the gradient of the phase at coordinates (x, y).
5757
@@ -66,7 +66,7 @@ def get_gradient(
6666
raise NotImplementedError
6767

6868
@abc.abstractmethod
69-
def get_paraxial_gradient(self, y: be.Array) -> be.Array:
69+
def get_paraxial_gradient(self, y: be.Array, wavelength: be.Array) -> be.Array:
7070
"""Calculates the paraxial phase gradient at y-coordinate.
7171
7272
This is the gradient d_phi/dy evaluated at x=0.

optiland/phase/constant.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class ConstantPhaseProfile(BasePhaseProfile):
2424
def __init__(self, phase: float = 0.0):
2525
self.phase = phase
2626

27-
def get_phase(self, x: be.Array, y: be.Array) -> be.Array:
27+
def get_phase(self, x: be.Array, y: be.Array, wavelength: be.Array = None) -> be.Array:
2828
"""Calculates the phase added by the profile at coordinates (x, y).
2929
3030
Args:
@@ -37,7 +37,7 @@ def get_phase(self, x: be.Array, y: be.Array) -> be.Array:
3737
return be.full_like(x, self.phase)
3838

3939
def get_gradient(
40-
self, x: be.Array, y: be.Array
40+
self, x: be.Array, y: be.Array, wavelength: be.Array = None
4141
) -> tuple[be.Array, be.Array, be.Array]:
4242
"""Calculates the gradient of the phase at coordinates (x, y).
4343
@@ -51,7 +51,7 @@ def get_gradient(
5151
"""
5252
return be.zeros_like(x), be.zeros_like(y), be.zeros_like(x)
5353

54-
def get_paraxial_gradient(self, y: be.Array) -> be.Array:
54+
def get_paraxial_gradient(self, y: be.Array, wavelength: be.Array = None) -> be.Array:
5555
"""Calculates the paraxial phase gradient at y-coordinate.
5656
5757
This is the gradient d_phi/dy evaluated at x=0.

optiland/phase/grid.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def __init__(
5656
self.y_coords, self.x_coords, self.phase_grid
5757
)
5858

59-
def get_phase(self, x: be.Array, y: be.Array) -> be.Array:
59+
def get_phase(self, x: be.Array, y: be.Array, wavelength: be.Array = None) -> be.Array:
6060
"""Calculates the phase added by the profile at coordinates (x, y).
6161
6262
Args:
@@ -69,7 +69,7 @@ def get_phase(self, x: be.Array, y: be.Array) -> be.Array:
6969
return self._spline.ev(be.to_numpy(y), be.to_numpy(x))
7070

7171
def get_gradient(
72-
self, x: be.Array, y: be.Array
72+
self, x: be.Array, y: be.Array, wavelength: be.Array = None
7373
) -> tuple[be.Array, be.Array, be.Array]:
7474
"""Calculates the gradient of the phase at coordinates (x, y).
7575
@@ -88,7 +88,7 @@ def get_gradient(
8888
d_phi_dz = be.zeros_like(x)
8989
return d_phi_dx, d_phi_dy, d_phi_dz
9090

91-
def get_paraxial_gradient(self, y: be.Array) -> be.Array:
91+
def get_paraxial_gradient(self, y: be.Array, wavelength: be.Array = None) -> be.Array:
9292
"""Calculates the paraxial phase gradient at y-coordinate.
9393
9494
This is the gradient d_phi/dy evaluated at x=0.

optiland/phase/height_profile.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""
2+
Provides a phase profile based on a height map and dispersive material.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from optiland import backend as be
8+
from optiland.materials.base import BaseMaterial
9+
from optiland.phase.base import BasePhaseProfile
10+
11+
try:
12+
from scipy.interpolate import RectBivariateSpline
13+
except ImportError:
14+
RectBivariateSpline = None
15+
16+
17+
class HeightProfile(BasePhaseProfile):
18+
"""A phase profile defined by a height map and a dispersive material.
19+
20+
The phase is calculated as:
21+
phi(x, y, λ) = (2π / λ) * (n_material(λ) - 1) * h(x, y)
22+
23+
Assumes air as the reference medium.
24+
25+
Args:
26+
x_coords (be.Array): X-coordinates of the height map grid.
27+
y_coords (be.Array): Y-coordinates of the height map grid.
28+
height_map (be.Array): Height values at grid points
29+
with shape (len(y_coords), len(x_coords)).
30+
material: Material providing wavelength-dependent refractive index n(λ).
31+
"""
32+
33+
phase_type = "height_profile"
34+
35+
def __init__(
36+
self,
37+
x_coords: be.Array,
38+
y_coords: be.Array,
39+
height_map: be.Array,
40+
material: BaseMaterial,
41+
):
42+
if RectBivariateSpline is None:
43+
raise ImportError(
44+
"scipy is required for HeightProfile. Install with: pip install scipy"
45+
)
46+
47+
self.x_coords = be.to_numpy(x_coords)
48+
self.y_coords = be.to_numpy(y_coords)
49+
self.height_map = be.to_numpy(height_map)
50+
self.material = material
51+
52+
self._spline = RectBivariateSpline(
53+
self.y_coords,
54+
self.x_coords,
55+
self.height_map,
56+
)
57+
58+
def _interpolate_height(self, x: be.Array, y: be.Array) -> be.Array:
59+
return self._spline.ev(be.to_numpy(y), be.to_numpy(x))
60+
61+
def _interpolate_gradient(
62+
self, x: be.Array, y: be.Array
63+
) -> tuple[be.Array, be.Array]:
64+
x_np = be.to_numpy(x)
65+
y_np = be.to_numpy(y)
66+
dh_dx = self._spline.ev(y_np, x_np, dy=1)
67+
dh_dy = self._spline.ev(y_np, x_np, dx=1)
68+
return dh_dx, dh_dy
69+
70+
def get_phase(
71+
self,
72+
x: be.Array,
73+
y: be.Array,
74+
wavelength: be.Array,
75+
) -> be.Array:
76+
h = self._interpolate_height(x, y)
77+
n = self.material.n(wavelength)
78+
return 2 * be.pi / (wavelength * 1e-3) * (n - 1.0) * h
79+
80+
def get_gradient(
81+
self,
82+
x: be.Array,
83+
y: be.Array,
84+
wavelength: be.Array,
85+
) -> tuple[be.Array, be.Array, be.Array]:
86+
dh_dx, dh_dy = self._interpolate_gradient(x, y)
87+
n = self.material.n(wavelength)
88+
factor = 2 * be.pi / (wavelength * 1e-3) * (n - 1.0)
89+
return factor * dh_dx, factor * dh_dy, be.zeros_like(x)
90+
91+
def get_paraxial_gradient(
92+
self,
93+
y: be.Array,
94+
wavelength: be.Array,
95+
) -> be.Array:
96+
dh_dy = self._spline.ev(
97+
be.to_numpy(y),
98+
be.zeros_like(y),
99+
dx=1,
100+
)
101+
n = self.material.n(wavelength)
102+
return 2 * be.pi / (wavelength * 1e-3) * (n - 1.0) * dh_dy
103+
104+
def to_dict(self) -> dict:
105+
data = super().to_dict()
106+
data["x_coords"] = self.x_coords.tolist()
107+
data["y_coords"] = self.y_coords.tolist()
108+
data["height_map"] = self.height_map.tolist()
109+
data["material"] = getattr(self.material, "name", str(self.material))
110+
return data
111+
112+
@classmethod
113+
def from_dict(cls, data: dict) -> HeightProfile:
114+
return cls(
115+
x_coords=be.array(data["x_coords"]),
116+
y_coords=be.array(data["y_coords"]),
117+
height_map=be.array(data["height_map"]),
118+
material=data["material"],
119+
)

optiland/phase/linear_grating.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def __init__(
5757
def efficiency(self) -> float:
5858
return self._efficiency
5959

60-
def get_phase(self, x: be.Array, y: be.Array) -> be.Array:
60+
def get_phase(self, x: be.Array, y: be.Array, wavelength: be.Array = None) -> be.Array:
6161
"""Calculates the phase added by the profile at coordinates (x, y).
6262
6363
Args:
@@ -70,7 +70,7 @@ def get_phase(self, x: be.Array, y: be.Array) -> be.Array:
7070
return self._K_x * x + self._K_y * y
7171

7272
def get_gradient(
73-
self, x: be.Array, y: be.Array
73+
self, x: be.Array, y: be.Array, wavelength: be.Array = None
7474
) -> tuple[be.Array, be.Array, be.Array]:
7575
"""Calculates the gradient of the phase at coordinates (x, y).
7676

optiland/phase/radial.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class RadialPhaseProfile(BasePhaseProfile):
2323
def __init__(self, coefficients: list[float]):
2424
self.coefficients = coefficients
2525

26-
def get_phase(self, x: be.Array, y: be.Array) -> be.Array:
26+
def get_phase(self, x: be.Array, y: be.Array, wavelength: be.Array = None) -> be.Array:
2727
"""Calculates the phase added by the profile at coordinates (x, y).
2828
2929
Args:
@@ -41,7 +41,7 @@ def get_phase(self, x: be.Array, y: be.Array) -> be.Array:
4141
return phase
4242

4343
def get_gradient(
44-
self, x: be.Array, y: be.Array
44+
self, x: be.Array, y: be.Array, wavelength: be.Array = None
4545
) -> tuple[be.Array, be.Array, be.Array]:
4646
"""Calculates the gradient of the phase at coordinates (x, y).
4747
@@ -74,7 +74,7 @@ def get_gradient(
7474

7575
return d_phi_dx, d_phi_dy, d_phi_dz
7676

77-
def get_paraxial_gradient(self, y: be.Array) -> be.Array:
77+
def get_paraxial_gradient(self, y: be.Array, wavelength: be.Array = None) -> be.Array:
7878
"""Calculates the paraxial phase gradient at y-coordinate.
7979
8080
This is the gradient d_phi/dy evaluated at x=0.

tests/test_height_profile_phase.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import pytest
2+
import numpy as np
3+
from optiland import backend as be
4+
from optiland.phase.height_profile import HeightProfile
5+
from optiland.materials.ideal import IdealMaterial
6+
from .utils import assert_allclose
7+
8+
pytest.importorskip("scipy")
9+
10+
@pytest.fixture
11+
def height_data():
12+
x = be.linspace(-1, 1, 5)
13+
y = be.linspace(-1, 1, 4)
14+
height_map = be.array([[i + j for i in x] for j in y])
15+
material = IdealMaterial(n=1.5)
16+
return x, y, height_map, material
17+
18+
def test_height_profile_init(height_data):
19+
x, y, height_map, material = height_data
20+
profile = HeightProfile(x, y, height_map, material)
21+
assert profile.x_coords.shape[0] == len(x)
22+
assert profile.y_coords.shape[0] == len(y)
23+
assert profile.height_map.shape == (len(y), len(x))
24+
assert profile.material is material
25+
26+
def test_height_profile_get_phase(height_data):
27+
x, y, height_map, material = height_data
28+
profile = HeightProfile(x, y, height_map, material)
29+
phase = profile.get_phase(be.array([0.0]), be.array([0.0]), be.array([1.0]))
30+
assert phase.shape == (1,)
31+
assert isinstance(phase.item(), float)
32+
33+
def test_height_profile_get_gradient(height_data):
34+
x, y, height_map, material = height_data
35+
profile = HeightProfile(x, y, height_map, material)
36+
grad_x, grad_y, grad_z = profile.get_gradient(be.array([0.0]), be.array([0.0]), be.array([1.0]))
37+
assert grad_x.shape == grad_y.shape == grad_z.shape
38+
assert_allclose(grad_z, be.zeros_like(grad_z))
39+
40+
def test_height_profile_get_paraxial_gradient(height_data):
41+
x, y, height_map, material = height_data
42+
profile = HeightProfile(x, y, height_map, material)
43+
paraxial = profile.get_paraxial_gradient(be.array([0.0, 0.5]), be.array([1.0]))
44+
assert paraxial.shape[0] == 2
45+
46+
def test_height_profile_to_from_dict(height_data):
47+
x, y, height_map, material = height_data
48+
profile = HeightProfile(x, y, height_map, material)
49+
data = profile.to_dict()
50+
new_profile = HeightProfile.from_dict(data)
51+
assert isinstance(new_profile, HeightProfile)
52+
assert_allclose(new_profile.x_coords, x)
53+
assert_allclose(new_profile.y_coords, y)
54+
assert_allclose(new_profile.height_map, height_map)

0 commit comments

Comments
 (0)