#!/usr/bin/env python
#-*- coding:utf-8 -*-
"""Masks for lithographic imaging: layouts (geometry) and mask stacks
(materials and the model that turns them into transmissions).
Layout
------
A MaskLayout is one period (pitch_x, pitch_y) of a periodic mask that holds
rectangular features on a background (the field). A pitch of None makes the
mask invariant along that direction: lines along y have pitch_y = None. With
tone="clear" the field is clear and the features are absorber (lines on a
clear field mask), with tone="dark" the field is absorber and the features
are clear (contact holes, trenches). Isolated features are periodic features
with a pitch large enough that the neighbours do not interact (the coherence
length of the illumination, a few lambda/NA).
The diffraction orders are the Fourier coefficients of the transmission on
one period. They are computed analytically from the rectangles (products of
sinc functions), so there is no pixelation of the mask.
Manhattan polygons (MaskLayout.from_polygons()) are turned into the exact
union of non-overlapping rectangles, so L and T shapes, line ends with
hammerheads or serifs and OPC'd shapes have exact Fourier coefficients too.
Overlapping polygons are merged. The boundary layer model is not available
for polygon layouts (the rectangles of a polygon share internal edges).
Mask stacks and approximations
------------------------------
A mask stack gives the transmissions of the clear and absorber regions as
Jones matrices for an illuminating plane wave (source point (s_x, s_y)):
- ThinMask: Kirchhoff (thin mask) approximation with given complex
transmissions, e.g. binary (chrome on glass, t = 0) or attenuated phase
shift masks (t = sqrt(T)*exp(i*phase)); independent of angle and
polarization.
- FilmMask: the absorber is a stack of thin films (n + i*k, thickness) on
the mask blank. Absorber and clear regions are taken as laterally infinite
film stacks (a thin mask whose local transmissions are those of the films):
their transmissions t_TE and t_TM are computed with pyoptics.thin_films for
the angle of incidence of each source point at the mask, n*sin(theta) =
``NA*|s|/M`` (M: reduction ratio), and for TE and TM with respect to its plane
of incidence. Phase and amplitude of the absorber, e.g. of an attenuated
PSM, follow from the material data instead of being set by hand.
- BoundaryLayer (optional for both): thick mask (mask 3D) effects at the
edges of the absorber are approximated by strips of width w centered on
every edge, with a complex transmission that depends on whether the
electric field is parallel (TE) or perpendicular (TM) to the edge
(Tirapu-Azpiroz and Yablonovitch's boundary layer model). Its parameters
come from a rigorous simulation or a calibration. At the corners of
rectangles, the strips along y are taken to cover the corner squares.
Transmissions are relative to the clear area: the clear region has the
transmission 1 (FilmMask normalizes to the clear region unless
normalize=False), so that images can be normalized to the open frame.
References
----------
J. Tirapu-Azpiroz, P. Burchard and E. Yablonovitch, "Boundary layer model to
account for thick mask effects in photolithography", Proc. SPIE 5040, 1611
(2003).
A. Erdmann, "Optical and EUV Lithography: A Modeling Perspective", SPIE Press
(2021).
A. K.-K. Wong, "Resolution Enhancement Techniques in Optical Lithography", SPIE
Press (2001).
"""
import numpy as np
from ..polarization import TE_TM_basis
from ..thin_films import _r_t
# layouts:
def _check_polygon(polygon):
P = np.asarray(polygon, dtype=float)
if P.ndim != 2 or P.shape[1] != 2 or len(P) < 4:
raise ValueError("a polygon is a sequence of at least four (x, y) vertices")
d = np.roll(P, -1, axis=0) - P
if np.any((d[:, 0] != 0) & (d[:, 1] != 0)):
raise ValueError("polygon edges must be parallel to x or y (Manhattan)")
return P
[docs]
def polygon_area(polygon):
"""Signed area (positive for counterclockwise vertices)."""
P = np.asarray(polygon, dtype=float)
x, y = P[:, 0], P[:, 1]
return 0.5*float(np.sum(x*np.roll(y, -1) - np.roll(x, -1)*y))
[docs]
def counterclockwise(polygon):
"""The polygon with counterclockwise vertices, without repeated or
collinear vertices."""
P = _check_polygon(polygon)
if polygon_area(P) < 0:
P = P[::-1]
P = P[np.any(P != np.roll(P, 1, axis=0), axis=1)]
keep = []
for i in range(len(P)): # (exact tests: the edges are parallel to x or y)
a, b, c = P[i - 1], P[i], P[(i + 1) % len(P)]
if (b - a)[0]*(c - b)[1] - (b - a)[1]*(c - b)[0] != 0: # not collinear
keep.append(b)
return np.array(keep)
[docs]
def offset_polygon(polygon, distance):
"""Move every edge of a Manhattan polygon outward, keeping it rectilinear.
Parameters
----------
polygon : (N, 2) array
Vertices (x, y); edges parallel to x or y.
distance : number
Shift of each edge along its outward normal (negative: inward).
Returns
-------
polygon : (N, 2) array
Offset polygon, counterclockwise.
"""
P = counterclockwise(polygon)
d = np.roll(P, -1, axis=0) - P
normal = np.column_stack([d[:, 1], -d[:, 0]])/np.linalg.norm(d, axis=1)[:, None]
out = []
for i in range(len(P)): # vertex i joins edge i - 1 and edge i
out.append(P[i] + distance*(normal[i - 1] + normal[i]))
return np.array(out)
[docs]
def polygon_rectangles(polygons):
"""The union of Manhattan polygons as non-overlapping rectangles (x0,
y0, w_x, w_y) (exact; cells of the grid of all vertex coordinates,
merged along x and then along y)."""
polys = [_check_polygon(P) for P in polygons]
if not polys:
return []
xs = np.unique(np.concatenate([P[:, 0] for P in polys]))
ys = np.unique(np.concatenate([P[:, 1] for P in polys]))
XC, YC = np.meshgrid((xs[:-1] + xs[1:])/2, (ys[:-1] + ys[1:])/2)
inside = np.zeros(XC.shape, dtype=bool)
for P in polys: # even-odd rule with the vertical edges
crossings = np.zeros(XC.shape, dtype=int)
Q = np.roll(P, -1, axis=0)
for (x_a, y_a), (x_b, y_b) in zip(P, Q):
if x_a == x_b and y_a != y_b:
crossings += (x_a > XC) & (YC > min(y_a, y_b)) & (YC < max(y_a, y_b))
inside |= crossings % 2 == 1
# runs along x in every row, then merge equal runs of consecutive rows
rects, open_runs = [], {}
for j in range(len(ys) - 1):
runs, i = [], 0
row = inside[j]
while i < len(row):
if row[i]:
k = i
while k + 1 < len(row) and row[k + 1]:
k += 1
runs.append((xs[i], xs[k + 1]))
i = k + 1
else:
i += 1
new_open = {}
for run in runs:
y0 = open_runs.pop(run, ys[j])
new_open[run] = y0
for (x_a, x_b), y0 in open_runs.items(): # runs that ended below row j
rects.append(((x_a + x_b)/2, (y0 + ys[j])/2, x_b - x_a, ys[j] - y0))
open_runs = new_open
for (x_a, x_b), y0 in open_runs.items():
rects.append(((x_a + x_b)/2, (y0 + ys[-1])/2, x_b - x_a, ys[-1] - y0))
return [tuple(float(v) for v in r) for r in rects]
def _rect_coeffs(m, P, center, size):
"""1D Fourier coefficients of a rect of width size at center on the
period P; P None: invariant direction (only order 0, coefficient 1)."""
m = np.asarray(m)
if P is None:
return np.ones(m.shape, dtype=complex)
return size/P*np.sinc(m*size/P)*np.exp(-2j*np.pi*m*center/P)
[docs]
class MaskLayout(object):
"""Geometry of one period of a mask.
Parameters
----------
pitch_x, pitch_y : numbers or None
Periods; None for an invariant direction (at most one).
features : sequence of (x0, y0, w_x, w_y)
Centers and sizes of rectangles (must not overlap). Sizes along an
invariant direction are ignored.
tone : "clear" or "dark"
Clear field (features are absorber) or dark field (features are
clear).
name : str, optional
"""
def __init__(self, pitch_x, pitch_y=None, features=(), tone="clear", name=None):
if pitch_x is None and pitch_y is None:
raise ValueError("At least one direction must be periodic")
if tone not in ("clear", "dark"):
raise ValueError("tone must be 'clear' or 'dark'")
self.pitch_x, self.pitch_y = pitch_x, pitch_y
self.features = [tuple(float(v) for v in f) for f in features]
self.tone = tone
self.name = name
self.polygons = None
[docs]
@classmethod
def from_polygons(cls, pitch_x, pitch_y, polygons, tone="clear", name=None):
"""Layout of Manhattan polygons in one period.
The polygons should lie within one period of pitch_x x pitch_y (they are
repeated periodically, images in neighbouring periods are not merged).
Overlapping polygons are merged; the first polygon is the feature that
cut_coordinates() and the CD metrics use (through its first rectangle).
Parameters
----------
pitch_x, pitch_y : numbers
Periods; both directions must be periodic.
polygons : sequence of (N, 2) arrays
Vertices (x, y) of each polygon, edges parallel to x or y.
tone : {"clear", "dark"}, optional
Clear field (polygons are absorber) or dark field (polygons are clear).
name : str, optional
Returns
-------
MaskLayout
"""
if pitch_x is None or pitch_y is None:
raise ValueError("polygon layouts must be periodic in x and y")
polys = [counterclockwise(P) for P in polygons]
rects = polygon_rectangles(polys)
if rects: # the rectangle holding the center of the first polygon's first rectangle first
x0, y0 = polygon_rectangles(polys[:1])[0][:2]
i = next(i for i, (x, y, a, b) in enumerate(rects)
if abs(x0 - x) <= a/2 and abs(y0 - y) <= b/2)
rects.insert(0, rects.pop(i))
layout = cls(pitch_x, pitch_y, rects, tone, name or "polygons")
layout.polygons = polys
return layout
# common layouts:
[docs]
@classmethod
def lines_spaces(cls, pitch, cd, orientation="y", tone="clear", name=None):
"""Lines and spaces.
Parameters
----------
pitch : number
Period.
cd : number
Width of the lines (absorber for tone="clear", clear trenches for
tone="dark").
orientation : {"y", "x"}, optional
Direction of the lines: along y (pitch along x) or along x.
tone : {"clear", "dark"}, optional
name : str, optional
Returns
-------
MaskLayout
"""
name = name or "L/S {:g}/{:g}".format(cd, pitch - cd)
if orientation == "y":
return cls(pitch, None, [(0.0, 0.0, cd, 0.0)], tone, name)
if orientation == "x":
return cls(None, pitch, [(0.0, 0.0, 0.0, cd)], tone, name)
raise ValueError("orientation must be 'x' or 'y'")
[docs]
@classmethod
def iso_line(cls, cd, pitch=None, orientation="y", tone="clear", name=None):
"""Isolated line (or trench for tone="dark").
Parameters
----------
cd : number
Line width.
pitch : number, optional
Period of the repeated line, default 20*cd.
orientation : {"y", "x"}, optional
Direction of the line.
tone : {"clear", "dark"}, optional
name : str, optional
Returns
-------
MaskLayout
"""
return cls.lines_spaces(20*cd if pitch is None else pitch, cd, orientation, tone,
name or "iso line {:g}".format(cd))
# properties:
@property
def is_1d(self):
return self.pitch_x is None or self.pitch_y is None
@property
def feature_is_bright(self):
"""True if the features transmit (dark field mask)."""
return self.tone == "dark"
[docs]
def scaled(self, factor_cd=1.0, bias=0.0):
"""Copy with scaled and biased features.
Parameters
----------
factor_cd : number, optional
Factor for all feature sizes (polygon layouts: only 1).
bias : number, optional
Added to every feature size (each edge moves by bias/2), e.g. a mask
CD error for MEEF.
Returns
-------
MaskLayout
"""
if self.polygons is not None:
if factor_cd != 1.0:
raise ValueError("polygon layouts can only be biased (factor_cd = 1)")
return MaskLayout.from_polygons(self.pitch_x, self.pitch_y,
[offset_polygon(P, bias/2) for P in self.polygons],
self.tone, self.name)
feats = [(x0, y0, w*factor_cd + bias, h*factor_cd + bias) for x0, y0, w, h in self.features]
return MaskLayout(self.pitch_x, self.pitch_y, feats, self.tone, self.name)
# diffraction:
[docs]
def max_orders(self, NA, wavelength, sigma_max=1.0):
"""Largest diffraction order indices that can pass the pupil.
Parameters
----------
NA : number
Numerical aperture (image side).
wavelength : number
Vacuum wavelength.
sigma_max : number, optional
Largest source point radius (partial coherence).
Returns
-------
M, N : int
Largest order indices in x and y (0 for an invariant direction).
"""
f_max = NA*(1 + sigma_max)/wavelength
M = 0 if self.pitch_x is None else int(np.floor(self.pitch_x*f_max)) + 1
N = 0 if self.pitch_y is None else int(np.floor(self.pitch_y*f_max)) + 1
return M, N
[docs]
def region_coefficients(self, m, n, boundary_width=0.0):
"""Fourier coefficients of the regions of the layout.
Parameters
----------
m, n : arrays of ints
Order indices along x and y (1D each).
boundary_width : number, optional
Width of the boundary layer strips.
Returns
-------
c : dict of arrays of shape (len(n), len(m))
"field", "feature" and, for boundary_width > 0, "edge_y" (strips
along edges parallel to y) and "edge_x" (edges parallel to x).
The coefficients of all regions add up to the delta of order 0.
"""
m, n = np.atleast_1d(m), np.atleast_1d(n)
Px, Py, w = self.pitch_x, self.pitch_y, boundary_width
if w > 0 and self.polygons is not None:
raise ValueError("the boundary layer model is not available for polygon layouts")
shape = (len(n), len(m))
def rect(x0, y0, a, b):
return np.outer(_rect_coeffs(n, Py, y0, b), _rect_coeffs(m, Px, x0, a))
c = {"feature": np.zeros(shape, dtype=complex)}
outer = np.zeros(shape, dtype=complex)
if w > 0:
c["edge_y"] = np.zeros(shape, dtype=complex)
c["edge_x"] = np.zeros(shape, dtype=complex)
for x0, y0, a, b in self.features:
if w <= 0:
c["feature"] += rect(x0, y0, a, b)
outer += rect(x0, y0, a, b)
continue
if (Px is not None and a <= w) or (Py is not None and b <= w):
raise ValueError("Feature smaller than the boundary layer width")
c["feature"] += rect(x0, y0, a - w, b - w)
outer += rect(x0, y0, a + w, b + w)
if Px is not None: # edges parallel to y at x0 -+ a/2, including the corners
for sgn in (-1, 1):
c["edge_y"] += rect(x0 + sgn*a/2, y0, w, b + w)
if Py is not None: # edges parallel to x, between the corners
for sgn in (-1, 1):
c["edge_x"] += rect(x0, y0 + sgn*b/2, a - w, w)
delta = np.outer(n == 0, m == 0).astype(complex)
c["field"] = delta - outer
return c
[docs]
def transmission(self, x, y=None, t_clear=1.0, t_absorber=0.0):
"""Sample the scalar thin mask transmission, e.g. for plots.
Parameters
----------
x : array
x coordinates.
y : array, optional
y coordinates; None gives a cut at y = 0.
t_clear, t_absorber : complex, optional
Transmissions of the clear area and the absorber.
Returns
-------
t : array
Shape (len(y), len(x)), or (len(x),) for y=None.
"""
X, Y = np.meshgrid(np.atleast_1d(x), np.atleast_1d(0.0 if y is None else y))
inside = np.zeros(X.shape, dtype=bool)
for x0, y0, a, b in self.features:
in_x = True if self.pitch_x is None else (
np.abs((X - x0 + self.pitch_x/2) % self.pitch_x - self.pitch_x/2) < a/2)
in_y = True if self.pitch_y is None else (
np.abs((Y - y0 + self.pitch_y/2) % self.pitch_y - self.pitch_y/2) < b/2)
inside |= in_x & in_y
t_feat, t_field = (t_clear, t_absorber) if self.tone == "dark" else (t_absorber, t_clear)
t = np.where(inside, t_feat, t_field)
return t[0] if y is None else t
def __repr__(self):
return "MaskLayout({}, pitch=({}, {}), tone={})".format(self.name, self.pitch_x,
self.pitch_y, self.tone)
# mask stacks:
[docs]
class BoundaryLayer(object):
"""Boundary layer at the absorber edges.
Parameters
----------
width : number
Width of the strip centered on each edge (wafer scale).
t_TE, t_TM : complex numbers
Transmission of the strip for the electric field parallel (TE) and
perpendicular (TM) to the edge.
"""
def __init__(self, width, t_TE, t_TM):
self.width, self.t_TE, self.t_TM = width, complex(t_TE), complex(t_TM)
[docs]
def jones(self):
"""Jones matrices of the strips along y and along x."""
return np.diag([self.t_TM, self.t_TE]), np.diag([self.t_TE, self.t_TM])
[docs]
class ThinMask(object):
"""Kirchhoff (thin mask) model with given transmissions.
Parameters
----------
t_absorber : complex, optional
Amplitude transmission of the absorber (default 0: binary mask).
t_clear : complex, optional
boundary_layer : BoundaryLayer, optional
name : str, optional
"""
def __init__(self, t_absorber=0.0, t_clear=1.0, boundary_layer=None, name=None):
self.t_absorber, self.t_clear = complex(t_absorber), complex(t_clear)
self.boundary_layer = boundary_layer
self.name = name or "thin mask t={:.3g}".format(self.t_absorber)
[docs]
@classmethod
def binary(cls, **kwargs):
"""Binary (chrome on glass) mask."""
kwargs.setdefault("name", "binary")
return cls(0.0, **kwargs)
[docs]
@classmethod
def att_psm(cls, transmission=0.06, phase=180.0, **kwargs):
"""Attenuated phase shift mask.
Parameters
----------
transmission : number, optional
Intensity transmission of the absorber.
phase : number, optional
Phase of the absorber in degrees.
**kwargs
Further arguments of `ThinMask` (e.g. boundary_layer, name).
Returns
-------
ThinMask
"""
kwargs.setdefault("name", "att. PSM {:g} %".format(100*transmission))
return cls(np.sqrt(transmission)*np.exp(1j*np.radians(phase)), **kwargs)
[docs]
def jones(self, s_x, s_y, NA, wavelength, magnification):
"""Jones matrices of the clear area and the absorber for source points.
Parameters
----------
s_x, s_y : numbers or arrays
Source point coordinates (sigma).
NA : number
Numerical aperture (unused by the thin mask).
wavelength : number
Vacuum wavelength (unused by the thin mask).
magnification : number
Reduction ratio of the projector (unused by the thin mask).
Returns
-------
J_clear, J_absorber : arrays
Shape np.shape(s_x) + (2, 2).
"""
eye = np.broadcast_to(np.eye(2), np.shape(s_x) + (2, 2))
return self.t_clear*eye, self.t_absorber*eye
[docs]
class FilmMask(object):
"""Mask whose absorber is a thin film stack on the mask blank.
Light comes from the blank (substrate_n), passes the absorber layers in
the given order and leaves into the exit medium (air). The clear region
is the bare blank; its transmission is taken at the same plane as the
absorber's (bottom of the absorber), i.e. through exit medium of the same
thickness.
Parameters
----------
n, k, t : sequences
Refractive indices n + i*k and thicknesses of the absorber layers,
from the blank outwards (mask scale thicknesses).
substrate_n, substrate_k : numbers, optional
Mask blank (fused silica at 193 nm: about 1.563).
exit_n : number, optional
Medium between mask and projection lens.
normalize : bool, optional
Divide by the clear transmission (per TE/TM), so that the clear
region transmits 1.
boundary_layer : BoundaryLayer, optional
name : str, optional
"""
def __init__(self, n, k, t, substrate_n=1.563, substrate_k=0.0, exit_n=1.0, normalize=True,
boundary_layer=None, name=None):
self.n, self.k, self.t = (np.atleast_1d(np.asarray(a, dtype=float)) for a in (n, k, t))
if not len(self.n) == len(self.k) == len(self.t):
raise ValueError("n, k and t must have the same length")
self.substrate = substrate_n + 1j*substrate_k
self.exit_n = exit_n
self.normalize = normalize
self.boundary_layer = boundary_layer
self.name = name or "film mask"
[docs]
def transmissions(self, sin_theta, wavelength):
"""Transmissions of absorber and clear area for oblique incidence.
Parameters
----------
sin_theta : number or array
Sine of the angle of incidence in the blank (substrate).
wavelength : number
Vacuum wavelength in the unit of the layer thicknesses.
Returns
-------
t_abs, t_clear : arrays of shape (2,) + np.shape(sin_theta)
[t_TE, t_TM] each (ratios of tangential electric fields).
"""
sin_theta = np.asarray(sin_theta, dtype=float)
n_abs = np.concatenate([[self.substrate], self.n + 1j*self.k, [self.exit_n]])
n_clear = np.array([self.substrate, self.exit_n, self.exit_n], dtype=complex)
t_abs = np.array([_r_t(sin_theta, n_abs, self.t, pol, wavelength)[1] for pol in ("TE", "TM")])
t_clear = np.array([_r_t(sin_theta, n_clear, [self.t.sum()], pol, wavelength)[1]
for pol in ("TE", "TM")])
return t_abs, t_clear
[docs]
def jones(self, s_x, s_y, NA, wavelength, magnification):
"""Jones matrices of the clear area and the absorber for source points.
The angle of incidence at the mask follows from NA*sigma/magnification
in the blank; TE and TM transmissions are combined on the TE/TM basis of
each source point.
Parameters
----------
s_x, s_y : numbers or arrays
Source point coordinates (sigma).
NA : number
Numerical aperture (image side).
wavelength : number
Vacuum wavelength.
magnification : number
Reduction ratio of the projector.
Returns
-------
J_clear, J_absorber : arrays
Shape np.shape(s_x) + (2, 2).
"""
sin_theta = NA*np.hypot(s_x, s_y)/(magnification*np.real(self.substrate))
t_abs, t_clear = self.transmissions(sin_theta, wavelength)
if self.normalize:
t_abs, t_clear = t_abs/t_clear, np.ones_like(t_clear)
B = np.array(TE_TM_basis(s_x, s_y)) # B[0]: TE, B[1]: TM Jones vector
def to_xy(t): # sum over TE and TM of t*e e^T
return np.einsum("ki...,k...,kj...->...ij", B, t, B)
return to_xy(t_clear), to_xy(t_abs)