Source code for rayoptics.optical.opticalmodel

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright © 2018 Michael J. Hayford
""" Top level model classes

.. Created on Wed Mar 14 11:08:28 2018

.. codeauthor: Michael J. Hayford
"""
import os.path
import json_tricks
from collections.abc import Sequence
from pathlib import Path

import rayoptics

import rayoptics.elem.elements as ele
import rayoptics.optical.model_constants as mc

#from rayoptics.elem import elements
from rayoptics.elem import parttree
from rayoptics.elem.parttree import (PartTree, elements_from_sequence)
from rayoptics.parax.paraxialdesign import ParaxialModel
from rayoptics.seq.sequential import SequentialModel
from rayoptics.raytr.opticalspec import OpticalSpecs
from rayoptics.parax.specsheet import create_specsheet_from_model
from rayoptics.optical.model_enums import get_dimension_for_type


[docs]class SystemSpec: """ Container for units and other system level constants Attributes: title (str): a short description of the model initials (str): user initials or other id temperature (float): model temperature in degrees Celsius pressure (float): model pressure in mm/Hg """ def __init__(self, opt_model, **kwargs): self.opt_model = opt_model self.title = '' self.initials = '' self.dimensions = 'mm' self.temperature = 20.0 self.pressure = 760.0 def __json_encode__(self): attrs = dict(vars(self)) if hasattr(self, 'opt_model'): del attrs['opt_model'] return attrs def __json_decode__(self, **attrs): for a_key, a_val in attrs.items(): if a_key == 'dimensions': self._dimensions = (a_val if isinstance(a_val, str) else get_dimension_for_type(a_val)) else: setattr(self, a_key, a_val)
[docs] def listobj_str(self): vs = vars(self) o_str = f"{type(self).__name__}:\n" for k, v in vs.items(): o_str += f"{k}: {v}\n" return o_str
@property def dimensions(self): """ the model linear units (str). """ return self._dimensions @dimensions.setter def dimensions(self, value): self._dimensions = (value if isinstance(value, str) else get_dimension_for_type(value))
[docs] def nm_to_sys_units(self, nm): """ convert nm to system units Args: nm (float): value in nm Returns: float: value converted to system units """ if self.dimensions == 'm': return 1e-9 * nm elif self.dimensions == 'cm': return 1e-7 * nm elif self.dimensions == 'mm': return 1e-6 * nm elif self.dimensions == 'in': return 1e-6 * nm/25.4 elif self.dimensions == 'ft': return 1e-6 * nm/304.8 else: return nm
[docs]class OpticalModel: """ Top level container for optical model. The OpticalModel serves as a top level container of model properties. Key aspects are built-in element and surface based repesentations of the optical surfaces. A sequential optical model is a sequence of surfaces and gaps. Additionally, it includes optical usage information to specify the aperture, field of view, spectrum and focus. Attributes: ro_version: current version of rayoptics radius_mode: if True output radius, else output curvature specsheet: :class:`~rayoptics.parax.specsheet.SpecSheet` system_spec: :class:`.SystemSpec` seq_model: :class:`~rayoptics.seq.sequential.SequentialModel` optical_spec: :class:`~rayoptics.raytr.opticalspec.OpticalSpecs` parax_model: :class:`~rayoptics.parax.paraxialdesign.ParaxialModel` ele_model: :class:`~rayoptics.elem.elements.ElementModel` """ def __init__(self, radius_mode=False, specsheet=None, **kwargs): self.ro_version = rayoptics.__version__ self.radius_mode = radius_mode self.map_submodels(specsheet=specsheet, **kwargs) if self.specsheet: self.set_from_specsheet() if kwargs.get('do_init', True): # need to do this after OpticalSpec is initialized self.seq_model.update_model() elements_from_sequence(self.ele_model, self.seq_model, self.part_tree)
[docs] def map_submodels(self, **kwargs): """Setup machinery for model mapping api. This function performs two tasks: - populating the submodel `dict` either with attributes or creating a new instance as needed. - populate a submodel alias `dict` with short versions of the wordy defining names The first task must handle these use cases: - opt_model populated by importing a .roa file - an opt_model created interactively - an opt_model initialized by a lens file importer """ submodels = {} specsheet = kwargs.pop('specsheet', None) submodels['specsheet'] = self.specsheet = (self.specsheet if hasattr(self, 'specsheet') else specsheet) submodels['system_spec'] = self.system_spec = (self.system_spec if hasattr(self, 'system_spec') else SystemSpec(self, **kwargs)) submodels['seq_model'] = self.seq_model = (self.seq_model if hasattr(self, 'seq_model') else SequentialModel(self, **kwargs)) submodels['optical_spec'] = self.optical_spec = (self.optical_spec if hasattr(self, 'optical_spec') else OpticalSpecs(self, specsheet=specsheet, **kwargs)) submodels['parax_model'] = self.parax_model = (self.parax_model if hasattr(self, 'parax_model') else ParaxialModel(self, **kwargs)) submodels['ele_model'] = self.ele_model = (self.ele_model if hasattr(self, 'ele_model') else ele.ElementModel(self, **kwargs)) submodels['part_tree'] = self.part_tree = (self.part_tree if hasattr(self, 'part_tree') else PartTree(self, **kwargs)) submodels['analysis_results'] = self.analysis_results = \ (self.analysis_results if hasattr(self, 'analysis_results') else {'parax_data': None}) # Add a level of indirection to allow short and long aliases submodel_aliases = { 'ss': 'specsheet', 'specsheet': 'specsheet', 'sys': 'system_spec', 'system_spec': 'system_spec', 'sm': 'seq_model', 'seq_model': 'seq_model', 'osp': 'optical_spec', 'optical_spec': 'optical_spec', 'pm': 'parax_model', 'parax_model': 'parax_model', 'em': 'ele_model', 'ele_model': 'ele_model', 'pt': 'part_tree', 'part_tree': 'part_tree', 'ar': 'analysis_results', 'analysis_results': 'analysis_results', } self._submodels = submodels, submodel_aliases
def __getitem__(self, key): """ Provide mapping interface to submodels. """ submodels, submodel_aliases = self._submodels return submodels[submodel_aliases[key]]
[docs] def name(self): return self.system_spec.title
[docs] def reset(self): rdm = self.radius_mode self.__init__() self.radius_mode = rdm
def __json_encode__(self): attrs = dict(vars(self)) if hasattr(self, 'app_manager'): del attrs['app_manager'] # not sure about saving analysis_results... if hasattr(self, 'analysis_results'): del attrs['analysis_results'] del attrs['_submodels'] return attrs
[docs] def listobj_str(self): vs = vars(self) o_str = f"{type(self).__name__}:\n" for k, v in vs.items(): o_str += f"{k}: {v}\n" return o_str
[docs] def set_from_specsheet(self, specsheet=None): if specsheet: self.specsheet = specsheet else: specsheet = self.specsheet self.optical_spec.set_from_specsheet(specsheet) self.seq_model.set_from_specsheet(specsheet)
[docs] def save_model(self, file_name, version=None): """Save the optical_model in a ray-optics JSON file. Args: file_name: str or Path version: optional override for rayoptics version number """ file_pth = Path(file_name).with_suffix('.roa') # Ensure the parent directory exists if not file_pth.parent.exists(): file_pth.parent.mkdir(parents=True) # update version number prior to writing file. self.ro_version = rayoptics.__version__ if version is None else version self.profile_dict = self._build_profile_dict() self.parts_dict = {id(p):p for p in self.ele_model.elements} fs_dict = {} fs_dict['optical_model'] = self with open(file_pth, 'w') as f: json_tricks.dump(fs_dict, f, indent=1, separators=(',', ':'), allow_nan=True) delattr(self, 'profile_dict') delattr(self, 'parts_dict')
def _build_profile_dict(self): """ build a profile dict for the union of the seq_model and part_tree. """ profile_dict = {} for ifc in self.seq_model.ifcs: if hasattr(ifc, 'profile') and ifc.profile is not None: profile_dict[str(id(ifc.profile))] = ifc.profile profile_nodes = self.part_tree.nodes_with_tag(tag='#profile') for profile_node in profile_nodes: profile = profile_node.id profile_id = str(id(profile)) if profile_id not in profile_dict: profile_dict[profile_id] = profile print(f"found new profile in part_tree: " f"{profile_node.parent.name}.{profile_node.name}") return profile_dict
[docs] def sync_to_restore(self): if not hasattr(self, 'ro_version'): self.ro_version = rayoptics.__version__ self.profile_dict = (self.profile_dict if hasattr(self, 'profile_dict') else {}) self.parts_dict = (self.parts_dict if hasattr(self, 'parts_dict') else {}) self.map_submodels() self.seq_model.sync_to_restore(self) self.ele_model.sync_to_restore(self) self.optical_spec.sync_to_restore(self) self.parax_model.sync_to_restore(self) if self.specsheet is not None: self.specsheet.sync_to_restore(self) if self.part_tree.is_empty(): self.part_tree.add_element_model_to_tree(self.ele_model) else: self.part_tree.sync_to_restore(self) self.update_model() # delete the profile_dict used for save/restore fidelity # the idea is one instance of an object is saved to a dictionary, # keyed to the original object's id. each class that uses the original # object save the object id, instead of the object. During the restore # process, sync_to_restore can use the saved profile id to lookup # the restored profile in the profile_dict. delattr(self, 'profile_dict') delattr(self, 'parts_dict')
[docs] def update_model(self, **kwargs): """Model and its constituents are updated. Args: kwargs: possible keyword arguments including: - build: - 'rebuild': rebuild the model "from scratch", e.g number of nodes changes - 'update': number of nodes unchanged, just the parameters - src_model: model that originated the modification """ self['seq_model'].update_model(**kwargs) self['optical_spec'].update_model(**kwargs) self.update_optical_properties(**kwargs) sm = self['seq_model'] em = self['ele_model'] pt = self['part_tree'] # generate elements if there are standalone interfaces (or no elements) if (len(pt.nodes_with_tag(tag='#element')) == 0 or len([ifc for ifc in sm.ifcs if pt.parent_node(ifc) is None]) > 0): elements_from_sequence(em, sm, pt) self['ele_model'].update_model(**kwargs) self['part_tree'].update_model(**kwargs) if self.specsheet is None: self.specsheet = create_specsheet_from_model(self) self.map_submodels()
[docs] def update_optical_properties(self, **kwargs): """Compute first order and other optical properties. """ # OpticalSpec maintains first order and ray aiming for fields self['optical_spec'].update_optical_properties(**kwargs) # Update the ParaxialModel as needed self['parax_model'].update_model(**kwargs) # Update surface apertures, if requested (do_apertures=True) self['seq_model'].update_optical_properties(**kwargs)
[docs] def nm_to_sys_units(self, nm): """ convert nm to system units Args: nm (float): value in nm Returns: float: value converted to system units """ return self.system_spec.nm_to_sys_units(nm)
[docs] def add_part(self, factory_fct, *args, **kwargs): """Use a factory_fct to create a new part and insert into optical model. """ descriptor = factory_fct(*args, **kwargs) kwargs['insert'] = True self.insert_ifc_gp_ele(*descriptor, **kwargs)
[docs] def add_lens(self, **kwargs): """ Add a lens into the optical model Args: kwargs: keyword arguments including: - idx: insertion point in the sequential model - t: the thickness following a chunk when inserting - lens: tuple of `cv1, cv2, th, glass_name_catalog, sd` where: - cv1: front curvature - cv2: rear curvature - th: lens thickness - glass_input: a str, e.g. 'N-BK7, Schott' or refractive index or index/V-number pair - sd: lens semi-diameter """ self.add_part(ele.create_lens, **kwargs)
[docs] def add_mirror(self, **kwargs): self.add_part(ele.create_mirror, **kwargs)
[docs] def add_thinlens(self, **kwargs): self.add_part(ele.create_thinlens, **kwargs)
[docs] def add_dummy_plane(self, **kwargs): self.add_part(ele.create_dummy_plane, **kwargs)
[docs] def add_from_file(self, filename, **kwargs): self.add_part(ele.create_from_file, filename, **kwargs)
[docs] def add_assembly_from_seq(self, idx1, idx2, **kwargs): """ Create an Assembly from the elements in the sequence range. """ pt = self['part_tree'] ifc1 = self['seq_model'].ifcs[idx1] # record where to insert asm_node in root_node.children start_idx = pt.root_node.children.index(pt.node(ifc1).ancestors[1]) asm, asm_node = ele.create_assembly_from_seq(self, idx1, idx2, **kwargs) self['ele_model'].add_element(asm) # use anytree mechanism to add asm_node to tree asm_node.parent = pt.root_node root_children = list(pt.root_node.children) # move asm_node to desired spot and update root's children root_children.remove(asm_node) root_children.insert(start_idx, asm_node) pt.root_node.children = root_children
[docs] def rebuild_from_seq(self): """ Rebuild ele_model and part_tree from seq_model. When in doubt about whether there is a problem with bad data in an OpticalModel, this function can be used to rebuild everything from the sequential model. """ self['em'].elements = [] self['pt'].root_node.children = [] elements_from_sequence(self['em'], self['sm'], self['pt'])
[docs] def flip(self, *args, **kwargs): """ Flip a `Part` or an `Interface` range in the optical model. The flip operation supports several different ways of specifying what is to be flipped. Args: - None: This flips the model from `ifc 1` to `ifc image-1` - idx1, idx2: This flips the model between interfaces idx1 and idx2. Flipping from object to image is disallowed. - part: This flips the corresponding part in the model - list: This flips a list of parts in the model """ import numpy as np rot_around_x = np.array([[1, 0, 0], [0, -1, 0], [0, 0, -1]]) rot_around_y = np.array([[-1, 0, 0], [0, 1, 0], [0, 0, -1]]) sm = self['seq_model'] osp = self['optical_spec'] em = self['ele_model'] pt = self['part_tree'] if len(args) == 0: # default behavior: flip 1st to image-1 interfaces args = 1, len(sm.gaps)-1 if isinstance(args[0], int): # flip a range of interfaces/gaps # disallow flipping from object to image idx1 = args[0] if args[0] > 0 else 1 idx2 = args[1] if args[1] < len(sm.gaps) else len(sm.gaps)-1 part_list, node_list = parttree.part_list_from_seq(self, idx1, idx2) flip_pt = 0.5*(sm.gbl_tfrms[idx2][1] + sm.gbl_tfrms[idx1][1]) flip_pt_tfrm = sm.gbl_tfrms[idx1][0], flip_pt ele.do_flip_with_part_list(part_list, flip_pt_tfrm) elif isinstance(args[0], list): # flip a list of parts part_list = args[0] idxs = [] for p in part_list: idxs += p.idx_list() idx1, idx2 = idxs[0], idxs[-1] flip_pt = 0.5*(sm.gbl_tfrms[idx2][1] + sm.gbl_tfrms[idx1][1]) flip_pt_tfrm = sm.gbl_tfrms[idx1][0], flip_pt ele.do_flip_with_part_list(part_list, flip_pt_tfrm) elif isinstance(args[0], ele.Part): # flip a Part, any subtype p = args[0] p.flip() idx_list = p.idx_list() idx1 = idx_list[0] idx2 = idx_list[-1] # flip the range in the sequential model sm.flip(idx1, idx2) osp.update_model() self.update_optical_properties() pt.update_model()
[docs] def insert_ifc_gp_ele(self, *descriptor, **kwargs): """ insert interfaces and gaps into seq_model and eles into ele_model Args: descriptor: a tuple of additions for the sequential, element and part tree models kwargs: keyword arguments including idx: insertion point in the sequential model insert: if True, insert the chunk, otherwise replace it t: the thickness following a chunk when inserting """ sm = self['seq_model'] osp = self['optical_spec'] em = self['ele_model'] pt = self['part_tree'] seq, elm, e_nodez = descriptor if 'idx' in kwargs: sm.cur_surface = kwargs['idx'] idx = sm.cur_surface if isinstance(e_nodez, Sequence): for node in e_nodez: node.parent = pt.root_node else: e_nodez.parent = pt.root_node # distinguish between adding a new chunk, which requires splitting a # gap in two, and replacing a node, which uses the existing gaps. ins_prev_gap = False if 'insert' in kwargs: t_after = kwargs['t'] if 't' in kwargs else 0. if sm.get_num_surfaces() == 2: # only object space gap, add image space gap following this gap_label = "Image space" gap_tag = '#image' ins_prev_gap = False else: # we have both object and image space gaps; retain the image # space gap by splitting and inserting the new gap before the # inserted chunk, unless we're inserting before idx=1. gap_label = None gap_tag = '' if idx > 0: ins_prev_gap = True if ins_prev_gap: t_air, sm.gaps[idx].thi = sm.gaps[idx].thi, t_after z_dir = sm.z_dir[idx] else: t_air = t_after z_dir = seq[-1][mc.Zdir] g, ag, ag_node = ele.create_air_gap(t=t_air, label=gap_label, z_dir=z_dir, tag=gap_tag) if not ins_prev_gap: seq[-1][mc.Gap] = g elm.append(ag) ag_node.parent = pt.root_node else: # replacing an existing node. need to hook new chunk final # interface to the existing gap and following (air gap) element g = sm.gaps[sm.cur_surface+1] seq[-1][mc.Gap] = g ag, ag_node = pt.parent_object(g, '#airgap') # insert the new seq into the seq_model for sg in seq: if ins_prev_gap: gap, g = g, sg[mc.Gap] else: gap = sg[mc.Gap] sm.insert(sg[mc.Intfc], gap, z_dir=sg[mc.Zdir], prev=ins_prev_gap) sm.update_model() osp.update_model() self.update_optical_properties() # add new elements into the ele_model and # re-sync them with the seq_model for e in elm: em.add_element(e) em.sync_to_seq(sm) # re-sort the part_tree to incorporate the new seq pt.update_model() # re-sort the ele_model by position on Z axis em.sequence_elements()
[docs] def remove_ifc_gp_ele(self, *descriptor, **kwargs): """ remove interfaces and gaps from seq_model and eles from ele_model """ sm = self['seq_model'] osp = self['optical_spec'] em = self['ele_model'] pt = self['part_tree'] seq, elm, e_nodez = descriptor sg = seq[0] idx = sm.ifcs.index(sg[mc.Intfc]) # verify that the sequences match seq_match = True for i, sg in enumerate(seq): if sg[0] is not sm.ifcs[idx+i]: seq_match = False break if seq_match: # remove interfaces in reverse for i in range(idx+len(seq)-1, idx-1, -1): sm.remove(i) sm.update_model() osp.update_model() self.update_optical_properties() for e in elm: em.remove_element(e) em.sync_to_seq(sm) if isinstance(e_nodez, Sequence): for node in e_nodez: node.parent = None else: e_nodez.parent = None pt.update_model() # re-sort the ele_model by position on Z axis em.sequence_elements()
[docs] def remove_node(self, e_node): # remove interfaces from seq_model self.seq_model.remove_node(e_node) # remove elements from ele_model self.ele_model.remove_node(e_node) # unhook node e_node.parent = None