def f_qbfs(n): """f(m) from oe-18-19-19700 eq. (A.16).""" if n == 0: return 2 elif n == 1: return np.sqrt(19) / 2 else: term1 = n * (n + 1) + 3 term2 = g_qbfs(n - 1)**2 term3 = h_qbfs(n - 2)**2 return np.sqrt(term1 - term2 - term3)
def sphere_sag(c, rhosq, phi=None): """Sag of a spherical surface. Parameters ---------- c : float surface curvature rhosq : numpy.ndarray radial coordinate squared e.g. for a 15 mm half-diameter optic, rho = 0 .. 15 rhosq = 0 .. 225 there is no requirement on rectilinear sampling or array dimensionality phi : numpy.ndarray, optional (1 - c^2 r^2)^.5 computed if not provided many surface types utilize phi; its computation can be de-duplicated by passing the optional argument Returns ------- numpy.ndarray surface sag """ if phi is None: csq = c * c phi = np.sqrt(1 - csq * rhosq) return (c * rhosq) / (1 + phi)
def refract(n, nprime, S, r): """Use Newton-Raphson iteration to solve Snell's law for the exitant direction cosines. Parameters ---------- n : float preceeding index of refraction nprime : float following index of refraction S : numpy.ndarray shape (3,) or (N,3), any float dtype (k,l,m) incident direction cosines r : numpy.ndarray shape (3,) or (N,3), any float dtype surface normals (Fx, Fy, 1) Returns ------- numpy.ndarray Sprime, a length 3 vector containing the exitant direction cosines """ mu = n/nprime musq = mu * mu cosI = _multi_dot(r, S) cosIsq = cosI * cosI # the inline newaxis-es are terrible for readability, but serve a performance purpose # broadcast the square root to 2D, so that fewer very expensive sqrt ops are done # then, in the second term, broadcast cosI for compatability with S and r # since it is needed there first_term = np.sqrt(1 - musq * (1 - cosIsq))[:, np.newaxis] * r second_term = mu * (S - cosI[:, np.newaxis] * r) return first_term + second_term
def noll_to_nm(idx): """Convert Noll Z to (n, m) two-term index.""" # I don't really understand this code, the math is inspired by POPPY # azimuthal order n = int(np.ceil((-1 + np.sqrt(1 + 8 * idx)) / 2) - 1) if n == 0: m = 0 else: # this is sort of a rising factorial to use that term incorrectly nseries = int((n + 1) * (n + 2) / 2) res = idx - nseries - 1 if is_odd(idx): sign = -1 else: sign = 1 if is_odd(n): ms = [1, 1] else: ms = [0] for i in range(n // 2): ms.append(ms[-1] + 2) ms.append(ms[-1]) m = ms[res] * sign return n, m
def conic_sag_der(c, kappa, rho, phi=None): """Sag of a spherical surface. Parameters ---------- c : float surface curvature kappa : float conic constant, 0=sphere, 1=parabola, etc rho : numpy.ndarray radial coordinate e.g. for a 15 mm half-diameter optic, rho = 0 .. 15 there is no requirement on rectilinear sampling or array dimensionality phi : numpy.ndarray, optional (1 - (1+kappa) c^2 r^2)^.5 computed if not provided many surface types utilize phi; its computation can be de-duplicated by passing the optional argument Returns ------- numpy.ndarray surface sag """ if phi is None: csq = c**2 rhosq = rho * rho phi = np.sqrt(1 - (1 + kappa) * csq * rhosq) return (c * rho) / phi
def fringe_to_nm(idx): """Convert Fringe Z to (n, m) two-term index.""" m_n = 2 * (np.ceil(np.sqrt(idx)) - 1) # sum of n+m g_s = (m_n / 2)**2 + 1 # start of each group of equal n+m given as idx index n = m_n / 2 + np.floor((idx - g_s) / 2) m = (m_n - n) * (1 - np.mod(idx - g_s, 2) * 2) return int(n), int(m)
def off_axis_conic_sigma(c, kappa, r, t, dx, dy=0): """Lowercase sigma (direction cosine projection term) for an off-axis conic. See Eq. (5.2) of oe-20-3-2483. Parameters ---------- c : float axial curvature of the conic kappa : float conic constant r : numpy.ndarray radial coordinate, where r=0 is centered on the off-axis section t : numpy.ndarray azimuthal coordinate dx : float shift of the surface in x with respect to the base conic vertex, mutually exclusive to dy (only one may be nonzero) use dx=0 when dy != 0 dy : float shift of the surface in y with respect to the base conic vertex Returns ------- sigma(r,t) """ if dy != 0 and dx != 0: raise ValueError('only one of dx/dy may be nonzero') if dx != 0: s = dx oblique_term = 2 * s * r * np.cos(t) else: s = dy oblique_term = 2 * s * r * np.sin(t) aggregate_term = r * r + oblique_term + s * s csq = c * c num = np.sqrt(1 - (1 + kappa) * csq * aggregate_term) den = np.sqrt(1 - kappa * csq * aggregate_term) # flipped sign, 1-kappa return num / den
def f_q2d(n, m): """Lowercase f term for 2D-Q polynomials. oe-20-3-2483 Eq. (A.18b). Parameters ---------- n : int radial order m : int azimuthal order Returns ------- float f """ if n == 0: return np.sqrt(F_q2d(n=0, m=m)) else: return np.sqrt(F_q2d(n, m) - g_q2d(n - 1, m)**2)
def off_axis_conic_sag(c, kappa, r, t, dx, dy=0): """Sag of an off-axis conicoid. Parameters ---------- c : float axial curvature of the conic kappa : float conic constant r : numpy.ndarray radial coordinate, where r=0 is centered on the off-axis section t : numpy.ndarray azimuthal coordinate dx : float shift of the surface in x with respect to the base conic vertex, mutually exclusive to dy (only one may be nonzero) use dx=0 when dy != 0 dy : float shift of the surface in y with respect to the base conic vertex Returns ------- numpy.ndarray surface sag, z(x,y) """ if dy != 0 and dx != 0: raise ValueError('only one of dx/dy may be nonzero') if dx != 0: s = dx oblique_term = 2 * s * r * np.cos(t) else: s = dy oblique_term = 2 * s * r * np.sin(t) aggregate_term = r * r + oblique_term + s * s num = c * aggregate_term csq = c * c den = 1 + np.sqrt(1 - (1 + kappa) * csq * aggregate_term) return num / den
def phi_spheroid(c, k, rhosq): """'phi' for a spheroid. phi = sqrt(1 - c^2 rho^2) Parameters ---------- c : float curvature, reciprocal radius of curvature k : float kappa, conic constant rhosq : numpy.ndarray squared radial coordinate (non-normalized) Returns ------- numpy.ndarray phi term """ csq = c * c return np.sqrt(1 - (1 + k) * csq * rhosq)
def _establish_axis(P1, P2): """Given two points, establish an axis between them. Parameters ---------- P1 : numpy.ndarray shape (3,), any float dtype first point P2 : numpy.ndarray shape (3,), any float dtype second point Returns ------- numpy.ndarray, numpy.ndarray P1 (same exact PyObject) and direction cosine from P1 -> P2 """ diff = P2 - P1 euclidean_distance = np.sqrt(diff**2).sum() num = diff den = euclidean_distance return num / den
def zernikes_to_magnitude_angle_nmkey(coefs): """Convert Zernike polynomial set to a magnitude and phase representation. Parameters ---------- coefs : list of tuples a list looking like[(1,2,3),] where (1,2) are the n, m indices and 3 the coefficient Returns ------- dict dict keyed by tuples of (n, |m|) with values of (rho, phi) where rho is the magnitudes, and phi the phase """ def mkary(): # default for defaultdict return list() combinations = defaultdict(mkary) # for each name and coefficient, make a len 2 array. Put the Y or 0 degree values in the first slot for n, m, coef in coefs: m2 = abs(m) key = (n, m2) combinations[key].append(coef) for key, value in combinations.items(): if len(value) == 1: magnitude = value[0] angle = 0 else: magnitude = np.sqrt(sum([v**2 for v in value])) angle = np.degrees(np.arctan2(*value)) combinations[key] = (magnitude, angle) return dict(combinations)
def generate_finite_ray_fan(nrays, na, P=0, min_na=None, azimuth=90, yangle=0, xangle=0, n=1, distribution='uniform'): """Generate a 1D fan of rays. Parameters ---------- nrays : int the number of rays in the fan na : float object-space numerical aperture P : numpy.ndarray length 3 vector containing the position from which the rays emanate min_na : float, optional minimum NA for the beam, -na if None azimuth: float angle in the XY plane, degrees. 0=X ray fan, 90=Y ray fan yangle : float propagation angle of the chief/gut ray with respect to the Y axis, clockwise xangle : float propagation angle of the gut ray with respect to the X axis, clockwise n : float refractive index at P (1=vacuum) distribution : str, {'uniform', 'random', 'cheby'} The distribution to use when placing the rays a uniform distribution has rays which are equally spaced from minr to maxr, random has rays randomly distributed in minr and maxr, while cheby has the Cheby-Gauss-Lobatto roots as its locations from minr to maxr Returns ------- numpy.ndarray, numpy.ndarray "P" and "S" variables, positions and direction cosines of the rays """ # TODO: revisit this; tracing a parabola from the focus, the output # ray spacing is not uniform as it should be. Or is this some manifestation # of the sine condition? # more likely it's the square root since it hides unless the na is big P = _ensure_P_vec(P) distribution = distribution.lower() if min_na is None: min_na = -na max_t = np.arcsin(na / n) min_t = np.arcsin(min_na / n) if distribution == 'uniform': t = np.linspace(min_t, max_t, nrays, dtype=config.precision) elif distribution == 'random': t = np.random.uniform(low=min_t, high=max_t, size=nrays).astype(config.precision) # use the even function for the y direction cosine, # use trig identity to compute the z direction cosine l = np.sin(t) # NOQA m = np.sqrt(1 - l * l) # NOQA k = np.array([0.], dtype=t.dtype) k = np.broadcast_to(k, (nrays, )) if azimuth == 0: k, l = l, k # NOQA swap Y and X axes S = np.stack([k, l, m], axis=1) if yangle != 0 and xangle != 0: R = make_rotation_matrix((0, yangle, -xangle)) # newaxis for batch matmul, squeeze needed for size 1 dim after S = np.matmul(R, S[..., np.newaxis]).squeeze() # need to see a copy of P for each ray, -> add empty dim and broadcast P = P[np.newaxis, :] P = np.broadcast_to(P, (nrays, 3)) return P, S
def Qbfs(n, x): """Qbfs polynomial of order n at point(s) x. Parameters ---------- n : int polynomial order x : numpy.array point(s) at which to evaluate Returns ------- numpy.ndarray Qbfs_n(x) """ # to compute the Qbfs polynomials, compute the auxiliary polynomial P_n # recursively. Simultaneously use the recurrence relation for Q_n # to compute the intermediary Q polynomials. # for input x, transform r = x ^ 2 # then compute P(r) and consequently Q(r) # and scale outputs by Qbfs = r*(1-r) * Q # the auxiliary polynomials are the jacobi polynomials with # alpha,beta = (-1/2,+1/2), # also known as the chebyshev polynomials of the third kind, V(x) # the first two Qbfs polynomials are # Q_bfs0 = x^2 - x^4 # Q_bfs1 = 1/19^.5 * (13 - 16 * x^2) * (x^2 - x^4) rho = x**2 # c_Q is the leading term used to convert Qm to Qbfs c_Q = rho * (1 - rho) if n == 0: return c_Q # == x^2 - x^4 if n == 1: return 1 / np.sqrt(19) * (13 - 16 * rho) * c_Q # c is the leading term of the recurrence relation for P c = 2 - 4 * rho # P0, P1 are the first two terms of the recurrence relation for auxiliary # polynomial P_n P0 = np.ones_like(x) * 2 P1 = 6 - 8 * rho Pnm2 = P0 Pnm1 = P1 # Q0, Q1 are the first two terms of the recurrence relation for Qm Q0 = np.ones_like(x) Q1 = 1 / np.sqrt(19) * (13 - 16 * rho) Qnm2 = Q0 Qnm1 = Q1 for nn in range(2, n + 1): Pn = c * Pnm1 - Pnm2 Pnm2 = Pnm1 Pnm1 = Pn g = g_qbfs(nn - 1) h = h_qbfs(nn - 2) f = f_qbfs(nn) Qn = (Pn - g * Qnm1 - h * Qnm2) * ( 1 / f) # small optimization; mul by 1/f instead of div by f Qnm2 = Qnm1 Qnm1 = Qn # Qn is certainly defined (flake8 can't tell the previous ifs bound the loop # to always happen once) return Qn * c_Q # NOQA
def Qbfs_sequence(ns, x): """Qbfs polynomials of orders ns at point(s) x. Parameters ---------- ns : Iterable of int polynomial orders x : numpy.array point(s) at which to evaluate Returns ------- generator of numpy.ndarray yielding one order of ns at a time """ # see the leading comment of Qbfs for some explanation of this code # and prysm:jacobi.py#jacobi_sequence the "_sequence" portion ns = list(ns) min_i = 0 rho = x**2 # c_Q is the leading term used to convert Qm to Qbfs c_Q = rho * (1 - rho) if ns[min_i] == 0: yield np.ones_like(x) * c_Q min_i += 1 if min_i == len(ns): return if ns[min_i] == 1: yield 1 / np.sqrt(19) * (13 - 16 * rho) * c_Q min_i += 1 if min_i == len(ns): return # c is the leading term of the recurrence relation for P c = 2 - 4 * rho # P0, P1 are the first two terms of the recurrence relation for auxiliary # polynomial P_n P0 = np.ones_like(x) * 2 P1 = 6 - 8 * rho Pnm2 = P0 Pnm1 = P1 # Q0, Q1 are the first two terms of the recurrence relation for Qbfs_n Q0 = np.ones_like(x) Q1 = 1 / np.sqrt(19) * (13 - 16 * rho) Qnm2 = Q0 Qnm1 = Q1 for nn in range(2, ns[-1] + 1): Pn = c * Pnm1 - Pnm2 Pnm2 = Pnm1 Pnm1 = Pn g = g_qbfs(nn - 1) h = h_qbfs(nn - 2) f = f_qbfs(nn) Qn = (Pn - g * Qnm1 - h * Qnm2) * ( 1 / f) # small optimization; mul by 1/f instead of div by f Qnm2 = Qnm1 Qnm1 = Qn if ns[min_i] == nn: yield Qn * c_Q min_i += 1 if min_i == len(ns): return
def ansi_j_to_nm(idx): """Convert ANSI single term to (n,m) two-term index.""" n = int(np.ceil((-3 + np.sqrt(9 + 8 * idx)) / 2)) m = 2 * idx - n * (n + 2) return n, m
def off_axis_conic_sigma_der(c, kappa, r, t, dx, dy=0): """Derivatives of 1/off_axis_conic_sigma. See Eq. (5.2) of oe-20-3-2483. Parameters ---------- c : float axial curvature of the conic kappa : float conic constant r : numpy.ndarray radial coordinate, where r=0 is centered on the off-axis section t : numpy.ndarray azimuthal coordinate dx : float shift of the surface in x with respect to the base conic vertex, mutually exclusive to dy (only one may be nonzero) use dx=0 when dy != 0 dy : float shift of the surface in y with respect to the base conic vertex Returns ------- numpy.ndarray, numpy.ndarray d/dr(z), d/dt(z) """ if dy != 0 and dx != 0: raise ValueError('only one of dx/dy may be nonzero') cost = np.cos(t) sint = np.sin(t) if dx != 0: s = dx oblique_term = 2 * s * r * cost ddr_oblique = 2 * r + 2 * s * cost # I accept the evil in writing this the way I have # to deduplicate the computation ddt_oblique_ = r * (-s) * sint else: s = dy oblique_term = 2 * s * r * sint ddr_oblique = 2 * r + 2 * s * sint ddt_oblique_ = r * s * cost aggregate_term = r * r + oblique_term + s * s csq = c * c # d/dr first phi_kernel = (1 + kappa) * csq * aggregate_term phi = np.sqrt(1 - phi_kernel) notquitephi_kernel = kappa * csq * aggregate_term notquitephi = np.sqrt(1 + notquitephi_kernel) num = csq * (1 + kappa) * ddr_oblique * notquitephi den = 2 * (1 - phi_kernel)**(3 / 2) term1 = num / den num = csq * kappa * ddr_oblique den = 2 * phi * notquitephi term2 = num / den dr = term1 + term2 # d/dt num = csq * (1 + kappa) * ddt_oblique_ * notquitephi den = (1 - phi_kernel)**(3 / 2) # phi^3? term1 = num / den num = csq * kappa * ddt_oblique_ den = phi * notquitephi term2 = num / den dt = term1 + term2 # minus in writing, but sine/cosine return dr, dt
def off_axis_conic_der(c, kappa, r, t, dx, dy=0): """Radial and azimuthal derivatives of an off-axis conic. Parameters ---------- c : float axial curvature of the conic kappa : float conic constant r : numpy.ndarray radial coordinate, where r=0 is centered on the off-axis section t : numpy.ndarray azimuthal coordinate dx : float shift of the surface in x with respect to the base conic vertex, mutually exclusive to dy (only one may be nonzero) use dx=0 when dy != 0 dy : float shift of the surface in y with respect to the base conic vertex Returns ------- numpy.ndarray, numpy.ndarray d/dr(z), d/dt(z) """ if dy != 0 and dx != 0: raise ValueError('only one of dx/dy may be nonzero') cost = np.cos(t) sint = np.sin(t) if dx != 0: s = dx oblique_term = 2 * s * r * cost ddr_oblique = 2 * r + 2 * s * cost # I accept the evil in writing this the way I have # to deduplicate the computation ddt_oblique_ = r * (-s) * sint ddt_oblique = 2 * ddt_oblique_ else: s = dy oblique_term = 2 * s * r * sint ddr_oblique = 2 * r + 2 * s * sint ddt_oblique_ = r * s * cost ddt_oblique = 2 * ddt_oblique_ aggregate_term = r * r + oblique_term + s * s csq = c * c c3 = csq * c # d/dr first num = c * ddr_oblique phi_kernel = (1 + kappa) * csq * aggregate_term phi = np.sqrt(1 - phi_kernel) phip1 = 1 + phi phip1sq = phip1 * phip1 den = phip1 term1 = num / den num = c3 * (1 + kappa) * ddr_oblique * aggregate_term den = (2 * phi) * phip1sq term2 = num / den dr = term1 + term2 # d/dt num = c * ddt_oblique den = phip1 term1 = num / den num = c3 * (1 + kappa) * ddt_oblique_ * aggregate_term den = phi * phip1sq term2 = num / den dt = term1 + term2 return dr, dt