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

dsavransky / EXOSIMS / 15449914539

04 Jun 2025 06:29PM UTC coverage: 65.705% (-0.09%) from 65.79%
15449914539

push

github

web-flow
Merge pull request #420 from dsavransky/csvloading

* Adding optional JSON input in starlight suppression system dict: `csv_names`. This is a dictionary mapping the CSV columns to the EXOSIMS name.
* Adding option to set up nearest-neighbor instead of linear interpolants. Adding fallback to load non utf-8 fromatted CSV files when initial loading fails.
* Adding `distFill` option to prototype StarCatalog to allow control of distances to fake stars that are auto-generated when using this catalog.
* Deprecating Nemati_2019 (but leaving in codebase for now).

18 of 30 new or added lines in 6 files covered. (60.0%)

20 existing lines in 5 files now uncovered.

9683 of 14737 relevant lines covered (65.71%)

0.66 hits per line

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

76.29
/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(
1✔
799
                    syst, param_name="core_mean_intensity"
800
                )
801

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

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

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

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

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

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

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

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

900
        # call additional setup
901
        self.populate_starlightSuppressionSystems_extra()
1✔
902

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

909
        pass
1✔
910

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

915
        Args:
916
            observingModes (list):
917
                List of observingMode dicts.
918

919
        """
920

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

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

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

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

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

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

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

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

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

1032
            # Evaluate the V-band zero magnitude flux density
1033

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

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

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

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

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

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

1083
        self.populate_observingModes_extra()
1✔
1084

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

1090
        pass
1✔
1091

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

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

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

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

1119
            mode["hex"] = genHexStr(modestr)
1✔
1120
            mode["index"] = nmode
1✔
1121

1122
    def get_core_mean_intensity(self, syst, param_name="core_mean_intensity"):
1✔
1123
        """Load and process core_mean_intensity data
1124

1125
        Args:
1126
            syst (dict):
1127
                Dictionary containing the parameters of one starlight suppression system
1128
            param_name (str):
1129
                Keyword name. Defaults to core_mean_intensity
1130

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

1135
        """
1136

1137
        fill = 1.0
1✔
1138
        assert param_name in syst, f"{param_name} not found in system {syst['name']}."
1✔
1139

1140
        if isinstance(syst[param_name], str):
1✔
NEW
1141
            if ("csv_names" in syst) and (param_name in syst["csv_names"]):
×
NEW
1142
                fparam_name = syst["csv_names"][param_name]
×
1143
            else:
NEW
1144
                fparam_name = param_name
×
UNCOV
1145
            dat, hdr = self.get_param_data(
×
1146
                syst[param_name],
1147
                left_col_name=syst["csv_angsep_colname"],
1148
                param_name=fparam_name,
1149
                expected_ndim=2,
1150
            )
1151
            dat = dat.transpose()  # flip such that data is in rows
×
1152
            WA, D = dat[0].astype(float), dat[1:].astype(float)
×
1153

1154
            # check values as needed
1155
            assert np.all(
×
1156
                D > 0
1157
            ), f"{param_name} in {syst['name']} must be >0 everywhere."
1158

1159
            # get angle unit scale WA
1160
            angunit = self.get_angle_unit_from_header(hdr, syst)
×
1161
            WA = (WA * angunit).to(u.arcsec).value
×
1162

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

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

1191
            # handle case where only one data row is present
1192
            if D.shape[0] == 1:
×
1193
                D = np.squeeze(D)
×
1194

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

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

1245
                # determine units and convert as needed
1246
                diams = (diams * angunit).to(u.arcsec).value
×
1247

1248
                Dinterp = scipy.interpolate.RegularGridInterpolator(
×
1249
                    (WA, diams), D.transpose(), bounds_error=False, fill_value=1.0
1250
                )
1251

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

1275
                    def core_mean_intens_fits_multi(lam, s, d):
×
1276
                        lam_val = lam.to_value(lam0_unit)
×
1277
                        lam_ratio = lam0_val / lam_val
×
1278
                        # Scale the working angle by the wavelength ratio
1279
                        s_scaled_as = s.to_value(u.arcsec) * lam_ratio
×
1280
                        # Scale the stellar diameter by the wavelength ratio
1281
                        d_scaled_as = d.to_value(u.arcsec) * lam_ratio
×
1282
                        return np.array(Dinterp((s_scaled_as, d_scaled_as)), ndmin=1)
×
1283

1284
                    syst[param_name] = core_mean_intens_fits_multi
×
1285

1286
            # update IWA/OWA in system as needed
1287
            syst = self.update_syst_WAs(syst, WA, param_name)
×
1288

1289
        elif isinstance(syst[param_name], numbers.Number):
1✔
1290
            # ensure paramter is within bounds
1291
            D = float(syst[param_name])
1✔
1292
            assert D > 0, f"{param_name} in {syst['name']} must be > 0."
1✔
1293

1294
            # ensure you have values for IWA/OWA, otherwise use defaults
1295
            syst = self.update_syst_WAs(syst, None, None)
1✔
1296
            IWA = syst["IWA"].to(u.arcsec).value
1✔
1297
            OWA = syst["OWA"].to(u.arcsec).value
1✔
1298

1299
            # same as for interpolant: coronagraphs scale with wavelength, occulters
1300
            # don't
1301
            if syst["occulter"]:
1✔
1302
                minl = syst["lam"] - syst["deltaLam"] / 2
×
1303
                maxl = syst["lam"] + syst["deltaLam"] / 2
×
1304

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

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

1341
        return syst
1✔
1342

1343
    def get_angle_unit_from_header(self, hdr, syst):
1✔
1344
        """Helper method. Extract angle unit from header, if it exists.
1345

1346
        Args:
1347
            hdr (astropy.io.fits.header.Header or list):
1348
                FITS header for data or header row from CSV
1349
            syst (dict):
1350
                Dictionary containing the parameters of one starlight suppression system
1351

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

1380
        # final consistency check before returning
1381
        assert (
1✔
1382
            angunit.unit.physical_type == "angle"
1383
        ), f"Angle unit for system {syst['name']} is not an angle."
1384

1385
        return angunit
1✔
1386

1387
    def update_syst_WAs(self, syst, WA0, param_name):
1✔
1388
        """Helper method. Check system IWA/OWA and update from table
1389
        data, as needed. Alternatively, set from defaults.
1390

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

1400
        Returns:
1401
            dict:
1402
                Updated dictionary of starlight suppression system parameters
1403

1404
        """
1405

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

1413
            if "OWA" not in syst:
1✔
1414
                syst["OWA"] = (
1✔
1415
                    float(self.default_vals["OWA"]) * syst["input_angle_unit_value"]
1416
                ).to(u.arcsec)
1417

1418
            return syst
1✔
1419

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

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

1443
        return syst
1✔
1444

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

1462
        Args:
1463
            syst (dict):
1464
                Dictionary containing the parameters of one starlight suppression system
1465
            param_name (str):
1466
                Name of the parameter that must be loaded
1467
            fill (float):
1468
                Fill value for working angles outside of the input array definition
1469
            expected_ndim (int, optional):
1470
                Expected number of dimensions.  Only checked if not None. Defaults None.
1471
            expected_first_dim (int, optional):
1472
                Expected size of first dimension of data.  Only checked if not None.
1473
                Defaults None
1474
            min_val (float, optional):
1475
                Minimum allowed value of parameter. Defaults to None (no check).
1476
            max_val (float, optional):
1477
                Maximum allowed value of paramter. Defaults to None (no check).
1478
            interp_kind (str):
1479
                Type of interpolant to use.  See documentation for
1480
                :py:meth:`~scipy.interpolate.interp1d`. Defaults to linear.
1481
            update_WAs (bool):
1482
                If True, update IWA/OWA based on extent of table data.  Defaults False
1483
                If using nearest-neighbor interpolation for a parameter, this value
1484
                should probably be set to False.
1485

1486

1487
        Returns:
1488
            dict:
1489
                Updated dictionary of starlight suppression system parameters
1490

1491
        .. note::
1492

1493
            The created lambda function handles the specified wavelength by
1494
            rescaling the specified working angle by a factor syst['lam']/mode['lam']
1495

1496
        .. note::
1497

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

1501
        """
1502

1503
        assert param_name in syst, f"{param_name} not found in system {syst['name']}."
1✔
1504
        if isinstance(syst[param_name], str):
1✔
1505
            if ("csv_names" in syst) and (param_name in syst["csv_names"]):
1✔
NEW
1506
                fparam_name = syst["csv_names"][param_name]
×
1507
            else:
1508
                fparam_name = param_name
1✔
1509
            dat, hdr = self.get_param_data(
1✔
1510
                syst[param_name],
1511
                left_col_name=syst["csv_angsep_colname"],
1512
                param_name=fparam_name,
1513
                expected_ndim=expected_ndim,
1514
                expected_first_dim=expected_first_dim,
1515
            )
1516
            WA, D = dat[0].astype(float), dat[1].astype(float)
1✔
1517

1518
            # check values as needed
1519
            if min_val is not None:
1✔
1520
                assert np.all(D >= min_val), (
1✔
1521
                    f"{param_name} in {syst['name']} may not "
1522
                    f"have values less than {min_val}."
1523
                )
1524
            if max_val is not None:
1✔
1525
                assert np.all(D <= max_val), (
1✔
1526
                    f"{param_name} in {syst['name']} may "
1527
                    f"not have values greater than {max_val}."
1528
                )
1529

1530
            # check for units
1531
            angunit = self.get_angle_unit_from_header(hdr, syst)
1✔
1532
            WA = (WA * angunit).to_value(u.arcsec)
1✔
1533

1534
            # for core_area only, also need to scale the data
1535
            if param_name == "core_area":
1✔
1536
                D = (D * angunit**2).to_value(u.arcsec**2)
×
1537
                outunit = u.arcsec**2
×
1538

1539
            # update IWA/OWA as needed
1540
            if update_WAs:
1✔
1541
                syst = self.update_syst_WAs(syst, WA, param_name)
1✔
1542

1543
            # table interpolate function
1544
            Dinterp = scipy.interpolate.interp1d(
1✔
1545
                WA,
1546
                D,
1547
                kind=interp_kind,
1548
                fill_value=fill,
1549
                bounds_error=False,
1550
            )
1551
            # create a callable lambda function. for coronagraphs, we need to scale the
1552
            # angular separation by wavelength, but for occulters we just need to
1553
            # ensure that we're within the wavelength range. for core_area, we also
1554
            # need to scale the output by wavelengh^2.
1555
            if syst["occulter"]:
1✔
1556
                minl = syst["lam"] - syst["deltaLam"] / 2
×
1557
                maxl = syst["lam"] + syst["deltaLam"] / 2
×
1558
                if param_name == "core_area":
×
1559
                    outunit = 1 * u.arcsec**2
×
1560
                else:
1561
                    outunit = 1
×
1562
                syst[param_name] = (
×
1563
                    lambda lam, s, Dinterp=Dinterp, minl=minl, maxl=maxl, fill=fill: (
1564
                        (np.array(Dinterp(s.to("arcsec").value), ndmin=1) - fill)
1565
                        * np.array((minl < lam) & (lam < maxl), ndmin=1).astype(int)
1566
                        + fill
1567
                    )
1568
                    * outunit
1569
                )
1570
            else:
1571
                lam0 = syst["lam"]
1✔
1572
                if param_name == "core_area":
1✔
1573
                    lam0_val = lam0.value
×
1574
                    lam0_unit = lam0.unit
×
1575

1576
                    def coro_core_area_float(lam, s):
×
1577
                        # Convert lam to the same unit as lam0, if the units already
1578
                        # match then this is a no-op
1579
                        lam_val = lam.to_value(lam0_unit)
×
1580
                        lam_ratio = lam0_val / lam_val
×
1581
                        # Scale the provided separations to the lam0 wavelength
1582
                        s_scaled_as = s.to_value(u.arcsec) * lam_ratio
×
1583
                        # Interpolate with np.interp and attach units in place
1584
                        return (
×
1585
                            np.interp(s_scaled_as, WA, D, left=fill, right=fill)
1586
                            << outunit
1587
                        )
1588

1589
                    syst[param_name] = coro_core_area_float
×
1590
                else:
1591
                    # if all we need is a linear interpolant, allow the numpy
1592
                    # interpolant to be built. For any other kind, use the original
1593
                    # scipy interpolant
1594
                    if interp_kind == "linear":
1✔
1595
                        syst[param_name] = self.create_coro_fits_param_func(
1✔
1596
                            WA, D, lam0, fill
1597
                        )
1598
                    else:
NEW
1599
                        syst[param_name] = lambda lam, s, Dinterp=Dinterp, lam0=syst[
×
1600
                            "lam"
1601
                        ]: np.array(
1602
                            Dinterp((s * lam0 / lam).to_value("arcsec")), ndmin=1
1603
                        )
1604

1605
        # now the case where we just got a scalar input
1606
        elif isinstance(syst[param_name], numbers.Number):
1✔
1607
            # ensure paramter is within bounds
1608
            D = float(syst[param_name])
1✔
1609
            if min_val is not None:
1✔
1610
                assert D >= min_val, (
1✔
1611
                    f"{param_name} in {syst['name']} may not "
1612
                    f"have values less than {min_val}."
1613
                )
1614
            if max_val is not None:
1✔
1615
                assert D <= max_val, (
1✔
1616
                    f"{param_name} in {syst['name']} may "
1617
                    f"not have values greater than {min_val}."
1618
                )
1619

1620
            # for core_area only, need to make sure that the units are right
1621
            if param_name == "core_area":
1✔
1622
                angunit = self.get_angle_unit_from_header(None, syst)
1✔
1623
                D = (D * angunit**2).to(u.arcsec**2).value
1✔
1624

1625
            # ensure you have values for IWA/OWA, otherwise use defaults
1626
            syst = self.update_syst_WAs(syst, None, None)
1✔
1627
            IWA = syst["IWA"].to_value(u.arcsec)
1✔
1628
            OWA = syst["OWA"].to_value(u.arcsec)
1✔
1629

1630
            # same as for interpolant: coronagraphs scale with wavelength, occulters
1631
            # don't
1632
            if syst["occulter"]:
1✔
1633
                minl = syst["lam"] - syst["deltaLam"] / 2
1✔
1634
                maxl = syst["lam"] + syst["deltaLam"] / 2
1✔
1635
                if param_name == "core_area":
1✔
1636
                    outunit = 1 * u.arcsec**2
1✔
1637
                else:
1638
                    outunit = 1
1✔
1639

1640
                syst[param_name] = (
1✔
1641
                    lambda lam, s, D=D, IWA=IWA, OWA=OWA, minl=minl, maxl=maxl, fill=fill: (  # noqa: E501
1642
                        (
1643
                            np.array(
1644
                                (IWA <= s.to_value("arcsec"))
1645
                                & (s.to_value("arcsec") <= OWA)
1646
                                & (minl < lam)
1647
                                & (lam < maxl),
1648
                                ndmin=1,
1649
                            ).astype(float)
1650
                            * (D - fill)
1651
                            + fill
1652
                        )
1653
                        * outunit
1654
                    )
1655
                )
1656
            # coronagraph:
1657
            else:
1658
                lam0 = syst["lam"]
1✔
1659
                lam0_val = lam0.value
1✔
1660
                lam0_unit = lam0.unit
1✔
1661
                D_minus_fill = D - fill
1✔
1662

1663
                if param_name == "core_area":
1✔
1664
                    outunit = u.arcsec**2
1✔
1665

1666
                    def coro_core_area_float(lam, s):
1✔
1667
                        # Convert lam to the same unit as lam0, if the units already match
1668
                        # then this is a no-op
1669
                        lam_val = lam.to_value(lam0_unit)
1✔
1670
                        lam_ratio = lam0_val / lam_val
1✔
1671
                        # Scale the provided separations to the lam0 wavelength
1672
                        s_scaled_as = s.to_value(u.arcsec) * lam_ratio
1✔
1673
                        # Create a mask that is True when s is inside the dark zone
1674
                        dz_mask = np.array(
1✔
1675
                            (IWA <= s_scaled_as) & (s_scaled_as <= OWA),
1676
                            ndmin=1,
1677
                            dtype=float,
1678
                        )
1679
                        # Multiply the mask by the core area and scale by the wavelength
1680
                        # ratio squared
1681
                        core_area = dz_mask * D_minus_fill * (1 / lam_ratio) ** 2 + fill
1✔
1682
                        # Attach units in place and return
1683
                        return core_area << outunit
1✔
1684

1685
                    syst[param_name] = coro_core_area_float
1✔
1686
                else:
1687
                    syst[param_name] = self.create_coro_float_param_func(
1✔
1688
                        D, lam0, IWA, OWA, fill
1689
                    )
1690

1691
        # finally the case where the input is None
1692
        elif syst[param_name] is None:
×
1693
            syst[param_name] = None
×
1694
        # anything else (not string, number, or None) throws an error
1695
        else:
1696
            raise TypeError(
×
1697
                f"{param_name} for system {syst['name']} is neither a "
1698
                f"string nor a number. I don't know what to do with that."
1699
            )
1700

1701
        return syst
1✔
1702

1703
    def create_coro_fits_param_func(self, WA, D, lam0, fill):
1✔
1704
        """
1705
        Create a function for a coronagraph parameter that was provided as a fits
1706
        file.
1707

1708
        The returned function minimizes the number of operations and uses closures
1709
        to capture the values of the parameters to avoid recalculating them on
1710
        each call.
1711

1712
        Args:
1713
            WA (astropy.units.Quantity):
1714
                Working angles from the input fits file in arcsec
1715
            D (float):
1716
                Parameter values from the input fits file
1717
            lam0 (astropy.units.Quantity):
1718
                Design wavelength of the coronagraph
1719
            fill (float):
1720
                Fill value for the parameter
1721

1722
        Returns:
1723
            function:
1724
                A function that takes a wavelength and a separation and returns the
1725
                parameter value.
1726

1727
        """
1728
        lam0_val = lam0.value
1✔
1729
        lam0_unit = lam0.unit
1✔
1730

1731
        def func(lam, s):
1✔
1732
            # Convert lam to the same unit as lam0, if the units already match
1733
            # then this is a no-op
1734
            lam_val = lam.to_value(lam0_unit)
×
1735
            lam_ratio = lam0_val / lam_val
×
1736
            # Scale the provided separations to the lam0 wavelength
1737
            s_scaled_as = s.to_value(u.arcsec) * lam_ratio
×
1738
            # Use np.interp to get the parameter value
1739
            return np.interp(s_scaled_as, WA, D, left=fill, right=fill)
×
1740

1741
        return func
1✔
1742

1743
    def create_coro_float_param_func(self, D, lam0, IWA, OWA, fill):
1✔
1744
        """
1745
        Create a function for a coronagraph parameter that was provided as a float.
1746

1747
        The returned function minimizes the number of operations and uses closures
1748
        to capture the values of the parameters to avoid recalculating them on
1749
        each call.
1750

1751
        Args:
1752
            D (float):
1753
                Value of the parameter from the input file
1754
            lam0 (astropy.units.Quantity):
1755
                Design wavelength of the coronagraph
1756
            IWA (float):
1757
                Inner working angle of the coronagraph in arcsec
1758
            OWA (float):
1759
                Outer working angle of the coronagraph in arcsec
1760
            fill (float):
1761
                Fill value for the parameter
1762

1763
        Returns:
1764
            function:
1765
                A function that takes a wavelength and a separation and returns the
1766
                parameter value.
1767
        """
1768
        lam0_unit = lam0.unit
1✔
1769
        lam0_val = lam0.value
1✔
1770
        D_minus_fill = D - fill
1✔
1771

1772
        def func(lam, s):
1✔
1773
            # Convert lam to the same unit as lam0, if the units already match
1774
            # then this is a no-op
1775
            lam_val = lam.to_value(lam0_unit)
1✔
1776
            lam_ratio = lam0_val / lam_val
1✔
1777
            # Scale the provided separations to the lam0 wavelength
1778
            s_scaled_as = s.to_value(u.arcsec) * lam_ratio
1✔
1779
            # Create a mask that is True when s is inside the dark zone
1780
            dz_mask = np.array(
1✔
1781
                (IWA <= s_scaled_as) & (s_scaled_as <= OWA), ndmin=1, dtype=float
1782
            )
1783
            # Multiply the mask by the parameter value and add the fill value
1784
            return dz_mask * D_minus_fill + fill
1✔
1785

1786
        return func
1✔
1787

1788
    def get_param_data(
1✔
1789
        self,
1790
        ipth,
1791
        left_col_name=None,
1792
        param_name=None,
1793
        expected_ndim=None,
1794
        expected_first_dim=None,
1795
    ):
1796
        """Gets the data from a file, used primarily to create interpolants for
1797
        coronagraph parameters
1798

1799
        Args:
1800
            ipth (str):
1801
                String to file location, will also work with any other path object
1802
            left_col_name (str,optional):
1803
                For CSV files only. String representing the column containing the
1804
                independent parameter to be extracted. This is for use in the case
1805
                where the CSV file contains multiple columns and only two need to be
1806
                returned. Defaults None.
1807
            param_name (str, optional):
1808
                For CSV files only. String representing the column containing the
1809
                dependent parameter to be extracted. This is for use in the case where
1810
                the CSV file contains multiple columns and only two need to be returned.
1811
                Defaults None.
1812
            expected_ndim (int, optional):
1813
                Expected number of dimensions.  Only checked if not None. Defaults None.
1814
            expected_first_dim (int, optional):
1815
                Expected size of first dimension of data.  Only checked if not None.
1816
                Defaults None
1817

1818
        Returns:
1819
            tuple:
1820
                dat (~numpy.ndarray):
1821
                    Data array
1822
                hdr (list or astropy.io.fits.header.Header):
1823
                    Data header.  For CVS files this is a list of column header strings.
1824

1825
        .. note::
1826

1827
            CSV files *must* have a single header row
1828

1829
        """
1830
        # Check that path represents a valid file
1831
        pth = os.path.normpath(os.path.expandvars(ipth))
1✔
1832
        assert os.path.isfile(pth), f"{ipth} is not a valid file."
1✔
1833

1834
        # Check for fits or csv file
1835
        ext = pth.split(".")[-1]
1✔
1836
        assert ext.lower() in ["fits", "csv"], f"{pth} must be a fits or csv file."
1✔
1837
        if ext.lower() == "fits":
1✔
1838
            with fits.open(pth) as f:
1✔
1839
                dat = f[0].data.squeeze()
1✔
1840
                hdr = f[0].header
1✔
1841
        else:
1842
            # Need to get all of the headers and data, then associate them in the same
1843
            # ndarray that the fits files would generate. Note that CSV data must be 2D
1844
            # If only one row is found, force into 2D shape.
NEW
1845
            try:
×
NEW
1846
                table_vals = np.array(
×
1847
                    np.genfromtxt(pth, delimiter=",", skip_header=1, comments="#"),
1848
                    ndmin=2,
1849
                    copy=copy_if_needed,
1850
                )
1851

NEW
1852
                hdr = np.genfromtxt(
×
1853
                    pth,
1854
                    delimiter=",",
1855
                    skip_footer=len(table_vals),
1856
                    dtype=str,
1857
                    comments="#",
1858
                )
NEW
1859
            except UnicodeDecodeError:
×
NEW
1860
                table_vals = np.array(
×
1861
                    np.genfromtxt(
1862
                        pth,
1863
                        delimiter=",",
1864
                        skip_header=1,
1865
                        comments="#",
1866
                        encoding="latin1",
1867
                    ),
1868
                    ndmin=2,
1869
                    copy=copy_if_needed,
1870
                )
NEW
1871
                hdr = np.genfromtxt(
×
1872
                    pth,
1873
                    delimiter=",",
1874
                    skip_footer=len(table_vals),
1875
                    dtype=str,
1876
                    comments="#",
1877
                    encoding="latin1",
1878
                )
1879

1880
            # remove any rows that are all NaNs
NEW
1881
            table_vals = table_vals[~np.all(np.isnan(table_vals), axis=1)]
×
1882

1883
            if left_col_name is not None:
×
1884
                assert (
×
1885
                    param_name is not None
1886
                ), "If left_col_name is nont None, param_name cannot be None."
1887

1888
                assert (
×
1889
                    left_col_name in hdr
1890
                ), f"{left_col_name} not found in table header for file {ipth}"
1891
                assert (
×
1892
                    param_name in hdr
1893
                ), f"{param_name} not found in table header for file {ipth}"
1894

1895
                left_column_location = np.where(hdr == left_col_name)[0][0]
×
1896
                param_location = np.where(hdr == param_name)[0][0]
×
1897
                dat = np.vstack(
×
1898
                    [table_vals[:, left_column_location], table_vals[:, param_location]]
1899
                ).T
1900
                hdr = [left_col_name, param_name]
×
1901
            else:
1902
                dat = table_vals
×
1903

1904
        if expected_ndim is not None:
1✔
1905
            assert len(dat.shape) == expected_ndim, (
1✔
1906
                f"Data shape did not match expected {expected_ndim} "
1907
                f"dimensions for file {ipth}"
1908
            )
1909

1910
        if expected_first_dim is not None:
1✔
1911
            assert expected_first_dim in dat.shape, (
1✔
1912
                f"Expected first dimension size {expected_first_dim} not found in any "
1913
                f"data dimension for file {ipth}."
1914
            )
1915

1916
            if dat.shape[0] != expected_first_dim:
1✔
1917
                assert len(dat.shape) == 2, (
1✔
1918
                    f"Data in file {ipth} contains a dimension of expected size "
1919
                    f"{expected_first_dim}, but it is not the first dimension, and the "
1920
                    "data has dimensionality of > 2, so I do not know how to reshape "
1921
                    "it."
1922
                )
1923

1924
                dat = dat.transpose()
1✔
1925

1926
        return dat, hdr
1✔
1927

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

1932
        Args:
1933
            TL (:ref:`TargetList`):
1934
                TargetList class object
1935
            sInds (~numpy.ndarray(int)):
1936
                Integer indices of the stars of interest
1937
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
1938
                Surface brightness of local zodiacal light in units of 1/arcsec2
1939
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
1940
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
1941
            dMag (~numpy.ndarray(float)):
1942
                Differences in magnitude between planets and their host star
1943
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
1944
                Working angles of the planets of interest in units of arcsec
1945
            mode (dict):
1946
                Selected observing mode
1947
            returnExtra (bool):
1948
                Optional flag, default False, set True to return additional rates for
1949
                validation
1950
            TK (:ref:`TimeKeeping`, optional):
1951
                Optional TimeKeeping object (default None), used to model detector
1952
                degradation effects where applicable.
1953

1954

1955
        Returns:
1956
            tuple:
1957
                C_p (~astropy.units.Quantity(~numpy.ndarray(float))):
1958
                    Planet signal electron count rate in units of 1/s
1959
                C_b (~astropy.units.Quantity(~numpy.ndarray(float))):
1960
                    Background noise electron count rate in units of 1/s
1961
                C_sp (~astropy.units.Quantity(~numpy.ndarray(float))):
1962
                    Residual speckle spatial structure (systematic error)
1963
                    in units of 1/s
1964

1965
        """
1966

1967
        # grab all count rates
1968
        C_star, C_p, C_sr, C_z, C_ez, C_dc, C_bl, Npix = self.Cp_Cb_Csp_helper(
1✔
1969
            TL, sInds, fZ, JEZ, dMag, WA, mode
1970
        )
1971

1972
        # readout noise
1973
        inst = mode["inst"]
1✔
1974
        C_rn = Npix * inst["sread"] / inst["texp"]
1✔
1975

1976
        # background signal rate
1977
        C_b = C_sr + C_z + C_ez + C_bl + C_dc + C_rn
1✔
1978

1979
        # for characterization, Cb must include the planet
1980
        # C_sp = spatial structure to the speckle including post-processing contrast
1981
        # factor and stability factor
1982
        if not (mode["detectionMode"]):
1✔
1983
            C_b = C_b + C_p
1✔
1984
            C_sp = C_sr * TL.PostProcessing.ppFact_char(WA) * self.stabilityFact
1✔
1985
        else:
1986
            C_sp = C_sr * TL.PostProcessing.ppFact(WA) * self.stabilityFact
1✔
1987

1988
        if returnExtra:
1✔
1989
            # organize components into an optional fourth result
1990
            C_extra = dict(
×
1991
                C_sr=C_sr.to("1/s"),
1992
                C_z=C_z.to("1/s"),
1993
                C_ez=C_ez.to("1/s"),
1994
                C_dc=C_dc.to("1/s"),
1995
                C_rn=C_rn.to("1/s"),
1996
                C_star=C_star.to("1/s"),
1997
                C_bl=C_bl.to("1/s"),
1998
                Npix=Npix,
1999
            )
2000
            return C_p.to("1/s"), C_b.to("1/s"), C_sp.to("1/s"), C_extra
×
2001
        else:
2002
            return C_p.to("1/s"), C_b.to("1/s"), C_sp.to("1/s")
1✔
2003

2004
    def Cp_Cb_Csp_helper(self, TL, sInds, fZ, JEZ, dMag, WA, mode):
1✔
2005
        """Helper method for Cp_Cb_Csp that performs lots of common computations
2006
        Args:
2007
            TL (:ref:`TargetList`):
2008
                TargetList class object
2009
            sInds (~numpy.ndarray(int)):
2010
                Integer indices of the stars of interest
2011
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2012
                Surface brightness of local zodiacal light in units of 1/arcsec2
2013
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2014
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
2015
            dMag (~numpy.ndarray(float)):
2016
                Differences in magnitude between planets and their host star
2017
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
2018
                Working angles of the planets of interest in units of arcsec
2019
            mode (dict):
2020
                Selected observing mode
2021

2022
        Returns:
2023
            tuple:
2024
                C_star (~astropy.units.Quantity(~numpy.ndarray(float))):
2025
                    Non-coronagraphic star count rate (1/s)
2026
                C_p0 (~astropy.units.Quantity(~numpy.ndarray(float))):
2027
                    Planet count rate (1/s)
2028
                C_sr (~astropy.units.Quantity(~numpy.ndarray(float))):
2029
                    Starlight residual count rate (1/s)
2030
                C_z (~astropy.units.Quantity(~numpy.ndarray(float))):
2031
                    Local zodi count rate (1/s)
2032
                C_ez (~astropy.units.Quantity(~numpy.ndarray(float))):
2033
                    Exozodi count rate (1/s)
2034
                C_dc (~astropy.units.Quantity(~numpy.ndarray(float))):
2035
                    Dark current count rate (1/s)
2036
                C_bl (~astropy.units.Quantity(~numpy.ndarray(float))):
2037
                    Background leak count rate (1/s)'
2038
                Npix (float):
2039
                    Number of pixels in photometric aperture
2040
        """
2041

2042
        # SET UP CONVERSION FACTORS FOR UNITS OF WA fZ and JEZ
2043
        # SOMETHING LIKE A DICTIONARY KEYED ON THOSE UNITS WITH ENTRIES FOR EACH CALCULATION
2044
        cache_conversions = (fZ.unit, JEZ.unit) not in self.unit_conv
1✔
2045
        convs_added = False
1✔
2046
        if cache_conversions:
1✔
2047
            convs = {}
1✔
2048
        else:
2049
            convs = self.unit_conv[(fZ.unit, JEZ.unit)]
1✔
2050

2051
        # get scienceInstrument and starlightSuppressionSystem and wavelength
2052
        inst = mode["inst"]
1✔
2053
        syst = mode["syst"]
1✔
2054
        lam = mode["lam"]
1✔
2055
        _lam = lam.to_value(u.nm)
1✔
2056
        _syst_lam = syst["lam"].to_value(u.nm)
1✔
2057

2058
        # coronagraph parameters
2059
        occ_trans = syst["occ_trans"](lam, WA)
1✔
2060
        core_thruput = syst["core_thruput"](lam, WA)
1✔
2061
        Omega = syst["core_area"](lam, WA)
1✔
2062

2063
        # number of pixels per lenslet
2064
        pixPerLens = inst["lenslSamp"] ** 2.0
1✔
2065

2066
        # number of detector pixels in the photometric aperture = Omega / theta^2
2067
        # Npix = pixPerLens * (Omega / inst["pixelScale"] ** 2.0).decompose().value
2068
        if cache_conversions or convs.get("Npix") is None:
1✔
2069
            Npix = pixPerLens * (Omega / inst["pixelScale"] ** 2.0)
1✔
2070
            if Npix[0].value != 0:
1✔
2071
                convs["Npix"] = (
1✔
2072
                    Npix[0].to_value(u.dimensionless_unscaled) / Npix[0].value
2073
                )
2074
                Npix = Npix.value * convs["Npix"]
1✔
2075
                convs_added = True
1✔
2076
        else:
2077
            Npix = (
1✔
2078
                pixPerLens
2079
                * (Omega.value / inst["pixelScale"].value ** 2.0)
2080
                * convs["Npix"]
2081
            )
2082

2083
        # get stellar residual intensity in the planet PSF core
2084
        # if core_mean_intensity is None, fall back to using core_contrast
2085
        if syst["core_mean_intensity"] is None:
1✔
2086
            core_contrast = syst["core_contrast"](lam, WA)
1✔
2087
            core_intensity = core_contrast * core_thruput
1✔
2088
        else:
2089
            # if we're here, we're using the core mean intensity
2090
            core_mean_intensity = syst["core_mean_intensity"](
1✔
2091
                lam, WA, TL.diameter[sInds]
2092
            )
2093
            # also, if we're here, we must have a platescale defined
2094
            # furthermore, if we're a coronagraph, we have to scale by wavelength
2095
            scale_factor = _lam / _syst_lam if not (syst["occulter"]) else 1
1✔
2096
            core_platescale = syst["core_platescale"] * scale_factor
1✔
2097

2098
            # core_intensity is the mean intensity times the number of map pixels
2099
            if cache_conversions or convs.get("core_intensity") is None:
1✔
2100
                core_intensity = core_mean_intensity * Omega / core_platescale**2
1✔
2101
                if core_intensity[0].value != 0:
1✔
2102
                    convs["core_intensity"] = (
1✔
2103
                        core_intensity[0].to_value(u.dimensionless_unscaled)
2104
                        / core_intensity[0].value
2105
                    )
2106
                    convs_added = True
1✔
2107
            else:
2108
                core_intensity = (
1✔
2109
                    core_mean_intensity
2110
                    * Omega.value
2111
                    / core_platescale.value**2
2112
                    * convs["core_intensity"]
2113
                )
2114

2115
            # finally, if a contrast floor was set, make sure we're not violating it
2116
            if syst["contrast_floor"] is not None:
1✔
2117
                below_contrast_floor = (
×
2118
                    core_intensity / core_thruput < syst["contrast_floor"]
2119
                )
2120
                core_intensity[below_contrast_floor] = (
×
2121
                    syst["contrast_floor"] * core_thruput[below_contrast_floor]
2122
                )
2123

2124
        # cast sInds to array
2125
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
2126

2127
        # Star fluxes (ph/m^2/s)
2128
        flux_star = TL.starFlux(sInds, mode)
1✔
2129

2130
        # ELECTRON COUNT RATES [ s^-1 ]
2131
        # non-coronagraphic star counts
2132
        if cache_conversions or convs.get("C_star") is None:
1✔
2133
            C_star = flux_star * mode["losses"]
1✔
2134
            if C_star[0].value != 0:
1✔
2135
                convs["C_star"] = C_star[0].to_value(self.inv_s) / C_star[0].value
1✔
2136
                C_star = C_star.value * convs["C_star"] << self.inv_s
1✔
2137
                convs_added = True
1✔
2138
        else:
2139
            C_star = (
1✔
2140
                flux_star.value * mode["losses"].value * convs["C_star"] << self.inv_s
2141
            )
2142
        _C_star = C_star.to_value(self.inv_s)
1✔
2143
        # planet counts:
2144
        # C_p0 = (C_star * 10.0 ** (-0.4 * dMag) * core_thruput).to("1/s")
2145
        if cache_conversions or convs.get("C_p0") is None:
1✔
2146
            C_p0 = C_star * 10.0 ** (-0.4 * dMag) * core_thruput
1✔
2147
            if C_p0[0].value != 0:
1✔
2148
                convs["C_p0"] = C_p0[0].to_value(self.inv_s) / C_p0[0].value
1✔
2149
                C_p0 = C_p0.value * convs["C_p0"] << self.inv_s
1✔
2150
                convs_added = True
1✔
2151
        else:
2152
            C_p0 = (
1✔
2153
                _C_star * 10.0 ** (-0.4 * dMag) * core_thruput * convs["C_p0"]
2154
                << self.inv_s
2155
            )
2156
        # starlight residual
2157
        # C_sr = (C_star * core_intensity).to("1/s")
2158
        if cache_conversions or convs.get("C_sr") is None:
1✔
2159
            C_sr = C_star * core_intensity
1✔
2160
            if C_sr[0].value != 0:
1✔
2161
                convs["C_sr"] = C_sr[0].to_value(self.inv_s) / C_sr[0].value
1✔
2162
                C_sr = C_sr.value * convs["C_sr"] << self.inv_s
1✔
2163
                convs_added = True
1✔
2164
        else:
2165
            C_sr = _C_star * core_intensity * convs["C_sr"] << self.inv_s
1✔
2166
        # zodiacal light
2167
        # C_z = (mode["F0"] * mode["losses"] * fZ * Omega * occ_trans).to("1/s")
2168
        if cache_conversions or convs.get("C_z") is None:
1✔
2169
            C_z = mode["F0"] * mode["losses"] * fZ * Omega * occ_trans
1✔
2170
            if C_z[0].value != 0:
1✔
2171
                convs["C_z"] = C_z[0].to_value(self.inv_s) / C_z[0].value
1✔
2172
                C_z = C_z.value * convs["C_z"] << self.inv_s
1✔
2173
                convs_added = True
1✔
2174
        else:
2175
            C_z = (
1✔
2176
                mode["F0"].value
2177
                * mode["losses"].value
2178
                * fZ.value
2179
                * Omega.value
2180
                * occ_trans
2181
                * convs["C_z"]
2182
                << self.inv_s
2183
            )
2184
        # exozodiacal light
2185
        if cache_conversions or convs.get("C_ez") is None:
1✔
2186
            if self.use_core_thruput_for_ez:
1✔
2187
                # C_ez = (JEZ * mode["losses"] * Omega * core_thruput).to("1/s")
2188
                C_ez = JEZ * mode["losses"] * Omega * core_thruput
×
2189
            else:
2190
                # C_ez = (JEZ * mode["losses"] * Omega * occ_trans).to("1/s")
2191
                C_ez = JEZ * mode["losses"] * Omega * occ_trans
1✔
2192
            if C_ez[0].value != 0:
1✔
2193
                convs["C_ez"] = C_ez[0].to_value(self.inv_s) / C_ez[0].value
1✔
2194
                C_ez = C_ez.value * convs["C_ez"] << self.inv_s
1✔
2195
                convs_added = True
1✔
2196
        else:
2197
            if self.use_core_thruput_for_ez:
1✔
2198
                C_ez = (
×
2199
                    JEZ.value
2200
                    * mode["losses"].value
2201
                    * Omega.value
2202
                    * core_thruput
2203
                    * convs["C_ez"]
2204
                    << self.inv_s
2205
                )
2206
            else:
2207
                C_ez = (
1✔
2208
                    JEZ.value
2209
                    * mode["losses"].value
2210
                    * Omega.value
2211
                    * occ_trans
2212
                    * convs["C_ez"]
2213
                    << self.inv_s
2214
                )
2215
        # dark current
2216
        C_dc = Npix * inst["idark"]
1✔
2217
        # only calculate binary leak if you have a model and relevant data
2218
        # in the targelist
2219
        if hasattr(self, "binaryleakmodel") and all(
1✔
2220
            hasattr(TL, attr)
2221
            for attr in ["closesep", "closedm", "brightsep", "brightdm"]
2222
        ):
2223
            cseps = TL.closesep[sInds]
×
2224
            cdms = TL.closedm[sInds]
×
2225
            bseps = TL.brightsep[sInds]
×
2226
            bdms = TL.brightdm[sInds]
×
2227

2228
            if cache_conversions:
×
2229
                convs["seps"] = self.arcsec2rad
×
2230
                convs["diam/lam"] = (1 * self.pupilDiam.unit / lam.unit).to_value(
×
2231
                    u.dimensionless_unscaled
2232
                )
2233
            # don't double count where the bright star is the close star
2234
            repinds = (cseps == bseps) & (cdms == bdms)
×
2235
            bseps[repinds] = np.nan
×
2236
            bdms[repinds] = np.nan
×
2237

2238
            crawleaks = self.binaryleakmodel(
×
2239
                (
2240
                    (cseps * convs["seps"])
2241
                    / lam.value
2242
                    * self.pupilDiam.value
2243
                    * convs["diam/lam"]
2244
                )
2245
            )
2246
            cleaks = crawleaks * 10 ** (-0.4 * cdms)
×
2247
            cleaks[np.isnan(cleaks)] = 0
×
2248

2249
            brawleaks = self.binaryleakmodel(
×
2250
                (
2251
                    (bseps * convs["seps"])
2252
                    / lam.value
2253
                    * self.pupilDiam.value
2254
                    * convs["diam/lam"]
2255
                )
2256
            )
2257
            bleaks = brawleaks * 10 ** (-0.4 * bdms)
×
2258
            bleaks[np.isnan(bleaks)] = 0
×
2259

2260
            C_bl = (cleaks + bleaks) * _C_star * core_thruput << self.inv_s
×
2261
        else:
2262
            C_bl = np.zeros(len(sInds)) << self.inv_s
1✔
2263

2264
        if cache_conversions or convs_added:
1✔
2265
            self.unit_conv[(fZ.unit, JEZ.unit)] = convs
1✔
2266
        return C_star, C_p0, C_sr, C_z, C_ez, C_dc, C_bl, Npix
1✔
2267

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

2272

2273
        Args:
2274
            TL (:ref:`TargetList`):
2275
                TargetList class object
2276
            sInds (numpy.ndarray(int)):
2277
                Integer indices of the stars of interest
2278
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2279
                Surface brightness of local zodiacal light in units of 1/arcsec2
2280
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2281
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
2282
            dMag (numpy.ndarray(int)numpy.ndarray(float)):
2283
                Differences in magnitude between planets and their host star
2284
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
2285
                Working angles of the planets of interest in units of arcsec
2286
            mode (dict):
2287
                Selected observing mode
2288
            TK (:ref:`TimeKeeping`, optional):
2289
                Optional TimeKeeping object (default None), used to model detector
2290
                degradation effects where applicable.
2291

2292
        Returns:
2293
            ~astropy.units.Quantity(~numpy.ndarray(float)):
2294
                Integration times
2295

2296
        .. note::
2297

2298
            All infeasible integration times are returned as NaN values
2299

2300
        """
2301
        # count rates
2302
        C_p, C_b, C_sp = self.Cp_Cb_Csp(TL, sInds, fZ, JEZ, dMag, WA, mode, TK=TK)
1✔
2303

2304
        # get SNR threshold
2305
        SNR = mode["SNR"]
1✔
2306

2307
        with np.errstate(divide="ignore", invalid="ignore"):
1✔
2308
            intTime = np.true_divide(
1✔
2309
                SNR**2.0 * C_b, (C_p**2.0 - (SNR * C_sp) ** 2.0)
2310
            ).to("day")
2311

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

2315
        return intTime
1✔
2316

2317
    def calc_dMag_per_intTime(
1✔
2318
        self,
2319
        intTimes,
2320
        TL,
2321
        sInds,
2322
        fZ,
2323
        JEZ,
2324
        WA,
2325
        mode,
2326
        C_b=None,
2327
        C_sp=None,
2328
        TK=None,
2329
        analytic_only=False,
2330
    ):
2331
        """Finds achievable planet delta magnitude for one integration
2332
        time per star in the input list at one working angle.
2333

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

2361
        Returns:
2362
            numpy.ndarray(float):
2363
                Achievable dMag for given integration time and working angle
2364

2365
        .. warning::
2366

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

2373
        """
2374

2375
        # cast sInds to array
2376
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
2377

2378
        if (C_b is None) or (C_sp is None):
1✔
2379
            _, C_b, C_sp = self.Cp_Cb_Csp(
1✔
2380
                TL, sInds, fZ, JEZ, np.zeros(len(sInds)), WA, mode, TK=TK
2381
            )
2382

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

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

2389
        return dMag.value
1✔
2390

2391
    def ddMag_dt(
1✔
2392
        self, intTimes, TL, sInds, fZ, JEZ, WA, mode, C_b=None, C_sp=None, TK=None
2393
    ):
2394
        """Finds derivative of achievable dMag with respect to integration time.
2395

2396
        Args:
2397
            intTimes (~astropy.units.Quantity(~numpy.ndarray(float))):
2398
                Integration times in units of day
2399
            TL (:ref:`TargetList`):
2400
                TargetList class object
2401
            sInds (numpy.ndarray(int)):
2402
                Integer indices of the stars of interest
2403
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2404
                Surface brightness of local zodiacal light in units of 1/arcsec2
2405
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2406
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
2407
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
2408
                Working angles of the planets of interest in units of arcsec
2409
            mode (dict):
2410
                Selected observing mode
2411
            C_b (~astropy.units.Quantity(~numpy.ndarray(float))):
2412
                Background noise electron count rate in units of 1/s (optional)
2413
            C_sp (~astropy.units.Quantity(~numpy.ndarray(float))):
2414
                Residual speckle spatial structure (systematic error) in units of 1/s
2415
                (optional)
2416
            TK (:ref:`TimeKeeping`, optional):
2417
                Optional TimeKeeping object (default None), used to model detector
2418
                degradation effects where applicable.
2419

2420
        Returns:
2421
            ~astropy.units.Quantity(~numpy.ndarray(float)):
2422
                Derivative of achievable dMag with respect to integration time
2423
                in units of 1/s
2424

2425
        """
2426
        # cast sInds to array
2427
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
2428

2429
        if (C_b is None) or (C_sp is None):
1✔
2430
            _, C_b, C_sp = self.Cp_Cb_Csp(
1✔
2431
                TL, sInds, fZ, JEZ, np.zeros(len(sInds)), WA, mode, TK=TK
2432
            )
2433

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

2436
        return ddMagdt.to("1/s")
1✔
2437

2438
    def calc_saturation_dMag(self, TL, sInds, fZ, JEZ, WA, mode, TK=None):
1✔
2439
        """
2440
        This calculates the delta magnitude for each target star that
2441
        corresponds to an infinite integration time.
2442

2443
        Args:
2444
            TL (:ref:`TargetList`):
2445
                TargetList class object
2446
            sInds (numpy.ndarray(int)):
2447
                Integer indices of the stars of interest
2448
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2449
                Surface brightness of local zodiacal light in units of 1/arcsec2
2450
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2451
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
2452
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
2453
                Working angles of the planets of interest in units of arcsec
2454
            mode (dict):
2455
                Selected observing mode
2456
            TK (:ref:`TimeKeeping`, optional):
2457
                Optional TimeKeeping object (default None), used to model detector
2458
                degradation effects where applicable.
2459

2460
        Returns:
2461
            ~numpy.ndarray(float):
2462
                Saturation (maximum achievable) dMag for each target star
2463
        """
2464

2465
        _, C_b, C_sp = self.Cp_Cb_Csp(
1✔
2466
            TL, sInds, fZ, JEZ, np.zeros(len(sInds)), WA, mode, TK=TK
2467
        )
2468

2469
        flux_star = TL.starFlux(sInds, mode)
1✔
2470
        core_thruput = mode["syst"]["core_thruput"](mode["lam"], WA)
1✔
2471

2472
        dMagmax = -2.5 * np.log10(
1✔
2473
            mode["SNR"] * C_sp / (flux_star * mode["losses"] * core_thruput)
2474
        )
2475

2476
        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