Source code for yieldplotlib.load.exosims.exosims_input_file
"""Node for handling input json files."""
import copy
import os
from pathlib import Path
import astropy.io.fits as pyfits
import numpy as np
import pandas as pd
from astropy import units as u
from EXOSIMS.Prototypes.OpticalSystem import OpticalSystem
from EXOSIMS.util.get_module import get_module_from_specs
from yieldplotlib.core.file_nodes import JSONFile
from yieldplotlib.logger import logger
from yieldplotlib.util import get_unit
# Define which nested keys correspond to the modes, systems, and
# instruments for parsing.
# Could also be added as a flag in the key_map
INST_KEYS = [
"QE",
"optics",
"sread",
"idark",
"texp",
"pixelScale",
"Rs",
"lenslSamp",
"pixelNumber",
"pixelSize",
"FoV",
"pixelScale",
"CIC",
"radDos",
"PCeff",
"ENF",
]
SYST_KEYS = [
"syst_optics",
"syst_lam", # Named syst_lam to differentiate from the mode's lam key
"syst_deltaLam",
"syst_BW",
"ohTime",
"occulter",
"contrast_floor",
"IWA",
"OWA",
"core_platescale",
"occ_trans",
"core_thruput",
"core_area",
"core_contrast",
"core_mean_intensity",
"koAngles_Small",
"koAngles_Sun",
"koAngles_Moon",
"koAngles_Earth",
]
MODE_KEYS = ["detectionMode", "SNR", "timeMultiplier", "lam", "deltaLam", "BW"]
# EXOSIMS keys that might be paths, probably not complete
EXOSIMS_PATHS = {
"top_level": ["cachedir", "binaryleakfilepath", "wdsfilepath", "EZ_distribution"],
"starlightSuppressionSystems": [
"occ_trans",
"core_thruput",
"core_mean_intensity",
"core_area",
"core_contrast",
],
"scienceInstruments": ["QE"],
}
TL_PARAMS = [
"Teff",
"coords_RA",
"coords_Dec",
"Umag",
"Bmag",
"Vmag",
"Rmag",
"Imag",
"Jmag",
"Hmag",
"Kmag",
"diameter",
"MsTrue", # Note: The `true` mass is probabilistic based on MsEst
]
# Keys that need special handling
SPECIAL_KEYS = {
"blind_comp_det": "_get_comp_per_intTime",
"blind_comp_spec": "_get_comp_per_intTime",
"core_thruput": "_get_core_thruput",
}
[docs]
class EXOSIMSInputFile(JSONFile):
"""Node for handling the EXOSIMS input JSON files.
The `data` attribute holds the input JSON file as a dictionary. Additional
information is generated by instantiating EXOSIMS objects (as possible) and
extracting the relevant information when the `_get` method is called.
"""
def __init__(self, file_path: Path):
"""Initialize the EXOSIMSInputFile node with the file path."""
super().__init__(file_path)
self.file_path = file_path
self.is_input = True
self.used_modes = []
self.used_insts = []
self.used_systs = []
self._initialize_modes()
self.process_input()
self.create_exosims_objects()
[docs]
def _initialize_modes(self):
"""Initialize the used modes, instruments and systems."""
self.used_modes = self.data["observingModes"]
self.used_insts = [m["instName"] for m in self.used_modes]
self.used_systs = [m["systName"] for m in self.used_modes]
# Get the default detection mode and spectroscopy mode
self.det_mode_ind = None
self.spec_mode_ind = None
for ind, mode in enumerate(self.used_modes):
if mode.get("detectionMode"):
# There is only one detection mode, so we can just use the index
# when we hit it in the loop
self.det_mode_ind = ind
self.det_inst_ind = [
inst["name"] for inst in self.data["scienceInstruments"]
].index(mode["instName"])
self.det_syst_ind = [
syst["name"] for syst in self.data["starlightSuppressionSystems"]
].index(mode["systName"])
if "spectro" in mode["instName"] and self.spec_mode_ind is None:
# Get the first spectroscopy mode, EXOSIMS does not define a single
# "default" spectroscopy mode so there isn't a great way to choose
# between them
self.spec_mode_ind = ind
self.spec_inst_ind = [
inst["name"] for inst in self.data["scienceInstruments"]
].index(mode["instName"])
self.spec_syst_ind = [
syst["name"] for syst in self.data["starlightSuppressionSystems"]
].index(mode["systName"])
[docs]
def process_input(self):
"""Process the input JSON file.
Searches through the input file and attempts to load all hardcoded paths,
deleting any that do not exist locally to avoid errors when instantiating
the TargetList object.
"""
self.exosims_specs = copy.deepcopy(self.data)
self.all_local_paths = True
# Load or delete all hardcoded paths in the file
# Handle top-level paths
for key in EXOSIMS_PATHS["top_level"]:
if key in self.exosims_specs:
if isinstance(self.exosims_specs[key], str):
_file_exists = Path(self.exosims_specs[key]).exists()
if not _file_exists:
# Delete the key from the exosims_specs dictionary
del self.exosims_specs[key]
self.all_local_paths = False
logger.debug(
f"Deleting key {key} from {self.file_name} because it "
"does not exist locally."
)
elif self.exosims_specs[key] == "":
# Delete the key from the exosims_specs dictionary but don't
# set all_local_paths to False because it's not a path
del self.exosims_specs[key]
logger.debug(
f"Deleting key {key} from {self.file_name} because an "
"empty string was provided."
)
# Handle starlight suppression systems
sss = self.data["starlightSuppressionSystems"]
unique_systems = list(set(self.used_systs))
for system in unique_systems:
syst_ind = [syst["name"] for syst in sss].index(system)
syst = sss[syst_ind]
for key in EXOSIMS_PATHS["starlightSuppressionSystems"]:
if key in syst:
if isinstance(syst[key], str):
_file_exists = Path(syst[key]).exists()
if _file_exists:
_data, _hdr = OpticalSystem.get_param_data(None, syst[key])
syst[key] = _data
elif syst[key] == "":
# Delete the key from the exosims_specs dictionary but don't
# set all_local_paths to False because it's not a path
logger.debug(
f"Deleting key {key} from {self.file_name}'s "
f"starlight suppression system {system} because an "
"empty string was provided."
)
del self.exosims_specs["starlightSuppressionSystems"][
syst_ind
][key]
else:
# Delete the key from the exosims_specs dictionary
del self.exosims_specs["starlightSuppressionSystems"][
syst_ind
][key]
self.all_local_paths = False
logger.debug(
f"Deleting key {key} from {self.file_name}'s "
f"starlight suppression system {system} because it "
"does not exist locally."
)
# Handle science instruments
for instrument in self.used_insts:
inst_ind = [inst["name"] for inst in self.data["scienceInstruments"]].index(
instrument
)
inst = self.data["scienceInstruments"][inst_ind]
for key in EXOSIMS_PATHS["scienceInstruments"]:
if key in inst:
if isinstance(inst[key], str):
_file_exists = Path(inst[key]).exists()
if _file_exists:
_data, _ = OpticalSystem.get_param_data(None, inst[key])
inst[key] = _data
elif inst[key] == "":
# Delete the key from the exosims_specs dictionary but don't
# set all_local_paths to False because it's not a path
del self.exosims_specs["scienceInstruments"][inst_ind][key]
logger.debug(
f"Deleting key {key} from {self.file_name}'s "
f"science instrument {instrument} because an "
"empty string was provided."
)
else:
# Delete the key from the exosims_specs dictionary
del self.exosims_specs["scienceInstruments"][inst_ind][key]
self.all_local_paths = False
logger.debug(
f"Deleting key {key} from {self.file_name}'s "
f"science instrument {instrument} because it "
"does not exist locally."
)
[docs]
def _get_mode_dict(self, inst, syst):
"""Get the dictionary for a given instrument and system.
In EXOSIMS modes are actually identified by a hash of the full
mode dictionary, but for this we're just looking for a used mode
that matches the instrument and system.
"""
mode_ind = None
for ind, mode in enumerate(self.data["observingModes"]):
if mode["instName"] == inst and mode["systName"] == syst:
mode_ind = ind
break
if mode_ind is None:
raise ValueError(f"No mode found with inst={inst} and syst={syst}")
return self.data["observingModes"][mode_ind]
[docs]
def _get(self, key, inst=None, syst=None, **kwargs):
"""Custom logic for the input JSON files.
This got a bit messy, but it handles loading from the input JSON file,
the TargetList object, has reasonable defaults for the provided key,
and has some management of units.
Args:
key (str):
The key to look up in the data.
inst (str, optional):
Optional instrument name to get instrument-specific parameters.
syst (str, optional):
Optional system name to get system-specific parameters.
**kwargs:
Unused keyword arguments (usually for later `transform_data` calls).
Returns:
The value(s) associated with the key.
"""
if key in SPECIAL_KEYS:
return getattr(self, SPECIAL_KEYS[key])(key, **kwargs)
in_TL = key in TL_PARAMS
if in_TL:
# Simple case, just return the value from the TargetList object
if "coords" not in key:
val = getattr(self.TL, key)
else:
coords = self.TL.coords
if key == "coords_RA":
val = coords.ra
elif key == "coords_Dec":
val = coords.dec
return val
# Check if we're using a default mode/system/instrument
using_default = (inst is None) and (syst is None)
# Check if we're looking for a spectroscopy mode parameter
is_spec = using_default and key.startswith("sc_")
# is_det = using_default and not is_spec
default_mode_ind = self.spec_mode_ind if is_spec else self.det_mode_ind
default_inst_ind = self.spec_inst_ind if is_spec else self.det_inst_ind
default_syst_ind = self.spec_syst_ind if is_spec else self.det_syst_ind
# Strip the sc_ prefix if necessary
key = key[3:] if is_spec else key
# Check if the key is a valid parameter for an instrument, system, or mode
in_INST = key in INST_KEYS
in_SYST = key in SYST_KEYS
in_MODE = key in MODE_KEYS
if not in_INST and not in_SYST and not in_MODE:
# If the key is not a valid parameter for an instrument, system, or mode
# check if it's a top-level parameter
val = self.data.get(key)
if val is None:
raise ValueError(f"Key {key} not found in EXOSIMS input file.")
else:
return val
if using_default:
# Get the default dictionary for either detection or spectroscopy
# depending on the key
if in_MODE:
# Get mode first since we more often want it for lambda values
_dict = self.data["observingModes"][default_mode_ind]
elif in_INST:
_dict = self.data["scienceInstruments"][default_inst_ind]
elif in_SYST:
_dict = self.data["starlightSuppressionSystems"][default_syst_ind]
else:
# Load the dictionary for the provided instrument, system, or combination
# of the two
if in_MODE:
# Get mode first since we more often want it for lambda values
_dict = self._get_mode_dict(inst, syst)
elif in_INST:
_insts = self.data["scienceInstruments"]
_dict = next(_inst for _inst in _insts if _inst["name"] == inst)
elif in_SYST:
_systs = self.data["starlightSuppressionSystems"]
_dict = next(_syst for _syst in _systs if _syst["name"] == syst)
if _dict is None:
raise ValueError(
f"Key {key} not found in EXOSIMS input file or the generated "
"TargetList object, although it should be present."
)
else:
if key.startswith("syst_"):
# Hacky way to differentiate between system/inst/mode keys
key = key[5:]
value = _dict.get(key)
unit = get_unit(key, self.__class__.__name__)
if unit:
return value * unit
else:
return value
[docs]
def create_exosims_objects(self):
"""Create a TargetList object from the input JSON file.
NOTE: To instantiate the TargetList object, we need to remove all
paths that do not exist locally. This can result in different
information being used to instantiate the TargetList object.
"""
# If all paths are local, we can use the exosims_specs to instantiate a
# SurveySimulation object and get the TargetList object from it
# as well as other modules
if self.all_local_paths:
self.SS = get_module_from_specs(self.exosims_specs, "SurveySimulation")(
**self.exosims_specs
)
self.TL = self.SS.TargetList
self.OS = self.SS.OpticalSystem
else:
# To avoid filtering out targets, remove optional filters and set
# minComp to 0. During the EXOSIMSDirectory object instantiation,
# we filter TL down to the targets that are in the CSV files.
self.exosims_specs["minComp"] = 0
self.exosims_specs["optionalFilters"] = {}
# Initialize the TargetList object
self.TL = get_module_from_specs(self.exosims_specs, "TargetList")(
**self.exosims_specs
)
[docs]
def _get_comp_per_intTime(self, key, int_times=None, star_names=None):
"""Get the completeness per integration time.
Args:
key (str):
The key to get the completeness for, either "blind_comp_det" or
"blind_comp_spec".
int_times (astropy.units.Quantity):
The integration times.
star_names (list):
The names of the stars of interest.
Returns:
np.ndarray:
The completeness values for each integration time and star.
"""
is_spec = key == "blind_comp_spec"
if is_spec:
# Get the default spectroscopy mode
mode_syst_ind = self.spec_syst_ind
mode_inst_ind = self.spec_inst_ind
mode_syst = self.data["starlightSuppressionSystems"][mode_syst_ind]["name"]
mode_inst = self.data["scienceInstruments"][mode_inst_ind]["name"]
else:
# Get the default detection mode
mode_syst_ind = self.det_syst_ind
mode_inst_ind = self.det_inst_ind
mode_syst = self.data["starlightSuppressionSystems"][mode_syst_ind]["name"]
mode_inst = self.data["scienceInstruments"][mode_inst_ind]["name"]
TL = self.TL
OS = TL.OpticalSystem
SU = self.SS.SimulatedUniverse
if star_names is None:
valid_stars = np.ones(len(TL.Name), dtype=bool)
_star_names = TL.Name
else:
# Check for which stars are in the target list
valid_stars = np.isin(star_names, TL.Name)
_star_names = star_names[valid_stars]
n_invalid_stars = np.sum(~valid_stars)
if n_invalid_stars > 0:
logger.warning(
f"Found {n_invalid_stars} stars in the provided star_names that "
"were not found in the TargetList object. These stars will be "
"ignored."
)
# Get the mode dictionary from the generated EXOSIMS objects,
# NOTE: We cannot take them from the input file because the `hex` value
# is not populated in the input file
matching_modes = [
m
for m in OS.observingModes
if m["instName"] == mode_inst and m["systName"] == mode_syst
]
if len(matching_modes) > 1:
logger.warning(
f"Found {len(matching_modes)} modes with the both inst {mode_inst}"
f" and syst {mode_syst}. Using the first one with SNR: "
f"{matching_modes[0]['SNR']}"
)
mode = matching_modes[0]
sInds = np.arange(len(TL.Name))[np.isin(TL.Name, _star_names)]
exosims_name_order = TL.Name[sInds]
# Get the index map of the original star name for each of the stars in the
# filtered star list
star_name_to_idx = {name: idx for idx, name in enumerate(star_names)}
index_map = np.array(
[star_name_to_idx.get(name) for name in exosims_name_order]
)
# Standard local zodi flux
fZ = np.repeat(TL.ZodiacalLight.fZ0, len(sInds))
# Standard working angle (get's luminosity corrected in EXOSIMS)
WA = TL.int_WA[sInds]
# Standard planet-star dMag to use (also luminosity corrected by EXOSIMS)
dMag = TL.int_dMag[sInds]
# Scale to the working angles
JEZ0 = TL.JEZ0[mode["hex"]][sInds]
# Calculate the planet-star distance based on the working angles
d = np.tan(WA) * TL.dist[sInds]
# Get nEZ values for stars with planets, default to 3 for stars without planets
star_nEZ = np.full(len(sInds), 3.0) # default value of 3
# For each star with planets, get its nEZ value
for i, sInd in enumerate(sInds):
# Get indices of all planets for this star
planet_indices = np.where(SU.plan2star == sInd)[0]
# Use the first planet's nEZ value for the star
if len(planet_indices) > 0:
star_nEZ[i] = SU.nEZ[planet_indices[0]]
# Use these nEZ values to scale JEZ
JEZ = JEZ0 * star_nEZ * (1 / d.to("AU").value) ** 2
# Get the star indices for the given star names
if int_times is None:
int_times = OS.calc_intTime(TL, sInds, fZ, JEZ, dMag, WA, mode)
# Set nan values to the maximum integration time
int_times[np.isnan(int_times)] = OS.intCutoff
elif isinstance(int_times, pd.Series):
int_times = int_times.values * u.d
int_times = int_times[index_map]
else:
int_times = int_times[index_map]
# Make sure the integration times are being used for the correct stars
assert int_times.shape == exosims_name_order.shape, (
"int_times and star_names must have the same shape"
)
if not self.all_local_paths:
logger.warning(
"Completeness per integration time shouldn't be trusted without "
"having all the necessary files to generate the OpticalSystem object."
"Returning None."
)
return None
# Finally, calculate completeness
comp = TL.Completeness.comp_per_intTime(int_times, TL, sInds, fZ, JEZ, WA, mode)
# Use the filtered comp_star_names instead of TL.Name[sInds]
result = pd.DataFrame(
{
"completeness": comp,
"star_name": exosims_name_order,
"integration_time": int_times,
},
)
return result
[docs]
def _get_core_thruput(self, *args, **kwargs):
"""Get the core thruput data."""
# Get both wavelength and core throughput
thruput_fits = self.data["starlightSuppressionSystems"][0]["core_thruput"]
if os.path.exists(thruput_fits):
# Load fully qualified file path.
thruput_data = pyfits.getdata(Path(thruput_fits))
else:
try:
# See if file is in the same directory as the input JSON.
thruput_data = pyfits.getdata(
Path(os.path.dirname(self.file_path), thruput_fits)
)
except FileNotFoundError:
logger.warning(
"No core throuphput file found for EXOSIMS, check file paths in"
" the input JSON are correct. "
)
return None
sep = thruput_data[:, 0]
thruput = thruput_data[:, 1]
df = pd.DataFrame({"sep": sep, "thruput": thruput})
return df
[docs]
def export_ayo(self, output_path: str):
"""Export the current EXOSIMS input to an AYO input file.
This method aggregates the discrete EXOSIMS Observing Modes into the
wavelength-dependent arrays (lambda, SNR, SR, etc.) required by AYO.
Args:
self: EXOSIMSInputFile instance.
output_path (str): The path to write the .ayo file to.
"""
# Helper to safely get params from mode/inst/syst
def get_param(obj, key, default):
return obj.get(key, default)
# Helper to format list as string for AYO
def to_ayo_list(arr):
# Format as [val1, val2, ...]
# Check for numpy types
return "[" + ", ".join([f"{float(x):.6g}" for x in arr]) + "]"
# 1. Gather Data
# General
D = self._get("pupilDiam") # Quantity with units (m)
mission_life = self._get("missionLife") # Quantity (yr)
# Starlight Suppression (Assume first one is primary for global params)
systs = self.data.get("starlightSuppressionSystems", [{}])
syst = systs[0]
# IWA/OWA Conversion
# EXOSIMS IWA is typically in arcsec. AYO requires lambda/D.
# We need a reference lambda to convert. We use 500nm as a standard reference.
ref_lam = 500 * u.nm
if isinstance(D, u.Quantity):
D_val_m = D.to_value(u.m)
else:
D_val_m = float(D)
# Conversion factor: 1 L/D in arcsec = (lam / D) * 206265
lod_as = 206265.0 * (ref_lam.to_value(u.m) / D_val_m)
iwa_raw = syst.get("IWA", 2.0)
# Heuristic: if IWA < 1.0, it is likely arcsec.
# If > 1.0, it is likely L/D (or very large IWA).
# We assume arcsec if small, else assume it's already L/D.
# AYO expects L/D.
if iwa_raw < 1.0:
iwa_val = iwa_raw / lod_as
else:
iwa_val = iwa_raw
owa_raw = syst.get("OWA", 30.0)
if owa_raw < 5.0: # likely arcsec
owa_val = owa_raw / lod_as
else:
owa_val = owa_raw
# Pitch: koAngles_Sun
pitch = self.data.get("koAngles_Sun", [45, 135])
# Contrast
contrast = syst.get("core_contrast", 1e-10)
# observingModes processing
modes = self.data.get("observingModes", [])
det_modes = [m for m in modes if m.get("detectionMode", False)]
char_modes = [m for m in modes if not m.get("detectionMode", False)]
# Sort by lambda
det_modes.sort(key=lambda x: x.get("lam", 0))
char_modes.sort(key=lambda x: x.get("lam", 0))
# Helper to extract arrays for modes
def extract_arrays(mode_list):
lams = [] # microns
srs = []
snrs = []
qes = []
t_opts = []
dc = []
rn = []
cic = []
tread = []
pixscale = []
for m in mode_list:
lam_nm = m.get("lam", 500)
lams.append(lam_nm / 1000.0) # nm to um
# BW/SR
bw = m.get("BW", 0.2) # Fractional
srs.append(1.0 / bw if bw > 0 else 5.0)
snrs.append(m.get("SNR", 5.0))
# Resolve Instrument
inst_name = m.get("instName")
inst_matches = [
i
for i in self.data.get("scienceInstruments", [])
if i["name"] == inst_name
]
inst = inst_matches[0] if inst_matches else {}
# Resolve System (for throughput)
syst_name = m.get("systName")
syst_matches = [
s
for s in self.data.get("starlightSuppressionSystems", [])
if s["name"] == syst_name
]
sys_val = syst_matches[0] if syst_matches else {}
# Params
# QE
qe_val = inst.get("QE", 0.9)
if isinstance(qe_val, str):
qe_val = 0.9 # skip paths
qes.append(qe_val)
# Toptical = Inst Optics * System Throughput (Approx)
opt_val = inst.get("optics", 0.5)
sys_thru = sys_val.get("core_thruput", 1.0) # often a file
if isinstance(sys_thru, str):
sys_thru = 1.0
t_opts.append(float(opt_val) * float(sys_thru))
# Detectors
dc.append(inst.get("idark", 0))
rn.append(inst.get("sread", 0))
cic.append(inst.get("CIC", 0))
tread.append(inst.get("texp", 0))
# Pixel scale (arcsec -> mas)
ps = inst.get("pixelScale", 0.01)
pixscale.append(ps * 1000.0)
return {
"lambda": lams,
"SR": srs,
"SNR": snrs,
"QE": qes,
"Toptical": t_opts,
"DC": dc,
"RN": rn,
"CIC": cic,
"tread": tread,
"pixscale": pixscale,
}
det_data = extract_arrays(det_modes)
char_data = extract_arrays(char_modes)
# Write File
with open(output_path, "w") as f:
f.write(";This is an exported input file for AYO from EXOSIMS\n\n")
f.write(";--- GENERAL PARAMETERS ---\n")
f.write("AYO_version = 'v17' ; \n")
if hasattr(D, "unit"):
d_val = D.to(u.m).value
else:
d_val = float(D)
f.write(
f"D = {d_val:.5f} ;(m) {{scalar}} circumscribed diameter of telescope\n"
)
if hasattr(mission_life, "unit"):
ml_val = mission_life.to_value(u.yr)
f.write(
f"mission_lifetime = {ml_val:.2f} ;(years) {{scalar}} total lifetime\n"
)
survey = ml_val * self.data.get("missionPortion", 0.5)
f.write(f"total_survey_time = {survey:.2f} ;(years) {{scalar}}\n")
else:
ml_val = float(mission_life)
f.write(
f"mission_lifetime = {ml_val:.2f} ;(years) {{scalar}} total lifetime\n"
)
survey = ml_val * self.data.get("missionPortion", 0.5)
f.write(f"total_survey_time = {survey:.2f} ;(years) {{scalar}}\n")
f.write(f"pitch = {to_ayo_list(pitch)} ;(degrees) {{2-element vector}}\n")
f.write("\n")
f.write(";--- CORONGRAPH PARAMETERS ---\n")
f.write("coronagraph1 = 'EXPORTED/coronagraph' ; {scalar}\n")
f.write(f"raw_contrast_floor = {contrast:.2e} ; {{scalar}}\n")
f.write(f"IWA = {iwa_val:.4f} ;(lambda/D) {{scalar}}\n")
f.write(f"OWA = {owa_val:.4f} ;(lambda/D) {{scalar}}\n")
f.write("\n")
f.write(";--- DETECTION OBSERVATIONS ---\n")
if det_data["lambda"]:
f.write(
f"lambda = {to_ayo_list(det_data['lambda'])} ;(microns) {{array}}\n"
)
f.write(f"SR = {to_ayo_list(det_data['SR'])} ; {{array}}\n")
f.write(f"SNR = {to_ayo_list(det_data['SNR'])} ; {{array}}\n")
f.write(f"Toptical = {to_ayo_list(det_data['Toptical'])} ; {{array}}\n")
f.write(f"det_QE = {to_ayo_list(det_data['QE'])} ; {{array}}\n")
f.write(
f"det_DC = {to_ayo_list(det_data['DC'])}"
" ;(counts pix^-1 s^-1) {array}\n"
)
f.write(
f"det_RN = {to_ayo_list(det_data['RN'])}"
" ;(counts pix^-1 read^-1) {array}\n"
)
f.write(f"det_CIC = {to_ayo_list(det_data['CIC'])} ; {{array}}\n")
f.write(f"det_tread = {to_ayo_list(det_data['tread'])} ;(s) {{array}}\n")
if det_data["pixscale"]:
ps = np.mean(det_data["pixscale"])
f.write(f"det_pixscale_mas = {ps:.4f} ;(mas) {{scalar}}\n")
f.write("\n;--- CHARACTERIZATION OBSERVATIONS ---\n")
if char_data["lambda"]:
f.write(
f"sc_lambda = {to_ayo_list(char_data['lambda'])} ;(microns) {{array}}\n"
)
f.write(f"sc_SR = {to_ayo_list(char_data['SR'])} ; {{array}}\n")
f.write(f"sc_SNR = {to_ayo_list(char_data['SNR'])} ; {{array}}\n")
f.write(f"sc_Toptical = {to_ayo_list(char_data['Toptical'])} ; {{array}}\n")
f.write(f"sc_det_QE = {to_ayo_list(char_data['QE'])} ; {{array}}\n")
f.write(f"sc_det_DC = {to_ayo_list(char_data['DC'])} ; {{array}}\n")
f.write(f"sc_det_RN = {to_ayo_list(char_data['RN'])} ; {{array}}\n")
f.write(f"sc_det_CIC = {to_ayo_list(char_data['CIC'])} ; {{array}}\n")
if char_data["pixscale"]:
ps = np.mean(char_data["pixscale"])
f.write(f"sc_det_pixscale_mas = {ps:.4f} ;(mas) {{scalar}}\n")
f.write("\n;--- MISC DEFAULTS ---\n")
f.write("nexozodis = 3.0 ; {{scalar}}\n")
f.write("target_vmag_cut = 30.0 ; {{scalar}}\n")
f.write("target_distance_cut = 100.0 ; {{scalar}}\n")
f.write("photap_rad = 0.85 ;(l/D) {{scalar}}\n")
f.write("sc_photap_rad = 0.85 ;(l/D) {{scalar}}\n")
logger.info(f"Exported EXOSIMS parameters to AYO file: {output_path}")