diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2f1c1554..16b1eff9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: runs-on: ${{ matrix.platform }} strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] platform: [ubuntu-latest, macos-latest] steps: - uses: actions/checkout@v6 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 63957da4..8bb8dfb0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,14 +4,13 @@ repos: hooks: - id: trailing-whitespace - id: name-tests-test + args: [--pytest-test-first] - id: end-of-file-fixer - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.0 + rev: v0.15.4 hooks: - # Run the linter. - - id: ruff - args: [ --fix ] - # Run the formatter. + - id: ruff-check + args: [--fix] - id: ruff-format - repo: https://github.com/compilerla/conventional-pre-commit rev: v4.3.0 diff --git a/docs/tutorials/yieldplotlib_tutorial.ipynb b/docs/tutorials/yieldplotlib_tutorial.ipynb index 8bea41cb..b1e6a29e 100644 --- a/docs/tutorials/yieldplotlib_tutorial.ipynb +++ b/docs/tutorials/yieldplotlib_tutorial.ipynb @@ -167,7 +167,7 @@ "\n", "def read_markdown_table(filepath):\n", " \"\"\"Parses our markdown table file into a Pandas DataFrame.\"\"\"\n", - " with open(filepath, \"r\") as f:\n", + " with open(filepath) as f:\n", " lines = f.readlines()\n", " clean_lines = [line for line in lines if \"|\" in line and \"---\" not in line]\n", " clean_data = \"\".join(clean_lines)\n", diff --git a/noxfile.py b/noxfile.py index 9262703a..f19715c4 100644 --- a/noxfile.py +++ b/noxfile.py @@ -3,7 +3,7 @@ import nox -@nox.session(venv_backend="uv", python=["3.10", "3.11", "3.12", "3.13"]) +@nox.session(venv_backend="uv", python=["3.11", "3.12", "3.13"]) def tests(session): """Run the test suite with pytest.""" # Install all test dependencies diff --git a/pyproject.toml b/pyproject.toml index 4294ce8b..55dcf401 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ ] license = { file = "LICENSE" } dynamic = ['readme', 'version'] -requires-python = ">=3.10" +requires-python = ">=3.11" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", @@ -69,7 +69,7 @@ test = ["nox", "pytest", "pytest-cov"] exclude = ["src/yieldplotlib/key_map.py"] [tool.ruff.lint] -select = ["D", "E", "F", "I"] +select = ["B", "D", "E", "F", "I", "UP", "RUF"] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/scripts/loader_test.py b/scripts/loader_test.py index 3e735182..ab65daa3 100644 --- a/scripts/loader_test.py +++ b/scripts/loader_test.py @@ -15,7 +15,7 @@ titles = ["EXOSIMS", "AYO"] fig, axs = plt.subplots(1, len(runs), figsize=(15, 5)) y_range = (0.001, 200) -for i, (run, title) in enumerate(zip(runs, titles)): +for i, (run, title) in enumerate(zip(runs, titles, strict=True)): star_L = run.get("star_L") star_dist = run.get("star_dist") star_comp = run.get("star_comp") diff --git a/src/yieldplotlib/__init__.py b/src/yieldplotlib/__init__.py index e2c833d4..2606c99f 100644 --- a/src/yieldplotlib/__init__.py +++ b/src/yieldplotlib/__init__.py @@ -1,23 +1,23 @@ """yieldplotlib - A library for plotting yield data.""" __all__ = [ + "KEY_MAP", "__version__", + "calculate_axis_limits_and_ticks", + "compare", "fetch_ayo_data", "fetch_exosims_data", "fetch_yip_data", - "KEY_MAP", - "logger", - "calculate_axis_limits_and_ticks", "get_nice_number", - "subplots", - "compare", + "logger", "multi", "panel", + "subplots", + "xy_grid", "ypl_cmap", "ypl_colors", "ypl_cycler", "ypl_rainbow", - "xy_grid", ] from importlib.resources import as_file, files diff --git a/src/yieldplotlib/core/__init__.py b/src/yieldplotlib/core/__init__.py index b380fb90..348db9c2 100644 --- a/src/yieldplotlib/core/__init__.py +++ b/src/yieldplotlib/core/__init__.py @@ -1,12 +1,12 @@ """Core module of yieldplotlib.""" __all__ = [ - "DirectoryNode", "CSVFile", + "DirectoryNode", "FileNode", "JSONFile", - "PickleFile", "Node", + "PickleFile", ] from .directory_node import DirectoryNode diff --git a/src/yieldplotlib/core/file_nodes.py b/src/yieldplotlib/core/file_nodes.py index cc9e43fb..657f48af 100644 --- a/src/yieldplotlib/core/file_nodes.py +++ b/src/yieldplotlib/core/file_nodes.py @@ -118,7 +118,7 @@ def __init__(self, file_path: Path): def load(self): """Load the JSON file into memory.""" - with open(self.file_path, "r") as f: + with open(self.file_path) as f: self.data = json.load(f) def _get(self, key: str, **kwargs): @@ -133,7 +133,7 @@ def json_recur(data, target_key): values[data["name"]] = data.get(key, None) except KeyError: values[data["instName"]] = data.get(key, None) - elif isinstance(v, (dict, list)): + elif isinstance(v, dict | list): json_recur(v, target_key) elif isinstance(data, list): for item in data: diff --git a/src/yieldplotlib/core/single_inputs.py b/src/yieldplotlib/core/single_inputs.py index 8b19b69b..69e7186f 100644 --- a/src/yieldplotlib/core/single_inputs.py +++ b/src/yieldplotlib/core/single_inputs.py @@ -13,14 +13,14 @@ class SingleInput(dict): def __init__(self, *args, **kwargs): """Initialise the SingleInput class.""" - super(SingleInput, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def apply(self, key, func): """Apply a function to all values of a given key.""" try: self[key] = func(self.get(key)) - except TypeError: - raise TypeError(f"Could not apply function to {key}") + except TypeError as err: + raise TypeError(f"Could not apply function to {key}") from err def check_units(self, key, desired_unit): """Checks that all values of key have the appropriate desired unit.""" @@ -30,15 +30,13 @@ def check_units(self, key, desired_unit): for value in iterator: if value.unit != desired_unit: raise AssertionError( - ( - f"Value {value} for {key} does not have desired " - f"unit {desired_unit}" - ) + f"Value {value} for {key} does not have desired " + f"unit {desired_unit}" ) - except AttributeError: + except AttributeError as err: raise AttributeError( f"{key} does not have a value of type astropy.units.Quantity" - ) + ) from err except TypeError: try: @@ -47,10 +45,10 @@ def check_units(self, key, desired_unit): f"Value {self.get(key)} for {key} does not have desired " f"unit {desired_unit}" ) - except AttributeError: + except AttributeError as err: raise AttributeError( f"{key} does not have a value of type astropy.units.Quantity" - ) + ) from err finally: logger.info("All unit checks passed.") diff --git a/src/yieldplotlib/generate_docs.py b/src/yieldplotlib/generate_docs.py index d3bca475..19870cd2 100755 --- a/src/yieldplotlib/generate_docs.py +++ b/src/yieldplotlib/generate_docs.py @@ -24,7 +24,6 @@ import io import os import sys -from typing import Optional import pandas as pd from google.oauth2 import service_account @@ -53,7 +52,7 @@ def parse_args(): def download_from_google_sheets( - sheet_id: str, credentials_json_path: Optional[str] = None + sheet_id: str, credentials_json_path: str | None = None ) -> str: """Download a CSV file from Google Sheets. @@ -128,7 +127,7 @@ def read_csv_file(file_path: str) -> str: CSV content as a string. """ try: - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: return f.read() except Exception as e: print(f"Error reading CSV file: {e}") diff --git a/src/yieldplotlib/generate_key_map.py b/src/yieldplotlib/generate_key_map.py index 239f001e..da655d6a 100644 --- a/src/yieldplotlib/generate_key_map.py +++ b/src/yieldplotlib/generate_key_map.py @@ -78,11 +78,9 @@ def download_from_google_sheets(sheet_id, output_path, credentials_json_path=Non from googleapiclient.discovery import build except ImportError: print( - ( - "Error: Google API libraries not installed. " - "Run: pip install google-auth google-auth-oauthlib" - " google-api-python-client" - ) + "Error: Google API libraries not installed. " + "Run: pip install google-auth google-auth-oauthlib" + " google-api-python-client" ) sys.exit(1) @@ -91,7 +89,7 @@ def download_from_google_sheets(sheet_id, output_path, credentials_json_path=Non # First check for credentials file path if credentials_json_path: try: - with open(credentials_json_path, "r") as f: + with open(credentials_json_path) as f: credentials_info = json.load(f) credentials = service_account.Credentials.from_service_account_info( credentials_info, @@ -106,10 +104,8 @@ def download_from_google_sheets(sheet_id, output_path, credentials_json_path=Non credentials_b64 = os.environ.get("GOOGLE_CREDENTIALS_B64") if not credentials_b64: print( - ( - "Error: No credentials provided. Either set GOOGLE_CREDENTIALS_B64" - " or provide --credentials" - ) + "Error: No credentials provided. Either set GOOGLE_CREDENTIALS_B64" + " or provide --credentials" ) sys.exit(1) @@ -206,10 +202,8 @@ def parse_csv(input_csv): key = exo_name else: print( - ( - f"Warning: Row {row_num} has no 'yieldplotlib name'" - " and no clear library names. Skipping." - ) + f"Warning: Row {row_num} has no 'yieldplotlib name'" + " and no clear library names. Skipping." ) continue # Skip rows that don't meet criteria @@ -255,10 +249,8 @@ def parse_csv(input_csv): else: # If neither library has complete info, skip the row print( - ( - f"Warning: Row {row_num} does not have complete " - "information for either library. Skipping." - ) + f"Warning: Row {row_num} does not have complete " + "information for either library. Skipping." ) continue @@ -303,10 +295,8 @@ def add_to_key_map(entry, context): if key in key_map: print( - ( - f"Warning: Duplicate key '{key}' found in {context}." - " Overwriting previous entry." - ) + f"Warning: Duplicate key '{key}' found in {context}." + " Overwriting previous entry." ) key_map[key] = map_entry diff --git a/src/yieldplotlib/load/__init__.py b/src/yieldplotlib/load/__init__.py index 6d61099e..d961a23e 100644 --- a/src/yieldplotlib/load/__init__.py +++ b/src/yieldplotlib/load/__init__.py @@ -2,11 +2,11 @@ __all__ = [ "AYODirectory", - "YIPDirectory", "DRMDirectory", "EXOSIMSCSVDirectory", "EXOSIMSDirectory", "SPCDirectory", + "YIPDirectory", ] from .ayo_directory import AYODirectory diff --git a/src/yieldplotlib/load/ayo/ayo_input.py b/src/yieldplotlib/load/ayo/ayo_input.py index 1634fd22..d8746588 100644 --- a/src/yieldplotlib/load/ayo/ayo_input.py +++ b/src/yieldplotlib/load/ayo/ayo_input.py @@ -1,11 +1,13 @@ """Node for handling input .ayo files.""" +import json from pathlib import Path import astropy.units as u import numpy as np import pyparsing as pp from lod_unit import lod +from yippy.coronagraph import Coronagraph from yieldplotlib.core.file_nodes import FileNode from yieldplotlib.logger import logger @@ -31,7 +33,7 @@ def __init__(self, file_path: Path): def load(self): """Load the text file into memory.""" - with open(self.file_path, "r", encoding="utf-8") as f: + with open(self.file_path, encoding="utf-8") as f: self.raw_data = f.read() logger.info(f"Loaded AYO input file: {self.file_path}") @@ -147,7 +149,7 @@ def process_expression(tokens): identifier.setResultsName("key") + pp.Suppress("=") + value_types.setResultsName("value") - + pp.Suppress(";") + + pp.Optional(pp.Suppress(";")) # Semicolon is optional + pp.Optional(unit_literal) + pp.Optional(pp.SkipTo("{").setResultsName("comment_before_type")) + pp.Optional(type_literal) @@ -231,3 +233,855 @@ def _convert_unit(self, parsed_unit): n_units += 1 return final_unit + + def export_exosims( + self, + output_path: str, + base_file: Path | str | None = None, + detection_wavelength_nm: float | None = None, + characterization_wavelength_nm: float | None = None, + **kwargs, + ): + """Export the AYO input to an EXOSIMS JSON file. + + Args: + output_path: + Path to write the output JSON file. + base_file: + Optional path to a base EXOSIMS JSON file. If provided, AYO + parameters will overwrite equivalent parameters in the base file, + and all other parameters will be preserved. + detection_wavelength_nm: + Wavelength in nanometers to use for detection mode. The closest + wavelength in the lambda array will be selected. If None, uses + the middle wavelength (default). + characterization_wavelength_nm: + Wavelength in nanometers to use for characterization mode. The + closest wavelength in the sc_lambda array will be selected. If + None, uses the middle wavelength (default). + **kwargs: + Additional keyword arguments to include in the output JSON. + These will overwrite any existing values. Useful for setting + paths like cachedir, e.g.: + cachedir="$HOME/.EXOSIMS/2025/Natasha_JATIS" + """ + + # Helper to round floats to avoid repeating decimals + def round_float(val, decimals=6): + """Round float values to specified decimal places.""" + if val is None: + return None + if isinstance(val, int | float): + return round(float(val), decimals) + if isinstance(val, list | np.ndarray): + return [round_float(v, decimals) for v in val] + return val + + # Helper to safely get values with units or defaults + def get_val(key, unit=None, default=None): + val = self.data.get(key) + if val is None: + return default + # Check if val is a Quantity (has .unit or .value) + if hasattr(val, "value"): + if unit: + try: + return val.to(unit).value + except u.UnitConversionError: + return val.value + return val.value + return val + + # Helper for arrays (return value if scalar, or array item) + def get_array_val(key, idx, default=None, unit=None): + val = self.data.get(key) + if val is None: + return default + + # If Quantity, convert to target unit if specified + if hasattr(val, "value"): + if unit is not None: + try: + val = val.to(unit) + except u.UnitConversionError: + pass # Keep original if conversion fails + v = val.value + else: + v = val + + if isinstance(v, list | np.ndarray): + if idx < len(v): + return v[idx] + # Warn when index is out of bounds - likely array length mismatch + raise ValueError( + f"Index {idx} out of bounds for '{key}' (length {len(v)}). " + f"Check that wavelength arrays have matching lengths." + ) + return v # Scalar + + # Load base file if provided + if base_file is not None: + base_path = Path(base_file) + if not base_path.exists(): + raise FileNotFoundError(f"Base EXOSIMS file not found: {base_path}") + with open(base_path) as f: + out = json.load(f) + logger.info(f"Loaded base EXOSIMS file: {base_path}") + else: + # Initialize with defaults if no base file + out = { + "missionLife": 5.0, + "missionStart": 60634, + "pupilDiam": 4.0, + "koAngles_Sun": [45, 135], + "modules": { + "PlanetPopulation": "AlbedoByRadiusDulzPlavchan", + "StarCatalog": "HPIC", + "OpticalSystem": "Nemati", + "ZodiacalLight": "Mennesson", + "BackgroundSources": "GalaxiesFaintStars", + "PlanetPhysicalModel": "Forecaster", + "Observatory": "WFIRSTObservatoryL2", + "TimeKeeping": " ", + "PostProcessing": " ", + "Completeness": "BrownCompleteness", + "TargetList": " ", + "SimulatedUniverse": "DulzPlavchanUniverseEarthsOnly", + "SurveySimulation": "coroOnlyScheduler", + "SurveyEnsemble": "EXOSIMS_local.IPClusterEnsembleJPL2", + }, + "scienceInstruments": [], + "starlightSuppressionSystems": [], + "observingModes": [], + } + + # Set settlingTime to 0 since all overhead is handled by ohTime in the system + # AYO's toverhead_fixed includes all overhead (slew + settle + initial + # dark hole digging) + out["settlingTime"] = 0.0 + + # Extract AYO parameters and overwrite base values + D_m = get_val("D", u.m, None) + if D_m is not None: + out["pupilDiam"] = round_float(float(D_m)) + + # Get nchannels for multi-channel coronagraph simulation. + # nchannels splits light into identical channels - EXOSIMS doesn't have + # a direct analog, so we approximate by: + # 1. Multiplying BW by nchannels (increased signal from combined channels) + # 2. Multiplying lenslSamp by sqrt(nchannels) (since lenslSamp is squared + # in EXOSIMS to get Npix, this gives Npix proportional to nchannels) + nchannels = get_val("nchannels", None, 1) + if nchannels is None: + nchannels = 1 + else: + nchannels = int(nchannels) + if nchannels > 1: + logger.info( + f"Applying nchannels={nchannels} to detection only: " + f"optics *= {nchannels}, " + f"lenslSamp *= sqrt({nchannels}) = {np.sqrt(nchannels):.4f}" + ) + + mission_life_yr = get_val("mission_lifetime", u.yr, None) + if mission_life_yr is not None: + out["missionLife"] = round_float(float(mission_life_yr)) + + # AYO pitch is [min, max] from Sun + # Will be set in starlightSuppressionSystems later + ko_sun = self.data.get("pitch") + if ko_sun is not None: + if hasattr(ko_sun, "unit"): + ko_sun = ko_sun.to(u.deg).value.tolist() + elif isinstance(ko_sun, np.ndarray): + ko_sun = ko_sun.tolist() + else: + ko_sun = None + + # Handle missionPortion + total_time = get_val("total_survey_time", u.yr, None) + mission_life_yr = out.get("missionLife", 5.0) + if total_time is not None and mission_life_yr > 0: + out["missionPortion"] = round_float(float(total_time / mission_life_yr)) + + # Map nexozodis to fixed_nEZ_val + nexozodis = get_val("nexozodis", zodis, None) + if nexozodis is not None: + # nexozodis is in zodis (dimensionless unit), convert to float + if hasattr(nexozodis, "value"): + out["fixed_nEZ_val"] = round_float(float(nexozodis.value)) + else: + out["fixed_nEZ_val"] = round_float(float(nexozodis)) + + # Map noisefloor_PPF to ppFact and ppFact_char (inverse relationship) + noisefloor_PPF = get_val("noisefloor_PPF", None, None) + if noisefloor_PPF is not None: + pp_fact_val = round_float(1.0 / float(noisefloor_PPF)) + out["ppFact"] = pp_fact_val + out["ppFact_char"] = pp_fact_val + logger.info( + f"Set ppFact and ppFact_char to {pp_fact_val} " + f"(from noisefloor_PPF={noisefloor_PPF})" + ) + + # Update optional_filters based on AYO target list cuts + # Initialize optional_filters if not present + if "optional_filters" not in out: + out["optional_filters"] = {} + + # Update vmag_filter with target_vmag_cut + target_vmag_cut = get_val("target_vmag_cut", None, None) + if target_vmag_cut is not None: + # Get existing vmag_range from base file if present, + # otherwise use default min + existing_vmag_filter = out["optional_filters"].get("vmag_filter", {}) + existing_params = existing_vmag_filter.get("params", {}) + existing_range = existing_params.get("vmag_range", [0, 15]) + vmag_min = existing_range[0] if isinstance(existing_range, list) else 2 + + out["optional_filters"]["vmag_filter"] = { + "enabled": True, + "params": { + "vmag_range": [ + round_float(vmag_min), + round_float(float(target_vmag_cut)), + ] + }, + } + logger.info( + f"Updated vmag_filter with range [{vmag_min:.1f}, " + f"{target_vmag_cut:.1f}]" + ) + + # Update distance_filter with target_distance_cut + target_distance_cut = get_val("target_distance_cut", u.pc, None) + if target_distance_cut is not None: + out["optional_filters"]["distance_filter"] = { + "enabled": True, + "params": {"max_distance": round_float(float(target_distance_cut))}, + } + logger.info( + f"Updated distance_filter with max_distance = " + f"{target_distance_cut:.1f} pc" + ) + + # Load coronagraph using yippy and generate starlightSuppressionSystem + coronagraph_path = self.data.get("coronagraph1") + + # Initialize coronagraph design bandwidth (will be set if coronagraph loaded) + coro_design_bw = None + + if coronagraph_path: + # Remove quotes if present (from string parsing) + if isinstance(coronagraph_path, str): + coronagraph_path = coronagraph_path.strip("'\"") + + # Try to find the coronagraph directory + # First try as absolute path, then relative to AYO file directory + coro_path = Path(coronagraph_path) + if not coro_path.is_absolute(): + # Try relative to AYO file's directory + ayo_dir = self.file_path.parent + coro_path = ayo_dir / coronagraph_path + # If still not found, try as-is (might be in a standard location) + if not coro_path.exists(): + coro_path = Path(coronagraph_path) + + if coro_path.exists(): + logger.info(f"Loading coronagraph from: {coro_path}") + # Load coronagraph with yippy + coro = Coronagraph(coro_path) + + # Extract coronagraph's design fractional bandwidth from header + # AYO uses min(1/SR, coro_design_bw) as the effective bandwidth + if ( + coro.header.minlam is not None + and coro.header.maxlam is not None + and coro.header.lambda0 is not None + ): + coro_design_bw = ( + ( + (coro.header.maxlam - coro.header.minlam) + / coro.header.lambda0 + ) + .decompose() + .value + ) + logger.info( + f"Coronagraph design bandwidth: {coro_design_bw:.4f} " + f"(from MINLAM={coro.header.minlam}, " + f"MAXLAM={coro.header.maxlam}, LAMBDA={coro.header.lambda0})" + ) + + # Generate EXOSIMS files and get system info + # Use AYO parameters if available, otherwise defaults + aperture_radius = get_val("photap_rad", lod, 0.7) + if aperture_radius is None: + aperture_radius = 0.7 + else: + aperture_radius = aperture_radius + + # Get IWA/OWA from AYO if available, otherwise use defaults + iwa_lod = get_val("IWA", lod, None) + owa_lod = get_val("OWA", lod, None) + + # Generate EXOSIMS format files + exosims_specs = coro.to_exosims( + aperture_radius_lod=aperture_radius, + fit_gaussian_for_core_area=False, + use_phot_aperture_as_min=False, + units="LAMBDA/D", + ) + + # Get the system from the generated specs + if exosims_specs.get("starlightSuppressionSystems"): + syst = exosims_specs["starlightSuppressionSystems"][0].copy() + + # Convert relative FITS file paths to absolute paths + exosims_dir = Path(coro_path, "exosims") + fits_files = ["occ_trans", "core_thruput", "core_mean_intensity"] + for fits_key in fits_files: + if syst.get(fits_key): + # If it's a relative path, make it absolute + fits_path = Path(syst[fits_key]) + if not fits_path.is_absolute(): + fits_path = exosims_dir / fits_path + syst[fits_key] = str(fits_path.resolve()) + + # Override with AYO parameters if specified + contrast = get_val("raw_contrast_floor", None, None) + if contrast is not None: + syst["core_contrast"] = float(contrast) + + overhead = get_val("toverhead_fixed", u.d, None) + if overhead is not None: + syst["ohTime"] = round_float(float(overhead)) + + # Override IWA/OWA from AYO if specified + # AYO IWA/OWA are in LAMBDA/D, same as yippy output + if iwa_lod is not None: + if hasattr(iwa_lod, "value"): + syst["IWA"] = round_float(float(iwa_lod.to(lod).value)) + else: + syst["IWA"] = round_float(float(iwa_lod)) + if owa_lod is not None: + if hasattr(owa_lod, "value"): + syst["OWA"] = round_float(float(owa_lod.to(lod).value)) + else: + syst["OWA"] = round_float(float(owa_lod)) + + # Round other float values in the system + for key in ["lam", "deltaLam", "BW", "core_area"]: + if key in syst and syst[key] is not None: + syst[key] = round_float(syst[key]) + + # Update pupilDiam from coronagraph if not set by AYO + if D_m is None and "pupilDiam" in exosims_specs: + out["pupilDiam"] = round_float(exosims_specs["pupilDiam"]) + + # Extract obscurFac and shapeFac from exosims_specs + if "obscurFac" in exosims_specs: + out["obscurFac"] = round_float(exosims_specs["obscurFac"]) + if "shapeFac" in exosims_specs: + out["shapeFac"] = round_float(exosims_specs["shapeFac"]) + + # Set koAngles_Sun from AYO pitch, preserve other koAngles + if ko_sun is not None: + syst["koAngles_Sun"] = [round_float(x) for x in ko_sun] + # Preserve other koAngles if they exist in base file + if ( + "starlightSuppressionSystems" in out + and len(out["starlightSuppressionSystems"]) > 0 + ): + base_syst = out["starlightSuppressionSystems"][0] + ko_keys = [ + "koAngles_Small", + "koAngles_Moon", + "koAngles_Earth", + ] + for ko_key in ko_keys: + if ko_key in base_syst: + syst[ko_key] = base_syst[ko_key] + + # Set BW in starlightSuppressionSystem from detection wavelength SR + # Calculate BW from detection wavelength's spectral resolution + lams = self.data.get("lambda") + if lams is not None: + if hasattr(lams, "unit"): + lams_nm = lams.to(u.nm).value + else: + lams_nm = np.array(lams) * 1000 + if not isinstance(lams_nm, list | np.ndarray): + lams_nm = [lams_nm] + lams_nm = np.array(lams_nm) + + # Use same wavelength selection logic as for detection mode + if detection_wavelength_nm is None: + det_idx = len(lams_nm) // 2 + else: + det_idx = int( + np.argmin(np.abs(lams_nm - detection_wavelength_nm)) + ) + + det_sr = get_array_val("SR", det_idx, 5.0) + sr_bw = 1.0 / float(det_sr) if det_sr > 0 else 0.2 + # Take minimum of 1/SR and coronagraph design bandwidth + if coro_design_bw is not None: + base_bw = min(sr_bw, coro_design_bw) + if base_bw < sr_bw: + logger.info( + f"BW limited by coronagraph design: " + f"{base_bw:.4f} < 1/SR={sr_bw:.4f}" + ) + else: + base_bw = sr_bw + # DO NOT multiply BW by nchannels. Keep it physical. + syst["BW"] = round_float(base_bw) + # Remove deltaLam so EXOSIMS calculates it from our BW + # (EXOSIMS recalculates BW = deltaLam/lam, so if deltaLam + # is present, our BW would be ignored) + syst.pop("deltaLam", None) + elif "BW" not in syst: + # Default if no detection wavelengths + default_bw = 0.2 + if coro_design_bw is not None: + default_bw = min(default_bw, coro_design_bw) + # DO NOT multiply BW by nchannels. Keep it physical. + syst["BW"] = round_float(default_bw) + syst.pop("deltaLam", None) + + # Remove core_contrast if core_mean_intensity is set + # (EXOSIMS uses one or the other, not both) + if syst.get("core_mean_intensity"): + syst.pop("core_contrast", None) + + # Replace or append the system + if "starlightSuppressionSystems" not in out: + out["starlightSuppressionSystems"] = [] + if len(out["starlightSuppressionSystems"]) == 0: + out["starlightSuppressionSystems"].append(syst) + else: + out["starlightSuppressionSystems"][0] = syst + else: + logger.warning( + "No starlightSuppressionSystems found in coronagraph specs" + ) + # except Exception as e: + # logger.warning( + # f"Failed to load coronagraph from {coro_path}: {e}. " + # "Falling back to manual construction." + # ) + # # Fall through to manual construction + # coronagraph_path = None + + # Fallback: Manual construction if coronagraph not found or failed to load + if ( + not coronagraph_path + or "starlightSuppressionSystems" not in out + or len(out["starlightSuppressionSystems"]) == 0 + ): + logger.info("Using manual starlightSuppressionSystem construction") + # Get or create starlightSuppressionSystems + if "starlightSuppressionSystems" not in out: + out["starlightSuppressionSystems"] = [] + syst = {} + elif len(out["starlightSuppressionSystems"]) == 0: + # Create new system if list is empty + syst = {} + else: + # Update first system with AYO parameters + syst = out["starlightSuppressionSystems"][0].copy() + + # Convert AYO IWA (L/D) to EXOSIMS + # (assume arcsec for safety or standard input) + # Using a reference lambda of 500nm + ref_lam_m = 500e-9 + D_m = out.get("pupilDiam", 4.0) + iwa_lod = get_val("IWA", lod, None) + owa_lod = get_val("OWA", lod, None) + + if iwa_lod is not None or owa_lod is not None: + # Convert to arcsec: IWA_as = IWA_lod * (lam/D)_as + # (lam/D)_rad = lam/D. (lam/D)_as = 206265 * lam/D + lod_to_as = 206265.0 * (ref_lam_m / D_m) + + if iwa_lod is not None: + syst["IWA"] = round_float(float(iwa_lod * lod_to_as)) + if owa_lod is not None: + syst["OWA"] = round_float(float(owa_lod * lod_to_as)) + + # Update system parameters from AYO + contrast = get_val("raw_contrast_floor", None, None) + if contrast is not None: + syst["core_contrast"] = round_float(float(contrast)) + + overhead = get_val("toverhead_fixed", u.d, None) + if overhead is not None: + syst["ohTime"] = round_float(float(overhead)) + + # Set default system name if not present + if "name" not in syst: + syst["name"] = "AYO_Coronagraph" + if "lam" not in syst: + syst["lam"] = round_float(500) # nm + # Set BW from detection wavelength SR if available + if "BW" not in syst: + lams = self.data.get("lambda") + if lams is not None: + if hasattr(lams, "unit"): + lams_nm = lams.to(u.nm).value + else: + lams_nm = np.array(lams) * 1000 + if not isinstance(lams_nm, list | np.ndarray): + lams_nm = [lams_nm] + lams_nm = np.array(lams_nm) + + # Use same wavelength selection logic as for detection mode + if detection_wavelength_nm is None: + det_idx = len(lams_nm) // 2 + else: + det_idx = int( + np.argmin(np.abs(lams_nm - detection_wavelength_nm)) + ) + + det_sr = get_array_val("SR", det_idx, 5.0) + sr_bw = 1.0 / float(det_sr) if det_sr > 0 else 0.2 + # Take minimum of 1/SR and coronagraph design bandwidth + if coro_design_bw is not None: + base_bw = min(sr_bw, coro_design_bw) + if base_bw < sr_bw: + logger.info( + f"BW limited by coronagraph design: " + f"{base_bw:.4f} < 1/SR={sr_bw:.4f}" + ) + else: + base_bw = sr_bw + # DO NOT multiply BW by nchannels. Keep it physical. + syst["BW"] = round_float(base_bw) + # Remove deltaLam so EXOSIMS calculates it from our BW + syst.pop("deltaLam", None) + else: + default_bw = 0.2 + if coro_design_bw is not None: + default_bw = min(default_bw, coro_design_bw) + # DO NOT multiply BW by nchannels. Keep it physical. + syst["BW"] = round_float(default_bw) + syst.pop("deltaLam", None) + + # Set koAngles_Sun from AYO pitch, preserve other koAngles from base + if ko_sun is not None: + syst["koAngles_Sun"] = [round_float(x) for x in ko_sun] + # Preserve other koAngles if they exist in base file + if ( + "starlightSuppressionSystems" in out + and len(out["starlightSuppressionSystems"]) > 0 + ): + base_syst = out["starlightSuppressionSystems"][0] + for ko_key in ["koAngles_Small", "koAngles_Moon", "koAngles_Earth"]: + if ko_key in base_syst: + syst[ko_key] = base_syst[ko_key] + + # Remove core_contrast if core_mean_intensity is set + # (EXOSIMS uses one or the other, not both) + if syst.get("core_mean_intensity"): + syst.pop("core_contrast", None) + + # Replace or append the system + if len(out["starlightSuppressionSystems"]) == 0: + out["starlightSuppressionSystems"].append(syst) + else: + out["starlightSuppressionSystems"][0] = syst + + # Get system name for use in modes + syst_name = "AYO_Coronagraph" + if ( + out.get("starlightSuppressionSystems") + and len(out["starlightSuppressionSystems"]) > 0 + ): + syst_name = out["starlightSuppressionSystems"][0].get("name", syst_name) + + # Get or create scienceInstruments and observingModes + if "scienceInstruments" not in out: + out["scienceInstruments"] = [] + if "observingModes" not in out: + out["observingModes"] = [] + + # Clear existing instruments and modes if AYO defines them + instruments = [] + modes = [] + + # Get Tcontam (contamination throughput factor) - applies to both + # detection and characterization + Tcontam = get_val("Tcontam", None, 1.0) + if Tcontam is None: + Tcontam = 1.0 + else: + Tcontam = float(Tcontam) + logger.info(f"Applying Tcontam={Tcontam:.6f} to optics throughput") + + # SAFEGUARD: Check for potential double-counting of throughput factors + # If the base file has syst['optics'] set AND AYO has Tcontam, this would + # cause the contamination throughput to be applied twice: + # - Once via Tcontam being multiplied into inst['optics'] + # - Once via syst['optics'] in the base file + # EXOSIMS computes: attenuation = inst['optics'] * syst['optics'] + if Tcontam != 1.0: + for syst in out.get("starlightSuppressionSystems", []): + if "optics" in syst: + syst_optics = syst["optics"] + syst_name = syst.get("name", "unnamed") + logger.warning( + f"CONFLICT DETECTED: Base file has " + f"syst['{syst_name}']['optics']={syst_optics} AND AYO has " + f"Tcontam={Tcontam}. Would cause double-counting. " + f"Removing syst['optics']." + ) + del syst["optics"] + + # Process Detection + lams = self.data.get("lambda") + if lams is not None: + # Get values in nm + if hasattr(lams, "unit"): + lams_nm = lams.to(u.nm).value + else: + lams_nm = ( + np.array(lams) * 1000 + ) # Assume microns if no unit, convert to nm + + if not isinstance(lams_nm, list | np.ndarray): + lams_nm = [lams_nm] + lams_nm = np.array(lams_nm) + + # Select which wavelength to use + if detection_wavelength_nm is None: + # Default: use middle wavelength + i = len(lams_nm) // 2 + else: + # Find closest wavelength to the requested value + i = int(np.argmin(np.abs(lams_nm - detection_wavelength_nm))) + logger.info( + f"Selected detection wavelength {lams_nm[i]:.1f} nm " + f"(closest to requested {detection_wavelength_nm:.1f} nm)" + ) + + # Create Mode for selected wavelength only + lam = lams_nm[i] + sr = get_array_val("SR", i, 5.0) + snr = get_array_val("SNR", i, 5.0) + + # Create Detection Instrument using values at selected wavelength index + # Format: imaging_{wavelength_nm}_ayo + lam_int = round(float(lam)) + inst_det_name = f"imaging_{lam_int}_ayo" + + # --- PIXEL MATH CORRECTION --- + # EXOSIMS squares lenslSamp to get Npix. AYO defines Npix directly. + # We want lenslSamp^2 = base * nchannels + base_npix_det = float(get_array_val("det_npix_multiplier", i, 1.0)) + corrected_lenslSamp_det = np.sqrt(base_npix_det * nchannels) + + # --- THROUGHPUT & CHANNELS CORRECTION --- + # Combine Toptical, dQE, nchannels, and Tcontam into one + # "Effective Optics" term. + # - dQE scales Signal & Noise Floor (matching AYO behavior) + # - nchannels sums the signal from multiple detectors + # - Tcontam accounts for contamination throughput losses + dQE_det = float(get_array_val("det_dQE", i, 0.75)) + Topt_det = float(get_array_val("Toptical", i, 0.5)) + effective_optics_det = Topt_det * dQE_det * nchannels * Tcontam + + inst_det = { + "name": inst_det_name, + "pixelScale": round_float( + float(get_val("det_pixscale_mas", None, 10.0) / 1000.0) + ), # as + "idark": round_float(float(get_array_val("det_DC", i, 0))), + "CIC": round_float(float(get_array_val("det_CIC", i, 1e-3))), + "sread": round_float(float(get_array_val("det_RN", i, 0.0))), + "texp": round_float(float(get_array_val("det_tread", i, 100.0, u.s))), + "texp_flag": False, + "QE": round_float(float(get_array_val("det_QE", i, 0.9))), + "optics": round_float(effective_optics_det), # Handle dQE and nchannels + "PCeff": 1.0, # Set to 1.0 to avoid double counting + "lenslSamp": round_float(corrected_lenslSamp_det), + } + instruments.append(inst_det) + + mode = { + "instName": inst_det_name, + "systName": syst_name, + "detectionMode": True, + "lam": round_float(float(lam)), + "SNR": round_float(float(snr)), + } + modes.append(mode) + + # Process Characterization + sc_lams = self.data.get("sc_lambda") + if sc_lams is not None: + if hasattr(sc_lams, "unit"): + sc_lams_nm = sc_lams.to(u.nm).value + else: + sc_lams_nm = np.array(sc_lams) * 1000 + + if not isinstance(sc_lams_nm, list | np.ndarray): + sc_lams_nm = [sc_lams_nm] + sc_lams_nm = np.array(sc_lams_nm) + + # Select which wavelength to use + if characterization_wavelength_nm is None: + # Default: use middle wavelength + i = len(sc_lams_nm) // 2 + else: + # Find closest wavelength to the requested value + i = int(np.argmin(np.abs(sc_lams_nm - characterization_wavelength_nm))) + logger.info( + f"Selected characterization wavelength {sc_lams_nm[i]:.1f} nm " + f"(closest to requested {characterization_wavelength_nm:.1f} nm)" + ) + + # Get spectral resolution for characterization instrument + lam = sc_lams_nm[i] + sr = get_array_val("sc_SR", i, 5.0) + snr = get_array_val("sc_SNR", i, 5.0) + + # Create Characterization Instrument using values at selected wavelength + # Format: spectro_{wavelength_nm}_ayo + lam_int = round(float(lam)) + inst_char_name = f"spectro_{lam_int}_ayo" + + # --- PIXEL MATH CORRECTION --- + # EXOSIMS squares lenslSamp to get Npix. AYO defines Npix directly. + # We want lenslSamp^2 = base (nchannels only affects detection) + base_npix_char = float(get_array_val("sc_det_npix_multiplier", i, 1.0)) + corrected_lenslSamp_char = np.sqrt(base_npix_char) + + # --- THROUGHPUT & CHANNELS CORRECTION --- + # Combine Toptical, dQE, and Tcontam into one "Effective Optics" term. + # - dQE scales Signal & Noise Floor (matching AYO behavior) + # - Tcontam accounts for contamination throughput losses + # - nchannels only affects detection, not characterization + dQE_char = float(get_array_val("sc_det_dQE", i, 0.75)) + Topt_char = float(get_array_val("sc_Toptical", i, 0.5)) + effective_optics_char = Topt_char * dQE_char * Tcontam + + inst_char = { + "name": inst_char_name, + "pixelScale": round_float( + float(get_val("sc_det_pixscale_mas", None, 10.0) / 1000.0) + ), + "idark": round_float(float(get_array_val("sc_det_DC", i, 0))), + "CIC": round_float(float(get_array_val("sc_det_CIC", i, 1e-3))), + "sread": round_float(float(get_array_val("sc_det_RN", i, 0.0))), + "texp": round_float( + float(get_array_val("sc_det_tread", i, 100.0, u.s)) + ), + "texp_flag": False, + "QE": round_float(float(get_array_val("sc_det_QE", i, 0.9))), + "optics": round_float(effective_optics_char), # dQE and nchannels + "PCeff": 1.0, # Set to 1.0 to avoid double counting + "Rs": round_float(float(sr)), + "lenslSamp": round_float(corrected_lenslSamp_char), + } + instruments.append(inst_char) + + # Create Mode for selected wavelength only + mode = { + "instName": inst_char_name, + "systName": syst_name, + "detectionMode": False, + "lam": round_float(float(lam)), + "SNR": round_float(float(snr)), + } + modes.append(mode) + + # Only replace instruments/modes if AYO defines them + if instruments: + out["scienceInstruments"] = instruments + if modes: + out["observingModes"] = modes + + # Remove koAngles_Sun from top level if it exists + # (it's now in starlightSuppressionSystems) + if "koAngles_Sun" in out: + del out["koAngles_Sun"] + + # Apply any additional kwargs as top-level overrides + if kwargs: + for key, value in kwargs.items(): + out[key] = value + logger.info(f"Applied override: {key} = {value}") + + # Reorder output to match typical EXOSIMS structure: + # 1. Top-level parameters (non-array, non-object, excluding special arrays) + # 2. erange, arange, Rprange, optional_filters + # 3. Other objects (anything not in structured_keys or special) + # 4. scienceInstruments + # 5. starlightSuppressionSystems + # 6. observingModes + # 7. modules + # 8. completeness_specs (special, goes after modules) + + # Separate parameters into categories + top_level = {} + special_arrays = {} + structured = {} + other_objects = {} + completeness_specs = None + + # Special arrays that go before scienceInstruments + special_array_keys = [ + "err_progression", + "erange", + "arange", + "Rprange", + "optional_filters", + ] + + structured_keys = [ + "scienceInstruments", + "starlightSuppressionSystems", + "observingModes", + "modules", + ] + + for key, value in out.items(): + if key in structured_keys: + structured[key] = value + elif key in special_array_keys: + special_arrays[key] = value + elif key == "completeness_specs": + completeness_specs = value + elif isinstance(value, dict | list) and key != "modules": + other_objects[key] = value + else: + top_level[key] = value + + # Reconstruct in desired order + ordered_out = {} + ordered_out.update(top_level) + # Add special arrays before scienceInstruments + for key in special_array_keys: + if key in special_arrays: + ordered_out[key] = special_arrays[key] + # Add other objects before structured keys + ordered_out.update(other_objects) + if "scienceInstruments" in structured: + ordered_out["scienceInstruments"] = structured["scienceInstruments"] + if "starlightSuppressionSystems" in structured: + ordered_out["starlightSuppressionSystems"] = structured[ + "starlightSuppressionSystems" + ] + if "observingModes" in structured: + ordered_out["observingModes"] = structured["observingModes"] + if "modules" in structured: + ordered_out["modules"] = structured["modules"] + # Add completeness_specs after modules + if completeness_specs is not None: + ordered_out["completeness_specs"] = completeness_specs + + with open(output_path, "w") as f: + json.dump(ordered_out, f, indent=4) + + logger.info(f"Exported AYO parameters to EXOSIMS file: {output_path}") diff --git a/src/yieldplotlib/load/exosims/__init__.py b/src/yieldplotlib/load/exosims/__init__.py index 1b960f17..4f4de42a 100644 --- a/src/yieldplotlib/load/exosims/__init__.py +++ b/src/yieldplotlib/load/exosims/__init__.py @@ -1,8 +1,8 @@ """Nodes for EXOSIMS specific data files.""" __all__ = [ - "EXOSIMSCSVFile", "DRMFile", + "EXOSIMSCSVFile", "EXOSIMSInputFile", "SPCFile", ] diff --git a/src/yieldplotlib/load/exosims/exosims_input_file.py b/src/yieldplotlib/load/exosims/exosims_input_file.py index a99ec553..33487935 100644 --- a/src/yieldplotlib/load/exosims/exosims_input_file.py +++ b/src/yieldplotlib/load/exosims/exosims_input_file.py @@ -294,7 +294,7 @@ def _get(self, key, inst=None, syst=None, **kwargs): if "coords" not in key: val = getattr(self.TL, key) else: - coords = getattr(self.TL, "coords") + coords = self.TL.coords if key == "coords_RA": val = coords.ra elif key == "coords_Dec": @@ -345,10 +345,10 @@ def _get(self, key, inst=None, syst=None, **kwargs): _dict = self._get_mode_dict(inst, syst) elif in_INST: _insts = self.data["scienceInstruments"] - _dict = [_inst for _inst in _insts if _inst["name"] == inst][0] + _dict = next(_inst for _inst in _insts if _inst["name"] == inst) elif in_SYST: _systs = self.data["starlightSuppressionSystems"] - _dict = [_syst for _syst in _systs if _syst["name"] == syst][0] + _dict = next(_syst for _syst in _systs if _syst["name"] == syst) if _dict is None: raise ValueError( @@ -549,3 +549,246 @@ def _get_core_thruput(self, *args, **kwargs): thruput = thruput_data[:, 1] df = pd.DataFrame({"sep": sep, "thruput": thruput}) return df + + +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}") diff --git a/src/yieldplotlib/load/exosims_directory.py b/src/yieldplotlib/load/exosims_directory.py index 4f83c006..bbda8554 100644 --- a/src/yieldplotlib/load/exosims_directory.py +++ b/src/yieldplotlib/load/exosims_directory.py @@ -56,10 +56,8 @@ def _create_file_node(self, path: Path): return EXOSIMSCSVFile(path) else: logger.warning( - ( - f"Unexpected file type {path.suffix} for CSV directory. " - f"File {path.name}." - ) + f"Unexpected file type {path.suffix} for CSV directory. " + f"File {path.name}." ) return self.create_base_file(path) @@ -73,10 +71,8 @@ def _create_file_node(self, path: Path): return DRMFile(path) else: logger.warning( - ( - f"Unexpected file type {path.suffix} for DRM directory. " - f"File {path.name}." - ) + f"Unexpected file type {path.suffix} for DRM directory. " + f"File {path.name}." ) return self.create_base_file(path) @@ -90,9 +86,7 @@ def _create_file_node(self, path: Path): return SPCFile(path) else: logger.warning( - ( - f"Unexpected file type {path.suffix} for SPC directory. " - f"File {path.name}." - ) + f"Unexpected file type {path.suffix} for SPC directory. " + f"File {path.name}." ) return self.create_base_file(path) diff --git a/src/yieldplotlib/load/yip_directory.py b/src/yieldplotlib/load/yip_directory.py index ca020292..4f20270c 100644 --- a/src/yieldplotlib/load/yip_directory.py +++ b/src/yieldplotlib/load/yip_directory.py @@ -15,7 +15,7 @@ class YIPDirectory(DirectoryNode): def __init__(self, root_directory: Path): """Initialize the loader by scanning the directory structure.""" super().__init__(root_directory) - self.coronagraph = Coronagraph(root_directory, use_jax=False) + self.coronagraph = Coronagraph(root_directory) def _create_directory_node(self, path: Path) -> Node: """Override directory node creation logic for YIP-specific directories.""" diff --git a/src/yieldplotlib/logger.py b/src/yieldplotlib/logger.py index 4e3c8fa4..d438a81f 100644 --- a/src/yieldplotlib/logger.py +++ b/src/yieldplotlib/logger.py @@ -1,6 +1,7 @@ """Logging module.""" import logging +from typing import ClassVar lib_name = "yieldplotlib" # See https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797 for @@ -28,7 +29,7 @@ class ColorCodes: class ColorFormatter(logging.Formatter): """Custom formatter to add colors to log messages.""" - COLORS = { + COLORS: ClassVar[dict[int, str]] = { logging.DEBUG: ColorCodes.BLUE, logging.INFO: ColorCodes.GREEN, logging.WARNING: ColorCodes.YELLOW, diff --git a/src/yieldplotlib/pipeline.py b/src/yieldplotlib/pipeline.py index a593cef8..6f48d37b 100644 --- a/src/yieldplotlib/pipeline.py +++ b/src/yieldplotlib/pipeline.py @@ -51,7 +51,7 @@ def ypl_pipeline(runs): plt.figtext( 0.11, y_locs[i], - "EXOSIMS ExoEarth yield: {:.2f}".format(earth_yield[0]), + f"EXOSIMS ExoEarth yield: {earth_yield[0]:.2f}", fontdict={"fontsize": 8}, ) elif isinstance(run, AYODirectory): @@ -59,7 +59,7 @@ def ypl_pipeline(runs): plt.figtext( 0.11, y_locs[i], - "AYO ExoEarth yield: {:.2f}".format(earth_yield), + f"AYO ExoEarth yield: {earth_yield:.2f}", fontdict={"fontsize": 8}, ) diff --git a/src/yieldplotlib/plots/__init__.py b/src/yieldplotlib/plots/__init__.py index d2b6ac24..d1865324 100644 --- a/src/yieldplotlib/plots/__init__.py +++ b/src/yieldplotlib/plots/__init__.py @@ -1,12 +1,12 @@ """Custom plot designs.""" __all__ = [ - "plot_hist", - "make_offax_psf_movie", - "comparison_plots", "compare", + "comparison_plots", + "make_offax_psf_movie", "multi", "panel", + "plot_hist", "xy_grid", ] diff --git a/src/yieldplotlib/plots/comparison_plots.py b/src/yieldplotlib/plots/comparison_plots.py index 3d7152ae..e3b08bee 100644 --- a/src/yieldplotlib/plots/comparison_plots.py +++ b/src/yieldplotlib/plots/comparison_plots.py @@ -33,7 +33,7 @@ def _get_plot_method(ax, plot_type): plot_method = getattr(ax, f"ypl_{plot_type}") if plot_method is None: raise ValueError( - (f"Unsupported plot_type: {plot_type}. Use 'scatter', 'plot', or 'hist'.") + f"Unsupported plot_type: {plot_type}. Use 'scatter', 'plot', or 'hist'." ) return plot_method @@ -181,7 +181,7 @@ def _create_subplot_titles(directories, specs=None): return [f"{d.__class__.__name__}" for d in directories] titles = [] - for d in directories: + for _d in directories: for s in specs: if "y" in s: titles.append(f"{s.get('x', 'x')} vs {s.get('y', 'y')}") @@ -443,7 +443,7 @@ class names. kwargs["bins"] = bins # Plot each dataset - for i, (directory, label) in enumerate(zip(directories, labels)): + for i, (directory, label) in enumerate(zip(directories, labels, strict=False)): # Create plot kwargs for this dataset plot_kwargs = kwargs.copy() @@ -571,7 +571,7 @@ def multi( axes_flat = axes.flatten() # Plot each directory in its own subplot - for i, (directory, title) in enumerate(zip(directories, titles)): + for i, (directory, title) in enumerate(zip(directories, titles, strict=False)): if i < len(axes_flat): ax = axes_flat[i] diff --git a/src/yieldplotlib/plots/hz_completeness.py b/src/yieldplotlib/plots/hz_completeness.py index 9231bdde..3d1eff9e 100644 --- a/src/yieldplotlib/plots/hz_completeness.py +++ b/src/yieldplotlib/plots/hz_completeness.py @@ -7,7 +7,7 @@ def plot_hz_completeness( - exosims_dir, ayo_dir, ax_kwargs={}, hline_kwargs={}, use_cyberpunk=False + exosims_dir, ayo_dir, ax_kwargs=None, hline_kwargs=None, use_cyberpunk=False ): """Generate a scatter plot of the habitable zone completeness. @@ -27,6 +27,10 @@ def plot_hz_completeness( matplotlib.figure.Figure, matplotlib.axes.Axes: Figure and axes objects for the plot. """ + if hline_kwargs is None: + hline_kwargs = {} + if ax_kwargs is None: + ax_kwargs = {} if use_cyberpunk: import mplcyberpunk # noqa: F401 diff --git a/src/yieldplotlib/plots/yield_hist.py b/src/yieldplotlib/plots/yield_hist.py index 7441cbcc..40c7cf50 100644 --- a/src/yieldplotlib/plots/yield_hist.py +++ b/src/yieldplotlib/plots/yield_hist.py @@ -7,7 +7,7 @@ def plot_hist( - temps, planet_bins, runs, run_labels, ax=None, ax_kwargs={}, use_cyberpunk=False + temps, planet_bins, runs, run_labels, ax=None, ax_kwargs=None, use_cyberpunk=False ): """Plot a histogram of planet populations for different temperature bins. @@ -31,6 +31,8 @@ def plot_hist( matplotlib.figure.Figure, matplotlib.axes.Axes: Figure and axes objects for the plot. """ + if ax_kwargs is None: + ax_kwargs = {} if use_cyberpunk: import mplcyberpunk # noqa: F401 @@ -65,7 +67,7 @@ def plot_hist( temperature = "unknown" planet_type = "Unknown" - for run, label in zip(runs, run_labels): + for run, label in zip(runs, run_labels, strict=False): # Retrieve data run_data = run.get(key) try: @@ -91,7 +93,7 @@ def plot_hist( plotting_earths = "yield_earth" in planet_populations planet_bins = ["Rocky", "Super Earth", "Sub Neptune", "Neptune", "Jupiter"] planet_types = [x for x in planet_bins if x in df["planet_type"].unique()] - group_labels = ["Earth"] + planet_types if plotting_earths else planet_types + group_labels = ["Earth", *planet_types] if plotting_earths else planet_types temps = df.temperature.unique() # Sort to make sure it's always "hot", "warm", "cold" temp_order = ["hot", "warm", "cold"] @@ -154,7 +156,7 @@ def plot_hist( # Iterate over each temperature and model to plot bars # Plot the Earth bars first if plotting_earths: - for j, (run, label) in enumerate(zip(runs, run_labels)): + for j, (_run, label) in enumerate(zip(runs, run_labels, strict=False)): offset = (j - (n_runs - 1) / 2) * bar_width # Filter the df to get the earth values for this run subset = df[(df["planet_type"] == "Earth") & (df["model"] == label)] @@ -171,7 +173,7 @@ def plot_hist( autolabel(ax, _bar, use_cyberpunk) for i, temp in enumerate(temperatures): - for j, (run, label) in enumerate(zip(runs, run_labels)): + for j, (_run, label) in enumerate(zip(runs, run_labels, strict=False)): # Calculate the offset for each bar offset = (i * n_runs + j + 0.5) * bar_width - (temp_group_width / 2) # Filter the DataFrame for the current temperature and model @@ -211,7 +213,7 @@ def plot_hist( model_title = "Run" # Combine handles and labels with titles - handles = [Patch(alpha=0)] + temp_patches + [Patch(alpha=0)] + model_patches + handles = [Patch(alpha=0), *temp_patches, Patch(alpha=0), *model_patches] labels = ( [temp_title] + [p.get_label() for p in temp_patches] @@ -225,8 +227,8 @@ def plot_hist( # Set labels and title ax.set_ylabel("Yield") - ax.set_xticks([0] + planet_x.tolist() if plotting_earths else planet_x.tolist()) - xtick_labels = ["Earth"] + planet_types if plotting_earths else planet_types + ax.set_xticks([0, *planet_x.tolist()] if plotting_earths else planet_x.tolist()) + xtick_labels = ["Earth", *planet_types] if plotting_earths else planet_types ax.set_xticklabels(xtick_labels, ha="center") ax.set(**ax_kwargs) diff --git a/src/yieldplotlib/plots/yip_plots.py b/src/yieldplotlib/plots/yip_plots.py index f1eed970..ebbbd16e 100644 --- a/src/yieldplotlib/plots/yip_plots.py +++ b/src/yieldplotlib/plots/yip_plots.py @@ -5,7 +5,7 @@ import numpy as np -def make_offax_psf_movie(yip, save_name, ax_kwargs={}, plot_kwargs={}): +def make_offax_psf_movie(yip, save_name, ax_kwargs=None, plot_kwargs=None): """Generate a movie of the off-axis stellar PSF moving as a function of lambda/D. Args: @@ -22,6 +22,10 @@ def make_offax_psf_movie(yip, save_name, ax_kwargs={}, plot_kwargs={}): None """ # Get the off-axis stellar PSF data from the YIP. + if plot_kwargs is None: + plot_kwargs = {} + if ax_kwargs is None: + ax_kwargs = {} offax_psf_data = yip.get("offax.data") offax_psf_offsets_list = yip.get("offax_offset_list.data") @@ -73,7 +77,7 @@ def plot_core_throughtput( run_labels, yip=None, ax=None, - ax_kwargs={}, + ax_kwargs=None, use_cyberpunk=False, title=None, aperture_radius=0.85, @@ -104,8 +108,10 @@ def plot_core_throughtput( matplotlib.figure.Figure, matplotlib.axes.Axes: Figure and axes objects for the plot. """ + if ax_kwargs is None: + ax_kwargs = {} if use_cyberpunk: - import mplcyberpunk # noqa: F401 + import mplcyberpunk from cycler import cycler plt.style.use("cyberpunk") diff --git a/src/yieldplotlib/style/__init__.py b/src/yieldplotlib/style/__init__.py index 0b578e7a..09d86736 100644 --- a/src/yieldplotlib/style/__init__.py +++ b/src/yieldplotlib/style/__init__.py @@ -1,5 +1,5 @@ """Style configuration for yieldplotlib.""" -__all__ = ["ypl_colors", "ypl_cycler", "ypl_cmap", "ypl_rainbow"] +__all__ = ["ypl_cmap", "ypl_colors", "ypl_cycler", "ypl_rainbow"] from .custom_colors import ypl_cmap, ypl_colors, ypl_cycler, ypl_rainbow diff --git a/src/yieldplotlib/util.py b/src/yieldplotlib/util.py index 81f7f6cf..30513ffb 100644 --- a/src/yieldplotlib/util.py +++ b/src/yieldplotlib/util.py @@ -115,7 +115,7 @@ def rgetattr(obj, attr, *args): def _getattr(obj, attr): return getattr(obj, attr, *args) - return functools.reduce(_getattr, [obj] + attr.split(".")) + return functools.reduce(_getattr, [obj, *attr.split(".")]) def discretize_colormap(num_colors, colormap_name, start_frac=0.1, end_frac=0.9): @@ -145,7 +145,7 @@ def find_unit_for_module_key(module_key, module_name, key_map): str or None: The unit string if found, None otherwise. """ - for yieldplotlib_key, module_data in key_map.items(): + for _yieldplotlib_key, module_data in key_map.items(): # Check if this entry has data for the specified module if module_name in module_data: module_info = module_data[module_name] diff --git a/tests/datasets_test.py b/tests/test_datasets.py similarity index 100% rename from tests/datasets_test.py rename to tests/test_datasets.py diff --git a/tests/key_map_test.py b/tests/test_key_map.py similarity index 94% rename from tests/key_map_test.py rename to tests/test_key_map.py index cf2ad3c9..42068845 100644 --- a/tests/key_map_test.py +++ b/tests/test_key_map.py @@ -39,7 +39,7 @@ def test_common_key_access(ayo_data, exosims_data): # Just check that we can access the data without error assert ayo_value is not None, f"Failed to retrieve AYO data for key: {key}" except Exception as e: - pytest.skip(f"Skipping key {key} for AYO: {str(e)}") + pytest.skip(f"Skipping key {key} for AYO: {e!s}") try: exosims_value = exosims_data.get(key) @@ -48,7 +48,7 @@ def test_common_key_access(ayo_data, exosims_data): f"Failed to retrieve EXOSIMS data for key: {key}" ) except Exception as e: - pytest.skip(f"Skipping key {key} for EXOSIMS: {str(e)}") + pytest.skip(f"Skipping key {key} for EXOSIMS: {e!s}") def test_all_common_keys_list(): diff --git a/tests/plots_test.py b/tests/test_plots.py similarity index 89% rename from tests/plots_test.py rename to tests/test_plots.py index 1a0a4a64..6fa619ee 100644 --- a/tests/plots_test.py +++ b/tests/test_plots.py @@ -19,14 +19,14 @@ def test_plot_hist(ayo_data, exosims_data): plt.close() except Exception as e: plt.close() - pytest.fail(f"plot_hist failed with error: {str(e)}") + pytest.fail(f"plot_hist failed with error: {e!s}") @pytest.mark.parametrize("plot_type", ["scatter", "plot", "hist"]) def test_compare_plot_types(ayo_data, exosims_data, plot_type): """Test comparison with different plot types.""" try: - fig, ax = plt.subplots(figsize=(10, 6)) + _fig, ax = plt.subplots(figsize=(10, 6)) kwargs = { "ax": ax, "directories": [ayo_data, exosims_data], @@ -52,13 +52,13 @@ def test_compare_plot_types(ayo_data, exosims_data, plot_type): plt.close() except Exception as e: plt.close() - pytest.fail(f"compare {plot_type} plot failed with error: {str(e)}") + pytest.fail(f"compare {plot_type} plot failed with error: {e!s}") def test_compare_custom_style(ayo_data, exosims_data): """Test comparison with custom markers and colors.""" try: - fig, ax = plt.subplots(figsize=(10, 6)) + _fig, ax = plt.subplots(figsize=(10, 6)) compare( ax, [ayo_data, exosims_data], @@ -73,13 +73,13 @@ def test_compare_custom_style(ayo_data, exosims_data): plt.close() except Exception as e: plt.close() - pytest.fail(f"compare custom style failed with error: {str(e)}") + pytest.fail(f"compare custom style failed with error: {e!s}") def test_compare_no_legend(ayo_data, exosims_data): """Test comparison without legend.""" try: - fig, ax = plt.subplots(figsize=(10, 6)) + _fig, ax = plt.subplots(figsize=(10, 6)) compare( ax, [ayo_data, exosims_data], @@ -92,7 +92,7 @@ def test_compare_no_legend(ayo_data, exosims_data): plt.close() except Exception as e: plt.close() - pytest.fail(f"compare no legend failed with error: {str(e)}") + pytest.fail(f"compare no legend failed with error: {e!s}") @pytest.mark.parametrize("plot_type", ["scatter", "plot", "hist"]) @@ -122,17 +122,17 @@ def test_multi_plot_types(ayo_data, exosims_data, plot_type): elif plot_type == "hist": kwargs["bins"] = 20 - fig, axes = multi(**kwargs) + _fig, _axes = multi(**kwargs) plt.close() except Exception as e: plt.close() - pytest.fail(f"multi {plot_type} plot failed with error: {str(e)}") + pytest.fail(f"multi {plot_type} plot failed with error: {e!s}") def test_multi_auto_layout(ayo_data, exosims_data): """Test multi-panel with auto layout.""" try: - fig, axes = multi( + _fig, _axes = multi( [ayo_data, exosims_data], x="star_dist", y="star_L", @@ -145,13 +145,13 @@ def test_multi_auto_layout(ayo_data, exosims_data): plt.close() except Exception as e: plt.close() - pytest.fail(f"multi auto layout failed with error: {str(e)}") + pytest.fail(f"multi auto layout failed with error: {e!s}") def test_multi_shared_axes(ayo_data, exosims_data): """Test multi-panel with shared axes.""" try: - fig, axes = multi( + _fig, _axes = multi( [ayo_data, exosims_data], x="star_dist", y="star_L", @@ -167,13 +167,13 @@ def test_multi_shared_axes(ayo_data, exosims_data): plt.close() except Exception as e: plt.close() - pytest.fail(f"multi shared axes failed with error: {str(e)}") + pytest.fail(f"multi shared axes failed with error: {e!s}") def test_multi_custom_style(ayo_data, exosims_data): """Test multi-panel with custom styling.""" try: - fig, axes = multi( + _fig, _axes = multi( [ayo_data, exosims_data], x="star_dist", y="star_L", @@ -189,7 +189,7 @@ def test_multi_custom_style(ayo_data, exosims_data): plt.close() except Exception as e: plt.close() - pytest.fail(f"multi custom style failed with error: {str(e)}") + pytest.fail(f"multi custom style failed with error: {e!s}") @pytest.mark.parametrize("plot_type", ["scatter", "plot", "hist"]) @@ -215,17 +215,17 @@ def test_panel_plot_types(ayo_data, exosims_data, plot_type): elif plot_type == "hist": spec["bins"] = 20 - fig, axes = panel([ayo_data, exosims_data], spec, **kwargs) + _fig, _axes = panel([ayo_data, exosims_data], spec, **kwargs) plt.close() except Exception as e: plt.close() - pytest.fail(f"panel {plot_type} plot failed with error: {str(e)}") + pytest.fail(f"panel {plot_type} plot failed with error: {e!s}") def test_panel_mixed_types(ayo_data, exosims_data): """Test panel with mixed plot types.""" try: - fig, axes = panel( + _fig, _axes = panel( [ayo_data, exosims_data], { "x": "star_dist", @@ -249,13 +249,13 @@ def test_panel_mixed_types(ayo_data, exosims_data): plt.close() except Exception as e: plt.close() - pytest.fail(f"panel mixed types failed with error: {str(e)}") + pytest.fail(f"panel mixed types failed with error: {e!s}") def test_panel_custom_titles(ayo_data, exosims_data): """Test panel with custom titles.""" try: - fig, axes = panel( + _fig, _axes = panel( [ayo_data, exosims_data], { "x": "star_dist", @@ -276,13 +276,13 @@ def test_panel_custom_titles(ayo_data, exosims_data): plt.close() except Exception as e: plt.close() - pytest.fail(f"panel custom titles failed with error: {str(e)}") + pytest.fail(f"panel custom titles failed with error: {e!s}") def test_panel_shared_axes(ayo_data, exosims_data): """Test panel with shared axes.""" try: - fig, axes = panel( + _fig, _axes = panel( [ayo_data, exosims_data], { "x": "star_dist", @@ -305,7 +305,7 @@ def test_panel_shared_axes(ayo_data, exosims_data): plt.close() except Exception as e: plt.close() - pytest.fail(f"panel shared axes failed with error: {str(e)}") + pytest.fail(f"panel shared axes failed with error: {e!s}") @pytest.mark.parametrize("plot_type", ["scatter", "plot", "hist"]) @@ -340,17 +340,17 @@ def test_xy_grid_plot_types(ayo_data, exosims_data, plot_type): if plot_type == "scatter": kwargs.update({"c": "star_comp_det", "cmap": "viridis"}) - fig, axes = xy_grid(**kwargs) + _fig, _axes = xy_grid(**kwargs) plt.close() except Exception as e: plt.close() - pytest.fail(f"xy grid {plot_type} plot failed with error: {str(e)}") + pytest.fail(f"xy grid {plot_type} plot failed with error: {e!s}") def test_xy_grid_custom_style(ayo_data, exosims_data): """Test xy grid with custom styling.""" try: - fig, axes = xy_grid( + _fig, _axes = xy_grid( [ayo_data, exosims_data], ["star_dist"], ["star_L", "star_comp_det"], @@ -365,7 +365,7 @@ def test_xy_grid_custom_style(ayo_data, exosims_data): plt.close() except Exception as e: plt.close() - pytest.fail(f"xy grid custom style failed with error: {str(e)}") + pytest.fail(f"xy grid custom style failed with error: {e!s}") if __name__ == "__main__":