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

dsavransky / EXOSIMS / 14561753842

20 Apr 2025 05:25PM UTC coverage: 65.777% (+0.07%) from 65.708%
14561753842

push

github

web-flow
Merge pull request #408 from dsavransky/performance_optimization

Performance optimizations.

604 of 731 new or added lines in 15 files covered. (82.63%)

22 existing lines in 9 files now uncovered.

9681 of 14718 relevant lines covered (65.78%)

0.66 hits per line

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

77.39
/EXOSIMS/Prototypes/OpticalSystem.py
1
# -*- coding: utf-8 -*-
2
import copy
1✔
3
import numbers
1✔
4
import os.path
1✔
5
import warnings
1✔
6

7
import astropy.io.fits as fits
1✔
8
import astropy.units as u
1✔
9
import numpy as np
1✔
10
import scipy.interpolate
1✔
11
import scipy.optimize
1✔
12
from synphot import Observation, SourceSpectrum, SpectralElement
1✔
13
from synphot.models import Box1D, Gaussian1D
1✔
14

15
from EXOSIMS.util._numpy_compat import copy_if_needed
1✔
16
from EXOSIMS.util.get_dirs import get_cache_dir
1✔
17
from EXOSIMS.util.keyword_fun import get_all_args
1✔
18
from EXOSIMS.util.utils import dictToSortedStr, genHexStr
1✔
19
from EXOSIMS.util.vprint import vprint
1✔
20

21

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

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

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

246
            .. math::
247

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

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

282
    """
283

284
    _modtype = "OpticalSystem"
1✔
285

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

442
        self.populate_observingModes(observingModes)
1✔
443

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

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

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

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

470
        self.unit_conv = {}
1✔
471
        self.inv_s = 1 / u.s
1✔
472
        self.s2d = (1 * u.s).to_value(u.d)
1✔
473
        self.arcsec2rad = (1 * u.arcsec).to_value(u.rad)
1✔
474

475
    def __str__(self):
1✔
476
        """String representation of the Optical System object
477

478
        When the command 'print' is used on the Optical System object, this
479
        method will print the attribute values contained in the object
480

481
        """
482

483
        for att in self.__dict__:
1✔
484
            print("%s: %r" % (att, getattr(self, att)))
1✔
485

486
        return "Optical System class object attributes"
1✔
487

488
    def populate_scienceInstruments(self, scienceInstruments):
1✔
489
        """Helper method to parse input scienceInstrument dictionaries and assign
490
        default values, as needed. Also creates the allowed_scienceInstrument_kws
491
        attribute.
492

493
        Args:
494
            scienceInstruments (list):
495
                List of scienceInstrument dicts.
496

497
        """
498

499
        self.scienceInstruments = copy.deepcopy(scienceInstruments)
1✔
500
        self._outspec["scienceInstruments"] = []
1✔
501
        instnames = []
1✔
502

503
        for ninst, inst in enumerate(self.scienceInstruments):
1✔
504
            assert isinstance(
1✔
505
                inst, dict
506
            ), "Science instruments must be defined as dicts."
507
            assert "name" in inst and isinstance(
1✔
508
                inst["name"], str
509
            ), "All science instruments must have key 'name'."
510
            instnames.append(inst["name"])
1✔
511

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

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

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

573
            for kw in kws:
1✔
574
                inst[kw] = float(inst.get(kw, self.default_vals[kw]))
1✔
575
                if kws[kw] is not None:
1✔
576
                    inst[kw] *= kws[kw]
1✔
577

578
            # start tracking allowed_scienceInstrument_kws
579
            self.allowed_scienceInstrument_kws = ["name", "QE"] + list(kws.keys())
1✔
580

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

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

604
            self.allowed_scienceInstrument_kws += ["Rs", "lenslSamp"]
1✔
605

606
            # calculate focal length and f-number as needed
607
            if "focal" in inst:
1✔
608
                inst["focal"] = float(inst["focal"]) * u.m
1✔
609
                inst["fnumber"] = float(inst["focal"] / self.pupilDiam)
1✔
610
            elif ("fnumber") in inst:
1✔
611
                inst["fnumber"] = float(inst["fnumber"])
×
612
                inst["focal"] = inst["fnumber"] * self.pupilDiam
×
613
            else:
614
                inst["focal"] = (
1✔
615
                    inst["pixelSize"] / 2 / np.tan(inst["pixelScale"] / 2)
616
                ).to(u.m)
617
                inst["fnumber"] = float(inst["focal"] / self.pupilDiam)
1✔
618

619
            self.allowed_scienceInstrument_kws += ["focal", "fnumber"]
1✔
620

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

630
            # populate updated detector specifications to outspec
631
            for att in inst:
1✔
632
                if att not in ["QE"]:
1✔
633
                    dat = inst[att]
1✔
634
                    self._outspec["scienceInstruments"][ninst][att] = (
1✔
635
                        dat.value if isinstance(dat, u.Quantity) else dat
636
                    )
637

638
        # ensure that all instrument names are unique:
639
        assert (
1✔
640
            len(instnames) == np.unique(instnames).size
641
        ), "Instrument names muse be unique."
642

643
        # call additional instrument setup
644
        self.populate_scienceInstruments_extra()
1✔
645

646
    def populate_scienceInstruments_extra(self):
1✔
647
        """Additional setup for science instruments.  This is intended for overloading
648
        in downstream implementations and is intentionally left blank in the prototype.
649
        """
650

651
        pass
1✔
652

653
    def populate_starlightSuppressionSystems(self, starlightSuppressionSystems):
1✔
654
        """Helper method to parse input starlightSuppressionSystem dictionaries and
655
        assign default values, as needed. Also creates the
656
        allowed_starlightSuppressionSystem_kws attribute.
657

658
        Args:
659
            starlightSuppressionSystems (list):
660
                List of starlightSuppressionSystem dicts.
661

662
        """
663

664
        self.starlightSuppressionSystems = copy.deepcopy(starlightSuppressionSystems)
1✔
665
        self.haveOcculter = False
1✔
666
        self._outspec["starlightSuppressionSystems"] = []
1✔
667
        systnames = []
1✔
668

669
        for nsyst, syst in enumerate(self.starlightSuppressionSystems):
1✔
670
            assert isinstance(
1✔
671
                syst, dict
672
            ), "Starlight suppression systems must be defined as dicts."
673
            assert "name" in syst and isinstance(
1✔
674
                syst["name"], str
675
            ), "All starlight suppression systems must have key 'name'."
676
            systnames.append(syst["name"])
1✔
677

678
            # determine system wavelength (lam), bandwidth (deltaLam) and bandwidth
679
            # fraction (BW)
680
            # use deltaLam if given, otherwise use BW
681
            syst["lam"] = float(syst.get("lam", self.default_vals["lam"])) * u.nm
1✔
682
            syst["deltaLam"] = (
1✔
683
                float(
684
                    syst.get(
685
                        "deltaLam",
686
                        syst["lam"].to("nm").value
687
                        * syst.get("BW", self.default_vals["BW"]),
688
                    )
689
                )
690
                * u.nm
691
            )
692
            syst["BW"] = float(syst["deltaLam"] / syst["lam"])
1✔
693

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

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

729
            # attenuation due to optics specific to the coronagraph not caputred by the
730
            # coronagraph throughput curves. Defaults to 1.
731
            syst["optics"] = float(syst.get("optics", 1.0))
1✔
732

733
            # set an occulter, for an external or hybrid system
734
            syst["occulter"] = syst.get("occulter", False)
1✔
735
            if syst["occulter"]:
1✔
736
                self.haveOcculter = True
1✔
737

738
            # copy system definition to outspec
739
            self._outspec["starlightSuppressionSystems"].append(syst.copy())
1✔
740

741
            # now we populate everything that has units
742

743
            # overhead time:
744
            syst["ohTime"] = (
1✔
745
                float(syst.get("ohTime", self.default_vals["ohTime"])) * u.d
746
            )
747

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

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

763
                syst["core_platescale"] = (
1✔
764
                    syst["core_platescale"] * platescale_unit
765
                ).to(u.arcsec)
766

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

782
            # get the system's keepout angles
783
            names = [
1✔
784
                "koAngles_Sun",
785
                "koAngles_Earth",
786
                "koAngles_Moon",
787
                "koAngles_Small",
788
            ]
789
            for n in names:
1✔
790
                syst[n] = [float(x) for x in syst.get(n, self.default_vals[n])] * u.deg
1✔
791

792
            self.allowed_starlightSuppressionSystem_kws += names
1✔
793

794
            # now we're going to populate everything that's callable
795

796
            # first let's handle core mean intensity
797
            if "core_mean_intensity" in syst:
1✔
798
                syst = self.get_core_mean_intensity(syst)
1✔
799

800
                # ensure that platescale has also been set
801
                assert syst["core_platescale"] is not None, (
1✔
802
                    f"In system {syst['name']}, core_mean_intensity "
803
                    "is set, but core_platescale is not.  This is not allowed."
804
                )
805
            else:
806
                syst["core_mean_intensity"] = None
1✔
807

808
            if "core_contrast" in syst:
1✔
809
                syst = self.get_coro_param(
1✔
810
                    syst,
811
                    "core_contrast",
812
                    fill=1.0,
813
                    expected_ndim=2,
814
                    expected_first_dim=2,
815
                    min_val=0.0,
816
                )
817
            else:
818
                syst["core_contrast"] = None
1✔
819

820
            # now get the throughputs
821
            syst = self.get_coro_param(
1✔
822
                syst,
823
                "occ_trans",
824
                expected_ndim=2,
825
                expected_first_dim=2,
826
                min_val=0.0,
827
                max_val=(np.inf if syst["occulter"] else 1.0),
828
            )
829
            syst = self.get_coro_param(
1✔
830
                syst,
831
                "core_thruput",
832
                expected_ndim=2,
833
                expected_first_dim=2,
834
                min_val=0.0,
835
                max_val=(np.inf if syst["occulter"] else 1.0),
836
            )
837

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

848
                syst["core_area"] = (
1✔
849
                    (
850
                        (np.pi / 2)
851
                        * (syst["lam"] / self.pupilDiam).to(
852
                            u.arcsec, equivalencies=u.dimensionless_angles()
853
                        )
854
                        ** 2
855
                        / angunit**2
856
                    )
857
                    .decompose()
858
                    .value
859
                )
860
            syst = self.get_coro_param(
1✔
861
                syst,
862
                "core_area",
863
                expected_ndim=2,
864
                expected_first_dim=2,
865
                min_val=0.0,
866
            )
867

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

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

893
        # ensure that all starlight suppression system names are unique:
894
        assert (
1✔
895
            len(systnames) == np.unique(systnames).size
896
        ), "Starlight suppression system names muse be unique."
897

898
        # call additional setup
899
        self.populate_starlightSuppressionSystems_extra()
1✔
900

901
    def populate_starlightSuppressionSystems_extra(self):
1✔
902
        """Additional setup for starlight suppression systems.  This is intended for
903
        overloading in downstream implementations and is intentionally left blank in
904
        the prototype.
905
        """
906

907
        pass
1✔
908

909
    def populate_observingModes(self, observingModes):
1✔
910
        """Helper method to parse input observingMode dictionaries and assign default
911
        values, as needed. Also creates the allowed_observingMode_kws attribute.
912

913
        Args:
914
            observingModes (list):
915
                List of observingMode dicts.
916

917
        """
918

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

937
            # loading mode specifications
938
            mode["SNR"] = float(mode.get("SNR", self.default_vals["SNR"]))
1✔
939
            mode["timeMultiplier"] = float(
1✔
940
                mode.get("timeMultiplier", self.default_vals["timeMultiplier"])
941
            )
942
            mode["detectionMode"] = mode.get("detectionMode", False)
1✔
943
            mode["inst"] = [
1✔
944
                inst
945
                for inst in self.scienceInstruments
946
                if inst["name"] == mode["instName"]
947
            ][0]
948
            mode["syst"] = [
1✔
949
                syst
950
                for syst in self.starlightSuppressionSystems
951
                if syst["name"] == mode["systName"]
952
            ][0]
953

954
            # start tracking allowed keywords
955
            self.allowed_observingMode_kws = [
1✔
956
                "instName",
957
                "systName",
958
                "SNR",
959
                "timeMultiplier",
960
                "detectionMode",
961
                "lam",
962
                "deltaLam",
963
                "BW",
964
                "bandpass_model",
965
                "bandpass_step",
966
            ]
967

968
            # get mode wavelength and bandwidth (get system's values by default)
969
            # when provided, always use deltaLam instead of BW (bandwidth fraction)
970
            syst_lam = mode["syst"]["lam"].to("nm").value
1✔
971
            syst_BW = mode["syst"]["BW"]
1✔
972
            mode["lam"] = float(mode.get("lam", syst_lam)) * u.nm
1✔
973
            mode["deltaLam"] = (
1✔
974
                float(mode.get("deltaLam", mode["lam"].value * mode.get("BW", syst_BW)))
975
                * u.nm
976
            )
977
            mode["BW"] = float(mode["deltaLam"] / mode["lam"])
1✔
978

979
            # get mode IWA and OWA: rescale if the mode wavelength is different from
980
            # the wavelength at which the system is defined
981
            mode["IWA"] = mode["syst"]["IWA"]
1✔
982
            mode["OWA"] = mode["syst"]["OWA"]
1✔
983
            if mode["lam"] != mode["syst"]["lam"]:
1✔
984
                mode["IWA"] = mode["IWA"] * mode["lam"] / mode["syst"]["lam"]
1✔
985
                mode["OWA"] = mode["OWA"] * mode["lam"] / mode["syst"]["lam"]
1✔
986

987
            # OWA must be bounded by FOV:
988
            if mode["OWA"] > mode["inst"]["FoV"]:
1✔
989
                mode["OWA"] = mode["inst"]["FoV"]
1✔
990

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

1018
            # check for out of range wavelengths
1019
            # currently capped to 10 um
1020
            assert (
1✔
1021
                mode["bandpass"].waveset.max() < 10 * u.um
1022
            ), "Bandpasses beyond 10 um are not supported."
1023

1024
            # evaluate zero-magnitude flux for this band from vega spectrum
1025
            # NB: This is flux, not flux density! The bandpass is already factored in.
1026
            mode["F0"] = Observation(
1✔
1027
                self.vega_spectrum, mode["bandpass"], force="taper"
1028
            ).integrate()
1029

1030
            # Evaluate the V-band zero magnitude flux density
1031

1032
            # populate system specifications to outspec
1033
            for att in mode:
1✔
1034
                if att not in [
1✔
1035
                    "inst",
1036
                    "syst",
1037
                    "F0",
1038
                    "bandpass",
1039
                ]:
1040
                    dat = mode[att]
1✔
1041
                    self._outspec["observingModes"][nmode][att] = (
1✔
1042
                        dat.value if isinstance(dat, u.Quantity) else dat
1043
                    )
1044

1045
            # populate some final mode attributes (computed from the others)
1046
            # define total mode attenution
1047
            mode["attenuation"] = mode["inst"]["optics"] * mode["syst"]["optics"]
1✔
1048

1049
            # effective mode bandwidth (including any IFS spectral resolving power)
1050
            mode["deltaLam_eff"] = (
1✔
1051
                mode["lam"] / mode["inst"]["Rs"]
1052
                if "spec" in mode["inst"]["name"].lower()
1053
                else mode["deltaLam"]
1054
            )
1055

1056
            # total attenuation due to non-coronagraphic optics:
1057
            mode["losses"] = (
1✔
1058
                self.pupilArea
1059
                * mode["inst"]["QE"](mode["lam"])
1060
                * mode["attenuation"]
1061
                * mode["deltaLam_eff"]
1062
                / mode["deltaLam"]
1063
            )
1064

1065
        # check for only one detection mode
1066
        allModes = self.observingModes
1✔
1067
        detModes = list(filter(lambda mode: mode["detectionMode"], allModes))
1✔
1068
        assert len(detModes) <= 1, "More than one detection mode specified."
1✔
1069

1070
        # if not specified, default detection mode is first imager mode
1071
        if len(detModes) == 0:
1✔
1072
            imagerModes = list(
1✔
1073
                filter(lambda mode: "imag" in mode["inst"]["name"], allModes)
1074
            )
1075
            if imagerModes:
1✔
1076
                imagerModes[0]["detectionMode"] = True
1✔
1077
            # if no imager mode, default detection mode is first observing mode
1078
            else:
1079
                allModes[0]["detectionMode"] = True
×
1080

1081
        self.populate_observingModes_extra()
1✔
1082

1083
    def populate_observingModes_extra(self):
1✔
1084
        """Additional setup for observing modes  This is intended for overloading in
1085
        downstream implementations and is intentionally left blank in the prototype.
1086
        """
1087

1088
        pass
1✔
1089

1090
    def genObsModeHex(self):
1✔
1091
        """Generate a unique hash for every observing mode to be used in downstream
1092
        identification and caching. Also adds an integer index to the mode corresponding
1093
        to its order in the observingModes list.
1094

1095
        The hash will be based on the _outspec entries for the obsmode, its science
1096
        instrument and its starlight suppression system.
1097
        """
1098

1099
        for nmode, mode in enumerate(self.observingModes):
1✔
1100
            inst = [
1✔
1101
                inst
1102
                for inst in self._outspec["scienceInstruments"]
1103
                if inst["name"] == mode["instName"]
1104
            ][0]
1105
            syst = [
1✔
1106
                syst
1107
                for syst in self._outspec["starlightSuppressionSystems"]
1108
                if syst["name"] == mode["systName"]
1109
            ][0]
1110

1111
            modestr = "{},{},{}".format(
1✔
1112
                dictToSortedStr(self._outspec["observingModes"][nmode]),
1113
                dictToSortedStr(inst),
1114
                dictToSortedStr(syst),
1115
            )
1116

1117
            mode["hex"] = genHexStr(modestr)
1✔
1118
            mode["index"] = nmode
1✔
1119

1120
    def get_core_mean_intensity(
1✔
1121
        self,
1122
        syst,
1123
    ):
1124
        """Load and process core_mean_intensity data
1125

1126
        Args:
1127
            syst (dict):
1128
                Dictionary containing the parameters of one starlight suppression system
1129

1130
        Returns:
1131
            dict:
1132
                Updated dictionary of starlight suppression system parameters
1133

1134
        """
1135

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

1149
            # check values as needed
1150
            assert np.all(
×
1151
                D > 0
1152
            ), f"{param_name} in {syst['name']} must be >0 everywhere."
1153

1154
            # get angle unit scale WA
1155
            angunit = self.get_angle_unit_from_header(hdr, syst)
×
1156
            WA = (WA * angunit).to(u.arcsec).value
×
1157

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

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

1186
            # handle case where only one data row is present
1187
            if D.shape[0] == 1:
×
1188
                D = np.squeeze(D)
×
1189

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

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

1240
                # determine units and convert as needed
1241
                diams = (diams * angunit).to(u.arcsec).value
×
1242

1243
                Dinterp = scipy.interpolate.RegularGridInterpolator(
×
1244
                    (WA, diams), D.transpose(), bounds_error=False, fill_value=1.0
1245
                )
1246

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

NEW
1270
                    def core_mean_intens_fits_multi(lam, s, d):
×
NEW
1271
                        lam_val = lam.to_value(lam0_unit)
×
NEW
1272
                        lam_ratio = lam0_val / lam_val
×
1273
                        # Scale the working angle by the wavelength ratio
NEW
1274
                        s_scaled_as = s.to_value(u.arcsec) * lam_ratio
×
1275
                        # Scale the stellar diameter by the wavelength ratio
NEW
1276
                        d_scaled_as = d.to_value(u.arcsec) * lam_ratio
×
NEW
1277
                        return np.array(Dinterp((s_scaled_as, d_scaled_as)), ndmin=1)
×
1278

NEW
1279
                    syst[param_name] = core_mean_intens_fits_multi
×
1280

1281
            # update IWA/OWA in system as needed
1282
            syst = self.update_syst_WAs(syst, WA, param_name)
×
1283

1284
        elif isinstance(syst[param_name], numbers.Number):
1✔
1285
            # ensure paramter is within bounds
1286
            D = float(syst[param_name])
1✔
1287
            assert D > 0, f"{param_name} in {syst['name']} must be > 0."
1✔
1288

1289
            # ensure you have values for IWA/OWA, otherwise use defaults
1290
            syst = self.update_syst_WAs(syst, None, None)
1✔
1291
            IWA = syst["IWA"].to(u.arcsec).value
1✔
1292
            OWA = syst["OWA"].to(u.arcsec).value
1✔
1293

1294
            # same as for interpolant: coronagraphs scale with wavelength, occulters
1295
            # don't
1296
            if syst["occulter"]:
1✔
1297
                minl = syst["lam"] - syst["deltaLam"] / 2
×
1298
                maxl = syst["lam"] + syst["deltaLam"] / 2
×
1299

1300
                syst[param_name] = (
×
1301
                    lambda lam, s, d=0 * u.arcsec, D=D, IWA=IWA, OWA=OWA, minl=minl, maxl=maxl, fill=fill: (  # noqa: E501
1302
                        np.array(
1303
                            (IWA <= s.to("arcsec").value)
1304
                            & (s.to("arcsec").value <= OWA)
1305
                            & (minl < lam)
1306
                            & (lam < maxl),
1307
                            ndmin=1,
1308
                        ).astype(float)
1309
                        * (D - fill)
1310
                        + fill
1311
                    )
1312
                )
1313

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

1336
        return syst
1✔
1337

1338
    def get_angle_unit_from_header(self, hdr, syst):
1✔
1339
        """Helper method. Extract angle unit from header, if it exists.
1340

1341
        Args:
1342
            hdr (astropy.io.fits.header.Header or list):
1343
                FITS header for data or header row from CSV
1344
            syst (dict):
1345
                Dictionary containing the parameters of one starlight suppression system
1346

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

1375
        # final consistency check before returning
1376
        assert (
1✔
1377
            angunit.unit.physical_type == "angle"
1378
        ), f"Angle unit for system {syst['name']} is not an angle."
1379

1380
        return angunit
1✔
1381

1382
    def update_syst_WAs(self, syst, WA0, param_name):
1✔
1383
        """Helper method. Check system IWA/OWA and update from table
1384
        data, as needed. Alternatively, set from defaults.
1385

1386
        Args:
1387
            syst (dict):
1388
                Dictionary containing the parameters of one starlight suppression system
1389
            WA0 (~numpy.ndarray, optional):
1390
                Array of angles from table data. Must be in arcseconds. If None, then
1391
                just set from defaults.
1392
            param_name (str, optional):
1393
                Name of parameter the table data belongs to. Must be set if WA is set.
1394

1395
        Returns:
1396
            dict:
1397
                Updated dictionary of starlight suppression system parameters
1398

1399
        """
1400

1401
        # if WA not given, then we're going to be setting defaults, as needed.
1402
        if WA0 is None:
1✔
1403
            if "IWA" not in syst:
1✔
1404
                syst["IWA"] = (
1✔
1405
                    float(self.default_vals["IWA"]) * syst["input_angle_unit_value"]
1406
                ).to(u.arcsec)
1407

1408
            if "OWA" not in syst:
1✔
1409
                syst["OWA"] = (
1✔
1410
                    float(self.default_vals["OWA"]) * syst["input_angle_unit_value"]
1411
                ).to(u.arcsec)
1412

1413
            return syst
1✔
1414

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

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

1438
        return syst
1✔
1439

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

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

1472
        Returns:
1473
            dict:
1474
                Updated dictionary of starlight suppression system parameters
1475

1476
        .. note::
1477

1478
            The created lambda function handles the specified wavelength by
1479
            rescaling the specified working angle by a factor syst['lam']/mode['lam']
1480

1481
        .. note::
1482

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

1486
        """
1487

1488
        assert param_name in syst, f"{param_name} not found in system {syst['name']}."
1✔
1489
        if isinstance(syst[param_name], str):
1✔
1490
            dat, hdr = self.get_param_data(
1✔
1491
                syst[param_name],
1492
                left_col_name=syst["csv_angsep_colname"],
1493
                param_name=param_name,
1494
                expected_ndim=expected_ndim,
1495
                expected_first_dim=expected_first_dim,
1496
            )
1497
            WA, D = dat[0].astype(float), dat[1].astype(float)
1✔
1498

1499
            # check values as needed
1500
            if min_val is not None:
1✔
1501
                assert np.all(D >= min_val), (
1✔
1502
                    f"{param_name} in {syst['name']} may not "
1503
                    f"have values less than {min_val}."
1504
                )
1505
            if max_val is not None:
1✔
1506
                assert np.all(D <= max_val), (
1✔
1507
                    f"{param_name} in {syst['name']} may "
1508
                    f"not have values greater than {max_val}."
1509
                )
1510

1511
            # check for units
1512
            angunit = self.get_angle_unit_from_header(hdr, syst)
1✔
1513
            WA = (WA * angunit).to_value(u.arcsec)
1✔
1514

1515
            # for core_area only, also need to scale the data
1516
            if param_name == "core_area":
1✔
NEW
1517
                D = (D * angunit**2).to_value(u.arcsec**2)
×
NEW
1518
                outunit = u.arcsec**2
×
1519

1520
            # update IWA/OWA as needed
1521
            syst = self.update_syst_WAs(syst, WA, param_name)
1✔
1522

1523
            # table interpolate function
1524
            Dinterp = scipy.interpolate.interp1d(
1✔
1525
                WA,
1526
                D,
1527
                kind="linear",
1528
                fill_value=fill,
1529
                bounds_error=False,
1530
            )
1531
            # create a callable lambda function. for coronagraphs, we need to scale the
1532
            # angular separation by wavelength, but for occulters we just need to
1533
            # ensure that we're within the wavelength range. for core_area, we also
1534
            # need to scale the output by wavelengh^2.
1535
            if syst["occulter"]:
1✔
1536
                minl = syst["lam"] - syst["deltaLam"] / 2
×
1537
                maxl = syst["lam"] + syst["deltaLam"] / 2
×
1538
                if param_name == "core_area":
×
1539
                    outunit = 1 * u.arcsec**2
×
1540
                else:
1541
                    outunit = 1
×
1542
                syst[param_name] = (
×
1543
                    lambda lam, s, Dinterp=Dinterp, minl=minl, maxl=maxl, fill=fill: (
1544
                        (np.array(Dinterp(s.to("arcsec").value), ndmin=1) - fill)
1545
                        * np.array((minl < lam) & (lam < maxl), ndmin=1).astype(int)
1546
                        + fill
1547
                    )
1548
                    * outunit
1549
                )
1550
            else:
1551
                lam0 = syst["lam"]
1✔
1552
                if param_name == "core_area":
1✔
NEW
1553
                    lam0_val = lam0.value
×
NEW
1554
                    lam0_unit = lam0.unit
×
1555

NEW
1556
                    def coro_core_area_float(lam, s):
×
1557
                        # Convert lam to the same unit as lam0, if the units already match
1558
                        # then this is a no-op
NEW
1559
                        lam_val = lam.to_value(lam0_unit)
×
NEW
1560
                        lam_ratio = lam0_val / lam_val
×
1561
                        # Scale the provided separations to the lam0 wavelength
NEW
1562
                        s_scaled_as = s.to_value(u.arcsec) * lam_ratio
×
1563
                        # Interpolate with np.interp and attach units in place
NEW
1564
                        return (
×
1565
                            np.interp(s_scaled_as, WA, D, left=fill, right=fill)
1566
                            << outunit
1567
                        )
1568

NEW
1569
                    syst[param_name] = coro_core_area_float
×
1570
                else:
1571
                    syst[param_name] = self.create_coro_fits_param_func(
1✔
1572
                        WA, D, lam0, fill
1573
                    )
1574
        # now the case where we just got a scalar input
1575
        elif isinstance(syst[param_name], numbers.Number):
1✔
1576
            # ensure paramter is within bounds
1577
            D = float(syst[param_name])
1✔
1578
            if min_val is not None:
1✔
1579
                assert D >= min_val, (
1✔
1580
                    f"{param_name} in {syst['name']} may not "
1581
                    f"have values less than {min_val}."
1582
                )
1583
            if max_val is not None:
1✔
1584
                assert D <= max_val, (
1✔
1585
                    f"{param_name} in {syst['name']} may "
1586
                    f"not have values greater than {min_val}."
1587
                )
1588

1589
            # for core_area only, need to make sure that the units are right
1590
            if param_name == "core_area":
1✔
1591
                angunit = self.get_angle_unit_from_header(None, syst)
1✔
1592
                D = (D * angunit**2).to(u.arcsec**2).value
1✔
1593

1594
            # ensure you have values for IWA/OWA, otherwise use defaults
1595
            syst = self.update_syst_WAs(syst, None, None)
1✔
1596
            IWA = syst["IWA"].to_value(u.arcsec)
1✔
1597
            OWA = syst["OWA"].to_value(u.arcsec)
1✔
1598

1599
            # same as for interpolant: coronagraphs scale with wavelength, occulters
1600
            # don't
1601
            if syst["occulter"]:
1✔
1602
                minl = syst["lam"] - syst["deltaLam"] / 2
1✔
1603
                maxl = syst["lam"] + syst["deltaLam"] / 2
1✔
1604
                if param_name == "core_area":
1✔
1605
                    outunit = 1 * u.arcsec**2
1✔
1606
                else:
1607
                    outunit = 1
1✔
1608

1609
                syst[param_name] = (
1✔
1610
                    lambda lam, s, D=D, IWA=IWA, OWA=OWA, minl=minl, maxl=maxl, fill=fill: (  # noqa: E501
1611
                        (
1612
                            np.array(
1613
                                (IWA <= s.to_value("arcsec"))
1614
                                & (s.to_value("arcsec") <= OWA)
1615
                                & (minl < lam)
1616
                                & (lam < maxl),
1617
                                ndmin=1,
1618
                            ).astype(float)
1619
                            * (D - fill)
1620
                            + fill
1621
                        )
1622
                        * outunit
1623
                    )
1624
                )
1625
            # coronagraph:
1626
            else:
1627
                lam0 = syst["lam"]
1✔
1628
                lam0_val = lam0.value
1✔
1629
                lam0_unit = lam0.unit
1✔
1630
                D_minus_fill = D - fill
1✔
1631

1632
                if param_name == "core_area":
1✔
1633
                    outunit = u.arcsec**2
1✔
1634

1635
                    def coro_core_area_float(lam, s):
1✔
1636
                        # Convert lam to the same unit as lam0, if the units already match
1637
                        # then this is a no-op
1638
                        lam_val = lam.to_value(lam0_unit)
1✔
1639
                        lam_ratio = lam0_val / lam_val
1✔
1640
                        # Scale the provided separations to the lam0 wavelength
1641
                        s_scaled_as = s.to_value(u.arcsec) * lam_ratio
1✔
1642
                        # Create a mask that is True when s is inside the dark zone
1643
                        dz_mask = np.array(
1✔
1644
                            (IWA <= s_scaled_as) & (s_scaled_as <= OWA),
1645
                            ndmin=1,
1646
                            dtype=float,
1647
                        )
1648
                        # Multiply the mask by the core area and scale by the wavelength
1649
                        # ratio squared
1650
                        core_area = dz_mask * D_minus_fill * (1 / lam_ratio) ** 2 + fill
1✔
1651
                        # Attach units in place and return
1652
                        return core_area << outunit
1✔
1653

1654
                    syst[param_name] = coro_core_area_float
1✔
1655
                else:
1656
                    syst[param_name] = self.create_coro_float_param_func(
1✔
1657
                        D, lam0, IWA, OWA, fill
1658
                    )
1659

1660
        # finally the case where the input is None
1661
        elif syst[param_name] is None:
×
1662
            syst[param_name] = None
×
1663
        # anything else (not string, number, or None) throws an error
1664
        else:
1665
            raise TypeError(
×
1666
                f"{param_name} for system {syst['name']} is neither a "
1667
                f"string nor a number. I don't know what to do with that."
1668
            )
1669

1670
        return syst
1✔
1671

1672
    def create_coro_fits_param_func(self, WA, D, lam0, fill):
1✔
1673
        """
1674
        Create a function for a coronagraph parameter that was provided as a fits
1675
        file.
1676

1677
        The returned function minimizes the number of operations and uses closures
1678
        to capture the values of the parameters to avoid recalculating them on
1679
        each call.
1680

1681
        Args:
1682
            WA (astropy.units.Quantity):
1683
                Working angles from the input fits file in arcsec
1684
            D (float):
1685
                Parameter values from the input fits file
1686
            lam0 (astropy.units.Quantity):
1687
                Design wavelength of the coronagraph
1688
            fill (float):
1689
                Fill value for the parameter
1690

1691
        Returns:
1692
            function:
1693
                A function that takes a wavelength and a separation and returns the
1694
                parameter value.
1695

1696
        """
1697
        lam0_val = lam0.value
1✔
1698
        lam0_unit = lam0.unit
1✔
1699

1700
        def func(lam, s):
1✔
1701
            # Convert lam to the same unit as lam0, if the units already match
1702
            # then this is a no-op
NEW
1703
            lam_val = lam.to_value(lam0_unit)
×
NEW
1704
            lam_ratio = lam0_val / lam_val
×
1705
            # Scale the provided separations to the lam0 wavelength
NEW
1706
            s_scaled_as = s.to_value(u.arcsec) * lam_ratio
×
1707
            # Use np.interp to get the parameter value
NEW
1708
            return np.interp(s_scaled_as, WA, D, left=fill, right=fill)
×
1709

1710
        return func
1✔
1711

1712
    def create_coro_float_param_func(self, D, lam0, IWA, OWA, fill):
1✔
1713
        """
1714
        Create a function for a coronagraph parameter that was provided as a float.
1715

1716
        The returned function minimizes the number of operations and uses closures
1717
        to capture the values of the parameters to avoid recalculating them on
1718
        each call.
1719

1720
        Args:
1721
            D (float):
1722
                Value of the parameter from the input file
1723
            lam0 (astropy.units.Quantity):
1724
                Design wavelength of the coronagraph
1725
            IWA (float):
1726
                Inner working angle of the coronagraph in arcsec
1727
            OWA (float):
1728
                Outer working angle of the coronagraph in arcsec
1729
            fill (float):
1730
                Fill value for the parameter
1731

1732
        Returns:
1733
            function:
1734
                A function that takes a wavelength and a separation and returns the
1735
                parameter value.
1736
        """
1737
        lam0_unit = lam0.unit
1✔
1738
        lam0_val = lam0.value
1✔
1739
        D_minus_fill = D - fill
1✔
1740

1741
        def func(lam, s):
1✔
1742
            # Convert lam to the same unit as lam0, if the units already match
1743
            # then this is a no-op
1744
            lam_val = lam.to_value(lam0_unit)
1✔
1745
            lam_ratio = lam0_val / lam_val
1✔
1746
            # Scale the provided separations to the lam0 wavelength
1747
            s_scaled_as = s.to_value(u.arcsec) * lam_ratio
1✔
1748
            # Create a mask that is True when s is inside the dark zone
1749
            dz_mask = np.array(
1✔
1750
                (IWA <= s_scaled_as) & (s_scaled_as <= OWA), ndmin=1, dtype=float
1751
            )
1752
            # Multiply the mask by the parameter value and add the fill value
1753
            return dz_mask * D_minus_fill + fill
1✔
1754

1755
        return func
1✔
1756

1757
    def get_param_data(
1✔
1758
        self,
1759
        ipth,
1760
        left_col_name=None,
1761
        param_name=None,
1762
        expected_ndim=None,
1763
        expected_first_dim=None,
1764
    ):
1765
        """Gets the data from a file, used primarily to create interpolants for
1766
        coronagraph parameters
1767

1768
        Args:
1769
            ipth (str):
1770
                String to file location, will also work with any other path object
1771
            left_col_name (str,optional):
1772
                For CSV files only. String representing the column containing the
1773
                independent parameter to be extracted. This is for use in the case
1774
                where the CSV file contains multiple columns and only two need to be
1775
                returned. Defaults None.
1776
            param_name (str, optional):
1777
                For CSV files only. String representing the column containing the
1778
                dependent parameter to be extracted. This is for use in the case where
1779
                the CSV file contains multiple columns and only two need to be returned.
1780
                Defaults None.
1781
            expected_ndim (int, optional):
1782
                Expected number of dimensions.  Only checked if not None. Defaults None.
1783
            expected_first_dim (int, optional):
1784
                Expected size of first dimension of data.  Only checked if not None.
1785
                Defaults None
1786

1787
        Returns:
1788
            tuple:
1789
                dat (~numpy.ndarray):
1790
                    Data array
1791
                hdr (list or astropy.io.fits.header.Header):
1792
                    Data header.  For CVS files this is a list of column header strings.
1793

1794
        .. note::
1795

1796
            CSV files *must* have a single header row
1797

1798
        """
1799
        # Check that path represents a valid file
1800
        pth = os.path.normpath(os.path.expandvars(ipth))
1✔
1801
        assert os.path.isfile(pth), f"{ipth} is not a valid file."
1✔
1802

1803
        # Check for fits or csv file
1804
        ext = pth.split(".")[-1]
1✔
1805
        assert ext.lower() in ["fits", "csv"], f"{pth} must be a fits or csv file."
1✔
1806
        if ext.lower() == "fits":
1✔
1807
            with fits.open(pth) as f:
1✔
1808
                dat = f[0].data.squeeze()
1✔
1809
                hdr = f[0].header
1✔
1810
        else:
1811
            # Need to get all of the headers and data, then associate them in the same
1812
            # ndarray that the fits files would generate
1813
            table_vals = np.genfromtxt(pth, delimiter=",", skip_header=1)
×
1814
            hdr = np.genfromtxt(
×
1815
                pth, delimiter=",", skip_footer=len(table_vals), dtype=str
1816
            )
1817

1818
            if left_col_name is not None:
×
1819
                assert (
×
1820
                    param_name is not None
1821
                ), "If left_col_name is nont None, param_name cannot be None."
1822

1823
                assert (
×
1824
                    left_col_name in hdr
1825
                ), f"{left_col_name} not found in table header for file {ipth}"
1826
                assert (
×
1827
                    param_name in hdr
1828
                ), f"{param_name} not found in table header for file {ipth}"
1829

1830
                left_column_location = np.where(hdr == left_col_name)[0][0]
×
1831
                param_location = np.where(hdr == param_name)[0][0]
×
1832
                dat = np.vstack(
×
1833
                    [table_vals[:, left_column_location], table_vals[:, param_location]]
1834
                ).T
1835
                hdr = [left_col_name, param_name]
×
1836
            else:
1837
                dat = table_vals
×
1838

1839
        if expected_ndim is not None:
1✔
1840
            assert len(dat.shape) == expected_ndim, (
1✔
1841
                f"Data shape did not match expected {expected_ndim} "
1842
                f"dimensions for file {ipth}"
1843
            )
1844

1845
        if expected_first_dim is not None:
1✔
1846
            assert expected_first_dim in dat.shape, (
1✔
1847
                f"Expected first dimension size {expected_first_dim} not found in any "
1848
                f"data dimension for file {ipth}."
1849
            )
1850

1851
            if dat.shape[0] != expected_first_dim:
1✔
1852
                assert len(dat.shape) == 2, (
1✔
1853
                    f"Data in file {ipth} contains a dimension of expected size "
1854
                    f"{expected_first_dim}, but it is not the first dimension, and the "
1855
                    "data has dimensionality of > 2, so I do not know how to reshape "
1856
                    "it."
1857
                )
1858

1859
                dat = dat.transpose()
1✔
1860

1861
        return dat, hdr
1✔
1862

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

1867
        Args:
1868
            TL (:ref:`TargetList`):
1869
                TargetList class object
1870
            sInds (~numpy.ndarray(int)):
1871
                Integer indices of the stars of interest
1872
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
1873
                Surface brightness of local zodiacal light in units of 1/arcsec2
1874
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
1875
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
1876
            dMag (~numpy.ndarray(float)):
1877
                Differences in magnitude between planets and their host star
1878
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
1879
                Working angles of the planets of interest in units of arcsec
1880
            mode (dict):
1881
                Selected observing mode
1882
            returnExtra (bool):
1883
                Optional flag, default False, set True to return additional rates for
1884
                validation
1885
            TK (:ref:`TimeKeeping`, optional):
1886
                Optional TimeKeeping object (default None), used to model detector
1887
                degradation effects where applicable.
1888

1889

1890
        Returns:
1891
            tuple:
1892
                C_p (~astropy.units.Quantity(~numpy.ndarray(float))):
1893
                    Planet signal electron count rate in units of 1/s
1894
                C_b (~astropy.units.Quantity(~numpy.ndarray(float))):
1895
                    Background noise electron count rate in units of 1/s
1896
                C_sp (~astropy.units.Quantity(~numpy.ndarray(float))):
1897
                    Residual speckle spatial structure (systematic error)
1898
                    in units of 1/s
1899

1900
        """
1901

1902
        # grab all count rates
1903
        C_star, C_p, C_sr, C_z, C_ez, C_dc, C_bl, Npix = self.Cp_Cb_Csp_helper(
1✔
1904
            TL, sInds, fZ, JEZ, dMag, WA, mode
1905
        )
1906

1907
        # readout noise
1908
        inst = mode["inst"]
1✔
1909
        C_rn = Npix * inst["sread"] / inst["texp"]
1✔
1910

1911
        # background signal rate
1912
        C_b = C_sr + C_z + C_ez + C_bl + C_dc + C_rn
1✔
1913

1914
        # for characterization, Cb must include the planet
1915
        # C_sp = spatial structure to the speckle including post-processing contrast
1916
        # factor and stability factor
1917
        if not (mode["detectionMode"]):
1✔
1918
            C_b = C_b + C_p
1✔
1919
            C_sp = C_sr * TL.PostProcessing.ppFact_char(WA) * self.stabilityFact
1✔
1920
        else:
1921
            C_sp = C_sr * TL.PostProcessing.ppFact(WA) * self.stabilityFact
1✔
1922

1923
        if returnExtra:
1✔
1924
            # organize components into an optional fourth result
1925
            C_extra = dict(
×
1926
                C_sr=C_sr.to("1/s"),
1927
                C_z=C_z.to("1/s"),
1928
                C_ez=C_ez.to("1/s"),
1929
                C_dc=C_dc.to("1/s"),
1930
                C_rn=C_rn.to("1/s"),
1931
                C_star=C_star.to("1/s"),
1932
                C_bl=C_bl.to("1/s"),
1933
                Npix=Npix,
1934
            )
1935
            return C_p.to("1/s"), C_b.to("1/s"), C_sp.to("1/s"), C_extra
×
1936
        else:
1937
            return C_p.to("1/s"), C_b.to("1/s"), C_sp.to("1/s")
1✔
1938

1939
    def Cp_Cb_Csp_helper(self, TL, sInds, fZ, JEZ, dMag, WA, mode):
1✔
1940
        """Helper method for Cp_Cb_Csp that performs lots of common computations
1941
        Args:
1942
            TL (:ref:`TargetList`):
1943
                TargetList class object
1944
            sInds (~numpy.ndarray(int)):
1945
                Integer indices of the stars of interest
1946
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
1947
                Surface brightness of local zodiacal light in units of 1/arcsec2
1948
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
1949
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
1950
            dMag (~numpy.ndarray(float)):
1951
                Differences in magnitude between planets and their host star
1952
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
1953
                Working angles of the planets of interest in units of arcsec
1954
            mode (dict):
1955
                Selected observing mode
1956

1957
        Returns:
1958
            tuple:
1959
                C_star (~astropy.units.Quantity(~numpy.ndarray(float))):
1960
                    Non-coronagraphic star count rate (1/s)
1961
                C_p0 (~astropy.units.Quantity(~numpy.ndarray(float))):
1962
                    Planet count rate (1/s)
1963
                C_sr (~astropy.units.Quantity(~numpy.ndarray(float))):
1964
                    Starlight residual count rate (1/s)
1965
                C_z (~astropy.units.Quantity(~numpy.ndarray(float))):
1966
                    Local zodi count rate (1/s)
1967
                C_ez (~astropy.units.Quantity(~numpy.ndarray(float))):
1968
                    Exozodi count rate (1/s)
1969
                C_dc (~astropy.units.Quantity(~numpy.ndarray(float))):
1970
                    Dark current count rate (1/s)
1971
                C_bl (~astropy.units.Quantity(~numpy.ndarray(float))):
1972
                    Background leak count rate (1/s)'
1973
                Npix (float):
1974
                    Number of pixels in photometric aperture
1975
        """
1976

1977
        # SET UP CONVERSION FACTORS FOR UNITS OF WA fZ and JEZ
1978
        # SOMETHING LIKE A DICTIONARY KEYED ON THOSE UNITS WITH ENTRIES FOR EACH CALCULATION
1979
        cache_conversions = (fZ.unit, JEZ.unit) not in self.unit_conv
1✔
1980
        convs_added = False
1✔
1981
        if cache_conversions:
1✔
1982
            convs = {}
1✔
1983
        else:
1984
            convs = self.unit_conv[(fZ.unit, JEZ.unit)]
1✔
1985

1986
        # get scienceInstrument and starlightSuppressionSystem and wavelength
1987
        inst = mode["inst"]
1✔
1988
        syst = mode["syst"]
1✔
1989
        lam = mode["lam"]
1✔
1990
        _lam = lam.to_value(u.nm)
1✔
1991
        _syst_lam = syst["lam"].to_value(u.nm)
1✔
1992

1993
        # coronagraph parameters
1994
        occ_trans = syst["occ_trans"](lam, WA)
1✔
1995
        core_thruput = syst["core_thruput"](lam, WA)
1✔
1996
        Omega = syst["core_area"](lam, WA)
1✔
1997

1998
        # number of pixels per lenslet
1999
        pixPerLens = inst["lenslSamp"] ** 2.0
1✔
2000

2001
        # number of detector pixels in the photometric aperture = Omega / theta^2
2002
        # Npix = pixPerLens * (Omega / inst["pixelScale"] ** 2.0).decompose().value
2003
        if cache_conversions or convs.get("Npix") is None:
1✔
2004
            Npix = pixPerLens * (Omega / inst["pixelScale"] ** 2.0)
1✔
2005
            if Npix[0].value != 0:
1✔
2006
                convs["Npix"] = (
1✔
2007
                    Npix[0].to_value(u.dimensionless_unscaled) / Npix[0].value
2008
                )
2009
                Npix = Npix.value * convs["Npix"]
1✔
2010
                convs_added = True
1✔
2011
        else:
2012
            Npix = (
1✔
2013
                pixPerLens
2014
                * (Omega.value / inst["pixelScale"].value ** 2.0)
2015
                * convs["Npix"]
2016
            )
2017

2018
        # get stellar residual intensity in the planet PSF core
2019
        # if core_mean_intensity is None, fall back to using core_contrast
2020
        if syst["core_mean_intensity"] is None:
1✔
2021
            core_contrast = syst["core_contrast"](lam, WA)
1✔
2022
            core_intensity = core_contrast * core_thruput
1✔
2023
        else:
2024
            # if we're here, we're using the core mean intensity
2025
            core_mean_intensity = syst["core_mean_intensity"](
1✔
2026
                lam, WA, TL.diameter[sInds]
2027
            )
2028
            # also, if we're here, we must have a platescale defined
2029
            # furthermore, if we're a coronagraph, we have to scale by wavelength
2030
            scale_factor = _lam / _syst_lam if not (syst["occulter"]) else 1
1✔
2031
            core_platescale = syst["core_platescale"] * scale_factor
1✔
2032

2033
            # core_intensity is the mean intensity times the number of map pixels
2034
            if cache_conversions or convs.get("core_intensity") is None:
1✔
2035
                core_intensity = core_mean_intensity * Omega / core_platescale**2
1✔
2036
                if core_intensity[0].value != 0:
1✔
2037
                    convs["core_intensity"] = (
1✔
2038
                        core_intensity[0].to_value(u.dimensionless_unscaled)
2039
                        / core_intensity[0].value
2040
                    )
2041
                    convs_added = True
1✔
2042
            else:
2043
                core_intensity = (
1✔
2044
                    core_mean_intensity
2045
                    * Omega.value
2046
                    / core_platescale.value**2
2047
                    * convs["core_intensity"]
2048
                )
2049

2050
            # finally, if a contrast floor was set, make sure we're not violating it
2051
            if syst["contrast_floor"] is not None:
1✔
2052
                below_contrast_floor = (
×
2053
                    core_intensity / core_thruput < syst["contrast_floor"]
2054
                )
2055
                core_intensity[below_contrast_floor] = (
×
2056
                    syst["contrast_floor"] * core_thruput[below_contrast_floor]
2057
                )
2058

2059
        # cast sInds to array
2060
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
2061

2062
        # Star fluxes (ph/m^2/s)
2063
        flux_star = TL.starFlux(sInds, mode)
1✔
2064

2065
        # ELECTRON COUNT RATES [ s^-1 ]
2066
        # non-coronagraphic star counts
2067
        if cache_conversions or convs.get("C_star") is None:
1✔
2068
            C_star = flux_star * mode["losses"]
1✔
2069
            if C_star[0].value != 0:
1✔
2070
                convs["C_star"] = C_star[0].to_value(self.inv_s) / C_star[0].value
1✔
2071
                C_star = C_star.value * convs["C_star"] << self.inv_s
1✔
2072
                convs_added = True
1✔
2073
        else:
2074
            C_star = (
1✔
2075
                flux_star.value * mode["losses"].value * convs["C_star"] << self.inv_s
2076
            )
2077
        _C_star = C_star.to_value(self.inv_s)
1✔
2078
        # planet counts:
2079
        # C_p0 = (C_star * 10.0 ** (-0.4 * dMag) * core_thruput).to("1/s")
2080
        if cache_conversions or convs.get("C_p0") is None:
1✔
2081
            C_p0 = C_star * 10.0 ** (-0.4 * dMag) * core_thruput
1✔
2082
            if C_p0[0].value != 0:
1✔
2083
                convs["C_p0"] = C_p0[0].to_value(self.inv_s) / C_p0[0].value
1✔
2084
                C_p0 = C_p0.value * convs["C_p0"] << self.inv_s
1✔
2085
                convs_added = True
1✔
2086
        else:
2087
            C_p0 = (
1✔
2088
                _C_star * 10.0 ** (-0.4 * dMag) * core_thruput * convs["C_p0"]
2089
                << self.inv_s
2090
            )
2091
        # starlight residual
2092
        # C_sr = (C_star * core_intensity).to("1/s")
2093
        if cache_conversions or convs.get("C_sr") is None:
1✔
2094
            C_sr = C_star * core_intensity
1✔
2095
            if C_sr[0].value != 0:
1✔
2096
                convs["C_sr"] = C_sr[0].to_value(self.inv_s) / C_sr[0].value
1✔
2097
                C_sr = C_sr.value * convs["C_sr"] << self.inv_s
1✔
2098
                convs_added = True
1✔
2099
        else:
2100
            C_sr = _C_star * core_intensity * convs["C_sr"] << self.inv_s
1✔
2101
        # zodiacal light
2102
        # C_z = (mode["F0"] * mode["losses"] * fZ * Omega * occ_trans).to("1/s")
2103
        if cache_conversions or convs.get("C_z") is None:
1✔
2104
            C_z = mode["F0"] * mode["losses"] * fZ * Omega * occ_trans
1✔
2105
            if C_z[0].value != 0:
1✔
2106
                convs["C_z"] = C_z[0].to_value(self.inv_s) / C_z[0].value
1✔
2107
                C_z = C_z.value * convs["C_z"] << self.inv_s
1✔
2108
                convs_added = True
1✔
2109
        else:
2110
            C_z = (
1✔
2111
                mode["F0"].value
2112
                * mode["losses"].value
2113
                * fZ.value
2114
                * Omega.value
2115
                * occ_trans
2116
                * convs["C_z"]
2117
                << self.inv_s
2118
            )
2119
        # exozodiacal light
2120
        if cache_conversions or convs.get("C_ez") is None:
1✔
2121
            if self.use_core_thruput_for_ez:
1✔
2122
                # C_ez = (JEZ * mode["losses"] * Omega * core_thruput).to("1/s")
NEW
2123
                C_ez = JEZ * mode["losses"] * Omega * core_thruput
×
2124
            else:
2125
                # C_ez = (JEZ * mode["losses"] * Omega * occ_trans).to("1/s")
2126
                C_ez = JEZ * mode["losses"] * Omega * occ_trans
1✔
2127
            if C_ez[0].value != 0:
1✔
2128
                convs["C_ez"] = C_ez[0].to_value(self.inv_s) / C_ez[0].value
1✔
2129
                C_ez = C_ez.value * convs["C_ez"] << self.inv_s
1✔
2130
                convs_added = True
1✔
2131
        else:
2132
            if self.use_core_thruput_for_ez:
1✔
NEW
2133
                C_ez = (
×
2134
                    JEZ.value
2135
                    * mode["losses"].value
2136
                    * Omega.value
2137
                    * core_thruput
2138
                    * convs["C_ez"]
2139
                    << self.inv_s
2140
                )
2141
            else:
2142
                C_ez = (
1✔
2143
                    JEZ.value
2144
                    * mode["losses"].value
2145
                    * Omega.value
2146
                    * occ_trans
2147
                    * convs["C_ez"]
2148
                    << self.inv_s
2149
                )
2150
        # dark current
2151
        C_dc = Npix * inst["idark"]
1✔
2152
        # only calculate binary leak if you have a model and relevant data
2153
        # in the targelist
2154
        if hasattr(self, "binaryleakmodel") and all(
1✔
2155
            hasattr(TL, attr)
2156
            for attr in ["closesep", "closedm", "brightsep", "brightdm"]
2157
        ):
2158
            cseps = TL.closesep[sInds]
×
2159
            cdms = TL.closedm[sInds]
×
2160
            bseps = TL.brightsep[sInds]
×
2161
            bdms = TL.brightdm[sInds]
×
2162

NEW
2163
            if cache_conversions:
×
NEW
2164
                convs["seps"] = self.arcsec2rad
×
NEW
2165
                convs["diam/lam"] = (1 * self.pupilDiam.unit / lam.unit).to_value(
×
2166
                    u.dimensionless_unscaled
2167
                )
2168
            # don't double count where the bright star is the close star
2169
            repinds = (cseps == bseps) & (cdms == bdms)
×
2170
            bseps[repinds] = np.nan
×
2171
            bdms[repinds] = np.nan
×
2172

2173
            crawleaks = self.binaryleakmodel(
×
2174
                (
2175
                    (cseps * convs["seps"])
2176
                    / lam.value
2177
                    * self.pupilDiam.value
2178
                    * convs["diam/lam"]
2179
                )
2180
            )
2181
            cleaks = crawleaks * 10 ** (-0.4 * cdms)
×
2182
            cleaks[np.isnan(cleaks)] = 0
×
2183

2184
            brawleaks = self.binaryleakmodel(
×
2185
                (
2186
                    (bseps * convs["seps"])
2187
                    / lam.value
2188
                    * self.pupilDiam.value
2189
                    * convs["diam/lam"]
2190
                )
2191
            )
2192
            bleaks = brawleaks * 10 ** (-0.4 * bdms)
×
2193
            bleaks[np.isnan(bleaks)] = 0
×
2194

NEW
2195
            C_bl = (cleaks + bleaks) * _C_star * core_thruput << self.inv_s
×
2196
        else:
2197
            C_bl = np.zeros(len(sInds)) << self.inv_s
1✔
2198

2199
        if cache_conversions or convs_added:
1✔
2200
            self.unit_conv[(fZ.unit, JEZ.unit)] = convs
1✔
2201
        return C_star, C_p0, C_sr, C_z, C_ez, C_dc, C_bl, Npix
1✔
2202

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

2207

2208
        Args:
2209
            TL (:ref:`TargetList`):
2210
                TargetList class object
2211
            sInds (numpy.ndarray(int)):
2212
                Integer indices of the stars of interest
2213
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2214
                Surface brightness of local zodiacal light in units of 1/arcsec2
2215
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2216
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
2217
            dMag (numpy.ndarray(int)numpy.ndarray(float)):
2218
                Differences in magnitude between planets and their host star
2219
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
2220
                Working angles of the planets of interest in units of arcsec
2221
            mode (dict):
2222
                Selected observing mode
2223
            TK (:ref:`TimeKeeping`, optional):
2224
                Optional TimeKeeping object (default None), used to model detector
2225
                degradation effects where applicable.
2226

2227
        Returns:
2228
            ~astropy.units.Quantity(~numpy.ndarray(float)):
2229
                Integration times
2230

2231
        .. note::
2232

2233
            All infeasible integration times are returned as NaN values
2234

2235
        """
2236
        # count rates
2237
        C_p, C_b, C_sp = self.Cp_Cb_Csp(TL, sInds, fZ, JEZ, dMag, WA, mode, TK=TK)
1✔
2238

2239
        # get SNR threshold
2240
        SNR = mode["SNR"]
1✔
2241

2242
        with np.errstate(divide="ignore", invalid="ignore"):
1✔
2243
            intTime = np.true_divide(
1✔
2244
                SNR**2.0 * C_b, (C_p**2.0 - (SNR * C_sp) ** 2.0)
2245
            ).to("day")
2246

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

2250
        return intTime
1✔
2251

2252
    def calc_dMag_per_intTime(
1✔
2253
        self,
2254
        intTimes,
2255
        TL,
2256
        sInds,
2257
        fZ,
2258
        JEZ,
2259
        WA,
2260
        mode,
2261
        C_b=None,
2262
        C_sp=None,
2263
        TK=None,
2264
        analytic_only=False,
2265
    ):
2266
        """Finds achievable planet delta magnitude for one integration
2267
        time per star in the input list at one working angle.
2268

2269
        Args:
2270
            intTimes (~astropy.units.Quantity(~numpy.ndarray(float))):
2271
                Integration times in units of day
2272
            TL (:ref:`TargetList`):
2273
                TargetList class object
2274
            sInds (numpy.ndarray(int)):
2275
                Integer indices of the stars of interest
2276
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2277
                Surface brightness of local zodiacal light in units of 1/arcsec2
2278
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2279
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
2280
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
2281
                Working angles of the planets of interest in units of arcsec
2282
            mode (dict):
2283
                Selected observing mode
2284
            C_b (~astropy.units.Quantity(~numpy.ndarray(float))):
2285
                Background noise electron count rate in units of 1/s (optional)
2286
            C_sp (~astropy.units.Quantity(~numpy.ndarray(float))):
2287
                Residual speckle spatial structure (systematic error) in units of 1/s
2288
                (optional)
2289
            TK (:ref:`TimeKeeping`, optional):
2290
                Optional TimeKeeping object (default None), used to model detector
2291
                degradation effects where applicable.
2292
            analytic_only (bool):
2293
                If True, return the analytic solution for dMag. Not used by the
2294
                Prototype OpticalSystem.
2295

2296
        Returns:
2297
            numpy.ndarray(float):
2298
                Achievable dMag for given integration time and working angle
2299

2300
        .. warning::
2301

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

2308
        """
2309

2310
        # cast sInds to array
2311
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
2312

2313
        if (C_b is None) or (C_sp is None):
1✔
2314
            _, C_b, C_sp = self.Cp_Cb_Csp(
1✔
2315
                TL, sInds, fZ, JEZ, np.zeros(len(sInds)), WA, mode, TK=TK
2316
            )
2317

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

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

2324
        return dMag.value
1✔
2325

2326
    def ddMag_dt(
1✔
2327
        self, intTimes, TL, sInds, fZ, JEZ, WA, mode, C_b=None, C_sp=None, TK=None
2328
    ):
2329
        """Finds derivative of achievable dMag with respect to integration time.
2330

2331
        Args:
2332
            intTimes (~astropy.units.Quantity(~numpy.ndarray(float))):
2333
                Integration times in units of day
2334
            TL (:ref:`TargetList`):
2335
                TargetList class object
2336
            sInds (numpy.ndarray(int)):
2337
                Integer indices of the stars of interest
2338
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2339
                Surface brightness of local zodiacal light in units of 1/arcsec2
2340
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2341
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
2342
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
2343
                Working angles of the planets of interest in units of arcsec
2344
            mode (dict):
2345
                Selected observing mode
2346
            C_b (~astropy.units.Quantity(~numpy.ndarray(float))):
2347
                Background noise electron count rate in units of 1/s (optional)
2348
            C_sp (~astropy.units.Quantity(~numpy.ndarray(float))):
2349
                Residual speckle spatial structure (systematic error) in units of 1/s
2350
                (optional)
2351
            TK (:ref:`TimeKeeping`, optional):
2352
                Optional TimeKeeping object (default None), used to model detector
2353
                degradation effects where applicable.
2354

2355
        Returns:
2356
            ~astropy.units.Quantity(~numpy.ndarray(float)):
2357
                Derivative of achievable dMag with respect to integration time
2358
                in units of 1/s
2359

2360
        """
2361
        # cast sInds to array
2362
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
2363

2364
        if (C_b is None) or (C_sp is None):
1✔
2365
            _, C_b, C_sp = self.Cp_Cb_Csp(
1✔
2366
                TL, sInds, fZ, JEZ, np.zeros(len(sInds)), WA, mode, TK=TK
2367
            )
2368

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

2371
        return ddMagdt.to("1/s")
1✔
2372

2373
    def calc_saturation_dMag(self, TL, sInds, fZ, JEZ, WA, mode, TK=None):
1✔
2374
        """
2375
        This calculates the delta magnitude for each target star that
2376
        corresponds to an infinite integration time.
2377

2378
        Args:
2379
            TL (:ref:`TargetList`):
2380
                TargetList class object
2381
            sInds (numpy.ndarray(int)):
2382
                Integer indices of the stars of interest
2383
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2384
                Surface brightness of local zodiacal light in units of 1/arcsec2
2385
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2386
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
2387
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
2388
                Working angles of the planets of interest in units of arcsec
2389
            mode (dict):
2390
                Selected observing mode
2391
            TK (:ref:`TimeKeeping`, optional):
2392
                Optional TimeKeeping object (default None), used to model detector
2393
                degradation effects where applicable.
2394

2395
        Returns:
2396
            ~numpy.ndarray(float):
2397
                Saturation (maximum achievable) dMag for each target star
2398
        """
2399

2400
        _, C_b, C_sp = self.Cp_Cb_Csp(
1✔
2401
            TL, sInds, fZ, JEZ, np.zeros(len(sInds)), WA, mode, TK=TK
2402
        )
2403

2404
        flux_star = TL.starFlux(sInds, mode)
1✔
2405
        core_thruput = mode["syst"]["core_thruput"](mode["lam"], WA)
1✔
2406

2407
        dMagmax = -2.5 * np.log10(
1✔
2408
            mode["SNR"] * C_sp / (flux_star * mode["losses"] * core_thruput)
2409
        )
2410

2411
        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