From a135fff161f9322e007dacfe126e3030724a0a5c Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Thu, 28 May 2026 20:53:12 -0700 Subject: [PATCH 01/43] Refactor PALACE parameter handling, add spectrum validation, and improve time bin computation checks. --- scopesim/effects/sky_ter_curves.py | 24 +++++++++++++++++++----- scopesim/source/__init__.py | 5 ++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/scopesim/effects/sky_ter_curves.py b/scopesim/effects/sky_ter_curves.py index dc6964d61..8f19c991d 100644 --- a/scopesim/effects/sky_ter_curves.py +++ b/scopesim/effects/sky_ter_curves.py @@ -108,11 +108,14 @@ def apply_to(self, obj, **kwargs): return obj def get_palace_inputs(self, **kwargs): + package_name = getattr(self.cmds, "package_name", "palace") + default_outdir = f"{from_rc_config('!SIM.file.local_packages_path')}/{package_name}" parlist = {"species": kwargs.get("species", "all"), "srf": kwargs.get("srf", 130.0), "isair": kwargs.get("isair", True), "isatm": kwargs.get("isatm", True), "pwv": from_currsys(kwargs.get("pwv", 2.5), self.cmds), + "outdir": kwargs.get("outdir", default_outdir), "outname": kwargs.get("outname", "palace"), "specsuffix": "dat", "showplot": False} @@ -131,7 +134,7 @@ def get_palace_inputs(self, **kwargs): atmo_name = [dt.name for dt in self.cmds.yaml_dicts if dt.alias == "!ATMO"][0] parlist["outdir"] = [pth for pth in rc.__search_path__ if atmo_name in pth][0] except: - parlist["outdir"] = f"{from_rc_config("!SIM.file.local_packages_path")}/{self.cmds.package_name}" + parlist["outdir"] = default_outdir ## Set PALACE model spectral resolution input from simulation settings if provided. resol = 2 * from_currsys("!SIM.spectral.spectral_resolution", self.cmds) if ( @@ -164,14 +167,26 @@ def get_palace_inputs(self, **kwargs): @staticmethod def get_mbin_tbin(obstime): mbin = obstime.datetime.month - tbin = obstime.datetime.hour - if not ((0 <= tbin <= 6) or (18 <= tbin <= 24)): + tbin = (obstime.datetime.hour + + obstime.datetime.minute / 60 + + obstime.datetime.second / 3600) + if not ((0 <= tbin < 6) or (18 <= tbin < 24)): logger.warning("Local time is outside of the range covered by the PALACE model (18-6h). Defaulting to tbin=0 (all times).") tbin = 0 return mbin, tbin def run_palace(self): _, spec_cont, spec_line = palace.model(**self.parlist) + + for label, spectrum in (("continuum", spec_cont), ("line", spec_line)): + if not isinstance(spectrum, Table): + raise RuntimeError(f"PALACE {label} spectrum has type {type(spectrum).__name__}; expected astropy Table.") + + missing_columns = {"lam", "flux"} - set(spectrum.colnames) + if missing_columns: + raise RuntimeError(f"PALACE {label} spectrum is missing required columns: " + f"{', '.join(sorted(missing_columns))}.") + if len(spec_cont) == 0: logger.warning("PALACE model returned empty continuum spectrum.") ## Try loading cont emission from saved model output if available @@ -194,7 +209,7 @@ def run_palace(self): if len(spec_line) > 0: self.parlist["outname"] = self.parlist["outname"].replace("_cont", "_line") palace.output(spec_line, **self.parlist) - logger.info(f"Saved PALACE models in {self.parlist["outdir"]}") + logger.info(f"Saved PALACE models in {self.parlist['outdir']}") return spec_cont, spec_line @@ -349,4 +364,3 @@ def get_skycalc_inputs(self, **kwargs): return params - diff --git a/scopesim/source/__init__.py b/scopesim/source/__init__.py index fcb12a449..ce6a79c49 100644 --- a/scopesim/source/__init__.py +++ b/scopesim/source/__init__.py @@ -1,2 +1,5 @@ from . import source_utils -# from . import source_templates +# Importing source_templates here creates an import cycle via +# source_templates -> optics -> effects -> ter_curves_utils -> source_templates. +# Keep source_templates lazily importable as scopesim.source.source_templates. + From 8532590540b9cedf1100413cb09aeffa395aeacb Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Fri, 29 May 2026 01:16:47 -0700 Subject: [PATCH 02/43] Add `BUNIT` header entry for spectral trace images clears up warnings --- scopesim/effects/spectral_trace_list_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scopesim/effects/spectral_trace_list_utils.py b/scopesim/effects/spectral_trace_list_utils.py index f70795e1a..4d78e96a7 100644 --- a/scopesim/effects/spectral_trace_list_utils.py +++ b/scopesim/effects/spectral_trace_list_utils.py @@ -283,6 +283,7 @@ def map_spectra_to_focal_plane(self, fov): img_header["XMAX"] = xmax img_header["YMIN"] = ymin img_header["YMAX"] = ymax + img_header["BUNIT"] = "ph s-1" if np.any(image < 0): logger.warning("map_spectra_to_focal_plane: %d negative pixels", From ed85b038cf0932e5d3427b140b08eec2bf7f806e Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Fri, 29 May 2026 10:36:29 -0700 Subject: [PATCH 03/43] Add effect order diagnostics --- scopesim/optics/optical_train.py | 4 + scopesim/optics/optics_manager.py | 117 +++++++++++++++++- .../tests/tests_optics/test_OpticsManager.py | 47 +++++++ 3 files changed, 167 insertions(+), 1 deletion(-) diff --git a/scopesim/optics/optical_train.py b/scopesim/optics/optical_train.py index e69c8d657..990993769 100644 --- a/scopesim/optics/optical_train.py +++ b/scopesim/optics/optical_train.py @@ -602,6 +602,10 @@ def shutdown(self): def effects(self): return self.optics_manager.list_effects() + @property + def effect_order(self): + return self.optics_manager.effect_order_table() + def __repr__(self): return f"{self.__class__.__name__}({self.cmds!r})" diff --git a/scopesim/optics/optics_manager.py b/scopesim/optics/optics_manager.py index 74a9c8cb5..cf4c72857 100644 --- a/scopesim/optics/optics_manager.py +++ b/scopesim/optics/optics_manager.py @@ -4,7 +4,7 @@ from inspect import isclass from typing import TextIO from io import StringIO -from collections.abc import Sequence +from collections.abc import Iterable, Sequence import numpy as np from astropy import units as u @@ -22,6 +22,76 @@ logger = get_logger(__name__) +Z_ORDER_PHASES = ( + (200, "fov_setup"), + (300, "image_plane_setup"), + (400, "detector_setup"), + (500, "source"), + (600, "fov"), + (700, "image_plane"), + (800, "detector"), + (900, "detector_array"), + (1000, "fits_header"), +) + +EFFECT_ORDER_COLUMNS = ( + "phase", + "phase_z", + "phase_index", + "phase_sort_key", + "element_index", + "element", + "effect_index", + "effect", + "class", + "z_orders", + "phase_z_orders", + "selector_key", + "selector_values", + "selected_classes", + "selected_z_orders", +) + + +def _format_iterable(values: Iterable) -> str: + return ", ".join(str(value) for value in values) + + +def _format_z_orders(z_order: Iterable[int]) -> str: + return _format_iterable(z_order) + + +def _selector_wheel_summary(effect) -> dict[str, str]: + """Return a compact summary for selector-like wrapper effects.""" + wheel_effects = getattr(effect, "wheel_effects", None) + if not wheel_effects: + return { + "selector_key": "", + "selector_values": "", + "selected_classes": "", + "selected_z_orders": "", + } + + classes = [] + z_orders = [] + for wheel_effect in wheel_effects.values(): + class_name = wheel_effect.__class__.__name__ + if class_name not in classes: + classes.append(class_name) + + z_order = getattr(wheel_effect, "z_order", ()) + z_order_text = _format_z_orders(z_order) + if z_order_text and z_order_text not in z_orders: + z_orders.append(z_order_text) + + return { + "selector_key": str(effect.meta.get("selector_key", "")), + "selector_values": _format_iterable(wheel_effects.keys()), + "selected_classes": _format_iterable(classes), + "selected_z_orders": "; ".join(z_orders), + } + + class OpticsManager: """ The workhorse class for dealing with all externally defined Effect objects. @@ -188,6 +258,51 @@ def _sortkey(eff): # return sorted(_gather_effects(), key=_sortkey) return list(_gather_effects()) + def effect_order_table(self) -> Table: + """ + Return the actual effect order used by each optical-train phase. + + The table reflects the current ScopeSim execution order. Within a + z-order phase, effects are listed in optical-element/YAML order because + :meth:`get_z_order_effects` currently does not sort by ``z_order``. + """ + rows = [] + + for phase_z, phase in Z_ORDER_PHASES: + z_range = range(phase_z, phase_z + 100) + phase_index = 0 + + for element_index, opt_el in enumerate(self.optical_elements): + element_name = opt_el.meta.get("name", "") + for effect_index, effect in enumerate(opt_el.effects): + if not effect.include or not hasattr(effect, "z_order"): + continue + + phase_z_orders = tuple( + z_order for z_order in effect.z_order + if z_order in z_range) + if not phase_z_orders: + continue + + selector_summary = _selector_wheel_summary(effect) + rows.append({ + "phase": phase, + "phase_z": phase_z, + "phase_index": phase_index, + "phase_sort_key": min(phase_z_orders) % 100, + "element_index": element_index, + "element": element_name, + "effect_index": effect_index, + "effect": effect.display_name, + "class": effect.__class__.__name__, + "z_orders": _format_z_orders(effect.z_order), + "phase_z_orders": _format_z_orders(phase_z_orders), + **selector_summary, + }) + phase_index += 1 + + return Table(rows=rows, names=EFFECT_ORDER_COLUMNS) + @property def is_spectroscope(self) -> bool: """Return True if any of the effects is a spectroscope.""" diff --git a/scopesim/tests/tests_optics/test_OpticsManager.py b/scopesim/tests/tests_optics/test_OpticsManager.py index 223e5efac..1c6b77cac 100644 --- a/scopesim/tests/tests_optics/test_OpticsManager.py +++ b/scopesim/tests/tests_optics/test_OpticsManager.py @@ -49,6 +49,53 @@ def test_has_effects_loaded(self, detector_yaml_dict): opt_mgr.OpticalElement) assert isinstance(opt_man.optical_elements[0].effects[0], Effect) + def test_effect_order_table_uses_runtime_phase_order(self, + detector_yaml_dict): + opt_man = opt_mgr.OpticsManager([detector_yaml_dict]) + + tbl = opt_man.effect_order_table() + source_rows = tbl[tbl["phase"] == "source"] + fov_rows = tbl[tbl["phase"] == "fov"] + detector_setup_rows = tbl[tbl["phase"] == "detector_setup"] + + assert source_rows["effect"].tolist() == ["detector_qe_curve"] + assert fov_rows["effect"].tolist() == ["detector_qe_curve"] + assert detector_setup_rows["effect"].tolist() == [ + "micado_detector_geometry"] + + def test_effect_order_table_summarises_selector_wheels(self): + yaml_dict = { + "object": "instrument", + "alias": "INST", + "name": "selector_test", + "properties": {"pixel_scale": 0.004}, + "effects": [{ + "name": "selected_qe", + "class": "SelectorWheel", + "kwargs": { + "selector_key": "aperture_id", + "wheel": [{ + "selector_value": [1, 2], + "effect_class": "TERCurve", + "effect_kwargs": {"filename": "TER_blank.dat"}, + }], + }, + }], + } + + opt_man = opt_mgr.OpticsManager([yaml_dict]) + + tbl = opt_man.effect_order_table() + source_row = tbl[tbl["phase"] == "source"][0] + fov_row = tbl[tbl["phase"] == "fov"][0] + + assert source_row["effect"] == "selected_qe" + assert source_row["class"] == "SelectorWheel" + assert source_row["selector_key"] == "aperture_id" + assert source_row["selector_values"] == "1, 2" + assert source_row["selected_classes"] == "TERCurve" + assert fov_row["phase_z_orders"] == "610" + @pytest.mark.usefixtures("patch_mock_path") class TestOpticsManagerImagePlaneHeader: From 00d97690de47a5276b3b83cedb3b9056ef083948 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Fri, 29 May 2026 10:54:39 -0700 Subject: [PATCH 04/43] Avoid writing generated echelle traces to package --- scopesim/effects/spectral_trace_list.py | 19 ++++-- scopesim/optics/echelle.py | 10 +-- .../tests_effects/test_SpectralTraceList.py | 65 ++++++++++++++++++- 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/scopesim/effects/spectral_trace_list.py b/scopesim/effects/spectral_trace_list.py index fe8746467..473785691 100644 --- a/scopesim/effects/spectral_trace_list.py +++ b/scopesim/effects/spectral_trace_list.py @@ -6,6 +6,7 @@ `spectral_trace_list_utils.SpectralTrace` objects to a `FieldOfView`. """ from itertools import cycle +from pathlib import Path from typing import ClassVar from tqdm.auto import tqdm @@ -14,7 +15,6 @@ from astropy.io import fits from astropy.table import Table import astropy.units as u -import os from .effects import Effect from .ter_curves import FilterCurve @@ -22,7 +22,7 @@ from ..optics.image_plane_utils import header_from_list_of_xy from ..optics.fov import FieldOfView from ..optics.fov_volume_list import FovVolumeList -from ..utils import from_currsys, check_keys, figure_factory, get_logger, from_rc_config +from ..utils import from_currsys, check_keys, figure_factory, get_logger from .data_container import DataContainer from ..optics import echelle @@ -556,12 +556,17 @@ def __init__(self, cmds=None, **kwargs): self.cmds = cmds trace_param_filename = kwargs.pop("filename") + save_generated_hdulist = kwargs.pop("save_generated_hdulist", False) + generated_hdulist_filename = kwargs.pop( + "generated_hdulist_filename", "analytical_echelle_traces.fits") trace_params = DataContainer(filename=trace_param_filename) hdulist = self._generate_trace_hdulist(trace_params) - hdulist.writeto(f"{from_rc_config('!SIM.file.local_packages_path')}/" - f"{self.cmds.package_name}/" - f"{os.path.dirname(trace_param_filename)}/" - f"analytical_echelle_traces.fits", overwrite=True) + if save_generated_hdulist: + output_path = Path(generated_hdulist_filename).expanduser() + if not output_path.is_absolute(): + output_path = Path.cwd() / output_path + output_path.parent.mkdir(parents=True, exist_ok=True) + hdulist.writeto(output_path, overwrite=True) kwargs["hdulist"] = hdulist super().__init__(cmds=cmds, **kwargs) @@ -651,4 +656,4 @@ def _generate_trace_hdulist(self, trace_params): trace_hdu.header["EXTNAME"] = f'{prefix}_{order:d}' hdul.append(trace_hdu) - return hdul \ No newline at end of file + return hdul diff --git a/scopesim/optics/echelle.py b/scopesim/optics/echelle.py index e23847a7b..5a992059c 100644 --- a/scopesim/optics/echelle.py +++ b/scopesim/optics/echelle.py @@ -186,10 +186,10 @@ def estimate_xdisp_angle_with_groove_len( t = detector_length / focal_length k = (l_max - l_min) / d x = k * (1 + np.sqrt(1 + t ** 2)) / 2 / t - u = np.arccos(np.sqrt(x)) - v = np.arcsin(k / 2 / np.sqrt(x)) - beta_max = u+v - beta_min = u-v + beta_center_guess = np.arccos(np.sqrt(x)) + beta_half_range = np.arcsin(k / 2 / np.sqrt(x)) + beta_max = beta_center_guess + beta_half_range + beta_min = beta_center_guess - beta_half_range alpha = np.arcsin(l_max / d - np.sin(beta_max)) return alpha.to(u.rad), ((beta_min + beta_max) / 2).to(u.rad) @@ -741,4 +741,4 @@ def plot_echellogram(self, center_orders=True, title='', blaze=False, cross_disp # GratingSetup(alpha=np.deg2rad(64.2), beta_center=np.deg2rad(64.2), delta=np.deg2rad(64.2), groove_length=u.mm/200), # Detector(4096,4096,15*u.micron), # cross_disperser=GratingSetup(groove_length=u.mm/1000, guess_littrow=(310*u.nm, 515*u.nm, (4096-20)*0.015*u.mm, 225*u.mm)), -# ) \ No newline at end of file +# ) diff --git a/scopesim/tests/tests_effects/test_SpectralTraceList.py b/scopesim/tests/tests_effects/test_SpectralTraceList.py index fbc460ab3..b22582948 100644 --- a/scopesim/tests/tests_effects/test_SpectralTraceList.py +++ b/scopesim/tests/tests_effects/test_SpectralTraceList.py @@ -7,8 +7,9 @@ from scopesim.effects.spectral_trace_list import SpectralTraceList, \ - SpectralTraceListWheel + SpectralTraceListWheel, EchelleSpectralTraceList from scopesim.effects.spectral_trace_list_utils import SpectralTrace +from scopesim.commands import UserCommands from scopesim.tests.mocks.py_objects import trace_list_objects as tlo from scopesim.tests.mocks.py_objects import header_objects as ho @@ -101,3 +102,65 @@ def test_basic_init(self): assert stw.meta["trace_list_names"] == ["foo"] assert isinstance(stw.trace_lists["foo"], SpectralTraceList) assert stw.trace_lists["foo"].meta["filename"] == "bogus_foo" + + +def _write_echelle_trace_params(path): + path.write_text( + "# min_wave_unit : nm\n" + "# max_wave_unit : nm\n" + "# echelle_blaze_unit : deg\n" + "# focal_length_unit : mm\n" + "# fwhm_unit : pixel\n" + "# detector_pad_unit : pixel\n" + "# pixel_size_unit : mm\n" + "# n_disp_unit : pixel\n" + "# n_xdisp_unit : pixel\n" + "# disp_freq_unit : mm\n" + "# xdisp_freq_unit : mm\n" + "# slitlength_unit : arcsec\n" + "prefix aperture_id image_plane_id m0 n min_wave max_wave " + "design_res echelle_blaze focal_length fwhm detector_pad " + "pixel_size n_disp n_xdisp disp_freq xdisp_freq slitlength " + "dispdir xbeta_center\n" + "b 0 0 91 0 310 420 17799 65.6 225 4.5 10 0.015 " + "128 128 65.0 1.0 10 x 0\n", + encoding="utf-8", + ) + + +def _echelle_cmds(): + cmds = UserCommands() + cmds["!INST.pixel_scale"] = 0.004 + return cmds + + +class TestEchelleSpectralTraceList: + def test_does_not_write_generated_hdulist_by_default(self, tmp_path): + params_file = tmp_path / "echelle_trace_parameters.dat" + _write_echelle_trace_params(params_file) + + spt = EchelleSpectralTraceList( + cmds=_echelle_cmds(), + filename=str(params_file), + wave_colname="wavelength", + s_colname="s", + ) + + assert isinstance(spt, EchelleSpectralTraceList) + assert not (tmp_path / "analytical_echelle_traces.fits").exists() + + def test_writes_generated_hdulist_to_working_dir_when_requested( + self, tmp_path, monkeypatch): + params_file = tmp_path / "echelle_trace_parameters.dat" + _write_echelle_trace_params(params_file) + monkeypatch.chdir(tmp_path) + + EchelleSpectralTraceList( + cmds=_echelle_cmds(), + filename=str(params_file), + wave_colname="wavelength", + s_colname="s", + save_generated_hdulist=True, + ) + + assert (tmp_path / "analytical_echelle_traces.fits").exists() From caf99db136888519238483b212ce2b15aac8c4c1 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Fri, 29 May 2026 14:06:20 -0700 Subject: [PATCH 05/43] Add image-plane background and detector response effects --- scopesim/effects/__init__.py | 1 + scopesim/effects/electronic/__init__.py | 2 +- scopesim/effects/electronic/noise.py | 57 ++++++ scopesim/effects/illumination.py | 180 ++++++++++++++++++ scopesim/effects/selector_wheel.py | 39 +++- scopesim/effects/ter_curves.py | 28 ++- scopesim/tests/tests_effects/test_TERCurve.py | 38 ++++ .../tests/tests_effects/test_illumination.py | 103 ++++++++++ scopesim/tests/tests_effects/test_prnu.py | 74 +++++++ .../tests_effects/test_selector_wheel.py | 67 +++++++ 10 files changed, 584 insertions(+), 5 deletions(-) create mode 100644 scopesim/effects/illumination.py create mode 100644 scopesim/tests/tests_effects/test_illumination.py create mode 100644 scopesim/tests/tests_effects/test_prnu.py create mode 100644 scopesim/tests/tests_effects/test_selector_wheel.py diff --git a/scopesim/effects/__init__.py b/scopesim/effects/__init__.py index 46116bedb..81610711d 100644 --- a/scopesim/effects/__init__.py +++ b/scopesim/effects/__init__.py @@ -12,6 +12,7 @@ from .surface_list import * from .ter_curves import * from . import ter_curves_utils +from .illumination import * from .detector_list import * from .electronic import * diff --git a/scopesim/effects/electronic/__init__.py b/scopesim/effects/electronic/__init__.py index 9ae7c4fff..087034592 100644 --- a/scopesim/effects/electronic/__init__.py +++ b/scopesim/effects/electronic/__init__.py @@ -23,7 +23,7 @@ from .electrons import LinearityCurve, ADConversion, InterPixelCapacitance from .noise import (Bias, PoorMansHxRGReadoutNoise, BasicReadoutNoise, - ShotNoise, DarkCurrent) + PixelResponseNonUniformity, ShotNoise, DarkCurrent) from .exposure import AutoExposure, ExposureIntegration, ExposureOutput from .pixels import ReferencePixelBorder, BinnedImage, UnequalBinnedImage from .dmps import DetectorModePropertiesSetter diff --git a/scopesim/effects/electronic/noise.py b/scopesim/effects/electronic/noise.py index d2ce522a3..990065e9c 100644 --- a/scopesim/effects/electronic/noise.py +++ b/scopesim/effects/electronic/noise.py @@ -134,6 +134,63 @@ def plot_hist(self, det, **kwargs): ax.hist(dtcr.data.flatten()) +class PixelResponseNonUniformity(Effect): + """Pixel response non-uniformity as a fixed multiplicative gain map.""" + + required_keys: ClassVar[set] = set() + z_order: ClassVar[tuple[int, ...]] = (805,) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.meta.update(kwargs) + self._gain_maps = {} + + def apply_to(self, obj, **kwargs): + if not isinstance(obj, Detector): + return obj + + random_seed = from_currsys(self.meta.get("prnu_seed"), self.cmds) + id_key = real_colname("id", obj.meta) + dtcr_id = obj.meta[id_key] if id_key is not None else None + + prnu_std_meta = from_currsys(self.meta["prnu_std"], self.cmds) + if isinstance(prnu_std_meta, dict): + prnu_std = float(from_currsys(prnu_std_meta[dtcr_id], self.cmds)) + elif isinstance(prnu_std_meta, (int, float)): + prnu_std = float(prnu_std_meta) + else: + raise TypeError( + ".meta['prnu_std'] must be a float " + f"or a dict keyed by detector ID, got {type(prnu_std_meta)}" + ) + + shape = obj._hdu.data.shape + if dtcr_id not in self._gain_maps: + rng = np.random.default_rng(random_seed) + self._gain_maps[dtcr_id] = rng.normal( + loc=1.0, scale=prnu_std, size=shape, + ) + + if self._gain_maps[dtcr_id].shape != shape: + raise ValueError("gain map shape mismatch") + + obj._hdu.data = obj._hdu.data * self._gain_maps[dtcr_id] + return obj + + def plot(self, det_id=None): + """Plot the cached gain map.""" + if not self._gain_maps: + raise RuntimeError("No gain map yet - run a simulation first.") + key = det_id if det_id in self._gain_maps else next(iter(self._gain_maps)) + gain_map = self._gain_maps[key] + dev = np.max(np.abs(gain_map - 1.0)) + fig, ax = figure_factory() + im = ax.imshow(gain_map, origin="lower", aspect="auto", + vmin=1 - dev, vmax=1 + dev) + fig.colorbar(im, ax=ax, label="per-pixel gain") + return fig + + class ShotNoise(Effect): z_order: ClassVar[tuple[int, ...]] = (820,) diff --git a/scopesim/effects/illumination.py b/scopesim/effects/illumination.py new file mode 100644 index 000000000..4822a2b66 --- /dev/null +++ b/scopesim/effects/illumination.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +"""Image-plane illumination effects.""" + +from collections.abc import Callable, Mapping +from typing import ClassVar + +import numpy as np +from astropy import units as u +from astropy.modeling.functional_models import Gaussian2D + +from . import Effect +from ..optics.image_plane import ImagePlane +from ..utils import figure_factory, from_currsys + + +__all__ = [ + "Illumination", + "ImagePlaneBackground", + "gaussian2d", + "quadratic_vignetting", +] + + +def gaussian2d( + shape: tuple[int, int], + amp: float = 1.0, + mu: tuple[float, float] = (0.0, 0.0), + sigma: tuple[float, float] = (2000.0, 2000.0), + theta: u.Quantity[u.deg] | float = 0.0 * u.deg, +) -> np.ndarray: + """Return a 2D elliptical Gaussian illumination map.""" + nx, ny = reversed(shape) + y, x = np.ogrid[:ny, :nx] + x = x - nx / 2 + y = y - ny / 2 + + model = Gaussian2D( + amplitude=amp, + x_mean=mu[0], + y_mean=mu[1], + x_stddev=sigma[0], + y_stddev=sigma[1], + theta=theta << u.deg, + ) + return model(x, y) + + +def quadratic_vignetting( + shape: tuple[int, int], + falloff: float = 0.01, + r_ref: float | None = None, + mu: tuple[float, float] = (0.0, 0.0), + stretch: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0), +) -> np.ndarray: + """Return a quadratic vignetting pattern.""" + nx, ny = reversed(shape) + + yy, xx = np.ogrid[:ny, :nx] + dx = xx - (nx / 2 + mu[0]) + dy = yy - (ny / 2 + mu[1]) + + sx = np.where(dx >= 0, stretch[0], stretch[1]) + sy = np.where(dy >= 0, stretch[2], stretch[3]) + + r2 = (dx / sx) ** 2 + (dy / sy) ** 2 + r2_ref = r2.max() if r_ref is None else r_ref ** 2 + + return np.clip(1.0 - falloff * r2 / r2_ref, 0.0, 1.0) + + +class Illumination(Effect): + """Large-scale multiplicative illumination variation on the image plane.""" + + z_order: ClassVar[tuple[int, ...]] = (750,) + + def __init__( + self, + model: Callable = gaussian2d, + modelargs: Mapping | None = None, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.meta.setdefault("include", "!DET.include_illumination") + self._model = model + self._modelargs = modelargs or {} + self._map = None + self._map_shape = None + + def apply_to(self, obj, **kwargs): + if not isinstance(obj, ImagePlane): + return obj + + shape = obj.hdu.data.shape + if self._map is None or shape != self._map_shape: + self._map = self._make_map(shape) + self._map_shape = shape + + obj.hdu.data *= self._map + return obj + + def _make_map(self, shape): + illumination_map = self._model(shape, **self._modelargs) + return illumination_map.astype(np.float32) + + def plot(self): + """Plot the cached illumination map.""" + if self._map is None: + raise RuntimeError("No illumination map cached - run a simulation first.") + + fig, ax = figure_factory() + im = ax.imshow( + self._map, origin="lower", vmin=0.98, vmax=1.0, cmap="gray_r", + ) + fig.colorbar(im, ax=ax, label="Relative illumination") + ax.set_title("Illumination") + ax.set_xlabel("x [px]") + ax.set_ylabel("y [px]") + return fig + + +class ImagePlaneBackground(Effect): + """Add an already integrated diffuse background to the image plane. + + The value added by this effect is in ScopeSim image-plane units, + ``ph s-1 pixel-1``. Use this for post-disperser diffuse backgrounds that + should not be converted into a source cube and sent through the spectral + trace list. The supplied value or model should already include downstream + throughput and detector QE. For tapered QE maps, use an average or otherwise + representative positional QE when deriving this non-dispersed background. + """ + + z_order: ClassVar[tuple[int, ...]] = (760,) + + def __init__( + self, + value: float = 0.0, + model: Callable | None = None, + modelargs: Mapping | None = None, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.meta.setdefault("include", "!DET.include_image_plane_background") + self.meta.setdefault("value", value) + self._model = model + self._modelargs = modelargs or {} + self._map = None + self._map_shape = None + + def apply_to(self, obj, **kwargs): + if not isinstance(obj, ImagePlane): + return obj + + shape = obj.hdu.data.shape + if self._map is None or shape != self._map_shape: + self._map = self._make_map(shape) + self._map_shape = shape + + obj.hdu.data = obj.hdu.data + self._map + return obj + + def _make_map(self, shape): + if self._model is None: + value = from_currsys(self.meta["value"], self.cmds) + background = np.full(shape, value, dtype=np.float32) + else: + background = self._model(shape, **self._modelargs) + return np.asarray(background, dtype=np.float32) + + def plot(self): + """Plot the cached background map.""" + if self._map is None: + raise RuntimeError("No background map cached - run a simulation first.") + + fig, ax = figure_factory() + im = ax.imshow(self._map, origin="lower") + fig.colorbar(im, ax=ax, label="ph s-1 pixel-1") + ax.set_title("Image-plane background") + ax.set_xlabel("x [px]") + ax.set_ylabel("y [px]") + return fig diff --git a/scopesim/effects/selector_wheel.py b/scopesim/effects/selector_wheel.py index 8eabbcb6e..cd5c34f5b 100644 --- a/scopesim/effects/selector_wheel.py +++ b/scopesim/effects/selector_wheel.py @@ -10,12 +10,15 @@ ( e.g. different aperture masks for different arms) in the wheel dictionary where each effect corresponds to a "selector_id" value. The user can set which id to use as the "selector", for e.g. aperture_id or id of the FoV object. """ +import importlib +from numbers import Integral + from ..utils import (check_keys, get_logger, real_colname) from .effects import Effect from ..optics.fov_volume_list import FovVolumeList from ..optics.fov import FieldOfView +from ..optics.image_plane import ImagePlane from ..detector.detector import Detector -import importlib logger = get_logger(__name__) @@ -72,8 +75,7 @@ def __init__(self, **kwargs): else: self.wheel_effects[selector_value] = effect_class(cmds=self.cmds, **effect_kwargs) - # Use the wheel effects' z_order as the z_order of the selector wheel - self.z_order = [eff.z_order for eff in self.wheel_effects.values()][0] + self.z_order = self._resolve_z_order() def apply_to(self, obj, **kwargs): @@ -128,6 +130,15 @@ def apply_to(self, obj, **kwargs): obj = effect_to_apply.apply_to(obj, **kwargs) + if isinstance(obj, ImagePlane): + selector_value = self._selector_value_from_image_plane(obj) + effect_to_apply = self.get_effect(selector_value) + if effect_to_apply is None: + logger.warning(f"No effect found for image plane ID: {selector_value}, skipping effect application.") + return obj + + obj = effect_to_apply.apply_to(obj, **kwargs) + return obj @@ -141,3 +152,25 @@ def get_effect(self, selector_value): return eff + def _resolve_z_order(self): + """Use an explicit wheel z_order if supplied, otherwise inherit one.""" + configured_z_order = self.meta.get("z_order") + if configured_z_order is not None: + if isinstance(configured_z_order, Integral): + return (int(configured_z_order),) + return tuple(configured_z_order) + + if not self.wheel_effects: + return () + return tuple(next(iter(self.wheel_effects.values())).z_order) + + + def _selector_value_from_image_plane(self, obj): + selector_key = self.meta["selector_key"] + if selector_key in obj.meta: + return obj.meta[selector_key] + if selector_key in obj.hdu.header: + return obj.hdu.header[selector_key] + if selector_key in {"id", "image_plane_id", "IMGPLANE"}: + return obj.id + raise ValueError(f"Selector key {selector_key} not found in ImagePlane.") diff --git a/scopesim/effects/ter_curves.py b/scopesim/effects/ter_curves.py index 793038a76..b1f74ff87 100644 --- a/scopesim/effects/ter_curves.py +++ b/scopesim/effects/ter_curves.py @@ -555,6 +555,32 @@ def __init__(self, **kwargs): self.meta["position"] = -1 # position in surface table +class SpectralQuantumEfficiency(TERCurve): + """Throughput-only detector QE for spectroscopic FOV cubes. + + Detector QE should attenuate photons reaching the detector, but it should + not create a thermal background source from detector emissivity. Place this + effect before spectral trace mapping so each order cube is multiplied by the + detector QE as a function of wavelength. + """ + + z_order: ClassVar[tuple[int, ...]] = (610,) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.meta["action"] = "transmission" + self.meta["position"] = -1 + + def apply_to(self, obj, **kwargs): + if not isinstance(obj, FieldOfView): + return obj + return super().apply_to(obj, **kwargs) + + @property + def background_source(self): + return None + + class FilterCurve(TERCurve): """ Descripton TBA. @@ -1361,4 +1387,4 @@ def apply_to(self, obj, **kwargs): dichroic.surface.meta.update(params) dichroic.apply_to(obj, **kwargs) - return obj \ No newline at end of file + return obj diff --git a/scopesim/tests/tests_effects/test_TERCurve.py b/scopesim/tests/tests_effects/test_TERCurve.py index 8e9a1847b..effd2035c 100644 --- a/scopesim/tests/tests_effects/test_TERCurve.py +++ b/scopesim/tests/tests_effects/test_TERCurve.py @@ -48,6 +48,44 @@ def test_adds_bg_to_source_if_source_has_no_bg(self): plt.show() +class TestSpectralQuantumEfficiency: + def test_is_fov_phase_only(self): + qe = tc.SpectralQuantumEfficiency( + array_dict={ + "wavelength": [0.5, 1.0], + "transmission": [0.8, 0.9], + }, + wavelength_unit="um", + ) + + assert qe.z_order == (610,) + + def test_does_not_create_background_source(self): + qe = tc.SpectralQuantumEfficiency( + array_dict={ + "wavelength": [0.5, 1.0], + "transmission": [0.8, 0.9], + }, + wavelength_unit="um", + ) + + assert qe.background_source is None + + def test_does_not_apply_to_source(self): + src = so._empty_sky() + n_fields = len(src.fields) + qe = tc.SpectralQuantumEfficiency( + array_dict={ + "wavelength": [0.5, 1.0], + "transmission": [0.8, 0.9], + }, + wavelength_unit="um", + ) + + assert qe.apply_to(src) is src + assert len(src.fields) == n_fields + + class TestTERCurvePlot: def test_plots_only_transmission(self): filt = eo._filter_surface(wave_min=0.8, wave_max=2.5) diff --git a/scopesim/tests/tests_effects/test_illumination.py b/scopesim/tests/tests_effects/test_illumination.py new file mode 100644 index 000000000..dc9d4ddb1 --- /dev/null +++ b/scopesim/tests/tests_effects/test_illumination.py @@ -0,0 +1,103 @@ +"""Tests for image-plane illumination effects.""" + +import numpy as np +import pytest + +from scopesim.effects.illumination import ( + ImagePlaneBackground, + Illumination, + gaussian2d, + quadratic_vignetting, +) +from scopesim.optics.image_plane import ImagePlane +from scopesim.tests.mocks.py_objects.imagehdu_objects import _image_hdu_square + + +@pytest.fixture +def imageplane(): + ip = ImagePlane(_image_hdu_square().header) + ip.hdu.data = np.ones((100, 100), dtype=np.float64) + return ip + + +def test_gaussian2d_peak_at_centre(): + result = np.asarray(gaussian2d((100, 100))) + assert result.max() == pytest.approx(1.0) + + +def test_gaussian2d_values_leq_amp(): + result = gaussian2d((101, 101)) + assert result.max() <= 1.0 + 1e-12 + + +def test_quadratic_vignetting_centre_is_one(): + result = quadratic_vignetting((101, 101)) + assert result[50, 50] == pytest.approx(1.0) + + +def test_quadratic_vignetting_values_in_range(): + result = quadratic_vignetting((101, 101)) + assert np.all(result >= 0.0) and np.all(result <= 1.0) + + +def test_illumination_instantiates(): + assert isinstance(Illumination(), Illumination) + + +def test_illumination_apply_to_returns_imageplane(imageplane): + eff = Illumination() + assert eff.apply_to(imageplane) is imageplane + + +def test_illumination_apply_to_skips_non_imageplane(): + eff = Illumination() + obj = object() + assert eff.apply_to(obj) is obj + + +def test_illumination_modifies_data(imageplane): + original = imageplane.hdu.data.copy() + Illumination().apply_to(imageplane) + assert not np.array_equal(imageplane.hdu.data, original) + + +def test_illumination_caches_map(imageplane): + eff = Illumination() + eff.apply_to(imageplane) + assert eff._map is not None and eff._map_shape == (100, 100) + + +def test_illumination_make_map_shape_and_dtype(): + eff = Illumination() + illumination_map = eff._make_map((80, 60)) + assert illumination_map.shape == (80, 60) + assert illumination_map.dtype == np.float32 + + +def test_illumination_plot_raises_before_apply(): + with pytest.raises(RuntimeError): + Illumination().plot() + + +def test_image_plane_background_adds_constant(imageplane): + ImagePlaneBackground(value=2.5).apply_to(imageplane) + assert np.all(imageplane.hdu.data == 3.5) + + +def test_image_plane_background_uses_model(imageplane): + def model(shape, value): + return np.full(shape, value) + + ImagePlaneBackground(model=model, modelargs={"value": 4}).apply_to(imageplane) + assert np.all(imageplane.hdu.data == 5) + + +def test_image_plane_background_skips_non_imageplane(): + eff = ImagePlaneBackground(value=1) + obj = object() + assert eff.apply_to(obj) is obj + + +def test_image_plane_background_plot_raises_before_apply(): + with pytest.raises(RuntimeError): + ImagePlaneBackground(value=1).plot() diff --git a/scopesim/tests/tests_effects/test_prnu.py b/scopesim/tests/tests_effects/test_prnu.py new file mode 100644 index 000000000..7d9b4dfb1 --- /dev/null +++ b/scopesim/tests/tests_effects/test_prnu.py @@ -0,0 +1,74 @@ +"""Tests for pixel response non-uniformity.""" + +import numpy as np +import pytest + +from scopesim.detector import Detector +from scopesim.effects.electronic import PixelResponseNonUniformity +from scopesim.optics.image_plane_utils import header_from_list_of_xy + + +def make_detector(value=1000, size=10): + hdr = header_from_list_of_xy([-size / 2, size / 2], + [-size / 2, size / 2], 1, "D") + dtcr = Detector(hdr) + dtcr._hdu.data[:] = value + return dtcr + + +def test_output_std_matches_prnu_std(): + prnu_std = 0.05 + dtcr = make_detector(value=1000, size=100) + PixelResponseNonUniformity(prnu_std=prnu_std, prnu_seed=42).apply_to(dtcr) + rel_std = dtcr._hdu.data.std() / dtcr._hdu.data.mean() + assert abs(rel_std - prnu_std) < 0.01 + + +def test_gain_map_is_reused(): + dtcr1 = make_detector() + dtcr2 = make_detector() + prnu = PixelResponseNonUniformity(prnu_std=0.01, prnu_seed=42) + prnu.apply_to(dtcr1) + prnu.apply_to(dtcr2) + np.testing.assert_array_equal(dtcr1._hdu.data, dtcr2._hdu.data) + + +def test_dict_prnu_std(): + hdr = header_from_list_of_xy([-5, 5], [-5, 5], 1, "D") + dtcr = Detector(hdr) + dtcr.meta["id"] = "H2RG" + dtcr._hdu.data[:] = 1000 + prnu = PixelResponseNonUniformity( + prnu_std={"H2RG": 0.005, "GeoSnap": 0.020}, prnu_seed=42) + prnu.apply_to(dtcr) + assert dtcr._hdu.data.std() > 0 + + +def test_multiplicative_zero_signal(): + dtcr = make_detector(value=0) + PixelResponseNonUniformity(prnu_std=0.01, prnu_seed=42).apply_to(dtcr) + assert dtcr._hdu.data.sum() == 0 + + +def test_non_detector_passthrough(): + prnu = PixelResponseNonUniformity(prnu_std=0.01) + result = prnu.apply_to("not a detector") + assert result == "not a detector" + + +def test_invalid_prnu_std_raises(): + prnu = PixelResponseNonUniformity(prnu_std="not a number nor a dict") + with pytest.raises(TypeError): + prnu.apply_to(make_detector()) + + +def test_plot_raises_before_simulation(): + prnu = PixelResponseNonUniformity(prnu_std=0.01) + with pytest.raises(RuntimeError): + prnu.plot() + + +def test_plot_returns_figure(): + prnu = PixelResponseNonUniformity(prnu_std=0.01, prnu_seed=42) + prnu.apply_to(make_detector()) + assert prnu.plot() is not None diff --git a/scopesim/tests/tests_effects/test_selector_wheel.py b/scopesim/tests/tests_effects/test_selector_wheel.py new file mode 100644 index 000000000..0f1f0d533 --- /dev/null +++ b/scopesim/tests/tests_effects/test_selector_wheel.py @@ -0,0 +1,67 @@ +"""Tests for SelectorWheel.""" + +import numpy as np + +from scopesim.effects import SelectorWheel +from scopesim.optics.image_plane import ImagePlane +from scopesim.tests.mocks.py_objects.imagehdu_objects import _image_hdu_square + + +def make_image_plane(): + image_plane = ImagePlane(_image_hdu_square().header) + image_plane.hdu.data = np.ones((10, 10), dtype=float) + return image_plane + + +def test_selector_wheel_can_override_child_z_order(): + wheel = SelectorWheel( + selector_key="image_plane_id", + z_order=[760], + wheel=[ + { + "selector_value": 0, + "effect_class": "ImagePlaneBackground", + "effect_kwargs": {"value": 1.0}, + }, + ], + ) + + assert wheel.z_order == (760,) + + +def test_selector_wheel_inherits_child_z_order_by_default(): + wheel = SelectorWheel( + selector_key="image_plane_id", + wheel=[ + { + "selector_value": 0, + "effect_class": "ImagePlaneBackground", + "effect_kwargs": {"value": 1.0}, + }, + ], + ) + + assert wheel.z_order == (760,) + + +def test_selector_wheel_applies_image_plane_effect_by_id(): + image_plane = make_image_plane() + wheel = SelectorWheel( + selector_key="image_plane_id", + wheel=[ + { + "selector_value": 0, + "effect_class": "ImagePlaneBackground", + "effect_kwargs": {"value": 2.0}, + }, + { + "selector_value": 1, + "effect_class": "ImagePlaneBackground", + "effect_kwargs": {"value": 10.0}, + }, + ], + ) + + wheel.apply_to(image_plane) + + assert np.all(image_plane.hdu.data == 3.0) From 718ff3522dc3b7ce80440b8ba50df6fa759862c6 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Fri, 29 May 2026 16:28:41 -0700 Subject: [PATCH 06/43] Add post-disperser diffuse background effect --- scopesim/effects/illumination.py | 280 +++++++++++++++++- .../tests/tests_effects/test_illumination.py | 99 +++++++ 2 files changed, 378 insertions(+), 1 deletion(-) diff --git a/scopesim/effects/illumination.py b/scopesim/effects/illumination.py index 4822a2b66..c839fbd1e 100644 --- a/scopesim/effects/illumination.py +++ b/scopesim/effects/illumination.py @@ -6,18 +6,27 @@ import numpy as np from astropy import units as u +from astropy.units import UnitConversionError from astropy.modeling.functional_models import Gaussian2D +from synphot.units import PHOTLAM from . import Effect +from .surface_list import SurfaceList +from .ter_curves import SpectralQuantumEfficiency from ..optics.image_plane import ImagePlane -from ..utils import figure_factory, from_currsys +from ..utils import figure_factory, from_currsys, pixel_area, quantify, real_colname __all__ = [ "Illumination", "ImagePlaneBackground", + "PostDisperserDiffuseBackground", + "effective_diffuse_qe", "gaussian2d", + "integrate_spectral_background", + "post_disperser_diffuse_spectrum", "quadratic_vignetting", + "wavelength_bin_widths", ] @@ -68,6 +77,168 @@ def quadratic_vignetting( return np.clip(1.0 - falloff * r2 / r2_ref, 0.0, 1.0) +def _as_float_array(values) -> np.ndarray: + if hasattr(values, "value"): + values = values.value + return np.asarray(values, dtype=float) + + +def wavelength_bin_widths(wave: u.Quantity) -> u.Quantity: + """Return centre-bin widths for a sampled wavelength grid.""" + wave = wave.to(u.um) + if wave.size < 2: + raise ValueError("At least two wavelength samples are required.") + + wave_values = wave.to_value(wave.unit) + if np.any(np.diff(wave_values) <= 0): + raise ValueError("Wavelength samples must be strictly increasing.") + + widths = np.zeros(wave.size, dtype=float) + diffs = np.diff(wave_values) + widths[:-1] += 0.5 * diffs + widths[1:] += 0.5 * diffs + return widths * wave.unit + + +def _representative_positional_qe(positional_qe, wave: u.Quantity): + if positional_qe is None: + return 1.0 + values = positional_qe(wave) if callable(positional_qe) else positional_qe + values = _as_float_array(values) + if values.shape == wave.shape: + return values + return float(np.nanmean(values)) + + +def effective_diffuse_qe( + detector_qe, + wave: u.Quantity, + positional_qe=None, +) -> np.ndarray: + """Return effective detector QE for non-dispersed diffuse backgrounds. + + Ordinary detector QE contributes its spectral throughput. Future tapered + QE coatings can provide a positional map/callable; this function reduces + that positional response to a representative footprint average instead of + skipping QE for diffuse image-plane backgrounds. + """ + if detector_qe is None: + spectral_qe = np.ones(wave.size, dtype=float) + else: + spectral_qe = _as_float_array(detector_qe.throughput(wave)) + return spectral_qe * _representative_positional_qe(positional_qe, wave) + + +def _row_value(row, name): + value = row[name] + return value.item() if hasattr(value, "item") else value + + +def _clean_text(value) -> str | None: + text = str(value).strip() + if not text or text in {"--", "None", "nan"}: + return None + return text + + +def _row_phase(row) -> str | None: + phase_col = real_colname("emission_phase", row.colnames) + if phase_col is None: + return None + return _clean_text(_row_value(row, phase_col)) + + +def _surface_emission_values(surface, wave: u.Quantity): + emission = surface.emission + if emission is None: + return None + values = emission(wave) + if not isinstance(values, u.Quantity): + values = values * PHOTLAM + return values + + +def post_disperser_diffuse_spectrum( + surface_list, + wave: u.Quantity, + qe_values: np.ndarray | None = None, + emission_phase: str = "post_disperser", +): + """Return summed post-disperser diffuse emission after downstream optics. + + This mirrors :meth:`SurfaceList.combine_emissions`, but selects emitting + rows by explicit ``emission_phase`` metadata instead of by z-order. The + returned spectrum is still a surface-brightness-like spectral density; use + :func:`integrate_spectral_background` to collapse it to image-plane + ``ph s-1 pixel-1``. + """ + if surface_list.table is None or len(surface_list.table) == 0: + return None + + name_col = real_colname("name", surface_list.table.colnames) + action_col = real_colname("action", surface_list.table.colnames) + if name_col is None or action_col is None: + raise ValueError("SurfaceList table must contain name and action columns.") + + rows = [] + for row in surface_list.table: + surface_name = str(_row_value(row, name_col)) + action_name = str(_row_value(row, action_col)) + surface = surface_list.surfaces[surface_name] + rows.append({ + "phase": _row_phase(row), + "surface": surface, + "action_values": _as_float_array(getattr(surface, action_name)(wave)), + "emission_values": _surface_emission_values(surface, wave), + }) + + downstream = [np.ones(wave.size, dtype=float) for _ in range(len(rows) + 1)] + for idx in range(len(rows) - 1, -1, -1): + downstream[idx] = downstream[idx + 1] * rows[idx]["action_values"] + + if qe_values is None: + qe_values = np.ones(wave.size, dtype=float) + + total = None + for idx, row in enumerate(rows): + if row["phase"] != emission_phase or row["emission_values"] is None: + continue + contribution = row["emission_values"] * downstream[idx + 1] * qe_values + total = contribution if total is None else total + contribution + + return total + + +def integrate_spectral_background( + spectral_density, + wave: u.Quantity, + telescope_area: u.Quantity, + image_pixel_area: u.Quantity, +) -> float: + """Integrate a diffuse spectrum to ScopeSim image-plane units. + + The returned scalar is ``ph s-1 pixel-1``. If the spectrum already carries + an inverse-solid-angle unit, that unit is converted explicitly. Otherwise + the result follows ScopeSim's current ``BackgroundSourceField`` convention: + PHOTLAM-like thermal spectra are interpreted as per square arcsecond, then + multiplied by the image-plane pixel area. + """ + if spectral_density is None: + return 0.0 + if not isinstance(spectral_density, u.Quantity): + spectral_density = spectral_density * PHOTLAM + + widths = wavelength_bin_widths(wave).to(u.AA) + telescope_area = telescope_area << u.m**2 + image_pixel_area = image_pixel_area << u.arcsec**2 + + rate = np.sum(spectral_density * widths * telescope_area) + try: + return (rate * image_pixel_area).to_value(u.ph / u.s) + except UnitConversionError: + return rate.to_value(u.ph / u.s) * image_pixel_area.to_value(u.arcsec**2) + + class Illumination(Effect): """Large-scale multiplicative illumination variation on the image plane.""" @@ -178,3 +349,110 @@ def plot(self): ax.set_xlabel("x [px]") ax.set_ylabel("y [px]") return fig + + +class PostDisperserDiffuseBackground(ImagePlaneBackground): + """Add thermal emission from post-disperser optics as image-plane light. + + The source surfaces are selected by explicit ``emission_phase`` metadata in + a ``SurfaceList`` table. This avoids using z-order as a proxy for optical + phase: pre-disperser emission remains ordinary source/FOV background, while + post-disperser emission is integrated and added after trace mapping. + Detector QE is applied as throughput only through + :func:`effective_diffuse_qe`. + """ + + z_order: ClassVar[tuple[int, ...]] = (760,) + + def __init__( + self, + filename: str | None = None, + detector_qe_filename: str | None = None, + surface_list=None, + detector_qe=None, + positional_qe=None, + emission_phase: str = "post_disperser", + **kwargs, + ) -> None: + super().__init__(value=0.0, **kwargs) + self.meta["include"] = kwargs.get("include", True) + self.meta.update({ + "filename": filename, + "detector_qe_filename": detector_qe_filename, + "wave_min": kwargs.get("wave_min", "!SIM.spectral.wave_min"), + "wave_max": kwargs.get("wave_max", "!SIM.spectral.wave_max"), + "wave_bin": kwargs.get("wave_bin", "!SIM.spectral.spectral_bin_width"), + "wave_unit": kwargs.get("wave_unit", "!SIM.spectral.wave_unit"), + "area": kwargs.get("area", "!TEL.area"), + "emission_phase": emission_phase, + }) + self._surface_list = ( + surface_list if surface_list is not None + else SurfaceList(filename=filename, cmds=self.cmds) + ) + self._detector_qe = ( + detector_qe if detector_qe is not None + else ( + SpectralQuantumEfficiency( + filename=detector_qe_filename, cmds=self.cmds) + if detector_qe_filename is not None + else None + ) + ) + self._positional_qe = positional_qe + self._last_value = None + + def apply_to(self, obj, **kwargs): + if not isinstance(obj, ImagePlane): + return obj + + shape = obj.hdu.data.shape + value = self.background_value(obj) + if ( + self._map is None or shape != self._map_shape + or value != self._last_value + ): + self._map = np.full(shape, value, dtype=np.float32) + self._map_shape = shape + self._last_value = value + + obj.hdu.data = obj.hdu.data + self._map + return obj + + def background_value(self, image_plane: ImagePlane) -> float: + """Return the scalar background in ``ph s-1 pixel-1``.""" + wave = self._waveset() + qe_values = effective_diffuse_qe( + self._detector_qe, wave, positional_qe=self._positional_qe, + ) + spectrum = post_disperser_diffuse_spectrum( + self._surface_list, + wave, + qe_values=qe_values, + emission_phase=self.meta["emission_phase"], + ) + area = quantify(from_currsys(self.meta["area"], self.cmds), u.m**2) + return integrate_spectral_background( + spectrum, + wave, + telescope_area=area, + image_pixel_area=pixel_area(image_plane.header), + ) + + def _waveset(self) -> u.Quantity: + wave_unit = u.Unit(from_currsys(self.meta["wave_unit"], self.cmds)) + wave_min = quantify(from_currsys(self.meta["wave_min"], self.cmds), + wave_unit).to(wave_unit) + wave_max = quantify(from_currsys(self.meta["wave_max"], self.cmds), + wave_unit).to(wave_unit) + wave_bin = quantify(from_currsys(self.meta["wave_bin"], self.cmds), + wave_unit).to(wave_unit) + stop = wave_max.to_value(wave_unit) + 0.5 * wave_bin.to_value(wave_unit) + wave = np.arange( + wave_min.to_value(wave_unit), + stop, + wave_bin.to_value(wave_unit), + ) * wave_unit + if wave.size < 2: + raise ValueError("Post-disperser background wavelength grid is empty.") + return wave diff --git a/scopesim/tests/tests_effects/test_illumination.py b/scopesim/tests/tests_effects/test_illumination.py index dc9d4ddb1..3c87f443f 100644 --- a/scopesim/tests/tests_effects/test_illumination.py +++ b/scopesim/tests/tests_effects/test_illumination.py @@ -2,15 +2,23 @@ import numpy as np import pytest +from astropy import units as u +from astropy.table import Table +from synphot.units import PHOTLAM from scopesim.effects.illumination import ( ImagePlaneBackground, Illumination, + PostDisperserDiffuseBackground, + effective_diffuse_qe, gaussian2d, + integrate_spectral_background, + post_disperser_diffuse_spectrum, quadratic_vignetting, ) from scopesim.optics.image_plane import ImagePlane from scopesim.tests.mocks.py_objects.imagehdu_objects import _image_hdu_square +from scopesim.utils import pixel_area @pytest.fixture @@ -101,3 +109,94 @@ def test_image_plane_background_skips_non_imageplane(): def test_image_plane_background_plot_raises_before_apply(): with pytest.raises(RuntimeError): ImagePlaneBackground(value=1).plot() + + +class ConstantCurve: + def __init__(self, value): + self.value = value + + def __call__(self, wave): + return np.full(wave.size, self.value) + + +class ConstantEmission: + def __init__(self, value): + self.value = value + + def __call__(self, wave): + return np.full(wave.size, self.value) * PHOTLAM + + +class FakeSurface: + def __init__(self, transmission, emission): + self.transmission = ConstantCurve(transmission) + self.emission = ConstantEmission(emission) + + +class FakeSurfaceList: + table = Table({ + "name": ["pre", "camera"], + "action": ["transmission", "transmission"], + "emission_phase": ["pre_disperser", "post_disperser"], + }) + + def __init__(self): + self.surfaces = { + "pre": FakeSurface(transmission=0.5, emission=100.0), + "camera": FakeSurface(transmission=0.8, emission=2.0), + } + + +class FakeQE: + throughput = ConstantCurve(0.5) + + +def test_effective_diffuse_qe_uses_average_positional_response(): + wave = np.linspace(1.0, 2.0, 3) * u.um + positional_qe = np.array([[0.8, 1.0], [0.6, 1.0]]) + + qe = effective_diffuse_qe(FakeQE(), wave, positional_qe=positional_qe) + + np.testing.assert_allclose(qe, np.full(wave.size, 0.425)) + + +def test_post_disperser_diffuse_spectrum_uses_phase_metadata_and_qe(): + wave = np.linspace(1.0, 2.0, 3) * u.um + qe = np.full(wave.size, 0.5) + + spectrum = post_disperser_diffuse_spectrum( + FakeSurfaceList(), wave, qe_values=qe, + ) + + np.testing.assert_allclose(spectrum.value, np.full(wave.size, 1.0)) + + +def test_integrate_spectral_background_returns_image_plane_rate(): + wave = np.array([1.0, 2.0]) * u.um + spectrum = np.full(wave.size, 1.0) * PHOTLAM + + rate = integrate_spectral_background( + spectrum, + wave, + telescope_area=1.0 * u.m**2, + image_pixel_area=0.01 * u.arcsec**2, + ) + + assert rate == pytest.approx(1.0e6) + + +def test_post_disperser_diffuse_background_adds_integrated_rate(imageplane): + eff = PostDisperserDiffuseBackground( + surface_list=FakeSurfaceList(), + detector_qe=FakeQE(), + wave_min=1.0, + wave_max=2.0, + wave_bin=1.0, + wave_unit="um", + area=1.0 * u.m**2, + ) + + eff.apply_to(imageplane) + + expected = 1.0 + 1.0e8 * pixel_area(imageplane.header).to_value(u.arcsec**2) + assert imageplane.hdu.data[0, 0] == pytest.approx(expected) From af0850b639f61915fda31cfd5c09e9b9b3f8cc35 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Fri, 29 May 2026 17:33:56 -0700 Subject: [PATCH 07/43] Handle detector WCS for diffuse background pixels --- scopesim/effects/illumination.py | 68 ++++++++++++++++++- .../tests/tests_effects/test_illumination.py | 47 +++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/scopesim/effects/illumination.py b/scopesim/effects/illumination.py index c839fbd1e..6079f5cc5 100644 --- a/scopesim/effects/illumination.py +++ b/scopesim/effects/illumination.py @@ -14,7 +14,7 @@ from .surface_list import SurfaceList from .ter_curves import SpectralQuantumEfficiency from ..optics.image_plane import ImagePlane -from ..utils import figure_factory, from_currsys, pixel_area, quantify, real_colname +from ..utils import figure_factory, from_currsys, quantify, real_colname __all__ = [ @@ -23,6 +23,7 @@ "PostDisperserDiffuseBackground", "effective_diffuse_qe", "gaussian2d", + "image_plane_pixel_area", "integrate_spectral_background", "post_disperser_diffuse_spectrum", "quadratic_vignetting", @@ -239,6 +240,69 @@ def integrate_spectral_background( return rate.to_value(u.ph / u.s) * image_pixel_area.to_value(u.arcsec**2) +def image_plane_pixel_area(header, cmds=None) -> u.Quantity: + """Return the angular area represented by one image-plane pixel. + + Some spectroscopic image-plane headers only carry detector WCS keywords + such as ``CDELT1D``/``CUNIT1D``. Those are detector lengths, not sky angles, + so diffuse surface-brightness backgrounds need the instrument angular pixel + scale instead of :func:`scopesim.utils.pixel_area`. + """ + for suffix in ("", "S", "D"): + area = _angular_pixel_area_from_header(header, suffix) + if area is not None: + return area + + detector_area = _detector_pixel_area_from_header(header, cmds) + if detector_area is not None: + return detector_area + + if cmds is not None and "!INST.pixel_scale" in cmds: + scale = quantify(from_currsys("!INST.pixel_scale", cmds), u.arcsec) + return (abs(scale) ** 2).to(u.arcsec**2) + + raise KeyError( + "Image-plane header has no angular WCS pixel scale and " + "!INST.pixel_scale is unavailable." + ) + + +def _angular_pixel_area_from_header(header, suffix: str) -> u.Quantity | None: + keys = (f"CDELT1{suffix}", f"CUNIT1{suffix}", + f"CDELT2{suffix}", f"CUNIT2{suffix}") + if not all(key in header for key in keys): + return None + + unit1 = u.Unit(header[keys[1]]) + unit2 = u.Unit(header[keys[3]]) + area = abs(header[keys[0]] * header[keys[2]]) * unit1 * unit2 + if area.unit.is_equivalent(u.arcsec**2): + return area.to(u.arcsec**2) + return None + + +def _detector_pixel_area_from_header(header, cmds) -> u.Quantity | None: + keys = ("CDELT1D", "CUNIT1D", "CDELT2D", "CUNIT2D") + if cmds is None or not all(key in header for key in keys): + return None + + unit1 = u.Unit(header["CUNIT1D"]) + unit2 = u.Unit(header["CUNIT2D"]) + if not ( + unit1.is_equivalent(u.mm) + and unit2.is_equivalent(u.mm) + and "!INST.plate_scale" in cmds + ): + return None + + plate_scale = quantify( + from_currsys("!INST.plate_scale", cmds), u.arcsec / u.mm, + ) + dx = abs(header["CDELT1D"]) * unit1 + dy = abs(header["CDELT2D"]) * unit2 + return (dx * plate_scale * dy * plate_scale).to(u.arcsec**2) + + class Illumination(Effect): """Large-scale multiplicative illumination variation on the image plane.""" @@ -436,7 +500,7 @@ def background_value(self, image_plane: ImagePlane) -> float: spectrum, wave, telescope_area=area, - image_pixel_area=pixel_area(image_plane.header), + image_pixel_area=image_plane_pixel_area(image_plane.header, self.cmds), ) def _waveset(self) -> u.Quantity: diff --git a/scopesim/tests/tests_effects/test_illumination.py b/scopesim/tests/tests_effects/test_illumination.py index 3c87f443f..b9a572960 100644 --- a/scopesim/tests/tests_effects/test_illumination.py +++ b/scopesim/tests/tests_effects/test_illumination.py @@ -3,6 +3,7 @@ import numpy as np import pytest from astropy import units as u +from astropy.io import fits from astropy.table import Table from synphot.units import PHOTLAM @@ -12,6 +13,7 @@ PostDisperserDiffuseBackground, effective_diffuse_qe, gaussian2d, + image_plane_pixel_area, integrate_spectral_background, post_disperser_diffuse_spectrum, quadratic_vignetting, @@ -185,6 +187,27 @@ def test_integrate_spectral_background_returns_image_plane_rate(): assert rate == pytest.approx(1.0e6) +def test_image_plane_pixel_area_uses_detector_wcs_with_plate_scale(): + header = fits.Header({ + "CDELT1D": 0.015, + "CUNIT1D": "mm", + "CDELT2D": 0.015, + "CUNIT2D": "mm", + }) + + area = image_plane_pixel_area(header, {"!INST.plate_scale": 10.0}) + + assert area.to_value(u.arcsec**2) == pytest.approx(0.0225) + + +def test_image_plane_pixel_area_falls_back_to_instrument_pixel_scale(): + header = fits.Header() + + area = image_plane_pixel_area(header, {"!INST.pixel_scale": 0.16}) + + assert area.to_value(u.arcsec**2) == pytest.approx(0.0256) + + def test_post_disperser_diffuse_background_adds_integrated_rate(imageplane): eff = PostDisperserDiffuseBackground( surface_list=FakeSurfaceList(), @@ -200,3 +223,27 @@ def test_post_disperser_diffuse_background_adds_integrated_rate(imageplane): expected = 1.0 + 1.0e8 * pixel_area(imageplane.header).to_value(u.arcsec**2) assert imageplane.hdu.data[0, 0] == pytest.approx(expected) + + +def test_post_disperser_diffuse_background_accepts_detector_wcs(imageplane): + for key in ("CDELT1", "CUNIT1", "CDELT2", "CUNIT2"): + imageplane.header.remove(key, ignore_missing=True, remove_all=True) + imageplane.header["CDELT1D"] = 0.015 + imageplane.header["CUNIT1D"] = "mm" + imageplane.header["CDELT2D"] = 0.015 + imageplane.header["CUNIT2D"] = "mm" + + eff = PostDisperserDiffuseBackground( + surface_list=FakeSurfaceList(), + detector_qe=FakeQE(), + wave_min=1.0, + wave_max=2.0, + wave_bin=1.0, + wave_unit="um", + area=1.0 * u.m**2, + cmds={"!INST.plate_scale": 10.0}, + ) + + eff.apply_to(imageplane) + + assert imageplane.hdu.data[0, 0] == pytest.approx(1.0 + 2.25e6) From 51771ad676511cc2f8310a1a9bbbf2052b6fe520 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Fri, 29 May 2026 18:54:34 -0700 Subject: [PATCH 08/43] Cache repeated ADC and moon calculations --- scopesim/effects/atmo_dispersion.py | 67 +++++++++++++++++++++-------- scopesim/utils.py | 15 +++++-- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/scopesim/effects/atmo_dispersion.py b/scopesim/effects/atmo_dispersion.py index 205435c4b..2ef5e3557 100644 --- a/scopesim/effects/atmo_dispersion.py +++ b/scopesim/effects/atmo_dispersion.py @@ -301,6 +301,9 @@ def __init__(self, **kwargs): self.target, self.location, self.time = get_observation_info_from_cmds(self.cmds) self.zenith_angle = get_zenith_angle(self.target, self.location, self.time) * u.deg + self._ad_shift_cache_key = None + self._ad_shift_cache = None + self._residual_interpolator_cache = None def get_shifts(self, obj: FieldOfView3D): """ @@ -313,19 +316,7 @@ def get_shifts(self, obj: FieldOfView3D): if self.data is not None and isinstance(self.data, Table): logger.info(f'Residuals supplied by {self.meta["filename"]}') - Z = self.data.colnames - Z.remove("wavelength") - lam_um = quantity_from_table("wavelength", self.data, "um").value - R = np.array([np.array(self.data[z]).astype(float) for z in Z]) - R_unit = u.Unit(self.data.meta.get("shifts_unit", "arcsec")) - Z = np.array(Z).astype(float) - adc_opt_resid = RegularGridInterpolator((Z, lam_um), R, method="linear", bounds_error=False, - fill_value=None) - - adc_nir_resid = lambda xy: adc_opt_resid((xy[0], (xy[1] - 1) / 1.5 * (1.1 - .31) + .31)) # scale to nIR - # 300-500 & 500-1000 - adc_ub_resid = lambda xy: adc_opt_resid((xy[0], (xy[1] - .3) / .2 * (1.1 - .31) + .31)) / 2 # scale - adc_gri_resid = lambda xy: adc_opt_resid((xy[0], (xy[1] - .5) / .5 * (1.1 - .31) + .31)) / 2 # scale to nIR + R_unit, adc_opt_resid = self._get_residual_interpolator() if self.meta.get("use_broadband", False): use = wave < 1.0 * u.um @@ -333,15 +324,16 @@ def get_shifts(self, obj: FieldOfView3D): (self.zenith_angle.to_value(u.deg), wave[use].to_value(u.um))) * R_unit).to(u.arcsec) else: use = wave < 0.5 * u.um - res1 = (adc_ub_resid( + res1 = (self._adc_ub_resid(adc_opt_resid, (self.zenith_angle.to_value(u.deg), wave[use].to_value(u.um))) * R_unit).to(u.arcsec) shifts[use] += res1 use = ~use & (wave < 1.0 * u.um) - res2 = (adc_gri_resid( + res2 = (self._adc_gri_resid(adc_opt_resid, (self.zenith_angle.to_value(u.deg), wave[use].to_value(u.um))) * R_unit).to(u.arcsec) shifts[use] += res2 use = wave >= 1.0 * u.um - res3 = (adc_nir_resid((self.zenith_angle.to_value(u.deg), wave[use].to_value(u.um))) * R_unit).to(u.arcsec) + res3 = (self._adc_nir_resid(adc_opt_resid, + (self.zenith_angle.to_value(u.deg), wave[use].to_value(u.um))) * R_unit).to(u.arcsec) shifts[use] += res3 if self.meta.get('zenith_angle_error', 0.0) != 0.0: @@ -354,11 +346,13 @@ def get_shifts(self, obj: FieldOfView3D): ad_kwargs[k] = v else: ad_kwargs[k] = self.meta[k] - ad = ADShift(**ad_kwargs, cmds=self.cmds) + ad = self._get_ad_shift(ad_kwargs) + ad.zenith_angle = self.zenith_angle ad_shift = ad._get_shifts_arcsec(obj) # get shift at zenith angle ad.zenith_angle = ad.zenith_angle + self.meta.get('zenith_angle_error', 0.0) * u.deg # update zenith angle ad_shift -= ad._get_shifts_arcsec(obj) # subtract shift at (zenith angle + error) to get residual shifts += ad_shift + ad.zenith_angle = self.zenith_angle pos_angle_y = field_rotation_pa_y(obj.hdu.header) par_angle = get_parallactic_angle(self.target, self.location, self.time) @@ -368,6 +362,43 @@ def get_shifts(self, obj: FieldOfView3D): dx = -1 * shifts * np.sin(theta) return dx, dy + def _get_residual_interpolator(self): + if self._residual_interpolator_cache is None: + Z = list(self.data.colnames) + Z.remove("wavelength") + lam_um = quantity_from_table("wavelength", self.data, "um").value + R = np.array([np.array(self.data[z]).astype(float) for z in Z]) + R_unit = u.Unit(self.data.meta.get("shifts_unit", "arcsec")) + Z = np.array(Z).astype(float) + adc_opt_resid = RegularGridInterpolator( + (Z, lam_um), R, method="linear", bounds_error=False, + fill_value=None, + ) + self._residual_interpolator_cache = (R_unit, adc_opt_resid) + return self._residual_interpolator_cache + + @staticmethod + def _adc_nir_resid(adc_opt_resid, xy): + return adc_opt_resid((xy[0], (xy[1] - 1) / 1.5 * (1.1 - .31) + .31)) + + @staticmethod + def _adc_ub_resid(adc_opt_resid, xy): + return adc_opt_resid((xy[0], (xy[1] - .3) / .2 * (1.1 - .31) + .31)) / 2 + + @staticmethod + def _adc_gri_resid(adc_opt_resid, xy): + return adc_opt_resid((xy[0], (xy[1] - .5) / .5 * (1.1 - .31) + .31)) / 2 + + def _get_ad_shift(self, ad_kwargs): + cache_key = tuple( + (key, str(from_currsys(value, self.cmds))) + for key, value in sorted(ad_kwargs.items()) + ) + if self._ad_shift_cache is None or cache_key != self._ad_shift_cache_key: + self._ad_shift_cache = ADShift(**ad_kwargs, cmds=self.cmds) + self._ad_shift_cache_key = cache_key + return self._ad_shift_cache + ########################### AD utils ############################### def field_rotation_pa_y(header) -> float: @@ -492,4 +523,4 @@ def refractive_index(wavelength: u.Quantity, temp: u.Quantity, pressure: u.Quant nprop = 1 + (ρa / ρaxs) * (naxs - 1) + (ρw / ρws) * (nws - 1) - return nprop \ No newline at end of file + return nprop diff --git a/scopesim/utils.py b/scopesim/utils.py index 602913e46..41e5ff067 100644 --- a/scopesim/utils.py +++ b/scopesim/utils.py @@ -1169,6 +1169,8 @@ def resolve_time(time_str, location: EarthLocation | None = None): if ('T' in time_str) and (':' in time_str): ## ISOT format logger.info(f"Resolving time: {time_str} assuming ISOT format and UTC scale") t = Time(time_str, format="isot", location=location) + elif time_str == "grey": + time_str = "gray" elif time_str not in ["bright", "gray", "dark"]: logger.warning(f"Unrecognized time string input: {time_str}. Defaulting to 'dark'.") time_str = "dark" @@ -1219,14 +1221,21 @@ def get_next_moon(moontype="full"): """ Get time of the next closest moon phase of given moon type (full, half or new). """ - times = Time.now() + np.linspace(0, 30, 1000)*u.day + today = Time.now().isot.split("T")[0] + return _get_next_moon_cached(moontype, today) + + +@functools.lru_cache(maxsize=32) +def _get_next_moon_cached(moontype="full", today=None): + now = Time.now() + times = now + np.linspace(0, 30, 1000)*u.day phases = get_moon_phase(times) flis = get_moon_fli(phases) next_full = times[np.argmax(flis)] next_new = times[np.argmin(flis)] prev_full = next_full - 29.53*u.day prev_new = next_new - 29.53*u.day - if min(next_new, next_full) - Time.now() > Time.now() - max(prev_new, prev_full): + if min(next_new, next_full) - now > now - max(prev_new, prev_full): next_half = min(next_new, next_full) - 7.38*u.day else: next_half = min(next_new, next_full) + 7.38*u.day @@ -1237,4 +1246,4 @@ def get_next_moon(moontype="full"): elif moontype == "half": return next_half.isot.split('T')[0]+"T00:00:00" else: - raise ValueError(f"Invalid moon type: {moontype}, should be 'full', 'new' or 'half'.") \ No newline at end of file + raise ValueError(f"Invalid moon type: {moontype}, should be 'full', 'new' or 'half'.") From 3e5e3fb712371715b1f6286f781eb135f578b6ef Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Sat, 30 May 2026 09:59:19 -0700 Subject: [PATCH 09/43] Cache post-disperser diffuse spectral rates --- scopesim/effects/illumination.py | 84 ++++++++++++++++++- .../tests/tests_effects/test_illumination.py | 48 +++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/scopesim/effects/illumination.py b/scopesim/effects/illumination.py index 6079f5cc5..0dff22952 100644 --- a/scopesim/effects/illumination.py +++ b/scopesim/effects/illumination.py @@ -17,6 +17,10 @@ from ..utils import figure_factory, from_currsys, quantify, real_colname +_POST_DISPERSER_RATE_CACHE_MAXSIZE = 64 +_POST_DISPERSER_RATE_CACHE: dict[tuple, float] = {} + + __all__ = [ "Illumination", "ImagePlaneBackground", @@ -111,6 +115,33 @@ def _representative_positional_qe(positional_qe, wave: u.Quantity): return float(np.nanmean(values)) +def _cache_object_key(filename, obj): + if filename is not None: + return ("file", str(filename)) + if obj is None: + return None + return ("object", id(obj)) + + +def _image_plane_cache_key(image_plane): + header = image_plane.header + return ( + header.get("EXTNAME"), + header.get("IMAGEID"), + header.get("IMGPLANE"), + header.get("CDELT1"), + header.get("CDELT2"), + header.get("CDELT1D"), + header.get("CDELT2D"), + ) + + +def _store_post_disperser_rate_cache(key: tuple, value: float) -> None: + if len(_POST_DISPERSER_RATE_CACHE) >= _POST_DISPERSER_RATE_CACHE_MAXSIZE: + _POST_DISPERSER_RATE_CACHE.clear() + _POST_DISPERSER_RATE_CACHE[key] = value + + def effective_diffuse_qe( detector_qe, wave: u.Quantity, @@ -485,6 +516,18 @@ def apply_to(self, obj, **kwargs): def background_value(self, image_plane: ImagePlane) -> float: """Return the scalar background in ``ph s-1 pixel-1``.""" + rate_per_arcsec2 = self._background_rate_per_arcsec2(image_plane) + pixel_area = image_plane_pixel_area( + image_plane.header, self.cmds, + ).to_value(u.arcsec**2) + return rate_per_arcsec2 * pixel_area + + def _background_rate_per_arcsec2(self, image_plane: ImagePlane) -> float: + """Return the image-plane background rate before pixel-area scaling.""" + key = self._background_rate_cache_key(image_plane) + if key in _POST_DISPERSER_RATE_CACHE: + return _POST_DISPERSER_RATE_CACHE[key] + wave = self._waveset() qe_values = effective_diffuse_qe( self._detector_qe, wave, positional_qe=self._positional_qe, @@ -496,11 +539,48 @@ def background_value(self, image_plane: ImagePlane) -> float: emission_phase=self.meta["emission_phase"], ) area = quantify(from_currsys(self.meta["area"], self.cmds), u.m**2) - return integrate_spectral_background( + rate = integrate_spectral_background( spectrum, wave, telescope_area=area, - image_pixel_area=image_plane_pixel_area(image_plane.header, self.cmds), + image_pixel_area=1.0 * u.arcsec**2, + ) + _store_post_disperser_rate_cache(key, rate) + return rate + + def _background_rate_cache_key(self, image_plane: ImagePlane) -> tuple: + wave_unit = u.Unit(from_currsys(self.meta["wave_unit"], self.cmds)) + wave_min = quantify( + from_currsys(self.meta["wave_min"], self.cmds), wave_unit, + ).to_value(wave_unit) + wave_max = quantify( + from_currsys(self.meta["wave_max"], self.cmds), wave_unit, + ).to_value(wave_unit) + wave_bin = quantify( + from_currsys(self.meta["wave_bin"], self.cmds), wave_unit, + ).to_value(wave_unit) + area = quantify( + from_currsys(self.meta["area"], self.cmds), u.m**2, + ).to_value(u.m**2) + filename = self.meta["filename"] + qe_filename = self.meta["detector_qe_filename"] + positional_qe_key = None + if self._positional_qe is not None: + positional_qe_key = ( + id(self._positional_qe), + _image_plane_cache_key(image_plane), + ) + return ( + id(self.cmds), + _cache_object_key(filename, self._surface_list), + _cache_object_key(qe_filename, self._detector_qe), + positional_qe_key, + str(self.meta["emission_phase"]), + str(wave_unit), + float(wave_min), + float(wave_max), + float(wave_bin), + float(area), ) def _waveset(self) -> u.Quantity: diff --git a/scopesim/tests/tests_effects/test_illumination.py b/scopesim/tests/tests_effects/test_illumination.py index b9a572960..a3812d5c1 100644 --- a/scopesim/tests/tests_effects/test_illumination.py +++ b/scopesim/tests/tests_effects/test_illumination.py @@ -153,6 +153,30 @@ class FakeQE: throughput = ConstantCurve(0.5) +class CountingEmission(ConstantEmission): + def __init__(self, value): + super().__init__(value) + self.calls = 0 + + def __call__(self, wave): + self.calls += 1 + return super().__call__(wave) + + +class CountingSurfaceList: + table = Table({ + "name": ["camera"], + "action": ["transmission"], + "emission_phase": ["post_disperser"], + }) + + def __init__(self): + self.emission = CountingEmission(2.0) + surface = FakeSurface(transmission=1.0, emission=2.0) + surface.emission = self.emission + self.surfaces = {"camera": surface} + + def test_effective_diffuse_qe_uses_average_positional_response(): wave = np.linspace(1.0, 2.0, 3) * u.um positional_qe = np.array([[0.8, 1.0], [0.6, 1.0]]) @@ -247,3 +271,27 @@ def test_post_disperser_diffuse_background_accepts_detector_wcs(imageplane): eff.apply_to(imageplane) assert imageplane.hdu.data[0, 0] == pytest.approx(1.0 + 2.25e6) + + +def test_post_disperser_diffuse_background_reuses_spectral_rate_cache( + imageplane, +): + surface_list = CountingSurfaceList() + detector_qe = FakeQE() + cmds = {"!INST.pixel_scale": 0.16} + kwargs = { + "surface_list": surface_list, + "detector_qe": detector_qe, + "wave_min": 1.0, + "wave_max": 2.0, + "wave_bin": 1.0, + "wave_unit": "um", + "area": 1.0 * u.m**2, + "cmds": cmds, + } + + value_1 = PostDisperserDiffuseBackground(**kwargs).background_value(imageplane) + value_2 = PostDisperserDiffuseBackground(**kwargs).background_value(imageplane) + + assert value_2 == pytest.approx(value_1) + assert surface_list.emission.calls == 1 From d50b7e825392c0249270c22a9cdcb6dadc21912b Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Sat, 30 May 2026 11:20:06 -0700 Subject: [PATCH 10/43] Cache FOV spectrum wavesets --- scopesim/optics/fov.py | 18 ++++++++++++--- scopesim/tests/tests_optics/test_fov_utls.py | 23 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/scopesim/optics/fov.py b/scopesim/optics/fov.py index 503214f53..ef251938d 100644 --- a/scopesim/optics/fov.py +++ b/scopesim/optics/fov.py @@ -5,6 +5,7 @@ from copy import deepcopy from itertools import chain from collections.abc import Iterable, Generator +from weakref import WeakKeyDictionary import numpy as np from scipy.interpolate import interp1d @@ -44,6 +45,8 @@ logger = get_logger(__name__) +_SPECTRUM_WAVESET_AA_CACHE = WeakKeyDictionary() + class FieldOfView: """ @@ -1166,15 +1169,15 @@ def extract_range_from_spectrum(spectrum, waverange): f"spectrum must be of type synphot.SourceSpectrum: {type(spectrum)}") wave_min, wave_max = quantify(waverange, u.um).to(u.AA).value - spec_waveset = spectrum.waveset.to(u.AA).value - mask = (spec_waveset > wave_min) * (spec_waveset < wave_max) + spec_waveset = _spectrum_waveset_aa_value(spectrum) + mask = (spec_waveset > wave_min) & (spec_waveset < wave_max) # FIXME: Why did I comment this out in 2023? Seems useful to have... # if sum(mask) == 0: # logger.info( # "Waverange does not overlap with Spectrum waveset: %s <> %s for " # "spectrum %s", [wave_min, wave_max], spec_waveset, spectrum) - if wave_min < min(spec_waveset) or wave_max > max(spec_waveset): + if wave_min < spec_waveset[0] or wave_max > spec_waveset[-1]: logger.info(("Waverange only partially overlaps with Spectrum waveset: " "%s <> %s for spectrum %s"), [wave_min, wave_max], spec_waveset, spectrum) @@ -1186,3 +1189,12 @@ def extract_range_from_spectrum(spectrum, waverange): new_spectrum.meta.update(spectrum.meta) return new_spectrum + + +def _spectrum_waveset_aa_value(spectrum): + """Return cached spectrum waveset values in Angstrom.""" + spec_waveset = _SPECTRUM_WAVESET_AA_CACHE.get(spectrum) + if spec_waveset is None: + spec_waveset = spectrum.waveset.to(u.AA).value + _SPECTRUM_WAVESET_AA_CACHE[spectrum] = spec_waveset + return spec_waveset diff --git a/scopesim/tests/tests_optics/test_fov_utls.py b/scopesim/tests/tests_optics/test_fov_utls.py index ba08540dd..a6acb844b 100644 --- a/scopesim/tests/tests_optics/test_fov_utls.py +++ b/scopesim/tests/tests_optics/test_fov_utls.py @@ -115,6 +115,29 @@ def test_extracts_the_wave_range_needed(self): assert new_spec.waverange[0] == 1.98 * u.um assert new_spec(1.98 * u.um).value == approx(12.8) + def test_reuses_cached_spectrum_waveset(self, monkeypatch): + from scopesim.optics import fov as fov_mod + + fov_mod._SPECTRUM_WAVESET_AA_CACHE.clear() + wave = np.arange(0.7, 2.5, 0.1) * u.um + flux = np.arange(len(wave)) * PHOTLAM + spec = SourceSpectrum(Empirical1D, points=wave, lookup_table=flux) + original_waveset = SourceSpectrum.waveset.fget + calls = 0 + + def counting_waveset(self): + nonlocal calls + if self is spec: + calls += 1 + return original_waveset(self) + + monkeypatch.setattr(SourceSpectrum, "waveset", property(counting_waveset)) + + extract_range_from_spectrum(spec, [1.98, 2.12] * u.um) + extract_range_from_spectrum(spec, [1.90, 2.00] * u.um) + + assert calls == 1 + @pytest.mark.parametrize(("endpoint", "msg"), [pytest.param(1.5, "Waverange does not overlap", marks=pytest.mark.xfail(reason="Check was disabled in function, dunno why.")), (2.05, "Waverange only partially overlaps")]) From 50681ca94d3e23010f5f3c83d2d9d75353a43e72 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Sat, 30 May 2026 19:44:40 -0700 Subject: [PATCH 11/43] Improve analytical PSF kernel flux handling --- scopesim/effects/illumination.py | 36 +++-- scopesim/effects/psfs/analytical.py | 145 +++++++++++++++--- scopesim/effects/ter_curves.py | 60 ++++++++ .../tests/tests_effects/test_MoffatPSF.py | 89 +++++++++++ scopesim/tests/tests_effects/test_TERCurve.py | 48 ++++++ .../tests/tests_effects/test_illumination.py | 32 ++++ 6 files changed, 375 insertions(+), 35 deletions(-) create mode 100644 scopesim/tests/tests_effects/test_MoffatPSF.py diff --git a/scopesim/effects/illumination.py b/scopesim/effects/illumination.py index 0dff22952..ce413e18f 100644 --- a/scopesim/effects/illumination.py +++ b/scopesim/effects/illumination.py @@ -12,7 +12,7 @@ from . import Effect from .surface_list import SurfaceList -from .ter_curves import SpectralQuantumEfficiency +from .ter_curves import SpectralQuantumEfficiency, diffuse_detector_qe from ..optics.image_plane import ImagePlane from ..utils import figure_factory, from_currsys, quantify, real_colname @@ -105,10 +105,20 @@ def wavelength_bin_widths(wave: u.Quantity) -> u.Quantity: return widths * wave.unit -def _representative_positional_qe(positional_qe, wave: u.Quantity): +def _representative_positional_qe( + positional_qe, + wave: u.Quantity, + footprint=None, +): if positional_qe is None: return 1.0 - values = positional_qe(wave) if callable(positional_qe) else positional_qe + if callable(positional_qe): + try: + values = positional_qe(wave, footprint=footprint) + except TypeError: + values = positional_qe(wave) + else: + values = positional_qe values = _as_float_array(values) if values.shape == wave.shape: return values @@ -146,6 +156,7 @@ def effective_diffuse_qe( detector_qe, wave: u.Quantity, positional_qe=None, + footprint=None, ) -> np.ndarray: """Return effective detector QE for non-dispersed diffuse backgrounds. @@ -154,11 +165,10 @@ def effective_diffuse_qe( that positional response to a representative footprint average instead of skipping QE for diffuse image-plane backgrounds. """ - if detector_qe is None: - spectral_qe = np.ones(wave.size, dtype=float) - else: - spectral_qe = _as_float_array(detector_qe.throughput(wave)) - return spectral_qe * _representative_positional_qe(positional_qe, wave) + spectral_qe = _as_float_array( + diffuse_detector_qe(detector_qe, wave, footprint=footprint)) + return spectral_qe * _representative_positional_qe( + positional_qe, wave, footprint=footprint) def _row_value(row, name): @@ -530,7 +540,10 @@ def _background_rate_per_arcsec2(self, image_plane: ImagePlane) -> float: wave = self._waveset() qe_values = effective_diffuse_qe( - self._detector_qe, wave, positional_qe=self._positional_qe, + self._detector_qe, + wave, + positional_qe=self._positional_qe, + footprint=image_plane, ) spectrum = post_disperser_diffuse_spectrum( self._surface_list, @@ -565,7 +578,10 @@ def _background_rate_cache_key(self, image_plane: ImagePlane) -> tuple: filename = self.meta["filename"] qe_filename = self.meta["detector_qe_filename"] positional_qe_key = None - if self._positional_qe is not None: + if ( + self._positional_qe is not None + or getattr(self._detector_qe, "uses_detector_footprint", False) + ): positional_qe_key = ( id(self._positional_qe), _image_plane_cache_key(image_plane), diff --git a/scopesim/effects/psfs/analytical.py b/scopesim/effects/psfs/analytical.py index 22c1f3dc5..99df3187c 100644 --- a/scopesim/effects/psfs/analytical.py +++ b/scopesim/effects/psfs/analytical.py @@ -218,7 +218,14 @@ class MoffatPSF(AnalyticalPSF): Optional kwargs: - - kernel_size: Size of kernel in multiples of FWHM (int) + - kernel_size: Minimum size of kernel in multiples of FWHM (int) + - kernel_enclosed_energy: Target enclosed kernel flux. Defaults to + ``1 - flux_accuracy``. + - max_kernel_size: Maximum kernel width in pixels. Defaults to 501. + - max_kernel_wavelength_samples: Maximum number of wavelengths used to + choose the kernel size. Defaults to 16. + - renormalize_clipped_kernel: If True, restore the historical behaviour of + normalizing the clipped finite kernel to unity. Defaults to False. Examples -------- @@ -249,7 +256,6 @@ class MoffatPSF(AnalyticalPSF): def __init__(self, **kwargs): super().__init__(**kwargs) - self.target, self.location, self.time = get_observation_info_from_cmds(self.cmds) self.alpha = self.meta["alpha"] self.fwhm = self.get_fwhm_interp() @@ -263,7 +269,8 @@ def get_fwhm_interp(self): fwhm = self.meta["fwhm"] if check_keys(fwhm, {"seeing", "seeing_unit", "pivot_wave", "pivot_wave_unit"}, action="warn"): logger.info("seeing and pivot supplied, using natural scale seeing law") - zenith_angle = get_zenith_angle(self.target, self.location, self.time) + target, location, time = get_observation_info_from_cmds(self.cmds) + zenith_angle = get_zenith_angle(target, location, time) return partial(self.natural_scale, seeing=fwhm["seeing"]*u.Unit(fwhm["seeing_unit"]), pivot=fwhm["pivot_wave"]*u.Unit(fwhm["pivot_wave_unit"]), @@ -288,30 +295,118 @@ def get_kernel(self, fov): pixel_scale = fov.header["CDELT1"] * u.deg.to(u.arcsec) pixel_scale = quantify(pixel_scale, u.arcsec) - # for each wavelength in waveset, get the corresponding FWHM, convert to gamma, and create a Moffat kernel - npts = (fov.meta["wave_max"] - fov.meta["wave_min"]) / (from_currsys("!SIM.spectral.spectral_bin_width", self.cmds) * u.um) - ##sample only npts from len(fov.waveset) - wavelengths = fov.waveset[::max(1, int(len(fov.waveset) / npts))] - ## get fwhm and gamma for the sampled wave pts - fwhms = self.fwhm(wavelengths).to(u.arcsec) / pixel_scale - gammas = self.fwhm2gamma(fwhms, self.alpha).value - - kx = self.meta.get("kernel_size", 4.0) - ksize = int(kx * np.max(fwhms).value) - ksize = ksize + 1 if ksize % 2 == 0 else ksize - - amplitude = (self.alpha - 1)/(np.pi * gammas**2) - x, y = np.meshgrid(np.arange(ksize)-ksize//2, np.arange(ksize)-ksize//2) - cube = Moffat2D.evaluate(x=x[None, ...], y=y[None, ...], - amplitude=amplitude[:, None, None], x_0=0, y_0=0, - gamma=gammas[:, None, None], alpha=self.alpha) - kernel = np.mean(cube, axis=0) # average over wavelength axis - norm = np.sum(kernel) - if norm < 0.98: - logger.warning(f"Kernel size too small, kernel sums to {norm}") - kernel /= norm + # Sample the wavelength-dependent seeing law with a bounded number of + # representative points. Kernel sizing is driven by the broadest PSF + # over the FOV, not by every spectral sample in the cube. + wavelengths = self._sample_kernel_wavelengths(fov.waveset) + fwhms = quantify(self.fwhm(wavelengths), u.arcsec).to(u.arcsec) / pixel_scale + fwhms = np.atleast_1d(fwhms.value) + if fwhms.size == 1 and wavelengths.size > 1: + fwhms = np.full(wavelengths.size, fwhms.item()) + gammas = np.asarray(self.fwhm2gamma(fwhms, self.alpha), dtype=float) + + target = self._target_enclosed_energy() + max_ksize = self._max_kernel_size() + ksize = self._minimum_kernel_size(np.max(fwhms)) + if max_ksize is not None: + ksize = min(ksize, max_ksize) + + kernel, norm = self._make_moffat_kernel(gammas, ksize) + while norm < target and (max_ksize is None or ksize < max_ksize): + ksize = self._next_kernel_size(ksize, max_ksize) + kernel, norm = self._make_moffat_kernel(gammas, ksize) + + if norm < target: + logger.warning( + "%s Moffat PSF kernel encloses %.6f of the analytic flux; " + "target is %.6f. wave_range=(%.6g, %.6g) um, " + "pixel_scale=%.6g arcsec, max_fwhm=%.6g pix, " + "kernel_size=%d pix, max_kernel_size=%s.", + self.display_name, + norm, + target, + wavelengths[0].to_value(u.um), + wavelengths[-1].to_value(u.um), + pixel_scale.to_value(u.arcsec), + np.max(fwhms), + ksize, + max_ksize, + ) + + if self._renormalize_clipped_kernel() and norm > 0: + kernel /= norm return kernel + def _sample_kernel_wavelengths(self, waveset: u.Quantity) -> u.Quantity: + max_samples = max(2, int(from_currsys( + self.meta.get("max_kernel_wavelength_samples", 16), self.cmds))) + waveset = quantify(waveset, u.um) + if waveset.size <= max_samples: + return waveset + + indices = np.unique(np.linspace( + 0, waveset.size - 1, max_samples).round().astype(int)) + return waveset[indices] + + def _target_enclosed_energy(self) -> float: + target = self.meta.get("kernel_enclosed_energy") + if target is None: + flux_accuracy = float(from_currsys( + self.meta.get("flux_accuracy", 1e-3), self.cmds)) + target = 1.0 - flux_accuracy + else: + target = float(from_currsys(target, self.cmds)) + if not 0 < target <= 1: + raise ValueError("kernel_enclosed_energy must be in the range (0, 1].") + return target + + def _max_kernel_size(self) -> int | None: + max_ksize = self.meta.get("max_kernel_size", 501) + max_ksize = from_currsys(max_ksize, self.cmds) + if max_ksize in (None, "None"): + return None + return self._ensure_odd_int(max_ksize) + + def _minimum_kernel_size(self, max_fwhm_pix: float) -> int: + kx = float(from_currsys(self.meta.get("kernel_size", 4.0), self.cmds)) + return self._ensure_odd_int(kx * max_fwhm_pix) + + def _make_moffat_kernel(self, gammas: np.ndarray, ksize: int) -> tuple[np.ndarray, float]: + amplitude = (self.alpha - 1) / (np.pi * gammas**2) + x, y = np.meshgrid( + np.arange(ksize) - ksize // 2, + np.arange(ksize) - ksize // 2, + ) + cube = Moffat2D.evaluate( + x=x[None, ...], + y=y[None, ...], + amplitude=amplitude[:, None, None], + x_0=0, + y_0=0, + gamma=gammas[:, None, None], + alpha=self.alpha, + ) + kernel = np.mean(cube, axis=0) + if from_currsys(self.meta.get("rounded_edges", False), self.cmds): + kernel = self._round_kernel_edges(kernel) + return kernel, float(np.sum(kernel)) + + @staticmethod + def _ensure_odd_int(value) -> int: + value = max(1, int(np.ceil(value))) + return value + 1 if value % 2 == 0 else value + + def _next_kernel_size(self, ksize: int, max_ksize: int | None) -> int: + next_size = self._ensure_odd_int(max(ksize + 2, int(np.ceil(1.25 * ksize)))) + if max_ksize is not None: + next_size = min(next_size, max_ksize) + next_size = next_size - 1 if next_size % 2 == 0 else next_size + return max(next_size, ksize + 2) + + def _renormalize_clipped_kernel(self) -> bool: + return bool(from_currsys( + self.meta.get("renormalize_clipped_kernel", False), self.cmds)) + @staticmethod def natural_scale(wavelengths: u.Quantity, seeing: u.Quantity = 0.7*u.arcsec, pivot: u.Quantity = 500*u.nm, diff --git a/scopesim/effects/ter_curves.py b/scopesim/effects/ter_curves.py index b1f74ff87..216e20ed3 100644 --- a/scopesim/effects/ter_curves.py +++ b/scopesim/effects/ter_curves.py @@ -32,6 +32,49 @@ logger = get_logger(__name__) +def detector_qe_at( + detector_qe, + wave: u.Quantity, + detector_x=None, + detector_y=None, + **kwargs, +): + """Evaluate detector QE for trace-mapped light. + + Spectral-only QE curves ignore detector position. Position-dependent QE + effects can provide ``throughput_at(wave, detector_x=..., detector_y=...)`` + to support tapered coatings without changing callers that only need + wavelength-dependent throughput. + """ + if detector_qe is None: + return np.ones(np.size(wave), dtype=float) + + if hasattr(detector_qe, "throughput_at"): + return detector_qe.throughput_at( + wave, detector_x=detector_x, detector_y=detector_y, **kwargs) + + if detector_x is not None or detector_y is not None: + try: + return detector_qe.throughput( + wave, detector_x=detector_x, detector_y=detector_y, **kwargs) + except TypeError: + pass + + return detector_qe.throughput(wave) + + +def diffuse_detector_qe(detector_qe, wave: u.Quantity, footprint=None, **kwargs): + """Evaluate effective detector QE for non-dispersed diffuse light.""" + if detector_qe is None: + return np.ones(np.size(wave), dtype=float) + + if hasattr(detector_qe, "effective_diffuse_throughput"): + return detector_qe.effective_diffuse_throughput( + wave, footprint=footprint, **kwargs) + + return detector_qe_at(detector_qe, wave, **kwargs) + + class TERCurve(Effect): """ Transmission, Emissivity, Reflection Curve. @@ -562,9 +605,14 @@ class SpectralQuantumEfficiency(TERCurve): not create a thermal background source from detector emissivity. Place this effect before spectral trace mapping so each order cube is multiplied by the detector QE as a function of wavelength. + + ``throughput_at`` and ``effective_diffuse_throughput`` define the detector + QE interface used by future position-dependent coatings. Spectral QE ignores + detector position; tapered QE effects should override these methods. """ z_order: ClassVar[tuple[int, ...]] = (610,) + uses_detector_footprint: ClassVar[bool] = False def __init__(self, **kwargs): super().__init__(**kwargs) @@ -576,6 +624,18 @@ def apply_to(self, obj, **kwargs): return obj return super().apply_to(obj, **kwargs) + def throughput_at(self, wave, detector_x=None, detector_y=None, **kwargs): + """Return trace-mapped detector QE. + + Spectral QE is independent of detector position. Position-dependent QE + subclasses should override this method. + """ + return self.throughput(wave) + + def effective_diffuse_throughput(self, wave, footprint=None, **kwargs): + """Return footprint-averaged QE for diffuse image-plane backgrounds.""" + return self.throughput(wave) + @property def background_source(self): return None diff --git a/scopesim/tests/tests_effects/test_MoffatPSF.py b/scopesim/tests/tests_effects/test_MoffatPSF.py new file mode 100644 index 000000000..1119df8dd --- /dev/null +++ b/scopesim/tests/tests_effects/test_MoffatPSF.py @@ -0,0 +1,89 @@ +import logging + +import numpy as np +import pytest +from astropy import units as u + +from scopesim.effects.psfs.analytical import MoffatPSF + + +class SimpleFov: + header = {"CDELT1": 0.1 / 3600} + meta = {"wave_min": 1.0 * u.um, "wave_max": 1.1 * u.um} + waveset = np.linspace(1.0, 1.1, 5) * u.um + + +def test_constant_fwhm_does_not_require_observation_commands(): + psf = MoffatPSF(alpha=4.765, fwhm=0.7) + + assert isinstance(psf, MoffatPSF) + + +def test_kernel_grows_to_flux_accuracy_without_renormalizing(): + psf = MoffatPSF( + alpha=4.765, + fwhm=0.7, + kernel_size=2, + max_kernel_size=151, + flux_accuracy=1e-3, + rounded_edges=False, + ) + + kernel = psf.get_kernel(SimpleFov()) + + assert kernel.shape[0] > 15 + assert np.sum(kernel) >= 0.999 + assert np.sum(kernel) < 1.0 + + +def test_rounded_kernel_grows_to_flux_accuracy_after_rounding(): + psf = MoffatPSF( + alpha=4.765, + fwhm=0.7, + kernel_size=2, + max_kernel_size=151, + flux_accuracy=1e-3, + rounded_edges=True, + ) + + kernel = psf.get_kernel(SimpleFov()) + + assert np.sum(kernel) >= 0.999 + assert np.sum(kernel) < 1.0 + + +def test_capped_kernel_warns_and_keeps_missing_wing_flux(caplog): + psf = MoffatPSF( + alpha=4.765, + fwhm=0.7, + kernel_size=2, + max_kernel_size=15, + flux_accuracy=1e-6, + rounded_edges=False, + ) + + with caplog.at_level( + logging.WARNING, + logger="astar.scopesim.effects.psfs.analytical", + ): + kernel = psf.get_kernel(SimpleFov()) + + assert np.sum(kernel) < 1.0 + assert "Moffat PSF kernel encloses" in caplog.text + assert "max_kernel_size=15" in caplog.text + + +def test_legacy_renormalization_can_be_requested(): + psf = MoffatPSF( + alpha=4.765, + fwhm=0.7, + kernel_size=2, + max_kernel_size=15, + flux_accuracy=1e-6, + rounded_edges=False, + renormalize_clipped_kernel=True, + ) + + kernel = psf.get_kernel(SimpleFov()) + + assert np.sum(kernel) == pytest.approx(1.0) diff --git a/scopesim/tests/tests_effects/test_TERCurve.py b/scopesim/tests/tests_effects/test_TERCurve.py index effd2035c..73eea5554 100644 --- a/scopesim/tests/tests_effects/test_TERCurve.py +++ b/scopesim/tests/tests_effects/test_TERCurve.py @@ -85,6 +85,54 @@ def test_does_not_apply_to_source(self): assert qe.apply_to(src) is src assert len(src.fields) == n_fields + def test_trace_and_diffuse_qe_methods_use_spectral_throughput(self): + qe = tc.SpectralQuantumEfficiency( + array_dict={ + "wavelength": [0.5, 1.0], + "transmission": [0.8, 0.9], + }, + wavelength_unit="um", + ) + wave = np.array([0.5, 1.0]) * u.um + + np.testing.assert_allclose( + qe.throughput_at(wave, detector_x=[0, 1], detector_y=[2, 3]), + [0.8, 0.9], + ) + np.testing.assert_allclose( + qe.effective_diffuse_throughput(wave, footprint=object()), + [0.8, 0.9], + ) + + def test_detector_qe_at_uses_position_aware_method(self): + class PositionAwareQE: + def throughput_at(self, wave, detector_x=None, detector_y=None): + return np.asarray(detector_x, dtype=float) + np.asarray(detector_y, dtype=float) + + wave = np.array([0.5, 1.0]) * u.um + + np.testing.assert_allclose( + tc.detector_qe_at( + PositionAwareQE(), wave, detector_x=[0.1, 0.2], + detector_y=[0.3, 0.4], + ), + [0.4, 0.6], + ) + + def test_diffuse_detector_qe_uses_effective_diffuse_method(self): + class DiffuseAwareQE: + uses_detector_footprint = True + + def effective_diffuse_throughput(self, wave, footprint=None): + return np.full(wave.size, footprint["qe"]) + + wave = np.array([0.5, 1.0]) * u.um + + np.testing.assert_allclose( + tc.diffuse_detector_qe(DiffuseAwareQE(), wave, footprint={"qe": 0.7}), + [0.7, 0.7], + ) + class TestTERCurvePlot: def test_plots_only_transmission(self): diff --git a/scopesim/tests/tests_effects/test_illumination.py b/scopesim/tests/tests_effects/test_illumination.py index a3812d5c1..2eb3a42c8 100644 --- a/scopesim/tests/tests_effects/test_illumination.py +++ b/scopesim/tests/tests_effects/test_illumination.py @@ -186,6 +186,38 @@ def test_effective_diffuse_qe_uses_average_positional_response(): np.testing.assert_allclose(qe, np.full(wave.size, 0.425)) +def test_effective_diffuse_qe_uses_detector_diffuse_method(): + class DiffuseAwareQE: + def throughput(self, wave): + return np.full(wave.size, 0.9) + + def effective_diffuse_throughput(self, wave, footprint=None): + return np.full(wave.size, footprint["average_qe"]) + + wave = np.linspace(1.0, 2.0, 3) * u.um + + qe = effective_diffuse_qe( + DiffuseAwareQE(), wave, footprint={"average_qe": 0.6}) + + np.testing.assert_allclose(qe, np.full(wave.size, 0.6)) + + +def test_effective_diffuse_qe_passes_footprint_to_positional_qe(): + wave = np.linspace(1.0, 2.0, 3) * u.um + + def positional_qe(_wave, footprint=None): + return footprint["qe_map"] + + qe = effective_diffuse_qe( + FakeQE(), + wave, + positional_qe=positional_qe, + footprint={"qe_map": np.array([[0.2, 0.4], [0.6, 0.8]])}, + ) + + np.testing.assert_allclose(qe, np.full(wave.size, 0.25)) + + def test_post_disperser_diffuse_spectrum_uses_phase_metadata_and_qe(): wave = np.linspace(1.0, 2.0, 3) * u.um qe = np.full(wave.size, 0.5) From b928d4e94c604e3dc92a5b0b7f44ccea80d67cd2 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Mon, 1 Jun 2026 14:11:57 -0700 Subject: [PATCH 12/43] Add tapered detector quantum efficiency --- scopesim/effects/spectral_trace_list_utils.py | 40 +++++ scopesim/effects/ter_curves.py | 137 ++++++++++++++++++ .../test_SpectralTraceListUtils.py | 17 +++ scopesim/tests/tests_effects/test_TERCurve.py | 68 +++++++++ 4 files changed, 262 insertions(+) diff --git a/scopesim/effects/spectral_trace_list_utils.py b/scopesim/effects/spectral_trace_list_utils.py index 4d78e96a7..f328e8148 100644 --- a/scopesim/effects/spectral_trace_list_utils.py +++ b/scopesim/effects/spectral_trace_list_utils.py @@ -24,6 +24,7 @@ from astropy.wcs import WCS from astropy.modeling.models import Polynomial2D +from .ter_curves import detector_qe_at from ..utils import (power_vector, quantify, from_currsys, close_loop, figure_factory, get_logger) @@ -31,6 +32,27 @@ logger = get_logger(__name__) +def apply_detector_qe_to_trace_image( + image: np.ndarray, + detector_qe, + wave_um: np.ndarray, + detector_x: np.ndarray, + detector_y: np.ndarray, + **kwargs, +) -> np.ndarray: + """Apply detector QE to an already trace-mapped image.""" + if detector_qe is None: + return image + qe_values = detector_qe_at( + detector_qe, + wave_um * u.um, + detector_x=detector_x, + detector_y=detector_y, + **kwargs, + ) + return image * np.asarray(qe_values, dtype=float) + + class SpectralTrace: """Definition of one spectral trace. @@ -276,6 +298,24 @@ def map_spectra_to_focal_plane(self, fov): dlam_by_dy(ximg_fpa, yimg_fpa)**2) image *= pixscale * dlam_per_pix # [arcsec/pix] * [um/pix] + detector_qe = fov.meta.get("detector_qe") + if detector_qe is not None: + xpix_img, ypix_img = np.meshgrid( + np.arange(xmin, xmax, dtype=np.float32), + np.arange(ymin, ymax, dtype=np.float32), + ) + image = apply_detector_qe_to_trace_image( + image, + detector_qe, + lam_fpa, + xpix_img, + ypix_img, + detector_x_mm=ximg_fpa, + detector_y_mm=yimg_fpa, + trace=self, + fov=fov, + ) + # img_header = sub_wcs.to_header() # img_header.update(det_wcs.to_header()) img_header = det_wcs.to_header() diff --git a/scopesim/effects/ter_curves.py b/scopesim/effects/ter_curves.py index 216e20ed3..88bec582b 100644 --- a/scopesim/effects/ter_curves.py +++ b/scopesim/effects/ter_curves.py @@ -641,6 +641,143 @@ def background_source(self): return None +class TaperedQuantumEfficiency(Effect): + """Position-dependent Gaussian detector QE coating. + + The QE peak wavelength varies linearly with one detector coordinate. This + represents tapered coatings whose bandpass is intentionally shifted along + the cross-dispersion axis. Trace-mapped light should call + :meth:`throughput_at` with detector pixel coordinates. Non-dispersed + diffuse light should call :meth:`effective_diffuse_throughput`, which + averages the coating over a representative detector footprint. + """ + + z_order: ClassVar[tuple[int, ...]] = (610,) + uses_detector_footprint: ClassVar[bool] = True + required_keys = { + "center_wave_min", + "center_wave_max", + "position_min", + "position_max", + "fwhm", + } + + def __init__(self, **kwargs): + super().__init__(**kwargs) + params = { + "axis": "y", + "wave_unit": "um", + "position_unit": "pix", + "peak": 0.99, + "floor": 0.0, + "clip_position": True, + "diffuse_position_samples": 256, + } + self.meta.update(params) + self.meta.update(kwargs) + check_keys(self.meta, self.required_keys, action="error") + + def apply_to(self, obj, **kwargs): + if isinstance(obj, FieldOfView): + obj.meta["detector_qe"] = self + return obj + + def throughput(self, wave): + """Return QE at the representative midpoint of the taper.""" + return self.throughput_at(wave) + + def throughput_at(self, wave, detector_x=None, detector_y=None, **kwargs): + """Return detector QE at wavelength and detector position.""" + wave_values = self._wave_values(wave) + position = self._position_values(detector_x, detector_y) + center = self._center_wave_values(position) + return self._gaussian_response(wave_values, center) + + def effective_diffuse_throughput(self, wave, footprint=None, **kwargs): + """Return QE averaged over a representative detector footprint.""" + wave_values = np.atleast_1d(self._wave_values(wave)) + positions = self._diffuse_positions(footprint) + centers = self._center_wave_values(positions) + values = self._gaussian_response( + wave_values[:, None], + centers[None, :], + ) + return np.mean(values, axis=1) + + def _wave_values(self, wave) -> np.ndarray: + wave_unit = u.Unit(from_currsys(self.meta["wave_unit"], self.cmds)) + return u.Quantity(wave, wave_unit).to_value(wave_unit) + + def _position_values(self, detector_x=None, detector_y=None) -> np.ndarray: + axis = str(from_currsys(self.meta["axis"], self.cmds)).lower() + if axis in {"x", "dispersion"}: + position = detector_x + elif axis in {"y", "cross_dispersion", "cross-dispersion"}: + position = detector_y + else: + raise ValueError("axis must be 'x' or 'y'.") + + if position is None: + position = 0.5 * (self._position_min() + self._position_max()) + if hasattr(position, "unit"): + position_unit = self.meta.get("position_unit") + if position_unit not in (None, "None"): + return u.Quantity(position).to_value(u.Unit(position_unit)) + return u.Quantity(position).value + return np.asarray(position, dtype=float) + + def _position_min(self) -> float: + return float(from_currsys(self.meta["position_min"], self.cmds)) + + def _position_max(self) -> float: + return float(from_currsys(self.meta["position_max"], self.cmds)) + + def _center_wave_values(self, position) -> np.ndarray: + pos_min = self._position_min() + pos_max = self._position_max() + if pos_max == pos_min: + raise ValueError("position_min and position_max must differ.") + + position = np.asarray(position, dtype=float) + fraction = (position - pos_min) / (pos_max - pos_min) + if from_currsys(self.meta["clip_position"], self.cmds): + fraction = np.clip(fraction, 0.0, 1.0) + + wave_unit = u.Unit(from_currsys(self.meta["wave_unit"], self.cmds)) + center_min = quantify( + from_currsys(self.meta["center_wave_min"], self.cmds), wave_unit, + ).to_value(wave_unit) + center_max = quantify( + from_currsys(self.meta["center_wave_max"], self.cmds), wave_unit, + ).to_value(wave_unit) + return center_min + fraction * (center_max - center_min) + + def _gaussian_response(self, wave_values, center_values) -> np.ndarray: + wave_unit = u.Unit(from_currsys(self.meta["wave_unit"], self.cmds)) + fwhm = quantify( + from_currsys(self.meta["fwhm"], self.cmds), wave_unit, + ).to_value(wave_unit) + if fwhm <= 0: + raise ValueError("fwhm must be positive.") + + sigma = fwhm / (2 * np.sqrt(2 * np.log(2))) + peak = float(from_currsys(self.meta["peak"], self.cmds)) + floor = float(from_currsys(self.meta["floor"], self.cmds)) + values = floor + (peak - floor) * np.exp( + -0.5 * ((wave_values - center_values) / sigma) ** 2) + return np.clip(values, 0.0, 1.0) + + def _diffuse_positions(self, footprint=None) -> np.ndarray: + nsamp = int(from_currsys( + self.meta["diffuse_position_samples"], self.cmds)) + nsamp = max(nsamp, 2) + return np.linspace(self._position_min(), self._position_max(), nsamp) + + @property + def background_source(self): + return None + + class FilterCurve(TERCurve): """ Descripton TBA. diff --git a/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py b/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py index 21dadc9ae..1b62be122 100644 --- a/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py +++ b/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py @@ -12,6 +12,7 @@ from scopesim.effects.spectral_trace_list_utils import SpectralTrace from scopesim.effects.spectral_trace_list_utils import Transform2D, power_vector from scopesim.effects.spectral_trace_list_utils import make_image_interpolations +from scopesim.effects.spectral_trace_list_utils import apply_detector_qe_to_trace_image from scopesim.tests.mocks.py_objects import trace_list_objects as tlo class TestSpectralTrace: @@ -36,6 +37,22 @@ def test_determines_correct_dispersion_axis_y(self): spt = SpectralTrace(trace_tbl) assert spt.dispersion_axis == 'y' + +def test_apply_detector_qe_to_trace_image_uses_detector_position(): + class PositionAwareQE: + def throughput_at(self, wave, detector_x=None, detector_y=None, **kwargs): + return 0.5 + 0.1 * np.asarray(detector_y) + + image = np.ones((2, 2), dtype=float) + wave = np.ones((2, 2), dtype=float) + detector_x = np.zeros((2, 2), dtype=float) + detector_y = np.array([[0, 1], [2, 3]], dtype=float) + + result = apply_detector_qe_to_trace_image( + image, PositionAwareQE(), wave, detector_x, detector_y) + + np.testing.assert_allclose(result, [[0.5, 0.6], [0.7, 0.8]]) + class TestPowerVec: """Test function power_vector()""" def test_gives_correct_result(self): diff --git a/scopesim/tests/tests_effects/test_TERCurve.py b/scopesim/tests/tests_effects/test_TERCurve.py index 73eea5554..aab02f5b8 100644 --- a/scopesim/tests/tests_effects/test_TERCurve.py +++ b/scopesim/tests/tests_effects/test_TERCurve.py @@ -5,6 +5,8 @@ from astropy import units as u from scopesim.effects import ter_curves as tc +from scopesim.optics.fov import FieldOfView +from scopesim.tests.mocks.py_objects.header_objects import _fov_header from scopesim.tests.mocks.py_objects import source_objects as so from scopesim.tests.mocks.py_objects import effects_objects as eo @@ -134,6 +136,72 @@ def effective_diffuse_throughput(self, wave, footprint=None): ) +class TestTaperedQuantumEfficiency: + def test_position_dependent_peak_wavelength(self): + qe = tc.TaperedQuantumEfficiency( + center_wave_min=0.5, + center_wave_max=1.0, + position_min=0, + position_max=100, + fwhm=0.1, + peak=0.99, + floor=0.01, + ) + + values = qe.throughput_at( + np.array([0.5, 1.0]) * u.um, + detector_y=np.array([0, 100]), + ) + + np.testing.assert_allclose(values, [0.99, 0.99]) + + def test_taper_suppresses_wavelength_away_from_position_peak(self): + qe = tc.TaperedQuantumEfficiency( + center_wave_min=0.5, + center_wave_max=1.0, + position_min=0, + position_max=100, + fwhm=0.1, + peak=0.99, + floor=0.01, + ) + + value = qe.throughput_at(1.0 * u.um, detector_y=0) + + assert value < 0.02 + + def test_effective_diffuse_throughput_averages_over_taper(self): + qe = tc.TaperedQuantumEfficiency( + center_wave_min=0.5, + center_wave_max=1.0, + position_min=0, + position_max=100, + fwhm=0.4, + peak=0.99, + floor=0.01, + diffuse_position_samples=16, + ) + + values = qe.effective_diffuse_throughput(np.array([0.5, 0.75, 1.0]) * u.um) + + assert values.shape == (3,) + assert np.all(values > 0.01) + assert np.all(values < 0.99) + + def test_apply_to_tags_field_of_view(self): + fov = FieldOfView(_fov_header(), (1, 2) * u.um, area=1 * u.m**2) + qe = tc.TaperedQuantumEfficiency( + center_wave_min=0.5, + center_wave_max=1.0, + position_min=0, + position_max=100, + fwhm=0.1, + ) + + assert qe.apply_to(fov) is fov + assert fov.meta["detector_qe"] is qe + + class TestTERCurvePlot: def test_plots_only_transmission(self): filt = eo._filter_surface(wave_min=0.8, wave_max=2.5) From 83984b8b0968f40125dddd78190174609af54e28 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Tue, 2 Jun 2026 16:23:29 -0700 Subject: [PATCH 13/43] Use flattop tapered quantum efficiency --- scopesim/effects/ter_curves.py | 50 ++++++++++++++++--- scopesim/tests/tests_effects/test_TERCurve.py | 40 ++++++++++++++- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/scopesim/effects/ter_curves.py b/scopesim/effects/ter_curves.py index 88bec582b..c788bfd0a 100644 --- a/scopesim/effects/ter_curves.py +++ b/scopesim/effects/ter_curves.py @@ -642,9 +642,9 @@ def background_source(self): class TaperedQuantumEfficiency(Effect): - """Position-dependent Gaussian detector QE coating. + """Position-dependent flattop detector QE coating. - The QE peak wavelength varies linearly with one detector coordinate. This + The QE bandpass center varies linearly with one detector coordinate. This represents tapered coatings whose bandpass is intentionally shifted along the cross-dispersion axis. Trace-mapped light should call :meth:`throughput_at` with detector pixel coordinates. Non-dispersed @@ -670,6 +670,8 @@ def __init__(self, **kwargs): "position_unit": "pix", "peak": 0.99, "floor": 0.0, + "transition_width": None, + "transition_fraction": 0.5, "clip_position": True, "diffuse_position_samples": 256, } @@ -691,14 +693,14 @@ def throughput_at(self, wave, detector_x=None, detector_y=None, **kwargs): wave_values = self._wave_values(wave) position = self._position_values(detector_x, detector_y) center = self._center_wave_values(position) - return self._gaussian_response(wave_values, center) + return self._flattop_response(wave_values, center) def effective_diffuse_throughput(self, wave, footprint=None, **kwargs): """Return QE averaged over a representative detector footprint.""" wave_values = np.atleast_1d(self._wave_values(wave)) positions = self._diffuse_positions(footprint) centers = self._center_wave_values(positions) - values = self._gaussian_response( + values = self._flattop_response( wave_values[:, None], centers[None, :], ) @@ -752,7 +754,7 @@ def _center_wave_values(self, position) -> np.ndarray: ).to_value(wave_unit) return center_min + fraction * (center_max - center_min) - def _gaussian_response(self, wave_values, center_values) -> np.ndarray: + def _flattop_response(self, wave_values, center_values) -> np.ndarray: wave_unit = u.Unit(from_currsys(self.meta["wave_unit"], self.cmds)) fwhm = quantify( from_currsys(self.meta["fwhm"], self.cmds), wave_unit, @@ -760,13 +762,45 @@ def _gaussian_response(self, wave_values, center_values) -> np.ndarray: if fwhm <= 0: raise ValueError("fwhm must be positive.") - sigma = fwhm / (2 * np.sqrt(2 * np.log(2))) + transition_width = self._transition_width(wave_unit, fwhm) + flat_half_width = 0.5 * (fwhm - transition_width) + outer_half_width = flat_half_width + transition_width peak = float(from_currsys(self.meta["peak"], self.cmds)) floor = float(from_currsys(self.meta["floor"], self.cmds)) - values = floor + (peak - floor) * np.exp( - -0.5 * ((wave_values - center_values) / sigma) ** 2) + distance = np.abs( + np.asarray(wave_values, dtype=float) + - np.asarray(center_values, dtype=float) + ) + + if transition_width <= 0: + values = np.where(distance <= 0.5 * fwhm, peak, floor) + return np.clip(values, 0.0, 1.0) + + edge_fraction = np.clip( + (distance - flat_half_width) / transition_width, 0.0, 1.0, + ) + edge_values = ( + floor + + (peak - floor) * 0.5 * (1 + np.cos(np.pi * edge_fraction)) + ) + values = np.where( + distance <= flat_half_width, + peak, + np.where(distance < outer_half_width, edge_values, floor), + ) return np.clip(values, 0.0, 1.0) + def _transition_width(self, wave_unit, fwhm: float) -> float: + value = from_currsys(self.meta.get("transition_width"), self.cmds) + if value in (None, "None"): + fraction = float(from_currsys( + self.meta["transition_fraction"], self.cmds)) + value = fraction * fwhm + width = quantify(value, wave_unit).to_value(wave_unit) + if width < 0: + raise ValueError("transition_width must not be negative.") + return min(width, fwhm) + def _diffuse_positions(self, footprint=None) -> np.ndarray: nsamp = int(from_currsys( self.meta["diffuse_position_samples"], self.cmds)) diff --git a/scopesim/tests/tests_effects/test_TERCurve.py b/scopesim/tests/tests_effects/test_TERCurve.py index aab02f5b8..bc83d1e66 100644 --- a/scopesim/tests/tests_effects/test_TERCurve.py +++ b/scopesim/tests/tests_effects/test_TERCurve.py @@ -155,7 +155,28 @@ def test_position_dependent_peak_wavelength(self): np.testing.assert_allclose(values, [0.99, 0.99]) - def test_taper_suppresses_wavelength_away_from_position_peak(self): + def test_flattop_has_peak_core_and_cosine_edges(self): + qe = tc.TaperedQuantumEfficiency( + center_wave_min=0.5, + center_wave_max=1.0, + position_min=0, + position_max=100, + fwhm=0.1, + peak=0.99, + floor=0.01, + transition_width=0.04, + ) + + values = qe.throughput_at( + np.array([1.0, 1.02, 1.05, 1.08]) * u.um, + detector_y=100, + ) + + np.testing.assert_allclose(values[:2], [0.99, 0.99]) + np.testing.assert_allclose(values[2], 0.5, atol=1e-12) + np.testing.assert_allclose(values[3], 0.01) + + def test_taper_suppresses_wavelength_away_from_position_bandpass(self): qe = tc.TaperedQuantumEfficiency( center_wave_min=0.5, center_wave_max=1.0, @@ -168,7 +189,7 @@ def test_taper_suppresses_wavelength_away_from_position_peak(self): value = qe.throughput_at(1.0 * u.um, detector_y=0) - assert value < 0.02 + np.testing.assert_allclose(value, 0.01) def test_effective_diffuse_throughput_averages_over_taper(self): qe = tc.TaperedQuantumEfficiency( @@ -188,6 +209,21 @@ def test_effective_diffuse_throughput_averages_over_taper(self): assert np.all(values > 0.01) assert np.all(values < 0.99) + def test_rejects_negative_transition_width(self): + qe = tc.TaperedQuantumEfficiency( + center_wave_min=0.5, + center_wave_max=1.0, + position_min=0, + position_max=100, + fwhm=0.1, + peak=0.99, + floor=0.01, + transition_width=-0.01, + ) + + with pytest.raises(ValueError, match="transition_width"): + qe.throughput(0.75 * u.um) + def test_apply_to_tags_field_of_view(self): fov = FieldOfView(_fov_header(), (1, 2) * u.um, area=1 * u.m**2) qe = tc.TaperedQuantumEfficiency( From 3bc9826f7352102a65808273c466609443fc2f26 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Tue, 2 Jun 2026 16:57:23 -0700 Subject: [PATCH 14/43] Rename tapered QE passband width --- scopesim/effects/ter_curves.py | 35 ++++++++++--------- scopesim/tests/tests_effects/test_TERCurve.py | 14 ++++---- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/scopesim/effects/ter_curves.py b/scopesim/effects/ter_curves.py index c788bfd0a..56236770e 100644 --- a/scopesim/effects/ter_curves.py +++ b/scopesim/effects/ter_curves.py @@ -646,10 +646,13 @@ class TaperedQuantumEfficiency(Effect): The QE bandpass center varies linearly with one detector coordinate. This represents tapered coatings whose bandpass is intentionally shifted along - the cross-dispersion axis. Trace-mapped light should call - :meth:`throughput_at` with detector pixel coordinates. Non-dispersed - diffuse light should call :meth:`effective_diffuse_throughput`, which - averages the coating over a representative detector footprint. + the cross-dispersion axis. ``flat_width`` is the full wavelength width of + the peak plateau at each detector position. ``transition_width`` is the + width of each cosine-edged shoulder from ``peak`` down to ``floor``. + Trace-mapped light should call :meth:`throughput_at` with detector pixel + coordinates. Non-dispersed diffuse light should call + :meth:`effective_diffuse_throughput`, which averages the coating over a + representative detector footprint. """ z_order: ClassVar[tuple[int, ...]] = (610,) @@ -659,7 +662,7 @@ class TaperedQuantumEfficiency(Effect): "center_wave_max", "position_min", "position_max", - "fwhm", + "flat_width", } def __init__(self, **kwargs): @@ -671,7 +674,7 @@ def __init__(self, **kwargs): "peak": 0.99, "floor": 0.0, "transition_width": None, - "transition_fraction": 0.5, + "transition_fraction": 1.0, "clip_position": True, "diffuse_position_samples": 256, } @@ -756,14 +759,14 @@ def _center_wave_values(self, position) -> np.ndarray: def _flattop_response(self, wave_values, center_values) -> np.ndarray: wave_unit = u.Unit(from_currsys(self.meta["wave_unit"], self.cmds)) - fwhm = quantify( - from_currsys(self.meta["fwhm"], self.cmds), wave_unit, + flat_width = quantify( + from_currsys(self.meta["flat_width"], self.cmds), wave_unit, ).to_value(wave_unit) - if fwhm <= 0: - raise ValueError("fwhm must be positive.") + if flat_width <= 0: + raise ValueError("flat_width must be positive.") - transition_width = self._transition_width(wave_unit, fwhm) - flat_half_width = 0.5 * (fwhm - transition_width) + transition_width = self._transition_width(wave_unit, flat_width) + flat_half_width = 0.5 * flat_width outer_half_width = flat_half_width + transition_width peak = float(from_currsys(self.meta["peak"], self.cmds)) floor = float(from_currsys(self.meta["floor"], self.cmds)) @@ -773,7 +776,7 @@ def _flattop_response(self, wave_values, center_values) -> np.ndarray: ) if transition_width <= 0: - values = np.where(distance <= 0.5 * fwhm, peak, floor) + values = np.where(distance <= flat_half_width, peak, floor) return np.clip(values, 0.0, 1.0) edge_fraction = np.clip( @@ -790,16 +793,16 @@ def _flattop_response(self, wave_values, center_values) -> np.ndarray: ) return np.clip(values, 0.0, 1.0) - def _transition_width(self, wave_unit, fwhm: float) -> float: + def _transition_width(self, wave_unit, flat_width: float) -> float: value = from_currsys(self.meta.get("transition_width"), self.cmds) if value in (None, "None"): fraction = float(from_currsys( self.meta["transition_fraction"], self.cmds)) - value = fraction * fwhm + value = fraction * flat_width width = quantify(value, wave_unit).to_value(wave_unit) if width < 0: raise ValueError("transition_width must not be negative.") - return min(width, fwhm) + return width def _diffuse_positions(self, footprint=None) -> np.ndarray: nsamp = int(from_currsys( diff --git a/scopesim/tests/tests_effects/test_TERCurve.py b/scopesim/tests/tests_effects/test_TERCurve.py index bc83d1e66..68160654c 100644 --- a/scopesim/tests/tests_effects/test_TERCurve.py +++ b/scopesim/tests/tests_effects/test_TERCurve.py @@ -143,7 +143,7 @@ def test_position_dependent_peak_wavelength(self): center_wave_max=1.0, position_min=0, position_max=100, - fwhm=0.1, + flat_width=0.1, peak=0.99, floor=0.01, ) @@ -161,14 +161,14 @@ def test_flattop_has_peak_core_and_cosine_edges(self): center_wave_max=1.0, position_min=0, position_max=100, - fwhm=0.1, + flat_width=0.1, peak=0.99, floor=0.01, transition_width=0.04, ) values = qe.throughput_at( - np.array([1.0, 1.02, 1.05, 1.08]) * u.um, + np.array([1.0, 1.05, 1.07, 1.09]) * u.um, detector_y=100, ) @@ -182,7 +182,7 @@ def test_taper_suppresses_wavelength_away_from_position_bandpass(self): center_wave_max=1.0, position_min=0, position_max=100, - fwhm=0.1, + flat_width=0.1, peak=0.99, floor=0.01, ) @@ -197,7 +197,7 @@ def test_effective_diffuse_throughput_averages_over_taper(self): center_wave_max=1.0, position_min=0, position_max=100, - fwhm=0.4, + flat_width=0.4, peak=0.99, floor=0.01, diffuse_position_samples=16, @@ -215,7 +215,7 @@ def test_rejects_negative_transition_width(self): center_wave_max=1.0, position_min=0, position_max=100, - fwhm=0.1, + flat_width=0.1, peak=0.99, floor=0.01, transition_width=-0.01, @@ -231,7 +231,7 @@ def test_apply_to_tags_field_of_view(self): center_wave_max=1.0, position_min=0, position_max=100, - fwhm=0.1, + flat_width=0.1, ) assert qe.apply_to(fov) is fov From 416831a0a1dc9a87417add65b24be96940c1f0b9 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Tue, 2 Jun 2026 17:17:46 -0700 Subject: [PATCH 15/43] Support downstream throughput in diffuse backgrounds --- scopesim/effects/illumination.py | 92 +++++++++++++++++-- .../tests/tests_effects/test_illumination.py | 25 +++++ 2 files changed, 107 insertions(+), 10 deletions(-) diff --git a/scopesim/effects/illumination.py b/scopesim/effects/illumination.py index ce413e18f..75979af40 100644 --- a/scopesim/effects/illumination.py +++ b/scopesim/effects/illumination.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- """Image-plane illumination effects.""" -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, Sequence +import importlib from typing import ClassVar import numpy as np @@ -12,7 +13,7 @@ from . import Effect from .surface_list import SurfaceList -from .ter_curves import SpectralQuantumEfficiency, diffuse_detector_qe +from .ter_curves import SpectralQuantumEfficiency, TERCurve, diffuse_detector_qe from ..optics.image_plane import ImagePlane from ..utils import figure_factory, from_currsys, quantify, real_colname @@ -133,6 +134,48 @@ def _cache_object_key(filename, obj): return ("object", id(obj)) +def _as_effect_specs(value) -> list: + if value is None: + return [] + if isinstance(value, (str, Mapping)): + return [value] + if isinstance(value, Sequence): + return list(value) + return [value] + + +def _effect_from_spec(spec, cmds=None): + if spec is None: + return None + if isinstance(spec, str): + return TERCurve(filename=spec, cmds=cmds) + if isinstance(spec, Mapping): + effect_module = importlib.import_module("scopesim.effects") + effect_class = getattr(effect_module, spec["class"]) + kwargs = dict(spec.get("kwargs", {})) + return effect_class(cmds=cmds, **kwargs) + return spec + + +def _detector_qe_from_spec(spec, filename=None, cmds=None): + if spec is not None: + if isinstance(spec, str): + return SpectralQuantumEfficiency(filename=spec, cmds=cmds) + return _effect_from_spec(spec, cmds=cmds) + if filename is not None: + return SpectralQuantumEfficiency(filename=filename, cmds=cmds) + return None + + +def _throughput_values(effect, wave: u.Quantity) -> np.ndarray: + if hasattr(effect, "throughput"): + return _as_float_array(effect.throughput(wave)) + surface = getattr(effect, "surface", None) + if surface is not None and hasattr(surface, "throughput"): + return _as_float_array(surface.throughput(wave)) + raise TypeError(f"Cannot evaluate throughput for {effect!r}") + + def _image_plane_cache_key(image_plane): header = image_plane.header return ( @@ -475,6 +518,10 @@ def __init__( detector_qe_filename: str | None = None, surface_list=None, detector_qe=None, + downstream_throughput=None, + downstream_throughputs=None, + downstream_throughput_filename: str | None = None, + downstream_throughput_filenames=None, positional_qe=None, emission_phase: str = "post_disperser", **kwargs, @@ -490,20 +537,25 @@ def __init__( "wave_unit": kwargs.get("wave_unit", "!SIM.spectral.wave_unit"), "area": kwargs.get("area", "!TEL.area"), "emission_phase": emission_phase, + "downstream_throughput_filename": downstream_throughput_filename, + "downstream_throughput_filenames": downstream_throughput_filenames, }) self._surface_list = ( surface_list if surface_list is not None else SurfaceList(filename=filename, cmds=self.cmds) ) - self._detector_qe = ( - detector_qe if detector_qe is not None - else ( - SpectralQuantumEfficiency( - filename=detector_qe_filename, cmds=self.cmds) - if detector_qe_filename is not None - else None - ) + self._detector_qe = _detector_qe_from_spec( + detector_qe, filename=detector_qe_filename, cmds=self.cmds, ) + downstream_specs = [] + downstream_specs.extend(_as_effect_specs(downstream_throughputs)) + downstream_specs.extend(_as_effect_specs(downstream_throughput)) + downstream_specs.extend(_as_effect_specs(downstream_throughput_filenames)) + downstream_specs.extend(_as_effect_specs(downstream_throughput_filename)) + self._downstream_throughput_specs = downstream_specs + self._downstream_throughputs = [ + _effect_from_spec(spec, cmds=self.cmds) for spec in downstream_specs + ] self._positional_qe = positional_qe self._last_value = None @@ -551,6 +603,8 @@ def _background_rate_per_arcsec2(self, image_plane: ImagePlane) -> float: qe_values=qe_values, emission_phase=self.meta["emission_phase"], ) + if spectrum is not None: + spectrum = spectrum * self._downstream_throughput_values(wave) area = quantify(from_currsys(self.meta["area"], self.cmds), u.m**2) rate = integrate_spectral_background( spectrum, @@ -561,6 +615,12 @@ def _background_rate_per_arcsec2(self, image_plane: ImagePlane) -> float: _store_post_disperser_rate_cache(key, rate) return rate + def _downstream_throughput_values(self, wave: u.Quantity) -> np.ndarray: + values = np.ones(wave.size, dtype=float) + for throughput in self._downstream_throughputs: + values *= _throughput_values(throughput, wave) + return values + def _background_rate_cache_key(self, image_plane: ImagePlane) -> tuple: wave_unit = u.Unit(from_currsys(self.meta["wave_unit"], self.cmds)) wave_min = quantify( @@ -586,10 +646,22 @@ def _background_rate_cache_key(self, image_plane: ImagePlane) -> tuple: id(self._positional_qe), _image_plane_cache_key(image_plane), ) + downstream_key = tuple( + _cache_object_key( + spec if isinstance(spec, str) else None, + throughput, + ) + for spec, throughput in zip( + self._downstream_throughput_specs, + self._downstream_throughputs, + strict=True, + ) + ) return ( id(self.cmds), _cache_object_key(filename, self._surface_list), _cache_object_key(qe_filename, self._detector_qe), + downstream_key, positional_qe_key, str(self.meta["emission_phase"]), str(wave_unit), diff --git a/scopesim/tests/tests_effects/test_illumination.py b/scopesim/tests/tests_effects/test_illumination.py index 2eb3a42c8..4e2925435 100644 --- a/scopesim/tests/tests_effects/test_illumination.py +++ b/scopesim/tests/tests_effects/test_illumination.py @@ -153,6 +153,11 @@ class FakeQE: throughput = ConstantCurve(0.5) +class FakeThroughput: + def __init__(self, value): + self.throughput = ConstantCurve(value) + + class CountingEmission(ConstantEmission): def __init__(self, value): super().__init__(value) @@ -281,6 +286,26 @@ def test_post_disperser_diffuse_background_adds_integrated_rate(imageplane): assert imageplane.hdu.data[0, 0] == pytest.approx(expected) +def test_post_disperser_diffuse_background_applies_downstream_throughput( + imageplane, +): + eff = PostDisperserDiffuseBackground( + surface_list=FakeSurfaceList(), + detector_qe=FakeQE(), + downstream_throughputs=[FakeThroughput(0.25)], + wave_min=1.0, + wave_max=2.0, + wave_bin=1.0, + wave_unit="um", + area=1.0 * u.m**2, + ) + + eff.apply_to(imageplane) + + expected = 1.0 + 0.25e8 * pixel_area(imageplane.header).to_value(u.arcsec**2) + assert imageplane.hdu.data[0, 0] == pytest.approx(expected) + + def test_post_disperser_diffuse_background_accepts_detector_wcs(imageplane): for key in ("CDELT1", "CUNIT1", "CDELT2", "CUNIT2"): imageplane.header.remove(key, ignore_missing=True, remove_all=True) From 7b3babd291bfb75534735d2efff58ad2e9312f67 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Tue, 2 Jun 2026 23:45:44 -0700 Subject: [PATCH 16/43] Allow declared missing selector values --- scopesim/effects/selector_wheel.py | 41 ++++++++++++++--- .../tests_effects/test_selector_wheel.py | 45 +++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/scopesim/effects/selector_wheel.py b/scopesim/effects/selector_wheel.py index cd5c34f5b..20a426801 100644 --- a/scopesim/effects/selector_wheel.py +++ b/scopesim/effects/selector_wheel.py @@ -87,7 +87,7 @@ def apply_to(self, obj, **kwargs): effect_to_apply = self.get_effect(selector_value) if effect_to_apply is None: - logger.warning(f"No effect found for selector value: {selector_value}, skipping effect application.") + self._log_no_effect(selector_value, "selector value") return obj obj = effect_to_apply.apply_to(obj, **kwargs) @@ -125,7 +125,7 @@ def apply_to(self, obj, **kwargs): effect_to_apply = self.get_effect(selector_value) if effect_to_apply is None: - logger.warning(f"No effect found for detector ID: {selector_value}, skipping effect application.") + self._log_no_effect(selector_value, "detector ID") return obj obj = effect_to_apply.apply_to(obj, **kwargs) @@ -134,7 +134,7 @@ def apply_to(self, obj, **kwargs): selector_value = self._selector_value_from_image_plane(obj) effect_to_apply = self.get_effect(selector_value) if effect_to_apply is None: - logger.warning(f"No effect found for image plane ID: {selector_value}, skipping effect application.") + self._log_no_effect(selector_value, "image plane ID") return obj obj = effect_to_apply.apply_to(obj, **kwargs) @@ -145,12 +145,43 @@ def apply_to(self, obj, **kwargs): def get_effect(self, selector_value): eff = None if selector_value not in self.wheel_effects.keys(): - logger.warning(f"Entry for selector value {selector_value} not found in wheel effects. " - f"Assuming no effect to apply for this selector value.") + if self._is_missing_selector_value_allowed(selector_value): + logger.debug( + "Entry for selector value %s intentionally absent from wheel effects.", + selector_value, + ) + else: + logger.warning(f"Entry for selector value {selector_value} not found in wheel effects. " + f"Assuming no effect to apply for this selector value.") else: eff = self.wheel_effects[selector_value] return eff + def _is_missing_selector_value_allowed(self, selector_value): + values = self.meta.get( + "allowed_missing_selector_values", + self.meta.get("allow_missing_selector_values", ()), + ) + if values is None: + return False + if isinstance(values, str): + if values.lower() in {"all", "any", "*"}: + return True + values = (values,) + elif not isinstance(values, (list, tuple, set, frozenset)): + values = (values,) + return selector_value in values + + def _log_no_effect(self, selector_value, selector_label): + message = ( + f"No effect found for {selector_label}: {selector_value}, " + "skipping effect application." + ) + if self._is_missing_selector_value_allowed(selector_value): + logger.debug(message) + else: + logger.warning(message) + def _resolve_z_order(self): """Use an explicit wheel z_order if supplied, otherwise inherit one.""" diff --git a/scopesim/tests/tests_effects/test_selector_wheel.py b/scopesim/tests/tests_effects/test_selector_wheel.py index 0f1f0d533..71a0e0dad 100644 --- a/scopesim/tests/tests_effects/test_selector_wheel.py +++ b/scopesim/tests/tests_effects/test_selector_wheel.py @@ -1,5 +1,7 @@ """Tests for SelectorWheel.""" +import logging + import numpy as np from scopesim.effects import SelectorWheel @@ -65,3 +67,46 @@ def test_selector_wheel_applies_image_plane_effect_by_id(): wheel.apply_to(image_plane) assert np.all(image_plane.hdu.data == 3.0) + + +def test_selector_wheel_warns_for_unexpected_missing_selector_value(caplog): + image_plane = make_image_plane() + wheel = SelectorWheel( + selector_key="image_plane_id", + wheel=[ + { + "selector_value": 1, + "effect_class": "ImagePlaneBackground", + "effect_kwargs": {"value": 2.0}, + }, + ], + ) + + with caplog.at_level(logging.WARNING): + wheel.apply_to(image_plane) + + assert np.all(image_plane.hdu.data == 1.0) + assert "Entry for selector value 0 not found" in caplog.text + assert "No effect found for image plane ID: 0" in caplog.text + + +def test_selector_wheel_allows_declared_missing_selector_value(caplog): + image_plane = make_image_plane() + wheel = SelectorWheel( + selector_key="image_plane_id", + allowed_missing_selector_values=[0], + wheel=[ + { + "selector_value": 1, + "effect_class": "ImagePlaneBackground", + "effect_kwargs": {"value": 2.0}, + }, + ], + ) + + with caplog.at_level(logging.WARNING): + wheel.apply_to(image_plane) + + assert np.all(image_plane.hdu.data == 1.0) + assert "Entry for selector value 0 not found" not in caplog.text + assert "No effect found for image plane ID: 0" not in caplog.text From 59041561e36c746f36b88099a0949a9e1395a7e4 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Wed, 3 Jun 2026 10:38:28 -0700 Subject: [PATCH 17/43] Avoid duplicate selector wheel missing logs --- scopesim/effects/selector_wheel.py | 19 ++++--------------- .../tests_effects/test_selector_wheel.py | 7 ++++--- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/scopesim/effects/selector_wheel.py b/scopesim/effects/selector_wheel.py index 20a426801..7ee03f242 100644 --- a/scopesim/effects/selector_wheel.py +++ b/scopesim/effects/selector_wheel.py @@ -87,7 +87,6 @@ def apply_to(self, obj, **kwargs): effect_to_apply = self.get_effect(selector_value) if effect_to_apply is None: - self._log_no_effect(selector_value, "selector value") return obj obj = effect_to_apply.apply_to(obj, **kwargs) @@ -106,12 +105,15 @@ def apply_to(self, obj, **kwargs): continue effect_to_apply = self.get_effect(val) - logger.debug(f"Applying effect for {self.meta['selector_key']}: {val} -> {effect_to_apply}, volumes: {len(vols_with_val)}") if effect_to_apply is None: new_volumes.extend(vols_with_val) continue + logger.debug( + f"Applying effect for {self.meta['selector_key']}: " + f"{val} -> {effect_to_apply}, volumes: {len(vols_with_val)}" + ) newvollist = FovVolumeList() newvollist.volumes = vols_with_val newvollist = effect_to_apply.apply_to(newvollist, **kwargs) @@ -125,7 +127,6 @@ def apply_to(self, obj, **kwargs): effect_to_apply = self.get_effect(selector_value) if effect_to_apply is None: - self._log_no_effect(selector_value, "detector ID") return obj obj = effect_to_apply.apply_to(obj, **kwargs) @@ -134,7 +135,6 @@ def apply_to(self, obj, **kwargs): selector_value = self._selector_value_from_image_plane(obj) effect_to_apply = self.get_effect(selector_value) if effect_to_apply is None: - self._log_no_effect(selector_value, "image plane ID") return obj obj = effect_to_apply.apply_to(obj, **kwargs) @@ -172,17 +172,6 @@ def _is_missing_selector_value_allowed(self, selector_value): values = (values,) return selector_value in values - def _log_no_effect(self, selector_value, selector_label): - message = ( - f"No effect found for {selector_label}: {selector_value}, " - "skipping effect application." - ) - if self._is_missing_selector_value_allowed(selector_value): - logger.debug(message) - else: - logger.warning(message) - - def _resolve_z_order(self): """Use an explicit wheel z_order if supplied, otherwise inherit one.""" configured_z_order = self.meta.get("z_order") diff --git a/scopesim/tests/tests_effects/test_selector_wheel.py b/scopesim/tests/tests_effects/test_selector_wheel.py index 71a0e0dad..a5595ee18 100644 --- a/scopesim/tests/tests_effects/test_selector_wheel.py +++ b/scopesim/tests/tests_effects/test_selector_wheel.py @@ -87,7 +87,7 @@ def test_selector_wheel_warns_for_unexpected_missing_selector_value(caplog): assert np.all(image_plane.hdu.data == 1.0) assert "Entry for selector value 0 not found" in caplog.text - assert "No effect found for image plane ID: 0" in caplog.text + assert "No effect found" not in caplog.text def test_selector_wheel_allows_declared_missing_selector_value(caplog): @@ -104,9 +104,10 @@ def test_selector_wheel_allows_declared_missing_selector_value(caplog): ], ) - with caplog.at_level(logging.WARNING): + with caplog.at_level(logging.DEBUG): wheel.apply_to(image_plane) assert np.all(image_plane.hdu.data == 1.0) assert "Entry for selector value 0 not found" not in caplog.text - assert "No effect found for image plane ID: 0" not in caplog.text + assert "Entry for selector value 0 intentionally absent" in caplog.text + assert "No effect found" not in caplog.text From 46ba0786da397816a0cb5b915b2d570cc4be5494 Mon Sep 17 00:00:00 2001 From: Yashvi-Sharma Date: Tue, 30 Jun 2026 11:34:16 -0700 Subject: [PATCH 18/43] fixed unit transfer during SpectralSurface creation --- scopesim/effects/surface_list.py | 7 ++++--- scopesim/utils.py | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scopesim/effects/surface_list.py b/scopesim/effects/surface_list.py index 70d3e0006..47f4c4417 100644 --- a/scopesim/effects/surface_list.py +++ b/scopesim/effects/surface_list.py @@ -11,7 +11,7 @@ from .ter_curves import TERCurve from ..optics.surface import PoorMansSurface, SpectralSurface -from ..utils import quantify, from_currsys, figure_factory, real_colname +from ..utils import quantify, from_currsys, figure_factory, real_colname, quantity_from_table class SurfaceList(TERCurve): @@ -32,9 +32,10 @@ def __init__(self, **kwargs): self.surfaces = OrderedDict({}) if self.table is not None: - for row in self.table: + for i in range(len(self.table)): surf_kwargs = deepcopy(self.table.meta) - surf_kwargs.update(dict(row)) + rowdict = {colname:quantity_from_table(colname, self.table)[i] for colname in self.table.colnames} + surf_kwargs.update(rowdict) surf_kwargs["cmds"] = self.cmds surf_kwargs["filename"] = from_currsys(surf_kwargs["filename"], self.cmds) self.surfaces[surf_kwargs["name"]] = SpectralSurface(**surf_kwargs) diff --git a/scopesim/utils.py b/scopesim/utils.py index a72de1942..e7b248215 100644 --- a/scopesim/utils.py +++ b/scopesim/utils.py @@ -511,6 +511,8 @@ def quantity_from_table(colname: str, table: Table, col = table[colname] if col.unit is not None: return col.quantity + if col.dtype.kind not in 'iufc': + return col unit = unit_from_table(colname, table, default_unit) # TODO: or rather << ? From 97ae50a6e4801b242c702282814bf1f4cd51e959 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Tue, 30 Jun 2026 22:11:46 -0700 Subject: [PATCH 19/43] Add support for detector angle in echelle trace calculations Enhanced echelle trace logic by incorporating a detector angle parameter for transformations. Updated relevant files to include detector angle metadata and calculations, improving flexibility for tilted detector configurations. --- scopesim/effects/spectral_trace_list.py | 12 ++++++++++++ scopesim/optics/echelle.py | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scopesim/effects/spectral_trace_list.py b/scopesim/effects/spectral_trace_list.py index 473785691..d3e781585 100644 --- a/scopesim/effects/spectral_trace_list.py +++ b/scopesim/effects/spectral_trace_list.py @@ -621,6 +621,14 @@ def _generate_trace_hdulist(self, trace_params): slit_edge = (row['slitlength'] / 2) * u.Unit(trace_params.meta["slitlength_unit"]) slit_pos = np.linspace(-slit_edge, slit_edge, num=3) slit_offset_pix = slit_pos / (from_currsys('!INST.pixel_scale', self.cmds) * u.arcsec) + detector_angle = 0.0 + if "detector_angle" in trace_params.table.colnames: + detector_angle = u.Quantity( + row["detector_angle"], + u.Unit(trace_params.meta.get("detector_angle_unit", "deg")), + ).to_value(u.deg) + cang = np.cos(np.deg2rad(detector_angle)) + sang = np.sin(np.deg2rad(detector_angle)) xvals, yvals = [], [] for i, order in enumerate(ss.orders): @@ -645,6 +653,10 @@ def _generate_trace_hdulist(self, trace_params): w = np.tile(wave, slit_offset_pix.size) xval = xvals[i] - xcent # Centering on 0,0 at detector center yval = yvals[i] - ycent # Centering on 0,0 at detector center + if detector_angle: + x0, y0 = xval, yval + xval = cang * x0 - sang * y0 + yval = sang * x0 + cang * y0 order_table = Table( {'wavelength': w.to(u.um), 's': s, diff --git a/scopesim/optics/echelle.py b/scopesim/optics/echelle.py index 5a992059c..cba975099 100644 --- a/scopesim/optics/echelle.py +++ b/scopesim/optics/echelle.py @@ -393,11 +393,11 @@ def __init__( f'\n\tOrders: {self.orders}' f'\n\tFocal length: {self.focal_length}' f'\n\tIncidence angle: {np.rad2deg(self.grating.alpha):.3f}' - f'\n\tReflectance angle: {np.rad2deg(self.beta_central_pixel):.2f}\n' + f'\n\tReflectance angle: {np.rad2deg(self.beta_central_pixel):.2f}' f'\n\tGroove length: {self.grating.d:.2f}' f'\n\t# of pixels: {self.detector.n_pix_x}x{self.detector.n_pix_y}' f'\n\tPixel size: {self.detector.pixel_size}' - f'\n\tPixels per res. element: {self.nominal_pixels_per_res_elem}') + f'\n\tPixels per res. element: {self.nominal_pixels_per_res_elem}\n') def set_beta_center(self, beta, littrow: bool = False): """ From 70adcc6147ecd737bd9e462a16ba52632c505c24 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Wed, 1 Jul 2026 09:48:41 -0700 Subject: [PATCH 20/43] Use detector-local scales for spectral image planes --- scopesim/effects/detector_list.py | 1 + scopesim/effects/spectral_trace_list.py | 35 +++++++++++-- scopesim/effects/spectral_trace_list_utils.py | 19 +++++-- scopesim/optics/fov_manager.py | 52 +++++++++++++++---- .../test_SpectralTraceListUtils.py | 24 ++++++++- .../tests/tests_optics/test_FOVManager.py | 33 ++++++++++++ 6 files changed, 144 insertions(+), 20 deletions(-) diff --git a/scopesim/effects/detector_list.py b/scopesim/effects/detector_list.py index 61fd4fcba..6e197f75f 100644 --- a/scopesim/effects/detector_list.py +++ b/scopesim/effects/detector_list.py @@ -139,6 +139,7 @@ def __init__(self, **kwargs): super().__init__(**kwargs) params = { "pixel_scale": "!INST.pixel_scale", # arcsec + "plate_scale": "!INST.plate_scale", # arcsec / mm "active_detectors": "all", } self.meta.update(params) diff --git a/scopesim/effects/spectral_trace_list.py b/scopesim/effects/spectral_trace_list.py index d3e781585..794d8a4bf 100644 --- a/scopesim/effects/spectral_trace_list.py +++ b/scopesim/effects/spectral_trace_list.py @@ -530,6 +530,8 @@ class EchelleSpectralTraceList(SpectralTraceList): # echelle_blaze_unit : deg # focal_length_unit : mm # fwhm_unit : pixel + # nominal_slit_width_unit : arcsec + # plate_scale_unit : arcsec/mm # detector_pad_unit : pixel # pixel_size_unit : mm # n_disp_unit : pixel @@ -538,10 +540,10 @@ class EchelleSpectralTraceList(SpectralTraceList): # xdisp_freq_unit : mm # slitwidth_unit : arcsec - prefix aperture_id image_plane_id m0 n min_wave max_wave design_res echelle_blaze focal_length fwhm detector_pad pixel_size n_disp n_xdisp disp_freq xdisp_freq slitwidth dispdir - ub 0 2 29 11 315 515 20000 64.2 225 4.7 10 0.015 4096 4096 200 1000 10 x - gri 1 1 36 18 490 1020 20000 64.2 225 4.7 10 0.015 4096 4096 100 500 10 x - nIR 2 0 40 24 970 2500 20000 64.2 225 4.7 10 0.015 4096 4096 45 175 10 x + prefix aperture_id image_plane_id m0 n min_wave max_wave design_res echelle_blaze focal_length fwhm nominal_slit_width plate_scale detector_pad pixel_size n_disp n_xdisp disp_freq xdisp_freq slitwidth dispdir + ub 0 2 29 11 315 515 20000 64.2 225 4.7 0.7 10.0 10 0.015 4096 4096 200 1000 10 x + gri 1 1 36 18 490 1020 20000 64.2 225 4.7 0.7 10.0 10 0.015 4096 4096 100 500 10 x + nIR 2 0 40 24 970 2500 20000 64.2 225 4.7 0.7 10.0 10 0.015 4096 4096 45 175 10 x ---------------------------------------------------------------- The calculated traces are stored in the same HDUList format as required by SpectralTraceList, @@ -620,7 +622,16 @@ def _generate_trace_hdulist(self, trace_params): slit_edge = (row['slitlength'] / 2) * u.Unit(trace_params.meta["slitlength_unit"]) slit_pos = np.linspace(-slit_edge, slit_edge, num=3) - slit_offset_pix = slit_pos / (from_currsys('!INST.pixel_scale', self.cmds) * u.arcsec) + if "plate_scale" in trace_params.table.colnames: + plate_scale = row["plate_scale"] * u.Unit( + trace_params.meta["plate_scale_unit"]) + slit_offset_pix = (slit_pos / plate_scale / pix_size).to_value( + u.dimensionless_unscaled) + else: + slit_offset_pix = ( + slit_pos / + (from_currsys('!INST.pixel_scale', self.cmds) * u.arcsec) + ).to_value(u.dimensionless_unscaled) detector_angle = 0.0 if "detector_angle" in trace_params.table.colnames: detector_angle = u.Quantity( @@ -666,6 +677,20 @@ def _generate_trace_hdulist(self, trace_params): trace_hdu = fits.BinTableHDU(order_table) trace_hdu.header['DISPDIR'] = row['dispdir'] trace_hdu.header["EXTNAME"] = f'{prefix}_{order:d}' + trace_hdu.header["DESIGNR"] = ( + float(design_res), "Analytical trace design resolving power") + trace_hdu.header["FWHMPIX"] = ( + float(pix_per_res_elem), "Analytical nominal FWHM [pix]") + trace_hdu.header["PIXSIZE"] = ( + pix_size.to_value(u.mm), "Detector pixel size [mm]") + if "nominal_slit_width" in trace_params.table.colnames: + trace_hdu.header["SLITWID"] = ( + float(row["nominal_slit_width"]), + "Analytical nominal slit width [arcsec]") + if "plate_scale" in trace_params.table.colnames: + trace_hdu.header["PLTSCALE"] = ( + plate_scale.to_value(u.arcsec / u.mm), + "Analytical sky-to-image plate scale [arcsec/mm]") hdul.append(trace_hdu) return hdul diff --git a/scopesim/effects/spectral_trace_list_utils.py b/scopesim/effects/spectral_trace_list_utils.py index f328e8148..35e446eed 100644 --- a/scopesim/effects/spectral_trace_list_utils.py +++ b/scopesim/effects/spectral_trace_list_utils.py @@ -95,6 +95,15 @@ def __init__(self, trace_tbl, cmds=None, **kwargs): self.meta["trace_id"] = trace_tbl.header.get("EXTNAME", "") self.dispersion_axis = trace_tbl.header.get("DISPDIR", "unknown") + for header_key, meta_key in ( + ("DESIGNR", "design_res"), + ("FWHMPIX", "nominal_fwhm_pix"), + ("PIXSIZE", "pixel_size"), + ("SLITWID", "nominal_slit_width"), + ("PLTSCALE", "plate_scale"), + ): + if header_key in trace_tbl.header: + self.meta[meta_key] = trace_tbl.header[header_key] elif isinstance(trace_tbl, Table): self.table = trace_tbl self.dispersion_axis = "unknown" @@ -200,7 +209,7 @@ def map_spectra_to_focal_plane(self, fov): det_header = fov.detector_header # WCSD from the FieldOfView - this is the full detector plane - pixsize = fov_header["CDELT1D"] * u.Unit(fov_header["CUNIT1D"]) + pixsize = det_header["CDELT1D"] * u.Unit(det_header["CUNIT1D"]) pixsize = pixsize.to_value(u.mm) pixscale = fov_header["CDELT1"] * u.Unit(fov_header["CUNIT1"]) pixscale = pixscale.to_value(u.arcsec) @@ -666,8 +675,12 @@ def _set_dispersion(self, wave_min, wave_max, pixsize=None): dlam_grad = self.xy2lam.gradient()[0] # dlam_by_dx else: dlam_grad = self.xy2lam.gradient()[1] # dlam_by_dy - pixsize = (from_currsys(self.meta["pixel_scale"], self.cmds) / - from_currsys(self.meta["plate_scale"], self.cmds)) + if pixsize is None: + pixsize = (from_currsys(self.meta["pixel_scale"], self.cmds) / + from_currsys(self.meta["plate_scale"], self.cmds)) + elif isinstance(pixsize, u.Quantity): + pixsize = pixsize.to_value(u.mm) + self.dlam_per_pix = interp1d(lam, dlam_grad(x_mm, y_mm) * pixsize, fill_value="extrapolate") diff --git a/scopesim/optics/fov_manager.py b/scopesim/optics/fov_manager.py index 05e0af6e7..e079ab63f 100644 --- a/scopesim/optics/fov_manager.py +++ b/scopesim/optics/fov_manager.py @@ -145,18 +145,47 @@ def generate_fovs_list(self) -> Iterator[FieldOfView]: params = {"pixel_scale": self.meta["pixel_scale"]} for effect in self.effects: - self.volumes_list = effect.apply_to(self.volumes_list, **params) + effect_params = params + if (isinstance(effect, DetectorList) and + effect.meta.get("pixel_scale") != "!INST.pixel_scale"): + effect_params = {} + self.volumes_list = effect.apply_to( + self.volumes_list, **effect_params) # ..todo: add catch to split volumes larger than chunk_size pixel_scale = from_currsys(self.meta["pixel_scale"], self.cmds) - plate_scale = from_currsys(self.meta["plate_scale"], self.cmds) splits = (chain.from_iterable(split) for split in zip(*self._get_splits(pixel_scale))) self.volumes_list.split(axis=["x", "y"], value=splits) + detector_effects = eu.get_all_effects(self.effects, DetectorList) + decouple = from_currsys(self.meta["decouple_sky_det_hdrs"], self.cmds) + for vol in self.volumes_list: + image_plane_id = vol["meta"].get("image_plane_id") + det_eff = None + if image_plane_id is not None: + for candidate in detector_effects: + if (from_currsys(candidate.meta["image_plane_id"], self.cmds) + == image_plane_id): + det_eff = candidate + break + + scale_meta = { + "pixel_scale": self.meta["pixel_scale"], + "plate_scale": self.meta["plate_scale"], + } + if det_eff is not None: + scale_meta["pixel_scale"] = det_eff.meta.get( + "pixel_scale", scale_meta["pixel_scale"]) + scale_meta["plate_scale"] = det_eff.meta.get( + "plate_scale", scale_meta["plate_scale"]) + scales = from_currsys(scale_meta, self.cmds) + pixel_scale = scales["pixel_scale"] + plate_scale = scales["plate_scale"] + xs_min, xs_max = vol["x_min"] / 3600., vol["x_max"] / 3600. ys_min, ys_max = vol["y_min"] / 3600., vol["y_max"] / 3600. waverange = (vol["wave_min"], vol["wave_max"]) @@ -166,20 +195,17 @@ def generate_fovs_list(self) -> Iterator[FieldOfView]: dethdr, _ = ipu.det_wcs_from_sky_wcs( WCS(skyhdr), pixel_scale, plate_scale) - skyhdr.update(dethdr.to_header()) # useful for spectroscopy mode where slit dimensions is not the same # as detector dimensions - if from_currsys(self.meta["decouple_sky_det_hdrs"], self.cmds): - det_effs = eu.get_all_effects(self.effects, DetectorList) - for det_eff in det_effs: - if det_eff.meta["image_plane_id"] == vol["meta"]["image_plane_id"]: - dethdr = det_eff.image_plane_header - break - + if decouple and det_eff is not None: + dethdr = det_eff.image_plane_header # TODO: Why is this .image_plane_header and not # .detector_headers()[0] or something? + skyhdr.update( + dethdr.to_header() if hasattr(dethdr, "to_header") else dethdr) + if not self.is_spectroscope: fovcls = FieldOfView2D else: @@ -188,12 +214,16 @@ def generate_fovs_list(self) -> Iterator[FieldOfView]: else: fovcls = FieldOfView1D + fov_meta = vol["meta"].copy() + fov_meta["pixel_scale"] = pixel_scale + fov_meta["plate_scale"] = plate_scale + new_fov = fovcls( skyhdr, waverange, detector_header=dethdr, cmds=self.cmds, - **vol["meta"], + **fov_meta, ) yield new_fov diff --git a/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py b/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py index 1b62be122..a678e1ea1 100644 --- a/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py +++ b/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py @@ -8,7 +8,6 @@ import numpy as np from astropy.io import fits - from scopesim.effects.spectral_trace_list_utils import SpectralTrace from scopesim.effects.spectral_trace_list_utils import Transform2D, power_vector from scopesim.effects.spectral_trace_list_utils import make_image_interpolations @@ -22,6 +21,18 @@ def test_initialises_with_table(self): spt = SpectralTrace(trace_tbl) assert isinstance(spt, SpectralTrace) + def test_copies_validation_metadata_from_fits_header(self): + hdu = fits.BinTableHDU(tlo.trace_1()) + hdu.header["DESIGNR"] = 18000 + hdu.header["FWHMPIX"] = 4.2 + hdu.header["SLITWID"] = 0.7 + + spt = SpectralTrace(hdu) + + assert spt.meta["design_res"] == 18000 + assert spt.meta["nominal_fwhm_pix"] == 4.2 + assert spt.meta["nominal_slit_width"] == 0.7 + def test_fails_without_table(self): a_number = 1 with pytest.raises(ValueError): @@ -37,6 +48,17 @@ def test_determines_correct_dispersion_axis_y(self): spt = SpectralTrace(trace_tbl) assert spt.dispersion_axis == 'y' + def test_set_dispersion_uses_supplied_detector_pixel_size(self): + trace_tbl = tlo.trace_6() + spt = SpectralTrace(trace_tbl) + + spt._set_dispersion(2.1, 2.4, pixsize=0.01) + small_pix = spt.dlam_per_pix(2.2) + spt._set_dispersion(2.1, 2.4, pixsize=0.02) + large_pix = spt.dlam_per_pix(2.2) + + assert large_pix == pytest.approx(2 * small_pix) + def test_apply_detector_qe_to_trace_image_uses_detector_position(): class PositionAwareQE: diff --git a/scopesim/tests/tests_optics/test_FOVManager.py b/scopesim/tests/tests_optics/test_FOVManager.py index e1a055e71..ec27b65ca 100644 --- a/scopesim/tests/tests_optics/test_FOVManager.py +++ b/scopesim/tests/tests_optics/test_FOVManager.py @@ -3,6 +3,7 @@ import numpy as np from astropy import units as u +from scopesim.effects import DetectorWindow from scopesim.optics.fov_manager import FOVManager from scopesim.tests.mocks.py_objects import effects_objects as eo from scopesim.utils import from_currsys @@ -56,3 +57,35 @@ def test_returns_n_fovs_for_smaller_chunk_size(self, chunk_size, n_fovs): assert len(fovs) == 4 assert fov_skycorners.min(axis=0)[0] == approx(-1024 / 3600) # [deg] 2k detector / pixel_scale assert fovs[0].waverange[0] == 0.6 * u.um # filter blue edge + + def test_uses_detector_scale_for_matching_image_plane(self): + class ImagePlaneTagger: + def apply_to(self, obj, **kwargs): + for vol in obj: + vol["meta"]["image_plane_id"] = 7 + return obj + + det = DetectorWindow( + pixel_size=0.01, + x=0, + y=0, + width=10, + height=10, + units="pixel", + image_plane_id=7, + pixel_scale=0.25, + plate_scale=25, + ) + fov_man = FOVManager( + effects=[ImagePlaneTagger(), det], + pixel_scale=1, + plate_scale=1, + decouple_sky_det_hdrs=True, + ) + + fov = next(fov_man.generate_fovs_list()) + + assert fov.meta["pixel_scale"] == approx(0.25) + assert fov.meta["plate_scale"] == approx(25) + assert fov.header["CDELT1"] * 3600 == approx(0.25) + assert fov.detector_header["CDELT1D"] == approx(0.01) From c0ad0b5c81da6ed97a8f644ce10f6f75d621e0a7 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Wed, 1 Jul 2026 09:50:06 -0700 Subject: [PATCH 21/43] Add optional trace flux Jacobian scaling --- scopesim/defaults.yaml | 1 + scopesim/effects/spectral_trace_list_utils.py | 38 +++++++++++++++-- .../test_SpectralTraceListUtils.py | 41 +++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/scopesim/defaults.yaml b/scopesim/defaults.yaml index 9c9cf8f75..d019ac7db 100644 --- a/scopesim/defaults.yaml +++ b/scopesim/defaults.yaml @@ -13,6 +13,7 @@ properties : spectral_bin_width : !!float 1E-4 spectral_resolution: 5000 + trace_flux_jacobian : False minimum_throughput : !!float 1E-6 minimum_pixel_flux : 1 diff --git a/scopesim/effects/spectral_trace_list_utils.py b/scopesim/effects/spectral_trace_list_utils.py index 35e446eed..0171e14b6 100644 --- a/scopesim/effects/spectral_trace_list_utils.py +++ b/scopesim/effects/spectral_trace_list_utils.py @@ -79,6 +79,7 @@ class SpectralTrace: "spline_order": 4, "pixel_size": None, "description": "", + "trace_flux_jacobian": "!SIM.spectral.trace_flux_jacobian", } def __init__(self, trace_tbl, cmds=None, **kwargs): @@ -302,10 +303,8 @@ def map_spectra_to_focal_plane(self, fov): image = xilam.interp(xi_fpa, lam_fpa, grid=False) * ijmask # Scale to ph / s / pixel - dlam_by_dx, dlam_by_dy = self.xy2lam.gradient() - dlam_per_pix = pixsize * np.sqrt(dlam_by_dx(ximg_fpa, yimg_fpa)**2 + - dlam_by_dy(ximg_fpa, yimg_fpa)**2) - image *= pixscale * dlam_per_pix # [arcsec/pix] * [um/pix] + image *= self._trace_flux_scale( + ximg_fpa, yimg_fpa, pixsize, pixscale, det_header) detector_qe = fov.meta.get("detector_qe") if detector_qe is not None: @@ -341,6 +340,37 @@ def map_spectra_to_focal_plane(self, fov): image_hdu = fits.ImageHDU(header=img_header, data=image) return image_hdu + def _trace_flux_scale(self, x_mm, y_mm, pixsize, pixscale, det_header): + """Return the local [arcsec um] per detector pixel flux scale.""" + try: + use_jacobian = bool(from_currsys( + self.meta["trace_flux_jacobian"], self.cmds)) + except ValueError: + use_jacobian = False + + if not use_jacobian: + dlam_by_dx, dlam_by_dy = self.xy2lam.gradient() + dlam_per_pix = pixsize * np.sqrt( + dlam_by_dx(x_mm, y_mm)**2 + dlam_by_dy(x_mm, y_mm)**2) + return pixscale * dlam_per_pix + + dxi_by_dx, dxi_by_dy = self.xy2xi.gradient() + dlam_by_dx, dlam_by_dy = self.xy2lam.gradient() + jacobian = (dxi_by_dx(x_mm, y_mm) * dlam_by_dy(x_mm, y_mm) - + dxi_by_dy(x_mm, y_mm) * dlam_by_dx(x_mm, y_mm)) + return np.abs(jacobian) * self._detector_pixel_area(det_header) + + @staticmethod + def _detector_pixel_area(det_header): + """Return detector WCS pixel area in mm2.""" + try: + pixel_scale_matrix = WCS(det_header, key="D").pixel_scale_matrix + return np.abs(np.linalg.det(pixel_scale_matrix)) + except Exception: # pragma: no cover - FITS fallback for odd headers + cdelt1 = det_header["CDELT1D"] * u.Unit(det_header["CUNIT1D"]) + cdelt2 = det_header["CDELT2D"] * u.Unit(det_header["CUNIT2D"]) + return np.abs((cdelt1 * cdelt2).to_value(u.mm**2)) + def rectify(self, hdulist, interps=None, wcs=None, **kwargs): """Create 2D spectrum for a trace. diff --git a/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py b/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py index a678e1ea1..b5f10e0af 100644 --- a/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py +++ b/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py @@ -8,6 +8,10 @@ import numpy as np from astropy.io import fits +from astropy.table import Table +from astropy import units as u + +from scopesim.optics.image_plane_utils import header_from_list_of_xy from scopesim.effects.spectral_trace_list_utils import SpectralTrace from scopesim.effects.spectral_trace_list_utils import Transform2D, power_vector from scopesim.effects.spectral_trace_list_utils import make_image_interpolations @@ -59,6 +63,43 @@ def test_set_dispersion_uses_supplied_detector_pixel_size(self): assert large_pix == pytest.approx(2 * small_pix) + def test_trace_flux_jacobian_handles_tilted_trace(self): + spt = SpectralTrace(_tilted_linear_trace_table()) + det_header = header_from_list_of_xy([-1, 1], [-1, 1], 0.01, "D") + x_mm = np.array([[2.0]]) + y_mm = np.array([[0.0]]) + + spt.meta["trace_flux_jacobian"] = False + projected_scale = spt._trace_flux_scale( + x_mm, y_mm, pixsize=0.01, pixscale=0.1, det_header=det_header) + + spt.meta["trace_flux_jacobian"] = True + jacobian_scale = spt._trace_flux_scale( + x_mm, y_mm, pixsize=0.01, pixscale=0.1, det_header=det_header) + + assert projected_scale[0, 0] > jacobian_scale[0, 0] + assert jacobian_scale[0, 0] == pytest.approx(1e-3, rel=1e-5) + + +def _tilted_linear_trace_table(): + xi_grid, wave_grid = np.meshgrid( + np.linspace(-1, 1, 5), + np.linspace(1, 3, 5), + indexing="ij", + ) + xi = xi_grid.ravel() + wave = wave_grid.ravel() + + return Table( + data=[ + wave * u.um, + xi * u.arcsec, + (wave + 0.1 * xi) * u.mm, + (xi / 10) * u.mm, + ], + names=["wavelength", "s", "x", "y"], + ) + def test_apply_detector_qe_to_trace_image_uses_detector_position(): class PositionAwareQE: From c98be9ac29a4a8e6354f2dfd73c5e7452997917a Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Wed, 1 Jul 2026 10:43:45 -0700 Subject: [PATCH 22/43] Keep decoupled detector headers out of FOV sky geometry --- scopesim/optics/fov_manager.py | 7 +++---- scopesim/tests/tests_optics/test_FOVManager.py | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/scopesim/optics/fov_manager.py b/scopesim/optics/fov_manager.py index e079ab63f..29f7ef888 100644 --- a/scopesim/optics/fov_manager.py +++ b/scopesim/optics/fov_manager.py @@ -193,8 +193,10 @@ def generate_fovs_list(self) -> Iterator[FieldOfView]: [ys_min, ys_max], pixel_scale=pixel_scale / 3600.) - dethdr, _ = ipu.det_wcs_from_sky_wcs( + fov_dethdr, _ = ipu.det_wcs_from_sky_wcs( WCS(skyhdr), pixel_scale, plate_scale) + skyhdr.update(fov_dethdr.to_header()) + dethdr = fov_dethdr # useful for spectroscopy mode where slit dimensions is not the same # as detector dimensions @@ -203,9 +205,6 @@ def generate_fovs_list(self) -> Iterator[FieldOfView]: # TODO: Why is this .image_plane_header and not # .detector_headers()[0] or something? - skyhdr.update( - dethdr.to_header() if hasattr(dethdr, "to_header") else dethdr) - if not self.is_spectroscope: fovcls = FieldOfView2D else: diff --git a/scopesim/tests/tests_optics/test_FOVManager.py b/scopesim/tests/tests_optics/test_FOVManager.py index ec27b65ca..a99477156 100644 --- a/scopesim/tests/tests_optics/test_FOVManager.py +++ b/scopesim/tests/tests_optics/test_FOVManager.py @@ -59,9 +59,13 @@ def test_returns_n_fovs_for_smaller_chunk_size(self, chunk_size, n_fovs): assert fovs[0].waverange[0] == 0.6 * u.um # filter blue edge def test_uses_detector_scale_for_matching_image_plane(self): - class ImagePlaneTagger: + class SmallImagePlaneTagger: def apply_to(self, obj, **kwargs): for vol in obj: + vol["x_min"] = -1 + vol["x_max"] = 1 + vol["y_min"] = -1 + vol["y_max"] = 1 vol["meta"]["image_plane_id"] = 7 return obj @@ -69,15 +73,15 @@ def apply_to(self, obj, **kwargs): pixel_size=0.01, x=0, y=0, - width=10, - height=10, + width=100, + height=100, units="pixel", image_plane_id=7, pixel_scale=0.25, plate_scale=25, ) fov_man = FOVManager( - effects=[ImagePlaneTagger(), det], + effects=[SmallImagePlaneTagger(), det], pixel_scale=1, plate_scale=1, decouple_sky_det_hdrs=True, @@ -88,4 +92,8 @@ def apply_to(self, obj, **kwargs): assert fov.meta["pixel_scale"] == approx(0.25) assert fov.meta["plate_scale"] == approx(25) assert fov.header["CDELT1"] * 3600 == approx(0.25) + assert fov.header["NAXIS1"] < fov.detector_header["NAXIS1"] + assert fov.header["NAXIS2"] < fov.detector_header["NAXIS2"] + assert fov.detector_header["NAXIS1"] == 100 + assert fov.detector_header["NAXIS2"] == 100 assert fov.detector_header["CDELT1D"] == approx(0.01) From d4840a79a6cc368bec40fa974dbdf9d4c31147d0 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Wed, 1 Jul 2026 12:51:04 -0700 Subject: [PATCH 23/43] Clip analytical echelle traces in detector frame --- scopesim/effects/spectral_trace_list.py | 129 +++++++++++++----- scopesim/effects/spectral_trace_list_utils.py | 2 + .../tests_effects/test_SpectralTraceList.py | 59 +++++++- 3 files changed, 150 insertions(+), 40 deletions(-) diff --git a/scopesim/effects/spectral_trace_list.py b/scopesim/effects/spectral_trace_list.py index 794d8a4bf..b229bda5c 100644 --- a/scopesim/effects/spectral_trace_list.py +++ b/scopesim/effects/spectral_trace_list.py @@ -580,20 +580,6 @@ def _generate_trace_hdulist(self, trace_params): hdul[0].header["EDATA"] = 2 trace_ids, ap_ids, im_ids = [], [], [] - for row in trace_params.table: - prefix = row["prefix"] - for i in range(row["m0"] - row["n"], row["m0"] + 1): - trace_ids.append(f'{prefix}_{i:d}') - ap_ids.append(row["aperture_id"]) - im_ids.append(row["image_plane_id"]) - - hdul.append(fits.BinTableHDU(Table( - {'description': trace_ids, - 'extension_id': np.arange(len(trace_ids), dtype=int)+2, - 'aperture_id': ap_ids, - 'image_plane_id': im_ids - }))) - for row in trace_params.table: prefix = row["prefix"] min_order = row['m0'] - row['n'] @@ -602,8 +588,9 @@ def _generate_trace_hdulist(self, trace_params): max_wave = row['max_wave'] * u.Unit(trace_params.meta["max_wave_unit"]) design_res = row['design_res'] focal_len = row['focal_length'] * u.Unit(trace_params.meta["focal_length_unit"]) - disp_npix = row['n_disp'] - 2 * row['detector_pad'] - xdisp_npix = row['n_xdisp'] - 2 * row['detector_pad'] + disp_npix = int(row['n_disp']) + xdisp_npix = int(row['n_xdisp']) + detector_pad = int(row['detector_pad']) pix_size = row['pixel_size'] * u.Unit(trace_params.meta["pixel_size_unit"]) echelle_angle = np.deg2rad(row['echelle_blaze'])*u.rad xdisp_beta_center = np.deg2rad(row['xbeta_center'])*u.rad @@ -641,33 +628,85 @@ def _generate_trace_hdulist(self, trace_params): cang = np.cos(np.deg2rad(detector_angle)) sang = np.sin(np.deg2rad(detector_angle)) - xvals, yvals = [], [] - for i, order in enumerate(ss.orders): - wave = edges[i] - wave = np.linspace(wave[0], wave[-1], num=max(int(disp_npix*.1), 2)) + def raw_detector_pixels(wave, order): x = ss.wavelength_to_x_pixel(wave, order) y = ss.wavelength_to_y_pixel(wave) - pix_y = y + slit_offset_pix[:, None] + row['detector_pad'] - xval = np.tile(x, slit_offset_pix.size)*pix_size.to('mm') - yval = pix_y.ravel()*pix_size.to('mm') - xvals.append(xval) - yvals.append(yval) + x = u.Quantity(x, copy=False).to_value( + u.dimensionless_unscaled) + y = u.Quantity(y, copy=False).to_value( + u.dimensionless_unscaled) + xpix = np.broadcast_to(x, (slit_offset_pix.size, wave.size)) + ypix = y[None, :] + slit_offset_pix[:, None] + return xpix, ypix + + rotated_x_min = rotated_y_min = np.inf + rotated_x_max = rotated_y_max = -np.inf + # The analytical echelle layout is relative, not yet detector + # placed. Rotate the footprint first, place that rotated footprint + # on the detector, then apply detector-frame padding below. + for i, order in enumerate(ss.orders): + wave = np.linspace( + edges[i][0], edges[i][-1], num=max(disp_npix, 2)) + xpix, ypix = raw_detector_pixels(wave, order) + xrot = cang * xpix - sang * ypix + yrot = sang * xpix + cang * ypix + rotated_x_min = min(rotated_x_min, float(np.nanmin(xrot))) + rotated_x_max = max(rotated_x_max, float(np.nanmax(xrot))) + rotated_y_min = min(rotated_y_min, float(np.nanmin(yrot))) + rotated_y_max = max(rotated_y_max, float(np.nanmax(yrot))) + rotated_x_center = rotated_x_min + (rotated_x_max - rotated_x_min) / 2 + rotated_y_center = rotated_y_min + (rotated_y_max - rotated_y_min) / 2 + + def rotated_detector_pixels(wave, order): + xpix, ypix = raw_detector_pixels(wave, order) + xrot = cang * xpix - sang * ypix + yrot = sang * xpix + cang * ypix + return ( + xrot - rotated_x_center + disp_npix / 2, + yrot - rotated_y_center + xdisp_npix / 2, + ) - # echelle above has 0,0 at detector corner, Scopesim uses 0,0 at detector center - xcent = (np.min(xvals) + (np.max(xvals) - np.min(xvals))/2) * u.mm - ycent = (np.min(yvals) + (np.max(yvals) - np.min(yvals))/2) * u.mm + def on_padded_detector(wave, order): + xpix, ypix = rotated_detector_pixels(wave, order) + return ( + (xpix >= detector_pad) + & (xpix <= disp_npix - detector_pad) + & (ypix >= detector_pad) + & (ypix <= xdisp_npix - detector_pad) + ) for i, order in enumerate(ss.orders): - wave = edges[i] - wave = np.linspace(wave[0], wave[-1], num=max(int(disp_npix*.1), 2)) + candidate_wave = np.linspace( + edges[i][0], edges[i][-1], num=max(disp_npix, 2)) + valid_wave = np.any( + on_padded_detector(candidate_wave, order), axis=0) + valid_indices = np.flatnonzero(valid_wave) + if valid_indices.size == 0: + logger.debug( + "Skipping analytical trace %s_%d: no samples inside " + "the rotated, padded detector rectangle.", + prefix, order, + ) + continue + wave = np.linspace( + candidate_wave[valid_indices[0]], + candidate_wave[valid_indices[-1]], + num=max(int((disp_npix - 2 * detector_pad) * .1), 2), + ) + valid_wave = np.any(on_padded_detector(wave, order), axis=0) + wave = wave[valid_wave] + if wave.size < 2: + logger.debug( + "Skipping analytical trace %s_%d: fewer than two " + "samples after rotated detector clipping.", + prefix, order, + ) + continue s = np.tile(slit_pos, wave.size).reshape(wave.size, slit_pos.size).T.ravel() w = np.tile(wave, slit_offset_pix.size) - xval = xvals[i] - xcent # Centering on 0,0 at detector center - yval = yvals[i] - ycent # Centering on 0,0 at detector center - if detector_angle: - x0, y0 = xval, yval - xval = cang * x0 - sang * y0 - yval = sang * x0 + cang * y0 + xpix, ypix = rotated_detector_pixels(wave, order) + xval = (xpix.ravel() - disp_npix / 2) * pix_size.to(u.mm) + yval = (ypix.ravel() - xdisp_npix / 2) * pix_size.to(u.mm) order_table = Table( {'wavelength': w.to(u.um), 's': s, @@ -683,6 +722,10 @@ def _generate_trace_hdulist(self, trace_params): float(pix_per_res_elem), "Analytical nominal FWHM [pix]") trace_hdu.header["PIXSIZE"] = ( pix_size.to_value(u.mm), "Detector pixel size [mm]") + trace_hdu.header["DETPAD"] = ( + detector_pad, "Detector-frame padding [pix]") + trace_hdu.header["DETANG"] = ( + detector_angle, "Detector rotation angle [deg]") if "nominal_slit_width" in trace_params.table.colnames: trace_hdu.header["SLITWID"] = ( float(row["nominal_slit_width"]), @@ -691,6 +734,20 @@ def _generate_trace_hdulist(self, trace_params): trace_hdu.header["PLTSCALE"] = ( plate_scale.to_value(u.arcsec / u.mm), "Analytical sky-to-image plate scale [arcsec/mm]") + trace_ids.append(f'{prefix}_{order:d}') + ap_ids.append(row["aperture_id"]) + im_ids.append(row["image_plane_id"]) hdul.append(trace_hdu) + if not trace_ids: + raise ValueError( + "Analytical echelle trace generation produced no detector " + "intersecting traces.") + hdul.insert(1, fits.BinTableHDU(Table( + {'description': trace_ids, + 'extension_id': np.arange(len(trace_ids), dtype=int)+2, + 'aperture_id': ap_ids, + 'image_plane_id': im_ids + }))) + return hdul diff --git a/scopesim/effects/spectral_trace_list_utils.py b/scopesim/effects/spectral_trace_list_utils.py index 0171e14b6..f101929cd 100644 --- a/scopesim/effects/spectral_trace_list_utils.py +++ b/scopesim/effects/spectral_trace_list_utils.py @@ -102,6 +102,8 @@ def __init__(self, trace_tbl, cmds=None, **kwargs): ("PIXSIZE", "pixel_size"), ("SLITWID", "nominal_slit_width"), ("PLTSCALE", "plate_scale"), + ("DETPAD", "detector_pad"), + ("DETANG", "detector_angle"), ): if header_key in trace_tbl.header: self.meta[meta_key] = trace_tbl.header[header_key] diff --git a/scopesim/tests/tests_effects/test_SpectralTraceList.py b/scopesim/tests/tests_effects/test_SpectralTraceList.py index b22582948..9b1ad6fe7 100644 --- a/scopesim/tests/tests_effects/test_SpectralTraceList.py +++ b/scopesim/tests/tests_effects/test_SpectralTraceList.py @@ -3,6 +3,8 @@ import pytest from unittest.mock import patch +import numpy as np +from astropy import units as u from astropy.io import fits @@ -104,7 +106,10 @@ def test_basic_init(self): assert stw.trace_lists["foo"].meta["filename"] == "bogus_foo" -def _write_echelle_trace_params(path): +def _write_echelle_trace_params(path, detector_angle=None): + angle_unit = "# detector_angle_unit : deg\n" if detector_angle is not None else "" + angle_col = " detector_angle" if detector_angle is not None else "" + angle_value = f" {detector_angle}" if detector_angle is not None else "" path.write_text( "# min_wave_unit : nm\n" "# max_wave_unit : nm\n" @@ -118,19 +123,20 @@ def _write_echelle_trace_params(path): "# disp_freq_unit : mm\n" "# xdisp_freq_unit : mm\n" "# slitlength_unit : arcsec\n" + f"{angle_unit}" "prefix aperture_id image_plane_id m0 n min_wave max_wave " "design_res echelle_blaze focal_length fwhm detector_pad " "pixel_size n_disp n_xdisp disp_freq xdisp_freq slitlength " - "dispdir xbeta_center\n" + f"dispdir xbeta_center{angle_col}\n" "b 0 0 91 0 310 420 17799 65.6 225 4.5 10 0.015 " - "128 128 65.0 1.0 10 x 0\n", + f"128 128 65.0 1.0 10 x 0{angle_value}\n", encoding="utf-8", ) def _echelle_cmds(): cmds = UserCommands() - cmds["!INST.pixel_scale"] = 0.004 + cmds["!INST.pixel_scale"] = 0.2 return cmds @@ -164,3 +170,48 @@ def test_writes_generated_hdulist_to_working_dir_when_requested( ) assert (tmp_path / "analytical_echelle_traces.fits").exists() + + def test_detector_angle_is_clipped_after_detector_padding(self, tmp_path): + params_file = tmp_path / "echelle_trace_parameters.dat" + _write_echelle_trace_params(params_file, detector_angle=25) + + spt = EchelleSpectralTraceList( + cmds=_echelle_cmds(), + filename=str(params_file), + wave_colname="wavelength", + s_colname="s", + ) + + trace = next(iter(spt.spectral_traces.values())) + n_wave = len(trace.table) // 3 + xpix = ( + u.Quantity(trace.table["x"]).to_value(u.mm) / 0.015 + 64 + ).reshape(3, n_wave) + ypix = ( + u.Quantity(trace.table["y"]).to_value(u.mm) / 0.015 + 64 + ).reshape(3, n_wave) + inside = ( + (xpix >= 10) + & (xpix <= 128 - 10) + & (ypix >= 10) + & (ypix <= 128 - 10) + ) + + assert np.all(np.any(inside, axis=0)) + assert trace.meta["detector_angle"] == 25 + assert trace.meta["detector_pad"] == 10 + + unrotated_file = tmp_path / "echelle_trace_parameters_unrotated.dat" + _write_echelle_trace_params(unrotated_file, detector_angle=0) + unrotated = EchelleSpectralTraceList( + cmds=_echelle_cmds(), + filename=str(unrotated_file), + wave_colname="wavelength", + s_colname="s", + ) + unrotated_trace = next(iter(unrotated.spectral_traces.values())) + + assert ( + abs(trace.wave_min - unrotated_trace.wave_min) > 1e-8 + or abs(trace.wave_max - unrotated_trace.wave_max) > 1e-8 + ) From a0f95d50c928321e3376152d956d500d63d4445b Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Wed, 1 Jul 2026 17:35:03 -0700 Subject: [PATCH 24/43] Stabilize spectral trace transform fits --- scopesim/effects/spectral_trace_list_utils.py | 104 ++++++++++++++---- .../test_SpectralTraceListUtils.py | 43 +++++++- 2 files changed, 127 insertions(+), 20 deletions(-) diff --git a/scopesim/effects/spectral_trace_list_utils.py b/scopesim/effects/spectral_trace_list_utils.py index f101929cd..7a15fd9cc 100644 --- a/scopesim/effects/spectral_trace_list_utils.py +++ b/scopesim/effects/spectral_trace_list_utils.py @@ -865,12 +865,15 @@ def rescale(x, scale=1.): """ def __init__(self, matrix, pretransform_x=None, - pretransform_y=None, posttransform=None): + pretransform_y=None, posttransform=None, + dpretransform_x=1.0, dpretransform_y=1.0): self.matrix = np.asarray(matrix) self.ny, self.nx = self.matrix.shape self.pretransform_x = self._repackage(pretransform_x) self.pretransform_y = self._repackage(pretransform_y) self.posttransform = self._repackage(posttransform) + self.dpretransform_x = dpretransform_x + self.dpretransform_y = dpretransform_y def _repackage(self, trafo): """Make sure `trafo` is a tuple.""" @@ -904,12 +907,15 @@ def __call__(self, x, y, grid=False, **kwargs): in x and y. When grid=False, a vector. In this case, x and y must have the same length. """ + pretransform_x = self.pretransform_x + pretransform_y = self.pretransform_y + posttransform = self.posttransform if "pretransform_x" in kwargs: - self.pretransform_x = self._repackage(kwargs["pretransform_x"]) + pretransform_x = self._repackage(kwargs["pretransform_x"]) if "pretransform_y" in kwargs: - self.pretransform_y = self._repackage(kwargs["pretransform_y"]) + pretransform_y = self._repackage(kwargs["pretransform_y"]) if "posttransform" in kwargs: - self.posttransform = self._repackage(kwargs["posttransform"]) + posttransform = self._repackage(kwargs["posttransform"]) x = np.array(x) y = np.array(y) @@ -920,10 +926,10 @@ def __call__(self, x, y, grid=False, **kwargs): "is False") # Apply pre transforms - if self.pretransform_x is not None: - x = self.pretransform_x[0](x, **self.pretransform_x[1]) - if self.pretransform_y is not None: - y = self.pretransform_y[0](y, **self.pretransform_y[1]) + if pretransform_x is not None: + x = pretransform_x[0](x, **pretransform_x[1]) + if pretransform_y is not None: + y = pretransform_y[0](y, **pretransform_y[1]) xvec = power_vector(x.flatten(), self.nx - 1) yvec = power_vector(y.flatten(), self.ny - 1) @@ -938,32 +944,92 @@ def __call__(self, x, y, grid=False, **kwargs): # expression in the "grid" branch. result = (yvec * temp).sum(axis=0) if not orig_shape: - result = np.float32(result) + result = result.item() else: result = result.reshape(orig_shape) # Apply posttransform - if self.posttransform is not None: - result = self.posttransform[0](result, **self.posttransform[1]) + if posttransform is not None: + result = posttransform[0](result, **posttransform[1]) return result @classmethod - def fit(cls, xin, yin, xout, degree=4): + def fit(cls, xin, yin, xout, degree=4, normalize=True): """Determine polynomial fits.""" + xin = np.asarray(xin, dtype=float) + yin = np.asarray(yin, dtype=float) + xout = np.asarray(xout, dtype=float) + pretransform_x = pretransform_y = None + dpretransform_x = dpretransform_y = 1.0 + if normalize: + x_offset, x_scale = _fit_normalization(xin) + y_offset, y_scale = _fit_normalization(yin) + xin_fit = _linear_rescale(xin, x_offset, x_scale) + yin_fit = _linear_rescale(yin, y_offset, y_scale) + pretransform_x = ( + _linear_rescale, + {"offset": x_offset, "scale": x_scale}, + ) + pretransform_y = ( + _linear_rescale, + {"offset": y_offset, "scale": y_scale}, + ) + dpretransform_x = 1.0 / x_scale + dpretransform_y = 1.0 / y_scale + else: + xin_fit = xin + yin_fit = yin pinit = Polynomial2D(degree=degree) fitter = fitting.LinearLSQFitter() - fit = fitter(pinit, xin, yin, xout) - return Transform2D(fit2matrix(fit)) + fit = fitter(pinit, xin_fit, yin_fit, xout) + return Transform2D( + fit2matrix(fit), + pretransform_x=pretransform_x, + pretransform_y=pretransform_y, + dpretransform_x=dpretransform_x, + dpretransform_y=dpretransform_y, + ) def gradient(self): """Compute the gradient of a 2d polynomial transformation.""" mat = self.matrix - dmat_x = (mat * np.arange(self.nx))[:, 1:] - dmat_y = (mat.T * np.arange(self.ny)).T[1:, :] - - return Transform2D(dmat_x), Transform2D(dmat_y) + dmat_x = (mat * np.arange(self.nx))[:, 1:] * self.dpretransform_x + dmat_y = (mat.T * np.arange(self.ny)).T[1:, :] * self.dpretransform_y + + return ( + Transform2D( + dmat_x, + pretransform_x=self.pretransform_x, + pretransform_y=self.pretransform_y, + ), + Transform2D( + dmat_y, + pretransform_x=self.pretransform_x, + pretransform_y=self.pretransform_y, + ), + ) + + +def _fit_normalization(values): + """Return a stable offset and scale for polynomial fitting.""" + values = np.asarray(values, dtype=float) + finite = values[np.isfinite(values)] + if finite.size == 0: + return 0.0, 1.0 + minimum = float(np.nanmin(finite)) + maximum = float(np.nanmax(finite)) + offset = minimum + (maximum - minimum) / 2 + scale = (maximum - minimum) / 2 + if not np.isfinite(scale) or scale == 0: + scale = 1.0 + return offset, scale + + +def _linear_rescale(values, offset=0.0, scale=1.0): + """Linearly rescale values for polynomial fitting/evaluation.""" + return (np.asarray(values, dtype=float) - offset) / scale def fit2matrix(fit): @@ -976,7 +1042,7 @@ def fit2matrix(fit): """ coeffs = dict(zip(fit.param_names, fit.parameters)) deg = fit.degree - mat = np.zeros((deg + 1, deg + 1), dtype=np.float32) + mat = np.zeros((deg + 1, deg + 1), dtype=float) for i in range(deg + 1): for j in range(deg + 1): try: diff --git a/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py b/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py index b5f10e0af..6fe64902c 100644 --- a/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py +++ b/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py @@ -204,10 +204,51 @@ def test_fit_gives_correct_matrix(self): zz = 1. + xx - yy matrix = np.array([[1, 1], [-1, 0]]) - tf2d = Transform2D.fit(xx, yy, zz, degree=1) + tf2d = Transform2D.fit(xx, yy, zz, degree=1, normalize=False) assert tf2d.matrix == pytest.approx(matrix) + def test_fit_preserves_high_dynamic_range_coefficients(self): + x_grid, y_grid = np.meshgrid( + np.linspace(-5, 5, 3), + np.linspace(0.67, 0.69, 200), + indexing="ij", + ) + x = x_grid.ravel() + y = y_grid.ravel() + z = ( + 1e8 * y**4 + - 2e7 * y**3 + + 3e5 * y**2 + + 15 * x * y + + 0.2 * x + ) + + tf2d = Transform2D.fit(x, y, z, degree=4) + + assert tf2d.matrix.dtype == np.float64 + assert np.max(np.abs(tf2d(x, y) - z)) < 1e-5 + + def test_gradient_with_normalized_fit_uses_input_units(self): + x_grid, y_grid = np.meshgrid( + np.linspace(-5, 5, 5), + np.linspace(0.67, 0.69, 20), + indexing="ij", + ) + x = x_grid.ravel() + y = y_grid.ravel() + z = 3 + 2 * x + 5 * y + 7 * x * y + 11 * y**2 + + tf2d = Transform2D.fit(x, y, z, degree=2) + dz_dx, dz_dy = tf2d.gradient() + + assert dz_dx(x, y) == pytest.approx(2 + 7 * y, rel=1e-10, abs=1e-10) + assert dz_dy(x, y) == pytest.approx( + 5 + 7 * x + 22 * y, + rel=1e-10, + abs=1e-10, + ) + def test_grid_false_shape_is_preserved(self, tf2d): n_x, n_y = 4, 2 res = tf2d(np.ones((n_y, n_x)), np.ones((n_y, n_x)), grid=False) From 1616827d7f06b175467fb0675169c3040f09eb5f Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Wed, 1 Jul 2026 17:35:09 -0700 Subject: [PATCH 25/43] Keep analytical echelle padding out of trace geometry --- scopesim/effects/spectral_trace_list.py | 33 +++++++++------- .../tests_effects/test_SpectralTraceList.py | 39 +++++++++++++------ 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/scopesim/effects/spectral_trace_list.py b/scopesim/effects/spectral_trace_list.py index b229bda5c..99954047d 100644 --- a/scopesim/effects/spectral_trace_list.py +++ b/scopesim/effects/spectral_trace_list.py @@ -590,7 +590,10 @@ def _generate_trace_hdulist(self, trace_params): focal_len = row['focal_length'] * u.Unit(trace_params.meta["focal_length_unit"]) disp_npix = int(row['n_disp']) xdisp_npix = int(row['n_xdisp']) - detector_pad = int(row['detector_pad']) + detector_pad = ( + int(row['detector_pad']) + if 'detector_pad' in trace_params.table.colnames else 0 + ) pix_size = row['pixel_size'] * u.Unit(trace_params.meta["pixel_size_unit"]) echelle_angle = np.deg2rad(row['echelle_blaze'])*u.rad xdisp_beta_center = np.deg2rad(row['xbeta_center'])*u.rad @@ -642,8 +645,10 @@ def raw_detector_pixels(wave, order): rotated_x_min = rotated_y_min = np.inf rotated_x_max = rotated_y_max = -np.inf # The analytical echelle layout is relative, not yet detector - # placed. Rotate the footprint first, place that rotated footprint - # on the detector, then apply detector-frame padding below. + # placed. Rotate the footprint first, then place that rotated + # footprint on the detector. Detector padding is not part of the + # optical trace geometry; downstream detector/FOV code is + # responsible for clipping padded display or extraction regions. for i, order in enumerate(ss.orders): wave = np.linspace( edges[i][0], edges[i][-1], num=max(disp_npix, 2)) @@ -666,39 +671,39 @@ def rotated_detector_pixels(wave, order): yrot - rotated_y_center + xdisp_npix / 2, ) - def on_padded_detector(wave, order): + def on_detector(wave, order): xpix, ypix = rotated_detector_pixels(wave, order) return ( - (xpix >= detector_pad) - & (xpix <= disp_npix - detector_pad) - & (ypix >= detector_pad) - & (ypix <= xdisp_npix - detector_pad) + (xpix >= 0) + & (xpix <= disp_npix) + & (ypix >= 0) + & (ypix <= xdisp_npix) ) for i, order in enumerate(ss.orders): candidate_wave = np.linspace( edges[i][0], edges[i][-1], num=max(disp_npix, 2)) valid_wave = np.any( - on_padded_detector(candidate_wave, order), axis=0) + on_detector(candidate_wave, order), axis=0) valid_indices = np.flatnonzero(valid_wave) if valid_indices.size == 0: logger.debug( "Skipping analytical trace %s_%d: no samples inside " - "the rotated, padded detector rectangle.", + "the rotated detector rectangle.", prefix, order, ) continue wave = np.linspace( candidate_wave[valid_indices[0]], candidate_wave[valid_indices[-1]], - num=max(int((disp_npix - 2 * detector_pad) * .1), 2), + num=max(int(disp_npix * .1), 2), ) - valid_wave = np.any(on_padded_detector(wave, order), axis=0) + valid_wave = np.any(on_detector(wave, order), axis=0) wave = wave[valid_wave] if wave.size < 2: logger.debug( "Skipping analytical trace %s_%d: fewer than two " - "samples after rotated detector clipping.", + "samples after rotated detector-edge clipping.", prefix, order, ) continue @@ -723,7 +728,7 @@ def on_padded_detector(wave, order): trace_hdu.header["PIXSIZE"] = ( pix_size.to_value(u.mm), "Detector pixel size [mm]") trace_hdu.header["DETPAD"] = ( - detector_pad, "Detector-frame padding [pix]") + detector_pad, "Legacy detector padding [pix]; not applied") trace_hdu.header["DETANG"] = ( detector_angle, "Detector rotation angle [deg]") if "nominal_slit_width" in trace_params.table.colnames: diff --git a/scopesim/tests/tests_effects/test_SpectralTraceList.py b/scopesim/tests/tests_effects/test_SpectralTraceList.py index 9b1ad6fe7..542c367a3 100644 --- a/scopesim/tests/tests_effects/test_SpectralTraceList.py +++ b/scopesim/tests/tests_effects/test_SpectralTraceList.py @@ -106,7 +106,7 @@ def test_basic_init(self): assert stw.trace_lists["foo"].meta["filename"] == "bogus_foo" -def _write_echelle_trace_params(path, detector_angle=None): +def _write_echelle_trace_params(path, detector_angle=None, detector_pad=10): angle_unit = "# detector_angle_unit : deg\n" if detector_angle is not None else "" angle_col = " detector_angle" if detector_angle is not None else "" angle_value = f" {detector_angle}" if detector_angle is not None else "" @@ -128,7 +128,7 @@ def _write_echelle_trace_params(path, detector_angle=None): "design_res echelle_blaze focal_length fwhm detector_pad " "pixel_size n_disp n_xdisp disp_freq xdisp_freq slitlength " f"dispdir xbeta_center{angle_col}\n" - "b 0 0 91 0 310 420 17799 65.6 225 4.5 10 0.015 " + f"b 0 0 91 0 310 420 17799 65.6 225 4.5 {detector_pad} 0.015 " f"128 128 65.0 1.0 10 x 0{angle_value}\n", encoding="utf-8", ) @@ -171,7 +171,7 @@ def test_writes_generated_hdulist_to_working_dir_when_requested( assert (tmp_path / "analytical_echelle_traces.fits").exists() - def test_detector_angle_is_clipped_after_detector_padding(self, tmp_path): + def test_detector_angle_clips_to_detector_not_padding(self, tmp_path): params_file = tmp_path / "echelle_trace_parameters.dat" _write_echelle_trace_params(params_file, detector_angle=25) @@ -191,16 +191,33 @@ def test_detector_angle_is_clipped_after_detector_padding(self, tmp_path): u.Quantity(trace.table["y"]).to_value(u.mm) / 0.015 + 64 ).reshape(3, n_wave) inside = ( - (xpix >= 10) - & (xpix <= 128 - 10) - & (ypix >= 10) - & (ypix <= 128 - 10) + (xpix >= 0) + & (xpix <= 128) + & (ypix >= 0) + & (ypix <= 128) ) assert np.all(np.any(inside, axis=0)) assert trace.meta["detector_angle"] == 25 assert trace.meta["detector_pad"] == 10 + padded_file = tmp_path / "echelle_trace_parameters_more_padding.dat" + _write_echelle_trace_params( + padded_file, + detector_angle=25, + detector_pad=30, + ) + padded = EchelleSpectralTraceList( + cmds=_echelle_cmds(), + filename=str(padded_file), + wave_colname="wavelength", + s_colname="s", + ) + padded_trace = next(iter(padded.spectral_traces.values())) + + assert trace.wave_min == pytest.approx(padded_trace.wave_min) + assert trace.wave_max == pytest.approx(padded_trace.wave_max) + unrotated_file = tmp_path / "echelle_trace_parameters_unrotated.dat" _write_echelle_trace_params(unrotated_file, detector_angle=0) unrotated = EchelleSpectralTraceList( @@ -211,7 +228,7 @@ def test_detector_angle_is_clipped_after_detector_padding(self, tmp_path): ) unrotated_trace = next(iter(unrotated.spectral_traces.values())) - assert ( - abs(trace.wave_min - unrotated_trace.wave_min) > 1e-8 - or abs(trace.wave_max - unrotated_trace.wave_max) > 1e-8 - ) + assert trace.wave_min == pytest.approx(unrotated_trace.wave_min) + assert trace.wave_max == pytest.approx(unrotated_trace.wave_max) + assert not np.allclose(trace.table["x"], unrotated_trace.table["x"]) + assert not np.allclose(trace.table["y"], unrotated_trace.table["y"]) From 6b7c3eb2f18dbfece1d986297e7b657c04b8f5c0 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Wed, 1 Jul 2026 18:24:52 -0700 Subject: [PATCH 26/43] Refine spectral trace logic by removing unused edge wave calculations and introducing padding adjustments for raw detector pixel ranges. --- scopesim/effects/spectral_trace_list.py | 33 ++++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/scopesim/effects/spectral_trace_list.py b/scopesim/effects/spectral_trace_list.py index 99954047d..7fbbdac26 100644 --- a/scopesim/effects/spectral_trace_list.py +++ b/scopesim/effects/spectral_trace_list.py @@ -608,8 +608,6 @@ def _generate_trace_hdulist(self, trace_params): pix_size, xdisp_groove_length=xdisp_groove_length, xdisp_beta_center=xdisp_beta_center) - edges = ss.edge_wave(fsr=False) - slit_edge = (row['slitlength'] / 2) * u.Unit(trace_params.meta["slitlength_unit"]) slit_pos = np.linspace(-slit_edge, slit_edge, num=3) if "plate_scale" in trace_params.table.colnames: @@ -630,6 +628,20 @@ def _generate_trace_hdulist(self, trace_params): ).to_value(u.deg) cang = np.cos(np.deg2rad(detector_angle)) sang = np.sin(np.deg2rad(detector_angle)) + detector_x_padding = max( + 0.0, + ( + disp_npix * abs(cang) + + xdisp_npix * abs(sang) + - disp_npix + ) / 2, + ) + slit_x_padding = ( + abs(np.tan(np.deg2rad(detector_angle))) + * float(np.nanmax(np.abs(slit_offset_pix))) + ) + raw_x_min = 0.5 - detector_x_padding - slit_x_padding + raw_x_max = disp_npix - 0.5 + detector_x_padding + slit_x_padding def raw_detector_pixels(wave, order): x = ss.wavelength_to_x_pixel(wave, order) @@ -650,8 +662,9 @@ def raw_detector_pixels(wave, order): # optical trace geometry; downstream detector/FOV code is # responsible for clipping padded display or extraction regions. for i, order in enumerate(ss.orders): - wave = np.linspace( - edges[i][0], edges[i][-1], num=max(disp_npix, 2)) + raw_x = np.linspace( + raw_x_min, raw_x_max, num=max(disp_npix, 2)) + wave = ss.x_pixel_to_wavelength(raw_x, order) xpix, ypix = raw_detector_pixels(wave, order) xrot = cang * xpix - sang * ypix yrot = sang * xpix + cang * ypix @@ -681,8 +694,9 @@ def on_detector(wave, order): ) for i, order in enumerate(ss.orders): - candidate_wave = np.linspace( - edges[i][0], edges[i][-1], num=max(disp_npix, 2)) + candidate_raw_x = np.linspace( + raw_x_min, raw_x_max, num=max(disp_npix, 2)) + candidate_wave = ss.x_pixel_to_wavelength(candidate_raw_x, order) valid_wave = np.any( on_detector(candidate_wave, order), axis=0) valid_indices = np.flatnonzero(valid_wave) @@ -693,11 +707,12 @@ def on_detector(wave, order): prefix, order, ) continue - wave = np.linspace( - candidate_wave[valid_indices[0]], - candidate_wave[valid_indices[-1]], + raw_x = np.linspace( + candidate_raw_x[valid_indices[0]], + candidate_raw_x[valid_indices[-1]], num=max(int(disp_npix * .1), 2), ) + wave = ss.x_pixel_to_wavelength(raw_x, order) valid_wave = np.any(on_detector(wave, order), axis=0) wave = wave[valid_wave] if wave.size < 2: From 7f3c4a54f9b30cf0007aa251b83565739b324f7c Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Fri, 3 Jul 2026 21:27:16 +0200 Subject: [PATCH 27/43] Update with full instrument design parameters, fixing focal length issue in echelle and updating dichroics. --- scopesim/effects/spectral_trace_list.py | 71 +++++++++++++++++-- scopesim/effects/spectral_trace_list_utils.py | 1 + scopesim/optics/echelle.py | 31 ++++++-- 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/scopesim/effects/spectral_trace_list.py b/scopesim/effects/spectral_trace_list.py index 7fbbdac26..3a2cfbd17 100644 --- a/scopesim/effects/spectral_trace_list.py +++ b/scopesim/effects/spectral_trace_list.py @@ -517,6 +517,47 @@ def current_trace_list(self): return trace_list_eff +def _warn_if_echelle_design_res_inconsistent( + prefix, + design_res, + echelle_angle, + pix_per_res_elem, + pix_size, + dispersion_focal_len, + tolerance=0.05): + """Warn if analytical order-center R is inconsistent with design_res.""" + try: + design_res = float(design_res) + pix_per_res_elem = float(pix_per_res_elem) + pix_size = u.Quantity(pix_size).to(u.mm) + dispersion_focal_len = u.Quantity(dispersion_focal_len).to(u.mm) + echelle_angle_rad = u.Quantity(echelle_angle).to_value(u.rad) + except (TypeError, ValueError, u.UnitConversionError): + return + if ( + not np.isfinite(design_res) or design_res <= 0 + or not np.isfinite(pix_per_res_elem) or pix_per_res_elem <= 0 + or dispersion_focal_len <= 0 * u.mm + or pix_size <= 0 * u.mm): + return + + implied_res = ( + 2.0 * np.tan(echelle_angle_rad) + * (dispersion_focal_len / pix_size).to_value(u.dimensionless_unscaled) + / pix_per_res_elem + ) + relative_delta = abs(implied_res - design_res) / design_res + if relative_delta > tolerance: + logger.warning( + "Analytical echelle row %s design_res %.0f differs from " + "order-center R %.0f implied by echelle angle, pixel size, " + "FWHM, and dispersion focal length.", + prefix, + design_res, + implied_res, + ) + + class EchelleSpectralTraceList(SpectralTraceList): """ SpectralTraceList effect for echelle spectrographs. Unlike SpectralTraceList, it generates the trace definitions @@ -529,6 +570,7 @@ class EchelleSpectralTraceList(SpectralTraceList): # max_wave_unit : nm # echelle_blaze_unit : deg # focal_length_unit : mm + # dispersion_focal_length_unit : mm # fwhm_unit : pixel # nominal_slit_width_unit : arcsec # plate_scale_unit : arcsec/mm @@ -540,10 +582,10 @@ class EchelleSpectralTraceList(SpectralTraceList): # xdisp_freq_unit : mm # slitwidth_unit : arcsec - prefix aperture_id image_plane_id m0 n min_wave max_wave design_res echelle_blaze focal_length fwhm nominal_slit_width plate_scale detector_pad pixel_size n_disp n_xdisp disp_freq xdisp_freq slitwidth dispdir - ub 0 2 29 11 315 515 20000 64.2 225 4.7 0.7 10.0 10 0.015 4096 4096 200 1000 10 x - gri 1 1 36 18 490 1020 20000 64.2 225 4.7 0.7 10.0 10 0.015 4096 4096 100 500 10 x - nIR 2 0 40 24 970 2500 20000 64.2 225 4.7 0.7 10.0 10 0.015 4096 4096 45 175 10 x + prefix aperture_id image_plane_id m0 n min_wave max_wave design_res echelle_blaze focal_length dispersion_focal_length fwhm nominal_slit_width plate_scale detector_pad pixel_size n_disp n_xdisp disp_freq xdisp_freq slitwidth dispdir + ub 0 2 29 11 315 515 20000 64.2 225 225 4.7 0.7 10.0 10 0.015 4096 4096 200 1000 10 x + gri 1 1 36 18 490 1020 20000 64.2 225 225 4.7 0.7 10.0 10 0.015 4096 4096 100 500 10 x + nIR 2 0 40 24 970 2500 20000 64.2 225 225 4.7 0.7 10.0 10 0.015 4096 4096 45 175 10 x ---------------------------------------------------------------- The calculated traces are stored in the same HDUList format as required by SpectralTraceList, @@ -588,6 +630,13 @@ def _generate_trace_hdulist(self, trace_params): max_wave = row['max_wave'] * u.Unit(trace_params.meta["max_wave_unit"]) design_res = row['design_res'] focal_len = row['focal_length'] * u.Unit(trace_params.meta["focal_length_unit"]) + dispersion_focal_len = None + if "dispersion_focal_length" in trace_params.table.colnames: + dispersion_focal_len = row["dispersion_focal_length"] * u.Unit( + trace_params.meta.get( + "dispersion_focal_length_unit", + trace_params.meta["focal_length_unit"], + )) disp_npix = int(row['n_disp']) xdisp_npix = int(row['n_xdisp']) detector_pad = ( @@ -606,7 +655,16 @@ def _generate_trace_hdulist(self, trace_params): design_res, echelle_angle, min_order, max_order, echelle_groove_length, pix_per_res_elem, disp_npix, xdisp_npix, pix_size, xdisp_groove_length=xdisp_groove_length, - xdisp_beta_center=xdisp_beta_center) + xdisp_beta_center=xdisp_beta_center, + dispersion_focal_len=dispersion_focal_len) + _warn_if_echelle_design_res_inconsistent( + prefix, + design_res, + echelle_angle, + pix_per_res_elem, + pix_size, + ss.dispersion_focal_length, + ) slit_edge = (row['slitlength'] / 2) * u.Unit(trace_params.meta["slitlength_unit"]) slit_pos = np.linspace(-slit_edge, slit_edge, num=3) @@ -742,6 +800,9 @@ def on_detector(wave, order): float(pix_per_res_elem), "Analytical nominal FWHM [pix]") trace_hdu.header["PIXSIZE"] = ( pix_size.to_value(u.mm), "Detector pixel size [mm]") + trace_hdu.header["DISPFLEN"] = ( + ss.dispersion_focal_length.to_value(u.mm), + "Effective echelle dispersion focal length [mm]") trace_hdu.header["DETPAD"] = ( detector_pad, "Legacy detector padding [pix]; not applied") trace_hdu.header["DETANG"] = ( diff --git a/scopesim/effects/spectral_trace_list_utils.py b/scopesim/effects/spectral_trace_list_utils.py index 7a15fd9cc..d65eaf37d 100644 --- a/scopesim/effects/spectral_trace_list_utils.py +++ b/scopesim/effects/spectral_trace_list_utils.py @@ -100,6 +100,7 @@ def __init__(self, trace_tbl, cmds=None, **kwargs): ("DESIGNR", "design_res"), ("FWHMPIX", "nominal_fwhm_pix"), ("PIXSIZE", "pixel_size"), + ("DISPFLEN", "dispersion_focal_length"), ("SLITWID", "nominal_slit_width"), ("PLTSCALE", "plate_scale"), ("DETPAD", "detector_pad"), diff --git a/scopesim/optics/echelle.py b/scopesim/optics/echelle.py index cba975099..4c7614e63 100644 --- a/scopesim/optics/echelle.py +++ b/scopesim/optics/echelle.py @@ -10,7 +10,8 @@ def spectrograph_factory(min_wave: float|u.Quantity, max_wave: float|u.Quantity, design_res: float, echelle_angle: float|u.Quantity, min_order: int, max_order: int, echelle_groove_length: float|u.Quantity, pix_per_res_elem: float, disp_npix: int, xdisp_npix: int, pix_size: float|u.Quantity, - xdisp_groove_length: float|u.Quantity = 0.0, xdisp_beta_center: float|u.Quantity = 0.0): + xdisp_groove_length: float|u.Quantity = 0.0, xdisp_beta_center: float|u.Quantity = 0.0, + dispersion_focal_len: float|u.Quantity = None): """ Parameters @@ -43,6 +44,9 @@ def spectrograph_factory(min_wave: float|u.Quantity, max_wave: float|u.Quantity, Cross disperser groove length. If float, assumed to be in mm. xdisp_beta_center: float|u.Quantity Cross disperser beta center angle. If float, assumed to be in degrees. + dispersion_focal_len: float|u.Quantity + Effective focal length for the echelle/main-dispersion detector + coordinate. If float, assumed to be in mm. Defaults to focal_len. Returns ------- @@ -65,6 +69,10 @@ def spectrograph_factory(min_wave: float|u.Quantity, max_wave: float|u.Quantity, xdisp_groove_length = xdisp_groove_length * u.mm if not isinstance(xdisp_beta_center, u.Quantity): xdisp_beta_center = xdisp_beta_center * u.deg + if dispersion_focal_len is None: + dispersion_focal_len = focal_len + elif not isinstance(dispersion_focal_len, u.Quantity): + dispersion_focal_len = dispersion_focal_len * u.mm x_disp_len = (xdisp_npix*pix_size).to(u.mm) @@ -89,6 +97,7 @@ def spectrograph_factory(min_wave: float|u.Quantity, max_wave: float|u.Quantity, design_res=design_res, pixels_per_res_elem=pix_per_res_elem, focal_length=focal_len, + dispersion_focal_length=dispersion_focal_len, grating=echelle_grating, detector=Detector(disp_npix, xdisp_npix, pix_size), cross_disperser=cross_disperser) @@ -354,6 +363,7 @@ def __init__( grating: GratingSetup, detector: Detector, cross_disperser: GratingSetup = None, + dispersion_focal_length: u.Quantity = None, ): """ @@ -365,6 +375,10 @@ def __init__( # :param u.Quantity final_wave: longest wavelength at the edge of detector :param float pixels_per_res_elem: number of pixels per resolution element of spectrometer :param u.Quantity focal_length: the focal length of the detector + :param u.Quantity dispersion_focal_length: effective focal length for the + echelle/main-dispersion detector coordinate. This can differ from + focal_length when the prescription has already folded relay + magnification into the dispersion scale. :param GratingSetup grating: configured grating :param MKIDDetector detector: configured detector :return spectrograph simulation object @@ -379,7 +393,13 @@ def __init__( self.grating = grating self.detector = detector self.focal_length = focal_length.to(u.mm) - self.pixel_scale = np.arctan(self.detector.pixel_size / self.focal_length) + self.dispersion_focal_length = ( + self.focal_length + if dispersion_focal_length is None + else dispersion_focal_length.to(u.mm) + ) + self.pixel_scale = np.arctan( + self.detector.pixel_size / self.dispersion_focal_length) self.beta_central_pixel = self.grating.beta_center self.nord = int(self.m_max - self.m0 + 1) self.nominal_pixels_per_res_elem = pixels_per_res_elem @@ -392,6 +412,7 @@ def __init__( # f'\n\tR0: {self.detector.design_R0}' f'\n\tOrders: {self.orders}' f'\n\tFocal length: {self.focal_length}' + f'\n\tDispersion focal length: {self.dispersion_focal_length}' f'\n\tIncidence angle: {np.rad2deg(self.grating.alpha):.3f}' f'\n\tReflectance angle: {np.rad2deg(self.beta_central_pixel):.2f}' f'\n\tGroove length: {self.grating.d:.2f}' @@ -448,7 +469,8 @@ def x_pixel_for_beta(self, beta): :return: pixel at beta """ delta_angle = np.tan(beta - self.beta_central_pixel) - return self.focal_length * delta_angle / self.detector.pixel_size + self.detector.n_pix_x / 2 + return (self.dispersion_focal_length * delta_angle / + self.detector.pixel_size + self.detector.n_pix_x / 2) def beta_for_x_pixel(self, pixel): """ @@ -456,7 +478,8 @@ def beta_for_x_pixel(self, pixel): :return: reflectance angle (radians) at pixel """ center_offset = self.detector.pixel_size * (pixel - self.detector.n_pix_x / 2) - return self.beta_central_pixel + np.arctan(center_offset / self.focal_length) + return self.beta_central_pixel + np.arctan( + center_offset / self.dispersion_focal_length) def y_pixel_for_beta(self, beta): """ From 2657d610b5583a2792ea1ed585aecb872f03e7b5 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Fri, 3 Jul 2026 22:59:57 +0200 Subject: [PATCH 28/43] Clip tiny negative trace pixels. --- scopesim/effects/spectral_trace_list_utils.py | 18 ++++++++++++++++++ .../test_SpectralTraceListUtils.py | 12 ++++++++++++ 2 files changed, 30 insertions(+) diff --git a/scopesim/effects/spectral_trace_list_utils.py b/scopesim/effects/spectral_trace_list_utils.py index d65eaf37d..0469138ff 100644 --- a/scopesim/effects/spectral_trace_list_utils.py +++ b/scopesim/effects/spectral_trace_list_utils.py @@ -32,6 +32,23 @@ logger = get_logger(__name__) +def _clip_tiny_negative_trace_pixels(image: np.ndarray) -> int: + """Set floating-point roundoff-scale negative trace pixels to zero.""" + if image is None or not np.issubdtype(image.dtype, np.floating): + return 0 + + negative = image < 0 + if not np.any(negative): + return 0 + + finite = np.isfinite(image) + image_scale = np.nanmax(np.abs(image[finite])) if np.any(finite) else 0 + tolerance = 128 * np.finfo(float).eps * max(float(image_scale), 1.0) + tiny_negative = negative & (image >= -tolerance) + image[tiny_negative] = 0 + return int(np.sum(tiny_negative)) + + def apply_detector_qe_to_trace_image( image: np.ndarray, detector_qe, @@ -336,6 +353,7 @@ def map_spectra_to_focal_plane(self, fov): img_header["YMAX"] = ymax img_header["BUNIT"] = "ph s-1" + _clip_tiny_negative_trace_pixels(image) if np.any(image < 0): logger.warning("map_spectra_to_focal_plane: %d negative pixels", np.sum(image < 0)) diff --git a/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py b/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py index 6fe64902c..4554e2df8 100644 --- a/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py +++ b/scopesim/tests/tests_effects/test_SpectralTraceListUtils.py @@ -16,6 +16,7 @@ from scopesim.effects.spectral_trace_list_utils import Transform2D, power_vector from scopesim.effects.spectral_trace_list_utils import make_image_interpolations from scopesim.effects.spectral_trace_list_utils import apply_detector_qe_to_trace_image +from scopesim.effects.spectral_trace_list_utils import _clip_tiny_negative_trace_pixels from scopesim.tests.mocks.py_objects import trace_list_objects as tlo class TestSpectralTrace: @@ -116,6 +117,17 @@ def throughput_at(self, wave, detector_x=None, detector_y=None, **kwargs): np.testing.assert_allclose(result, [[0.5, 0.6], [0.7, 0.8]]) + +def test_clip_tiny_negative_trace_pixels_only_clips_roundoff(): + image = np.array([[1.0, -1e-15], [0.0, -1e-10]], dtype=float) + + clipped = _clip_tiny_negative_trace_pixels(image) + + assert clipped == 1 + assert image[0, 1] == 0 + assert image[1, 1] < 0 + + class TestPowerVec: """Test function power_vector()""" def test_gives_correct_result(self): From 2eb4f23b9dabd1948b2d763246bd532859f6c298 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Thu, 23 Jul 2026 16:03:49 -0700 Subject: [PATCH 29/43] Keep analytical echelle geometry consistent --- scopesim/effects/spectral_efficiency.py | 12 ++++- scopesim/effects/spectral_trace_list.py | 4 +- .../tests_effects/test_SpectralEfficiency.py | 47 ++++++++++++++++++- .../tests_effects/test_SpectralTraceList.py | 24 ++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/scopesim/effects/spectral_efficiency.py b/scopesim/effects/spectral_efficiency.py index 4bf4002ef..7be75d780 100644 --- a/scopesim/effects/spectral_efficiency.py +++ b/scopesim/effects/spectral_efficiency.py @@ -155,6 +155,13 @@ def _generate_efficiency_curve_func(self) -> Callable: max_wave = row['max_wave'] * u.Unit(trace_params.meta["max_wave_unit"]) design_res = row['design_res'] focal_len = row['focal_length'] * u.Unit(trace_params.meta["focal_length_unit"]) + dispersion_focal_len = None + if "dispersion_focal_length" in trace_params.colnames: + dispersion_focal_len = row["dispersion_focal_length"] * u.Unit( + trace_params.meta.get( + "dispersion_focal_length_unit", + trace_params.meta["focal_length_unit"], + )) disp_npix = row['n_disp'] - 2 * row['detector_pad'] xdisp_npix = row['n_xdisp']- 2 * row['detector_pad'] pix_size = row['pixel_size'] * u.Unit(trace_params.meta["pixel_size_unit"]) @@ -169,7 +176,8 @@ def _generate_efficiency_curve_func(self) -> Callable: design_res, echelle_angle, min_order, max_order, echelle_groove_length, pix_per_res_elem, disp_npix, xdisp_npix, pix_size, xdisp_groove_length=xdisp_groove_length, - xdisp_beta_center=xdisp_beta_center) + xdisp_beta_center=xdisp_beta_center, + dispersion_focal_len=dispersion_focal_len) self._spectrographs = spectrographs def efficiency_curve(trace_id, wavelength): @@ -212,4 +220,4 @@ def plot(self): axes.set_title(f"Grating efficiencies {self.display_name}") axes.legend() - return fig \ No newline at end of file + return fig diff --git a/scopesim/effects/spectral_trace_list.py b/scopesim/effects/spectral_trace_list.py index 3cad4b038..ebc3e6995 100644 --- a/scopesim/effects/spectral_trace_list.py +++ b/scopesim/effects/spectral_trace_list.py @@ -806,7 +806,9 @@ def on_detector(wave, order): order_table = Table( {'wavelength': w.to(u.um), 's': s, 'x': xval, - 'y': yval}) + 'y': yval, + 'x_pix': xpix.ravel() * u.pixel, + 'y_pix': ypix.ravel() * u.pixel}) trace_hdu = fits.BinTableHDU(order_table) trace_hdu.header['DISPDIR'] = row['dispdir'] diff --git a/scopesim/tests/tests_effects/test_SpectralEfficiency.py b/scopesim/tests/tests_effects/test_SpectralEfficiency.py index bfdc3a4b6..f0b08ebce 100644 --- a/scopesim/tests/tests_effects/test_SpectralEfficiency.py +++ b/scopesim/tests/tests_effects/test_SpectralEfficiency.py @@ -1,10 +1,13 @@ """Tests for class SpectralEfficiency""" +import numpy as np import pytest +from astropy import units as u from astropy.io import fits -from scopesim.effects import SpectralEfficiency, TERCurve +from scopesim.effects import EchelleSpectralEfficiency, SpectralEfficiency, \ + TERCurve @pytest.fixture(name="speceff", scope="class") @@ -28,3 +31,45 @@ def test_has_efficiencies(self, speceff): efficiencies = speceff.efficiencies assert all(isinstance(effic, TERCurve) for effic in efficiencies.values()) + + +def _write_echelle_trace_params(path, dispersion_focal_length): + path.write_text( + "# min_wave_unit : nm\n" + "# max_wave_unit : nm\n" + "# echelle_blaze_unit : deg\n" + "# focal_length_unit : mm\n" + "# dispersion_focal_length_unit : mm\n" + "# pixel_size_unit : mm\n" + "# disp_freq_unit : mm\n" + "# xdisp_freq_unit : mm\n" + "prefix m0 n min_wave max_wave design_res echelle_blaze " + "focal_length dispersion_focal_length fwhm detector_pad " + "pixel_size n_disp n_xdisp disp_freq xdisp_freq xbeta_center\n" + f"b 91 0 310 420 17799 65.6 225 {dispersion_focal_length} " + "4.5 10 0.015 128 128 65.0 1.0 0\n", + encoding="utf-8", + ) + + +def test_echelle_efficiency_uses_dispersion_focal_length(tmp_path): + params_225 = tmp_path / "echelle_225.dat" + params_270 = tmp_path / "echelle_270.dat" + _write_echelle_trace_params(params_225, 225) + _write_echelle_trace_params(params_270, 270) + + efficiency_225 = EchelleSpectralEfficiency(filename=str(params_225)) + efficiency_270 = EchelleSpectralEfficiency(filename=str(params_270)) + spectrograph = efficiency_270._spectrographs["b"] + + assert spectrograph.focal_length == 225 * u.mm + assert spectrograph.dispersion_focal_length == 270 * u.mm + + wave = ( + efficiency_225._spectrographs["b"].central_wave(91) + * np.linspace(0.99, 1.01, 20) + ) + np.testing.assert_allclose( + efficiency_225.efficiency_generator("b_91", wave), + efficiency_270.efficiency_generator("b_91", wave), + ) diff --git a/scopesim/tests/tests_effects/test_SpectralTraceList.py b/scopesim/tests/tests_effects/test_SpectralTraceList.py index 542c367a3..47fdebfbc 100644 --- a/scopesim/tests/tests_effects/test_SpectralTraceList.py +++ b/scopesim/tests/tests_effects/test_SpectralTraceList.py @@ -232,3 +232,27 @@ def test_detector_angle_clips_to_detector_not_padding(self, tmp_path): assert trace.wave_max == pytest.approx(unrotated_trace.wave_max) assert not np.allclose(trace.table["x"], unrotated_trace.table["x"]) assert not np.allclose(trace.table["y"], unrotated_trace.table["y"]) + + def test_keeps_rotated_detector_pixel_coordinates(self, tmp_path): + params_file = tmp_path / "echelle_trace_parameters.dat" + _write_echelle_trace_params(params_file, detector_angle=25) + + spt = EchelleSpectralTraceList( + cmds=_echelle_cmds(), + filename=str(params_file), + wave_colname="wavelength", + s_colname="s", + ) + + trace = next(iter(spt.spectral_traces.values())) + xpix = trace.table["x_pix"].quantity.to_value(u.pixel) + ypix = trace.table["y_pix"].quantity.to_value(u.pixel) + + np.testing.assert_allclose( + xpix, + trace.table["x"].quantity.to_value(u.mm) / 0.015 + 64, + ) + np.testing.assert_allclose( + ypix, + trace.table["y"].quantity.to_value(u.mm) / 0.015 + 64, + ) From 12a5ea76f1a59193e9ae9990a9ebd64795b22f45 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Thu, 23 Jul 2026 16:19:27 -0700 Subject: [PATCH 30/43] Keep echelle trace tables in physical coordinates --- scopesim/effects/spectral_trace_list.py | 4 +--- .../tests_effects/test_SpectralTraceList.py | 24 ------------------- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/scopesim/effects/spectral_trace_list.py b/scopesim/effects/spectral_trace_list.py index ebc3e6995..3cad4b038 100644 --- a/scopesim/effects/spectral_trace_list.py +++ b/scopesim/effects/spectral_trace_list.py @@ -806,9 +806,7 @@ def on_detector(wave, order): order_table = Table( {'wavelength': w.to(u.um), 's': s, 'x': xval, - 'y': yval, - 'x_pix': xpix.ravel() * u.pixel, - 'y_pix': ypix.ravel() * u.pixel}) + 'y': yval}) trace_hdu = fits.BinTableHDU(order_table) trace_hdu.header['DISPDIR'] = row['dispdir'] diff --git a/scopesim/tests/tests_effects/test_SpectralTraceList.py b/scopesim/tests/tests_effects/test_SpectralTraceList.py index 47fdebfbc..542c367a3 100644 --- a/scopesim/tests/tests_effects/test_SpectralTraceList.py +++ b/scopesim/tests/tests_effects/test_SpectralTraceList.py @@ -232,27 +232,3 @@ def test_detector_angle_clips_to_detector_not_padding(self, tmp_path): assert trace.wave_max == pytest.approx(unrotated_trace.wave_max) assert not np.allclose(trace.table["x"], unrotated_trace.table["x"]) assert not np.allclose(trace.table["y"], unrotated_trace.table["y"]) - - def test_keeps_rotated_detector_pixel_coordinates(self, tmp_path): - params_file = tmp_path / "echelle_trace_parameters.dat" - _write_echelle_trace_params(params_file, detector_angle=25) - - spt = EchelleSpectralTraceList( - cmds=_echelle_cmds(), - filename=str(params_file), - wave_colname="wavelength", - s_colname="s", - ) - - trace = next(iter(spt.spectral_traces.values())) - xpix = trace.table["x_pix"].quantity.to_value(u.pixel) - ypix = trace.table["y_pix"].quantity.to_value(u.pixel) - - np.testing.assert_allclose( - xpix, - trace.table["x"].quantity.to_value(u.mm) / 0.015 + 64, - ) - np.testing.assert_allclose( - ypix, - trace.table["y"].quantity.to_value(u.mm) / 0.015 + 64, - ) From 4755a86907782545e0a04cc84ddf1dd4d13c3593 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Thu, 23 Jul 2026 18:27:04 -0700 Subject: [PATCH 31/43] formatting --- scopesim/effects/spectral_efficiency.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/scopesim/effects/spectral_efficiency.py b/scopesim/effects/spectral_efficiency.py index 7be75d780..611519499 100644 --- a/scopesim/effects/spectral_efficiency.py +++ b/scopesim/effects/spectral_efficiency.py @@ -157,11 +157,9 @@ def _generate_efficiency_curve_func(self) -> Callable: focal_len = row['focal_length'] * u.Unit(trace_params.meta["focal_length_unit"]) dispersion_focal_len = None if "dispersion_focal_length" in trace_params.colnames: - dispersion_focal_len = row["dispersion_focal_length"] * u.Unit( - trace_params.meta.get( - "dispersion_focal_length_unit", - trace_params.meta["focal_length_unit"], - )) + dispersion_focal_len = (row["dispersion_focal_length"] * + u.Unit(trace_params.meta.get("dispersion_focal_length_unit", + trace_params.meta["focal_length_unit"]))) disp_npix = row['n_disp'] - 2 * row['detector_pad'] xdisp_npix = row['n_xdisp']- 2 * row['detector_pad'] pix_size = row['pixel_size'] * u.Unit(trace_params.meta["pixel_size_unit"]) From bb83f055ea503eb1c2176f8b4cba496462263cf3 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Fri, 24 Jul 2026 12:46:11 -0700 Subject: [PATCH 32/43] Add live spectral-trace detector coordinate query --- scopesim/optics/optical_train.py | 191 ++++++++++++++++++ .../tests/tests_optics/test_OpticalTrain.py | 134 +++++++++++- 2 files changed, 324 insertions(+), 1 deletion(-) diff --git a/scopesim/optics/optical_train.py b/scopesim/optics/optical_train.py index f3a4f015d..75b5be4f0 100644 --- a/scopesim/optics/optical_train.py +++ b/scopesim/optics/optical_train.py @@ -2,11 +2,13 @@ import copy import os +from collections.abc import Mapping from datetime import datetime import numpy as np from scipy.interpolate import interp1d from astropy import units as u +from astropy.table import QTable from astropy.wcs import WCS from tqdm.auto import tqdm @@ -205,6 +207,195 @@ def update(self, **kwargs): self.cmds.maps[1].dic = recursive_update(self.cmds.maps[1].dic, self.cmds.maps[0].dic) self.cmds.maps[0].dic.clear() + def trace_detector_coordinates(self, *, xi=0 * u.arcsec, + wavelengths: Mapping | None = None) -> QTable: + """ + Return slit coordinates mapped to the configured detector pixels. + + With no wavelength argument, every configured spectral trace is + sampled at the wavelengths carried by its trace table. A mapping of + ``trace_id: wavelength`` arrays can instead request exact wavelengths. + The returned detector coordinates are continuous, zero-origin pixel + coordinates. No observation or image data are used. + + This deliberately supports one spectral-trace list and one detector + per image plane. More complicated detector arrangements need an + explicit definition of which output image owns each trace coordinate. + """ + from ..effects import ( + BinnedImage, + Rotate90CCD, + SelectorWheel, + SpectralTraceList, + UnequalBinnedImage, + ) + + trace_lists = self.optics_manager.get_all(SpectralTraceList) + if len(trace_lists) != 1: + raise NotImplementedError( + "Trace detector coordinates require exactly one active " + "SpectralTraceList.") + trace_list = trace_lists[0] + + fovs_by_trace = {} + for fov in self.fov_manager.fovs: + fovs_by_trace.setdefault(str(fov.trace_id), []).append(fov) + + if wavelengths is None: + trace_ids = list(trace_list.spectral_traces) + else: + trace_ids = list(wavelengths) + + xi_arcsec = np.atleast_1d( + u.Quantity(xi, u.arcsec).to_value(u.arcsec)) + columns = { + "readout_index": [], + "image_plane_id": [], + "detector_id": [], + "trace_id": [], + "wavelength": [], + "xi": [], + "detector_x": [], + "detector_y": [], + } + + for trace_id in trace_ids: + trace = trace_list.spectral_traces[trace_id] + trace_name = str(trace_id) + trace_fovs = fovs_by_trace[trace_name] + image_plane_id = int(trace.meta["image_plane_id"]) + + if any(int(fov.meta["image_plane_id"]) != image_plane_id + for fov in trace_fovs): + raise NotImplementedError( + f"Trace {trace_name!r} has conflicting image-plane " + "assignments.") + + image_planes = [ + image_plane for image_plane in self.image_planes + if image_plane.id == image_plane_id + ] + detector_managers = [ + (index, manager) + for index, manager in enumerate(self.detector_managers) + if int(from_currsys( + manager._detector_list.image_plane_id, self.cmds + )) == image_plane_id + ] + if len(image_planes) != 1 or len(detector_managers) != 1: + raise NotImplementedError( + f"Trace {trace_name!r} does not resolve to exactly one " + "image plane and detector manager.") + + readout_index, detector_manager = detector_managers[0] + if len(detector_manager) != 1: + raise NotImplementedError( + "Trace detector coordinates do not yet support multiple " + "detectors on one image plane.") + detector = detector_manager[0] + detector_id = detector.header["ID"] + + for effect in self.optics_manager.detector_effects: + selected_effect = ( + effect.get_effect(detector_id) + if isinstance(effect, SelectorWheel) + else effect + ) + if selected_effect is None: + continue + if isinstance(selected_effect, BinnedImage): + bin_size = from_currsys( + selected_effect.meta["bin_size"], self.cmds) + if bin_size != 1: + raise NotImplementedError( + "Trace detector coordinates do not yet support " + "post-extraction detector binning.") + if isinstance(selected_effect, UnequalBinnedImage): + binx = from_currsys( + selected_effect.meta["binx"], self.cmds) + biny = from_currsys( + selected_effect.meta["biny"], self.cmds) + if binx != 1 or biny != 1: + raise NotImplementedError( + "Trace detector coordinates do not yet support " + "post-extraction detector binning.") + if isinstance(selected_effect, Rotate90CCD): + rotations = from_currsys( + selected_effect.meta["rotations"], self.cmds) + if rotations % 4: + raise NotImplementedError( + "Trace detector coordinates do not yet support " + "post-extraction detector rotation.") + + detector_wcs = WCS(detector.header, key="D") + if not np.allclose(detector_wcs.wcs.pc, np.eye(2)): + raise NotImplementedError( + "Trace detector coordinates do not yet support a rotated " + "or sheared detector D-WCS.") + + fov_ranges = [ + ( + fov.meta["wave_min"].to_value(u.um), + fov.meta["wave_max"].to_value(u.um), + ) + for fov in trace_fovs + ] + if wavelengths is None: + wave_um = np.unique( + u.Quantity( + trace.table[trace.meta["wave_colname"]] + ).to_value(u.um) + ) + configured = np.zeros(wave_um.shape, dtype=bool) + for wave_min, wave_max in fov_ranges: + configured |= ( + (wave_um >= wave_min) & (wave_um <= wave_max)) + wave_um = wave_um[configured] + else: + wave_um = np.atleast_1d( + u.Quantity(wavelengths[trace_id]).to_value(u.um)) + configured = np.zeros(wave_um.shape, dtype=bool) + for wave_min, wave_max in fov_ranges: + configured |= ( + (wave_um >= wave_min) & (wave_um <= wave_max)) + if not np.all(configured): + raise ValueError( + f"Requested wavelengths for trace {trace_name!r} " + "extend outside its configured FOV range.") + + wave_grid, xi_grid = np.meshgrid(wave_um, xi_arcsec) + x_mm = trace.xilam2x( + xi_grid.ravel(), wave_grid.ravel()) + y_mm = trace.xilam2y( + xi_grid.ravel(), wave_grid.ravel()) + x_pix, y_pix = detector_wcs.all_world2pix( + x_mm, y_mm, 0) + + point_count = wave_grid.size + columns["readout_index"].extend( + np.full(point_count, readout_index)) + columns["image_plane_id"].extend( + np.full(point_count, image_plane_id)) + columns["detector_id"].extend( + np.full(point_count, detector_id)) + columns["trace_id"].extend( + np.full(point_count, trace_name)) + columns["wavelength"].extend(wave_grid.ravel()) + columns["xi"].extend(xi_grid.ravel()) + columns["detector_x"].extend(x_pix) + columns["detector_y"].extend(y_pix) + + table = QTable() + table["readout_index"] = columns["readout_index"] + table["image_plane_id"] = columns["image_plane_id"] + table["detector_id"] = columns["detector_id"] + table["trace_id"] = columns["trace_id"] + table["wavelength"] = columns["wavelength"] * u.um + table["xi"] = columns["xi"] * u.arcsec + table["detector_x"] = columns["detector_x"] * u.pixel + table["detector_y"] = columns["detector_y"] * u.pixel + return table + @top_level_catch def observe(self, orig_source=None, update=True, **kwargs): """ diff --git a/scopesim/tests/tests_optics/test_OpticalTrain.py b/scopesim/tests/tests_optics/test_OpticalTrain.py index 135eb7167..fbef0882d 100644 --- a/scopesim/tests/tests_optics/test_OpticalTrain.py +++ b/scopesim/tests/tests_optics/test_OpticalTrain.py @@ -1,21 +1,29 @@ from copy import deepcopy +from types import SimpleNamespace import pytest from pytest import approx from unittest.mock import patch import numpy as np from astropy import units as u +from astropy.io import fits from astropy.table import Table import scopesim as sim +from scopesim.detector import DetectorManager from scopesim.optics.fov_manager import FOVManager from scopesim.optics.image_plane import ImagePlane from scopesim.optics.optical_train import OpticalTrain from scopesim.optics.optics_manager import OpticsManager from scopesim.optics.optical_element import OpticalElement from scopesim.commands.user_commands import UserCommands -from scopesim.effects import Effect, DetectorList +from scopesim.effects import ( + DetectorList, + Effect, + SpectralTraceList, + UnequalBinnedImage, +) from scopesim.utils import find_file from scopesim.tests.mocks.py_objects import source_objects as src_objs @@ -77,6 +85,77 @@ def simplecado_opt(mock_path_yamls): return sim.OpticalTrain(cmd) +def spectral_geometry_train(detector_count=1): + """Return a small configured train that needs no observation.""" + xi_grid, wave_grid = np.meshgrid( + np.linspace(-1, 1, 5), + np.linspace(1, 2, 6), + indexing="ij", + ) + trace_table = Table({ + "wavelength": wave_grid.ravel() * u.um, + "s": xi_grid.ravel() * u.arcsec, + "x": (wave_grid.ravel() - 1.5 + 0.1 * xi_grid.ravel()) * u.mm, + "y": (0.2 * xi_grid.ravel()) * u.mm, + }) + trace_hdu = fits.BinTableHDU(trace_table, name="linear") + catalog = fits.BinTableHDU(Table({ + "description": ["linear"], + "extension_id": [2], + "aperture_id": [0], + "image_plane_id": [0], + })) + primary = fits.PrimaryHDU() + primary.header["ECAT"] = 1 + primary.header["EDATA"] = 2 + trace_list = SpectralTraceList( + hdulist=fits.HDUList([primary, catalog, trace_hdu]), + wave_colname="wavelength", + s_colname="s", + ) + + detector_list = DetectorList( + image_plane_id=0, + array_dict={ + "id": list(range(detector_count)), + "x_cen": np.linspace( + 0, 12 * (detector_count - 1), detector_count), + "y_cen": np.zeros(detector_count), + "x_size": np.full(detector_count, 100), + "y_size": np.full(detector_count, 80), + "pixel_size": np.full(detector_count, 0.1), + "angle": np.zeros(detector_count), + "gain": np.ones(detector_count), + }, + x_cen_unit="mm", + y_cen_unit="mm", + x_size_unit="pixel", + y_size_unit="pixel", + pixel_size_unit="mm", + angle_unit="deg", + gain_unit="electron/adu", + ) + + train = OpticalTrain() + train.optics_manager = SimpleNamespace( + get_all=lambda effect_class: [trace_list], + detector_effects=[], + ) + train.fov_manager = SimpleNamespace(fovs=[ + SimpleNamespace( + trace_id="linear", + meta={ + "image_plane_id": 0, + "wave_min": 1 * u.um, + "wave_max": 2 * u.um, + }, + ), + ]) + train.image_planes = [ImagePlane(detector_list.image_plane_header)] + train.detector_managers = [DetectorManager(detector_list)] + return train, trace_list + + @pytest.mark.usefixtures("patch_mock_path") class TestInit: def test_initialises_with_nothing(self): @@ -112,6 +191,59 @@ def test_ignore_effects_works(self, cmds_with_ignore): assert opt["detector QE curve"].include is False +class TestTraceDetectorCoordinates: + def test_maps_live_trace_to_zero_origin_detector_pixels(self): + train, _ = spectral_geometry_train() + + coordinates = train.trace_detector_coordinates( + wavelengths={"linear": [1.5] * u.um}) + + assert coordinates["trace_id"][0] == "linear" + assert coordinates["readout_index"][0] == 0 + assert coordinates["detector_x"][0].to_value(u.pixel) == approx(49.5) + assert coordinates["detector_y"][0].to_value(u.pixel) == approx(39.5) + + def test_returns_all_trace_wavelengths_and_requested_slit_positions(self): + train, _ = spectral_geometry_train() + + coordinates = train.trace_detector_coordinates( + xi=[-0.5, 0.5] * u.arcsec) + + assert len(coordinates) == 12 + assert set(coordinates["xi"].to_value(u.arcsec)) == {-0.5, 0.5} + assert coordinates["wavelength"].unit == u.um + assert coordinates["detector_x"].unit == u.pixel + + def test_uses_changed_live_trace_transform(self): + train, trace_list = spectral_geometry_train() + before = train.trace_detector_coordinates( + wavelengths={"linear": [1.5] * u.um}) + + trace = trace_list.spectral_traces["linear"] + trace.meta["offset_y"] = 0.1 + trace.compute_interpolation_functions() + after = train.trace_detector_coordinates( + wavelengths={"linear": [1.5] * u.um}) + + shift = after["detector_y"][0] - before["detector_y"][0] + assert shift.to_value(u.pixel) == approx(1) + + def test_rejects_multiple_detectors_on_one_image_plane(self): + train, _ = spectral_geometry_train(detector_count=2) + + with pytest.raises(NotImplementedError, match="multiple detectors"): + train.trace_detector_coordinates() + + def test_rejects_post_extraction_binning(self): + train, _ = spectral_geometry_train() + train.optics_manager.detector_effects = [ + UnequalBinnedImage(binx=2, biny=1), + ] + + with pytest.raises(NotImplementedError, match="binning"): + train.trace_detector_coordinates() + + @pytest.mark.slow @pytest.mark.usefixtures("patch_mock_path") class TestObserve: From 27e246e5cb61c853af10aeddccf5a52e642dc031 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Fri, 24 Jul 2026 12:54:32 -0700 Subject: [PATCH 33/43] Allow trace queries at rounded FOV boundaries --- scopesim/optics/optical_train.py | 34 ++++++++++++------- .../tests/tests_optics/test_OpticalTrain.py | 9 +++++ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/scopesim/optics/optical_train.py b/scopesim/optics/optical_train.py index 75b5be4f0..c519b71c1 100644 --- a/scopesim/optics/optical_train.py +++ b/scopesim/optics/optical_train.py @@ -346,22 +346,30 @@ def trace_detector_coordinates(self, *, xi=0 * u.arcsec, trace.table[trace.meta["wave_colname"]] ).to_value(u.um) ) - configured = np.zeros(wave_um.shape, dtype=bool) - for wave_min, wave_max in fov_ranges: - configured |= ( - (wave_um >= wave_min) & (wave_um <= wave_max)) - wave_um = wave_um[configured] else: wave_um = np.atleast_1d( u.Quantity(wavelengths[trace_id]).to_value(u.um)) - configured = np.zeros(wave_um.shape, dtype=bool) - for wave_min, wave_max in fov_ranges: - configured |= ( - (wave_um >= wave_min) & (wave_um <= wave_max)) - if not np.all(configured): - raise ValueError( - f"Requested wavelengths for trace {trace_name!r} " - "extend outside its configured FOV range.") + + configured = np.zeros(wave_um.shape, dtype=bool) + for wave_min, wave_max in fov_ranges: + configured |= ( + ( + (wave_um >= wave_min) + | np.isclose( + wave_um, wave_min, rtol=1e-12, atol=0) + ) + & ( + (wave_um <= wave_max) + | np.isclose( + wave_um, wave_max, rtol=1e-12, atol=0) + ) + ) + if wavelengths is None: + wave_um = wave_um[configured] + elif not np.all(configured): + raise ValueError( + f"Requested wavelengths for trace {trace_name!r} " + "extend outside its configured FOV range.") wave_grid, xi_grid = np.meshgrid(wave_um, xi_arcsec) x_mm = trace.xilam2x( diff --git a/scopesim/tests/tests_optics/test_OpticalTrain.py b/scopesim/tests/tests_optics/test_OpticalTrain.py index fbef0882d..2005fc30e 100644 --- a/scopesim/tests/tests_optics/test_OpticalTrain.py +++ b/scopesim/tests/tests_optics/test_OpticalTrain.py @@ -214,6 +214,15 @@ def test_returns_all_trace_wavelengths_and_requested_slit_positions(self): assert coordinates["wavelength"].unit == u.um assert coordinates["detector_x"].unit == u.pixel + def test_accepts_unit_roundoff_at_configured_wavelength_boundary(self): + train, _ = spectral_geometry_train() + boundary = np.nextafter(1.0, 0.0) * u.um + + coordinates = train.trace_detector_coordinates( + wavelengths={"linear": [boundary]}) + + assert coordinates["wavelength"][0] == boundary + def test_uses_changed_live_trace_transform(self): train, trace_list = spectral_geometry_train() before = train.trace_detector_coordinates( From 03dcbca1f30625f1a0f092e373b297bb2c3b7e47 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Fri, 24 Jul 2026 13:10:59 -0700 Subject: [PATCH 34/43] Refactor selector wheel effect retrieval logic in optical train --- scopesim/effects/spectral_trace_list_utils.py | 75 ++++----- scopesim/optics/optical_train.py | 151 ++++++++++++++---- .../tests/tests_optics/test_OpticalTrain.py | 24 ++- 3 files changed, 180 insertions(+), 70 deletions(-) diff --git a/scopesim/effects/spectral_trace_list_utils.py b/scopesim/effects/spectral_trace_list_utils.py index 8130eadb3..a7d26a06f 100644 --- a/scopesim/effects/spectral_trace_list_utils.py +++ b/scopesim/effects/spectral_trace_list_utils.py @@ -207,19 +207,8 @@ def compute_interpolation_functions(self): logger.info( "Dispersion axis determined to be %s", self.dispersion_axis) - def map_spectra_to_focal_plane(self, fov): - """ - Apply the spectral trace mapping to a spectral cube. - - The cube is contained in a FieldOfView object, which also has - world coordinate systems for the Source (sky coordinates and - wavelengths) and for the focal plane. - The method returns a section of the fov image along with info on - where this image lies in the focal plane. - """ - logger.debug("Mapping %s", fov.trace_id) - # Initialise the image based on the footprint of the spectral - # trace and the focal plane WCS + def _focal_plane_grid(self, fov): + """Return the exact focal-plane grid used to rasterize this FOV.""" wave_min = fov.meta["wave_min"].value # [um] wave_max = fov.meta["wave_max"].value # [um] xi_min = fov.meta["xi_min"].value # [arcsec] @@ -231,15 +220,7 @@ def map_spectra_to_focal_plane(self, fov): logger.warning("xlim_mm is None") return None - fov_header = fov.header det_header = fov.detector_header - - # WCSD from the FieldOfView - this is the full detector plane - pixsize = det_header["CDELT1D"] * u.Unit(det_header["CUNIT1D"]) - pixsize = pixsize.to_value(u.mm) - pixscale = fov_header["CDELT1"] * u.Unit(fov_header["CUNIT1"]) - pixscale = pixscale.to_value(u.arcsec) - fpa_wcsd = WCS(det_header, key="D") naxis1d, naxis2d = det_header["NAXIS1"], det_header["NAXIS2"] xlim_px, ylim_px = fpa_wcsd.all_world2pix(xlim_mm, ylim_mm, 0) @@ -260,23 +241,50 @@ def map_spectra_to_focal_plane(self, fov): ymin = max(ymin, 0) ymax = min(ymax, naxis2d) - # Create header for the subimage - I think this only needs the DET one, - # but we'll do both. The WCSs are initialised from the full fpa WCS and - # then shifted accordingly. det_wcs = WCS(det_header, key="D") det_wcs.wcs.crpix -= np.array([xmin, ymin]) sub_naxis1 = xmax - xmin sub_naxis2 = ymax - ymin - - # initialise the subimage - image = np.zeros((sub_naxis2, sub_naxis1), dtype=np.float32) - - # Adjust the limits of the subimage in millimeters in the focal plane - # This takes the adjustment to integer pixels into account xmin_mm, ymin_mm = fpa_wcsd.all_pix2world(xmin, ymin, 0) xmax_mm, ymax_mm = fpa_wcsd.all_pix2world(xmax, ymax, 0) + x_mm = np.linspace(xmin_mm, xmax_mm, sub_naxis1, dtype=np.float32) + y_mm = np.linspace(ymin_mm, ymax_mm, sub_naxis2, dtype=np.float32) + return xmin, ymin, x_mm, y_mm, det_wcs + + def map_spectra_to_focal_plane(self, fov): + """ + Apply the spectral trace mapping to a spectral cube. + + The cube is contained in a FieldOfView object, which also has + world coordinate systems for the Source (sky coordinates and + wavelengths) and for the focal plane. + The method returns a section of the fov image along with info on + where this image lies in the focal plane. + """ + logger.debug("Mapping %s", fov.trace_id) + wave_min = fov.meta["wave_min"].value # [um] + wave_max = fov.meta["wave_max"].value # [um] + xi_min = fov.meta["xi_min"].value # [arcsec] + xi_max = fov.meta["xi_max"].value # [arcsec] + fov_header = fov.header + det_header = fov.detector_header + pixsize = det_header["CDELT1D"] * u.Unit(det_header["CUNIT1D"]) + pixsize = pixsize.to_value(u.mm) + pixscale = fov_header["CDELT1"] * u.Unit(fov_header["CUNIT1"]) + pixscale = pixscale.to_value(u.arcsec) + + focal_plane_grid = self._focal_plane_grid(fov) + if focal_plane_grid is None: + return None + xmin, ymin, x_mm, y_mm, det_wcs = focal_plane_grid + sub_naxis1 = len(x_mm) + sub_naxis2 = len(y_mm) + xmax = xmin + sub_naxis1 + ymax = ymin + sub_naxis2 + image = np.zeros((sub_naxis2, sub_naxis1), dtype=np.float32) + self._set_dispersion(wave_min, wave_max, pixsize=pixsize) try: xilam = XiLamImage(fov, self.dlam_per_pix) @@ -288,12 +296,7 @@ def map_spectra_to_focal_plane(self, fov): xilam_wcs = xilam.wcs # focal-plane coordinate images - ximg_fpa, yimg_fpa = np.meshgrid(np.linspace(xmin_mm, xmax_mm, - sub_naxis1, - dtype=np.float32), - np.linspace(ymin_mm, ymax_mm, - sub_naxis2, - dtype=np.float32)) + ximg_fpa, yimg_fpa = np.meshgrid(x_mm, y_mm) # Image mapping (xi, lambda) on the focal plane xi_fpa = self.xy2xi(ximg_fpa, yimg_fpa).astype(np.float32) diff --git a/scopesim/optics/optical_train.py b/scopesim/optics/optical_train.py index c519b71c1..8466aff75 100644 --- a/scopesim/optics/optical_train.py +++ b/scopesim/optics/optical_train.py @@ -216,7 +216,9 @@ def trace_detector_coordinates(self, *, xi=0 * u.arcsec, sampled at the wavelengths carried by its trace table. A mapping of ``trace_id: wavelength`` arrays can instead request exact wavelengths. The returned detector coordinates are continuous, zero-origin pixel - coordinates. No observation or image data are used. + coordinates after the trace subimage rasterization, image-plane + placement, and detector extraction conventions. No observation or + image data are used. This deliberately supports one spectral-trace list and one detector per image plane. More complicated detector arrangements need an @@ -237,6 +239,15 @@ def trace_detector_coordinates(self, *, xi=0 * u.arcsec, "SpectralTraceList.") trace_list = trace_lists[0] + if self.optics_manager.image_plane_effects: + raise NotImplementedError( + "Trace detector coordinates do not yet support image-plane " + "effects.") + if self.optics_manager.detector_array_effects: + raise NotImplementedError( + "Trace detector coordinates do not yet support detector-array " + "effects.") + fovs_by_trace = {} for fov in self.fov_manager.fovs: fovs_by_trace.setdefault(str(fov.trace_id), []).append(fov) @@ -248,6 +259,17 @@ def trace_detector_coordinates(self, *, xi=0 * u.arcsec, xi_arcsec = np.atleast_1d( u.Quantity(xi, u.arcsec).to_value(u.arcsec)) + + def image_origin_on_canvas(shape_xy, image_wcs, canvas_wcs): + """Mirror add_imagehdu_to_imagehdu and overlay_image placement.""" + image_center = (shape_xy - 1) / 2 + world_center = image_wcs.all_pix2world([image_center], 0) + canvas_center = canvas_wcs.all_world2pix(world_center, 0)[0] + return ( + np.ceil(np.round(canvas_center, 10)).astype(int) + - shape_xy // 2 + ) + columns = { "readout_index": [], "image_plane_id": [], @@ -288,6 +310,7 @@ def trace_detector_coordinates(self, *, xi=0 * u.arcsec, "image plane and detector manager.") readout_index, detector_manager = detector_managers[0] + image_plane = image_planes[0] if len(detector_manager) != 1: raise NotImplementedError( "Trace detector coordinates do not yet support multiple " @@ -297,7 +320,7 @@ def trace_detector_coordinates(self, *, xi=0 * u.arcsec, for effect in self.optics_manager.detector_effects: selected_effect = ( - effect.get_effect(detector_id) + effect.wheel_effects.get(detector_id) if isinstance(effect, SelectorWheel) else effect ) @@ -328,56 +351,122 @@ def trace_detector_coordinates(self, *, xi=0 * u.arcsec, "post-extraction detector rotation.") detector_wcs = WCS(detector.header, key="D") - if not np.allclose(detector_wcs.wcs.pc, np.eye(2)): + image_plane_wcs = image_plane._det_wcs + if ( + not np.allclose(image_plane_wcs.wcs.pc, np.eye(2)) + or not np.allclose(detector_wcs.wcs.pc, np.eye(2)) + ): + raise NotImplementedError( + "Trace detector coordinates do not yet support rotated " + "or sheared image-plane or detector D-WCSs.") + if not np.allclose( + np.abs(image_plane_wcs.wcs.cdelt[:2]), + np.abs(detector_wcs.wcs.cdelt[:2]), + ): raise NotImplementedError( - "Trace detector coordinates do not yet support a rotated " - "or sheared detector D-WCS.") + "Trace detector coordinates do not yet support rescaling " + "between image-plane and detector pixels.") + + image_plane_shape = np.array([ + image_plane.header["NAXIS1"], + image_plane.header["NAXIS2"], + ]) + detector_origin = image_origin_on_canvas( + image_plane_shape, image_plane_wcs, detector_wcs) - fov_ranges = [ - ( - fov.meta["wave_min"].to_value(u.um), - fov.meta["wave_max"].to_value(u.um), - ) - for fov in trace_fovs - ] if wavelengths is None: wave_um = np.unique( u.Quantity( trace.table[trace.meta["wave_colname"]] ).to_value(u.um) ) + configured = np.zeros(wave_um.shape, dtype=bool) + for fov in trace_fovs: + wave_min = fov.meta["wave_min"].to_value(u.um) + wave_max = fov.meta["wave_max"].to_value(u.um) + configured |= ( + (wave_um >= wave_min) & (wave_um <= wave_max)) + wave_um = wave_um[configured] else: wave_um = np.atleast_1d( u.Quantity(wavelengths[trace_id]).to_value(u.um)) - configured = np.zeros(wave_um.shape, dtype=bool) - for wave_min, wave_max in fov_ranges: - configured |= ( + wave_grid, xi_grid = np.meshgrid(wave_um, xi_arcsec) + wave_flat = wave_grid.ravel() + xi_flat = xi_grid.ravel() + x_pix = np.empty(wave_flat.shape) + y_pix = np.empty(wave_flat.shape) + mapped_points = np.zeros(wave_flat.shape, dtype=bool) + + for fov in trace_fovs: + wave_min = fov.meta["wave_min"].to_value(u.um) + wave_max = fov.meta["wave_max"].to_value(u.um) + in_fov = ( ( - (wave_um >= wave_min) + (wave_flat >= wave_min) | np.isclose( - wave_um, wave_min, rtol=1e-12, atol=0) + wave_flat, wave_min, rtol=1e-12, atol=0) ) & ( - (wave_um <= wave_max) + (wave_flat <= wave_max) | np.isclose( - wave_um, wave_max, rtol=1e-12, atol=0) + wave_flat, wave_max, rtol=1e-12, atol=0) ) ) - if wavelengths is None: - wave_um = wave_um[configured] - elif not np.all(configured): + if not np.any(in_fov): + continue + if np.any(mapped_points & in_fov): + raise NotImplementedError( + f"Trace {trace_name!r} has overlapping FOV wavelength " + "ranges.") + + ( + _, + _, + x_grid_mm, + y_grid_mm, + subimage_wcs, + ) = trace._focal_plane_grid(fov) + if ( + not np.allclose(subimage_wcs.wcs.pc, np.eye(2)) + or not np.allclose( + np.abs(subimage_wcs.wcs.cdelt[:2]), + np.abs(image_plane_wcs.wcs.cdelt[:2]), + ) + ): + raise NotImplementedError( + "Trace detector coordinates do not yet support " + "rescaling or reorientation during image-plane " + "placement.") + + x_mm = trace.xilam2x(xi_flat[in_fov], wave_flat[in_fov]) + y_mm = trace.xilam2y(xi_flat[in_fov], wave_flat[in_fov]) + subimage_x = ( + (x_mm - x_grid_mm[0]) + / (x_grid_mm[-1] - x_grid_mm[0]) + * (len(x_grid_mm) - 1) + ) + subimage_y = ( + (y_mm - y_grid_mm[0]) + / (y_grid_mm[-1] - y_grid_mm[0]) + * (len(y_grid_mm) - 1) + ) + + subimage_shape = np.array( + [len(x_grid_mm), len(y_grid_mm)]) + image_plane_origin = image_origin_on_canvas( + subimage_shape, subimage_wcs, image_plane_wcs) + image_plane_x = image_plane_origin[0] + subimage_x + image_plane_y = image_plane_origin[1] + subimage_y + + x_pix[in_fov] = detector_origin[0] + image_plane_x + y_pix[in_fov] = detector_origin[1] + image_plane_y + mapped_points |= in_fov + + if not np.all(mapped_points): raise ValueError( f"Requested wavelengths for trace {trace_name!r} " - "extend outside its configured FOV range.") - - wave_grid, xi_grid = np.meshgrid(wave_um, xi_arcsec) - x_mm = trace.xilam2x( - xi_grid.ravel(), wave_grid.ravel()) - y_mm = trace.xilam2y( - xi_grid.ravel(), wave_grid.ravel()) - x_pix, y_pix = detector_wcs.all_world2pix( - x_mm, y_mm, 0) + "do not map through a configured FOV.") point_count = wave_grid.size columns["readout_index"].extend( diff --git a/scopesim/tests/tests_optics/test_OpticalTrain.py b/scopesim/tests/tests_optics/test_OpticalTrain.py index 2005fc30e..581b5e29f 100644 --- a/scopesim/tests/tests_optics/test_OpticalTrain.py +++ b/scopesim/tests/tests_optics/test_OpticalTrain.py @@ -139,19 +139,24 @@ def spectral_geometry_train(detector_count=1): train = OpticalTrain() train.optics_manager = SimpleNamespace( get_all=lambda effect_class: [trace_list], + image_plane_effects=[], + detector_array_effects=[], detector_effects=[], ) + train.image_planes = [ImagePlane(detector_list.image_plane_header)] train.fov_manager = SimpleNamespace(fovs=[ SimpleNamespace( trace_id="linear", + detector_header=train.image_planes[0].header, meta={ "image_plane_id": 0, "wave_min": 1 * u.um, "wave_max": 2 * u.um, + "xi_min": -1 * u.arcsec, + "xi_max": 1 * u.arcsec, }, ), ]) - train.image_planes = [ImagePlane(detector_list.image_plane_header)] train.detector_managers = [DetectorManager(detector_list)] return train, trace_list @@ -200,8 +205,21 @@ def test_maps_live_trace_to_zero_origin_detector_pixels(self): assert coordinates["trace_id"][0] == "linear" assert coordinates["readout_index"][0] == 0 - assert coordinates["detector_x"][0].to_value(u.pixel) == approx(49.5) - assert coordinates["detector_y"][0].to_value(u.pixel) == approx(39.5) + assert coordinates["detector_x"][0].to_value(u.pixel) == approx(49) + assert coordinates["detector_y"][0].to_value(u.pixel) == approx(39) + + def test_includes_renderer_endpoint_sampling(self): + train, _ = spectral_geometry_train() + + coordinates = train.trace_detector_coordinates( + wavelengths={"linear": [1.25] * u.um}) + + # The rendered subimage samples -0.65..0.65 mm at 13 inclusive + # positions. The trace's -0.25 mm point therefore lands here, rather + # than at the ideal detector-WCS coordinate x=47. + expected_x = 43 + (-0.25 + 0.65) / (0.65 + 0.65) * 12 + assert coordinates["detector_x"][0].to_value(u.pixel) == approx( + expected_x) def test_returns_all_trace_wavelengths_and_requested_slit_positions(self): train, _ = spectral_geometry_train() From 3dcf441c1c1ed3b983aad4baeb06be094e79d4b9 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Sat, 25 Jul 2026 13:32:47 -0700 Subject: [PATCH 35/43] Adjust echelle trace order calculation to ensure inclusive range. --- scopesim/effects/spectral_efficiency.py | 3 ++- scopesim/effects/spectral_trace_list.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scopesim/effects/spectral_efficiency.py b/scopesim/effects/spectral_efficiency.py index 611519499..e84a35587 100644 --- a/scopesim/effects/spectral_efficiency.py +++ b/scopesim/effects/spectral_efficiency.py @@ -135,6 +135,7 @@ class EchelleSpectralEfficiency(Effect): """ Spectral efficiency list from analytical calculations of the blaze function for ZShooter gratings. Requires same input trace parameter table as EchelleSpectralTraceList, supply as kwarg "filename" + ``m0`` is the highest order number and ``n`` is the number of orders. """ z_order: ClassVar[tuple[int, ...]] = (630,) @@ -149,7 +150,7 @@ def _generate_efficiency_curve_func(self) -> Callable: spectrographs = {} for row in trace_params: prefix = row["prefix"] # note trance ids are assumed to be prefix_{order} - min_order = row['m0'] - row['n'] + min_order = row['m0'] - row['n'] + 1 max_order = row['m0'] min_wave = row['min_wave'] * u.Unit(trace_params.meta["min_wave_unit"]) max_wave = row['max_wave'] * u.Unit(trace_params.meta["max_wave_unit"]) diff --git a/scopesim/effects/spectral_trace_list.py b/scopesim/effects/spectral_trace_list.py index 3cad4b038..43fd8076f 100644 --- a/scopesim/effects/spectral_trace_list.py +++ b/scopesim/effects/spectral_trace_list.py @@ -580,6 +580,7 @@ class EchelleSpectralTraceList(SpectralTraceList): SpectralTraceList effect for echelle spectrographs. Unlike SpectralTraceList, it generates the trace definitions instead of loading them from FITS file. The arguments required to define the echelle traces are supplied through a txt file containing a table of parameters using the filename kwarg. + ``m0`` is the highest order number and ``n`` is the number of orders. Below is an example of how to define the echelle trace parameters (see irdb/ZShooter_v1/traces/echelle_trace_parameters.txt): ---------------------------------------------------------------- @@ -641,7 +642,7 @@ def _generate_trace_hdulist(self, trace_params): trace_ids, ap_ids, im_ids = [], [], [] for row in trace_params.table: prefix = row["prefix"] - min_order = row['m0'] - row['n'] + min_order = row['m0'] - row['n'] + 1 max_order = row['m0'] min_wave = row['min_wave'] * u.Unit(trace_params.meta["min_wave_unit"]) max_wave = row['max_wave'] * u.Unit(trace_params.meta["max_wave_unit"]) From 9e76ccd12d70b2c3ca4053b087b6a35d5090c6c9 Mon Sep 17 00:00:00 2001 From: Yashvi-Sharma Date: Thu, 6 Aug 2026 09:49:59 -0700 Subject: [PATCH 36/43] put skycalc timeout as a effect kwarg passed by alias property, refactored time resolution for brightness setting --- scopesim/effects/atmo_dispersion.py | 4 +- scopesim/effects/psfs/analytical.py | 2 +- scopesim/effects/sky_ter_curves.py | 57 ++++++++++++++++---------- scopesim/utils.py | 62 ++++++++++++++++++----------- 4 files changed, 77 insertions(+), 48 deletions(-) diff --git a/scopesim/effects/atmo_dispersion.py b/scopesim/effects/atmo_dispersion.py index 2ef5e3557..268cd7fee 100644 --- a/scopesim/effects/atmo_dispersion.py +++ b/scopesim/effects/atmo_dispersion.py @@ -218,7 +218,7 @@ class ADShift(ShiftFoV3D): def __init__(self, **kwargs): super().__init__(**kwargs) - self.target, self.location, self.time = get_observation_info_from_cmds(self.cmds) + self.target, self.location, self.time, _ = get_observation_info_from_cmds(self.cmds) self.zenith_angle = get_zenith_angle(self.target, self.location, self.time) * u.deg @@ -298,7 +298,7 @@ def __init__(self, **kwargs): if 'filename' not in kwargs and 'zenith_angle_error' not in kwargs: raise ValueError("Residuals must be supplied through either filename or zenith_angle_error.") - self.target, self.location, self.time = get_observation_info_from_cmds(self.cmds) + self.target, self.location, self.time, _ = get_observation_info_from_cmds(self.cmds) self.zenith_angle = get_zenith_angle(self.target, self.location, self.time) * u.deg self._ad_shift_cache_key = None diff --git a/scopesim/effects/psfs/analytical.py b/scopesim/effects/psfs/analytical.py index 99df3187c..f29101733 100644 --- a/scopesim/effects/psfs/analytical.py +++ b/scopesim/effects/psfs/analytical.py @@ -269,7 +269,7 @@ def get_fwhm_interp(self): fwhm = self.meta["fwhm"] if check_keys(fwhm, {"seeing", "seeing_unit", "pivot_wave", "pivot_wave_unit"}, action="warn"): logger.info("seeing and pivot supplied, using natural scale seeing law") - target, location, time = get_observation_info_from_cmds(self.cmds) + target, location, time, _ = get_observation_info_from_cmds(self.cmds) zenith_angle = get_zenith_angle(target, location, time) return partial(self.natural_scale, seeing=fwhm["seeing"]*u.Unit(fwhm["seeing_unit"]), diff --git a/scopesim/effects/sky_ter_curves.py b/scopesim/effects/sky_ter_curves.py index 8f19c991d..c9cdb9204 100644 --- a/scopesim/effects/sky_ter_curves.py +++ b/scopesim/effects/sky_ter_curves.py @@ -5,9 +5,10 @@ from astropy.table import Table from palace import palace +import skycalc_ipy from ..utils import (get_logger, from_currsys, from_rc_config, find_file, - zendist2airmass, get_zenith_angle, get_moon_phase, get_observation_info_from_cmds) + zendist2airmass, get_zenith_angle, get_moon_phase, get_observation_info_from_cmds, get_local_time) from .. import rc from ..effects import Effect, SkycalcTERCurve, TERCurve @@ -72,7 +73,7 @@ class PalaceAirglowEmission(Effect): def __init__(self, **kwargs): super().__init__(**kwargs) - self.target, self.location, self.time = get_observation_info_from_cmds(self.cmds) + self.target, self.location, self.time, self.brightness = get_observation_info_from_cmds(self.cmds) self.parlist = self.get_palace_inputs(**kwargs) @@ -108,7 +109,7 @@ def apply_to(self, obj, **kwargs): return obj def get_palace_inputs(self, **kwargs): - package_name = getattr(self.cmds, "package_name", "palace") + package_name = getattr(self.cmds, "package_name", None) default_outdir = f"{from_rc_config('!SIM.file.local_packages_path')}/{package_name}" parlist = {"species": kwargs.get("species", "all"), "srf": kwargs.get("srf", 130.0), @@ -155,7 +156,7 @@ def get_palace_inputs(self, **kwargs): parlist["dlam"] = dlam ## month and time - mbin, tbin = self.get_mbin_tbin(self.time) + mbin, tbin = self.get_mbin_tbin() parlist["mbin"] = mbin parlist["tbin"] = tbin @@ -164,15 +165,18 @@ def get_palace_inputs(self, **kwargs): return parlist - @staticmethod - def get_mbin_tbin(obstime): - mbin = obstime.datetime.month - tbin = (obstime.datetime.hour - + obstime.datetime.minute / 60 - + obstime.datetime.second / 3600) - if not ((0 <= tbin < 6) or (18 <= tbin < 24)): - logger.warning("Local time is outside of the range covered by the PALACE model (18-6h). Defaulting to tbin=0 (all times).") - tbin = 0 + def get_mbin_tbin(self): + localtime = get_local_time(self.time, self.location) + mbin = localtime.datetime.month + if self.brightness is None: + tbin = (localtime.datetime.hour + + localtime.datetime.minute / 60. + + localtime.datetime.second / 3600.) + if not ((0 <= tbin < 6) or (18 <= tbin < 24)): + logger.warning("Local time is outside of the range covered by the PALACE model (18-6h). Defaulting to tbin=0 (all times).") + tbin = 0 + else: + tbin = int(1) if self.brightness == 'bright' else int(6) if self.brightness == 'dark' else int(3) return mbin, tbin def run_palace(self): @@ -224,6 +228,7 @@ class SkyBackgroundTERCurve(SkycalcTERCurve): * disable_transmission: True by default * disable_airglow: True by default + * skycalc_query_timeout: 2 (in seconds) by default The following SkyCalc input parameters can be supplied in kwargs: @@ -268,6 +273,7 @@ class SkyBackgroundTERCurve(SkycalcTERCurve): kwargs: disable_transmission: True disable_airglow: True + skycalc_query_timeout: "!ATMO.skycalc_timeout" pwv: "!ATMO.pwv" wmin: "!SIM.spectral.wave_min" wmax: "!SIM.spectral.wave_max" @@ -282,10 +288,13 @@ class SkyBackgroundTERCurve(SkycalcTERCurve): def __init__(self, **kwargs): self.cmds = kwargs.get("cmds") - self.target, self.location, self.time = get_observation_info_from_cmds(self.cmds) + self.target, self.location, self.time, self.brightness = get_observation_info_from_cmds(self.cmds) skycalc_params = self.get_skycalc_inputs(**kwargs) kwargs.update(skycalc_params) + + skycalc_ipy.core.SkyModel.REQUEST_TIMEOUT = from_currsys(kwargs.get("skycalc_query_timeout", 2), + self.cmds) super().__init__(**kwargs) if self.meta.get("disable_transmission", True): @@ -354,13 +363,19 @@ def get_skycalc_inputs(self, **kwargs): params["pwv_mode"] = 'pwv' else: params["pwv_mode"] = 'season' - params["season"] = self.time.datetime.month//2 + 1 if self.time.datetime.month != 12 else 1 - if 18 <= self.time.datetime.hour <= 24: - params["time"] = 1 - elif 0 <= self.time.datetime.hour < 6: - params["time"] = 2 - else: - params["time"] = 3 + params["season"] = self.time.datetime.month//2 + 1 if self.time.datetime.month != 12 else 1 + if self.brightness is None: + localtime = get_local_time(self.time, self.location) + if 18 <= localtime.datetime.hour < 22: + params["time"] = 1 + elif 23 <= localtime.datetime.hour < 24 or 0 <= localtime.datetime.hour < 2: + params["time"] = 2 + elif 2 <= localtime.datetime.hour < 6: + params["time"] = 3 + else: + params["time"] = 0 + else: + params["time"] = 1 if self.brightness == "bright" else 2 if self.brightness == "dark" else 0 return params diff --git a/scopesim/utils.py b/scopesim/utils.py index ea1ae1488..abb45ecd5 100644 --- a/scopesim/utils.py +++ b/scopesim/utils.py @@ -1046,7 +1046,7 @@ def get_observation_info_from_cmds(cmds): :param cmds: output dict from UserCommands :return: (target: SkyCoord, location: EarthLocation, time: Time) """ - location, time, target = None, None, None + location, time, brightness, target = None, None, None, None if check_keys(cmds, {"!ATMO.longitude", "!ATMO.latitude", "!ATMO.altitude"}, action="error"): location = get_location(lon=from_currsys("!ATMO.longitude", cmds), @@ -1059,6 +1059,7 @@ def get_observation_info_from_cmds(cmds): logger.info(f'Using !OBS.mjdobs {time_str} for observation time') else: time_str = from_currsys("!OBS.brightness", cmds) + brightness = time_str logger.info(f'Using !OBS.brightness {time_str} for observation time') time = resolve_time(time_str, location=location) @@ -1068,12 +1069,12 @@ def get_observation_info_from_cmds(cmds): try: target_kwargs[k] = from_currsys(v, cmds) except: - logger.warning(f'Target coord {v} not set in !OBS config.') + logger.debug(f'Target coord {v} not set in !OBS config.') target_kwargs[k] = None target_kwargs["obstime"] = time target_kwargs["location"] = location target = get_target(**target_kwargs) - return target, location, time + return target, location, time, brightness def get_target(alt: float = None, az: float = 0, @@ -1154,8 +1155,12 @@ def is_night(obstime: Time, location: EarthLocation, return_midnight: bool = Tru return False return True +def get_local_time(time: Time, location: EarthLocation) -> Time: + utcoffset = (location.lon.deg / 15.) * u.hour + return time + utcoffset -def resolve_time(time_str, location: EarthLocation | None = None): +def resolve_time(time_str: int | float | Literal['bright', 'gray', 'grey', 'dark'] | str, + location: EarthLocation | None = None): """ Parses the time input. Checks if the time is in MJD, or ISOT format, or indicates sky brightness ["bright", "gray", "dark"]. @@ -1185,7 +1190,7 @@ def resolve_time(time_str, location: EarthLocation | None = None): if t is None: kw = {"bright":"full", "gray":"half", "dark":"new"} - t = Time(get_next_moon(kw[time_str]), format="isot", location=location) + t = Time(get_next_moon(kw[time_str], location), format="isot", location=location) # check if t is at night if location is not None: @@ -1210,7 +1215,7 @@ def get_moon_phase(time: Time, get_elongation=False): else: return np.arctan2(sun.distance * np.sin(elongation), moon.distance - sun.distance * np.cos(elongation)) else: - raise ValueError(f"Invalid time type: {type(time)}, should be astropy Time object.") + raise ValueError(f"Invalid input, should be astropy Time object, got {type(time)} instead.") def get_moon_fli(phase_angle: u.Quantity): """ @@ -1222,33 +1227,42 @@ def get_moon_fli(phase_angle: u.Quantity): else: raise ValueError(f"Invalid phase angle type: {type(phase_angle)}, should be astropy Quantity object with angle units.") -def get_next_moon(moontype="full"): +def get_next_moon(moontype="full", location: EarthLocation = None): """ Get time of the next closest moon phase of given moon type (full, half or new). """ + geodetic = location.to_geodetic() + location_key = (float(geodetic.lon.to_value(u.deg)), float(geodetic.lat.to_value(u.deg)), + float(geodetic.height.to_value(u.m))) today = Time.now().isot.split("T")[0] - return _get_next_moon_cached(moontype, today) - + return _get_next_moon_cached(moontype, today, location_key) @functools.lru_cache(maxsize=32) -def _get_next_moon_cached(moontype="full", today=None): +def _get_next_moon_cached(moontype="full", today=None, location_key=None): + if location_key is None: + raise ValueError("location_key must be provided.") + lon_deg, lat_deg, height_m = location_key + location = EarthLocation.from_geodetic(lon=lon_deg * u.deg, lat=lat_deg * u.deg, height=height_m * u.m) + now = Time.now() - times = now + np.linspace(0, 30, 1000)*u.day - phases = get_moon_phase(times) - flis = get_moon_fli(phases) - next_full = times[np.argmax(flis)] - next_new = times[np.argmin(flis)] - prev_full = next_full - 29.53*u.day - prev_new = next_new - 29.53*u.day - if min(next_new, next_full) - now > now - max(prev_new, prev_full): - next_half = min(next_new, next_full) - 7.38*u.day - else: - next_half = min(next_new, next_full) + 7.38*u.day + utctimes = now + np.linspace(0, 28, 1000) * u.day + sunalts = np.array([get_sun(utctime).transform_to(AltAz(obstime=utctime, location=location)).alt.deg + for utctime in utctimes]) + + times = utctimes[sunalts < 0.0] # sun down + flis = get_moon_fli(get_moon_phase(times)) # fractional lunar illum + moonalts = np.array([get_body("moon", utctime).transform_to(AltAz(obstime=utctime, location=location)).alt.deg + for utctime in times]) + if moontype == "full": - return next_full.isot.split('T')[0]+"T00:00:00" + mask = (flis > 0.95) & (moonalts > 0.0) # few days around full moon, moon is up + nextm = (times[mask])[np.argmax(moonalts[mask])] # when moon is at peak altitude during full moon elif moontype == "new": - return next_new.isot.split('T')[0]+"T00:00:00" + mask = (flis < 0.05) & (moonalts < 0.0) # few days around new moon, moon is down + nextm = times[mask][np.argmin(flis[mask])] elif moontype == "half": - return next_half.isot.split('T')[0]+"T00:00:00" + mask = (moonalts > 0.0) & (flis > 0.4) & (flis < 0.6) # few days around half moon, moon is up + nextm = times[mask][np.argmax(moonalts[mask])] else: raise ValueError(f"Invalid moon type: {moontype}, should be 'full', 'new' or 'half'.") + return nextm \ No newline at end of file From 713cfd1b69852c2b780e81ea829ceda494c729f1 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Fri, 7 Aug 2026 12:45:52 -0700 Subject: [PATCH 37/43] Code cleanup: simplify and reformat inline loops, conditionals, and assignments --- scopesim/effects/illumination.py | 19 ++++++------------- scopesim/effects/surface_list.py | 5 +---- scopesim/optics/optical_train.py | 3 +-- scopesim/optics/surface_utils.py | 7 ++----- 4 files changed, 10 insertions(+), 24 deletions(-) diff --git a/scopesim/effects/illumination.py b/scopesim/effects/illumination.py index 75979af40..045a92c17 100644 --- a/scopesim/effects/illumination.py +++ b/scopesim/effects/illumination.py @@ -540,22 +540,15 @@ def __init__( "downstream_throughput_filename": downstream_throughput_filename, "downstream_throughput_filenames": downstream_throughput_filenames, }) - self._surface_list = ( - surface_list if surface_list is not None - else SurfaceList(filename=filename, cmds=self.cmds) - ) - self._detector_qe = _detector_qe_from_spec( - detector_qe, filename=detector_qe_filename, cmds=self.cmds, - ) + self._surface_list = surface_list if surface_list is not None else SurfaceList(filename=filename, cmds=self.cmds) + self._detector_qe = _detector_qe_from_spec(detector_qe, filename=detector_qe_filename, cmds=self.cmds) downstream_specs = [] downstream_specs.extend(_as_effect_specs(downstream_throughputs)) downstream_specs.extend(_as_effect_specs(downstream_throughput)) downstream_specs.extend(_as_effect_specs(downstream_throughput_filenames)) downstream_specs.extend(_as_effect_specs(downstream_throughput_filename)) self._downstream_throughput_specs = downstream_specs - self._downstream_throughputs = [ - _effect_from_spec(spec, cmds=self.cmds) for spec in downstream_specs - ] + self._downstream_throughputs = [_effect_from_spec(spec, cmds=self.cmds) for spec in downstream_specs] self._positional_qe = positional_qe self._last_value = None @@ -579,9 +572,7 @@ def apply_to(self, obj, **kwargs): def background_value(self, image_plane: ImagePlane) -> float: """Return the scalar background in ``ph s-1 pixel-1``.""" rate_per_arcsec2 = self._background_rate_per_arcsec2(image_plane) - pixel_area = image_plane_pixel_area( - image_plane.header, self.cmds, - ).to_value(u.arcsec**2) + pixel_area = image_plane_pixel_area(image_plane.header, self.cmds).to_value(u.arcsec**2) return rate_per_arcsec2 * pixel_area def _background_rate_per_arcsec2(self, image_plane: ImagePlane) -> float: @@ -603,8 +594,10 @@ def _background_rate_per_arcsec2(self, image_plane: ImagePlane) -> float: qe_values=qe_values, emission_phase=self.meta["emission_phase"], ) + if spectrum is not None: spectrum = spectrum * self._downstream_throughput_values(wave) + area = quantify(from_currsys(self.meta["area"], self.cmds), u.m**2) rate = integrate_spectral_background( spectrum, diff --git a/scopesim/effects/surface_list.py b/scopesim/effects/surface_list.py index ad1431669..12e37a1cf 100644 --- a/scopesim/effects/surface_list.py +++ b/scopesim/effects/surface_list.py @@ -34,10 +34,7 @@ def __init__(self, **kwargs): if self.table is not None: for i in range(len(self.table)): surf_kwargs = deepcopy(self.table.meta) - rowdict = { - colname: _row_quantity_or_value(self.table, colname, i) - for colname in self.table.colnames - } + rowdict = { colname: _row_quantity_or_value(self.table, colname, i) for colname in self.table.colnames} surf_kwargs.update(rowdict) surf_kwargs["cmds"] = self.cmds surf_kwargs["filename"] = from_currsys(surf_kwargs["filename"], self.cmds) diff --git a/scopesim/optics/optical_train.py b/scopesim/optics/optical_train.py index 8466aff75..48bc5c274 100644 --- a/scopesim/optics/optical_train.py +++ b/scopesim/optics/optical_train.py @@ -593,8 +593,7 @@ def observe(self, orig_source=None, update=True, **kwargs): # [2D - Vibration, flat fielding, chopping+nodding] impeffs = self.optics_manager.image_plane_effects nobar = len(impeffs) <= 1 - for effect in tqdm(impeffs, disable=nobar, - desc=" Image Plane effects"): + for effect in tqdm(impeffs, disable=nobar, desc=" Image Plane effects"): for ii, image_plane in enumerate(self.image_planes): self.image_planes[ii] = effect.apply_to(image_plane) diff --git a/scopesim/optics/surface_utils.py b/scopesim/optics/surface_utils.py index 3d7884ff9..0226eca1c 100644 --- a/scopesim/optics/surface_utils.py +++ b/scopesim/optics/surface_utils.py @@ -41,9 +41,7 @@ def make_emission_from_emissivity(temp: u.Quantity[u.K], flux *= emiss_src_spec flux.meta["temperature"] = temp flux.meta["solid_angle"] = u.sr**-1 - flux.meta["history"] = [ - "Created from Blackbody curve. Units are per steradian", - ] + flux.meta["history"] = ["Created from Blackbody curve. Units are per steradian",] return flux @@ -87,8 +85,7 @@ def make_emission_from_array(flux, wave, meta) -> SourceSpectrum: flux = normalise_flux_if_binned(flux, wave) orig_unit = flux.unit - flux = SourceSpectrum(Empirical1D, points=wave, - lookup_table=flux) + flux = SourceSpectrum(Empirical1D, points=wave, lookup_table=flux) flux.meta["solid_angle"] = angle flux.meta["history"] = [ f"Created from emission array with units {orig_unit}", From d69d575b79ff0ab94b490950171937fdaa30dc09 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Wed, 2 Sep 2026 15:10:03 -0700 Subject: [PATCH 38/43] Refine sky wavelength bounds check to avoid redundant warnings. --- scopesim/effects/sky_ter_curves.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scopesim/effects/sky_ter_curves.py b/scopesim/effects/sky_ter_curves.py index c9cdb9204..e5839472c 100644 --- a/scopesim/effects/sky_ter_curves.py +++ b/scopesim/effects/sky_ter_curves.py @@ -321,10 +321,12 @@ def get_skycalc_inputs(self, **kwargs): params[k] = params[k] * scale_factor params["wunit"] = "nm" if params["wmin"] < 300.: - logger.warning(f"wmin {params['wmin']} is below the minimum wavelength covered by SkyCalc. Setting to 300 nm.") + if not np.allclose(params["wmin"], 300): + logger.warning(f"wmin {params['wmin']} is below the minimum wavelength covered by SkyCalc. Setting to 300 nm.") params["wmin"] = 300. if params["wmax"] > 30000.: - logger.warning(f"wmax {params['wmax']} is above the maximum wavelength covered by SkyCalc. Setting to 30000 nm.") + if not np.allclose(params["wmax"], 30000): + logger.warning(f"wmax {params['wmax']} is above the maximum wavelength covered by SkyCalc. Setting to 30000 nm.") params["wmax"] = 30000. if kwargs.get("disable_airglow", True): From ed222c1f5a3c33da2ef086c736f2ec8cca070efd Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Wed, 2 Sep 2026 16:00:48 -0700 Subject: [PATCH 39/43] Fix a shadowing bug with alpha in Moffat/AOEnhanceablePSF that produced worse natural seeing throughput. Improve performance of interpolation of the PFS FWHM and togglign between various AO settings. Remove obsolete AO table and update ZImager plotting (consistent with plot previously sent to DJR). Hack at validation.py to cleanup some AI cruft. Deleted the `ao_table_best.dat` file as it is no longer in use. Streamlined imports and reorganized plotting logic in the ZImager notebook, adding adjustable paper settings for consistent styling. Simplified dependencies and optimized function utilization. --- scopesim/effects/psfs/analytical.py | 103 ++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 29 deletions(-) diff --git a/scopesim/effects/psfs/analytical.py b/scopesim/effects/psfs/analytical.py index f29101733..d7f625f3f 100644 --- a/scopesim/effects/psfs/analytical.py +++ b/scopesim/effects/psfs/analytical.py @@ -255,9 +255,25 @@ class MoffatPSF(AnalyticalPSF): def __init__(self, **kwargs): super().__init__(**kwargs) + self._fwhm_interp = None - self.alpha = self.meta["alpha"] - self.fwhm = self.get_fwhm_interp() + def fwhm(self, wavelengths): + target, location, time, _ = get_observation_info_from_cmds(self.cmds) + if isinstance(self.meta, dict): + fwhm = self.meta["fwhm"] + key = (fwhm["seeing"], fwhm["seeing_unit"], fwhm["pivot_wave"], fwhm["pivot_wave_unit"], + target, location, time) + else: + key = self.meta["fwhm"], target, location, time + + if self._fwhm_interp is None or self._fwhm_interp[1] != key: + self._fwhm_interp = self.get_fwhm_interp(), key + + return self._fwhm_interp[0](wavelengths) + + @property + def alpha(self): + return self.meta["alpha"] def get_fwhm_interp(self): """ @@ -278,16 +294,16 @@ def get_fwhm_interp(self): elif check_keys(fwhm, {"seeing", "seeing_unit"}, action="error"): logger.info("only seeing supplied, FWHM is wavelength independent") - return lambda wavelengths: fwhm["seeing"] * u.Unit(fwhm["seeing_unit"]) + return lambda wavelengths: np.full(wavelengths.shape, fwhm["seeing"], dtype=float) * u.Unit(fwhm["seeing_unit"]) if isinstance(self.meta["fwhm"], (int, float)): logger.info("float value supplied, assuming arcsec") - return lambda wavelengths: self.meta["fwhm"] * u.arcsec + return lambda wavelengths: np.full(wavelengths.shape, self.meta["fwhm"], dtype=float) * u.arcsec if isinstance(self.meta["fwhm"], str): logger.info("filename supplied for FWHM") self.table = DataContainer(filename=find_file(from_currsys(self.meta["fwhm"], self.cmds))).table - return self.fwhm_from_table(self.table) + return lambda wavelengths: self.fwhm_from_table(self.table)(wavelengths) * u.arcsec raise TypeError("fwhm kwarg must be of type dict or float or str") @@ -299,11 +315,9 @@ def get_kernel(self, fov): # representative points. Kernel sizing is driven by the broadest PSF # over the FOV, not by every spectral sample in the cube. wavelengths = self._sample_kernel_wavelengths(fov.waveset) - fwhms = quantify(self.fwhm(wavelengths), u.arcsec).to(u.arcsec) / pixel_scale - fwhms = np.atleast_1d(fwhms.value) - if fwhms.size == 1 and wavelengths.size > 1: - fwhms = np.full(wavelengths.size, fwhms.item()) - gammas = np.asarray(self.fwhm2gamma(fwhms, self.alpha), dtype=float) + fwhms = self.fwhm(wavelengths) / pixel_scale + alpha = self.alpha + gammas = np.asarray(self.fwhm2gamma(fwhms, alpha), dtype=float) target = self._target_enclosed_energy() max_ksize = self._max_kernel_size() @@ -311,10 +325,10 @@ def get_kernel(self, fov): if max_ksize is not None: ksize = min(ksize, max_ksize) - kernel, norm = self._make_moffat_kernel(gammas, ksize) + kernel, norm = self._make_moffat_kernel(gammas, alpha, ksize) while norm < target and (max_ksize is None or ksize < max_ksize): ksize = self._next_kernel_size(ksize, max_ksize) - kernel, norm = self._make_moffat_kernel(gammas, ksize) + kernel, norm = self._make_moffat_kernel(gammas, alpha, ksize) if norm < target: logger.warning( @@ -344,8 +358,7 @@ def _sample_kernel_wavelengths(self, waveset: u.Quantity) -> u.Quantity: if waveset.size <= max_samples: return waveset - indices = np.unique(np.linspace( - 0, waveset.size - 1, max_samples).round().astype(int)) + indices = np.unique(np.linspace(0, waveset.size - 1, max_samples).round().astype(int)) return waveset[indices] def _target_enclosed_energy(self) -> float: @@ -371,8 +384,8 @@ def _minimum_kernel_size(self, max_fwhm_pix: float) -> int: kx = float(from_currsys(self.meta.get("kernel_size", 4.0), self.cmds)) return self._ensure_odd_int(kx * max_fwhm_pix) - def _make_moffat_kernel(self, gammas: np.ndarray, ksize: int) -> tuple[np.ndarray, float]: - amplitude = (self.alpha - 1) / (np.pi * gammas**2) + def _make_moffat_kernel(self, gammas: np.ndarray, alpha:float, ksize: int) -> tuple[np.ndarray, float]: + amplitude = (alpha - 1) / (np.pi * gammas**2) x, y = np.meshgrid( np.arange(ksize) - ksize // 2, np.arange(ksize) - ksize // 2, @@ -384,7 +397,7 @@ def _make_moffat_kernel(self, gammas: np.ndarray, ksize: int) -> tuple[np.ndarra x_0=0, y_0=0, gamma=gammas[:, None, None], - alpha=self.alpha, + alpha=alpha, ) kernel = np.mean(cube, axis=0) if from_currsys(self.meta.get("rounded_edges", False), self.cmds): @@ -417,7 +430,7 @@ def natural_scale(wavelengths: u.Quantity, https://opg.optica.org/josa/fulltext.cfm?uri=josa-68-7-877&id=57124 https://www.mdpi.com/2072-4292/14/2/405 """ - return seeing * (wavelengths / pivot) ** -0.2 * 1 / np.cos(zenith_angle.to(u.rad)) ** .6 + return seeing * (wavelengths.to_value(u.um) / pivot.to_value(u.um)) ** -0.2 * 1 / np.cos(zenith_angle.to(u.rad)) ** .6 @staticmethod def fwhm2gamma(fwhm: u.Quantity, alpha) -> u.Quantity: @@ -431,7 +444,7 @@ def fwhm_from_table(table): raise ValueError("Table must contain 'wavelength' and 'fwhm' columns.") wave_array = quantity_from_table("wavelength", table, "um").to_value(u.um) fwhm_array = table["fwhm"] - return make_interp_spline(wave_array, fwhm_array) # returns bspline instance + return lambda wavelengths: make_interp_spline(wave_array, fwhm_array)(wavelengths.to(u.um).value) # returns bspline instance class AOEnhanceablePSF(MoffatPSF): @@ -479,24 +492,56 @@ def __init__(self, **kwargs): kwargs["alpha"] = None if "alpha" not in kwargs else kwargs["alpha"] kwargs["fwhm"] = 1.0 if "fwhm" not in kwargs else kwargs["fwhm"] super().__init__(**kwargs) + self._ao_alpha = None + self._ao_interp = None + + @property + def natural_alpha(self): + return super().alpha + + @property + def ao_alpha(self): + if self._ao_alpha is None or self._ao_alpha[1] != self.meta['ao_table']: + _, alpha = self.ao_table_data() + self._ao_alpha = alpha, self.meta['ao_table'] + return self._ao_alpha[0] + @property + def alpha(self): + return self.ao_alpha if self.meta['enable_ao'] else self.natural_alpha + + def ao_table_data(self): aotab = DataContainer(filename=find_file(from_currsys(self.meta["ao_table"], self.cmds))).table - self.ao_scale = self.fwhm_from_table(aotab) if "alpha" in aotab.meta: - self.alpha = aotab.meta["alpha"] - logger.info(f"Alpha parameter found in AO table header: {self.alpha}") + alpha = aotab.meta["alpha"] + logger.info(f"Alpha parameter found in AO table header: {alpha}") elif self.meta["alpha"] is not None: - self.alpha = self.meta["alpha"] - logger.info(f"Alpha parameter found in kwargs: {self.alpha}") + alpha = self.meta["alpha"] + logger.info(f"Alpha parameter found in kwargs: {alpha}") else: raise ValueError("Alpha parameter missing: Not found in ao_table header or kwargs.") - if self.meta["enable_ao"]: - if self.meta["is_absolute"]: - self.fwhm = self.ao_scale - else: - self.fwhm = lambda wavelengths: (self.fwhm(wavelengths) * self.ao_scale(wavelengths)).to(u.arcsec) + return aotab, alpha + + @property + def ao_scale(self): + if self._ao_interp is None or self._ao_interp[1] != self.meta['ao_table']: + ao_table, _ = self.ao_table_data() + self._ao_interp = self.fwhm_from_table(ao_table), self.meta['ao_table'] + return self._ao_interp[0] + + def ao_fwhm(self, wavelengths): + ao = self.ao_scale(wavelengths) + if not self.meta["is_absolute"]: + ao *= self.natural_fwhm(wavelengths) + return quantify(ao, u.arcsec) + + def natural_fwhm(self, wavelengths): + return super().fwhm(wavelengths) + + def fwhm(self, wavelengths): + return self.ao_fwhm(wavelengths) if self.meta["enable_ao"] else self.natural_fwhm(wavelengths) def wfe2gauss(wfe, wave, width=None): From a3a077686a171a13eafcb6c2238d5b69babe14e1 Mon Sep 17 00:00:00 2001 From: Jeb Bailey Date: Wed, 2 Sep 2026 16:48:58 -0700 Subject: [PATCH 40/43] Improve handling of unit conversion and caching for to standardize and speed things up. --- scopesim/effects/psfs/analytical.py | 48 ++++++++++++++++------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/scopesim/effects/psfs/analytical.py b/scopesim/effects/psfs/analytical.py index d7f625f3f..dc39f5207 100644 --- a/scopesim/effects/psfs/analytical.py +++ b/scopesim/effects/psfs/analytical.py @@ -258,13 +258,14 @@ def __init__(self, **kwargs): self._fwhm_interp = None def fwhm(self, wavelengths): - target, location, time, _ = get_observation_info_from_cmds(self.cmds) - if isinstance(self.meta, dict): - fwhm = self.meta["fwhm"] - key = (fwhm["seeing"], fwhm["seeing_unit"], fwhm["pivot_wave"], fwhm["pivot_wave_unit"], - target, location, time) + fwhm = self.meta["fwhm"] + if isinstance(fwhm, dict) and {"pivot_wave", "pivot_wave_unit"}.issubset(fwhm): + target, location, time, _ = get_observation_info_from_cmds(self.cmds) + key = tuple(fwhm.items()), target, location, time + elif isinstance(fwhm, dict): + key = tuple(fwhm.items()) else: - key = self.meta["fwhm"], target, location, time + key = fwhm if self._fwhm_interp is None or self._fwhm_interp[1] != key: self._fwhm_interp = self.get_fwhm_interp(), key @@ -280,30 +281,32 @@ def get_fwhm_interp(self): Parses supplied FWHM input kwarg and returns a function that takes wavelength as input and returns FWHM. Overwrite this function for subclassing if FWHM input options change. """ - if isinstance(self.meta["fwhm"], dict): + fwhm = self.meta["fwhm"] + if isinstance(fwhm, dict): logger.info("dict supplied for FWHM") - fwhm = self.meta["fwhm"] if check_keys(fwhm, {"seeing", "seeing_unit", "pivot_wave", "pivot_wave_unit"}, action="warn"): logger.info("seeing and pivot supplied, using natural scale seeing law") target, location, time, _ = get_observation_info_from_cmds(self.cmds) zenith_angle = get_zenith_angle(target, location, time) + seeing = (fwhm["seeing"] * u.Unit(fwhm["seeing_unit"])).to(u.arcsec) + pivot = (fwhm["pivot_wave"] * u.Unit(fwhm["pivot_wave_unit"])).to(u.um) - return partial(self.natural_scale, seeing=fwhm["seeing"]*u.Unit(fwhm["seeing_unit"]), - pivot=fwhm["pivot_wave"]*u.Unit(fwhm["pivot_wave_unit"]), - zenith_angle=zenith_angle*u.deg) + return partial(self.natural_scale, seeing=seeing, pivot=pivot, zenith_angle=zenith_angle*u.deg) elif check_keys(fwhm, {"seeing", "seeing_unit"}, action="error"): logger.info("only seeing supplied, FWHM is wavelength independent") - return lambda wavelengths: np.full(wavelengths.shape, fwhm["seeing"], dtype=float) * u.Unit(fwhm["seeing_unit"]) + seeing = (fwhm["seeing"] * u.Unit(fwhm["seeing_unit"])).to_value(u.arcsec) + return lambda wavelengths: np.full(wavelengths.shape, seeing, dtype=float) * u.arcsec - if isinstance(self.meta["fwhm"], (int, float)): + if isinstance(fwhm, (int, float)): logger.info("float value supplied, assuming arcsec") - return lambda wavelengths: np.full(wavelengths.shape, self.meta["fwhm"], dtype=float) * u.arcsec + return lambda wavelengths: np.full(wavelengths.shape, fwhm, dtype=float) * u.arcsec - if isinstance(self.meta["fwhm"], str): + if isinstance(fwhm, str): logger.info("filename supplied for FWHM") - self.table = DataContainer(filename=find_file(from_currsys(self.meta["fwhm"], self.cmds))).table - return lambda wavelengths: self.fwhm_from_table(self.table)(wavelengths) * u.arcsec + self.table = DataContainer(filename=find_file(from_currsys(fwhm, self.cmds))).table + interp = self.fwhm_from_table(self.table) + return lambda wavelengths: interp(wavelengths) * u.arcsec raise TypeError("fwhm kwarg must be of type dict or float or str") @@ -430,7 +433,7 @@ def natural_scale(wavelengths: u.Quantity, https://opg.optica.org/josa/fulltext.cfm?uri=josa-68-7-877&id=57124 https://www.mdpi.com/2072-4292/14/2/405 """ - return seeing * (wavelengths.to_value(u.um) / pivot.to_value(u.um)) ** -0.2 * 1 / np.cos(zenith_angle.to(u.rad)) ** .6 + return seeing * (wavelengths.to_value(pivot.unit) / pivot.value) ** -0.2 * 1 / np.cos(zenith_angle.to(u.rad)) ** .6 @staticmethod def fwhm2gamma(fwhm: u.Quantity, alpha) -> u.Quantity: @@ -444,7 +447,8 @@ def fwhm_from_table(table): raise ValueError("Table must contain 'wavelength' and 'fwhm' columns.") wave_array = quantity_from_table("wavelength", table, "um").to_value(u.um) fwhm_array = table["fwhm"] - return lambda wavelengths: make_interp_spline(wave_array, fwhm_array)(wavelengths.to(u.um).value) # returns bspline instance + interp = make_interp_spline(wave_array, fwhm_array) + return lambda wavelengths: interp(wavelengths.to_value(u.um)) class AOEnhanceablePSF(MoffatPSF): @@ -533,9 +537,9 @@ def ao_scale(self): def ao_fwhm(self, wavelengths): ao = self.ao_scale(wavelengths) - if not self.meta["is_absolute"]: - ao *= self.natural_fwhm(wavelengths) - return quantify(ao, u.arcsec) + if self.meta["is_absolute"]: + return ao * u.arcsec + return self.natural_fwhm(wavelengths) * ao def natural_fwhm(self, wavelengths): return super().fwhm(wavelengths) From 081f756f8699bece653c761c80bcab357ec4ad20 Mon Sep 17 00:00:00 2001 From: Yashvi-Sharma Date: Thu, 3 Sep 2026 18:55:32 -0700 Subject: [PATCH 41/43] fixed bright, grey, dark time params for moon background --- scopesim/effects/sky_ter_curves.py | 69 +++++++++++++++++++----------- scopesim/utils.py | 46 +++++++++----------- 2 files changed, 64 insertions(+), 51 deletions(-) diff --git a/scopesim/effects/sky_ter_curves.py b/scopesim/effects/sky_ter_curves.py index e5839472c..02269099b 100644 --- a/scopesim/effects/sky_ter_curves.py +++ b/scopesim/effects/sky_ter_curves.py @@ -341,32 +341,26 @@ def get_skycalc_inputs(self, **kwargs): params["ecl_lon"] = target_ecl.lon.wrap_at(180*u.deg).deg params["ecl_lat"] = target_ecl.lat.wrap_at(90*u.deg).deg - moon = get_body("moon", self.time) - alt_moon = moon.transform_to(AltAz(obstime=self.time, location=self.location)).alt.deg - z_moon = 90 - alt_moon z_target = get_zenith_angle(self.target, self.location, self.time) - moon_target_sep = moon.separation(self.target).deg - if (abs(z_target - z_moon) < moon_target_sep) and (moon_target_sep < abs(z_target + z_moon)): - params.update({ - "airmass": zendist2airmass(z_target), - "incl_moon": "Y", - "moon_sun_sep": get_moon_phase(self.time, get_elongation=True).deg, - "moon_target_sep": moon_target_sep, - "moon_alt": alt_moon, - "moon_earth_dist": max(0.91, min(moon.distance.km / 384400.0, 1.08)) - }) - else: - params["incl_moon"] = "N" - params["airmass"] = zendist2airmass(z_target) - - if params["pwv_mode"] == 'season': - if from_currsys("!ATMO.location", self.cmds) not in ["Paranal", "Armazones"]: - logger.warning("Seasonal PWV mode is only calibrated for Paranal/Armazones. Defaulting to pwv_mode='pwv'.") - params["pwv_mode"] = 'pwv' - else: - params["pwv_mode"] = 'season' - params["season"] = self.time.datetime.month//2 + 1 if self.time.datetime.month != 12 else 1 if self.brightness is None: + moon = get_body("moon", self.time) + alt_moon = moon.transform_to(AltAz(obstime=self.time, location=self.location)).alt.deg + z_moon = 90 - alt_moon + moon_target_sep = moon.separation(self.target).deg + if (abs(z_target - z_moon) < moon_target_sep) and (moon_target_sep < abs(z_target + z_moon)): + params.update({ + "airmass": zendist2airmass(z_target), + "incl_moon": "Y", + "moon_sun_sep": get_moon_phase(self.time, get_elongation=True).deg, + "moon_target_sep": moon_target_sep, + "moon_alt": alt_moon, + "moon_earth_dist": max(0.91, min(moon.distance.km / 384400.0, 1.08)) + }) + else: + params.update({ + "airmass": zendist2airmass(z_target), + "incl_moon": "N"}) + localtime = get_local_time(self.time, self.location) if 18 <= localtime.datetime.hour < 22: params["time"] = 1 @@ -377,7 +371,32 @@ def get_skycalc_inputs(self, **kwargs): else: params["time"] = 0 else: - params["time"] = 1 if self.brightness == "bright" else 2 if self.brightness == "dark" else 0 + params["time"] = 0 + if self.brightness == 'bright': + params.update({ + "airmass": zendist2airmass(z_target), + "incl_moon": "Y", + "moon_sun_sep": 180.0, "moon_target_sep": 60.0, "moon_alt": 45.0, "moon_earth_dist": 1.0}) + elif self.brightness == 'grey' or self.brightness == 'gray': + params.update({ + "airmass": zendist2airmass(z_target), + "incl_moon": "Y", + "moon_sun_sep": 90.0, "moon_target_sep": 60.0, "moon_alt": 45.0, "moon_earth_dist": 1.0}) + elif self.brightness == 'dark': + params.update({ + "airmass": zendist2airmass(z_target), + "incl_moon": "N"}) + else: + raise ValueError(f"Invalid brightness value: {self.brightness}. Must be one of 'bright', 'grey', 'gray', or 'dark'.") + + if params["pwv_mode"] == 'season': + if from_currsys("!ATMO.location", self.cmds) not in ["Paranal", "Armazones"]: + logger.warning("Seasonal PWV mode is only calibrated for Paranal/Armazones. Defaulting to pwv_mode='pwv'.") + params["pwv_mode"] = 'pwv' + else: + params["pwv_mode"] = 'season' + params["season"] = self.time.datetime.month//2 + 1 if self.time.datetime.month != 12 else 1 + return params diff --git a/scopesim/utils.py b/scopesim/utils.py index abb45ecd5..b90f7363f 100644 --- a/scopesim/utils.py +++ b/scopesim/utils.py @@ -1054,14 +1054,11 @@ def get_observation_info_from_cmds(cmds): alt=from_currsys("!ATMO.altitude", cmds)) if check_keys(cmds, {"!OBS.mjdobs", "!OBS.brightness"}, action="error", all_any="any"): - if "!OBS.mjdobs" in cmds: - time_str = from_currsys("!OBS.mjdobs", cmds) - logger.info(f'Using !OBS.mjdobs {time_str} for observation time') - else: - time_str = from_currsys("!OBS.brightness", cmds) - brightness = time_str - logger.info(f'Using !OBS.brightness {time_str} for observation time') + timekey = "!OBS.mjdobs" if "!OBS.mjdobs" in cmds else "!OBS.brightness" + time_str = from_currsys(timekey, cmds) time = resolve_time(time_str, location=location) + if timekey == "!OBS.brightness": + brightness = time_str target_kwargs: dict[str, Any] = {'alt': '!OBS.alt', 'az': '!OBS.az', 'ra': '!OBS.ra', 'dec': '!OBS.dec', 'airmass': '!OBS.airmass'} @@ -1160,37 +1157,34 @@ def get_local_time(time: Time, location: EarthLocation) -> Time: return time + utcoffset def resolve_time(time_str: int | float | Literal['bright', 'gray', 'grey', 'dark'] | str, - location: EarthLocation | None = None): + location: EarthLocation | None = None) -> Time | None: """ Parses the time input. Checks if the time is in MJD, or ISOT format, or indicates sky brightness ["bright", "gray", "dark"]. If it is supplied as a sky brightness string, the next corresponding moon phase time is calculated and used. If the parsed time is not after sunset, the corresponding midnight time of that date is returned. """ - t = None - if isinstance(time_str, int) or isinstance(time_str, float): ## if time_str is a numeric MJD value + if isinstance(time_str, int) or isinstance(time_str, float): ## if time_str is a numeric MJD value logger.info(f"Resolving time: {time_str} assuming MJD format and UTC scale") - try: - t = Time(time_str, format="mjd", location=location) - except Exception as e: - logger.warning(f"Failed to parse time from {time_str}: {e}. Defaulting to 'dark'.") - time_str = "dark" + t = Time(time_str, format="mjd", location=location) + elif isinstance(time_str, str): - if ('T' in time_str) and (':' in time_str): ## ISOT format + if ('T' in time_str) and (':' in time_str): ## ISOT format logger.info(f"Resolving time: {time_str} assuming ISOT format and UTC scale") t = Time(time_str, format="isot", location=location) - elif time_str == "grey": - time_str = "gray" - elif time_str not in ["bright", "gray", "dark"]: - logger.warning(f"Unrecognized time string input: {time_str}. Defaulting to 'dark'.") - time_str = "dark" + elif time_str in ["bright", "gray", "grey", "dark"]: + logger.info(f"Brightness level supplied instead of time, {time_str}") + t = Time.now(location=location) + else: + logger.error(f"Unrecognized string input for time: {time_str}.") + raise ValueError(f"Unrecognized string input for time: {time_str}.") else: - logger.warning(f"Invalid time input type: {type(time_str)}, should be a string or numeric MJD value. Defaulting to 'dark'.") - time_str = "dark" + logger.error(f"Invalid time input type: {type(time_str)}, should be a string or numeric MJD value.") + raise ValueError(f"Invalid time input type: {type(time_str)}, should be a string or numeric MJD value.") - if t is None: - kw = {"bright":"full", "gray":"half", "dark":"new"} - t = Time(get_next_moon(kw[time_str], location), format="isot", location=location) + # if t is None: + # kw = {"bright":"full", "gray":"half", "dark":"new"} + # t = Time(get_next_moon(kw[time_str], location), format="isot", location=location) # check if t is at night if location is not None: From 2c1608a980e48ec943700ffe8931941465c6c101 Mon Sep 17 00:00:00 2001 From: Yashvi Sharma Date: Thu, 3 Sep 2026 19:16:41 -0700 Subject: [PATCH 42/43] Update Python version requirement to 3.12 to fix workflow errors --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cb4ecb884..f540be5e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ maintainers = [ readme = "README.md" keywords = [ "astronomy" ] dynamic = [ "classifiers" ] -requires-python = ">=3.10,<3.15" +requires-python = ">=3.12,<3.15" dependencies = [ "numpy (>=1.26.4,<2.3.0) ; python_version >= '3.10' and python_version < '3.13'", "numpy (>=2.3.5,<3.0.0) ; python_version >= '3.13'", From 70f6ae06f6ece39148680df012e089efd8503f17 Mon Sep 17 00:00:00 2001 From: Yashvi Sharma Date: Thu, 3 Sep 2026 19:24:39 -0700 Subject: [PATCH 43/43] Reverted Python version change --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f540be5e7..cb4ecb884 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ maintainers = [ readme = "README.md" keywords = [ "astronomy" ] dynamic = [ "classifiers" ] -requires-python = ">=3.12,<3.15" +requires-python = ">=3.10,<3.15" dependencies = [ "numpy (>=1.26.4,<2.3.0) ; python_version >= '3.10' and python_version < '3.13'", "numpy (>=2.3.5,<3.0.0) ; python_version >= '3.13'",