• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dsavransky / EXOSIMS / 14536111075

18 Apr 2025 01:44PM UTC coverage: 65.708% (+0.2%) from 65.466%
14536111075

push

github

web-flow
Merge pull request #402 from dsavransky/exozodi_fix

Exozodi fix. Extensive re-plumbing of all things related to exozodi.

210 of 269 new or added lines in 26 files covered. (78.07%)

14 existing lines in 7 files now uncovered.

9665 of 14709 relevant lines covered (65.71%)

0.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.31
/EXOSIMS/Prototypes/OpticalSystem.py
1
# -*- coding: utf-8 -*-
2
from EXOSIMS.util.vprint import vprint
1✔
3
from EXOSIMS.util.get_dirs import get_cache_dir
1✔
4
from EXOSIMS.util.utils import dictToSortedStr, genHexStr
1✔
5
from EXOSIMS.util.keyword_fun import get_all_args
1✔
6
from synphot.models import Box1D
1✔
7
from synphot.models import Gaussian1D
1✔
8
from synphot import SpectralElement, SourceSpectrum, Observation
1✔
9
import os.path
1✔
10
import numbers
1✔
11
import numpy as np
1✔
12
import astropy.units as u
1✔
13
import astropy.io.fits as fits
1✔
14
import scipy.interpolate
1✔
15
import scipy.optimize
1✔
16
import copy
1✔
17
import warnings
1✔
18
from EXOSIMS.util._numpy_compat import copy_if_needed
1✔
19

20

21
class OpticalSystem(object):
1✔
22
    r""":ref:`OpticalSystem` Prototype
23

24
    Args:
25
        obscurFac (float):
26
            Obscuration factor (fraction of primary mirror area obscured by secondary
27
            and spiders). Defaults to 0.1. Must be between 0 and 1.
28
            See :py:attr:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem.pupilArea`
29
            attribute definition.
30
        shapeFac (float):
31
            Shape Factor. Determines the ellipticity of the primary mirror.
32
            Defaults to np.pi/4 (circular aperture). See
33
            :py:attr:`~EXOSIMS.Prototypes.OpticalSystem..OpticalSystem.pupilArea`
34
            attribute definition.
35
        pupilDiam (float):
36
            Primary mirror major diameter (in meters).  Defaults to 4.
37
        intCutoff (float):
38
            Integration time cutoff (in days).  Determines the maximum time that is
39
            allowed per integration, and is used to limit integration target
40
            :math:`\Delta\mathrm{mag}`. Defaults to 50.
41
        scienceInstruments (list(dict)):
42
            List of dicts defining all science instruments. Minimally must contain
43
            one science instrument. Each dictionary must minimally contain a ``name``
44
            keyword, which must be unique to each instrument and must include the
45
            substring ``imager`` (for imaging devices) or ``spectro`` (for
46
            spectrometers). By default, this keyword is set to
47
            ``[{'name': 'imager'}]``, creating a single imaging science
48
            instrument. Additional parameters are filled in with default values set
49
            by the keywords below. For more details on science instrument definitions
50
            see :ref:`OpticalSystem`.
51
        QE  (float):
52
            Default quantum efficiency. Only used when not set in science instrument
53
            definition.  Defaults to 0.9
54
        optics (float):
55
            Total attenuation due to science instrument optics.  This is the net
56
            attenuation due to all optics in the science instrument path after the
57
            primary mirror, excluding any starlight suppression system (i.e.,
58
            coronagraph) optics. Only used when not set in science instrument
59
            definition. Defaults to 0.5
60
        FoV (float):
61
            Default instrument half-field of view (in arcseconds). Only used when not
62
            set in science instrument definition. Defaults to 10
63
        pixelNumber (float):
64
            Default number of pixels across the detector. Only used when not set
65
            in science instrument definition. Defaults to 1000.
66
        pixelSize (float):
67
            Default pixel pitch (nominal distance between adjacent pixel centers,
68
            in meters). Only used when not set in science instrument definition.
69
            Defaults to 1e-5
70
        pixelScale (float):
71
            Default pixel scale (instantaneous field of view of each pixel,
72
            in arcseconds). Only used when not set in science instrument definition.
73
            Defaults to 0.02.
74
        sread (float):
75
            Default read noise (in electrons/pixel/read).  Only used when not set
76
            in science instrument definition. Defaults to 1e-6
77
        idark (float):
78
            Default dark current (in electrons/pixel/s).  Only used when not set
79
            in science instrument definition. Defaults to 1e-4
80
        texp (float):
81
            Default single exposure time (in s).  Only used when not set
82
            in science instrument definition. Defaults to 100
83
        Rs (float):
84
            Default spectral resolving power.   Only used when not set
85
            in science instrument definition. Only applies to spetrometers.
86
            Defaults to 50.
87
        lenslSamp (float):
88
            Default lenslet sampling (number of pixels per lenslet rows or columns).
89
            Only used when not set in science instrument definition. Defaults to 2
90
        starlightSuppressionSystems (list(dict)):
91
            List of dicts defining all starlight suppression systems. Minimally must
92
            contain one system. Each dictionary must minimally contain a ``name``
93
            keyword, which must be unique to each system. By default, this keyword is
94
            set to ``[{'name': 'coronagraph'}]``, creating a single coronagraphic
95
            starlight suppression system. Additional parameters are filled in with
96
            default values set by the keywords below. For more details on starlight
97
            suppression system definitions see :ref:`OpticalSystem`.
98
        lam (float):
99
            Default central wavelength of starlight suppression system (in nm).
100
            Only used when not set in starlight suppression system definition.
101
            Defaults to 500
102
        BW (float):
103
            Default fractional bandwidth. Only used when not set in starlight
104
            suppression system definition. Defaults to 0.2
105
        occ_trans (float):
106
            Default coronagraphic transmission. Only used when not set in starlight
107
            suppression system definition. Defaults to 0.2
108
        core_thruput (float):
109
            Default core throughput. Only used when not set in starlight suppression
110
            system definition.  Defaults to 0.1
111
        core_contrast (float):
112
            Default core contrast. Only used when not set in starlight suppression
113
            system definition. Defaults to 1e-10
114
        contrast_floor (float, optional):
115
            Default contrast floor. Only used when not set in starlight suppression
116
            system definition. If not None, sets absolute contrast floor.
117
            Defaults to None
118
        core_platescale (float, optional):
119
            Default core platescale.  Only used when not set in starlight suppression
120
            system definition. Defaults to None. Units determiend by
121
            ``input_angle_units``.
122
        input_angle_units (str, optional):
123
            Default angle units of all starlightSuppressionSystems-related inputs
124
            (as applicable). This includes all CSV input tables or FITS input tables
125
            without a UNIT keyword in the header.
126
            Only used when not set in starlight suppression system definition.
127
            None, 'unitless' or 'LAMBDA/D' are all interepreted as :math:`\\lambda/D`
128
            units. Otherwise must be a string that is parsable as an astropy angle unit.
129
            Defaults to 'arcsec'.
130
        ohTime (float):
131
            Default overhead time (in days).  Only used when not set in starlight
132
            suppression system definition. Time is added to every observation (on
133
            top of observatory settling time). Defaults to 1
134
        observingModes (list(dict), optional):
135
            List of dicts defining observing modes. These are essentially combinations
136
            of instruments and starlight suppression systems, identified by their
137
            names in keywords ``instName`` and ``systName``, respectively.  One mode
138
            must be identified as the default detection mode (by setting keyword
139
            ``detectionMode`` to True in the mode definition. If None (default)
140
            a single observing mode is generated combining the first instrument in
141
            ``scienceInstruments`` with the first starlight suppression system in
142
            ``starlightSuppressionSystems``, and is marked as the detection mode.
143
            Additional parameters are filled in with default values set by the
144
            keywords below.  For more details on mode definitions see
145
            :ref:`OpticalSystem`.
146
        SNR (float):
147
            Default target signal to noise ratio.  Only used when not set in observing
148
            mode definition. Defaults to 5
149
        timeMultiplier (float):
150
            Default integration time multiplier.  Only used when not set in observing
151
            mode definition. Every integration time calculated for an observing mode
152
            is scaled by this factor.  For example, if an observing mode requires two
153
            rolls per observation (i.e., if it covers only 180 degrees of the field),
154
            then this quantity should be set to 2 for that mode.  However, in some cases
155
            (i.e., spectroscopic followup) it may not be necessary to integrate on the
156
            full field, in which case this quantity could be set to 1. Defaults to 1
157
        IWA (float):
158
            Default :term:`IWA` (in input_angle_units).  Only used when not set in
159
            starlight suppression system definition. Defaults to 0.1
160
        OWA (float):
161
            Default :term:`OWA` (in input_angle_units). Only used when not set in
162
            starlight suppression system definition. Defaults to numpy.Inf
163
        stabilityFact (float):
164
            Stability factor. Defaults to 1
165
        cachedir (str, optional):
166
            Full path to cachedir.
167
            If None (default) use default (see :ref:`EXOSIMSCACHE`)
168
        koAngles_Sun (list(float)):
169
            Default [Min, Max] keepout angles for Sun.  Only used when not set in
170
            starlight suppression system definition.  Defaults to [0,180]
171
        koAngles_Earth (list(float)):
172
            Default [Min, Max] keepout angles for Earth.  Only used when not set in
173
            starlight suppression system definition. Defaults to [0,180]
174
        koAngles_Moon (list(float)):
175
            Default [Min, Max] keepout angles for the moon.  Only used when not set in
176
            starlight suppression system definition.  Defaults to [0,180]
177
        koAngles_Small (list(float)):
178
            Default [Min, Max] keepout angles for all other bodies.  Only used when
179
            not set in starlight suppression system definition.
180
            Defaults to [0,180],
181
        binaryleakfilepath (str, optional):
182
            If set, full path to binary leak definition file. Defaults to None
183
        texp_flag (bool):
184
            Toggle use of planet shot noise value for frame exposure time
185
            (overriides instrument texp value). Defaults to False.
186
        bandpass_model (str):
187
            Default model to use for mode bandpasses. Must be one of 'gaussian' or 'box'
188
            (case insensitive). Only used if not set in mode definition. Defaults to
189
            box.
190
        bandpass_step (float):
191
            Default step size (in nm) to use when generating Box-model bandpasses. Only
192
            used if not set in mode definition. Defaults to 0.1.
193
        use_core_thruput_for_ez (bool):
194
            If True, compute exozodi contribution using core_thruput.
195
            If False (default) use occ_trans
196
        csv_angsep_colname (str):
197
            Default column name to use for the angular separation column for CSV data.
198
            Only used when not set in starlight suppression system definition.
199
            Defaults to r_as (matching the default input_angle_units). These two inputs
200
            should be updated together.
201
        **specs:
202
            :ref:`sec:inputspec`
203

204
    Attributes:
205
        _outspec (dict):
206
            :ref:`sec:outspec`
207
        allowed_observingMode_kws (list):
208
            List of allowed keywords in observingMode dictionaries
209
        allowed_scienceInstrument_kws (list):
210
            List of allowed keywords in scienceInstrument dictionaries
211
        allowed_starlightSuppressionSystem_kws (list):
212
            List of allowed keywords in starlightSuppressionSystem dictionaries
213
        cachedir (str):
214
            Path to the EXOSIMS cache directory (see :ref:`EXOSIMSCACHE`)
215
        default_vals (dict):
216
            All inputs not assigned to object attributes are considered to be default
217
            values to be used for filling in information in the optical system
218
            definition, and are copied into this dictionary for storage.
219
        haveOcculter (bool):
220
            One or more starlight suppresion systems are starshade-based
221
        intCutoff (astropy.units.quantity.Quantity):
222
            Maximum allowable continuous integration time.  Time units.
223
        IWA (astropy.units.quantity.Quantity):
224
            Minimum inner working angle.
225
        obscurFac (float):
226
            Obscuration factor (fraction of primary mirror area obscured by secondary
227
            and spiders).
228
        observingModes (list):
229
            List of dicts defining observing modes. These are essentially combinations
230
            of instruments and starlight suppression systems, identified by their
231
            names in keywords ``instName`` and ``systName``, respectively.  One mode
232
            must be identified as the default detection mode (by setting keyword
233
            ``detectionMode`` to True in the mode definition. If None (default)
234
            a single observing mode is generated combining the first instrument in
235
            ``scienceInstruments`` with the first starlight suppression system in
236
            ``starlightSuppressionSystems``, and is marked as the detection mode.
237
            Additional parameters are filled in with default values set by the
238
            keywords below.  For more details on mode definitions see
239
            :ref:`OpticalSystem`.
240
        OWA (astropy.units.quantity.Quantity):
241
            Maximum outer working angle.
242
        pupilArea (astropy.units.quantity.Quantity):
243
            Total effective pupil area:
244

245
            .. math::
246

247
                A  = (1 - F_o)F_sD^2
248

249
            where :math:`F_o` is the obscuration factor, :math:`F_s` is the shape
250
            factor, and :math:`D` is the pupil diameter.
251
        pupilDiam (astropy.units.quantity.Quantity):
252
            Pupil major diameter. Length units.
253
        scienceInstruments (list):
254
            List of dicts defining all science instruments. Minimally must contain
255
            one science instrument. Each dictionary must minimally contain a ``name``
256
            keyword, which must be unique to each instrument and must include the
257
            substring ``imager`` (for imaging devices) or ``spectro`` (for
258
            spectrometers). By default, this keyword is set to
259
            ``[{'name': 'imager'}]``, creating a single imaging science
260
            instrument. Additional parameters are filled in with default values set
261
            by the keywords below. For more details on science instrument definitions
262
            see :ref:`OpticalSystem`.
263
        shapeFac (float):
264
            Primary mirror shape factor.
265
        stabilityFact (float):
266
            Telescope stability factor.
267
        starlightSuppressionSystems (list):
268
            List of dicts defining all starlight suppression systems. Minimally must
269
            contain one system. Each dictionary must minimally contain a ``name``
270
            keyword, which must be unique to each system. By default, this keyword is
271
            set to ``[{'name': 'coronagraph'}]``, creating a single coronagraphic
272
            starlight suppression system. Additional parameters are filled in with
273
            default values set by the keywords below. For more details on starlight
274
            suppression system definitions see :ref:`OpticalSystem`.
275
        texp_flag (bool):
276
            Toggle use of planet shot noise value for frame exposure time
277
            (overriides instrument texp value).
278
        use_core_thruput_for_ez (bool):
279
            Toggle use of core_thruput (instead of occ_trans) in computing exozodi flux.
280

281
    """
282

283
    _modtype = "OpticalSystem"
1✔
284

285
    def __init__(
1✔
286
        self,
287
        obscurFac=0.1,
288
        shapeFac=np.pi / 4,
289
        pupilDiam=4,
290
        intCutoff=50,
291
        scienceInstruments=[{"name": "imager"}],
292
        QE=0.9,
293
        optics=0.5,
294
        FoV=10,
295
        pixelNumber=1000,
296
        pixelSize=1e-5,
297
        pixelScale=0.02,
298
        sread=1e-6,
299
        idark=1e-4,
300
        texp=100,
301
        Rs=50,
302
        lenslSamp=2,
303
        starlightSuppressionSystems=[{"name": "coronagraph"}],
304
        lam=500,
305
        BW=0.2,
306
        occ_trans=0.2,
307
        core_thruput=0.1,
308
        core_contrast=1e-10,
309
        contrast_floor=None,
310
        core_platescale=None,
311
        core_platescale_units=None,
312
        input_angle_units="arcsec",
313
        ohTime=1,
314
        observingModes=None,
315
        SNR=5,
316
        timeMultiplier=1.0,
317
        IWA=0.1,
318
        OWA=np.inf,
319
        stabilityFact=1,
320
        cachedir=None,
321
        koAngles_Sun=[0, 180],
322
        koAngles_Earth=[0, 180],
323
        koAngles_Moon=[0, 180],
324
        koAngles_Small=[0, 180],
325
        binaryleakfilepath=None,
326
        texp_flag=False,
327
        bandpass_model="box",
328
        bandpass_step=0.1,
329
        use_core_thruput_for_ez=False,
330
        csv_angsep_colname="r_as",
331
        **specs,
332
    ):
333
        # start the outspec
334
        self._outspec = {}
1✔
335

336
        # load the vprint function (same line in all prototype module constructors)
337
        self.vprint = vprint(specs.get("verbose", True))
1✔
338

339
        # set attributes from inputs
340
        self.obscurFac = float(obscurFac)  # obscuration factor (fraction of PM area)
1✔
341
        self.shapeFac = float(shapeFac)  # shape factor
1✔
342
        self.pupilDiam = float(pupilDiam) * u.m  # entrance pupil diameter
1✔
343
        self.intCutoff = float(intCutoff) * u.d  # integration time cutoff
1✔
344
        self.stabilityFact = float(stabilityFact)  # stability factor for telescope
1✔
345
        self.texp_flag = bool(texp_flag)
1✔
346
        self.use_core_thruput_for_ez = bool(use_core_thruput_for_ez)
1✔
347

348
        # get cache directory
349
        self.cachedir = get_cache_dir(cachedir)
1✔
350
        specs["cachedir"] = self.cachedir
1✔
351

352
        # if binary leakage model provided, let's grab that as well
353
        if binaryleakfilepath is not None:
1✔
354
            binaryleakfilepathnorm = os.path.normpath(
×
355
                os.path.expandvars(binaryleakfilepath)
356
            )
357

358
            assert os.path.exists(
×
359
                binaryleakfilepathnorm
360
            ), "Binary leakage model data file not found at {}".format(
361
                binaryleakfilepath
362
            )
363

364
            binaryleakdata = np.genfromtxt(binaryleakfilepathnorm, delimiter=",")
×
365

366
            self.binaryleakmodel = scipy.interpolate.interp1d(
×
367
                binaryleakdata[:, 0], binaryleakdata[:, 1], bounds_error=False
368
            )
369
        self._outspec["binaryleakfilepath"] = binaryleakfilepath
1✔
370

371
        # populate outspec with all attributes assigned so far
372
        for att in self.__dict__:
1✔
373
            if att not in [
1✔
374
                "vprint",
375
                "_outspec",
376
            ]:
377
                dat = self.__dict__[att]
1✔
378
                self._outspec[att] = dat.value if isinstance(dat, u.Quantity) else dat
1✔
379

380
        # consistency check IWA/OWA defaults
381
        if OWA == 0:
1✔
382
            OWA = np.inf
1✔
383
        assert IWA < OWA, "Input default IWA must be smaller than input default OWA."
1✔
384

385
        # get all inputs that haven't been assiged to attributes will be treated as
386
        # default values (and should also go into outspec)
387
        kws = get_all_args(self.__class__)
1✔
388
        ignore_kws = [
1✔
389
            "self",
390
            "scienceInstruments",
391
            "starlightSuppressionSystems",
392
            "observingModes",
393
            "binaryleakfilepath",
394
        ]
395
        kws = list(
1✔
396
            (set(kws) - set(ignore_kws) - set(self.__dict__.keys())).intersection(
397
                set(locals().keys())
398
            )
399
        )
400
        self.default_vals = {}
1✔
401
        for kw in kws:
1✔
402
            self.default_vals[kw] = locals()[kw]
1✔
403
            if kw not in self._outspec:
1✔
404
                self._outspec[kw] = locals()[kw]
1✔
405

406
        # pupil collecting area (obscured PM)
407
        self.pupilArea = (1 - self.obscurFac) * self.shapeFac * self.pupilDiam**2
1✔
408

409
        # load Vega's spectrum for later calculations
410
        self.vega_spectrum = SourceSpectrum.from_vega()
1✔
411

412
        # populate science instruments (must have one defined)
413
        assert isinstance(scienceInstruments, list) and (
1✔
414
            len(scienceInstruments) > 0
415
        ), "No science instrument defined."
416
        self.populate_scienceInstruments(scienceInstruments)
1✔
417

418
        # populate starlight suppression systems (must have one defined)
419
        assert isinstance(starlightSuppressionSystems, list) and (
1✔
420
            len(starlightSuppressionSystems) > 0
421
        ), "No starlight suppression systems defined."
422
        self.populate_starlightSuppressionSystems(starlightSuppressionSystems)
1✔
423

424
        # if no observing mode defined, create a default mode from the first instrument
425
        # and first starlight suppression system. then populate all observing modes
426
        if observingModes is None:
1✔
427
            inst = self.scienceInstruments[0]
1✔
428
            syst = self.starlightSuppressionSystems[0]
1✔
429
            observingModes = [
1✔
430
                {
431
                    "detectionMode": True,
432
                    "instName": inst["name"],
433
                    "systName": syst["name"],
434
                }
435
            ]
436
        else:
437
            assert isinstance(observingModes, list) and (
1✔
438
                len(observingModes) > 0
439
            ), "No observing modes defined."
440

441
        self.populate_observingModes(observingModes)
1✔
442

443
        # populate fundamental IWA and OWA - the extrema of both values for all modes
444
        IWAs = [
1✔
445
            x.get("IWA").to(u.arcsec).value
446
            for x in self.observingModes
447
            if x.get("IWA") is not None
448
        ]
449
        if len(IWAs) > 0:
1✔
450
            self.IWA = min(IWAs) * u.arcsec
1✔
451
        else:
452
            self.IWA = float(IWA) * u.arcsec
×
453

454
        OWAs = [
1✔
455
            x.get("OWA").to(u.arcsec).value
456
            for x in self.observingModes
457
            if x.get("OWA") is not None
458
        ]
459
        if len(OWAs) > 0:
1✔
460
            self.OWA = max(OWAs) * u.arcsec
1✔
461
        else:
462
            self.OWA = float(OWA) * u.arcsec if OWA != 0 else np.inf * u.arcsec
×
463

464
        assert self.IWA < self.OWA, "Fundamental IWA must be smaller that the OWA."
1✔
465

466
        # provide every observing mode with a unique identifier
467
        self.genObsModeHex()
1✔
468

469
    def __str__(self):
1✔
470
        """String representation of the Optical System object
471

472
        When the command 'print' is used on the Optical System object, this
473
        method will print the attribute values contained in the object
474

475
        """
476

477
        for att in self.__dict__:
1✔
478
            print("%s: %r" % (att, getattr(self, att)))
1✔
479

480
        return "Optical System class object attributes"
1✔
481

482
    def populate_scienceInstruments(self, scienceInstruments):
1✔
483
        """Helper method to parse input scienceInstrument dictionaries and assign
484
        default values, as needed. Also creates the allowed_scienceInstrument_kws
485
        attribute.
486

487
        Args:
488
            scienceInstruments (list):
489
                List of scienceInstrument dicts.
490

491
        """
492

493
        self.scienceInstruments = copy.deepcopy(scienceInstruments)
1✔
494
        self._outspec["scienceInstruments"] = []
1✔
495
        instnames = []
1✔
496

497
        for ninst, inst in enumerate(self.scienceInstruments):
1✔
498
            assert isinstance(
1✔
499
                inst, dict
500
            ), "Science instruments must be defined as dicts."
501
            assert "name" in inst and isinstance(
1✔
502
                inst["name"], str
503
            ), "All science instruments must have key 'name'."
504
            instnames.append(inst["name"])
1✔
505

506
            # quantum efficiency can be a single number of a filename
507
            inst["QE"] = inst.get("QE", self.default_vals["QE"])
1✔
508
            self._outspec["scienceInstruments"].append(inst.copy())
1✔
509
            if isinstance(inst["QE"], str):
1✔
510
                # Load data and create interpolant
511
                dat, hdr = self.get_param_data(
×
512
                    inst["QE"],
513
                    # left_col_name="lambda", # TODO: start enforcing these
514
                    # param_name="QE",
515
                    expected_ndim=2,
516
                    expected_first_dim=2,
517
                )
518
                lam, D = (dat[0].astype(float), dat[1].astype(float))
×
519
                assert np.all(D >= 0) and np.all(
×
520
                    D <= 1
521
                ), "All QE values must be positive and smaller than 1."
522
                if isinstance(hdr, fits.Header):
×
523
                    if "UNITS" in hdr:
×
524
                        lam = ((lam * u.Unit(hdr["UNITS"])).to(u.nm)).value
×
525

526
                # parameter values outside of lam
527
                Dinterp1 = scipy.interpolate.interp1d(
×
528
                    lam,
529
                    D,
530
                    kind="cubic",
531
                    fill_value=0.0,
532
                    bounds_error=False,
533
                )
534
                inst["QE"] = (
×
535
                    lambda l: np.array(Dinterp1(l.to("nm").value), ndmin=1) / u.photon
536
                )
537
            elif isinstance(inst["QE"], numbers.Number):
1✔
538
                assert (
1✔
539
                    inst["QE"] >= 0 and inst["QE"] <= 1
540
                ), "QE must be positive and smaller than 1."
541
                inst["QE"] = (
1✔
542
                    lambda l, QE=float(inst["QE"]): np.array([QE] * l.size, ndmin=1)
543
                    / u.photon
544
                )
545
            else:
546
                inst["QE"] = self.default_vals["QE"]
×
547
                warnings.warn(
×
548
                    (
549
                        "QE input is not string or number for instrument "
550
                        f" {inst['name']}. Value set to default."
551
                    )
552
                )
553

554
            # load all required detector specifications
555
            # specify dictionary of keys and units
556
            kws = {
1✔
557
                "optics": None,  # attenuation due to instrument optics
558
                "FoV": u.arcsec,  # angular half-field of view of instrument
559
                "pixelNumber": None,  # array format
560
                "pixelSize": u.m,  # pixel pitch
561
                "pixelScale": u.arcsec,  # pixel scale (angular IFOV)
562
                "idark": 1 / u.s,  # dark-current rate
563
                "sread": None,  # effective readout noise
564
                "texp": u.s,  # default exposure time per frame
565
            }
566

567
            for kw in kws:
1✔
568
                inst[kw] = float(inst.get(kw, self.default_vals[kw]))
1✔
569
                if kws[kw] is not None:
1✔
570
                    inst[kw] *= kws[kw]
1✔
571

572
            # start tracking allowed_scienceInstrument_kws
573
            self.allowed_scienceInstrument_kws = ["name", "QE"] + list(kws.keys())
1✔
574

575
            # do some basic consistency checking on pixelScale and FoV:
576
            predFoV = np.arctan(inst["pixelNumber"] * np.tan(inst["pixelScale"] / 2))
1✔
577
            # generate warning if FoV is larger than prediction (but allow for
578
            # approximate equality)
579
            if (inst["FoV"] > predFoV) and not (np.isclose(inst["FoV"], predFoV)):
1✔
580
                warnings.warn(
×
581
                    f'Input FoV ({inst["FoV"]}) is larger than FoV computed '
582
                    f"from pixelScale ({predFoV.to(u.arcsec) :.2f}) for "
583
                    f'instrument {inst["name"]}. This feels like a mistake.'
584
                )
585

586
            # parameters specific to spectrograph
587
            if "spec" in inst["name"].lower():
1✔
588
                # spectral resolving power
589
                inst["Rs"] = float(inst.get("Rs", self.default_vals["Rs"]))
1✔
590
                # lenslet sampling, number of pixel per lenslet rows or cols
591
                inst["lenslSamp"] = float(
1✔
592
                    inst.get("lenslSamp", self.default_vals["lenslSamp"])
593
                )
594
            else:
595
                inst["Rs"] = 1.0
1✔
596
                inst["lenslSamp"] = 1.0
1✔
597

598
            self.allowed_scienceInstrument_kws += ["Rs", "lenslSamp"]
1✔
599

600
            # calculate focal length and f-number as needed
601
            if "focal" in inst:
1✔
602
                inst["focal"] = float(inst["focal"]) * u.m
1✔
603
                inst["fnumber"] = float(inst["focal"] / self.pupilDiam)
1✔
604
            elif ("fnumber") in inst:
1✔
605
                inst["fnumber"] = float(inst["fnumber"])
×
606
                inst["focal"] = inst["fnumber"] * self.pupilDiam
×
607
            else:
608
                inst["focal"] = (
1✔
609
                    inst["pixelSize"] / 2 / np.tan(inst["pixelScale"] / 2)
610
                ).to(u.m)
611
                inst["fnumber"] = float(inst["focal"] / self.pupilDiam)
1✔
612

613
            self.allowed_scienceInstrument_kws += ["focal", "fnumber"]
1✔
614

615
            # consistency check parameters
616
            predFocal = (inst["pixelSize"] / 2 / np.tan(inst["pixelScale"] / 2)).to(u.m)
1✔
617
            if not (np.isclose(predFocal.value, inst["focal"].to(u.m).value)):
1✔
618
                warnings.warn(
×
619
                    f'Input focal length ({inst["focal"] :.2f}) does not '
620
                    f"match value from pixelScale ({predFocal :.2f}) for "
621
                    f'instrument {inst["name"]}. This feels like a mistkae.'
622
                )
623

624
            # populate updated detector specifications to outspec
625
            for att in inst:
1✔
626
                if att not in ["QE"]:
1✔
627
                    dat = inst[att]
1✔
628
                    self._outspec["scienceInstruments"][ninst][att] = (
1✔
629
                        dat.value if isinstance(dat, u.Quantity) else dat
630
                    )
631

632
        # ensure that all instrument names are unique:
633
        assert (
1✔
634
            len(instnames) == np.unique(instnames).size
635
        ), "Instrument names muse be unique."
636

637
        # call additional instrument setup
638
        self.populate_scienceInstruments_extra()
1✔
639

640
    def populate_scienceInstruments_extra(self):
1✔
641
        """Additional setup for science instruments.  This is intended for overloading
642
        in downstream implementations and is intentionally left blank in the prototype.
643
        """
644

645
        pass
1✔
646

647
    def populate_starlightSuppressionSystems(self, starlightSuppressionSystems):
1✔
648
        """Helper method to parse input starlightSuppressionSystem dictionaries and
649
        assign default values, as needed. Also creates the
650
        allowed_starlightSuppressionSystem_kws attribute.
651

652
        Args:
653
            starlightSuppressionSystems (list):
654
                List of starlightSuppressionSystem dicts.
655

656
        """
657

658
        self.starlightSuppressionSystems = copy.deepcopy(starlightSuppressionSystems)
1✔
659
        self.haveOcculter = False
1✔
660
        self._outspec["starlightSuppressionSystems"] = []
1✔
661
        systnames = []
1✔
662

663
        for nsyst, syst in enumerate(self.starlightSuppressionSystems):
1✔
664
            assert isinstance(
1✔
665
                syst, dict
666
            ), "Starlight suppression systems must be defined as dicts."
667
            assert "name" in syst and isinstance(
1✔
668
                syst["name"], str
669
            ), "All starlight suppression systems must have key 'name'."
670
            systnames.append(syst["name"])
1✔
671

672
            # determine system wavelength (lam), bandwidth (deltaLam) and bandwidth
673
            # fraction (BW)
674
            # use deltaLam if given, otherwise use BW
675
            syst["lam"] = float(syst.get("lam", self.default_vals["lam"])) * u.nm
1✔
676
            syst["deltaLam"] = (
1✔
677
                float(
678
                    syst.get(
679
                        "deltaLam",
680
                        syst["lam"].to("nm").value
681
                        * syst.get("BW", self.default_vals["BW"]),
682
                    )
683
                )
684
                * u.nm
685
            )
686
            syst["BW"] = float(syst["deltaLam"] / syst["lam"])
1✔
687

688
            # populate all required default_vals
689
            names = [
1✔
690
                "occ_trans",
691
                "core_thruput",
692
                "core_platescale",
693
                "input_angle_units",
694
                "core_platescale_units",
695
                "contrast_floor",
696
                "csv_angsep_colname",
697
            ]
698
            # fill contrast from default only if core_mean_intensity not set
699
            if "core_mean_intensity" not in syst:
1✔
700
                names.append("core_contrast")
1✔
701
            for n in names:
1✔
702
                syst[n] = syst.get(n, self.default_vals[n])
1✔
703

704
            # start tracking allowed keywords
705
            self.allowed_starlightSuppressionSystem_kws = [
1✔
706
                "name",
707
                "lam",
708
                "deltaLam",
709
                "BW",
710
                "core_mean_intensity",
711
                "optics",
712
                "occulter",
713
                "ohTime",
714
                "core_platescale",
715
                "IWA",
716
                "OWA",
717
                "core_area",
718
            ]
719
            self.allowed_starlightSuppressionSystem_kws += names
1✔
720
            if "core_contrast" not in self.allowed_starlightSuppressionSystem_kws:
1✔
721
                self.allowed_starlightSuppressionSystem_kws.append("core_contrast")
1✔
722

723
            # attenuation due to optics specific to the coronagraph not caputred by the
724
            # coronagraph throughput curves. Defaults to 1.
725
            syst["optics"] = float(syst.get("optics", 1.0))
1✔
726

727
            # set an occulter, for an external or hybrid system
728
            syst["occulter"] = syst.get("occulter", False)
1✔
729
            if syst["occulter"]:
1✔
730
                self.haveOcculter = True
1✔
731

732
            # copy system definition to outspec
733
            self._outspec["starlightSuppressionSystems"].append(syst.copy())
1✔
734

735
            # now we populate everything that has units
736

737
            # overhead time:
738
            syst["ohTime"] = (
1✔
739
                float(syst.get("ohTime", self.default_vals["ohTime"])) * u.d
740
            )
741

742
            # figure out the angle unit we're assuming for all inputs
743
            syst["input_angle_unit_value"] = self.get_angle_unit_from_header(None, syst)
1✔
744

745
            # if platescale was set, give it units
746
            if syst["core_platescale"] is not None:
1✔
747
                # check for units to use
748
                if (syst["core_platescale_units"] is None) or (
1✔
749
                    syst["core_platescale_units"] in ["unitless", "LAMBDA/D"]
750
                ):
751
                    platescale_unit = (syst["lam"] / self.pupilDiam).to(
1✔
752
                        u.arcsec, equivalencies=u.dimensionless_angles()
753
                    )
754
                else:
755
                    platescale_unit = 1 * u.Unit(syst["core_platescale_units"])
×
756

757
                syst["core_platescale"] = (
1✔
758
                    syst["core_platescale"] * platescale_unit
759
                ).to(u.arcsec)
760

761
            # if IWA/OWA are given, assign them units (otherwise they'll be set from
762
            # table data or defaults (whichever comes first).
763
            if "IWA" in syst:
1✔
764
                syst["IWA"] = (float(syst["IWA"]) * syst["input_angle_unit_value"]).to(
1✔
765
                    u.arcsec
766
                )
767
            if "OWA" in syst:
1✔
768
                # Zero OWA aliased to inf OWA
769
                if (syst["OWA"] == 0) or (syst["OWA"] == np.inf):
1✔
770
                    syst["OWA"] = np.inf * u.arcsec
1✔
771
                else:
772
                    syst["OWA"] = (
1✔
773
                        float(syst["OWA"]) * syst["input_angle_unit_value"]
774
                    ).to(u.arcsec)
775

776
            # get the system's keepout angles
777
            names = [
1✔
778
                "koAngles_Sun",
779
                "koAngles_Earth",
780
                "koAngles_Moon",
781
                "koAngles_Small",
782
            ]
783
            for n in names:
1✔
784
                syst[n] = [float(x) for x in syst.get(n, self.default_vals[n])] * u.deg
1✔
785

786
            self.allowed_starlightSuppressionSystem_kws += names
1✔
787

788
            # now we're going to populate everything that's callable
789

790
            # first let's handle core mean intensity
791
            if "core_mean_intensity" in syst:
1✔
792
                syst = self.get_core_mean_intensity(syst)
1✔
793

794
                # ensure that platescale has also been set
795
                assert syst["core_platescale"] is not None, (
1✔
796
                    f"In system {syst['name']}, core_mean_intensity "
797
                    "is set, but core_platescale is not.  This is not allowed."
798
                )
799
            else:
800
                syst["core_mean_intensity"] = None
1✔
801

802
            if "core_contrast" in syst:
1✔
803
                syst = self.get_coro_param(
1✔
804
                    syst,
805
                    "core_contrast",
806
                    fill=1.0,
807
                    expected_ndim=2,
808
                    expected_first_dim=2,
809
                    min_val=0.0,
810
                )
811
            else:
812
                syst["core_contrast"] = None
1✔
813

814
            # now get the throughputs
815
            syst = self.get_coro_param(
1✔
816
                syst,
817
                "occ_trans",
818
                expected_ndim=2,
819
                expected_first_dim=2,
820
                min_val=0.0,
821
                max_val=(np.inf if syst["occulter"] else 1.0),
822
            )
823
            syst = self.get_coro_param(
1✔
824
                syst,
825
                "core_thruput",
826
                expected_ndim=2,
827
                expected_first_dim=2,
828
                min_val=0.0,
829
                max_val=(np.inf if syst["occulter"] else 1.0),
830
            )
831

832
            # finally, for core_area, if none is supplied, then set to area of
833
            # \sqrt{2}/2 lambda/D radius aperture
834
            if (
1✔
835
                ("core_area" not in syst)
836
                or (syst["core_area"] is None)
837
                or (syst["core_area"] == 0)
838
            ):
839
                # need to put this in the proper unit
840
                angunit = self.get_angle_unit_from_header(None, syst)
1✔
841

842
                syst["core_area"] = (
1✔
843
                    (
844
                        (np.pi / 2)
845
                        * (syst["lam"] / self.pupilDiam).to(
846
                            u.arcsec, equivalencies=u.dimensionless_angles()
847
                        )
848
                        ** 2
849
                        / angunit**2
850
                    )
851
                    .decompose()
852
                    .value
853
                )
854
            syst = self.get_coro_param(
1✔
855
                syst,
856
                "core_area",
857
                expected_ndim=2,
858
                expected_first_dim=2,
859
                min_val=0.0,
860
            )
861

862
            # at this point, we must have set an IWA/OWA, but lets make sure
863
            for key in ["IWA", "OWA"]:
1✔
864
                assert (
1✔
865
                    (key in syst)
866
                    and isinstance(syst[key], u.Quantity)
867
                    and (syst[key].unit == u.arcsec)
868
                ), f"{key} not found or has the wrong unit in system {syst['name']}."
869

870
            # populate system specifications to outspec
871
            for att in syst:
1✔
872
                if att not in [
1✔
873
                    "occ_trans",
874
                    "core_thruput",
875
                    "core_contrast",
876
                    "core_mean_intensity",
877
                    "core_area",
878
                    "input_angle_unit_value",
879
                    "IWA",
880
                    "OWA",
881
                ]:
882
                    dat = syst[att]
1✔
883
                    self._outspec["starlightSuppressionSystems"][nsyst][att] = (
1✔
884
                        dat.value if isinstance(dat, u.Quantity) else dat
885
                    )
886

887
        # ensure that all starlight suppression system names are unique:
888
        assert (
1✔
889
            len(systnames) == np.unique(systnames).size
890
        ), "Starlight suppression system names muse be unique."
891

892
        # call additional setup
893
        self.populate_starlightSuppressionSystems_extra()
1✔
894

895
    def populate_starlightSuppressionSystems_extra(self):
1✔
896
        """Additional setup for starlight suppression systems.  This is intended for
897
        overloading in downstream implementations and is intentionally left blank in
898
        the prototype.
899
        """
900

901
        pass
1✔
902

903
    def populate_observingModes(self, observingModes):
1✔
904
        """Helper method to parse input observingMode dictionaries and assign default
905
        values, as needed. Also creates the allowed_observingMode_kws attribute.
906

907
        Args:
908
            observingModes (list):
909
                List of observingMode dicts.
910

911
        """
912

913
        self.observingModes = observingModes
1✔
914
        self._outspec["observingModes"] = []
1✔
915
        for nmode, mode in enumerate(self.observingModes):
1✔
916
            assert isinstance(mode, dict), "Observing modes must be defined as dicts."
1✔
917
            assert (
1✔
918
                "instName" in mode and "systName" in mode
919
            ), "All observing modes must have keys 'instName' and 'systName'."
920
            assert np.any(
1✔
921
                [mode["instName"] == inst["name"] for inst in self.scienceInstruments]
922
            ), f"The mode's instrument name {mode['instName']} does not exist."
923
            assert np.any(
1✔
924
                [
925
                    mode["systName"] == syst["name"]
926
                    for syst in self.starlightSuppressionSystems
927
                ]
928
            ), f"The mode's system name {mode['systName']} does not exist."
929
            self._outspec["observingModes"].append(mode.copy())
1✔
930

931
            # loading mode specifications
932
            mode["SNR"] = float(mode.get("SNR", self.default_vals["SNR"]))
1✔
933
            mode["timeMultiplier"] = float(
1✔
934
                mode.get("timeMultiplier", self.default_vals["timeMultiplier"])
935
            )
936
            mode["detectionMode"] = mode.get("detectionMode", False)
1✔
937
            mode["inst"] = [
1✔
938
                inst
939
                for inst in self.scienceInstruments
940
                if inst["name"] == mode["instName"]
941
            ][0]
942
            mode["syst"] = [
1✔
943
                syst
944
                for syst in self.starlightSuppressionSystems
945
                if syst["name"] == mode["systName"]
946
            ][0]
947

948
            # start tracking allowed keywords
949
            self.allowed_observingMode_kws = [
1✔
950
                "instName",
951
                "systName",
952
                "SNR",
953
                "timeMultiplier",
954
                "detectionMode",
955
                "lam",
956
                "deltaLam",
957
                "BW",
958
                "bandpass_model",
959
                "bandpass_step",
960
            ]
961

962
            # get mode wavelength and bandwidth (get system's values by default)
963
            # when provided, always use deltaLam instead of BW (bandwidth fraction)
964
            syst_lam = mode["syst"]["lam"].to("nm").value
1✔
965
            syst_BW = mode["syst"]["BW"]
1✔
966
            mode["lam"] = float(mode.get("lam", syst_lam)) * u.nm
1✔
967
            mode["deltaLam"] = (
1✔
968
                float(mode.get("deltaLam", mode["lam"].value * mode.get("BW", syst_BW)))
969
                * u.nm
970
            )
971
            mode["BW"] = float(mode["deltaLam"] / mode["lam"])
1✔
972

973
            # get mode IWA and OWA: rescale if the mode wavelength is different from
974
            # the wavelength at which the system is defined
975
            mode["IWA"] = mode["syst"]["IWA"]
1✔
976
            mode["OWA"] = mode["syst"]["OWA"]
1✔
977
            if mode["lam"] != mode["syst"]["lam"]:
1✔
978
                mode["IWA"] = mode["IWA"] * mode["lam"] / mode["syst"]["lam"]
1✔
979
                mode["OWA"] = mode["OWA"] * mode["lam"] / mode["syst"]["lam"]
1✔
980

981
            # OWA must be bounded by FOV:
982
            if mode["OWA"] > mode["inst"]["FoV"]:
1✔
983
                mode["OWA"] = mode["inst"]["FoV"]
1✔
984

985
            # generate the mode's bandpass
986
            # TODO: Add support for custom filter profiles
987
            mode["bandpass_model"] = mode.get(
1✔
988
                "bandpass_model", self.default_vals["bandpass_model"]
989
            ).lower()
990
            assert mode["bandpass_model"] in [
1✔
991
                "gaussian",
992
                "box",
993
            ], "bandpass_model must be one of ['gaussian', 'box']"
994
            mode["bandpass_step"] = (
1✔
995
                float(mode.get("bandpass_step", self.default_vals["bandpass_step"]))
996
                * u.nm
997
            )
998
            if mode["bandpass_model"] == "box":
1✔
999
                mode["bandpass"] = SpectralElement(
1✔
1000
                    Box1D,
1001
                    x_0=mode["lam"],
1002
                    width=mode["deltaLam"],
1003
                    step=mode["bandpass_step"].to(u.AA).value,
1004
                )
1005
            else:
1006
                mode["bandpass"] = SpectralElement(
×
1007
                    Gaussian1D,
1008
                    mean=mode["lam"],
1009
                    stddev=mode["deltaLam"] / np.sqrt(2 * np.pi),
1010
                )
1011

1012
            # check for out of range wavelengths
1013
            # currently capped to 10 um
1014
            assert (
1✔
1015
                mode["bandpass"].waveset.max() < 10 * u.um
1016
            ), "Bandpasses beyond 10 um are not supported."
1017

1018
            # evaluate zero-magnitude flux for this band from vega spectrum
1019
            # NB: This is flux, not flux density! The bandpass is already factored in.
1020
            mode["F0"] = Observation(
1✔
1021
                self.vega_spectrum, mode["bandpass"], force="taper"
1022
            ).integrate()
1023

1024
            # Evaluate the V-band zero magnitude flux density
1025

1026
            # populate system specifications to outspec
1027
            for att in mode:
1✔
1028
                if att not in [
1✔
1029
                    "inst",
1030
                    "syst",
1031
                    "F0",
1032
                    "bandpass",
1033
                ]:
1034
                    dat = mode[att]
1✔
1035
                    self._outspec["observingModes"][nmode][att] = (
1✔
1036
                        dat.value if isinstance(dat, u.Quantity) else dat
1037
                    )
1038

1039
            # populate some final mode attributes (computed from the others)
1040
            # define total mode attenution
1041
            mode["attenuation"] = mode["inst"]["optics"] * mode["syst"]["optics"]
1✔
1042

1043
            # effective mode bandwidth (including any IFS spectral resolving power)
1044
            mode["deltaLam_eff"] = (
1✔
1045
                mode["lam"] / mode["inst"]["Rs"]
1046
                if "spec" in mode["inst"]["name"].lower()
1047
                else mode["deltaLam"]
1048
            )
1049

1050
            # total attenuation due to non-coronagraphic optics:
1051
            mode["losses"] = (
1✔
1052
                self.pupilArea
1053
                * mode["inst"]["QE"](mode["lam"])
1054
                * mode["attenuation"]
1055
                * mode["deltaLam_eff"]
1056
                / mode["deltaLam"]
1057
            )
1058

1059
        # check for only one detection mode
1060
        allModes = self.observingModes
1✔
1061
        detModes = list(filter(lambda mode: mode["detectionMode"], allModes))
1✔
1062
        assert len(detModes) <= 1, "More than one detection mode specified."
1✔
1063

1064
        # if not specified, default detection mode is first imager mode
1065
        if len(detModes) == 0:
1✔
1066
            imagerModes = list(
1✔
1067
                filter(lambda mode: "imag" in mode["inst"]["name"], allModes)
1068
            )
1069
            if imagerModes:
1✔
1070
                imagerModes[0]["detectionMode"] = True
1✔
1071
            # if no imager mode, default detection mode is first observing mode
1072
            else:
1073
                allModes[0]["detectionMode"] = True
×
1074

1075
        self.populate_observingModes_extra()
1✔
1076

1077
    def populate_observingModes_extra(self):
1✔
1078
        """Additional setup for observing modes  This is intended for overloading in
1079
        downstream implementations and is intentionally left blank in the prototype.
1080
        """
1081

1082
        pass
1✔
1083

1084
    def genObsModeHex(self):
1✔
1085
        """Generate a unique hash for every observing mode to be used in downstream
1086
        identification and caching. Also adds an integer index to the mode corresponding
1087
        to its order in the observingModes list.
1088

1089
        The hash will be based on the _outspec entries for the obsmode, its science
1090
        instrument and its starlight suppression system.
1091
        """
1092

1093
        for nmode, mode in enumerate(self.observingModes):
1✔
1094
            inst = [
1✔
1095
                inst
1096
                for inst in self._outspec["scienceInstruments"]
1097
                if inst["name"] == mode["instName"]
1098
            ][0]
1099
            syst = [
1✔
1100
                syst
1101
                for syst in self._outspec["starlightSuppressionSystems"]
1102
                if syst["name"] == mode["systName"]
1103
            ][0]
1104

1105
            modestr = "{},{},{}".format(
1✔
1106
                dictToSortedStr(self._outspec["observingModes"][nmode]),
1107
                dictToSortedStr(inst),
1108
                dictToSortedStr(syst),
1109
            )
1110

1111
            mode["hex"] = genHexStr(modestr)
1✔
1112
            mode["index"] = nmode
1✔
1113

1114
    def get_core_mean_intensity(
1✔
1115
        self,
1116
        syst,
1117
    ):
1118
        """Load and process core_mean_intensity data
1119

1120
        Args:
1121
            syst (dict):
1122
                Dictionary containing the parameters of one starlight suppression system
1123

1124
        Returns:
1125
            dict:
1126
                Updated dictionary of starlight suppression system parameters
1127

1128
        """
1129

1130
        param_name = "core_mean_intensity"
1✔
1131
        fill = 1.0
1✔
1132
        assert param_name in syst, f"{param_name} not found in syst."
1✔
1133
        if isinstance(syst[param_name], str):
1✔
1134
            dat, hdr = self.get_param_data(
×
1135
                syst[param_name],
1136
                left_col_name=syst["csv_angsep_colname"],
1137
                param_name=param_name,
1138
                expected_ndim=2,
1139
            )
1140
            dat = dat.transpose()  # flip such that data is in rows
×
1141
            WA, D = dat[0].astype(float), dat[1:].astype(float)
×
1142

1143
            # check values as needed
1144
            assert np.all(
×
1145
                D > 0
1146
            ), f"{param_name} in {syst['name']} must be >0 everywhere."
1147

1148
            # get angle unit scale WA
1149
            angunit = self.get_angle_unit_from_header(hdr, syst)
×
1150
            WA = (WA * angunit).to(u.arcsec).value
×
1151

1152
            # get platescale from header (if this is a FITS header)
1153
            if isinstance(hdr, fits.Header) and ("PIXSCALE" in hdr):
×
1154
                # use the header unit preferentially. otherwise drop back to the
1155
                # core_platescale_units keyword
1156
                if "UNITS" in hdr:
×
1157
                    platescale = (float(hdr["PIXSCALE"]) * angunit).to(u.arcsec)
×
1158
                else:
1159
                    if (syst["core_platescale_units"] is None) or (
×
1160
                        syst["core_platescale_units"] in ["unitless", "LAMBDA/D"]
1161
                    ):
1162
                        platescale_unit = (syst["lam"] / self.pupilDiam).to(
×
1163
                            u.arcsec, equivalencies=u.dimensionless_angles()
1164
                        )
1165
                    else:
1166
                        platescale_unit = 1 * u.Unit(syst["core_platescale_units"])
×
1167
                    platescale = (float(hdr["PIXSCALE"]) * platescale_unit).to(u.arcsec)
×
1168

1169
                if (syst.get("core_platescale") is not None) and (
×
1170
                    syst["core_platescale"] != platescale
1171
                ):
1172
                    warnings.warn(
×
1173
                        "platescale for core_mean_intensity in system "
1174
                        f"{syst['name']} does not match input value.  "
1175
                        "Overwriting with value from FITS file but you "
1176
                        "should check your inputs."
1177
                    )
1178
                syst["core_platescale"] = platescale
×
1179

1180
            # handle case where only one data row is present
1181
            if D.shape[0] == 1:
×
1182
                D = np.squeeze(D)
×
1183

1184
                # table interpolate function
1185
                Dinterp = scipy.interpolate.interp1d(
×
1186
                    WA,
1187
                    D,
1188
                    kind="linear",
1189
                    fill_value=fill,
1190
                    bounds_error=False,
1191
                )
1192
                # create a callable lambda function. for coronagraphs, we need to scale
1193
                # the angular separation by wavelength, but for occulters we just need
1194
                # to ensure that we're within the wavelength range
1195
                if syst["occulter"]:
×
1196
                    minl = syst["lam"] - syst["deltaLam"] / 2
×
1197
                    maxl = syst["lam"] + syst["deltaLam"] / 2
×
1198
                    syst[param_name] = (
×
1199
                        lambda lam, s, d=0 * u.arcsec, Dinterp=Dinterp, minl=minl, maxl=maxl, fill=fill: (  # noqa: E501
1200
                            np.array(Dinterp(s.to("arcsec").value), ndmin=1) - fill
1201
                        )
1202
                        * np.array((minl < lam) & (lam < maxl), ndmin=1).astype(int)
1203
                        + fill
1204
                    )
1205
                else:
1206
                    syst[param_name] = (
×
1207
                        lambda lam, s, d=0 * u.arcsec, Dinterp=Dinterp, lam0=syst[
1208
                            "lam"
1209
                        ]: np.array(
1210
                            Dinterp((s * lam0 / lam).to("arcsec").value), ndmin=1
1211
                        )
1212
                    )
1213

1214
            # and now the general case of multiple rows
1215
            else:
1216
                # grab stellar diameters from header info
1217
                diams = np.zeros(len(D))
×
1218
                # FITS files
1219
                if isinstance(hdr, fits.Header):
×
1220
                    for j in range(len(D)):
×
1221
                        k = f"DIAM{j :03d}"
×
1222
                        assert k in hdr, (
×
1223
                            f"Expected keyword {k} not found in header "
1224
                            f"of file {syst[param_name]} for system "
1225
                            f"{syst['name']}"
1226
                        )
1227
                        diams[j] = float(hdr[k])
×
1228
                # TODO: support for CSV files
1229
                else:
1230
                    raise NotImplementedError(
×
1231
                        "No CSV support for 2D core_mean_intensity"
1232
                    )
1233

1234
                # determine units and convert as needed
1235
                diams = (diams * angunit).to(u.arcsec).value
×
1236

1237
                Dinterp = scipy.interpolate.RegularGridInterpolator(
×
1238
                    (WA, diams), D.transpose(), bounds_error=False, fill_value=1.0
1239
                )
1240

1241
                # create a callable lambda function. for coronagraphs, we need to scale
1242
                # the angular separation and stellar diameter by wavelength, but for
1243
                # occulters we just need to ensure that we're within the wavelength
1244
                # range
1245
                if syst["occulter"]:
×
1246
                    minl = syst["lam"] - syst["deltaLam"] / 2
×
1247
                    maxl = syst["lam"] + syst["deltaLam"] / 2
×
1248
                    syst[param_name] = (
×
1249
                        lambda lam, s, d=0 * u.arcsec, Dinterp=Dinterp, minl=minl, maxl=maxl, fill=fill: (  # noqa: E501
1250
                            np.array(
1251
                                Dinterp((s.to("arcsec").value, d.to("arcsec").value)),
1252
                                ndmin=1,
1253
                            )
1254
                            - fill
1255
                        )
1256
                        * np.array((minl < lam) & (lam < maxl), ndmin=1).astype(int)
1257
                        + fill
1258
                    )
1259
                else:
1260
                    syst[
×
1261
                        param_name
1262
                    ] = lambda lam, s, d=0 * u.arcsec, Dinterp=Dinterp, lam0=syst[
1263
                        "lam"
1264
                    ]: np.array(
1265
                        Dinterp(
1266
                            (
1267
                                (s * lam0 / lam).to("arcsec").value,
1268
                                (d * lam0 / lam).to("arcsec").value,
1269
                            )
1270
                        ),
1271
                        ndmin=1,
1272
                    )
1273

1274
            # update IWA/OWA in system as needed
1275
            syst = self.update_syst_WAs(syst, WA, param_name)
×
1276

1277
        elif isinstance(syst[param_name], numbers.Number):
1✔
1278
            # ensure paramter is within bounds
1279
            D = float(syst[param_name])
1✔
1280
            assert D > 0, f"{param_name} in {syst['name']} must be > 0."
1✔
1281

1282
            # ensure you have values for IWA/OWA, otherwise use defaults
1283
            syst = self.update_syst_WAs(syst, None, None)
1✔
1284
            IWA = syst["IWA"].to(u.arcsec).value
1✔
1285
            OWA = syst["OWA"].to(u.arcsec).value
1✔
1286

1287
            # same as for interpolant: coronagraphs scale with wavelength, occulters
1288
            # don't
1289
            if syst["occulter"]:
1✔
1290
                minl = syst["lam"] - syst["deltaLam"] / 2
×
1291
                maxl = syst["lam"] + syst["deltaLam"] / 2
×
1292

1293
                syst[param_name] = (
×
1294
                    lambda lam, s, d=0 * u.arcsec, D=D, IWA=IWA, OWA=OWA, minl=minl, maxl=maxl, fill=fill: (  # noqa: E501
1295
                        np.array(
1296
                            (IWA <= s.to("arcsec").value)
1297
                            & (s.to("arcsec").value <= OWA)
1298
                            & (minl < lam)
1299
                            & (lam < maxl),
1300
                            ndmin=1,
1301
                        ).astype(float)
1302
                        * (D - fill)
1303
                        + fill
1304
                    )
1305
                )
1306

1307
            else:
1308
                syst[param_name] = (
1✔
1309
                    lambda lam, s, d=0 * u.arcsec, D=D, lam0=syst[
1310
                        "lam"
1311
                    ], IWA=IWA, OWA=OWA, fill=fill: (
1312
                        np.array(
1313
                            (IWA <= (s * lam0 / lam).to("arcsec").value)
1314
                            & ((s * lam0 / lam).to("arcsec").value <= OWA),
1315
                            ndmin=1,
1316
                        ).astype(float)
1317
                    )
1318
                    * (D - fill)
1319
                    + fill
1320
                )
1321
        elif syst[param_name] is None:
×
1322
            syst[param_name] = None
×
1323
        else:
1324
            raise TypeError(
×
1325
                f"{param_name} for system {syst['name']} is neither a "
1326
                f"string nor a number. I don't know what to do with that."
1327
            )
1328

1329
        return syst
1✔
1330

1331
    def get_angle_unit_from_header(self, hdr, syst):
1✔
1332
        """Helper method. Extract angle unit from header, if it exists.
1333

1334
        Args:
1335
            hdr (astropy.io.fits.header.Header or list):
1336
                FITS header for data or header row from CSV
1337
            syst (dict):
1338
                Dictionary containing the parameters of one starlight suppression system
1339

1340
        Returns:
1341
            astropy.units.Unit:
1342
                The angle unit.
1343
        """
1344
        # if this is a FITS header, grab value from UNITS key if it exists
1345
        if isinstance(hdr, fits.Header) and ("UNITS" in hdr):
1✔
1346
            if hdr["UNITS"] in ["unitless", "LAMBDA/D"]:
×
1347
                angunit = (syst["lam"] / self.pupilDiam).to(
×
1348
                    u.arcsec, equivalencies=u.dimensionless_angles()
1349
                )
1350
            else:
1351
                angunit = 1 * u.Unit(hdr["UNITS"])
×
1352
        # otherwise, use the input_angle_units key
1353
        else:
1354
            # check if we've already computed this
1355
            if "input_angle_unit_value" in syst:
1✔
1356
                angunit = syst["input_angle_unit_value"]
1✔
1357
            else:
1358
                # if we're here, we have to do it from scratch
1359
                if (syst["input_angle_units"] is None) or (
1✔
1360
                    syst["input_angle_units"] in ["unitless", "LAMBDA/D"]
1361
                ):
1362
                    angunit = (syst["lam"] / self.pupilDiam).to(
×
1363
                        u.arcsec, equivalencies=u.dimensionless_angles()
1364
                    )
1365
                else:
1366
                    angunit = 1 * u.Unit(syst["input_angle_units"])
1✔
1367

1368
        # final consistency check before returning
1369
        assert (
1✔
1370
            angunit.unit.physical_type == "angle"
1371
        ), f"Angle unit for system {syst['name']} is not an angle."
1372

1373
        return angunit
1✔
1374

1375
    def update_syst_WAs(self, syst, WA0, param_name):
1✔
1376
        """Helper method. Check system IWA/OWA and update from table
1377
        data, as needed. Alternatively, set from defaults.
1378

1379
        Args:
1380
            syst (dict):
1381
                Dictionary containing the parameters of one starlight suppression system
1382
            WA0 (~numpy.ndarray, optional):
1383
                Array of angles from table data. Must be in arcseconds. If None, then
1384
                just set from defaults.
1385
            param_name (str, optional):
1386
                Name of parameter the table data belongs to. Must be set if WA is set.
1387

1388
        Returns:
1389
            dict:
1390
                Updated dictionary of starlight suppression system parameters
1391

1392
        """
1393

1394
        # if WA not given, then we're going to be setting defaults, as needed.
1395
        if WA0 is None:
1✔
1396
            if "IWA" not in syst:
1✔
1397
                syst["IWA"] = (
1✔
1398
                    float(self.default_vals["IWA"]) * syst["input_angle_unit_value"]
1399
                ).to(u.arcsec)
1400

1401
            if "OWA" not in syst:
1✔
1402
                syst["OWA"] = (
1✔
1403
                    float(self.default_vals["OWA"]) * syst["input_angle_unit_value"]
1404
                ).to(u.arcsec)
1405

1406
            return syst
1✔
1407

1408
        # otherwise, update IWA from table value
1409
        WA = WA0 * u.arcsec
1✔
1410
        if ("IWA" in syst) and (np.min(WA) > syst["IWA"]):
1✔
1411
            warnings.warn(
×
1412
                f"{param_name} has larger IWA than current system value "
1413
                f"for {syst['name']}. Updating to match table, but you "
1414
                "should check your inputs."
1415
            )
1416
            syst["IWA"] = np.min(WA)
×
1417
        elif "IWA" not in syst:
1✔
1418
            syst["IWA"] = np.min(WA)
×
1419

1420
        # update OWA (if not an occulter)
1421
        if not (syst["occulter"]) and ("OWA" in syst) and (np.max(WA) < syst["OWA"]):
1✔
1422
            warnings.warn(
1✔
1423
                f"{param_name} has smaller OWA than current system "
1424
                f"value for {syst['name']}. Updating to match table, but "
1425
                "you should check your inputs."
1426
            )
1427
            syst["OWA"] = np.max(WA)
1✔
1428
        elif "OWA" not in syst:
1✔
1429
            syst["OWA"] = np.max(WA)
×
1430

1431
        return syst
1✔
1432

1433
    def get_coro_param(
1✔
1434
        self,
1435
        syst,
1436
        param_name,
1437
        fill=0.0,
1438
        expected_ndim=None,
1439
        expected_first_dim=None,
1440
        min_val=None,
1441
        max_val=None,
1442
    ):
1443
        """For a given starlightSuppressionSystem, this method loads an input
1444
        parameter from a table (fits or csv file) or a scalar value. It then creates a
1445
        callable lambda function, which depends on the wavelength of the system
1446
        and the angular separation of the observed planet.
1447

1448
        Args:
1449
            syst (dict):
1450
                Dictionary containing the parameters of one starlight suppression system
1451
            param_name (str):
1452
                Name of the parameter that must be loaded
1453
            fill (float):
1454
                Fill value for working angles outside of the input array definition
1455
            expected_ndim (int, optional):
1456
                Expected number of dimensions.  Only checked if not None. Defaults None.
1457
            expected_first_dim (int, optional):
1458
                Expected size of first dimension of data.  Only checked if not None.
1459
                Defaults None
1460
            min_val (float, optional):
1461
                Minimum allowed value of parameter. Defaults to None (no check).
1462
            max_val (float, optional):
1463
                Maximum allowed value of paramter. Defaults to None (no check).
1464

1465
        Returns:
1466
            dict:
1467
                Updated dictionary of starlight suppression system parameters
1468

1469
        .. note::
1470

1471
            The created lambda function handles the specified wavelength by
1472
            rescaling the specified working angle by a factor syst['lam']/mode['lam']
1473

1474
        .. note::
1475

1476
            If the input parameter is taken from a table, the IWA and OWA of that
1477
            system are constrained by the limits of the allowed WA on that table.
1478

1479
        """
1480

1481
        assert param_name in syst, f"{param_name} not found in system {syst['name']}."
1✔
1482
        if isinstance(syst[param_name], str):
1✔
1483
            dat, hdr = self.get_param_data(
1✔
1484
                syst[param_name],
1485
                left_col_name=syst["csv_angsep_colname"],
1486
                param_name=param_name,
1487
                expected_ndim=expected_ndim,
1488
                expected_first_dim=expected_first_dim,
1489
            )
1490
            WA, D = dat[0].astype(float), dat[1].astype(float)
1✔
1491

1492
            # check values as needed
1493
            if min_val is not None:
1✔
1494
                assert np.all(D >= min_val), (
1✔
1495
                    f"{param_name} in {syst['name']} may not "
1496
                    f"have values less than {min_val}."
1497
                )
1498
            if max_val is not None:
1✔
1499
                assert np.all(D <= max_val), (
1✔
1500
                    f"{param_name} in {syst['name']} may "
1501
                    f"not have values greater than {max_val}."
1502
                )
1503

1504
            # check for units
1505
            angunit = self.get_angle_unit_from_header(hdr, syst)
1✔
1506
            WA = (WA * angunit).to(u.arcsec).value
1✔
1507

1508
            # for core_area only, also need to scale the data
1509
            if param_name == "core_area":
1✔
1510
                D = (D * angunit**2).to(u.arcsec**2).value
×
1511

1512
            # update IWA/OWA as needed
1513
            syst = self.update_syst_WAs(syst, WA, param_name)
1✔
1514

1515
            # table interpolate function
1516
            Dinterp = scipy.interpolate.interp1d(
1✔
1517
                WA,
1518
                D,
1519
                kind="linear",
1520
                fill_value=fill,
1521
                bounds_error=False,
1522
            )
1523
            # create a callable lambda function. for coronagraphs, we need to scale the
1524
            # angular separation by wavelength, but for occulters we just need to
1525
            # ensure that we're within the wavelength range. for core_area, we also
1526
            # need to scale the output by wavelengh^2.
1527
            if syst["occulter"]:
1✔
1528
                minl = syst["lam"] - syst["deltaLam"] / 2
×
1529
                maxl = syst["lam"] + syst["deltaLam"] / 2
×
1530
                if param_name == "core_area":
×
1531
                    outunit = 1 * u.arcsec**2
×
1532
                else:
1533
                    outunit = 1
×
1534
                syst[param_name] = (
×
1535
                    lambda lam, s, Dinterp=Dinterp, minl=minl, maxl=maxl, fill=fill: (
1536
                        (np.array(Dinterp(s.to("arcsec").value), ndmin=1) - fill)
1537
                        * np.array((minl < lam) & (lam < maxl), ndmin=1).astype(int)
1538
                        + fill
1539
                    )
1540
                    * outunit
1541
                )
1542
            else:
1543
                if param_name == "core_area":
1✔
1544
                    syst[param_name] = (
×
1545
                        lambda lam, s, Dinterp=Dinterp, lam0=syst["lam"]: np.array(
1546
                            Dinterp((s * lam0 / lam).to("arcsec").value), ndmin=1
1547
                        )
1548
                        * ((lam / lam0).decompose() * u.arcsec) ** 2
1549
                    )
1550
                else:
1551
                    syst[param_name] = lambda lam, s, Dinterp=Dinterp, lam0=syst[
1✔
1552
                        "lam"
1553
                    ]: np.array(Dinterp((s * lam0 / lam).to("arcsec").value), ndmin=1)
1554
        # now the case where we just got a scalar input
1555
        elif isinstance(syst[param_name], numbers.Number):
1✔
1556
            # ensure paramter is within bounds
1557
            D = float(syst[param_name])
1✔
1558
            if min_val is not None:
1✔
1559
                assert D >= min_val, (
1✔
1560
                    f"{param_name} in {syst['name']} may not "
1561
                    f"have values less than {min_val}."
1562
                )
1563
            if max_val is not None:
1✔
1564
                assert D <= max_val, (
1✔
1565
                    f"{param_name} in {syst['name']} may "
1566
                    f"not have values greater than {min_val}."
1567
                )
1568

1569
            # for core_area only, need to make sure that the units are right
1570
            if param_name == "core_area":
1✔
1571
                angunit = self.get_angle_unit_from_header(None, syst)
1✔
1572
                D = (D * angunit**2).to(u.arcsec**2).value
1✔
1573

1574
            # ensure you have values for IWA/OWA, otherwise use defaults
1575
            syst = self.update_syst_WAs(syst, None, None)
1✔
1576
            IWA = syst["IWA"].to(u.arcsec).value
1✔
1577
            OWA = syst["OWA"].to(u.arcsec).value
1✔
1578

1579
            # same as for interpolant: coronagraphs scale with wavelength, occulters
1580
            # don't
1581
            if syst["occulter"]:
1✔
1582
                minl = syst["lam"] - syst["deltaLam"] / 2
1✔
1583
                maxl = syst["lam"] + syst["deltaLam"] / 2
1✔
1584
                if param_name == "core_area":
1✔
1585
                    outunit = 1 * u.arcsec**2
1✔
1586
                else:
1587
                    outunit = 1
1✔
1588

1589
                syst[param_name] = (
1✔
1590
                    lambda lam, s, D=D, IWA=IWA, OWA=OWA, minl=minl, maxl=maxl, fill=fill: (  # noqa: E501
1591
                        (
1592
                            np.array(
1593
                                (IWA <= s.to("arcsec").value)
1594
                                & (s.to("arcsec").value <= OWA)
1595
                                & (minl < lam)
1596
                                & (lam < maxl),
1597
                                ndmin=1,
1598
                            ).astype(float)
1599
                            * (D - fill)
1600
                            + fill
1601
                        )
1602
                        * outunit
1603
                    )
1604
                )
1605
            # coronagraph:
1606
            else:
1607
                if param_name == "core_area":
1✔
1608
                    syst[param_name] = (
1✔
1609
                        lambda lam, s, D=D, lam0=syst[
1610
                            "lam"
1611
                        ], IWA=IWA, OWA=OWA, fill=fill: (
1612
                            np.array(
1613
                                (IWA <= (s * lam0 / lam).to("arcsec").value)
1614
                                & ((s * lam0 / lam).to("arcsec").value <= OWA),
1615
                                ndmin=1,
1616
                            ).astype(float)
1617
                            * (lam / lam0 * u.arcsec) ** 2
1618
                        )
1619
                        * (D - fill)
1620
                        + fill
1621
                    )
1622
                else:
1623
                    syst[param_name] = (
1✔
1624
                        lambda lam, s, D=D, lam0=syst[
1625
                            "lam"
1626
                        ], IWA=IWA, OWA=OWA, fill=fill: (
1627
                            np.array(
1628
                                (IWA <= (s * lam0 / lam).to("arcsec").value)
1629
                                & ((s * lam0 / lam).to("arcsec").value <= OWA),
1630
                                ndmin=1,
1631
                            ).astype(float)
1632
                        )
1633
                        * (D - fill)
1634
                        + fill
1635
                    )
1636
        # finally the case where the input is None
1637
        elif syst[param_name] is None:
×
1638
            syst[param_name] = None
×
1639
        # anything else (not string, number, or None) throws an error
1640
        else:
1641
            raise TypeError(
×
1642
                f"{param_name} for system {syst['name']} is neither a "
1643
                f"string nor a number. I don't know what to do with that."
1644
            )
1645

1646
        return syst
1✔
1647

1648
    def get_param_data(
1✔
1649
        self,
1650
        ipth,
1651
        left_col_name=None,
1652
        param_name=None,
1653
        expected_ndim=None,
1654
        expected_first_dim=None,
1655
    ):
1656
        """Gets the data from a file, used primarily to create interpolants for
1657
        coronagraph parameters
1658

1659
        Args:
1660
            ipth (str):
1661
                String to file location, will also work with any other path object
1662
            left_col_name (str,optional):
1663
                For CSV files only. String representing the column containing the
1664
                independent parameter to be extracted. This is for use in the case
1665
                where the CSV file contains multiple columns and only two need to be
1666
                returned. Defaults None.
1667
            param_name (str, optional):
1668
                For CSV files only. String representing the column containing the
1669
                dependent parameter to be extracted. This is for use in the case where
1670
                the CSV file contains multiple columns and only two need to be returned.
1671
                Defaults None.
1672
            expected_ndim (int, optional):
1673
                Expected number of dimensions.  Only checked if not None. Defaults None.
1674
            expected_first_dim (int, optional):
1675
                Expected size of first dimension of data.  Only checked if not None.
1676
                Defaults None
1677

1678
        Returns:
1679
            tuple:
1680
                dat (~numpy.ndarray):
1681
                    Data array
1682
                hdr (list or astropy.io.fits.header.Header):
1683
                    Data header.  For CVS files this is a list of column header strings.
1684

1685
        .. note::
1686

1687
            CSV files *must* have a single header row
1688

1689
        """
1690
        # Check that path represents a valid file
1691
        pth = os.path.normpath(os.path.expandvars(ipth))
1✔
1692
        assert os.path.isfile(pth), f"{ipth} is not a valid file."
1✔
1693

1694
        # Check for fits or csv file
1695
        ext = pth.split(".")[-1]
1✔
1696
        assert ext.lower() in ["fits", "csv"], f"{pth} must be a fits or csv file."
1✔
1697
        if ext.lower() == "fits":
1✔
1698
            with fits.open(pth) as f:
1✔
1699
                dat = f[0].data.squeeze()
1✔
1700
                hdr = f[0].header
1✔
1701
        else:
1702
            # Need to get all of the headers and data, then associate them in the same
1703
            # ndarray that the fits files would generate
1704
            table_vals = np.genfromtxt(pth, delimiter=",", skip_header=1)
×
1705
            hdr = np.genfromtxt(
×
1706
                pth, delimiter=",", skip_footer=len(table_vals), dtype=str
1707
            )
1708

1709
            if left_col_name is not None:
×
1710
                assert (
×
1711
                    param_name is not None
1712
                ), "If left_col_name is nont None, param_name cannot be None."
1713

1714
                assert (
×
1715
                    left_col_name in hdr
1716
                ), f"{left_col_name} not found in table header for file {ipth}"
1717
                assert (
×
1718
                    param_name in hdr
1719
                ), f"{param_name} not found in table header for file {ipth}"
1720

1721
                left_column_location = np.where(hdr == left_col_name)[0][0]
×
1722
                param_location = np.where(hdr == param_name)[0][0]
×
1723
                dat = np.vstack(
×
1724
                    [table_vals[:, left_column_location], table_vals[:, param_location]]
1725
                ).T
1726
                hdr = [left_col_name, param_name]
×
1727
            else:
1728
                dat = table_vals
×
1729

1730
        if expected_ndim is not None:
1✔
1731
            assert len(dat.shape) == expected_ndim, (
1✔
1732
                f"Data shape did not match expected {expected_ndim} "
1733
                f"dimensions for file {ipth}"
1734
            )
1735

1736
        if expected_first_dim is not None:
1✔
1737
            assert expected_first_dim in dat.shape, (
1✔
1738
                f"Expected first dimension size {expected_first_dim} not found in any "
1739
                f"data dimension for file {ipth}."
1740
            )
1741

1742
            if dat.shape[0] != expected_first_dim:
1✔
1743
                assert len(dat.shape) == 2, (
1✔
1744
                    f"Data in file {ipth} contains a dimension of expected size "
1745
                    f"{expected_first_dim}, but it is not the first dimension, and the "
1746
                    "data has dimensionality of > 2, so I do not know how to reshape "
1747
                    "it."
1748
                )
1749

1750
                dat = dat.transpose()
1✔
1751

1752
        return dat, hdr
1✔
1753

1754
    def Cp_Cb_Csp(self, TL, sInds, fZ, JEZ, dMag, WA, mode, returnExtra=False, TK=None):
1✔
1755
        """Calculates electron count rates for planet signal, background noise,
1756
        and speckle residuals.
1757

1758
        Args:
1759
            TL (:ref:`TargetList`):
1760
                TargetList class object
1761
            sInds (~numpy.ndarray(int)):
1762
                Integer indices of the stars of interest
1763
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
1764
                Surface brightness of local zodiacal light in units of 1/arcsec2
1765
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
1766
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
1767
            dMag (~numpy.ndarray(float)):
1768
                Differences in magnitude between planets and their host star
1769
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
1770
                Working angles of the planets of interest in units of arcsec
1771
            mode (dict):
1772
                Selected observing mode
1773
            returnExtra (bool):
1774
                Optional flag, default False, set True to return additional rates for
1775
                validation
1776
            TK (:ref:`TimeKeeping`, optional):
1777
                Optional TimeKeeping object (default None), used to model detector
1778
                degradation effects where applicable.
1779

1780

1781
        Returns:
1782
            tuple:
1783
                C_p (~astropy.units.Quantity(~numpy.ndarray(float))):
1784
                    Planet signal electron count rate in units of 1/s
1785
                C_b (~astropy.units.Quantity(~numpy.ndarray(float))):
1786
                    Background noise electron count rate in units of 1/s
1787
                C_sp (~astropy.units.Quantity(~numpy.ndarray(float))):
1788
                    Residual speckle spatial structure (systematic error)
1789
                    in units of 1/s
1790

1791
        """
1792

1793
        # grab all count rates
1794
        C_star, C_p, C_sr, C_z, C_ez, C_dc, C_bl, Npix = self.Cp_Cb_Csp_helper(
1✔
1795
            TL, sInds, fZ, JEZ, dMag, WA, mode
1796
        )
1797

1798
        # readout noise
1799
        inst = mode["inst"]
1✔
1800
        C_rn = Npix * inst["sread"] / inst["texp"]
1✔
1801

1802
        # background signal rate
1803
        C_b = C_sr + C_z + C_ez + C_bl + C_dc + C_rn
1✔
1804

1805
        # for characterization, Cb must include the planet
1806
        # C_sp = spatial structure to the speckle including post-processing contrast
1807
        # factor and stability factor
1808
        if not (mode["detectionMode"]):
1✔
1809
            C_b = C_b + C_p
1✔
1810
            C_sp = C_sr * TL.PostProcessing.ppFact_char(WA) * self.stabilityFact
1✔
1811
        else:
1812
            C_sp = C_sr * TL.PostProcessing.ppFact(WA) * self.stabilityFact
1✔
1813

1814
        if returnExtra:
1✔
1815
            # organize components into an optional fourth result
1816
            C_extra = dict(
×
1817
                C_sr=C_sr.to("1/s"),
1818
                C_z=C_z.to("1/s"),
1819
                C_ez=C_ez.to("1/s"),
1820
                C_dc=C_dc.to("1/s"),
1821
                C_rn=C_rn.to("1/s"),
1822
                C_star=C_star.to("1/s"),
1823
                C_bl=C_bl.to("1/s"),
1824
            )
1825
            return C_p.to("1/s"), C_b.to("1/s"), C_sp.to("1/s"), C_extra
×
1826
        else:
1827
            return C_p.to("1/s"), C_b.to("1/s"), C_sp.to("1/s")
1✔
1828

1829
    def Cp_Cb_Csp_helper(self, TL, sInds, fZ, JEZ, dMag, WA, mode):
1✔
1830
        """Helper method for Cp_Cb_Csp that performs lots of common computations
1831
        Args:
1832
            TL (:ref:`TargetList`):
1833
                TargetList class object
1834
            sInds (~numpy.ndarray(int)):
1835
                Integer indices of the stars of interest
1836
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
1837
                Surface brightness of local zodiacal light in units of 1/arcsec2
1838
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
1839
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
1840
            dMag (~numpy.ndarray(float)):
1841
                Differences in magnitude between planets and their host star
1842
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
1843
                Working angles of the planets of interest in units of arcsec
1844
            mode (dict):
1845
                Selected observing mode
1846

1847
        Returns:
1848
            tuple:
1849
                C_star (~astropy.units.Quantity(~numpy.ndarray(float))):
1850
                    Non-coronagraphic star count rate (1/s)
1851
                C_p0 (~astropy.units.Quantity(~numpy.ndarray(float))):
1852
                    Planet count rate (1/s)
1853
                C_sr (~astropy.units.Quantity(~numpy.ndarray(float))):
1854
                    Starlight residual count rate (1/s)
1855
                C_z (~astropy.units.Quantity(~numpy.ndarray(float))):
1856
                    Local zodi count rate (1/s)
1857
                C_ez (~astropy.units.Quantity(~numpy.ndarray(float))):
1858
                    Exozodi count rate (1/s)
1859
                C_dc (~astropy.units.Quantity(~numpy.ndarray(float))):
1860
                    Dark current count rate (1/s)
1861
                C_bl (~astropy.units.Quantity(~numpy.ndarray(float))):
1862
                    Background leak count rate (1/s)'
1863
                Npix (float):
1864
                    Number of pixels in photometric aperture
1865
        """
1866

1867
        # get scienceInstrument and starlightSuppressionSystem and wavelength
1868
        inst = mode["inst"]
1✔
1869
        syst = mode["syst"]
1✔
1870
        lam = mode["lam"]
1✔
1871

1872
        # coronagraph parameters
1873
        occ_trans = syst["occ_trans"](lam, WA)
1✔
1874
        core_thruput = syst["core_thruput"](lam, WA)
1✔
1875
        Omega = syst["core_area"](lam, WA)
1✔
1876

1877
        # number of pixels per lenslet
1878
        pixPerLens = inst["lenslSamp"] ** 2.0
1✔
1879

1880
        # number of detector pixels in the photometric aperture = Omega / theta^2
1881
        Npix = pixPerLens * (Omega / inst["pixelScale"] ** 2.0).decompose().value
1✔
1882

1883
        # get stellar residual intensity in the planet PSF core
1884
        # if core_mean_intensity is None, fall back to using core_contrast
1885
        if syst["core_mean_intensity"] is None:
1✔
1886
            core_contrast = syst["core_contrast"](lam, WA)
1✔
1887
            core_intensity = core_contrast * core_thruput
1✔
1888
        else:
1889
            # if we're here, we're using the core mean intensity
1890
            core_mean_intensity = syst["core_mean_intensity"](
1✔
1891
                lam, WA, TL.diameter[sInds]
1892
            )
1893
            # also, if we're here, we must have a platescale defined
1894
            core_platescale = syst["core_platescale"].copy()
1✔
1895
            # furthermore, if we're a coronagraph, we have to scale by wavelength
1896
            if not (syst["occulter"]) and (syst["lam"] != mode["lam"]):
1✔
1897
                core_platescale *= mode["lam"] / syst["lam"]
×
1898

1899
            # core_intensity is the mean intensity times the number of map pixels
1900
            core_intensity = core_mean_intensity * Omega / core_platescale**2
1✔
1901

1902
            # finally, if a contrast floor was set, make sure we're not violating it
1903
            if syst["contrast_floor"] is not None:
1✔
1904
                below_contrast_floor = (
×
1905
                    core_intensity / core_thruput < syst["contrast_floor"]
1906
                )
1907
                core_intensity[below_contrast_floor] = (
×
1908
                    syst["contrast_floor"] * core_thruput[below_contrast_floor]
1909
                )
1910

1911
        # cast sInds to array
1912
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
1913

1914
        # Star fluxes (ph/m^2/s)
1915
        flux_star = TL.starFlux(sInds, mode)
1✔
1916

1917
        # ELECTRON COUNT RATES [ s^-1 ]
1918
        # non-coronagraphic star counts
1919
        C_star = flux_star * mode["losses"]
1✔
1920
        # planet counts:
1921
        C_p0 = (C_star * 10.0 ** (-0.4 * dMag) * core_thruput).to("1/s")
1✔
1922
        # starlight residual
1923
        C_sr = (C_star * core_intensity).to("1/s")
1✔
1924
        # zodiacal light
1925
        C_z = (mode["F0"] * mode["losses"] * fZ * Omega * occ_trans).to("1/s")
1✔
1926
        # exozodiacal light
1927
        if self.use_core_thruput_for_ez:
1✔
NEW
1928
            C_ez = (JEZ * mode["losses"] * Omega * core_thruput).to("1/s")
×
1929
        else:
1930
            C_ez = (JEZ * mode["losses"] * Omega * occ_trans).to("1/s")
1✔
1931
        # dark current
1932
        C_dc = Npix * inst["idark"]
1✔
1933

1934
        # only calculate binary leak if you have a model and relevant data
1935
        # in the targelist
1936
        if hasattr(self, "binaryleakmodel") and all(
1✔
1937
            hasattr(TL, attr)
1938
            for attr in ["closesep", "closedm", "brightsep", "brightdm"]
1939
        ):
UNCOV
1940
            cseps = TL.closesep[sInds]
×
1941
            cdms = TL.closedm[sInds]
×
1942
            bseps = TL.brightsep[sInds]
×
1943
            bdms = TL.brightdm[sInds]
×
1944

1945
            # don't double count where the bright star is the close star
1946
            repinds = (cseps == bseps) & (cdms == bdms)
×
1947
            bseps[repinds] = np.nan
×
1948
            bdms[repinds] = np.nan
×
1949

1950
            crawleaks = self.binaryleakmodel(
×
1951
                (
1952
                    ((cseps * u.arcsec).to(u.rad)).value / lam * self.pupilDiam
1953
                ).decompose()
1954
            )
1955
            cleaks = crawleaks * 10 ** (-0.4 * cdms)
×
1956
            cleaks[np.isnan(cleaks)] = 0
×
1957

1958
            brawleaks = self.binaryleakmodel(
×
1959
                (
1960
                    ((bseps * u.arcsec).to(u.rad)).value / lam * self.pupilDiam
1961
                ).decompose()
1962
            )
1963
            bleaks = brawleaks * 10 ** (-0.4 * bdms)
×
1964
            bleaks[np.isnan(bleaks)] = 0
×
1965

1966
            C_bl = (cleaks + bleaks) * C_star * core_thruput
×
1967
        else:
1968
            C_bl = np.zeros(len(sInds)) / u.s
1✔
1969

1970
        return C_star, C_p0, C_sr, C_z, C_ez, C_dc, C_bl, Npix
1✔
1971

1972
    def calc_intTime(self, TL, sInds, fZ, JEZ, dMag, WA, mode, TK=None):
1✔
1973
        """Finds integration time to reach a given dMag at a particular WA with given
1974
        local and exozodi values for specific targets and for a specific observing mode.
1975

1976

1977
        Args:
1978
            TL (:ref:`TargetList`):
1979
                TargetList class object
1980
            sInds (numpy.ndarray(int)):
1981
                Integer indices of the stars of interest
1982
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
1983
                Surface brightness of local zodiacal light in units of 1/arcsec2
1984
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
1985
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
1986
            dMag (numpy.ndarray(int)numpy.ndarray(float)):
1987
                Differences in magnitude between planets and their host star
1988
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
1989
                Working angles of the planets of interest in units of arcsec
1990
            mode (dict):
1991
                Selected observing mode
1992
            TK (:ref:`TimeKeeping`, optional):
1993
                Optional TimeKeeping object (default None), used to model detector
1994
                degradation effects where applicable.
1995

1996
        Returns:
1997
            ~astropy.units.Quantity(~numpy.ndarray(float)):
1998
                Integration times
1999

2000
        .. note::
2001

2002
            All infeasible integration times are returned as NaN values
2003

2004
        """
2005
        # count rates
2006
        C_p, C_b, C_sp = self.Cp_Cb_Csp(TL, sInds, fZ, JEZ, dMag, WA, mode, TK=TK)
1✔
2007

2008
        # get SNR threshold
2009
        SNR = mode["SNR"]
1✔
2010

2011
        with np.errstate(divide="ignore", invalid="ignore"):
1✔
2012
            intTime = np.true_divide(
1✔
2013
                SNR**2.0 * C_b, (C_p**2.0 - (SNR * C_sp) ** 2.0)
2014
            ).to("day")
2015

2016
        # infinite and negative values are set to NAN
2017
        intTime[np.isinf(intTime) | (intTime.value < 0.0)] = np.nan
1✔
2018

2019
        return intTime
1✔
2020

2021
    def calc_dMag_per_intTime(
1✔
2022
        self, intTimes, TL, sInds, fZ, JEZ, WA, mode, C_b=None, C_sp=None, TK=None
2023
    ):
2024
        """Finds achievable planet delta magnitude for one integration
2025
        time per star in the input list at one working angle.
2026

2027
        Args:
2028
            intTimes (~astropy.units.Quantity(~numpy.ndarray(float))):
2029
                Integration times in units of day
2030
            TL (:ref:`TargetList`):
2031
                TargetList class object
2032
            sInds (numpy.ndarray(int)):
2033
                Integer indices of the stars of interest
2034
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2035
                Surface brightness of local zodiacal light in units of 1/arcsec2
2036
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2037
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
2038
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
2039
                Working angles of the planets of interest in units of arcsec
2040
            mode (dict):
2041
                Selected observing mode
2042
            C_b (~astropy.units.Quantity(~numpy.ndarray(float))):
2043
                Background noise electron count rate in units of 1/s (optional)
2044
            C_sp (~astropy.units.Quantity(~numpy.ndarray(float))):
2045
                Residual speckle spatial structure (systematic error) in units of 1/s
2046
                (optional)
2047
            TK (:ref:`TimeKeeping`, optional):
2048
                Optional TimeKeeping object (default None), used to model detector
2049
                degradation effects where applicable.
2050

2051
        Returns:
2052
            numpy.ndarray(float):
2053
                Achievable dMag for given integration time and working angle
2054

2055
        .. warning::
2056

2057
            The prototype implementation assumes the exact same integration time model
2058
            as the other prototype methods (specifically Cp_Cb_Csp and calc_intTime).
2059
            If either of these is overloaded, and, in particular, if C_b and/or C_sp are
2060
            not modeled as independent of C_p, then the analytical approach used here
2061
            will *not* work and must be replaced with numerical inversion.
2062

2063
        """
2064

2065
        # cast sInds to array
2066
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
2067

2068
        if (C_b is None) or (C_sp is None):
1✔
2069
            _, C_b, C_sp = self.Cp_Cb_Csp(
1✔
2070
                TL, sInds, fZ, JEZ, np.zeros(len(sInds)), WA, mode, TK=TK
2071
            )
2072

2073
        C_p = mode["SNR"] * np.sqrt(C_sp**2 + C_b / intTimes)  # planet count rate
1✔
2074
        core_thruput = mode["syst"]["core_thruput"](mode["lam"], WA)
1✔
2075
        flux_star = TL.starFlux(sInds, mode)
1✔
2076

2077
        dMag = -2.5 * np.log10(C_p / (flux_star * mode["losses"] * core_thruput))
1✔
2078

2079
        return dMag.value
1✔
2080

2081
    def ddMag_dt(
1✔
2082
        self, intTimes, TL, sInds, fZ, JEZ, WA, mode, C_b=None, C_sp=None, TK=None
2083
    ):
2084
        """Finds derivative of achievable dMag with respect to integration time.
2085

2086
        Args:
2087
            intTimes (~astropy.units.Quantity(~numpy.ndarray(float))):
2088
                Integration times in units of day
2089
            TL (:ref:`TargetList`):
2090
                TargetList class object
2091
            sInds (numpy.ndarray(int)):
2092
                Integer indices of the stars of interest
2093
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2094
                Surface brightness of local zodiacal light in units of 1/arcsec2
2095
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2096
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
2097
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
2098
                Working angles of the planets of interest in units of arcsec
2099
            mode (dict):
2100
                Selected observing mode
2101
            C_b (~astropy.units.Quantity(~numpy.ndarray(float))):
2102
                Background noise electron count rate in units of 1/s (optional)
2103
            C_sp (~astropy.units.Quantity(~numpy.ndarray(float))):
2104
                Residual speckle spatial structure (systematic error) in units of 1/s
2105
                (optional)
2106
            TK (:ref:`TimeKeeping`, optional):
2107
                Optional TimeKeeping object (default None), used to model detector
2108
                degradation effects where applicable.
2109

2110
        Returns:
2111
            ~astropy.units.Quantity(~numpy.ndarray(float)):
2112
                Derivative of achievable dMag with respect to integration time
2113
                in units of 1/s
2114

2115
        """
2116
        # cast sInds to array
2117
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
2118

2119
        if (C_b is None) or (C_sp is None):
1✔
2120
            _, C_b, C_sp = self.Cp_Cb_Csp(
1✔
2121
                TL, sInds, fZ, JEZ, np.zeros(len(sInds)), WA, mode, TK=TK
2122
            )
2123

2124
        ddMagdt = 5 / 4 / np.log(10) * C_b / (C_b * intTimes + (C_sp * intTimes) ** 2)
1✔
2125

2126
        return ddMagdt.to("1/s")
1✔
2127

2128
    def calc_saturation_dMag(self, TL, sInds, fZ, JEZ, WA, mode, TK=None):
1✔
2129
        """
2130
        This calculates the delta magnitude for each target star that
2131
        corresponds to an infinite integration time.
2132

2133
        Args:
2134
            TL (:ref:`TargetList`):
2135
                TargetList class object
2136
            sInds (numpy.ndarray(int)):
2137
                Integer indices of the stars of interest
2138
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2139
                Surface brightness of local zodiacal light in units of 1/arcsec2
2140
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2141
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
2142
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
2143
                Working angles of the planets of interest in units of arcsec
2144
            mode (dict):
2145
                Selected observing mode
2146
            TK (:ref:`TimeKeeping`, optional):
2147
                Optional TimeKeeping object (default None), used to model detector
2148
                degradation effects where applicable.
2149

2150
        Returns:
2151
            ~numpy.ndarray(float):
2152
                Saturation (maximum achievable) dMag for each target star
2153
        """
2154

2155
        _, C_b, C_sp = self.Cp_Cb_Csp(
1✔
2156
            TL, sInds, fZ, JEZ, np.zeros(len(sInds)), WA, mode, TK=TK
2157
        )
2158

2159
        flux_star = TL.starFlux(sInds, mode)
1✔
2160
        core_thruput = mode["syst"]["core_thruput"](mode["lam"], WA)
1✔
2161

2162
        dMagmax = -2.5 * np.log10(
1✔
2163
            mode["SNR"] * C_sp / (flux_star * mode["losses"] * core_thruput)
2164
        )
2165

2166
        return dMagmax.value
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc