from __future__ import annotations
import os
from pathlib import Path
from scipy.integrate import cumulative_trapezoid
import numpy as np
import xarray as xr
from box import Box
from PIL import Image
import shutil
from skZemax.skZemax_subfunctions._c_print import c_print as cp
from skZemax.skZemax_subfunctions._LDE_functions import (
ZOSAPI_Editors_LDE_ILDERow,
_convert_raw_surface_input_,
)
from skZemax.skZemax_subfunctions._wavelength_functions import (
ZOSAPI_SystemData_IWavelength,
)
from skZemax.skZemax_subfunctions._field_functions import (
ZOSAPI_SystemData_IField,
)
from skZemax.skZemax_subfunctions._ZOSAPI_interface_functions import (
__LowLevelZemaxStringCheck__,
_CheckIfStringValidInDir_,
)
type ZOSAPI_Analysis_Data_IA = object # <- ZOSAPI.Analysis.IA_ # The actual module is referenced by the base PythonStandaloneApplication class.
type ZOSAPI_Analysis_Data_IAR = object # <- ZOSAPI.Analysis.Data.IAR_ # The actual module is referenced by the base PythonStandaloneApplication class.
type ZOSAPI_Analysis_Data_IAS = object # <- ZOSAPI.Analysis.Settings.IAS_ # The actual module is referenced by the base PythonStandaloneApplication class.
def _Analysis_CalcLsfEsfFrom2DPsf_(
self, psf: np.ndarray, x: np.ndarray, y: np.ndarray, normalize: bool = True
):
"""Worker function to calculate the vertical and horizontal line spread functions and edge spread functions
from a 2D PSF.
lsf_x and esf_x are functions of x, i.e. if there is a line/edge oriented along the vertical (y) dir.
lsf_y and esf_y are functions of y, i.e. if there is a line/edge oriented along the horizontal (x) dir.
:param psf: The 2D PSF
:type psf: np.ndarray
:param x: The values of the x component
:type x: np.ndarray
:param y: The values of the y component
:type y: np.ndarray
:param normalize: If should normalize the lsf and esf functions, defaults to True
:type normalize: bool, optional
"""
lsf_x = np.trapezoid(psf, x=y, axis=0).astype(float)
esf_x = cumulative_trapezoid(lsf_x, x=x, initial=0)
lsf_y = np.trapezoid(psf, x=x, axis=1).astype(float)
esf_y = cumulative_trapezoid(lsf_y, x=y, initial=0)
if normalize:
# Normalize LSFs to peak = 1
lsf_x /= lsf_x.max()
lsf_y /= lsf_y.max()
# Normalize ESFs to range [0, 1]
esf_x = (esf_x - esf_x.min()) / (esf_x.max() - esf_x.min())
esf_y = (esf_y - esf_y.min()) / (esf_y.max() - esf_y.min())
return lsf_x, lsf_y, esf_x, esf_y
def _Analysis_GeneralDataSeriesReader_(self, results: ZOSAPI_Analysis_Data_IAR):
"""Germanized worker for reading X and Y data from data series returned from an analysis GetResults() call.
:param results: The return of an analysis GetResults() call.
:type results: ZOSAPI_Analysis_Data_IAR
"""
xar = []
yar = []
for seriesNum in range(results.NumberOfDataSeries):
data = results.GetDataSeries(seriesNum)
xRaw = data.XData.Data
yRaw = data.YData.Data
xar.append(np.array(tuple(xRaw)))
try:
yar.append(
np.array(
np.asarray(tuple(yRaw)).reshape(
data.YData.Data.GetLength(0), data.YData.Data.GetLength(1)
)
)
)
except Exception:
yar.append(np.array(np.asarray(tuple(yRaw))))
return np.array(xar), np.array(yar)
def _Analysis_GeneralDataGridReader_(self, results: ZOSAPI_Analysis_Data_IAR):
"""Germanized worker for reading grid data from data returned from an analysis GetResults() call.
:param results: The return of an analysis GetResults() call.
:type results: ZOSAPI_Analysis_Data_IAR
"""
gar = []
xar = []
yar = []
for gridNum in range(results.NumberOfDataGrids):
data = results.GetDataGrid(gridNum)
gRaw = data.Values
dx = data.Dx
dy = data.Dy
xmin = data.MinX
ymin = data.MinY
xar.append(xmin + np.arange(data.Nx) * dx)
yar.append(ymin + np.arange(data.Ny) * dy)
gar.append(np.array(tuple(gRaw)).reshape(data.Ny, data.Nx))
return np.array(gar), np.array(xar), np.array(yar)
def _Analysis_GetZOSObjectAndSettings_(
self, analysis: str
) -> tuple[ZOSAPI_Analysis_Data_IA, ZOSAPI_Analysis_Data_IAS, str]:
"""
Worker function which looks up the specified analysis object and settings - which some condition checking.
A tuple of None values is returned if the analysis is not recognized, or not applicable to the type of sequential mode.
:param analysis: The name of the analysis to perform. See output of :func:`Analyses_GetNamesOfAllAnalyses` for names.
:type analysis: str
:return: OSAPI.Analysis.Data object, the ZOSAPI.Analysis.Settings object, and the ZOS-API name of the specified analysis.
:rtype: tuple[ZOSAPI_Analysis_Data_IA, ZOSAPI_Analysis_Data_IAS, str]
"""
analysis_enum = _CheckIfStringValidInDir_(
self, self.ZOSAPI.Analysis.AnalysisIDM, analysis
)
if analysis_enum is None:
return None, None, None
analysis_obj = self.TheSystem.Analyses.New_Analysis(analysis_enum)
if analysis_obj is None:
if self._verbose:
cp(
f"!@ly!@_Analysis_GetZOSObjectAndSettings_ :: Analysis [!@lm!@{analysis_enum!s}!@ly!@] is not applicable to [!@lm!@{self.System_GetMode()}!@ly!@] mode."
)
return None, None, None
analysis_settings_obj = analysis_obj.GetSettings()
if analysis_settings_obj is None:
del analysis_obj
analysis_obj = None
if self._verbose:
cp(
f"!@ly!@_Analysis_GetZOSObjectAndSettings_ :: Analysis [!@lm!@{analysis_enum!s}!@ly!@] is not applicable to [!@lm!@{self.System_GetMode()}!@ly!@] mode."
)
return None, None, None
return analysis_obj, analysis_settings_obj, str(analysis_enum)
def _Analysis_SetZOSObjectSettingsByDict_(
self,
analysis_settings: dict | Box,
analysis_settings_obj: ZOSAPI_Analysis_Data_IAS,
analysis_enum: str,
) -> None:
"""
A worker function which will set analysis settings through the ModifySettings() scheme documented in Section 10.2.14.92. MODIFYSETTINGS (keywords).
This is done by making, configuring, and then loading a configuration file. This config file method seems to be the only robust way to configure analysis settings.
:param analysis_settings: A python dictatory to adjust settings of the analysis. Formatted as dict[MODIFYSETTINGS KEYWORD] = str(value), defaults to None
:type analysis_settings: dict|Box
:param analysis_settings_obj: Analysis settings object
:type analysis_settings_obj: ZOSAPI_Analysis_Data_IAS
:param analysis_enum: the ZOS-API name of the specified analysis
:type analysis_enum: str
"""
if analysis_settings is not None:
# Set all settings through the configuration file "ModifySettings()" function.
cfgFile = self.Utilities_ConfigFilesDir() + os.sep + analysis_enum + ".CFG"
analysis_settings_obj.SaveTo(cfgFile)
[
analysis_settings_obj.ModifySettings(cfgFile, x, str(analysis_settings[x]))
for x in analysis_settings
]
analysis_settings_obj.LoadFrom(cfgFile)
def _Analysis_SetZOSObjectSettingsByBinaryAlteration_(
self,
analysis_settings: np.ndarray,
analysis_settings_obj: ZOSAPI_Analysis_Data_IAS,
analysis_enum: str,
) -> None:
"""
A worker function to set a analysis configuration file through direct modification.
Disclaimer: I worked out this hack thanks to seeing it in ZOSpy.
:param analysis_settings: An integer array of analysis settings. See descriptions of :func:`Analyses_RunAnalysesAndGetResults`.
:type analysis_settings: np.ndarray
:param analysis_settings_obj: The settings object of the analysis.
:type analysis_settings_obj: ZOSAPI_Analysis_Data_IAS
:param analysis_enum: the ZOS-API name of the specified analysis
:type analysis_enum: str
"""
if analysis_settings is not None:
cfgFile = self.Utilities_ConfigFilesDir() + os.sep + str(analysis_enum) + ".CFG"
analysis_settings_obj.SaveTo(cfgFile)
cfgFile_path = Path(cfgFile)
settings_bytestring = cfgFile_path.read_bytes()
settings_bytearray = bytearray(settings_bytestring)
# I believe byte indices 0-19 is effectively header information. Analysis settings begin at index 20 and increments by 4 per option.
for idx, binidx in enumerate(
np.arange(20, 20 + analysis_settings.shape[0] * 4, 4)
):
settings_bytearray[binidx] = analysis_settings[idx]
cfgFile_path.write_bytes(settings_bytearray)
del cfgFile_path
cfgFile_path = None
analysis_settings_obj.LoadFrom(cfgFile)
[docs]
def Analyses_GetNamesOfAllAnalyses(self, print_to_console: bool = False) -> list:
"""
This function is simply for user convenance to look up the ZOS-API names of all analysis types.
This can be useful to look up what one may want to code as input to functions like :func:`Analyses_RunAnalysesAndGetResults`.
:param print_to_console: If True will print to console, defaults to False
:type print_to_console: bool, optional
:return: A list of the names of all analyses types the ZOS-API knows.
:rtype: list
"""
analyses_types = __LowLevelZemaxStringCheck__(
self, in_obj=self.ZOSAPI.Analysis.AnalysisIDM
)
if print_to_console:
cp("\n!@lg!@Analyses_GetNamesOfAllAnalyses :: Names of Analyses:")
[cp(" !@lm!@" + str(x)) for x in analyses_types]
cp("\n")
return analyses_types
[docs]
def Analyses_RunAnalysesAndGetResults(
self, analysis: str, analysis_settings: dict | Box | np.ndarray[int] = None
) -> ZOSAPI_Analysis_Data_IAR:
"""
This is a generalized function to run a Zemax analysis on the optical system.
It is intended to facilitate users - with some familiarity with the ZOS-API - to support their own analysis code
if a wrapper function for the analysis doesn't exist, or if the user would like more direct access to the results.
User ZOS-API familiarity is expected to be on the topics of the analysis setting documentation, and processing
the output of a `GetResults()` call for the analysis (which differs based on the type of analysis being done).
It is recommended to use a wrapper function, such as :func:`Analyses_FFTMTF`, for robustness and ease of use.
Note that this function will adjust all settings through analysis configuration (.CFG) files rather than direct property assignment.
The reason for this is that much of the direct assignment through the ZOS-API does not work or was never supported to begin with.
Analysis settings are intended by ZOI-API to be adjusted through a configuration file with a `ModifySettings()` function call.
If analysis settings are provided to this function as a python dictionary formatted as `dict[MODIFYSETTINGS KEYWORD] = str(value)`, this method is attempted.
Documentation on the analysis settings - and the keys to provide in the settings dictionary - can be found in the pdf help file
in Section 10.2.14.92. MODIFYSETTINGS (keywords).
There are analysis settings which are either simply not documented by ZOI-API within Section 10.2.14.92, or support for them simply doesn't exist through the ZOS-API.
In this case it is possible to directly adjust the binary values of the configuration file to implement the settings you want.
In this case supply a numpy array of integer values for these settings. Since these are undocumented it may take some trial and error will to work out what to enter.
However, a fairly reliable way is to look at the settings interface for the analysis in the typical Zemax application user interface.
The order of the settings to enter into the np.ndarray will *typically* be the order of the options in the settings menu - starting with each option in the left most column of settings,
then each in the next column, and so on all the way to the right most column.
Values are (*typically*) 1 meaning enabled and 0 meaning disabled - or in the case of multiple options, an integer value selecting the item in the user interface's drop down menu for that option.
As an example: For the Cardinal Point Data Analysis the settings menu in the typical Zemax application user interface looks like:
+========================+============+
| First Surface | Wavelength |
+------------------------+------------+
| Last Surface | Orientation |
+========================+============+
Where applicable, the menu options are sorted by index. Other (such as orientation here) have the options as: [Y-Z, X-Y]. Therefore, for settings
- First Surface = second in the system
- Last Surface = fourth in the system
- Wavelength = third in the system
- Orientation = 'Y-Z'
The array this function wants will be: [2, 4, 3, 1].
... and yes, I know this sucks ... one of the reasons for the wrapper functions in skZemax.
:param analysis: The name of the analysis to perform. See output of :func:`Analyses_GetNamesOfAllAnalyses` for names.
:type analysis: str
:param analysis_settings: User settings of the analysis. See descriptions above, defaults to None
:type analysis_settings: Union[dict, np.ndarray[int]], optional
:return: The output of the analysis `GetResults()` function call.
:rtype: ZOSAPI_Analysis_Data_IAR
"""
analysis_obj, analysis_settings_obj, analysis_enum = (
self._Analysis_GetZOSObjectAndSettings_(analysis=analysis)
)
if analysis_obj is None or analysis_settings_obj is None:
return None
if analysis_settings is not None and isinstance(analysis_settings, dict):
self._Analysis_SetZOSObjectSettingsByDict_(
analysis_settings=analysis_settings,
analysis_settings_obj=analysis_settings_obj,
analysis_enum=analysis_enum,
)
elif analysis_settings is not None and isinstance(analysis_settings, np.ndarray):
self._Analysis_SetZOSObjectSettingsByBinaryAlteration_(
analysis_settings=analysis_settings,
analysis_settings_obj=analysis_settings_obj,
analysis_enum=analysis_enum,
)
elif analysis_settings is not None:
if self._verbose:
cp(
"!@ly!@Analyses_RunAnalysesAndGetResults :: WARNING :: Settings supplied but format not recognized. Nothing configured."
)
if self._verbose:
cp(
f"!@lg!@Analyses_RunAnalysesAndGetResults :: Running analysis [!@lm!@{analysis_enum!s}!@lg!@] ..."
)
analysis_obj.ApplyAndWaitForCompletion()
if self._verbose:
cp("!@lg!@Analyses_RunAnalysesAndGetResults :: Done.")
results = analysis_obj.GetResults()
return results
[docs]
def Analyses_ReportSystemPrescription(
self, save_textfile_path: str | None = None
) -> list:
"""
Constructs the prescription report of the optical system. This is returned as text information which can be saved in a .txt file.
:param save_textfile_path:save_textfile_path: Full absolute .txt file path to save prescription data text file, defaults to None (no custom saving)
:type save_textfile_path: str, optional
:return: A list where each element is a line of the prescription report.
:rtype: list
"""
if save_textfile_path is None:
save_path = (
self.Utilities_AnalysesFilesDir() + os.sep + "PrescriptionDataSettings.txt"
)
else:
if save_textfile_path[-4:] != ".txt":
save_textfile_path += ".txt"
save_path = save_textfile_path
result = self.Analyses_RunAnalysesAndGetResults(analysis="PrescriptionDataSettings")
result.GetTextFile(save_path)
with open(save_path, errors="replace") as file:
content = file.read()
if save_textfile_path is None:
# Delete the default .txt file if no custom save path was given
os.remove(save_path)
return [
x for x in content.replace("\x00", "").strip("ÿþ").split("\n") if len(x) > 0
]
[docs]
def Analyses_ReportSurfacePrescription(
self,
in_Surface: int | ZOSAPI_Editors_LDE_ILDERow,
save_textfile_path: str | None = None,
) -> list:
"""
Constructs the prescription report of a sequential (LDE) surface in optical system. This is returned as text information which can be saved in a .txt file.
:param in_Surface: The surface to analyze as an LDE surface object or as an index.
:type in_Surface: Union[int, ZOSAPI_Editors_LDE_ILDERow]
:param save_textfile_path: Full absolute .txt file path to save prescription data text file, defaults to None (no custom saving)
:type save_textfile_path: str, optional
:return: A list where each element is a line of the prescription report.
:rtype: list
"""
if save_textfile_path is None:
save_path = (
self.Utilities_AnalysesFilesDir() + os.sep + "SurfaceDataSetting.txt"
)
else:
if save_textfile_path[-4:] != ".txt":
save_textfile_path += ".txt"
save_path = save_textfile_path
result = self.Analyses_RunAnalysesAndGetResults(
analysis="SurfaceDataSetting",
analysis_settings=np.array(
[self._convert_raw_surface_input_(in_Surface, return_index=True)]
),
)
result.GetTextFile(save_path)
with open(save_path, errors="replace") as file:
content = file.read()
if save_textfile_path is None:
# Delete the default .txt file if no custom save path was given
os.remove(save_path)
return [
x for x in content.replace("\x00", "").strip("ÿþ").split("\n") if len(x) > 0
]
[docs]
def Analyses_GetGeneralLensData(self) -> Box(dict()):
"""This function does not do any "formal analysis" that Zemax does, instead it simply parses the "General Lens Data" section of :func:`Analyses_ReportSystemPrescription`
in a way that is nice to use in python (a boxed dict).
There are functions like self.TheSystem.LDE.GetPupil() to get (most) of these values separately, but this is a succinct way to get most things one is interested in.
"""
self.Analyses_ReportSystemPrescription(
save_textfile_path=self.Utilities_AnalysesFilesDir()
+ os.sep
+ "PrescriptionDataSettings.txt"
)
general_data = self.Analyses_ExtractSectionOfTextFile(
in_file=self.Utilities_AnalysesFilesDir()
+ os.sep
+ "PrescriptionDataSettings.txt",
start_marker="GENERAL LENS DATA:",
end_marker="Fields",
)
os.remove(
self.Utilities_AnalysesFilesDir() + os.sep + "PrescriptionDataSettings.txt"
)
def _format_datatype_(in_key: str, in_value: str):
in_key = in_key.strip().title().replace(" ", "")
in_value = in_value.replace("\t", "").strip()
if in_key == "EffectiveFocalLength":
# Handle the special case of EFL having two entries with description in the values
in_key += in_value.split("(")[-1].strip(")").title().replace(" ", "")
in_value = in_value.split("(")[0]
try:
return in_key, int(in_value)
except ValueError:
try:
return in_key, float(in_value)
except ValueError:
if in_value.lower() == "on":
return in_key, True
elif in_value.lower() == "off":
return in_key, False
else:
return in_key, in_value
out = Box(
dict(
sorted( # Sort keys alphabetically
(
_format_datatype_(key, value)
for key, value in (line.split(":", 1) for line in general_data[:-1])
)
)
)
)
return out
[docs]
def Analyses_FFTMTF(
self,
wavelength: int | float | ZOSAPI_SystemData_IWavelength = 0,
field: int | ZOSAPI_SystemData_IField = 0,
surface: int | ZOSAPI_Editors_LDE_ILDERow = 0,
sample_size: str = "256x256",
max_freq: float = 0,
use_polarization: bool = True,
configuration: int = None,
) -> xr.Dataset:
"""
Get the (sequential) FFT MTF of the system.
:param wavelength: System wavelength (as index, microns, or object) to do the MTF on. An int of 0 selects all all wavelengths, Defaults to 0.
:type wavelength: int | float | ZOSAPI_SystemData_IWavelength, optional
:param field: System field (index of object) to do the MTF on. An int of 0 selects all fields, defaults to 0
:type field: int | ZOSAPI_SystemData_IField, optional
:param surface: System surface (index or object) to do the MTF on. An int of 0 selects the image surface, defaults to 0
:type surface: int, optional
:param sample_size: '32x32', '64x64', '128x128', '256x256', '512x512', '1024x1024', '2048x2048', '4096x4096', '8192x8192', '16384x16384', defaults to '256x256'
:type sample_size: str, optional
:param max_freq: Max frequency of the analysis, 0 allows Zemax to select it, defaults to 0
:type max_freq: float, optional
:param use_polarization: Use polarization, defaults to True
:type use_polarization: bool, optional
:param configuration: MCE configration to perform the analysis on. If None will use the active configuration, defaults to None.
:type configuration: bool, optional
:return: The x and y data of the FFTMTF analysis.
:rtype: tuple[np.ndarray, np.ndarray]
"""
CURRENT_CONFIG = int(self.MCE_GetCurrentConfig())
if configuration is None:
self.MCE_SetActiveConfig(CURRENT_CONFIG)
else:
self.MCE_SetActiveConfig(configuration)
# convert wavelength/fields/surface
if not (isinstance(wavelength, int) and wavelength == 0):
wavelength = self._convert_raw_wavelength_input_(wavelength, return_index=True)
if not (isinstance(field, int) and field == 0):
field = self._convert_raw_field_input_(field, return_index=True)
if not (isinstance(surface, int) and surface == 0):
surface = self._convert_raw_field_input_(surface, return_index=True)
def _do_mtf_mode_(MTF_type: str):
# Settings. Example API calls for it do not work. Found a work around through configuration files.
# MODIFYSETTINGS are defined in the ZPL help files: The Programming Tab > About the ZPL > Keywords
SampSizeIdx = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.SampleSizes, sample_size, extra_include_filter="S_"
)
TypeIdx = int(
self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.Mtf.MtfTypes, MTF_type
)
)
Settings = {}
Settings["MTF_SAMP"] = str(int(SampSizeIdx))
Settings["MTF_WAVE"] = str(int(wavelength))
Settings["MTF_FIELD"] = str(int(field))
Settings["MTF_TYPE"] = str(int(TypeIdx))
Settings["MTF_SURF"] = str(int(surface))
Settings["MTF_MAXF"] = "0" if float(max_freq) <= 0 else str(float(max_freq))
Settings["MTF_SDLI"] = "1" # Always show diffraction limit
Settings["MTF_POLAR"] = "1" if use_polarization else "0"
Settings["MTF_DASH"] = (
"1" # Dash line (for "classic mode" and does not matter here)
)
if self._verbose:
cp(
"!@lg!@Analyses_FFTMTF :: Calculating FFT MTF [!@lm!@%s!@lg!@]."
% MTF_type
)
xar, yar = self._Analysis_GeneralDataSeriesReader_(
self.Analyses_RunAnalysesAndGetResults(
analysis="FftMtf", analysis_settings=Settings
)
)
return xar, yar
freq, mod_y = _do_mtf_mode_("modulation")
_, phase_y = _do_mtf_mode_("phase")
_, real_y = _do_mtf_mode_("real")
_, imag_y = _do_mtf_mode_("imaginary")
_, square_y = _do_mtf_mode_("squarewave")
if field != 0:
fields = [self.Field_GetField(field)]
else:
fields = [
self.Field_GetField(x + 1) for x in range(self.Fields_GetNumberOfFields())
]
units = self.Utilities_GetAllSystemUnits()
freq_units = str(units["MTFUnits"])
if self._verbose:
cp("!@lg!@Analyses_FFTMTF :: Done Calculating FFT MTF.")
out = xr.Dataset(
{
"modulation": (("field", "freq", "ray_type"), mod_y[1::].astype(float)),
"phase": (("field", "freq", "ray_type"), phase_y[1::].astype(float)),
"real_part": (("field", "freq", "ray_type"), real_y[1::].astype(float)),
"imag_part": (("field", "freq", "ray_type"), imag_y[1::].astype(float)),
"square_wave": (("field", "freq", "ray_type"), square_y[1::].astype(float)),
"modulation_diff_limited": (("freq", "ray_type"), mod_y[0].astype(float)),
"phase_diff_limited": (("freq", "ray_type"), phase_y[0].astype(float)),
"real_diff_limited": (("freq", "ray_type"), real_y[0].astype(float)),
"imaginary_diff_limited": (("freq", "ray_type"), imag_y[0].astype(float)),
"square_wave_diff_limited": (
("freq", "ray_type"),
square_y[0].astype(float),
),
},
coords={
"freq": ("freq", freq[0].astype(float), {"units": freq_units}),
"field_x": (
"field",
np.array([x.X for x in fields]).astype(float),
),
"field_y": (
"field",
np.array([x.Y for x in fields]).astype(float),
),
"ray_type": (
"ray_type",
np.array(
["sagittal_periodic_in_object_y", "tangential_periodic_in_object_x"]
).astype(str),
),
},
attrs={
"Field_Type": str(self.Field_GetFieldType()),
"Lens_Units": str(units["LensUnits"]),
"MCE_Configuration": int(self.MCE_GetCurrentConfig()),
},
)
self.MCE_SetActiveConfig(CURRENT_CONFIG)
return out
[docs]
def Analyses_FFTPSF(
self,
wavelength: int | float | ZOSAPI_SystemData_IWavelength = 0,
field: int | ZOSAPI_SystemData_IField = 0,
surface: int | ZOSAPI_Editors_LDE_ILDERow = 0,
sample_size: str = "256x256",
output_size: str = "512x512",
image_delta_microns: int | float = 0,
use_polarization: bool = True,
use_normalization: bool = True,
configuration: int = None,
) -> xr.Dataset:
"""
Get the (sequential) FFT PSF of the system.
:param wavelength: System wavelength (as index, microns, or object) to do the MTF on. An int of 0 selects all all wavelengths, Defaults to 0.
:type wavelength: int | float | ZOSAPI_SystemData_IWavelength, optional
:param field: System field (index of object) to do the MTF on. An int of 0 selects all fields, defaults to 0
:type field: int | ZOSAPI_SystemData_IField, optional
:param surface: System surface (index or object) to do the MTF on. An int of 0 selects the image surface, defaults to 0
:type surface: int, optional
:param sample_size: Sampling of the PSF calulation. '32x32', '64x64', '128x128', '256x256', '512x512', '1024x1024', '2048x2048', '4096x4096', '8192x8192', '16384x16384', defaults to '256x256'
:type sample_size: str, optional
:param output_size: Size of the output grid (for display). '32x32', '64x64', '128x128', '256x256', '512x512', '1024x1024', '2048x2048', '4096x4096', '8192x8192', '16384x16384', defaults to '512x512'
:type output_size: str, optional
:param image_delta_microns: The distance in micrometers between points in the image grid. Use zero for the default grid spacing, defaults to 0
:type image_delta: int|float, optional
:param use_polarization: Use polarization, defaults to True
:type use_polarization: bool, optional
:param use_normalization: If should normalize the PSF or not, defaults to True
:type use_normalization: bool, optional
:param configuration: MCE configration to perform the analysis on. If None will use the active configuration, defaults to None.
:type configuration: bool, optional
:return: The x and y data of the FFTMTF analysis.
:rtype: tuple[np.ndarray, np.ndarray]
"""
CURRENT_CONFIG = int(self.MCE_GetCurrentConfig())
if configuration is None:
self.MCE_SetActiveConfig(CURRENT_CONFIG)
else:
self.MCE_SetActiveConfig(configuration)
# convert wavelength/fields/surface
if not (isinstance(wavelength, int) and wavelength == 0):
wavelength = self._convert_raw_wavelength_input_(wavelength, return_index=True)
if not (isinstance(field, int) and field == 0):
field = np.array([self._convert_raw_field_input_(field, return_index=True)])
else:
field = np.arange(1, self.Fields_GetNumberOfFields() + 1)
if not (isinstance(surface, int) and surface == 0):
surface = self._convert_raw_field_input_(surface, return_index=True)
def _do_psf_mode_(PSF_type: str, field_idx: int):
# "OutputSize" is not docummented or seemignly accessable through what :func:`Analyses_RunAnalysesAndGetResults` would do.
# This actually seems to be build on a "newer" version of the Zemax settings API which is much less abstractable it seems.
# Doing manual assignmnet below through the use of `analysis_settings_obj.__implementation__`.
analysis_obj, analysis_settings_obj, analysis_enum = (
self._Analysis_GetZOSObjectAndSettings_(analysis="FftPsf")
)
analysis_settings_obj = analysis_settings_obj.__implementation__
analysis_settings_obj.SampleSize = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.Psf.PsfSampling,
sample_size,
extra_include_filter="S_",
)
analysis_settings_obj.OutputSize = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.Psf.PsfSampling,
output_size,
extra_include_filter="S_",
)
analysis_settings_obj.Wavelength.SetWavelengthNumber(int(wavelength))
analysis_settings_obj.Field.SetFieldNumber(int(field_idx))
analysis_settings_obj.Type = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.Psf.FftPsfType, PSF_type
)
analysis_settings_obj.Surface.SetSurfaceNumber(int(surface))
analysis_settings_obj.UsePolarization = use_polarization
analysis_settings_obj.Normalize = use_normalization
analysis_settings_obj.ImageDelta = float(image_delta_microns)
if self._verbose:
cp(
"!@lg!@Analyses_FFTPSF :: Calculating FFT PSF [!@lm!@%s!@lg!@]..."
% PSF_type
)
analysis_obj.ApplyAndWaitForCompletion()
results = analysis_obj.GetResults()
results.GetTextFile(
self.Utilities_AnalysesFilesDir() + os.sep + "HuygensPSF.txt"
)
info = self.Analyses_ExtractSectionOfTextFile(
in_file=self.Utilities_AnalysesFilesDir() + os.sep + "HuygensPSF.txt",
start_marker="Date",
end_marker="Values",
)
gar, xar, yar = self._Analysis_GeneralDataGridReader_(results)
analysis_obj.Close()
del analysis_obj
return gar[0], xar[0], yar[0], info
out_list = []
for fieldidx in field:
if self._verbose:
cp(
f"!@lg!@Analyses_FFTPSF :: Calculating FFT PSF for field [!@lm!@{fieldidx}!@lg!@]."
)
ling, x, y, info = _do_psf_mode_("linear", field_idx=fieldidx)
logg, _, _, _ = _do_psf_mode_("log", field_idx=fieldidx)
phaseg, _, _, _ = _do_psf_mode_("phase", field_idx=fieldidx)
realg, _, _, _ = _do_psf_mode_("real", field_idx=fieldidx)
imagg, _, _, _ = _do_psf_mode_("imaginary", field_idx=fieldidx)
units = self.Utilities_GetAllSystemUnits()
lsf_x, lsf_y, esf_x, esf_y = self._Analysis_CalcLsfEsfFrom2DPsf_(
psf=ling, x=x, y=y, normalize=True
)
if self._verbose:
cp("!@lg!@Analyses_FFTPSF :: Done Calculating FFT PSF.")
out_list.append(
xr.Dataset(
{
"linear": (("field", "y", "x"), ling.astype(float)[np.newaxis]),
"lsf_x": (("field", "x"), lsf_x[np.newaxis]),
"esf_x": (("field", "x"), esf_x[np.newaxis]),
"lsf_y": (("field", "y"), lsf_y[np.newaxis]),
"esf_y": (("field", "y"), esf_y[np.newaxis]),
"log": (("field", "y", "x"), logg.astype(float)[np.newaxis]),
"phase": (("field", "y", "x"), phaseg.astype(float)[np.newaxis]),
"real_part": (("field", "y", "x"), realg.astype(float)[np.newaxis]),
"imag_part": (("field", "y", "x"), imagg.astype(float)[np.newaxis]),
"data_spacing": (
"field",
np.array(
[
float(
info[2]
.split("is")[-1]
.strip(" ")
.strip(".")
.split(" ")[0]
)
]
),
{
"units": info[2]
.split("is")[-1]
.strip(" ")
.strip(".")
.split(" ")[1]
},
),
},
coords={
"field": (
"field",
np.array([fieldidx]),
),
"field_x": (
"field",
np.array([float(self.Field_GetField(fieldidx).X)]),
),
"field_y": (
"field",
np.array([float(self.Field_GetField(fieldidx).Y)]),
),
"center_point_row": (
("field"),
np.array(
[
[
int(x.split(" ")[-1].strip(" ").strip(","))
for x in info[8]
.split(":")[-1]
.strip(" ")
.strip(".")
.split(", ")
][0]
]
),
),
"center_point_col": (
("field"),
np.array(
[
[
int(x.split(" ")[-1].strip(" ").strip(","))
for x in info[8]
.split(":")[-1]
.strip(" ")
.strip(".")
.split(", ")
][1]
]
),
),
"x": (
("field", "x"),
x.astype(float)[np.newaxis],
{"units": "micrometers"},
),
"y": (
("field", "y"),
y.astype(float)[np.newaxis],
{"units": "micrometers"},
),
},
attrs={
"Field_Type": str(self.Field_GetFieldType()),
"Lens_Units": str(units["LensUnits"]),
"Pupil_Grid": str(info[6].split(":")[-1].strip(" ").strip(".")),
"Image_Grid": str(info[7].split(":")[-1].strip(" ").strip(".")),
"MCE_Configuration": int(self.MCE_GetCurrentConfig()),
"LSF_ESF_NOTE": "This may differ from Zemax FFT LSF/ESF specific analysis even given the same (as possible) settings. The integration done here is correct, but Zemax LSF/ESF specific analysis adjusts the sampeling in a way that is unclear to reproduce. It also has a coherent version which this cannot do.",
},
)
)
self.MCE_SetActiveConfig(CURRENT_CONFIG)
return xr.concat(out_list, dim="field", join="exact")
[docs]
def Analyses_HuygensMTF(
self,
wavelength: int | float | ZOSAPI_SystemData_IWavelength = 0,
field: int | ZOSAPI_SystemData_IField = 0,
pupil_sample_size: str = "64x64",
image_sample_size: str = "64x64",
image_delta_microns: int | float = 0,
max_freq: float = 0,
use_polarization: bool = True,
configuration: int = None,
) -> xr.Dataset:
"""
Get the Huygens MTF of the system. Simply, this is more accurate than the FFT version, but (possibly much) slower.
:param wavelength: System wavelength (as index, microns, or object) to do the MTF on. An int of 0 selects all all wavelengths, Defaults to 0.
:type wavelength: int | float | ZOSAPI_SystemData_IWavelength, optional
:param field: System field (index of object) to do the MTF on. An int of 0 selects all fields, defaults to 0
:type field: int | ZOSAPI_SystemData_IField, optional
:param pupil_sample_size: Selects the size of the grid of rays to trace to perform the computation. Higher sampling densities yield more accurate results at the expense of longer computation times.
'32x32', '64x64', '128x128', '256x256', '512x512', '1024x1024', '2048x2048', '4096x4096', '8192x8192', '16384x16384', defaults to '64x64'
:type pupil_sample_size: str, optional
:param image_sample_size: The size of the grid of points on which to compute the diffraction image intensity. This number, combined with the image delta, determine the size of the area displayed.
'32x32', '64x64', '128x128', '256x256', '512x512', '1024x1024', '2048x2048', '4096x4096', '8192x8192', '16384x16384', defaults to '64x64'
:type image_sample_size: str, optional
:param image_delta_microns: The distance in micrometers between points in the image grid. Use zero for the default grid spacing, defaults to 0
:type image_delta: int|float, optional
:param max_freq: Max frequency of the analysis, 0 allows Zemax to select it, defaults to 0
:type max_freq: float, optional
:param use_polarization: Use polarization, defaults to True
:type use_polarization: bool, optional
:param configuration: MCE configration to perform the analysis on. If None will use the active configuration, defaults to None.
:type configuration: bool, optional
:return: The x and y data of the FFTMTF analysis.
:rtype: tuple[np.ndarray, np.ndarray]
"""
CURRENT_CONFIG = int(self.MCE_GetCurrentConfig())
if configuration is None:
self.MCE_SetActiveConfig(CURRENT_CONFIG)
else:
self.MCE_SetActiveConfig(configuration)
# Code wise this is just an adapt of the FFT MTF
# convert wavelength/fields/surface
if not (isinstance(wavelength, int) and wavelength == 0):
wavelength = self._convert_raw_wavelength_input_(wavelength, return_index=True)
if not (isinstance(field, int) and field == 0):
field = self._convert_raw_field_input_(field, return_index=True)
PupilSampSizeIdx = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.SampleSizes, pupil_sample_size, extra_include_filter="S_"
)
ImageSampSizeIdx = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.SampleSizes, image_sample_size, extra_include_filter="S_"
)
Settings = {}
Settings["HMF_PUPILSAMP"] = str(int(PupilSampSizeIdx))
Settings["HMF_IMAGESAMP"] = str(int(ImageSampSizeIdx))
if np.isclose(image_delta_microns, 0.0):
Settings["HMF_IMAGEDELTA"] = str(int(0))
else:
Settings["HMF_IMAGEDELTA"] = str(float(image_delta_microns))
Settings["HMF_CONFIG"] = str(int(self.MCE_GetCurrentConfig()))
Settings["HMF_WAVE"] = str(int(wavelength))
Settings["HMF_FIELD"] = str(int(field))
Settings["HMF_TYPE"] = str(
int(0)
) # Only mode 0 = Modulation is supported by Zemax for Huygens
Settings["HMF_MAXF"] = "0" if float(max_freq) <= 0 else str(float(max_freq))
Settings["HMF_POLAR"] = "1" if use_polarization else "0"
Settings["HMF_DASH"] = "1"
freq, mod_y = self._Analysis_GeneralDataSeriesReader_(
self.Analyses_RunAnalysesAndGetResults(
analysis="HuygensMtf", analysis_settings=Settings
)
)
if field != 0:
fields = [self.Field_GetField(field)]
else:
fields = [
self.Field_GetField(x + 1) for x in range(self.Fields_GetNumberOfFields())
]
units = self.Utilities_GetAllSystemUnits()
freq_units = str(units["MTFUnits"])
out = xr.Dataset(
{
"modulation": (("field", "freq", "ray_type"), mod_y.astype(float)),
},
coords={
"freq": ("freq", freq[0].astype(float), {"units": freq_units}),
"field_x": (
"field",
np.array([x.X for x in fields]).astype(float),
),
"field_y": (
"field",
np.array([x.Y for x in fields]).astype(float),
),
"ray_type": (
"ray_type",
np.array(
["sagittal_periodic_in_image_y", "tangential_periodic_in_image_x"]
).astype(str),
),
},
attrs={
"Field_Type": str(self.Field_GetFieldType()),
"Lens_Units": str(units["LensUnits"]),
"MCE_Configuration": int(self.MCE_GetCurrentConfig()),
},
)
self.MCE_SetActiveConfig(CURRENT_CONFIG)
return out
[docs]
def Analyses_HuygensPSF(
self,
wavelength: int | float | ZOSAPI_SystemData_IWavelength = 0,
field: int | ZOSAPI_SystemData_IField = 0,
pupil_size: str = "64x64",
image_size: str = "64x64",
image_delta_microns: int | float = 0,
rotation: int = 0,
use_polarization: bool = True,
use_normalization: bool = True,
use_centroid: bool = False,
configuration: int = None,
) -> xr.Dataset:
"""
Get the (sequential) Huygens PSF of the system. Simply, this is more accurate than the FFT version, but (possibly much) slower.
:param wavelength: System wavelength (as index, microns, or object) to do the MTF on. An int of 0 selects all all wavelengths, Defaults to 0.
:type wavelength: int | float | ZOSAPI_SystemData_IWavelength, optional
:param field: System field (index of object) to do the MTF on. An int of 0 selects all fields, defaults to 0
:type field: int | ZOSAPI_SystemData_IField, optional
:param pupil_sample_size: Selects the size of the grid of rays to trace to perform the computation. Higher sampling densities yield more accurate results at the expense of longer computation times.
'32x32', '64x64', '128x128', '256x256', '512x512', '1024x1024', '2048x2048', '4096x4096', '8192x8192', '16384x16384', defaults to '64x64'
:type pupil_sample_size: str, optional
:param image_sample_size: The size of the grid of points on which to compute the diffraction image intensity. This number, combined with the image delta, determine the size of the area displayed.
'32x32', '64x64', '128x128', '256x256', '512x512', '1024x1024', '2048x2048', '4096x4096', '8192x8192', '16384x16384', defaults to '64x64'
:type image_sample_size: str, optional
:param image_delta_microns: The distance in micrometers between points in the image grid. Use zero for the default grid spacing, defaults to 0
:type image_delta: int|float, optional
:param rotation: Rotation specifies how the surface plots are rotated; either 0, 90, 180, or 270 degrees, defaults to 0
:type rotation: int, optional
:param use_polarization: Use polarization, defaults to True
:type use_polarization: bool, optional
:param use_normalization: If should normalize the PSF or not, defaults to True
:type use_normalization: bool, optional
:param use_centroid: If true, the plot will be centered on the geometric image centroid. If false, the plot will be centered on the chief ray, defaults to False
:type use_centroid: bool, optional
:param configuration: MCE configration to perform the analysis on. If None will use the active configuration, defaults to None.
:type configuration: bool, optional
:return: The x and y data of the FFTMTF analysis.
:rtype: tuple[np.ndarray, np.ndarray]
"""
CURRENT_CONFIG = int(self.MCE_GetCurrentConfig())
if configuration is None:
self.MCE_SetActiveConfig(CURRENT_CONFIG)
else:
self.MCE_SetActiveConfig(configuration)
# convert wavelength/fields/surface
if not (isinstance(wavelength, int) and wavelength == 0):
wavelength = self._convert_raw_wavelength_input_(wavelength, return_index=True)
if not (isinstance(field, int) and field == 0):
field = np.array([self._convert_raw_field_input_(field, return_index=True)])
else:
field = np.arange(1, self.Fields_GetNumberOfFields() + 1)
def _do_psf_mode_(PSF_type: str, field_idx: int):
# Like the FFT PSF, this has options that are not docummented or seemignly accessable through what :func:`Analyses_RunAnalysesAndGetResults` would do.
# This actually seems to be build on a "newer" version of the Zemax settings API which is much less abstractable it seems.
# Doing manual assignmnet below through the use of `analysis_settings_obj.__implementation__`.
analysis_obj, analysis_settings_obj, analysis_enum = (
self._Analysis_GetZOSObjectAndSettings_(analysis="HuygensPsf")
)
analysis_settings_obj = analysis_settings_obj.__implementation__
#
analysis_settings_obj.ImageSampleSize = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.SampleSizes, image_size, extra_include_filter="S_"
)
analysis_settings_obj.PupilSampleSize = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.SampleSizes, pupil_size, extra_include_filter="S_"
)
analysis_settings_obj.Rotation = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.Rotations, str(rotation)
)
analysis_settings_obj.Wavelength.SetWavelengthNumber(int(wavelength))
analysis_settings_obj.Field.SetFieldNumber(int(field_idx))
analysis_settings_obj.Type = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.HuygensPsfTypes, PSF_type
)
analysis_settings_obj.UsePolarization = use_polarization
analysis_settings_obj.Normalize = use_normalization
analysis_settings_obj.UseCentroid = use_centroid
analysis_settings_obj.ImageDelta = float(image_delta_microns)
if self._verbose:
cp(
"!@lg!@Analyses_HuygensPSF :: Calculating Huygens PSF [!@lm!@%s!@lg!@]..."
% PSF_type
)
analysis_obj.ApplyAndWaitForCompletion()
results = analysis_obj.GetResults()
results.GetTextFile(
self.Utilities_AnalysesFilesDir() + os.sep + "HuygensPSF.txt"
)
info = self.Analyses_ExtractSectionOfTextFile(
in_file=self.Utilities_AnalysesFilesDir() + os.sep + "HuygensPSF.txt",
start_marker="Date",
end_marker="Values",
)
gar, xar, yar = self._Analysis_GeneralDataGridReader_(results)
return gar[0], xar[0], yar[0], info
out_list = []
for fieldidx in field:
if self._verbose:
cp(
f"!@lg!@Analyses_HuygensPSF :: Calculating PSF for field [!@lm!@{fieldidx}!@lg!@]."
)
ling, x, y, info = _do_psf_mode_("Linear", field_idx=fieldidx)
logm1g, _, _, _ = _do_psf_mode_("Log_Minus_1", field_idx=fieldidx)
logm2g, _, _, _ = _do_psf_mode_("Log_Minus_2", field_idx=fieldidx)
logm3g, _, _, _ = _do_psf_mode_("Log_Minus_3", field_idx=fieldidx)
logm4g, _, _, _ = _do_psf_mode_("Log_Minus_4", field_idx=fieldidx)
logm5g, _, _, _ = _do_psf_mode_("Log_Minus_5", field_idx=fieldidx)
phaseg, _, _, _ = _do_psf_mode_("phase", field_idx=fieldidx)
realg, _, _, _ = _do_psf_mode_("real", field_idx=fieldidx)
imagg, _, _, _ = _do_psf_mode_("imaginary", field_idx=fieldidx)
units = self.Utilities_GetAllSystemUnits()
lsf_x, lsf_y, esf_x, esf_y = self._Analysis_CalcLsfEsfFrom2DPsf_(
psf=ling, x=x, y=y, normalize=True
)
if self._verbose:
cp("!@lg!@Analyses_HuygensPSF :: Done Calculating PSF.")
out_list.append(
xr.Dataset(
{
"linear": (("field", "y", "x"), ling.astype(float)[np.newaxis]),
"lsf_x": (("field", "x"), lsf_x[np.newaxis]),
"esf_x": (("field", "x"), esf_x[np.newaxis]),
"lsf_y": (("field", "y"), lsf_y[np.newaxis]),
"esf_y": (("field", "y"), esf_y[np.newaxis]),
"log_m1": (("field", "y", "x"), logm1g.astype(float)[np.newaxis]),
"log_m2": (("field", "y", "x"), logm2g.astype(float)[np.newaxis]),
"log_m3": (("field", "y", "x"), logm3g.astype(float)[np.newaxis]),
"log_m4": (("field", "y", "x"), logm4g.astype(float)[np.newaxis]),
"log_m5": (("field", "y", "x"), logm5g.astype(float)[np.newaxis]),
"phase": (("field", "y", "x"), phaseg.astype(float)[np.newaxis]),
"real_part": (("field", "y", "x"), realg.astype(float)[np.newaxis]),
"imag_part": (("field", "y", "x"), imagg.astype(float)[np.newaxis]),
"data_spacing": (
"field",
np.array(
[
float(
" ".join(info[1].split(" ")[-2::])
.strip(".")
.split(" ")[0]
)
]
),
{
"units": str(
" ".join(info[1].split(" ")[-2::])
.strip(".")
.split(" ")[1]
)
},
),
"strehl_ratio": (
"field",
np.array([float(info[3].split(":")[-1].strip(".").strip(" "))]),
),
},
coords={
"field": (
"field",
np.array([fieldidx]),
),
"field_x": (
"field",
np.array([float(self.Field_GetField(fieldidx).X)]),
),
"field_y": (
"field",
np.array([float(self.Field_GetField(fieldidx).Y)]),
),
"x": (
("field", "x"),
x.astype(float)[np.newaxis],
{"units": "micrometers"},
),
"y": (
("field", "y"),
y.astype(float)[np.newaxis],
{"units": "micrometers"},
),
"center_point_row": (
("field"),
np.array(
[
[
int(x.split(" ")[-1].strip(" ").strip(","))
for x in info[6]
.split(":")[-1]
.strip(" ")
.strip(".")
.split(", ")
][0]
]
),
),
"center_point_col": (
("field"),
np.array(
[
[
int(x.split(" ")[-1].strip(" ").strip(","))
for x in info[6]
.split(":")[-1]
.strip(" ")
.strip(".")
.split(", ")
][1]
]
),
),
"center_coord_row": (
("field"),
np.array(
[
[
float(x.strip(" ").split(" ")[0])
for x in info[7]
.split(":")[-1]
.strip(".")
.strip(" ")
.split(",")
][0]
]
),
{
"units": str(
info[7]
.split(":")[-1]
.strip(".")
.strip(" ")
.split(",")[-1]
.split(" ")[-1]
)
},
),
"center_coord_col": (
("field"),
np.array(
[
[
float(x.strip(" ").split(" ")[0])
for x in info[7]
.split(":")[-1]
.strip(".")
.strip(" ")
.split(",")
][1]
]
),
{
"units": str(
info[7]
.split(":")[-1]
.strip(".")
.strip(" ")
.split(",")[-1]
.split(" ")[-1]
)
},
),
"center_offset_row": (
("field"),
np.array(
[
[
float(x.strip(" ").split(" ")[0])
for x in info[8]
.split(":")[-1]
.strip(".")
.strip(" ")
.split(",")
][0]
]
),
{
"units": str(
info[8]
.split(":")[-1]
.strip(".")
.strip(" ")
.split(",")[-1]
.split(" ")[-1]
)
},
),
"center_offset_col": (
("field"),
np.array(
[
[
float(x.strip(" ").split(" ")[0])
for x in info[8]
.split(":")[-1]
.strip(".")
.strip(" ")
.split(",")
][1]
]
),
{
"units": str(
info[8]
.split(":")[-1]
.strip(".")
.strip(" ")
.split(",")[-1]
.split(" ")[-1]
)
},
),
"centroid_coord_row": (
("field"),
np.array(
[
[
float(x.strip(" ").split(" ")[0])
for x in info[9]
.split(":")[-1]
.strip(".")
.strip(" ")
.split(",")
][0]
]
),
{
"units": str(
info[9]
.split(":")[-1]
.strip(".")
.strip(" ")
.split(",")[-1]
.split(" ")[-1]
)
},
),
"centroid_coord_col": (
("field"),
np.array(
[
[
float(x.strip(" ").split(" ")[0])
for x in info[9]
.split(":")[-1]
.strip(".")
.strip(" ")
.split(",")
][1]
]
),
{
"units": str(
info[9]
.split(":")[-1]
.strip(".")
.strip(" ")
.split(",")[-1]
.split(" ")[-1]
)
},
),
},
attrs={
"Field_Type": str(self.Field_GetFieldType()),
"Lens_Units": str(units["LensUnits"]),
"Pupil_Grid": str(info[4].split(":")[-1].strip(" ").strip(".")),
"Image_Grid": str(info[5].split(":")[-1].strip(" ").strip(".")),
"MCE_Configuration": int(self.MCE_GetCurrentConfig()),
},
)
)
self.MCE_SetActiveConfig(CURRENT_CONFIG)
return xr.concat(out_list, dim="field", join="exact")
def Analyses_ImageSimulation(
self,
input_image_full_path: str = None,
wavelengths: int | float | ZOSAPI_SystemData_IWavelength = 0,
field_height: float = None,
number_of_pixels_x: int = 1280,
number_of_pixels_y: int = 1024,
pixel_pitch_mm: float = 0.01,
pupil_size: str = "64x64",
image_size: str = "64x64",
psf_x_points: int = 15,
psf_y_points: int = 15,
use_polarization: bool = True,
use_fixed_apertures: bool = True,
use_relative_illumination: bool = True,
oversampling: int = None, # 2, 4, 8, 16, 32, 64
gaurdband: int = None, # 2, 4, 8, 16, 32, 64
abberations: str = "diffraction", # None, geometric, diffraciton
):
# TODO add flipping and rotation, and add field selection (maybe)
# TODO add polychormatics image simulation
if not (isinstance(wavelengths, int) and wavelengths == 0):
wavelengths = np.array(
[self._convert_raw_wavelength_input_(x, return_index=True) for x in wavelengths]
)
else:
wavelengths = np.arange(1, self.Wavelength_GetNumberOfWavelengths() + 1)
# Always have the field be the optical axis
field = self._convert_raw_field_input_(
self.Fields_AddField(0, 0), return_index=True
)
# Load image for storage in xarray. Need to flipud to both make nice in xarray plot
if input_image_full_path is None:
input_image_full_path = (
self.Utilities_ZemaxInstallationImageDir()
+ os.sep
+ "ColorChart_Speos_4800x3600.png"
)
IMAGE_GIVEN = False
else:
IMAGE_GIVEN = True
img = Image.open(input_image_full_path)
input_image_rgb = np.flipud(np.array(img.convert("RGB")))
input_image_mono = np.flipud(np.array(img.convert("L")))
if IMAGE_GIVEN:
# Copy the given image to where Zemax can find it.
zemax_input_image_file_path = shutil.copy(
input_image_full_path, self.Utilities_ZemaxInstallationImageDir()
)
if field_height is None:
# Assign such that the image matches the input field/size to fill the detector - based on paraxial magnifcation (values are rounded for this paraixal reason)
lens_data = self.Analyses_GetGeneralLensData()
if "height" in self.Field_GetFieldType().lower():
field_height = np.round(
np.abs(
number_of_pixels_y
* pixel_pitch_mm
* np.round(1 / lens_data.ParaxialMagnification, 1)
),
1,
)
else:
# # Use the entrnace pupil and EFL to determine the half angle in deg, 2X this is the full field height of the image
field_height = 2 * np.round(
np.rad2deg(
np.arctan(
lens_data.EntrancePupilDiameter
/ (2 * lens_data.EffectiveFocalLengthInImageSpace)
)
),
2,
)
# # # Make to size of detector
# field_height = 2*np.rad2deg(np.arctan((number_of_pixels_y*pixel_pitch_mm/2)/lens_data.EffectiveFocalLengthInImageSpace))
def _do_sim_(in_wavln: int):
if self._verbose:
cp(
f"!@lg!@Analyses_ImageSimulation :: Simulating image for wavelength [!@lm!@{in_wavln}!@lg!@/!@lm!@{len(wavelengths)}!@lg!@]."
)
# Configure settings
analysis_obj, analysis_settings_obj, analysis_enum = (
self._Analysis_GetZOSObjectAndSettings_(analysis="ImageSimulation")
)
analysis_settings_obj = analysis_settings_obj.__implementation__
analysis_settings_obj.InputFile = str(
zemax_input_image_file_path.split(os.sep)[-1]
)
analysis_settings_obj.Field.SetFieldNumber(field)
if oversampling is None:
analysis_settings_obj.Oversampling = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.ExtendedScene.ISSamplings, "None"
)
else:
analysis_settings_obj.Oversampling = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.ExtendedScene.ISSamplings,
f"X{int(oversampling)}",
)
if gaurdband is None:
analysis_settings_obj.GuardBand = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.ExtendedScene.ISSamplings, "None"
)
else:
analysis_settings_obj.GuardBand = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.ExtendedScene.ISSamplings,
f"X{int(gaurdband)}",
)
analysis_settings_obj.SetWavelengthNumber(int(in_wavln))
analysis_settings_obj.Aberrations = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.ExtendedScene.ISAberrationTypes, abberations
)
analysis_settings_obj.PupilSampling = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.SampleSizes, pupil_size, extra_include_filter="S_"
)
analysis_settings_obj.ImageSampling = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.SampleSizes, image_size, extra_include_filter="S_"
)
analysis_settings_obj.PSFXPoints = int(psf_x_points)
analysis_settings_obj.PSFYPoints = int(psf_y_points)
analysis_settings_obj.UsePolarization = bool(use_polarization)
analysis_settings_obj.ApplyFixedApertures = bool(use_fixed_apertures)
analysis_settings_obj.UseRelativeIllumination = bool(use_relative_illumination)
analysis_settings_obj.FlipImage = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.ExtendedScene.ISFlipTypes, "None"
)
analysis_settings_obj.FlipSource = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.ExtendedScene.ISFlipTypes, "None"
)
analysis_settings_obj.RotationSource = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.Rotations, "0"
)
analysis_settings_obj.ShowAs = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.ExtendedScene.ISShowAsTypes, "SimulatedImage"
)
analysis_settings_obj.Reference = self._CheckIfStringValidInDir_(
self.ZOSAPI.Analysis.Settings.ReferenceGia, "ChiefRay"
)
analysis_settings_obj.PixelSize = float(pixel_pitch_mm)
analysis_settings_obj.XPixels = int(number_of_pixels_x)
analysis_settings_obj.YPixels = int(number_of_pixels_y)
analysis_settings_obj.FieldHeight = float(field_height)
analysis_settings_obj.OutputFile = (
self.Utilities_ZemaxInstallationImageDir() + os.sep + "TEMP.png"
)
analysis_obj.ApplyAndWaitForCompletion()
sim_image = np.flipud(
np.array(
Image.open(
self.Utilities_ZemaxInstallationImageDir() + os.sep + "TEMP.png"
).convert("L")
)
)
os.remove(self.Utilities_ZemaxInstallationImageDir() + os.sep + "TEMP.png")
return sim_image
sim_images = []
for wvln in wavelengths:
sim_images.append(_do_sim_(wvln))
# Removed added on-axis field
self.Field_DeleteField(field)
# Make xarray output
out = xr.Dataset(
{
"input_image_rgb": (
("input_y", "input_x", "rgb"),
input_image_rgb,
),
"input_image_mono": (
("input_y", "input_x"),
input_image_mono,
),
"sim_image_mono": (
("wavelength", "y", "x"),
sim_images,
),
},
coords={
"input_x": (
("input_x"),
np.arange(0, input_image_mono.shape[1]).astype(int),
{"units": "pixels"},
),
"input_y": (
("input_y"),
np.arange(0, input_image_mono.shape[0]).astype(int),
{"units": "pixels"},
),
"x": (
("x"),
np.arange(0, number_of_pixels_x).astype(int) * pixel_pitch_mm,
{"units": "mm"},
),
"image_x": (
("x"),
np.arange(0, number_of_pixels_x).astype(int),
{"units": "pixels"},
),
"y": (
("y"),
np.arange(0, number_of_pixels_y).astype(int) * pixel_pitch_mm,
{"units": "mm"},
),
"image_y": (
("y"),
np.arange(0, number_of_pixels_y).astype(int),
{"units": "pixelsmm"},
),
"wavelength": (
("wavelength"),
np.array(
[self.Wavelength_GetWavelength(x).Wavelength for x in wavelengths]
).astype(float),
{"units": "micrometers"},
),
},
attrs={
"Input_Image_Path": str(input_image_full_path),
},
)
if IMAGE_GIVEN:
# Remove the copied input image from the zemax folder so you don't clutter the lib
os.remove(zemax_input_image_file_path)
return out