#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright © 2018 Michael J. Hayford
""" Container class for optical usage information
.. Created on Thu Jan 25 11:01:04 2018
.. codeauthor: Michael J. Hayford
"""
import math
import numpy as np
from rayoptics.parax.firstorder import (compute_first_order,
list_parax_trace_fotr)
from rayoptics.parax import etendue
from rayoptics.parax import idealimager
from rayoptics.raytr.trace import aim_chief_ray
from rayoptics.raytr.wideangle import eval_real_image_ht
import rayoptics.optical.model_constants as mc
from opticalglass.spectral_lines import get_wavelength
import rayoptics.util.colour_system as cs
from rayoptics.util.misc_math import (transpose, normalize, is_kinda_big,
isanumber, rot_v1_into_v2)
import rayoptics.gui.util as gui_util
from rayoptics.util import colors
srgb = cs.cs_srgb
[docs]
class OpticalSpecs:
""" Container class for optical usage information
Contains optical usage information to specify the aperture, field of view,
spectrum and focal position. These can be accessed via the mapping
interface:
- self['wvls']: instance of :class:`~.WvlSpec`
- self['pupil']: instance of :class:`~.PupilSpec`
- self['fov']: instance of :class:`~.FieldSpec`
- self['focus']: instance of :class:`~.FocusRange`
It also maintains a repository of paraxial data.
Attributes:
do_aiming: if True, iterate chief rays to stop center, else entrance pupil
"""
do_aiming_default = True
def __init__(self, opt_model, specsheet=None, **kwargs):
self.opt_model = opt_model
self._submodels = {}
self['wvls'] = WvlSpec(**kwargs)
self['pupil'] = PupilSpec(self)
self['fov'] = FieldSpec(self)
self['focus'] = FocusRange(0.0)
self.do_aiming = OpticalSpecs.do_aiming_default
if specsheet:
self.set_from_specsheet(specsheet)
def __getitem__(self, key):
""" Provide mapping interface to submodels. """
return self._submodels[key]
def __setitem__(self, key, value):
""" Provide mapping interface to submodels. """
self._submodels[key] = value
def __json_encode__(self):
attrs = dict(vars(self))
del attrs['opt_model']
del attrs['_submodels']
del attrs['do_aiming']
attrs['spectral_region'] = self['wvls']
attrs['pupil'] = self['pupil']
attrs['field_of_view'] = self['fov']
attrs['defocus'] = self['focus']
return attrs
def __json_decode__(self, **attrs):
submodels = {}
submodels['wvls'] = attrs['spectral_region']
submodels['pupil'] = attrs['pupil']
submodels['fov'] = attrs['field_of_view']
submodels['focus'] = (attrs['defocus'] if 'defocus' in attrs
else FocusRange(0.0))
self._submodels = submodels
[docs]
def listobj_str(self):
o_str = self["pupil"].listobj_str()
o_str += self["fov"].listobj_str()
o_str += self["wvls"].listobj_str()
o_str += self["focus"].listobj_str()
return o_str
@property
def spectral_region(self):
return self._submodels['wvls']
@spectral_region.setter
def spectral_region(self, sr):
self._submodels['wvls'] = sr
@property
def pupil(self):
return self._submodels['pupil']
@pupil.setter
def pupil(self, pup):
self._submodels['pupil'] = pup
@property
def field_of_view(self):
return self._submodels['fov']
@field_of_view.setter
def field_of_view(self, fov):
self._submodels['fov'] = fov
@property
def defocus(self):
return self._submodels['focus']
@defocus.setter
def defocus(self, foc):
self._submodels['focus'] = foc
[docs]
def set_from_list(self, dl):
self.spectral_region = dl[0]
self.pupil = dl[1]
self.field_of_view = dl[2]
[docs]
def set_from_specsheet(self, ss):
self.spectral_region.set_from_specsheet(ss)
self.pupil.set_from_specsheet(ss)
self.field_of_view.set_from_specsheet(ss)
self.defocus.set_from_specsheet(ss)
[docs]
def sync_to_parax(self, parax_model):
""" Use the parax_model database to update the optical specs. """
self.pupil.sync_to_parax(parax_model)
self.field_of_view.sync_to_parax(parax_model)
[docs]
def conjugate_type(self, space: str='object') -> str:
""" Returns if object or image space is finite or infinite conjugates. """
seq_model = self.opt_model['seq_model']
conj_type = 'finite'
if space == 'object':
if is_kinda_big(seq_model.gaps[0].thi):
conj_type = 'infinite'
elif space == 'image':
if is_kinda_big(seq_model.gaps[-1].thi):
conj_type = 'infinite'
else:
raise ValueError(f"Unrecognized value for space: {space}")
return conj_type
[docs]
def is_afocal(self) -> bool:
""" Returns True if the system is afocal. """
obj_space = self.conjugate_type('object')
fod = self.opt_model['analysis_results']['parax_data'].fod
return obj_space == 'infinite' and abs(fod.efl) < 1e-8
[docs]
def setup_specs_using_dgms(self, pm):
""" Use the parax_model database to update the pupil specs. """
num_nodes = pm.get_num_nodes()
y1_star, ybar0_star = pm.calc_object_and_pupil(0)
yk_star, ybark_star = pm.calc_object_and_pupil(num_nodes-2)
pupil = self['pupil']
aperture_spec = pupil.derive_parax_params()
pupil_oi_key, pupil_key, pupil_value = aperture_spec
if pupil_key == 'slope':
if pupil_oi_key == 'object':
n = pm.sys[0][mc.indx]
slope = pm.ax[0][mc.slp]
pupil_value = pupil.get_aperture_from_slope(slope, n=n)
else:
n = pm.sys[-2][mc.indx]
slope = pm.ax[-2][mc.slp]
pupil_value = pupil.get_aperture_from_slope(slope, n=n)
pupil_key = pupil.key[2]
else:
if is_kinda_big(y1_star): # telecentric, use angular aperture spec
pupil_oi_key = 'object'
pupil_key = 'NA'
n = pm.sys[0][mc.indx]
slope = pm.ax[0][mc.slp]
pupil_value = etendue.slp2na(slope, n=n)
else:
pupil_oi_key = 'object'
pupil_key = 'epd'
pupil_value = 2*y1_star
pupil.key = pupil_oi_key, pupil_key
pupil.value = pupil_value
fov = self['fov']
field_spec = fov.derive_parax_params()
fov_oi_key, field_key, field_value = field_spec
if is_kinda_big(ybar0_star):
if is_kinda_big(ybark_star):
fov_oi_key = 'object'
field_key = 'angle'
n = pm.sys[0][mc.indx]
slope = pm.pr[0][mc.slp]/n
field_value = etendue.slp2ang(slope)
else: # collimated object space
if field_key == 'height':
fov_oi_key = 'image' # force image space height spec for
field_key = 'height' # infinite objects
field_value = ybark_star
else: # field_key == 'angle'
fov_oi_key = fov.key[0]
field_key = 'angle'
oi_idx = 0 if fov_oi_key == 'object' else num_nodes-2
n = pm.sys[oi_idx][mc.indx]
slope = pm.pr[oi_idx][mc.slp]/n
field_value = etendue.slp2ang(slope)
else:
fov_oi_key = 'object'
field_key = 'height'
field_value = ybar0_star
fov.key = fov_oi_key, field_key
fov.value = field_value
[docs]
def sync_to_restore(self, opt_model):
self.opt_model = opt_model
if not hasattr(self, 'do_aiming'):
self.do_aiming = OpticalSpecs.do_aiming_default
self['wvls'].sync_to_restore(self)
self['pupil'].sync_to_restore(self)
self['fov'].sync_to_restore(self)
[docs]
def update_model(self, **kwargs):
self.spectral_region.update_model(**kwargs)
self.pupil.update_model(**kwargs)
self.field_of_view.update_model(**kwargs)
[docs]
def update_optical_properties(self, **kwargs):
opm = self.opt_model
sm = opm['seq_model']
if sm.get_num_surfaces() > 2:
src = kwargs.get('src_model', None)
stop = sm.stop_surface
wvl = self.spectral_region.central_wvl
parax_pkg = compute_first_order(opm, stop, wvl, src_model=src)
opm['analysis_results']['parax_data'] = parax_pkg
if self.do_aiming:
for i, fld in enumerate(self.field_of_view.fields):
fld.aim_info = aim_chief_ray(opm, fld, wvl)
[docs]
def apply_scale_factor(self, scale_factor):
self['wvls'].apply_scale_factor(scale_factor)
self['pupil'].apply_scale_factor(scale_factor)
self['fov'].apply_scale_factor(scale_factor)
self['focus'].apply_scale_factor(scale_factor)
[docs]
def ray_start_from_osp(self, pupil, fld, pupil_type:str):
""" turn pupil and field specs into ray start specification.
Args:
pupil: aperture coordinates of ray
fld: instance of :class:`~.Field`
pupil_type: controls how `pupil` data is interpreted
- 'rel pupil': relative pupil coordinates
- 'aim pt': aim point on pupil plane
- 'aim dir': aim direction in object space
"""
pupil_oi_key, pupil_value_key = self['pupil'].key
pupil_value = self['pupil'].value
fov_oi_key, fov_value_key = self['fov'].key
p0, d0 = self.obj_coords(fld)
opt_model = self.opt_model
fod = opt_model['analysis_results']['parax_data'].fod
# if image space specification, swap in the corresponding first order
# object space parameter
if pupil_oi_key == 'image':
if abs(fod.m) < 1e-10: # infinite object distance
if 'epd' == pupil_value_key:
pupil_value = 2*fod.enp_radius
else:
pupil_value_key = 'epd'
pupil_value = 2*fod.enp_radius
else: # finite conjugate
if abs(fod.enp_dist) > 1e10: # telecentric entrance pupil
pupil_value_key = 'NA'
pupil_value = fod.obj_na
else:
pupil_value_key = 'epd'
pupil_value = 2*fod.enp_radius
aim_info = None
if hasattr(fld, 'aim_info') and fld.aim_info is not None:
aim_info = fld.aim_info
z_enp = fod.enp_dist
# generate starting pt and dir depending on whether the pupil spec is
# spatial or angular
if 'epd' == pupil_value_key:
if pupil_type == 'aim pt':
pt0 = p0
pt1 = np.array([pupil[0], pupil[1],
fod.obj_dist + z_enp])
else:
eprad = pupil_value/2
if self['fov'].is_wide_angle:
# transform pupil_pt, in direction coords into surf#1 coordinates
pupil_pt = eprad * np.array([pupil[0], pupil[1], 0.])
rot_mat_d2s = rot_v1_into_v2(d0, np.array([0., 0., 1.]))
pt1 = np.matmul(rot_mat_d2s, pupil_pt)
if aim_info is not None:
z_enp = aim_info
obj2enp_dist = -(fod.obj_dist + z_enp)
# rotate the on-axis object pt into the incident direction
# and then position wrt z_enp
enp_pt = np.array([0., 0., obj2enp_dist])
rot_mat_s2d = rot_v1_into_v2(np.array([0., 0., 1.]), d0)
pt0 = np.matmul(rot_mat_s2d, enp_pt) - enp_pt
pt1[2] -= obj2enp_dist
else:
aim_pt = aim_info
obj2enp_dist = -(fod.obj_dist + z_enp)
pt1 = np.array([eprad*pupil[0]+aim_pt[0],
eprad*pupil[1]+aim_pt[1],
fod.obj_dist+z_enp])
pt0 = obj2enp_dist*np.array([d0[0]/d0[2], d0[1]/d0[2], 0.])
dir0 = normalize(pt1 - pt0)
else: # an angular based measure
if pupil_type == 'aim dir':
dir_tot = pupil
pt0 = p0
else:
if 'NA' in pupil_value_key:
na = pupil_value
slope = etendue.na2slp(na)
elif 'f/#' in pupil_value_key:
fno = pupil_value
slope = -1/(2*fno)
hypt = np.sqrt(1 + (pupil[0]*slope)**2 + (pupil[1]*slope)**2)
pupil_dir = np.array([slope*pupil[0]/hypt,
slope*pupil[1]/hypt])
pt0 = p0
if d0 is not None:
cr_dir = d0[:2]
else:
aim_pt = aim_info
pt1 = np.array([aim_pt[0], aim_pt[1],
fod.obj_dist+fod.enp_dist])
cr_dir = normalize(pt1 - pt0)[:2]
dir_tot = pupil_dir + cr_dir
dir0 = np.array([dir_tot[0], dir_tot[1],
np.sqrt(1 - np.dot(dir_tot, dir_tot))])
return pt0, dir0
[docs]
def lookup_fld_wvl_focus(self, fi, wl=None, fr=0.0):
""" returns field, wavelength and defocus data
Args:
fi (int): index into the field_of_view list of Fields
wl (int): index into the spectral_region list of wavelengths
fr (float): focus range parameter, -1.0 to 1.0
Returns:
(**fld**, **wvl**, **foc**)
- **fld** - :class:`Field` instance for field_of_view[fi]
- **wvl** - wavelength in nm
- **foc** - focus shift from image interface
"""
if wl is None:
wvl = self.spectral_region.central_wvl
else:
wvl = self.spectral_region.wavelengths[wl]
fld = self.field_of_view.fields[fi]
foc = self.defocus.get_focus(fr)
return fld, wvl, foc
[docs]
def obj_coords(self, fld):
return self.field_of_view.obj_coords(fld)
[docs]
def list_first_order_data(self):
self.opt_model['parax_model'].first_order_data()
[docs]
def list_parax_trace(self, **kwargs):
list_parax_trace_fotr(self.opt_model, **kwargs)
[docs]
class WvlSpec:
""" Class defining a spectral region
A spectral region is a list of wavelengths (in nm) and corresponding
weights. The central wavelength of the spectral region is central_wvl.
The index into the wavelength list for central_wvl is reference_wvl.
"""
def __init__(self, wlwts=[('d', 1.)], ref_wl=0, do_init=True, **kwargs):
if do_init:
self.set_from_list(wlwts)
else:
self.wavelengths = []
self.spectral_wts = []
self.reference_wvl = ref_wl
self.coating_wvl = 550.0
[docs]
def listobj_str(self):
wvls = self.wavelengths
ref_wvl = self.reference_wvl
o_str = f"central wavelength={wvls[ref_wvl]:10.4f} nm\n"
o_str += "wavelength (weight) ="
for i, wlwt in enumerate(zip(wvls, self.spectral_wts)):
wl, wt = wlwt
comma = "," if i > 0 else ""
ref_mark = "*" if i == ref_wvl else ""
o_str += comma + f"{wl:10.4f} ({wt:5.3f})" + ref_mark
o_str += "\n"
return o_str
@property
def central_wvl(self):
return self.wavelengths[self.reference_wvl]
@central_wvl.setter
def central_wvl(self, wvl):
self.wavelengths[self.reference_wvl] = wvl
[docs]
def set_from_list(self, wlwts):
self.wavelengths = []
self.spectral_wts = []
for wlwt in wlwts:
self.wavelengths.append(get_wavelength(wlwt[0]))
self.spectral_wts.append(wlwt[1])
self.calc_colors()
[docs]
def sync_to_restore(self, optical_spec):
self.calc_colors()
[docs]
def set_from_specsheet(self, ss):
pass
[docs]
def update_model(self, **kwargs):
self.calc_colors()
[docs]
def apply_scale_factor(self, scale_factor):
pass
[docs]
def add(self, wl, wt):
self.wavelengths.append(get_wavelength(wl))
self.spectral_wts.append(wt)
self.sort_spectrum()
[docs]
def sort_spectrum(self):
spectrum = [[wl, wt] for wl, wt in zip(self.wavelengths,
self.spectral_wts)]
spectrum.sort(key=lambda w: w[0])
spectrumT = transpose(spectrum)
self.wavelengths = spectrumT[0]
self.spectral_wts = spectrumT[1]
[docs]
def calc_colors(self):
accent = colors.accent_colors()
self.render_colors = []
num_wvls = len(self.wavelengths)
if num_wvls == 1:
self.render_colors.append(accent['green'])
elif num_wvls > 1:
step = 1 if self.wavelengths[0] < self.wavelengths[-1] else -1
if num_wvls == 2:
c = ['blue', 'red']
elif num_wvls == 3:
c = ['blue', 'green', 'red']
elif num_wvls == 4:
c = ['blue', 'green', 'yellow', 'red']
elif num_wvls == 5:
c = ['violet', 'cyan', 'green', 'yellow', 'red']
elif num_wvls == 6:
c = ['violet', 'cyan', 'green', 'yellow', 'red', 'magenta']
else:
c = ['violet', 'blue', 'cyan', 'green', 'yellow',
'red', 'magenta']
self.render_colors = [accent[clr] for clr in c[::step]]
# else:
# for w in self.wavelengths:
# print("calc_colors", w)
# rgb = srgb.wvl_to_rgb(w)
# print("rgb", rgb)
# self.render_colors.append(rgb)
[docs]
class PupilSpec:
""" Aperture specification
Attributes:
key: 'object'|'image', 'epd'|'NA'|'f/#'
value: size of the pupil
pupil_rays: list of relative pupil coordinates for pupil limiting rays
ray_labels: list of string labels for pupil_rays
'pupil' is a deprecated literal pupil_value_type; use 'epd' instead.
"""
default_pupil_rays = [[0., 0.], [1., 0.], [-1., 0.], [0., 1.], [0., -1.]]
default_ray_labels = ['00', '+X', '-X', '+Y', '-Y']
def __init__(self, parent, key=('object', 'epd'), value=1.0):
self.optical_spec = parent
self.set_key_value(key, value)
self.pupil_rays = PupilSpec.default_pupil_rays
self.ray_labels = PupilSpec.default_ray_labels
def __json_encode__(self):
attrs = dict(vars(self))
del attrs['optical_spec']
return attrs
def __json_decode__(self, **attrs):
for a_key, a_val in attrs.items():
if a_key == 'key':
a_key = '_key'
if a_val[2] == 'pupil':
a_val = a_val[0], a_val[1], 'epd'
setattr(self, a_key, a_val)
[docs]
def listobj_str(self):
_key = self._key
o_str = f"{_key[0]}: {_key[1]} {_key[2]}; value={self.value:#10.6g}\n"
return o_str
@property
def key(self):
""" ('object'|'image', 'epd'|'NA'|'f/#') """
return self._key[1], self._key[2]
@key.setter
def key(self, k):
obj_img_key, value_key = k
if value_key == 'pupil':
print("'pupil' deprecated; use 'epd' instead.")
value_key = 'epd'
self._key = 'aperture', obj_img_key, value_key
[docs]
def set_key_value(self, key, value):
""" Set aperture keys and value for the pupil specification. """
self.key = key
self.value = value
return self
[docs]
def sync_to_parax(self, parax_model):
""" Use the parax_model database to update the pupil specs. """
pupil_oi_key, pupil_value_key = self.key
num_nodes = parax_model.get_num_nodes()
if pupil_oi_key == 'object':
idx = 0
else: # obj_img_key == 'image'
idx = num_nodes-2
n = parax_model.sys[idx][mc.indx]
slope = parax_model.ax[idx][mc.slp]
y_star, ybar_star = parax_model.calc_object_and_pupil(idx)
if y_star == np.inf: # telecentric, use angular aperture spec
pupil_value_key = 'NA'
if 'NA' in pupil_value_key:
pupil_value = etendue.slp2na(slope, n=n)
elif 'f/#' in pupil_value_key:
pupil_value = -1/(2*slope)
elif 'epd' == pupil_value_key:
pupil_value = 2*y_star
self.value = pupil_value
[docs]
def derive_parax_params(self):
""" return pupil spec as paraxial height or slope value. """
pupil_oi_key, pupil_value_key = self.key
pupil_value = self.value
if 'NA' in pupil_value_key:
na = pupil_value
slope = etendue.na2slp(na)
pupil_key = 'slope'
pupil_value = slope
elif 'f/#' in pupil_value_key:
fno = pupil_value
slope = -1/(2*fno)
pupil_key = 'slope'
pupil_value = slope
if pupil_value_key == 'epd':
height = pupil_value/2
pupil_key = 'height'
pupil_value = height
return pupil_oi_key, pupil_key, pupil_value
[docs]
def get_aperture_from_slope(self, slope, n=1):
if slope == 0:
return 0
if self.key[1] == 'f/#':
value = -1/(2*slope)
elif self.key[1] == 'NA':
value = etendue.slp2na(slope, n=n)
return value
[docs]
def sync_to_restore(self, optical_spec):
self.optical_spec = optical_spec
[docs]
def set_from_specsheet(self, ss):
key, self.value = ss.get_etendue_inputs('aperture')
self.key = tuple(key)
[docs]
def update_model(self, **kwargs):
if not hasattr(self, 'pupil_rays'):
self.pupil_rays = PupilSpec.default_pupil_rays
self.ray_labels = PupilSpec.default_ray_labels
[docs]
def apply_scale_factor(self, scale_factor):
obj_img_key, value_key = self.key
if value_key == 'epd' or value_key == 'pupil':
self.value *= scale_factor
[docs]
def mutate_pupil_type(self, ape_key):
obj_img_key, value_key = ape_key
if self.optical_spec is not None:
opm = self.optical_spec.opt_model
if opm['ar']['parax_data'] is not None:
fod = opm['ar']['parax_data'].fod
if obj_img_key == 'object':
if value_key == 'epd':
self.value = 2*fod.enp_radius
elif value_key == 'NA':
self.value = fod.obj_na
elif obj_img_key == 'image':
if value_key == 'f/#':
self.value = fod.fno
elif value_key == 'NA':
self.value = fod.img_na
self.key = ape_key
[docs]
class FieldSpec:
""" Field of view specification
Attributes:
key: 'object'|'image', 'height'|'angle'
value: maximum field, per the key
fields: list of Field instances
is_relative: if True, `fields` are relative to max field
is_wide_angle: if True, aim at real entrance pupil
"""
def __init__(self, parent, key=('object', 'angle'), value=0, flds=None,
index_labels=None, is_relative=False, is_wide_angle=False,
do_init=True, **kwargs):
self.optical_spec = parent
self.set_key_value(key, value)
self.is_relative = is_relative
if index_labels is None:
self.index_labels = []
self.index_label_type = 'auto'
else:
self.index_labels = index_labels
self.index_label_type = 'user'
self.is_wide_angle = is_wide_angle
if do_init:
flds = flds if flds is not None else [0., 1.]
self.set_from_list(flds)
else:
self.fields = []
def __json_encode__(self):
attrs = dict(vars(self))
del attrs['optical_spec']
return attrs
def __json_decode__(self, **attrs):
for a_key, a_val in attrs.items():
if a_key == 'key':
a_key = '_key'
setattr(self, a_key, a_val)
[docs]
def listobj_str(self):
_key = self._key
o_str = f"{_key[0]}: {_key[1]} {_key[2]}; value={self.value:#10.6g}\n"
has_x = False
has_y = False
for fld in self.fields:
if fld.x != 0. and fld.y != 0.:
has_x = True
has_y = True
elif fld.x == 0. and fld.y != 0.:
has_y = True
fmtstr = 'y'
elif fld.x != 0. and fld.y == 0.:
has_x = True
fmtstr = 'x'
else:
fmtstr = ''
if has_x and has_y:
fmtstr = 'xy'
for fld in self.fields:
o_str += fld.listobj_str(format=fmtstr)
o_str += (f"is_relative={self.is_relative}, "
f"is_wide_angle={self.is_wide_angle}\n")
return o_str
@property
def key(self):
""" ('object'|'image', 'height'|'angle') """
return self._key[1], self._key[2]
@key.setter
def key(self, k):
obj_img_key, value_key = k
self._key = 'field', obj_img_key, value_key
@property
def value(self):
return self._value
@value.setter
def value(self, v: float):
self._value = v
[docs]
def set_key_value(self, key, value):
""" Set field keys and value for the fov specification. """
self.key = key
self.value = value
return self
[docs]
def sync_to_parax(self, parax_model):
""" Use the parax_model database to update the field specs. """
fov_oi_key, fov_value_key = self.key
num_nodes = parax_model.get_num_nodes()
if fov_oi_key == 'object':
idx = 0
else: # obj_img_key== 'image'
idx = num_nodes-2
y_star, ybar_star = parax_model.calc_object_and_pupil(idx)
if ybar_star == np.inf:
fov_value_key = 'angle'
if 'height' in fov_value_key:
fov_value = ybar_star
elif 'angle' in fov_value_key:
n = parax_model.sys[idx][mc.indx]
slope = parax_model.pr[idx][mc.slp]/n
fov_value = etendue.slp2ang(slope)
self.key = fov_oi_key, fov_value_key
self.value = fov_value
[docs]
def derive_parax_params(self):
""" return field spec as paraxial height or slope value. """
fov_oi_key, fov_value_key = self.key
# guard against zero as a field spec for parax calc
fov_value = self.value if self.value != 0 else 1.
if 'angle' in fov_value_key:
slope_bar = etendue.ang2slp(fov_value)
field_key = 'slope'
field_value = slope_bar
elif 'height' in fov_value_key:
height_bar = fov_value
field_key = 'height'
field_value = height_bar
elif 'real height' in fov_value_key:
height_bar = fov_value
field_key = 'height'
field_value = height_bar
return fov_oi_key, field_key, field_value
[docs]
def sync_to_restore(self, optical_spec):
for f in self.fields:
f.fov = self
if not hasattr(self, 'is_relative'):
self.is_relative = False
if not hasattr(self, 'value'):
self.value, _ = self.max_field()
if not hasattr(self, 'index_label_type'):
self.index_label_type = 'auto'
if not hasattr(self, 'is_wide_angle'):
self.is_wide_angle = False
self.optical_spec = optical_spec
def __str__(self):
return "key={}, max field={}".format(self.key, self.max_field()[0])
[docs]
def set_from_list(self, flds):
self.fields = [Field(fov=self) for f in range(len(flds))]
for i, f in enumerate(self.fields):
f.y = flds[i]
self.value, _ = self.max_field()
[docs]
def set_from_specsheet(self, ss):
key, value = ss.get_etendue_inputs('field')
if value != 0 and len(self.fields) == 1:
# just one field, add a second one for max value
self.is_relative = True
self.fields.append(Field(x=0, y=1, fov=self))
if not self.is_relative:
fld_scale = 1 if self.value == 0 else value/self.value
for i, f in enumerate(self.fields):
f.x *= fld_scale
f.y *= fld_scale
self.key, self.value = key, value
[docs]
def check_is_wide_angle(self, angle_threshold=45.) -> bool:
""" Checks for object angles greater than the threshold. """
is_wide_angle = False
if (self.key == ('image', 'real height') and
self.optical_spec.conjugate_type('object') == 'infinite'):
is_wide_angle = True
if self.key == ('object', 'angle'):
max_angle, fld_idx = self.max_field()
is_wide_angle = max_angle > angle_threshold
return is_wide_angle
[docs]
def update_model(self, **kwargs):
for f in self.fields:
f.update()
# recalculate max_field and relabel fields.
# relabeling really assumes the fields are radial, specifically,
# y axis only
if self.is_relative:
field_norm = 1
else:
field_norm = 1 if self.value == 0 else 1.0/self.value
if self.index_label_type == 'auto':
self.index_labels = []
for f in self.fields:
if f.x != 0.0:
fldx = '{:5.2f}x'.format(field_norm*f.x)
else:
fldx = ''
if f.y != 0.0:
fldy = '{:5.2f}y'.format(field_norm*f.y)
else:
fldy = ''
self.index_labels.append(fldx + fldy)
self.index_labels[0] = 'axis'
if len(self.index_labels) > 1:
self.index_labels[-1] = 'edge'
return self
[docs]
def apply_scale_factor(self, scale_factor):
obj_img_key, value_key = self.key
if value_key == 'height':
if not self.is_relative:
for f in self.fields:
f.apply_scale_factor(scale_factor)
self.value *= scale_factor
[docs]
def mutate_field_type(self, fld_key):
obj_img_key, value_key = fld_key
if self.optical_spec is not None:
opm = self.optical_spec.opt_model
if opm['ar']['parax_data'] is not None:
parax_data = opm['ar']['parax_data']
fod = parax_data.fod
if obj_img_key == 'object':
if value_key == 'height':
self.value = parax_data.pr_ray[0][mc.ht]
elif value_key == 'angle':
self.value = fod.obj_ang
elif obj_img_key == 'image':
if value_key == 'height':
self.value = fod.img_ht
self.key = fld_key
[docs]
def obj_coords(self, fld):
""" Return a pt, direction pair characterizing `fld`.
If a field point is defined in image space, the paraxial object
space data is used to calculate the field coordinates.
"""
def hypot(dir_tan):
return np.sqrt(1 + dir_tan[0]**2 + dir_tan[1]**2)
obj_img_key, value_key = self.key
fld_coord = np.array([fld.x, fld.y, 0.0])
rel_fld_coord = np.array([fld.x, fld.y, 0.0])
if self.is_relative:
fld_coord *= self.value
else:
if self.value != 0:
rel_fld_coord /= self.value
opt_model = self.optical_spec.opt_model
ax, pr, fod = opt_model['ar']['parax_data']
obj_pt = None
obj_dir = None
obj2enp_dist = -(fod.obj_dist + fod.enp_dist)
pt1 = np.array([0., 0., obj2enp_dist])
obj_conj = self.optical_spec.conjugate_type('object')
if obj_conj == 'infinite':
# generate 'object', 'angle' fld_spec
if obj_img_key == 'image':
if value_key == 'real height':
wvl = self.optical_spec['wvls'].central_wvl
pkg = eval_real_image_ht(opt_model, fld, wvl)
(obj_pt, obj_dir), z_enp = pkg
fld.aim_info = z_enp
return obj_pt, obj_dir
else:
max_field_ang = math.atan(pr[0][mc.slp])
fld_angle = max_field_ang*rel_fld_coord
else: # obj_img_key == 'object'
if value_key == 'angle':
fld_angle = np.deg2rad(fld_coord)
else:
obj_pt = fld_coord
obj_dir = normalize(pt1 - obj_pt)
return obj_pt, obj_dir
dir_cos = np.sin(fld_angle)
dir_cos[2] = np.sqrt(1 - dir_cos[0]**2 - dir_cos[1]**2)
if self.is_wide_angle:
rot_mat = rot_v1_into_v2(np.array([0., 0., 1.]), dir_cos)
obj_pt = np.matmul(rot_mat, pt1) - pt1
else:
obj_pt = obj2enp_dist * np.array([dir_cos[0]/dir_cos[2],
dir_cos[1]/dir_cos[2], 0.0])
obj_dir = dir_cos
elif obj_conj == 'finite':
if obj_img_key == 'image':
max_field_ht = pr[0][mc.ht]
obj_pt = max_field_ht*rel_fld_coord
else: # obj_img_key == 'object'
if value_key == 'angle':
fld_angle = np.deg2rad(fld_coord)
obj_dir = np.sin(fld_angle)
obj_dir[2] = np.sqrt(1 - obj_dir[0]**2 - obj_dir[1]**2)
obj_pt = obj2enp_dist * np.array([obj_dir[0]/obj_dir[2],
obj_dir[1]/obj_dir[2],
0.0])
return obj_pt, obj_dir
else:
obj_pt = fld_coord
obj_dir = normalize(pt1 - obj_pt)
return obj_pt, obj_dir
[docs]
def max_field(self):
""" calculates the maximum field of view
Returns:
magnitude of maximum field, maximum Field instance
"""
max_fld = None
max_fld_sqrd = -1.0
for i, f in enumerate(self.fields):
fld_sqrd = f.x*f.x + f.y*f.y
if fld_sqrd > max_fld_sqrd:
max_fld_sqrd = fld_sqrd
max_fld = i
max_fld_value = math.sqrt(max_fld_sqrd)
if self.is_relative:
max_fld_value *= self.value
return max_fld_value, max_fld
[docs]
def clear_vignetting(self):
""" Reset the vignetting to 0 for all fields. """
for f in self.fields:
f.clear_vignetting()
[docs]
class Field:
""" a single field point, chief ray pkg and pupil limits
The Field class manages several types of data:
- the field coordinates, unscaled and fractional
- aim info for tracing through the stop surface
- the vignetting factors for the pupil definition
- pkgs for the chief ray and reference sphere
The Field can have a reference to a fov/FieldSpec (recommended!) which is used to support the fractional and value interfaces simultaneously. If
no fov is given, a max_field may be specified, with the default being unit field size.
Attributes:
vux: +x vignetting factor
vuy: +y vignetting factor
vlx: -x vignetting factor
vly: -y vignetting factor
wt: field weight
aim_info: x, y chief ray coords on the paraxial entrance pupil plane,
or z_enp for wide angle fovs
chief_ray: ray package for the ray from the field point throught the
center of the aperture stop, traced in the central
wavelength
ref_sphere: a tuple containing (image_pt, ref_dir, ref_sphere_radius)
fov: :class:`~.FieldSpec` to be used as reference or None
"""
def __init__(self, x: float=0., y: float=0., wt: float=1.,
fov=None, max_field=None):
self.x = x
self.y = y
self.vux: float = 0.0
self.vuy: float = 0.0
self.vlx: float = 0.0
self.vly: float = 0.0
self.wt: float = wt
self.aim_info = None
self.chief_ray = None
self.ref_sphere = None
self.fov = fov
self._max_field = max_field
# field coordinates, per self.fov.is_relative
@property
def x(self) -> float:
""" the x field value. """
return self._x
@x.setter
def x(self, x_val: float):
""" sets the x field value to x_val. """
self._x = x_val
@property
def y(self) -> float:
""" the y field value. """
return self._y
@y.setter
def y(self, y_val: float):
""" sets the y field value to y_val. """
self._y = y_val
# value attribute access
@property
def xv(self) -> float:
""" the unscaled x field value. """
return self._get_x_by_fref() if self.is_relative else self._x
@xv.setter
def xv(self, x_val: float):
""" sets the x field value to the unscaled x field value, x_val. """
self._x = self._set_x_by_fref(x_val) if self.is_relative else x_val
@property
def yv(self) -> float:
""" the unscaled y field value. """
return self._get_y_by_fref() if self.is_relative else self._y
@yv.setter
def yv(self, y_val: float):
""" sets the y field value to the unscaled y field value, y_val. """
self._y = self._set_y_by_fref(y_val) if self.is_relative else y_val
# fractional attribute access
@property
def xf(self) -> float:
""" the fractional x field value. """
return self._x if self.is_relative else self._get_x_by_vref()
@xf.setter
def xf(self, x_fract: float):
""" sets the x field value to the fractional x field value, x_fract. """
self._x = x_fract if self.is_relative else self._set_x_by_vref(x_fract)
@property
def yf(self) -> float:
""" the fractional y field value. """
return self._y if self.is_relative else self._get_y_by_vref()
@yf.setter
def yf(self, y_fract: float):
""" sets the y field value to the fractional y field value, y_fract. """
self._y = y_fract if self.is_relative else self._set_y_by_vref(y_fract)
# dispatch routines
def _get_x_by_vref(self) -> float:
return self._x / self.max_field
def _set_x_by_vref(self, x_fract: float):
return x_fract * self.max_field
def _get_y_by_vref(self) -> float:
return self._y / self.max_field
def _set_y_by_vref(self, y_fract: float):
return y_fract * self.max_field
def _get_x_by_fref(self) -> float:
return self._x * self.max_field
def _set_x_by_fref(self, xval: float):
return xval / self.max_field
def _get_y_by_fref(self) -> float:
return self._y * self.max_field
def _set_y_by_fref(self, yval: float):
return yval / self.max_field
@property
def max_field(self) -> float:
""" the maximum field value used for the fractional field calculation. """
if self.fov is not None:
return self.fov.value
if self._max_field is not None:
if isanumber(self._max_field):
return self._max_field
elif callable(self._max_field):
return self._max_field()
return 1.
@max_field.setter
def max_field(self, val: float):
self._max_field = val
@property
def is_relative(self):
""" if True, x and y are normalized by max_field, else they are unscaled. """
if self.fov is not None:
return self.fov.is_relative
else:
return False
#
def __json_encode__(self):
attrs = dict(vars(self))
items = ['chief_ray', 'ref_sphere', 'pupil_rays', 'fov']
for item in items:
if item in attrs:
del attrs[item]
if '_max_field' in attrs:
if not isanumber(attrs['_max_field']):
del attrs['_max_field']
return attrs
def __json_decode__(self, **attrs):
for a_key, a_val in attrs.items():
if a_key == 'aim_pt':
a_key = 'aim_info'
setattr(self, a_key, a_val)
def __str__(self):
return "{}, {}".format(self.x, self.y)
def __repr__(self):
return "Field(x={}, y={}, wt={})".format(self.x, self.y, self.wt)
[docs]
def listobj_str(self, format='xy'):
if format == 'x':
o_str = (f"x ={self.xv:7.3f} ({self.xf:5.2f})"
f" vlx={self.vlx:6.3f} vux={self.vux:6.3f}"
f" vly={self.vly:6.3f} vuy={self.vuy:6.3f}\n")
elif format == 'y':
o_str = (f"y ={self.yv:7.3f} ({self.yf:5.2f})"
f" vlx={self.vlx:6.3f} vux={self.vux:6.3f}"
f" vly={self.vly:6.3f} vuy={self.vuy:6.3f}\n")
elif format == '':
o_str = (f"x,y={self.yv:4.2f}"
f" vlx={self.vlx:6.3f} vux={self.vux:6.3f}"
f" vly={self.vly:6.3f} vuy={self.vuy:6.3f}\n")
else: # 'xy' or anything else
o_str = (f"xy=({self.xv:7.3f}, {self.yv:7.3f})"
f" ({self.xf:5.2f}, {self.yf:5.2f})"
f" vlx={self.vlx:6.3f} vux={self.vux:6.3f}"
f" vly={self.vly:6.3f} vuy={self.vuy:6.3f}\n")
return o_str
[docs]
def update(self):
self.aim_info = None
self.chief_ray = None
self.ref_sphere = None
[docs]
def apply_scale_factor(self, scale_factor: float):
self.x *= scale_factor
self.y *= scale_factor
[docs]
def vignetting_bbox(self, pupil_spec: PupilSpec, oversize=1.02):
""" returns a bbox of the vignetted pupil ray extents. """
poly = []
for pup_ray in pupil_spec.pupil_rays:
vig_pup_ray = self.apply_vignetting(pup_ray)
poly.append(vig_pup_ray)
vig_bbox = oversize*gui_util.bbox_from_poly(poly)
return vig_bbox
[docs]
def clear_vignetting(self):
""" Resets vignetting values to 0. """
self.vux = self.vuy = self.vlx = self.vly = 0.
[docs]
def apply_vignetting(self, pupil):
vig_pupil = pupil[:]
if pupil[0] < 0.0:
if self.vlx != 0.0:
vig_pupil[0] *= (1.0 - self.vlx)
else:
if self.vux != 0.0:
vig_pupil[0] *= (1.0 - self.vux)
if pupil[1] < 0.0:
if self.vly != 0.0:
vig_pupil[1] *= (1.0 - self.vly)
else:
if self.vuy != 0.0:
vig_pupil[1] *= (1.0 - self.vuy)
return vig_pupil
[docs]
class FocusRange:
""" Focus range specification
Attributes:
focus_shift: focus shift (z displacement) from nominal image interface
defocus_range: +/- half the total focal range, from the focus_shift
position
"""
def __init__(self, focus_shift=0.0, defocus_range=0.0):
self.focus_shift = focus_shift
self.defocus_range = defocus_range
def __repr__(self):
return ("FocusRange(focus_shift={}, defocus_range={})"
.format(self.focus_shift, self.defocus_range))
[docs]
def listobj_str(self):
o_str = f"focus shift={self.focus_shift}"
o_str += (f", defocus range={self.defocus_range}\n"
if self.defocus_range != 0. else "\n")
return o_str
[docs]
def set_from_specsheet(self, ss):
pass
[docs]
def apply_scale_factor(self, scale_factor):
self.focus_shift *= scale_factor
self.defocus_range *= scale_factor
[docs]
def get_focus(self, fr=0.0):
""" return focus position for input focus range parameter
Args:
fr (float): focus range parameter, -1.0 to 1.0
Returns:
focus position for input focus range parameter
"""
return self.focus_shift + fr*self.defocus_range