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}")