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

dsavransky / EXOSIMS / 20249289169

15 Dec 2025 10:16PM UTC coverage: 65.689% (-0.08%) from 65.768%
20249289169

Pull #451

github

web-flow
Merge f255f83fe into 9dd00555b
Pull Request #451: Improve angular diameter filtering in TargetList

1 of 15 new or added lines in 2 files covered. (6.67%)

15 existing lines in 5 files now uncovered.

9827 of 14960 relevant lines covered (65.69%)

0.66 hits per line

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

79.21
/EXOSIMS/Prototypes/TargetList.py
1
import copy
1✔
2
import gzip
1✔
3
import importlib.resources
1✔
4
import json
1✔
5
import os.path
1✔
6
import pickle
1✔
7
import warnings
1✔
8
from pathlib import Path
1✔
9

10
import astropy.units as u
1✔
11
import numpy as np
1✔
12
from astropy.coordinates import SkyCoord
1✔
13
from astropy.time import Time
1✔
14
from MeanStars import MeanStars
1✔
15
from synphot import Observation, SourceSpectrum, SpectralElement
1✔
16
from synphot.exceptions import DisjointError, SynphotError
1✔
17
from synphot.models import BlackBodyNorm1D, Empirical1D
1✔
18
from synphot.units import VEGAMAG
1✔
19
from tqdm import tqdm
1✔
20

21
from EXOSIMS.util.deltaMag import deltaMag
1✔
22
from EXOSIMS.util.get_dirs import get_cache_dir
1✔
23
from EXOSIMS.util.get_module import get_module
1✔
24
from EXOSIMS.util.getExoplanetArchive import getExoplanetArchiveAliases
1✔
25
from EXOSIMS.util.utils import genHexStr
1✔
26
from EXOSIMS.util.vprint import vprint
1✔
27
from EXOSIMS.util._numpy_compat import copy_if_needed
1✔
28

29

30
class TargetList(object):
1✔
31
    r""":ref:`TargetList` Prototype
32

33
    Instantiation of an object of this class requires the instantiation of the
34
    following class objects:
35

36
    * StarCatalog
37
    * OpticalSystem
38
    * ZodiacalLight
39
    * Completeness
40
    * PostProcessing
41

42
    Args:
43
        missionStart (float):
44
            Mission start time (MJD)
45
        staticStars (bool):
46
            Do not apply proper motions to stars during simulation. Defaults True.
47
        fillPhotometry (bool):
48
            Attempt to fill in missing photometric data for targets from
49
            available values (primarily spectral type and luminosity). Defaults False.
50
        fillMissingBandMags (bool):
51
            If ``fillPhotometry`` is True, also fill in missing band magnitudes.
52
            Ignored if ``fillPhotometry`` is False.  Defaults False.
53

54
            .. warning::
55

56
                This can be used for generating more complete target data for other uses
57
                but is *not* recommended for use in integration time calculations.
58

59
        explainFiltering (bool):
60
            Print informational messages at each target list filtering step.
61
            Defaults False.
62
        filterBinaries (bool):
63
            Remove all binary stars or stars with known close companions from target
64
            list. Defaults True.
65
        cachedir (str or None):
66
            Full path to cache directory.  If None, use default.
67
        filter_for_char (bool):
68
            Use spectroscopy observation mode (rather than the default detection mode)
69
            for all calculations. Defaults False.
70
        earths_only (bool):
71
            Used in upstream modules.  Alias for filter_for_char. Defaults False.
72
        getKnownPlanets (bool):
73
            Retrieve information about which stars have known planets along with all
74
            known (to the NASA Exoplanet Archive) target aliases. Defaults False.
75

76
            .. warning::
77

78
                This can take a *very* long time for large star catalogs if starting
79
                from scratch. For this reason, the cache comes pre-loaded with all
80
                entries coresponding to EXOCAT1.
81

82
        int_WA (float or numpy.ndarray or None):
83
            Working angle (arcsec) at which to caluclate integration times for default
84
            observations.  If input is scalar, all targets will get the same value.  If
85
            input is an array, it must match the size of the input catalog.  If None,
86
            a default value of halway between the inner and outer working angle of the
87
            default observing mode will be used.  If the OWA is infinite, twice the IWA
88
            is used.
89
        int_dMag (float or numpy.ndarray):
90
            :math:`\\Delta\\textrm{mag}` to assume when calculating integration time for
91
            default observations. If input is scalar, all targets will get the same
92
            value.  If input is an array, it must match the size of the input catalog.
93
            Defaults to 25
94
        scaleWAdMag (bool):
95
            If True, rescale int_dMag and int_WA for all stars based on luminosity and
96
            to ensure that WA is within the IWA/OWA. Defaults False.
97
        popStars (list, optional):
98
            Remove given stars (by exact name matching) from target list.
99
            Defaults None.
100
        cherryPickStars (list, optional):
101
            Before doing any other filtering, filter out all stars from input star
102
            catalog *excep* for the ones in this list (by exact name matching).
103
            Defaults None (do not initially filter out any stars from the star catalog).
104
        skipSaturationCalcs (bool):
105
            If True, do not perform any new calculations for saturation dMag and
106
            saturation completeness (cached values will still be loaded if found on
107
            disk).  The saturation_dMag and saturation_comp will all be set to NaN if
108
            this keyword is set True and no cached values are found.  No cache will
109
            be written in that case. Defaults False.
110
        massLuminosityRelationship(str):
111
            String describing the mass-luminsoity relaitonship to use to populate
112
            stellar masses when not provided by the star catalog.
113
            Defaults to Henry1993.
114
            Allowable values: [Henry1993, Fernandes2021, Henry1993+1999, Fang2010]
115
        **specs:
116
            :ref:`sec:inputspec`
117

118
    Attributes:
119
        _outspec (dict):
120
            :ref:`sec:outspec`
121
        BackgroundSources (:ref:`BackgroundSources`):
122
            :ref:`BackgroundSources` object
123
        BC (numpy.ndarray):
124
            Bolometric correction (V band)
125
        Binary_Cut (numpy.ndarray):
126
            Boolean - True is target has close companion.
127
        blackbody_spectra (numpy.ndarray):
128
            Storage array for blackbody spectra (populated as needed)
129
        Bmag (numpy.ndarray):
130
            B band magniutde
131
        BV (numpy.ndarray):
132
            B-V color
133
        cachedir (str):
134
            Path to the EXOSIMS cache directory (see :ref:`EXOSIMSCACHE`)
135
        calc_char_int_comp (bool):
136
            Boolean flagged by ``filter_for_char`` or ``earths_only``
137
        catalog_atts (list):
138
            All star catalog attributes that were copied in
139
        cherryPickStars (list):
140
            List of star names to keep from input star catalog (all others are filtered
141
            out prior to any other filtering).
142
        Completeness (:ref:`Completeness`):
143
            :ref:`Completeness` object
144
        coords (astropy.coordinates.sky_coordinate.SkyCoord):
145
            Target coordinates
146
        diameter (astropy.units.quantity.Quantity):
147
            Stellar diameter in angular units.
148
        dist (astropy.units.quantity.Quantity):
149
            Target distances
150
        earths_only (bool):
151
            Used in upstream modules.  Alias for filter_for_char.
152
        explainFiltering (bool):
153
            Print informational messages at each target list filtering step.
154
        fillPhotometry (bool):
155
            Attempt to fill in missing target photometric  values using interpolants of
156
            tabulated values for the stellar type. See MeanStars documentation for
157
            more details.
158
        fillMissingBandMags (bool):
159
            If ``self.fillPhotometry`` is True, also fill in missing band magnitudes.
160
            Ignored if ``self.fillPhotometry`` is False.
161
        filter_for_char (bool):
162
            Use spectroscopy observation mode (rather than the default detection mode)
163
            for all calculations.
164
        filterBinaries (bool):
165
            Remove all binary stars or stars with known close companions from target
166
            list.
167
        filter_mode (dict):
168
            :ref:`OpticalSystem` observingMode dictionary. The observingMode used for
169
            target filtering.  Either the detection mode (default) or first
170
            characterization mode (if ``filter_for_char`` is True).
171
        getKnownPlanets (bool):
172
            Grab the list of known planets and target aliases from the NASA Exoplanet
173
            Archive
174
        hasKnownPlanet (numpy.ndarray):
175
            bool array indicating whether a target has known planets.  Only populated
176
            if attribute ``getKnownPlanets`` is True. Otherwise all entries are False.
177
        Hmag (numpy.ndarray):
178
            H band magnitudes
179
        Imag (numpy.ndarray):
180
            I band magnitudes
181
        int_comp (numpy.ndarray):
182
            Completeness value for each target star for default observation WA and
183
            :math:`\Delta{\\textrm{mag}}`.
184
        int_dMag (numpy.ndarray):
185
             :math:`\Delta{\\textrm{mag}}` used for default observation integration time
186
             calculation
187
        int_tmin (astropy.units.quantity.Quantity):
188
            Integration times corresponding to `int_dMag` with global minimum local zodi
189
            contribution.
190
        int_WA (astropy.units.quantity.Quantity):
191
            Working angle used for integration time calculation (angle)
192
        intCutoff_comp (numpy.ndarray):
193
            Completeness values of all targets corresponding to the cutoff integration
194
            time set in the optical system.
195
        intCutoff_dMag (numpy.ndarray):
196
            :math:`\Delta{\\textrm{mag}}` of all targets corresponding to the cutoff
197
            integration time set in the optical system.
198
        JEZ0 (dict):
199
            Internal storage of pre-computed exozodi intensity values that are
200
            mode and star specific. The values are computed for radial distance of 1 AU
201
            and have units of photons/s/m^2/arcsec^2. These must be scaled by the inverse
202
            square of the planet's distance in AU. Keyed by observing mode hex attribute.
203
        Jmag (numpy.ndarray):
204
            J band magnitudes
205
        Kmag (numpy.ndarray):
206
            K band mangitudes
207
        L (numpy.ndarray):
208
            Luminosities in solar luminosities (linear scale!)
209
        massLuminosityRealtionship (str):
210
            String describing the mass-luminosity relationship used to populate
211
            the stellar masses when not provided by the star catalog.
212
        ms (MeanStars.MeanStars.MeanStars):
213
            MeanStars object
214
        MsEst (astropy.units.quantity.Quantity):
215
            'approximate' stellar masses
216
        MsTrue (astropy.units.quantity.Quantity):
217
            'true' stellar masses
218
        MV (numpy.ndarray):
219
            Absolute V band magnitudes
220
        Name (numpy.ndarray):
221
            Target names (str array)
222
        nStars (int):
223
            Number of stars currently in target list
224
        systemOmega (astropy.units.quantity.Quantity):
225
            Base longitude of the ascending node for target system orbital planes
226
        OpticalSystem (:ref:`OpticalSystem`):
227
            :ref:`OpticalSystem` object
228
        optional_filters (dict):
229
            Dictionary of optional filters to apply to the target list.  Keys are the
230
            filter's name and values are the values necessary to apply the filter.
231
        parx (astropy.units.quantity.Quantity):
232
            Parallaxes
233
        PlanetPhysicalModel (:ref:`PlanetPhysicalModel`):
234
            :ref:`PlanetPhysicalModel` object
235
        PlanetPopulation (:ref:`PlanetPopulation`):
236
            :ref:`PlanetPopulation` object
237
        pmdec (astropy.units.quantity.Quantity):
238
            Proper motion in declination
239
        pmra (astropy.units.quantity.Quantity):
240
            Proper motion in right ascension
241
        popStars (list, optional):
242
            List of target names that were removed from target list
243
        PostProcessing (:ref:`PostProcessing`):
244
            :ref:`PostProcessing` object
245
        required_catalog_atts (list):
246
            Catalog attributes that may not be missing or nan
247
        Rmag (numpy.ndarray):
248
            R band magnitudes
249
        rv (astropy.units.quantity.Quantity):
250
            Radial velocities
251
        saturation_comp (numpy.ndarray):
252
            Maximum possible completeness values of all targets.
253
        saturation_dMag (numpy.ndarray):
254
            :math:`\Delta{\\textrm{mag}}` at which completness stops increasing for all
255
            targets.
256
        scaleWAdMag (bool):
257
            Rescale int_dMag and int_WA for all stars based on luminosity and to ensure
258
            that WA is within the IWA/OWA.
259
        skipSaturationCalcs (bool):
260
            If True (default), saturation dMag and saturation completeness are not
261
            computed. If cached values exist, they will be loaded, otherwise
262
            saturation_dMag and saturation_comp will all be set to NaN.  No new cache
263
            files will be written for these values.
264
        Spec (numpy.ndarray):
265
            Spectral type strings. Will be strictly in G0V format.
266
        specdict (dict):
267
            Dictionary of numerical mappings for spectral classes (O = 0, M = 6).
268
        spectral_catalog_index (dict):
269
            Dictionary mapping spectral type strings (keys) to template spectra files
270
            on disk (values).
271
        spectral_catalog_types (numpy.ndarray):
272
            nx4 ndarray (n is the number of template spectra avaiable). First three
273
            columns are spectral class (str), subclass (int), and luinosity class (str).
274
            The fourth column is a spectral class numeric representation, equaling
275
            ``specdict[specclass]*10 + subclass``.
276
        spectral_class (numpy.ndarray):
277
            nStars x 4 array.  Same column definitions as ``spectral_catalog_types`` but
278
            evaluated for the target stars rather than the template spectra.
279
        standard_bands (dict):
280
            Dictionary mapping UVBRIJHK (keys are single characters) to
281
            :py:class:`synphot.spectrum.SpectralElement` objects of the filter profiles.
282
        standard_bands_deltaLam (astropy.units.quantity.Quantity):
283
            Effective bandpasses of the profiles in `standard_bands`.
284
        standard_bands_lam (astropy.units.quantity.Quantity):
285
            Effective central wavelengths of the profiles in `standard_bands`.
286
        standard_bands_letters (str):
287
            Concatenation of the keys of  `standard_bands`.
288
        star_fluxes (dict):
289
            Internal storage of pre-computed star flux values that is populated
290
            each time a flux is requested for a particular target. Keyed by observing
291
            mode hex attribute.
292
        StarCatalog (:ref:`StarCatalog`):
293
            Star catalog object
294
        StarCatalogHex (str):
295
            Unique hash of StarCatalog contents (populated immediately after
296
            StarCatalog is loaded)
297
        staticStars (bool):
298
            Do not apply proper motions to stars.  Stars always at mission start time
299
            positions.
300
        systemInclination (astropy.units.quantity.Quantity):
301
            Inclinations of target system orbital planes
302
        system_fbeta (numpy.ndarray):
303
            fbeta values for target system orbital planes
304
        Teff (astropy.units.Quantity):
305
            Stellar effective temperature.
306
        template_spectra (dict):
307
            Dictionary of template spectra objects (populated as needed).
308
        Umag (numpy.ndarray):
309
            U band magnitudes
310
        Vmag (numpy.ndarray):
311
            V band magnitudes
312
        ZodiacalLight (:ref:`ZodiacalLight`):
313
            :ref:`ZodiacalLight` object
314

315
    """
316

317
    _modtype = "TargetList"
1✔
318

319
    def __init__(
1✔
320
        self,
321
        missionStart=60634,
322
        staticStars=True,
323
        fillPhotometry=False,
324
        fillMissingBandMags=False,
325
        explainFiltering=False,
326
        filterBinaries=True,
327
        cachedir=None,
328
        filter_for_char=False,
329
        earths_only=False,
330
        getKnownPlanets=False,
331
        int_WA=None,
332
        int_dMag=25,
333
        scaleWAdMag=False,
334
        popStars=None,
335
        cherryPickStars=None,
336
        skipSaturationCalcs=True,
337
        massLuminosityRelationship="Henry1993",
338
        optional_filters={},
339
        **specs,
340
    ):
341
        # start the outspec
342
        self._outspec = {}
1✔
343

344
        # get cache directory
345
        self.cachedir = get_cache_dir(cachedir)
1✔
346
        self._outspec["cachedir"] = self.cachedir
1✔
347
        specs["cachedir"] = self.cachedir
1✔
348

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

352
        # assign inputs to attributes
353
        self.getKnownPlanets = bool(getKnownPlanets)
1✔
354
        self.staticStars = bool(staticStars)
1✔
355
        self.fillPhotometry = bool(fillPhotometry)
1✔
356
        self.fillMissingBandMags = bool(fillMissingBandMags)
1✔
357
        self.explainFiltering = bool(explainFiltering)
1✔
358
        self.filterBinaries = bool(filterBinaries)
1✔
359
        self.filter_for_char = bool(filter_for_char)
1✔
360
        self.earths_only = bool(earths_only)
1✔
361
        self.scaleWAdMag = bool(scaleWAdMag)
1✔
362
        self.skipSaturationCalcs = bool(skipSaturationCalcs)
1✔
363
        self.massLuminosityRelationship = str(massLuminosityRelationship)
1✔
364
        allowable_massLuminosityRelationships = [
1✔
365
            "Henry1993",
366
            "Fernandes2021",
367
            "Henry1993+1999",
368
            "Fang2010",
369
        ]
370

371
        # Set up optional filters
372
        default_filters = {
1✔
373
            "outside_IWA_filter": {"enabled": True},
374
            "completeness_filter": {"enabled": True},
375
        }
376
        # Add the binary filter to the default optional filters
377
        if optional_filters.get("binary_filter"):
1✔
378
            # if binary_filter is provided in the optional_filters dict, we use that
379
            # value but raise a warning if it conflicts with the set filter value.
380
            if self.filterBinaries != optional_filters.get("enabled"):
×
381
                warnings.warn(
×
382
                    f"binary_filter is {optional_filters.get('enabled')} "
383
                    f"but filterBinaries is {self.filterBinaries}. "
384
                    f"Using binary_filter value of {optional_filters.get('enabled')}."
385
                )
386
        else:
387
            default_filters["binary_filter"] = {"enabled": self.filterBinaries}
1✔
388
        # Add the provided optional filters to the default filters, overriding
389
        # the defaults if necessary, and then save the combined dictionary as a
390
        # class attribute
391
        default_filters.update(optional_filters)
1✔
392
        self.optional_filters = default_filters
1✔
393

394
        assert (
1✔
395
            self.massLuminosityRelationship in allowable_massLuminosityRelationships
396
        ), (
397
            "massLuminosityRelationship must be one of: "
398
            f"{','.join(allowable_massLuminosityRelationships)}"
399
        )
400

401
        # list of target names to remove from targetlist
402
        if popStars is not None:
1✔
403
            assert isinstance(popStars, list), "popStars must be a list."
1✔
404
        self.popStars = popStars
1✔
405

406
        # list of target names to keep in targetlist
407
        if cherryPickStars is not None:
1✔
408
            assert isinstance(cherryPickStars, list), "cherryPickStars must be a list."
×
409
        self.cherryPickStars = cherryPickStars
1✔
410

411
        # populate outspec
412
        for att in self.__dict__:
1✔
413
            if att not in ["vprint", "_outspec"]:
1✔
414
                dat = self.__dict__[att]
1✔
415
                self._outspec[att] = dat.value if isinstance(dat, u.Quantity) else dat
1✔
416

417
        # set up stuff for spectral type conversion
418
        # Create a MeanStars object for future use
419
        self.ms = MeanStars()
1✔
420
        # Define a helper dictionary for spectral classes
421
        self.specdict = {"O": 0, "B": 1, "A": 2, "F": 3, "G": 4, "K": 5, "M": 6}
1✔
422
        # figure out what template spectra we have access to
423
        self.load_spectral_catalog()
1✔
424
        # set up standard photometric bands
425
        self.load_standard_bands()
1✔
426
        # Create internal storage for speeding up spectral flux  calculations.
427
        # This dictionary is for storing SourceSpectrum objects (values) by spectral
428
        # types (keys). This will be populated as spectra are loaded.
429
        self.template_spectra = {}
1✔
430
        # This dictionary is for storing target-specific fluxes for observing mode
431
        # bands. keys are mod['hex'] values. values are arrays equal in size to the
432
        # current targetlist. This will be populated as calculations are performed.
433
        self.star_fluxes = {}
1✔
434

435
        # Strucutred the same as star_fluxes, this dictionary holds the exozodi intensity
436
        # values at 1 AU for each star in each observing mode. These values are used to
437
        # generate the exozodi intensity at the planet's distance from the star which is
438
        # then used to calculate the count rate in OpticalSystem.
439
        self.JEZ0 = {}
1✔
440

441
        # get desired module names (specific or prototype) and instantiate objects
442
        self.StarCatalog = get_module(specs["modules"]["StarCatalog"], "StarCatalog")(
1✔
443
            **specs
444
        )
445
        self.OpticalSystem = get_module(
1✔
446
            specs["modules"]["OpticalSystem"], "OpticalSystem"
447
        )(**specs)
448
        self.ZodiacalLight = get_module(
1✔
449
            specs["modules"]["ZodiacalLight"], "ZodiacalLight"
450
        )(**specs)
451
        self.PostProcessing = get_module(
1✔
452
            specs["modules"]["PostProcessing"], "PostProcessing"
453
        )(**specs)
454
        self.Completeness = get_module(
1✔
455
            specs["modules"]["Completeness"], "Completeness"
456
        )(**specs)
457

458
        # bring inherited class objects to top level of Simulated Universe
459
        self.BackgroundSources = self.PostProcessing.BackgroundSources
1✔
460

461
        # if specs contains a completeness_spec then we are going to generate separate
462
        # instances of planet population and planet physical model for completeness
463
        # and for the rest of the sim
464
        if "completeness_specs" in specs:
1✔
465
            self.PlanetPopulation = get_module(
1✔
466
                specs["modules"]["PlanetPopulation"], "PlanetPopulation"
467
            )(**specs)
468
            self.PlanetPhysicalModel = self.PlanetPopulation.PlanetPhysicalModel
1✔
469
        else:
470
            self.PlanetPopulation = self.Completeness.PlanetPopulation
1✔
471
            self.PlanetPhysicalModel = self.Completeness.PlanetPhysicalModel
1✔
472

473
        # identify the observingMode to use for target filtering
474
        detmode = list(
1✔
475
            filter(
476
                lambda mode: mode["detectionMode"], self.OpticalSystem.observingModes
477
            )
478
        )[0]
479
        if self.filter_for_char or self.earths_only:
1✔
480
            mode = list(
×
481
                filter(
482
                    lambda mode: "spec" in mode["inst"]["name"],
483
                    self.OpticalSystem.observingModes,
484
                )
485
            )[0]
486
            self.calc_char_int_comp = True
×
487
        else:
488
            mode = detmode
1✔
489
            self.calc_char_int_comp = False
1✔
490
        self.filter_mode = mode
1✔
491

492
        # Define int_WA if None provided
493
        if int_WA is None:
1✔
494
            int_WA = (
1✔
495
                2.0 * self.filter_mode["IWA"]
496
                if np.isinf(self.filter_mode["OWA"])
497
                else (self.filter_mode["IWA"] + self.filter_mode["OWA"]) / 2.0
498
            )
499
            int_WA = int_WA.to("arcsec")
1✔
500

501
        # Save the dMag and WA values used to calculate integration time
502
        self.int_dMag = np.array(int_dMag, dtype=float, ndmin=1)
1✔
503
        self.int_WA = np.array(int_WA, dtype=float, ndmin=1) * u.arcsec
1✔
504

505
        # set Star Catalog attributes
506
        self.set_catalog_attributes()
1✔
507

508
        # bring in StarCatalog attributes into TargetList
509
        self.populate_target_list(**specs)
1✔
510
        if self.explainFiltering:
1✔
511
            print("%d targets imported from star catalog." % self.nStars)
×
512

513
        # Initialize system_fbeta, will be overwritten by calc_all_JEZ0
514
        # but is needed for revise_lists()
515
        self.system_fbeta = np.ones(self.nStars)
1✔
516

517
        # remove any requested stars from TargetList
518
        if self.popStars is not None:
1✔
519
            keep = np.ones(self.nStars, dtype=bool)
1✔
520
            for n in self.popStars:
1✔
521
                keep[self.Name == n] = False
1✔
522

523
            self.revise_lists(np.where(keep)[0])
1✔
524

525
            if self.explainFiltering:
1✔
526
                print(
×
527
                    "%d targets remain after removing requested targets." % self.nStars
528
                )
529

530
        # if cherry-picking stars, filter out all the rest
531
        if self.cherryPickStars is not None:
1✔
532
            keep = np.zeros(self.nStars, dtype=bool)
×
533

534
            for n in self.cherryPickStars:
×
535
                keep[self.Name == n] = True
×
536

537
            self.revise_lists(np.where(keep)[0])
×
538

539
            if self.explainFiltering:
×
540
                print("%d targets remain after cherry-picking targets." % self.nStars)
×
541

542
        # populate spectral types and, if requested, attempt to fill in any crucial
543
        # missing bits of information
544
        self.fillPhotometryVals()
1✔
545

546
        # filter out nan attribute values from Star Catalog
547
        self.nan_filter()
1✔
548
        if self.explainFiltering:
1✔
549
            print("%d targets remain after nan filtering." % self.nStars)
×
550

551
        # filter out target stars with 0 luminosity
552
        self.zero_lum_filter()
1✔
553
        if self.explainFiltering:
1✔
554
            print(
×
555
                "%d targets remain after removing zero luminosity targets."
556
                % self.nStars
557
            )
558

559
        # compute stellar effective temperatures as needed
560
        self.stellar_Teff()
1✔
561
        # calculate 'true' and 'approximate' stellar masses and radii
562
        self.stellar_mass()
1✔
563
        self.stellar_diameter()
1✔
564
        # Calculate Star System Inclinations
565
        self.systemInclination = self.gen_inclinations(self.PlanetPopulation.Irange)
1✔
566
        self.catalog_atts.append("systemInclination")
1✔
567

568
        # Calculate common Star System longitude of the ascending node
569
        self.systemOmega = self.gen_Omegas(self.PlanetPopulation.Orange)
1✔
570

571
        # create placeholder array black-body spectra
572
        # (only filled if any modes require it)
573
        self.blackbody_spectra = np.ndarray((self.nStars), dtype=object)
1✔
574
        self.catalog_atts.append("blackbody_spectra")
1✔
575

576
        # Compute all the JEZ0 values for each star in each observing mode
577
        self.calc_all_JEZ0()
1✔
578

579
        # Apply optional filters that don't depend on completeness
580
        secondary_filter_names = ["completeness_filter"]
1✔
581
        first_filter_set = {
1✔
582
            key: self.optional_filters.get(key, {})
583
            for key in self.optional_filters.keys()
584
            if key not in secondary_filter_names
585
        }
586
        self.filter_target_list(first_filter_set)
1✔
587

588
        # Calculate saturation and intCutoff delta mags and completeness values
589
        self.calc_saturation_and_intCutoff_vals()
1✔
590

591
        # populate completeness values
592
        self.int_comp = self.Completeness.target_completeness(self)
1✔
593
        self.catalog_atts.append("int_comp")
1✔
594

595
        # generate any completeness update data needed
596
        self.Completeness.gen_update(self)
1✔
597

598
        # apply any requested additional filters that depend on completeness
599
        second_filter_set = {
1✔
600
            key: self.optional_filters.get(key, {}) for key in secondary_filter_names
601
        }
602
        self.filter_target_list(second_filter_set)
1✔
603

604
        # if we're doing filter_for_char, then that means that we haven't computed the
605
        # star fluxes for the detection mode.  Let's do that now (post-filtering to
606
        # limit the number of calculations
607
        if self.filter_for_char or self.earths_only:
1✔
608
            fname = (
×
609
                f"TargetList_{self.StarCatalogHex}_"
610
                f"nStars_{self.nStars}_mode_{detmode['hex']}.star_fluxes"
611
            )
612
            star_flux_path = Path(self.cachedir, fname)
×
613
            if star_flux_path.exists():
×
614
                with open(star_flux_path, "rb") as f:
×
615
                    self.star_fluxes[detmode["hex"]] = pickle.load(f)
×
616
                self.vprint(f"Loaded star fluxes values from {star_flux_path}")
×
617
            else:
618
                _ = self.starFlux(np.arange(self.nStars), detmode)
×
619
                with open(star_flux_path, "wb") as f:
×
620
                    pickle.dump(self.star_fluxes[detmode["hex"]], f)
×
621
                    self.vprint(f"Star fluxes stored in {star_flux_path}")
×
622

623
            # remove any zero-flux vals
624
            if np.any(self.star_fluxes[detmode["hex"]].value == 0):
×
625
                keepinds = np.where(self.star_fluxes[detmode["hex"]].value != 0)[0]
×
626
                self.revise_lists(keepinds)
×
627
                if self.explainFiltering:
×
628
                    print(
×
629
                        (
630
                            "{} targets remain after removing those with zero flux. "
631
                        ).format(self.nStars)
632
                    )
633

634
        # get target system information from exopoanet archive if requested
635
        if self.getKnownPlanets:
1✔
636
            self.queryNEAsystems()
×
637
        else:
638
            self.hasKnownPlanet = np.zeros(self.nStars, dtype=bool)
1✔
639
            self.catalog_atts.append("hasKnownPlanet")
1✔
640

641
        # add nStars to outspec (this is a rare exception to not allowing extraneous
642
        # information into the outspec).
643
        self._outspec["nStars"] = self.nStars
1✔
644

645
        # if staticStars is True, the star coordinates are taken at mission start,
646
        # and are not propagated during the mission
647
        # TODO: This is barely legible. Replace with class method.
648
        self.starprop_static = None
1✔
649
        if self.staticStars is True:
1✔
650
            allInds = np.arange(self.nStars, dtype=int)
1✔
651
            missionStart = Time(float(missionStart), format="mjd", scale="tai")
1✔
652
            self.starprop_static = (
1✔
653
                lambda sInds, currentTime, eclip=False, c1=self.starprop(
654
                    allInds, missionStart, eclip=False
655
                ), c2=self.starprop(allInds, missionStart, eclip=True): (
656
                    c1[sInds] if not (eclip) else c2[sInds]  # noqa: E275
657
                )
658
            )
659

660
    def __str__(self):
1✔
661
        """String representation of the Target List object
662

663
        When the command 'print' is used on the Target List object, this method
664
        will return the values contained in the object
665

666
        """
667

668
        for att in self.__dict__:
1✔
669
            print("%s: %r" % (att, getattr(self, att)))
1✔
670

671
        return "Target List class object attributes"
1✔
672

673
    def load_spectral_catalog(self):
1✔
674
        """Helper method for generating a cache of available template spectra and
675
        loading them as attributes
676

677
        Creates the following attributes:
678

679
        #. ``spectral_catalog_index``: A dictionary of spectral types (keys) and the
680
           associated spectra files on disk (values)
681
        #. ``spectral_catalog_types``: An nx4 ndarray (n is the number of teplate
682
           spectra avaiable). First three columns are spectral class (str),
683
           subclass (int), and luinosity class (str). The fourth column is a spectral
684
           class numeric representation, equaling specdict[specclass]*10 + subclass.
685

686
        """
687
        spectral_catalog_cache = Path(self.cachedir, "spectral_catalog.pkl")
1✔
688
        if spectral_catalog_cache.exists():
1✔
689
            with open(spectral_catalog_cache, "rb") as f:
1✔
690
                tmp = pickle.load(f)
1✔
691
                self.spectral_catalog_index = tmp["spectral_catalog_index"]
1✔
692
                self.spectral_catalog_types = tmp["spectral_catalog_types"]
1✔
693
        else:
694
            # Find data locations on disk and ensure that they're there
695
            pickles_path = os.path.join(
1✔
696
                importlib.resources.files("EXOSIMS.TargetList"), "dat_uvk"
697
            )
698

699
            bpgs_path = os.path.join(
1✔
700
                importlib.resources.files("EXOSIMS.TargetList"), "bpgs"
701
            )
702

703
            spectral_catalog_file = os.path.join(
1✔
704
                importlib.resources.files("EXOSIMS.TargetList"),
705
                "spectral_catalog_index.json",
706
            )
707

708
            assert os.path.isdir(
1✔
709
                pickles_path
710
            ), f"Pickles Atlas path {pickles_path} does not appear to be a directory."
711
            assert os.path.isdir(
1✔
712
                bpgs_path
713
            ), f"BPGS Atlas path {bpgs_path} does not appear to be a directory."
714
            assert os.path.exists(
1✔
715
                spectral_catalog_file
716
            ), f"Spectral catalog index file {spectral_catalog_file} not found."
717

718
            # grab original spectral catalog index
719
            with open(spectral_catalog_file, "r") as f:
1✔
720
                spectral_catalog = json.load(f)
1✔
721

722
            # assign system-specific paths and repackage catalog info for easier
723
            # accessiblity downstream
724
            spectral_catalog_index = {}
1✔
725
            spectral_catalog_types = np.zeros((len(spectral_catalog), 4), dtype=object)
1✔
726

727
            for j, s in enumerate(spectral_catalog):
1✔
728
                if spectral_catalog[s]["file"].startswith("pickles"):
1✔
729
                    spectral_catalog_index[s] = os.path.join(
1✔
730
                        pickles_path, spectral_catalog[s]["file"]
731
                    )
732
                else:
733
                    spectral_catalog_index[s] = os.path.join(
1✔
734
                        bpgs_path, spectral_catalog[s]["file"]
735
                    )
736
                assert os.path.exists(spectral_catalog_index[s])
1✔
737

738
                spectral_catalog_types[j] = spectral_catalog[s]["specclass"]
1✔
739

740
            # cache the system-specific values for future use
741
            with open(spectral_catalog_cache, "wb") as f:
1✔
742
                pickle.dump(
1✔
743
                    {
744
                        "spectral_catalog_index": spectral_catalog_index,
745
                        "spectral_catalog_types": spectral_catalog_types,
746
                    },
747
                    f,
748
                )
749

750
            # now assign catalog values as class attributes
751
            self.spectral_catalog_index = spectral_catalog_index
1✔
752
            self.spectral_catalog_types = spectral_catalog_types
1✔
753

754
    def get_template_spectrum(self, spec):
1✔
755
        """Helper method for loading/retrieving spectra from the spectral catalog
756

757
        Args:
758
            spec (str):
759
                Spectral type string. Must be a keys in self.spectral_catalog_index
760
        Returns:
761
            synphot.SourceSpectrum:
762
                Template pectrum from file.
763
        """
764

765
        if spec not in self.template_spectra:
1✔
766
            self.template_spectra[spec] = SourceSpectrum.from_file(
1✔
767
                self.spectral_catalog_index[spec]
768
            )
769

770
        return self.template_spectra[spec]
1✔
771

772
    def load_standard_bands(self):
1✔
773
        """Helper method that defines standard photometric bandpasses
774

775
        This method defines the following class attributes:
776

777
        #. ``standard_bands_letters``: String with standard band letters
778
           (nominally UVBRI)
779
        #. ``standard_bands``: A dictionary (key of band letter) whose values are
780
           synphot SpectralElement objects for that bandpass.
781
        #. ``standard_bands_lam``: An array of band central wavelengths (same order as
782
           standard_bands_letters
783
        #. ``standard_bands_deltaLam``: An array of band bandwidths (same order as
784
           standard_bands_letters.
785

786
        """
787

788
        band_letters = "UBVRIJHK"
1✔
789
        band_file_names = [
1✔
790
            "johnson_u",
791
            "johnson_b",
792
            "johnson_v",
793
            "cousins_r",
794
            "cousins_i",
795
            "bessel_j",
796
            "bessel_h",
797
            "bessel_k",
798
        ]
799
        self.standard_bands = {}
1✔
800
        for b, bf in zip(band_letters, band_file_names):
1✔
801
            self.standard_bands[b] = SpectralElement.from_filter(bf)
1✔
802

803
        self.standard_bands_lam = (
1✔
804
            np.array(
805
                [
806
                    self.standard_bands[b].avgwave().to(u.nm).value
807
                    for b in self.standard_bands
808
                ]
809
            )
810
            * u.nm
811
        )
812
        self.standard_bands_deltaLam = (
1✔
813
            np.array(
814
                [
815
                    self.standard_bands[b].rectwidth().to(u.nm).value
816
                    for b in self.standard_bands
817
                ]
818
            )
819
            * u.nm
820
        )
821
        self.standard_bands_letters = band_letters
1✔
822

823
    def set_catalog_attributes(self):
1✔
824
        """Hepler method that sets possible and required catalog attributes.
825

826
        Sets attributes:
827
            catalog_atts (list):
828
                Attributes to try to copy from star catalog.  Missing ones will be
829
                ignored and removed from this list.
830
            required_catalog_atts(list):
831
                Attributes that cannot be missing or nan.
832

833
        .. note::
834

835
            This is a separate method primarily for downstream implementations that wish
836
            to modify the catalog attributes.  Overloaded methods can first call this
837
            method to get the base values, or overwrite them entirely.
838

839
        """
840

841
        # required catalog attributes for the Prototype
842
        self.required_catalog_atts = [
1✔
843
            "Name",
844
            "Vmag",
845
            "BV",
846
            "MV",
847
            "BC",
848
            "L",
849
            "coords",
850
            "dist",
851
            "pmra",
852
            "pmdec",
853
            "rv",
854
            "Binary_Cut",
855
            "Spec",
856
            "parx",
857
        ]
858

859
        # generate list of possible Star Catalog attributes. If the StarCatalog provides
860
        # the list, use that.
861
        if hasattr(self.StarCatalog, "catalog_atts"):
1✔
862
            self.catalog_atts = copy.copy(self.StarCatalog.catalog_atts)
1✔
863
        else:
864
            self.catalog_atts = [
×
865
                "Name",
866
                "Spec",
867
                "parx",
868
                "Umag",
869
                "Bmag",
870
                "Vmag",
871
                "Rmag",
872
                "Imag",
873
                "Jmag",
874
                "Hmag",
875
                "Kmag",
876
                "dist",
877
                "BV",
878
                "MV",
879
                "BC",
880
                "L",
881
                "coords",
882
                "pmra",
883
                "pmdec",
884
                "rv",
885
                "Binary_Cut",
886
                "closesep",
887
                "closedm",
888
                "brightsep",
889
                "brightdm",
890
            ]
891

892
        # ensure that the catalog will provide our required attributes
893
        tmp = list(set(self.required_catalog_atts) - set(self.catalog_atts))
1✔
894
        assert len(tmp) == 0, (
1✔
895
            f"Star catalog {self.StarCatalog.__class__.__name__} "
896
            "does not provide required attribute(s): "
897
            f"{' ,'.join(tmp)}"
898
        )
899

900
    def populate_target_list(self, **specs):
1✔
901
        """This function is responsible for populating values from the star
902
        catalog into the target list attributes and enforcing attribute requirements.
903

904

905
        Args:
906
            **specs:
907
                :ref:`sec:inputspec`
908

909
        """
910
        SC = self.StarCatalog
1✔
911

912
        # bring Star Catalog values to top level of Target List
913
        missingatts = []
1✔
914
        for att in self.catalog_atts:
1✔
915
            if not hasattr(SC, att):
1✔
916
                assert (
×
917
                    att not in self.required_catalog_atts
918
                ), f"Star catalog attribute {att} is missing but listed as required."
919
                missingatts.append(att)
×
920
            else:
921
                if isinstance(getattr(SC, att), np.ma.core.MaskedArray):
1✔
922
                    setattr(self, att, getattr(SC, att).filled(fill_value=float("nan")))
×
923
                else:
924
                    setattr(self, att, getattr(SC, att))
1✔
925
        for att in missingatts:
1✔
926
            self.catalog_atts.remove(att)
×
927

928
        # number of target stars
929
        self.nStars = len(SC.Name)
1✔
930

931
        # add catalog _outspec to our own
932
        self._outspec.update(SC._outspec)
1✔
933

934
        # generate unique hash for the imported starcatalog
935
        self.StarCatalogHex = self.genStarCatalogHex()
1✔
936

937
    def genStarCatalogHex(self):
1✔
938
        """Generate unique string representationg of StarCatalog contents based on
939
        currently loaded values and attributes in ``self.catalog_atts``.
940

941
        Returns:
942
            str:
943
                Unique hash of StarCatalog contents
944

945
        """
946
        starcatstr = ""
1✔
947
        for att in self.catalog_atts:
1✔
948
            tmp = getattr(self, att)
1✔
949
            if tmp.size != 0:
1✔
950
                starcatstr = f"{starcatstr}_{att}_{tmp}"
1✔
951

952
        return f"{self.StarCatalog.__class__.__name__}_{genHexStr(starcatstr)}"
1✔
953

954
    def calc_saturation_and_intCutoff_vals(self):
1✔
955
        """
956
        Calculates the saturation and integration cutoff time dMag and
957
        completeness values, saves them as attributes, refines the dMag used to
958
        calculate integration times so it does not exceed the integration
959
        cutoff time dMag, and handles any orbit scaling necessary
960
        """
961

962
        # pad out int_WA and int_dMag to size of targetlist, as needed
963
        if len(self.int_WA) == 1:
1✔
964
            self.int_WA = np.repeat(self.int_WA, self.nStars)
1✔
965
        if len(self.int_dMag) == 1:
1✔
966
            self.int_dMag = np.repeat(self.int_dMag, self.nStars)
1✔
967
        # add these to the target list catalog attributes
968
        self.catalog_atts.append("int_dMag")
1✔
969
        self.catalog_atts.append("int_WA")
1✔
970

971
        # grab required modules and determine which observing mode to use
972
        # also populate inputs for calculations
973
        OS = self.OpticalSystem
1✔
974
        ZL = self.ZodiacalLight
1✔
975
        PPop = self.PlanetPopulation
1✔
976
        Comp = self.Completeness
1✔
977

978
        # grab zodi vals for any required calculations
979
        sInds = np.arange(self.nStars)
1✔
980
        fZminglobal = ZL.global_zodi_min(self.filter_mode)
1✔
981
        fZ = np.repeat(fZminglobal, len(sInds))
1✔
982
        JEZ0 = self.JEZ0[self.filter_mode["hex"]]
1✔
983

984
        # compute proj separation bounds for any required calculations
985
        if PPop.scaleOrbits:
1✔
986
            tmp_smin = np.tan(self.filter_mode["IWA"]) * self.dist / np.sqrt(self.L)
1✔
987
            if np.isinf(self.filter_mode["OWA"]):
1✔
988
                tmp_smax = np.inf * self.dist
×
989
            else:
990
                tmp_smax = np.tan(self.filter_mode["OWA"]) * self.dist / np.sqrt(self.L)
1✔
991
        else:
992
            tmp_smin = np.tan(self.filter_mode["IWA"]) * self.dist
1✔
993
            if np.isinf(self.filter_mode["OWA"]):
1✔
994
                tmp_smax = np.inf * self.dist
×
995
            else:
996
                tmp_smax = np.tan(self.filter_mode["OWA"]) * self.dist
1✔
997

998
        # 0. Regardless of whatever else we do, we're going to need stellar fluxes in
999
        # the relevant observing mode.  So let's just compute them now and cache them
1000
        # for later use.
1001
        fname = (
1✔
1002
            f"TargetList_{self.StarCatalogHex}_"
1003
            f"nStars_{self.nStars}_mode_{self.filter_mode['hex']}.star_fluxes"
1004
        )
1005
        star_flux_path = Path(self.cachedir, fname)
1✔
1006
        if star_flux_path.exists():
1✔
1007
            with open(star_flux_path, "rb") as f:
1✔
1008
                self.star_fluxes = pickle.load(f)
1✔
1009
            self.vprint(f"Loaded star fluxes values from {star_flux_path}")
1✔
1010
        else:
1011
            _ = self.starFlux(np.arange(self.nStars), self.filter_mode)
1✔
1012
            with open(star_flux_path, "wb") as f:
1✔
1013
                pickle.dump(self.star_fluxes, f)
1✔
1014
                self.vprint(f"Star fluxes stored in {star_flux_path}")
1✔
1015

1016
        # remove any zero-flux vals
1017
        if np.any(self.star_fluxes[self.filter_mode["hex"]].value == 0):
1✔
1018
            keepinds = np.where(self.star_fluxes[self.filter_mode["hex"]].value != 0)[0]
×
1019
            self.revise_lists(keepinds)
×
1020
            sInds = np.arange(self.nStars)
×
1021
            tmp_smin = tmp_smin[keepinds]
×
1022
            tmp_smax = tmp_smax[keepinds]
×
1023
            fZ = fZ[keepinds]
×
1024
            JEZ0 = JEZ0[keepinds]
×
1025
            if self.explainFiltering:
×
1026
                print(
×
1027
                    ("{} targets remain after removing those with zero flux. ").format(
1028
                        self.nStars
1029
                    )
1030
                )
1031

1032
        # 1. Calculate the saturation dMag. This is stricly a function of
1033
        # fZminglobal, ZL.fEZ0, self.int_WA, mode, the current targetlist,
1034
        # and the postprocessing factor
1035
        zodi_vals_str = f"{str(ZL.global_zodi_min(self.filter_mode))} {str(ZL.fEZ0)}"
1✔
1036
        stars_str = (
1✔
1037
            f"ppFact:{self.PostProcessing._outspec['ppFact']}, "
1038
            f"ppFact_char:{self.PostProcessing._outspec['ppFact_char']}, "
1039
            f"stabilityFact:{self.OpticalSystem.stabilityFact}, "
1040
            f"fillPhotometry:{self.fillPhotometry}, "
1041
            f"fillMissingBandMags:{self.fillMissingBandMags}"
1042
        )
1043
        stars_str = f"{stars_str}, {','.join(self.Name)}"
1✔
1044
        int_WA_str = ",".join(self.int_WA.value.astype(str)) + str(self.int_WA.unit)
1✔
1045

1046
        # cache filename is the three class names, the vals hash, and the mode hash
1047
        vals_hash = genHexStr(zodi_vals_str + stars_str + int_WA_str)
1✔
1048
        fname = (
1✔
1049
            f"TargetList_{self.StarCatalogHex}_"
1050
            f"{OS.__class__.__name__}_{ZL.__class__.__name__}_"
1051
            f"vals_{vals_hash}_mode_{self.filter_mode['hex']}"
1052
        )
1053

1054
        saturation_dMag_path = Path(self.cachedir, f"{fname}.sat_dMag")
1✔
1055
        if saturation_dMag_path.exists():
1✔
1056
            with open(saturation_dMag_path, "rb") as f:
1✔
1057
                self.saturation_dMag = pickle.load(f)
1✔
1058
            self.vprint(f"Loaded saturation_dMag values from {saturation_dMag_path}")
1✔
1059
        else:
1060
            if self.skipSaturationCalcs:
1✔
1061
                self.saturation_dMag = np.zeros(self.nStars) * np.nan
1✔
1062
            else:
1063
                self.saturation_dMag = OS.calc_saturation_dMag(
1✔
1064
                    self, sInds, fZ, JEZ0, self.int_WA, self.filter_mode, TK=None
1065
                )
1066

1067
                with open(saturation_dMag_path, "wb") as f:
1✔
1068
                    pickle.dump(self.saturation_dMag, f)
1✔
1069
                self.vprint(f"saturation_dMag values stored in {saturation_dMag_path}")
1✔
1070

1071
        # 2. Calculate the completeness value if the star is integrated for an
1072
        # infinite time by using the saturation dMag
1073
        if PPop.scaleOrbits:
1✔
1074
            tmp_dMag = self.saturation_dMag - 2.5 * np.log10(self.L)
1✔
1075
        else:
1076
            tmp_dMag = self.saturation_dMag
1✔
1077

1078
        # cache filename is the two class names and the vals hash
1079
        satcomp_valstr = (
1✔
1080
            ",".join(tmp_smin.to(u.AU).value.astype(str))
1081
            + ",".join(tmp_smax.to(u.AU).value.astype(str))
1082
            + ",".join(tmp_dMag.astype(str))
1083
        )
1084

1085
        vals_hash = genHexStr(stars_str + satcomp_valstr)
1✔
1086
        fname = (
1✔
1087
            f"TargetList_{self.StarCatalogHex}_"
1088
            f"{Comp.__class__.__name__}_vals_{vals_hash}"
1089
        )
1090

1091
        # calculate or load from disk if cache exists
1092
        saturation_comp_path = Path(self.cachedir, f"{fname}.sat_comp")
1✔
1093
        if saturation_comp_path.exists():
1✔
1094
            with open(saturation_comp_path, "rb") as f:
1✔
1095
                self.saturation_comp = pickle.load(f)
1✔
1096
            self.vprint(f"Loaded saturation_comp values from {saturation_comp_path}")
1✔
1097
        else:
1098
            if self.skipSaturationCalcs:
1✔
1099
                self.saturation_comp = np.zeros(self.nStars) * np.nan
1✔
1100
            else:
1101
                self.vprint("Calculating the saturation time completeness")
1✔
1102
                self.saturation_comp = Comp.comp_calc(
1✔
1103
                    tmp_smin.to(u.AU).value, tmp_smax.to(u.AU).value, tmp_dMag
1104
                )
1105
                with open(saturation_comp_path, "wb") as f:
1✔
1106
                    pickle.dump(self.saturation_comp, f)
1✔
1107
                self.vprint(f"saturation_comp values stored in {saturation_comp_path}")
1✔
1108

1109
        # 3. Find limiting dMag for intCutoff time. This is stricly a function of
1110
        # OS.intCutoff, fZminglobal, ZL.fEZ0, self.int_WA, mode, and the current
1111
        # targetlist
1112
        vals_hash = genHexStr(
1✔
1113
            f"{OS.intCutoff} " + zodi_vals_str + stars_str + int_WA_str
1114
        )
1115
        fname = (
1✔
1116
            f"TargetList_{self.StarCatalogHex}_"
1117
            f"{OS.__class__.__name__}_{ZL.__class__.__name__}_"
1118
            f"vals_{vals_hash}_mode_{self.filter_mode['hex']}"
1119
        )
1120

1121
        intCutoff_dMag_path = Path(self.cachedir, f"{fname}.intCutoff_dMag")
1✔
1122
        if intCutoff_dMag_path.exists():
1✔
1123
            with open(intCutoff_dMag_path, "rb") as f:
1✔
1124
                self.intCutoff_dMag = pickle.load(f)
1✔
1125
            self.vprint(f"Loaded intCutoff_dMag values from {intCutoff_dMag_path}")
1✔
1126
        else:
1127
            self.vprint("Calculating intCutoff_dMag")
1✔
1128
            intTimes = np.repeat(OS.intCutoff.value, len(sInds)) * OS.intCutoff.unit
1✔
1129

1130
            self.intCutoff_dMag = OS.calc_dMag_per_intTime(
1✔
1131
                intTimes, self, sInds, fZ, JEZ0, self.int_WA, self.filter_mode
1132
            ).reshape((len(intTimes),))
1133
            with open(intCutoff_dMag_path, "wb") as f:
1✔
1134
                pickle.dump(self.intCutoff_dMag, f)
1✔
1135
            self.vprint(f"intCutoff_dMag values stored in {intCutoff_dMag_path}")
1✔
1136

1137
        # 4. Calculate intCutoff completeness. This is a function of the exact same
1138
        # things as the previous calculation, so we can recycle the filename
1139
        if PPop.scaleOrbits:
1✔
1140
            tmp_dMag = self.intCutoff_dMag - 2.5 * np.log10(self.L)
1✔
1141
        else:
1142
            tmp_dMag = self.intCutoff_dMag
1✔
1143

1144
        # cache filename is the two class names and the vals hash
1145
        intcutoffcomp_valstr = (
1✔
1146
            ",".join(tmp_smin.to(u.AU).value.astype(str))
1147
            + ",".join(tmp_smax.to(u.AU).value.astype(str))
1148
            + ",".join(tmp_dMag.astype(str))
1149
        )
1150

1151
        vals_hash = genHexStr(stars_str + intcutoffcomp_valstr)
1✔
1152
        fname = (
1✔
1153
            f"TargetList_{self.StarCatalogHex}_"
1154
            f"{Comp.__class__.__name__}_vals_{vals_hash}"
1155
        )
1156

1157
        intCutoff_comp_path = Path(self.cachedir, f"{fname}.intCutoff_comp")
1✔
1158
        if intCutoff_comp_path.exists():
1✔
1159
            with open(intCutoff_comp_path, "rb") as f:
1✔
1160
                self.intCutoff_comp = pickle.load(f)
1✔
1161
            self.vprint(f"Loaded intCutoff_comp values from {intCutoff_comp_path}")
1✔
1162
        else:
1163
            self.vprint("Calculating the integration cutoff time completeness")
1✔
1164
            self.intCutoff_comp = Comp.comp_calc(
1✔
1165
                tmp_smin.to(u.AU).value, tmp_smax.to(u.AU).value, tmp_dMag
1166
            )
1167
            with open(intCutoff_comp_path, "wb") as f:
1✔
1168
                pickle.dump(self.intCutoff_comp, f)
1✔
1169
            self.vprint(f"intCutoff_comp values stored in {intCutoff_comp_path}")
1✔
1170

1171
        # Refine int_dMag
1172
        if len(self.int_dMag) == 1:
1✔
1173
            self._outspec["int_dMag"] = self.int_dMag[0]
1✔
1174
            self.int_dMag = np.array([self.int_dMag[0]] * self.nStars)
1✔
1175
        else:
1176
            assert (
1✔
1177
                len(self.int_dMag) == self.nStars
1178
            ), "Input int_dMag array doesn't match number of target stars."
1179
            self._outspec["int_dMag"] = self.int_dMag
1✔
1180

1181
        if len(self.int_WA) == 1:
1✔
1182
            self._outspec["int_WA"] = self.int_WA[0].to("arcsec").value
1✔
1183
            self.int_WA = (
1✔
1184
                np.array([self.int_WA[0].value] * self.nStars) * self.int_WA.unit
1185
            )
1186
        else:
1187
            assert (
1✔
1188
                len(self.int_WA) == self.nStars
1189
            ), "Input int_WA array doesn't match number of target stars."
1190
            self._outspec["int_WA"] = self.int_WA.to("arcsec").value
1✔
1191

1192
        if self.scaleWAdMag:
1✔
1193
            # the goal of this is to make these values match the earthlike pdf
1194
            # used to calculate completness, which scales with luminosity
1195
            self.int_WA = ((np.sqrt(self.L) * u.AU / self.dist).decompose() * u.rad).to(
×
1196
                u.arcsec
1197
            )
1198
            self.int_WA[np.where(self.int_WA > self.filter_mode["OWA"])[0]] = (
×
1199
                self.filter_mode["OWA"] * (1.0 - 1e-14)
1200
            )
1201
            self.int_WA[np.where(self.int_WA < self.filter_mode["IWA"])[0]] = (
×
1202
                self.filter_mode["IWA"] * (1.0 + 1e-14)
1203
            )
1204
            self.int_dMag = self.int_dMag + 2.5 * np.log10(self.L)
×
1205

1206
        # Go through the int_dMag values and replace with limiting dMag where
1207
        # int_dMag is higher. Since the int_dMag will never be reached if
1208
        # intCutoff_dMag is below it
1209
        for i, int_dMag_val in enumerate(self.int_dMag):
1✔
1210
            if int_dMag_val > self.intCutoff_dMag[i]:
1✔
1211
                self.int_dMag[i] = self.intCutoff_dMag[i]
1✔
1212

1213
        # Finally, compute the nominal integration time at minimum zodi
1214
        self.int_tmin = self.OpticalSystem.calc_intTime(
1✔
1215
            self, sInds, fZ, JEZ0, self.int_dMag, self.int_WA, self.filter_mode
1216
        )
1217

1218
        # update catalog attributes for any future filtering
1219
        self.catalog_atts.append("intCutoff_dMag")
1✔
1220
        self.catalog_atts.append("intCutoff_comp")
1✔
1221
        self.catalog_atts.append("saturation_dMag")
1✔
1222
        self.catalog_atts.append("saturation_comp")
1✔
1223
        self.catalog_atts.append("int_tmin")
1✔
1224

1225
    def fillPhotometryVals(self):
1✔
1226
        """
1227
        Attempts to determine the spectral class and luminosity class of each
1228
        target star. If ``self.fillPhotometry`` is True, attempts to reconstruct any
1229
        missing spectral types from other avaialable information, and then fill
1230
        missing L, B, V and BC information from spectral type.
1231

1232
        Uses the MeanStars object for regexps and data filling. This explicitly treats
1233
        all stars as dwarfs. TODO: Update to use spectra for other luminosity classes.
1234

1235
        The data is from:
1236
        "A Modern Mean Dwarf Stellar Color and Effective Temperature Sequence"
1237
        http://www.pas.rochester.edu/~emamajek/EEM_dwarf_UBVIJHK_colors_Teff.txt
1238
        Eric Mamajek (JPL/Caltech, University of Rochester)
1239

1240
        See MeanStars documentation for futher details.
1241

1242
        TODO: only use MeanStars for dwarfs. Otherwise use spectra.
1243

1244
        """
1245

1246
        # first let's try to establish the spectral type
1247
        self.spectral_class = np.ndarray((self.nStars, 3), dtype=object)
1✔
1248
        with warnings.catch_warnings():
1✔
1249
            warnings.filterwarnings("ignore")
1✔
1250
            for j, s in enumerate(self.Spec):
1✔
1251
                tmp = self.ms.matchSpecType(s)
1✔
1252
                if tmp:
1✔
1253
                    self.spectral_class[j] = tmp
1✔
1254
                elif self.fillPhotometry:
1✔
1255
                    # if we have a luminosity, try to reconstruct from that
1256
                    # otherwise try for B-V color
1257
                    if not (np.isnan(self.L[j])) and (self.L[j] != 0):
×
1258
                        ind = self.ms.tableLookup("logL", np.log10(self.L[j]))
×
1259
                        self.spectral_class[j] = self.ms.MK[ind], self.ms.MKn[ind], "V"
×
1260
                    elif not (np.isnan(self.BV[j])) and (self.BV[j] != 0):
×
1261
                        try:
×
1262
                            ind = self.ms.tableLookup("B-V", self.BV[j])
×
1263
                            self.spectral_class[j] = (
×
1264
                                self.ms.MK[ind],
1265
                                self.ms.MKn[ind],
1266
                                "V",
1267
                            )
1268
                        except ValueError:
×
1269
                            self.spectral_class[j] = "", np.nan, ""
×
1270
                    else:
1271
                        self.spectral_class[j] = "", np.nan, ""
×
1272
                else:
1273
                    self.spectral_class[j] = "", np.nan, ""
1✔
1274

1275
        self.catalog_atts.append("spectral_class")
1✔
1276
        self.revise_lists(np.where(self.spectral_class[:, 0] != "")[0])
1✔
1277
        if self.explainFiltering:
1✔
1278
            print(
×
1279
                (
1280
                    "{} targets remain after removing those where spectral class "
1281
                    "cannot be established."
1282
                ).format(self.nStars)
1283
            )
1284

1285
        # remove all subdwarfs and white-dwarfs
1286
        sInds = np.array(
1✔
1287
            [
1288
                j
1289
                for j in range(self.nStars)
1290
                if (
1291
                    (self.spectral_class[j, 0] in "OBAFGKM")
1292
                    and (self.spectral_class[j, 2] not in ["VI", "VII"])
1293
                )
1294
            ]
1295
        )
1296
        self.revise_lists(sInds)
1✔
1297
        if self.explainFiltering:
1✔
1298
            print(
×
1299
                ("{} targets remain after removing white dwarfs and subdwarfs").format(
1300
                    self.nStars
1301
                )
1302
            )
1303

1304
        # Update all spectral strings to their normalized values
1305
        self.Spec = np.array(
1✔
1306
            [f"{s[0]}{int(np.round(s[1]))}{s[2]}" for s in self.spectral_class]
1307
        )
1308

1309
        # Add fourth column to spectral_class with the numerical class value
1310
        # defined as specdict[specclass] * 10 + specsubclass
1311
        spectypenum = [
1✔
1312
            self.specdict[c] * 10 + sc
1313
            for c, sc in zip(self.spectral_class[:, 0], self.spectral_class[:, 1])
1314
        ]
1315
        self.spectral_class = np.hstack(
1✔
1316
            (self.spectral_class, np.array(spectypenum, ndmin=2).transpose())
1317
        )
1318

1319
        # if we don't need to fill photometry values, we're done here
1320
        if not (self.fillPhotometry):
1✔
1321
            return
1✔
1322

1323
        # first check on absolute V mags
1324
        if np.all(self.MV == 0):
1✔
1325
            self.MV *= np.nan
1✔
1326
        if np.all(self.Vmag == 0):
1✔
1327
            self.Vmag *= np.nan
×
1328
        if np.any(np.isnan(self.MV)):
1✔
1329
            inds = np.where(np.isnan(self.MV))[0]
1✔
1330
            for i in inds:
1✔
1331
                # try to get from apparent mag
1332
                if not (np.isnan(self.Vmag[i])):
1✔
1333
                    self.MV[i] = self.Vmag[i] - 5 * (
1✔
1334
                        np.log10(self.dist[i].to("pc").value) - 1
1335
                    )
1336
                # if not, get from MeanStars
1337
                else:
1338
                    self.MV[i] = self.ms.SpTOther(
×
1339
                        "Mv", self.spectral_class[i][0], self.spectral_class[i][1]
1340
                    )
1341

1342
        # We should now have all the absolute V mags and so should be able to just
1343
        # fill in missing apparent V mags from those.
1344
        if np.any(np.isnan(self.Vmag)):
1✔
1345
            inds = np.isnan(self.Vmag)
×
1346
            self.Vmag[inds] = self.MV[inds] + 5 * (
×
1347
                np.log10(self.dist[inds].to("pc").value) - 1
1348
            )
1349

1350
        # next, try to fill in any missing BV colors
1351
        if np.all(self.BV == 0):
1✔
1352
            self.BV *= np.nan
1✔
1353
        if np.all(self.Bmag == 0):
1✔
1354
            self.Bmag *= np.nan
1✔
1355
        if np.any(np.isnan(self.BV)):
1✔
1356
            inds = np.where(np.isnan(self.BV))[0]
1✔
1357
            for i in inds:
1✔
1358
                if not (np.isnan(self.Bmag[i])):
1✔
1359
                    self.BV[i] = self.Bmag[i] - self.Vmag[i]
×
1360
                else:
1361
                    self.BV[i] = self.ms.SpTColor(
1✔
1362
                        "B", "V", self.spectral_class[i][0], self.spectral_class[i][1]
1363
                    )
1364

1365
        # we should now have all BV colors, so fill in missing Bmags from those
1366
        if np.any(np.isnan(self.Bmag)):
1✔
1367
            inds = np.isnan(self.Bmag)
1✔
1368
            self.Bmag[inds] = self.BV[inds] + self.Vmag[inds]
1✔
1369

1370
        # next fix any missing luminosities
1371
        if np.all(self.L == 0):
1✔
1372
            self.L *= np.nan
×
1373
        if np.any(np.isnan(self.L)) or np.any(self.L == 0):
1✔
1374
            inds = np.where(np.isnan(self.L) | (self.L == 0))[0]
×
1375
            for i in inds:
×
1376
                self.L[i] = 10 ** self.ms.SpTOther(
×
1377
                    "logL", self.spectral_class[i][0], self.spectral_class[i][1]
1378
                )
1379

1380
        # and bolometric corrections
1381
        if np.all(self.BC == 0):
1✔
1382
            self.BC *= np.nan
1✔
1383
        if np.any(np.isnan(self.BC)):
1✔
1384
            inds = np.where(np.isnan(self.BC))[0]
1✔
1385
            for i in inds:
1✔
1386
                self.BC[i] = self.ms.SpTOther(
1✔
1387
                    "BCv", self.spectral_class[i][0], self.spectral_class[i][1]
1388
                )
1389

1390
        # if we don't need to fill band mag values, we're done here
1391
        if not (self.fillMissingBandMags):
1✔
1392
            return
×
1393

1394
        # finally, get as many other bands as we can from table colors
1395
        mag_atts = ["Kmag", "Hmag", "Jmag", "Imag", "Umag", "Rmag"]
1✔
1396
        # these start-end colors to calculate for each band
1397
        colors_to_add = [
1✔
1398
            ["Ks", "V"],  # K = (K-V) + V
1399
            ["H", "Ks"],  # H = (H-K) + K
1400
            ["J", "H"],  # J = (J-H) + H
1401
            ["Ic", "V"],  # I = (I-V) + V
1402
            ["U", "B"],  # U = (U-B) + B
1403
            ["Rc", "V"],  # R = (R-V) + V
1404
        ]
1405
        # and these are the known band values to add the colors to
1406
        mags_to_add = [self.Vmag, self.Kmag, self.Hmag, self.Vmag, self.Bmag, self.Vmag]
1✔
1407

1408
        for mag_att, color_to_add, mag_to_add in zip(
1✔
1409
            mag_atts, colors_to_add, mags_to_add
1410
        ):
1411
            mag = getattr(self, mag_att)
1✔
1412
            if np.all(mag == 0):
1✔
1413
                mag *= np.nan
1✔
1414
            if np.any(np.isnan(mag)):
1✔
1415
                inds = np.where(np.isnan(mag))[0]
1✔
1416
                for i in inds:
1✔
1417
                    mag[i] = (
1✔
1418
                        self.ms.SpTColor(
1419
                            color_to_add[0],
1420
                            color_to_add[1],
1421
                            self.spectral_class[i][0],
1422
                            self.spectral_class[i][1],
1423
                        )
1424
                        + mag_to_add[i]
1425
                    )
1426

1427
    def filter_target_list(self, filters):
1✔
1428
        """This function is responsible for filtering by any required metrics.
1429

1430
        This should be used for *optional* filters. The ones in the prototype
1431
        are:
1432

1433
        * binary_filter
1434
        * outside_IWA_filter
1435
        * life_expectancy_filter
1436
        * main_sequence_filter
1437
        * fgk_filter
1438
        * vis_mag_filter (takes Vmagcrit as input)
1439
        * max_dmag_filter
1440
        * completeness_filter (must be run after the completeness values are calculated)
1441
        * vmag_filter (takes vmag_range as input)
1442
        * ang_diam_filter
1443
        * distance_filter (takes max_distance as input in parsecs)
1444

1445
        Args:
1446
            filters (dict):
1447
                Dictionary of filters to apply to the target list.  Keys are
1448
                filter names, and values are the filter functions to apply.
1449

1450

1451
        Examples:
1452

1453
            .. code-block:: python
1454

1455
                filters = {
1456
                    "binary_filter": {"enabled": True},
1457
                    "outside_IWA_filter": {"enabled": True},
1458
                    vmag_filter: {"enabled": True, "params": {"vmag_range": [4, 10]}},
1459
                    distance_filter: {"enabled": True, "params": {"max_distance": 20.0}},
1460
                }
1461

1462

1463
        """
1464
        for filter_name, filter_config in filters.items():
1✔
1465
            # check if the filter is enabled
1466
            if filter_config.get("enabled", False):
1✔
1467
                # get the filter function
1468
                if hasattr(self, filter_name):
1✔
1469
                    # Get the filter method
1470
                    filter_func = getattr(self, filter_name)
1✔
1471
                    # Apply the filter
1472
                    filter_func(**filter_config.get("params", {}))
1✔
1473
                    if self.explainFiltering:
1✔
1474
                        print(
×
1475
                            f"{self.nStars} targets remain after {filter_name.replace('_', ' ')}."
1476
                        )
1477
                else:
1478
                    raise AttributeError(
×
1479
                        (f"No filter '{filter_name}' in {self.__class__.__name__}.")
1480
                    )
1481

1482
    def vmag_filter(self, vmag_range):
1✔
1483
        """Removes stars with Vmag outside of specified range
1484

1485
        Args:
1486
            vmag_range (list):
1487
                2-element list of min, max Vmag values
1488

1489
        """
1490
        meets_lower_bound = self.Vmag > vmag_range[0]
×
1491
        meets_upper_bound = self.Vmag < vmag_range[1]
×
1492
        i = np.where(meets_lower_bound & meets_upper_bound)[0]
×
1493
        self.revise_lists(i)
×
1494

1495
    def distance_filter(self, max_distance):
1✔
1496
        """Removes stars which are further than the specified maximum distance
1497

1498
        Args:
1499
            max_distance (float):
1500
                Maximum distance in parsecs
1501

1502
        """
1503
        i = np.where(self.dist.to_value(u.pc) <= max_distance)[0]
×
1504
        self.revise_lists(i)
×
1505

1506
    def ang_diam_filter(self):
1✔
1507
        """Remove stars whose angular diameter exceeds the maximum supported.
1508

1509
        The maximum supported diameter is determined by the core_mean_intensity
1510
        tables in the starlight suppression systems. If a system has 2D
1511
        core_mean_intensity data (indexed by working angle and stellar diameter),
1512
        then a max_diam value is stored. Stars with diameters larger than this
1513
        will cause the core_mean_intensity interpolator to return invalid values.
1514

1515
        If no core_mean_intensity_max_diam is available for any system, falls back
1516
        to using the IWA as the maximum angular radius.
1517
        """
NEW
1518
        OS = self.OpticalSystem
×
1519

1520
        # Collect maximum supported diameters from all observing modes
NEW
1521
        max_diams = []
×
NEW
1522
        for mode in OS.observingModes:
×
NEW
1523
            syst = mode["syst"]
×
NEW
1524
            if "core_mean_intensity_max_diam" in syst:
×
1525
                # Scale by wavelength ratio: for coronagraphs, the diameter is
1526
                # scaled by (lam0 / lam) in the interpolator, so the effective
1527
                # max diameter at the mode's wavelength is:
1528
                # max_diam_effective = max_diam_at_lam0 * (lam / lam0)
NEW
1529
                lam_ratio = (mode["lam"] / syst["lam"]).decompose().value
×
NEW
1530
                max_diam_effective = syst["core_mean_intensity_max_diam"] * lam_ratio
×
NEW
1531
                max_diams.append(max_diam_effective)
×
1532

NEW
1533
        if len(max_diams) > 0:
×
1534
            # Use the minimum max_diam across all modes to ensure validity
NEW
1535
            max_diam_limit = min(max_diams)
×
NEW
1536
            i = np.where(self.diameter < max_diam_limit)[0]
×
1537
        else:
1538
            # Fall back to IWA-based filtering (angular radius < IWA)
NEW
1539
            ang_rad = self.diameter / 2
×
NEW
1540
            i = np.where(ang_rad < OS.IWA)[0]
×
1541

UNCOV
1542
        self.revise_lists(i)
×
1543

1544
    def nan_filter(self):
1✔
1545
        """Filters out targets where required values are nan"""
1546

1547
        for att_name in self.required_catalog_atts:
1✔
1548
            # treat sky coordinates differently form the other arrays
1549
            if att_name == "coords":
1✔
1550
                inds = (
1✔
1551
                    ~np.isnan(self.coords.data.lon)
1552
                    & ~np.isnan(self.coords.data.lat)
1553
                    & ~np.isnan(self.coords.data.distance)
1554
                )
1555
            else:
1556
                # all other attributes should be ndarrays or quantity ndarrays
1557
                # in either case, they should have a dtype
1558
                att = getattr(self, att_name)
1✔
1559
                if np.issubdtype(att.dtype, np.number):
1✔
1560
                    inds = ~np.isnan(att)
1✔
1561
                elif np.issubdtype(att.dtype, str):
1✔
1562
                    inds = att != ""
1✔
1563
                elif att.dtype == bool:
1✔
1564
                    continue
1✔
1565
                else:
1566
                    warnings.warn(
×
1567
                        f"Cannot filter attribute {att_name} of type {att.dtype}"
1568
                    )
1569

1570
            # only need to do something if there are any False in inds:
1571
            if not (np.all(inds)):
1✔
1572
                self.revise_lists(np.where(inds)[0])
1✔
1573

1574
    def binary_filter(self):
1✔
1575
        """Removes stars which have attribute Binary_Cut == True"""
1576

1577
        i = np.where(~self.Binary_Cut)[0]
1✔
1578
        self.revise_lists(i)
1✔
1579

1580
    def life_expectancy_filter(self):
1✔
1581
        """Removes stars from Target List which have BV < 0.3"""
1582

1583
        i = np.where(self.BV > 0.3)[0]
1✔
1584
        self.revise_lists(i)
1✔
1585

1586
    def main_sequence_filter(self):
1✔
1587
        """Removes stars from Target List which are not main sequence"""
1588

1589
        # indices from Target List to keep
1590
        i1 = np.where((self.BV < 0.74) & (self.MV < 6 * self.BV + 1.8))[0]
1✔
1591
        i2 = np.where(
1✔
1592
            (self.BV >= 0.74) & (self.BV < 1.37) & (self.MV < 4.3 * self.BV + 3.05)
1593
        )[0]
1594
        i3 = np.where((self.BV >= 1.37) & (self.MV < 18 * self.BV - 15.7))[0]
1✔
1595
        i4 = np.where((self.BV < 0.87) & (self.MV > -8 * (self.BV - 1.35) ** 2 + 7.01))[
1✔
1596
            0
1597
        ]
1598
        i5 = np.where(
1✔
1599
            (self.BV >= 0.87) & (self.BV < 1.45) & (self.MV < 5 * self.BV + 0.81)
1600
        )[0]
1601
        i6 = np.where((self.BV >= 1.45) & (self.MV > 18 * self.BV - 18.04))[0]
1✔
1602
        ia = np.append(np.append(i1, i2), i3)
1✔
1603
        ib = np.append(np.append(i4, i5), i6)
1✔
1604
        i = np.intersect1d(np.unique(ia), np.unique(ib))
1✔
1605
        self.revise_lists(i)
1✔
1606

1607
    def fgk_filter(self):
1✔
1608
        """Includes only F, G, K spectral type stars in Target List"""
1609

1610
        spec = np.array(list(map(str, self.Spec)))
1✔
1611
        iF = np.where(np.core.defchararray.startswith(spec, "F"))[0]
1✔
1612
        iG = np.where(np.core.defchararray.startswith(spec, "G"))[0]
1✔
1613
        iK = np.where(np.core.defchararray.startswith(spec, "K"))[0]
1✔
1614
        i = np.append(np.append(iF, iG), iK)
1✔
1615
        i = np.unique(i)
1✔
1616
        self.revise_lists(i)
1✔
1617

1618
    def vis_mag_filter(self, Vmagcrit):
1✔
1619
        """Includes stars which are below the maximum apparent visual magnitude
1620

1621
        Args:
1622
            Vmagcrit (float):
1623
                maximum apparent visual magnitude
1624

1625
        """
1626

1627
        i = np.where(self.Vmag < Vmagcrit)[0]
1✔
1628
        self.revise_lists(i)
1✔
1629

1630
    def outside_IWA_filter(self):
1✔
1631
        """Includes stars with planets with orbits outside of the IWA"""
1632

1633
        PPop = self.PlanetPopulation
1✔
1634
        OS = self.OpticalSystem
1✔
1635

1636
        s = np.tan(OS.IWA) * self.dist
1✔
1637
        L = np.sqrt(self.L) if PPop.scaleOrbits else 1.0
1✔
1638
        i = np.where(s < L * np.max(PPop.rrange))[0]
1✔
1639
        self.revise_lists(i)
1✔
1640

1641
    def zero_lum_filter(self):
1✔
1642
        """Filter Target Stars with 0 luminosity"""
1643
        i = np.where(self.L != 0.0)[0]
1✔
1644
        self.revise_lists(i)
1✔
1645

1646
    def max_dmag_filter(self):
1✔
1647
        """Includes stars if maximum delta mag is in the allowed orbital range
1648

1649
        Removed from prototype filters. Prototype is already calling the
1650
        completeness_filter with self.intCutoff_dMag
1651

1652
        """
1653

1654
        PPop = self.PlanetPopulation
1✔
1655
        PPMod = self.PlanetPhysicalModel
1✔
1656

1657
        # s and beta arrays
1658
        s = np.tan(self.int_WA) * self.dist
1✔
1659
        if PPop.scaleOrbits:
1✔
1660
            s /= np.sqrt(self.L)
×
1661
        beta = np.array([1.10472881476178] * len(s)) * u.rad
1✔
1662

1663
        # fix out of range values
1664
        below = np.where(s < np.min(PPop.rrange) * np.sin(beta))[0]
1✔
1665
        above = np.where(s > np.max(PPop.rrange) * np.sin(beta))[0]
1✔
1666
        s[below] = np.sin(beta[below]) * np.min(PPop.rrange)
1✔
1667
        beta[above] = np.arcsin(s[above] / np.max(PPop.rrange))
1✔
1668

1669
        # calculate delta mag
1670
        p = np.max(PPop.prange)
1✔
1671
        Rp = np.max(PPop.Rprange)
1✔
1672
        d = s / np.sin(beta)
1✔
1673
        Phi = PPMod.calc_Phi(beta)
1✔
1674
        i = np.where(deltaMag(p, Rp, d, Phi) < self.intCutoff_dMag)[0]
1✔
1675
        self.revise_lists(i)
1✔
1676

1677
    def completeness_filter(self):
1✔
1678
        """Includes stars if completeness is larger than the minimum value"""
1679

1680
        i = np.where(self.intCutoff_comp >= self.Completeness.minComp)[0]
1✔
1681
        self.revise_lists(i)
1✔
1682

1683
    def revise_lists(self, sInds):
1✔
1684
        """Replaces Target List catalog attributes with filtered values,
1685
        and updates the number of target stars.
1686

1687
        Args:
1688
            sInds (~numpy.ndarray(int)):
1689
                Integer indices of the stars to retain
1690

1691
        """
1692

1693
        # cast sInds to array
1694
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
1695

1696
        if len(sInds) == 0:
1✔
1697
            raise IndexError("Requested target revision would leave 0 stars.")
1✔
1698

1699
        for att in self.catalog_atts:
1✔
1700
            if getattr(self, att).size != 0:
1✔
1701
                setattr(self, att, getattr(self, att)[sInds])
1✔
1702
        for key in self.star_fluxes:
1✔
1703
            self.star_fluxes[key] = self.star_fluxes[key][sInds]
1✔
1704
        for key in self.JEZ0:
1✔
1705
            self.JEZ0[key] = self.JEZ0[key][sInds]
1✔
1706
        self.system_fbeta = self.system_fbeta[sInds]
1✔
1707
        try:
1✔
1708
            self.Completeness.revise_updates(sInds)
1✔
1709
        except AttributeError:
1✔
1710
            pass
1✔
1711
        self.nStars = len(sInds)
1✔
1712

1713
    def stellar_diameter(self):
1✔
1714
        """Populates target list with approximate stellar diameters
1715

1716
        Stellar radii are computed using the BV target colors according to the model
1717
        from [Boyajian2014]_.  This model has a standard deviation error of 7.8%.
1718

1719
        Updates/creates attribute ``diameter``, as needed.
1720

1721
        """
1722

1723
        # if any diameters were populated from the star catalog, do not
1724
        # overwrite those
1725
        if hasattr(self, "diameter"):
1✔
1726
            sInds = np.where(np.isnan(self.diameter) | (self.diameter.value == 0))[0]
×
1727
        else:
1728
            sInds = np.arange(self.nStars)
1✔
1729
            self.diameter = np.zeros(self.nStars) * u.mas
1✔
1730

1731
        if "diameter" not in self.catalog_atts:
1✔
1732
            self.catalog_atts.append("diameter")
1✔
1733

1734
        # B-V polynomial fit coefficients:
1735
        coeffs = [0.49612, 1.11136, -1.18694, 0.91974, -0.19526]
1✔
1736

1737
        # Evaluate eq. 2 using B-V color
1738
        logth_zero = np.zeros(sInds.shape)
1✔
1739
        for j, ai in enumerate(coeffs):
1✔
1740
            logth_zero += ai * self.BV[sInds] ** j
1✔
1741

1742
        # now invert eq. 1 to get the actual diameter in mas
1743
        self.diameter[sInds] = 10 ** (logth_zero - 0.2 * self.Vmag[sInds]) * u.mas
1✔
1744

1745
    def stellar_Teff(self):
1✔
1746
        """Calculate the effective stellar temperature based on B-V color.
1747

1748
        This method uses the empirical fit from [Ballesteros2012]_
1749
        doi:10.1209/0295-5075/97/34008
1750

1751
        Updates/creates attribute ``Teff``, as needed.
1752
        """
1753

1754
        # if any effective temperatures were populated from the star catalog, do not
1755
        # overwrite those
1756
        if hasattr(self, "Teff"):
1✔
1757
            sInds = np.where(np.isnan(self.Teff) | (self.Teff.value == 0))[0]
×
1758
        else:
1759
            sInds = np.arange(self.nStars)
1✔
1760
            self.Teff = np.zeros(self.nStars) * u.K
1✔
1761

1762
        if "Teff" not in self.catalog_atts:
1✔
1763
            self.catalog_atts.append("Teff")
1✔
1764

1765
        self.Teff[sInds] = (
1✔
1766
            4600.0
1767
            * u.K
1768
            * (
1769
                1.0 / (0.92 * self.BV[sInds] + 1.7)
1770
                + 1.0 / (0.92 * self.BV[sInds] + 0.62)
1771
            )
1772
        )
1773

1774
    def stellar_mass(self):
1✔
1775
        """Populates target list with 'true' and 'approximate' stellar masses
1776

1777
        Approximate stellar masses are calculated from absolute magnitudes using the
1778
        model from [Henry1993]_. "True" masses are generated by a uniformly
1779
        distributed, 7%-mean error term to the apprxoimate masses.
1780

1781
        All values are in units of solar mass.
1782

1783
        Function called by reset sim.
1784

1785
        """
1786

1787
        if self.massLuminosityRelationship == "Henry1993":
1✔
1788
            # good generalist, but out of date
1789
            # 'approximate' stellar mass
1790
            self.MsEst = (
1✔
1791
                10.0 ** (0.002456 * self.MV**2 - 0.09711 * self.MV + 0.4365)
1792
            ) * u.solMass
1793
            # normally distributed 'error' of 7%
1794
            err = (np.random.random(len(self.MV)) * 2.0 - 1.0) * 0.07
1✔
1795
            self.MsTrue = (1.0 + err) * self.MsEst
1✔
1796

1797
        elif self.massLuminosityRelationship == "Fernandes2021":
1✔
1798
            # only good for FGK
1799
            # 'approximate' stellar mass without error
1800
            self.MsEst = (
1✔
1801
                10
1802
                ** (
1803
                    (0.219 * np.log10(self.L))
1804
                    + (0.063 * ((np.log10(self.L)) ** 2))
1805
                    - (0.119 * ((np.log10(self.L)) ** 3))
1806
                )
1807
            ) * u.solMass
1808
            # error distribution in literature as 3% in approxoimate masses
1809
            err = (np.random.random(len(self.L)) * 2.0 - 1.0) * 0.03
1✔
1810
            self.MsTrue = (1.0 + err) * self.MsEst
1✔
1811

1812
        elif self.massLuminosityRelationship == "Henry1993+1999":
1✔
1813
            # more specific than Henry1993
1814
            # initialize MsEst attribute
1815
            self.MsEst = np.zeros(self.nStars)
1✔
1816
            for j, MV in enumerate(self.MV):
1✔
1817
                if 0.50 <= MV <= 2.0:
1✔
1818
                    mass = (10.0 ** (0.002456 * MV**2 - 0.09711 * MV + 0.4365)).item()
1✔
1819
                    self.MsEst = np.append(self.MsEst, mass)
1✔
1820
                    err = (np.random.random(1) * 2.0 - 1.0) * 0.07
1✔
1821
                elif 0.18 <= MV < 0.50:
1✔
1822
                    mass = (10.0 ** (-0.1681 * MV + 1.4217)).item()
1✔
1823
                    self.MsEst = np.append(self.MsEst, mass)
1✔
1824
                    err = (np.random.random(1) * 2.0 - 1.0) * 0.07
1✔
1825
                elif 0.08 <= MV < 0.18:
1✔
1826
                    mass = (10 ** (0.005239 * MV**2 - 0.2326 * MV + 1.3785)).item()
1✔
1827
                    self.MsEst = np.append(self.MsEst, mass)
1✔
1828
                    # 5% error desccribed in 1999 paper
1829
                    err = (np.random.random(1) * 2.0 - 1.0) * 0.05
1✔
1830
                else:
1831
                    # default to Henry 1993
1832
                    mass = (10.0 ** (0.002456 * MV**2 - 0.09711 * MV + 0.4365)).item()
1✔
1833
                    self.MsEst = np.append(self.MsEst, mass)
1✔
1834
                    err = (np.random.random(1) * 2.0 - 1.0) * 0.07
1✔
1835
                self.MsEst[j] = mass
1✔
1836
            self.MsEst = self.MsEst * u.solMass
1✔
1837
            self.MsTrue = (1.0 + err) * self.MsEst
1✔
1838

1839
        elif self.massLuminosityRelationship == "Fang2010":
1✔
1840
            # for all main sequence stars, good generalist
1841
            self.MsEst = np.zeros(self.nStars)
1✔
1842
            for j, MV in enumerate(self.MV):
1✔
1843
                if MV <= 1.05:
1✔
1844
                    mass = (10 ** (0.558 - 0.182 * MV - 0.0028 * MV**2)).item()
1✔
1845
                    self.MsEst = np.append(self.MsEst, mass)
1✔
1846
                    err = (np.random.random(1) * 2.0 - 1.0) * 0.05
1✔
1847
                else:
1848
                    mass = (10 ** (0.489 - 0.125 * MV + 0.00511 * MV**2)).item()
×
1849
                    self.MsEst = np.append(self.MsEst, mass)
×
1850
                    err = (np.random.random(1) * 2.0 - 1.0) * 0.07
×
1851
                self.MsEst[j] = mass
1✔
1852
            self.MsEst = self.MsEst * u.solMass
1✔
1853
            self.MsTrue = (1.0 + err) * self.MsEst
1✔
1854

1855
        # if additional filters are desired, need self.catalog_atts fully populated
1856
        if not hasattr(self.catalog_atts, "MsEst"):
1✔
1857
            self.catalog_atts.append("MsEst")
1✔
1858
        if not hasattr(self.catalog_atts, "MsTrue"):
1✔
1859
            self.catalog_atts.append("MsTrue")
1✔
1860

1861
    def starprop(self, sInds, currentTime, eclip=False):
1✔
1862
        """Finds target star positions vector in heliocentric equatorial (default)
1863
        or ecliptic frame for current time (MJD).
1864

1865
        This method uses ICRS coordinates which is approximately the same as
1866
        equatorial coordinates.
1867

1868
        Args:
1869
            sInds (~numpy.ndarray(int)):
1870
                Integer indices of the stars of interest
1871
            currentTime (~astropy.time.Time):
1872
                Current absolute mission time in MJD
1873
            eclip (bool):
1874
                Boolean used to switch to heliocentric ecliptic frame. Defaults to
1875
                False, corresponding to heliocentric equatorial frame.
1876

1877
        Returns:
1878
            ~astropy.units.Quantity(~numpy.ndarray(float)):
1879
                Target star positions vector in heliocentric equatorial (default)
1880
                or ecliptic frame in units of pc. Will return an m x n x 3 array
1881
                where m is size of currentTime, n is size of sInds. If either m or
1882
                n is 1, will return n x 3 or m x 3.
1883

1884
        Note: Use eclip=True to get ecliptic coordinates.
1885

1886
        """
1887

1888
        # if multiple time values, check they are different otherwise reduce to scalar
1889
        if currentTime.size > 1:
1✔
1890
            if np.all(currentTime == currentTime[0]):
1✔
1891
                currentTime = currentTime[0]
1✔
1892

1893
        # cast sInds to array
1894
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
1895

1896
        # get all array sizes
1897
        nStars = sInds.size
1✔
1898
        nTimes = currentTime.size
1✔
1899

1900
        # if the starprop_static method was created (staticStars is True), then use it
1901
        if self.starprop_static is not None:
1✔
1902
            r_targ = self.starprop_static(sInds, currentTime, eclip)
1✔
1903
            if nTimes == 1 or nStars == 1 or nTimes == nStars:
1✔
1904
                return r_targ
1✔
1905
            else:
1906
                return np.tile(r_targ, (nTimes, 1, 1))
1✔
1907

1908
        # target star ICRS coordinates
1909
        coord_old = self.coords[sInds]
1✔
1910
        # right ascension and declination
1911
        ra = coord_old.ra
1✔
1912
        dec = coord_old.dec
1✔
1913
        # directions
1914
        p0 = np.array([-np.sin(ra), np.cos(ra), np.zeros(sInds.size)])
1✔
1915
        q0 = np.array(
1✔
1916
            [-np.sin(dec) * np.cos(ra), -np.sin(dec) * np.sin(ra), np.cos(dec)]
1917
        )
1918
        r0 = coord_old.cartesian.xyz / coord_old.distance
1✔
1919
        # proper motion vector
1920
        mu0 = p0 * self.pmra[sInds] + q0 * self.pmdec[sInds]
1✔
1921
        # space velocity vector
1922
        v = mu0 / self.parx[sInds] * u.AU + r0 * self.rv[sInds]
1✔
1923
        # set J2000 epoch
1924
        j2000 = Time(2000.0, format="jyear")
1✔
1925

1926
        # if only 1 time in currentTime
1927
        if nTimes == 1 or nStars == 1 or nTimes == nStars:
1✔
1928
            # target star positions vector in heliocentric equatorial frame
1929
            dr = v * (currentTime.mjd - j2000.mjd) * u.day
1✔
1930
            r_targ = (coord_old.cartesian.xyz + dr).T.to("pc")
1✔
1931

1932
            if eclip:
1✔
1933
                # transform to heliocentric true ecliptic frame
1934
                coord_new = SkyCoord(
1✔
1935
                    r_targ[:, 0],
1936
                    r_targ[:, 1],
1937
                    r_targ[:, 2],
1938
                    representation_type="cartesian",
1939
                )
1940
                r_targ = coord_new.heliocentrictrueecliptic.cartesian.xyz.T.to("pc")
1✔
1941
            return r_targ
1✔
1942

1943
        # create multi-dimensional array for r_targ
1944
        else:
1945
            # target star positions vector in heliocentric equatorial frame
1946
            r_targ = np.zeros([nTimes, nStars, 3]) * u.pc
1✔
1947
            for i, m in enumerate(currentTime):
1✔
1948
                dr = v * (m.mjd - j2000.mjd) * u.day
1✔
1949
                r_targ[i, :, :] = (coord_old.cartesian.xyz + dr).T.to("pc")
1✔
1950

1951
            if eclip:
1✔
1952
                # transform to heliocentric true ecliptic frame
1953
                coord_new = SkyCoord(
×
1954
                    r_targ[i, :, 0],
1955
                    r_targ[i, :, 1],
1956
                    r_targ[i, :, 2],
1957
                    representation_type="cartesian",
1958
                )
1959
                r_targ[i, :, :] = coord_new.heliocentrictrueecliptic.cartesian.xyz.T.to(
×
1960
                    "pc"
1961
                )
1962
            return r_targ
1✔
1963

1964
    def get_spectral_template(self, sInd, mode, Vband=False):
1✔
1965
        """
1966
        Determine and return the renormalized spectral template for a given star and
1967
        observing mode.
1968

1969
        This method selects the appropriate magnitude and band based on the observing
1970
        mode, chooses the correct spectral template (either a black-body spectrum or a
1971
        template from the spectral catalog), and renormalizes the template to match the
1972
        selected magnitude.
1973

1974
        Args: sInd (int):
1975
            Index of the star of interest.
1976
        mode (dict):
1977
            Observing mode dictionary (see :ref:`OpticalSystem`).
1978
        Vband (bool):
1979
            If True, use V band magnitudes for all calculations and ignore any
1980
            mode input. Defaults False.
1981

1982

1983
        Returns:
1984
            SourceSpectrum:
1985
                The renormalized spectral template for the star.
1986

1987
        Raises:
1988
            AssertionError:
1989
                If no valid magnitudes are found for the star.
1990
        """
1991
        # Select the best available magnitude and corresponding band
1992
        if Vband:
1✔
1993
            mag_to_use = self.Vmag[sInd]
1✔
1994
            v_band_ind = self.standard_bands_letters.index("V")
1✔
1995
            band_to_use = self.standard_bands_letters[v_band_ind]
1✔
1996
        else:
1997
            # Find distances (in wavelength) between standard bands and mode
1998
            band_dists = np.abs(self.standard_bands_lam - mode["lam"]).to(u.nm).value
1✔
1999
            # Order of preferred band indices based on proximity to mode wavelength
2000
            band_pref_inds = np.argsort(band_dists)
1✔
2001
            mag_to_use = None
1✔
2002
            band_to_use = None
1✔
2003
            for band_ind in band_pref_inds:
1✔
2004
                mag_attr = f"{self.standard_bands_letters[band_ind]}mag"
1✔
2005
                tmp_mags = getattr(self, mag_attr)
1✔
2006

2007
                # Skip if all magnitudes are zero or if the specific star's magnitude
2008
                # is NaN
2009
                if np.all(tmp_mags == 0):
1✔
2010
                    continue
1✔
2011
                if not np.isnan(tmp_mags[sInd]):
1✔
2012
                    band_to_use = self.standard_bands_letters[band_ind]
1✔
2013
                    mag_to_use = tmp_mags[sInd]
1✔
2014
                    break
1✔
2015

2016
        # Ensure a valid magnitude was found
2017
        assert (
1✔
2018
            mag_to_use is not None
2019
        ), f"No valid magnitudes found for {self.Name[sInd]}"
2020

2021
        # Determine the appropriate spectral template
2022
        if not (Vband) and (mode["bandpass"].waveset.max() > 2.4 * u.um):
1✔
2023
            # Use black-body spectrum if bandpass exceeds 2.4 microns
2024
            if self.blackbody_spectra[sInd] is None:
×
2025
                self.blackbody_spectra[sInd] = SourceSpectrum(
×
2026
                    BlackBodyNorm1D, temperature=self.Teff[sInd]
2027
                )
2028
            template = self.blackbody_spectra[sInd]
×
2029
        else:
2030
            # Use spectral catalog template
2031
            if self.Spec[sInd] in self.spectral_catalog_index:
1✔
2032
                spec_to_use = self.Spec[sInd]
1✔
2033
            else:
2034
                # Match closest spectral type within the same luminosity class
2035
                luminosity_class = self.spectral_class[sInd][2]
1✔
2036
                tmp_catalog = self.spectral_catalog_types[
1✔
2037
                    self.spectral_catalog_types[:, 2] == luminosity_class
2038
                ]
2039
                if len(tmp_catalog) == 0:
1✔
2040
                    tmp_catalog = self.spectral_catalog_types
×
2041

2042
                # Find the closest spectral type based on a specific attribute (e.g., temperature)
2043
                spectral_attr = self.spectral_class[sInd][3]
1✔
2044
                row = tmp_catalog[np.argmin(np.abs(tmp_catalog[:, 3] - spectral_attr))]
1✔
2045
                spec_to_use = f"{row[0]}{row[1]}{row[2]}"
1✔
2046

2047
            # Load the spectral template
2048
            template = self.get_template_spectrum(spec_to_use)
1✔
2049

2050
        # Renormalize the template to the selected magnitude using the appropriate band
2051
        try:
1✔
2052
            template_renorm = template.normalize(
1✔
2053
                mag_to_use * VEGAMAG,
2054
                self.standard_bands[band_to_use],
2055
                vegaspec=self.OpticalSystem.vega_spectrum,
2056
            )
2057
        except SynphotError:
×
2058
            # If the normalization fails, return the unrenormalized template.
2059
            # This can happen if the normalization results in negative flux
2060
            # values
2061
            template_renorm = template
×
2062

2063
        return template_renorm
1✔
2064

2065
    def starFlux(self, sInds, mode):
1✔
2066
        """
2067
        Return the total spectral flux of the requested stars for the
2068
        given observing mode. Caches results internally for faster access in
2069
        subsequent calls.
2070

2071
        Args:
2072
            sInds (~numpy.ndarray[int]):
2073
                Indices of the stars of interest.
2074
            mode (dict):
2075
                Observing mode dictionary (see :ref:`OpticalSystem`).
2076

2077
        Returns:
2078
            ~astropy.units.Quantity(~numpy.ndarray[float]):
2079
                Spectral fluxes in units of ph/m**2/s.
2080
        """
2081
        # If we've never been asked for fluxes in this mode before, create a new array
2082
        # of flux values for it and set them all to nan.
2083
        if mode["hex"] not in self.star_fluxes:
1✔
2084
            self.star_fluxes[mode["hex"]] = np.full(self.nStars, np.nan) * (
1✔
2085
                u.ph / u.s / u.m**2
2086
            )
2087

2088
        # figure out which target indices (if any) need new calculations to be done
2089
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
2090
        novals = np.isnan(self.star_fluxes[mode["hex"]][sInds])
1✔
2091
        inds = np.unique(sInds[novals])
1✔
2092

2093
        if len(inds) > 0:
1✔
2094
            # Loop through each required star index and compute its flux
2095
            for sInd in tqdm(inds, desc="Computing star fluxes", delay=2):
1✔
2096
                # Obtain the renormalized spectral template using the new method
2097
                template_renorm = self.get_spectral_template(sInd, mode)
1✔
2098

2099
                # Write the result back to the star_fluxes cache
2100
                try:
1✔
2101
                    self.star_fluxes[mode["hex"]][sInd] = Observation(
1✔
2102
                        template_renorm, mode["bandpass"], force="taper"
2103
                    ).integrate()
2104
                except DisjointError:
×
2105
                    self.star_fluxes[mode["hex"]][sInd] = 0 * (u.ph / u.s / u.m**2)
×
2106

2107
        return self.star_fluxes[mode["hex"]][sInds]
1✔
2108

2109
    def radiusFromMass(self, sInds):
1✔
2110
        """Estimates the star radius based on its mass
2111
        Table 2, ZAMS models pg321
2112
        STELLAR MASS-LUMINOSITY AND MASS-RADIUS RELATIONS OSMAN DEMIRCAN and GOKSEL
2113
        KAHRAMAN 1991
2114

2115
        Args:
2116
            sInds (~numpy.ndarray(int)):
2117
                star indices
2118

2119
        Returns:
2120
            ~astropy.units.Quantity(~numpy.ndarray(float)):
2121
                Star radius estimates
2122
        """
2123

2124
        M = self.MsEst[sInds].value  # use MsEst as that's the deterministic one
×
2125
        a = -0.073
×
2126
        b = 0.668
×
2127
        starRadius = 10 ** (a + b * np.log10(M))
×
2128

2129
        return starRadius * u.R_sun
×
2130

2131
    def gen_inclinations(self, Irange):
1✔
2132
        """Randomly Generate Inclination of Target System Orbital Plane for
2133
        all stars in the target list
2134

2135
        Args:
2136
            Irange (~numpy.ndarray(float)):
2137
                the range to generate inclinations over
2138

2139
        Returns:
2140
            ~astropy.units.Quantity(~numpy.ndarray(float)):
2141
                System inclinations
2142
        """
2143
        C = 0.5 * (np.cos(Irange[0]) - np.cos(Irange[1]))
1✔
2144
        return (
1✔
2145
            np.arccos(np.cos(Irange[0]) - 2.0 * C * np.random.uniform(size=self.nStars))
2146
        ).to("deg")
2147

2148
    def gen_Omegas(self, Orange):
1✔
2149
        """Randomly Generate longitude of the ascending node of target system
2150
        orbital planes for all stars in the target list
2151

2152
        Args:
2153
            Orange (~numpy.ndarray(float)):
2154
                The range to generate Omegas over
2155

2156
        Returns:
2157
            ~astropy.units.Quantity(~numpy.ndarray(float)):
2158
                System Omegas
2159
        """
2160
        return (
1✔
2161
            np.random.uniform(
2162
                low=Orange[0].to(u.deg).value,
2163
                high=Orange[1].to(u.deg).value,
2164
                size=self.nStars,
2165
            )
2166
            * u.deg
2167
        )
2168

2169
    def calc_HZ_inner(
1✔
2170
        self,
2171
        sInds,
2172
        S_inner=1.7665,
2173
        A_inner=1.3351e-4,
2174
        B_inner=3.1515e-9,
2175
        C_inner=-3.3488e-12,
2176
        **kwargs,
2177
    ):
2178
        """
2179
        Convenience function to find the inner edge of the habitable zone using the
2180
        emperical approach in calc_HZ().
2181

2182
        Default contstants: Recent Venus limit Inner edge" , Kaltinegger et al 2018,
2183
        Table 1.
2184

2185
        """
2186
        return self.calc_HZ(sInds, S_inner, A_inner, B_inner, C_inner, **kwargs)
1✔
2187

2188
    def calc_HZ_outer(
1✔
2189
        self,
2190
        sInds,
2191
        S_outer=0.324,
2192
        A_outer=5.3221e-5,
2193
        B_outer=1.4288e-9,
2194
        C_outer=-1.1049e-12,
2195
        **kwargs,
2196
    ):
2197
        """
2198
        Convenience function to find the inner edge of the habitable zone using the
2199
        emperical approach in calc_HZ().
2200

2201
        The default outer limit constants are the Early Mars outer limit,
2202
        Kaltinegger et al (2018) Table 1.
2203

2204
        """
2205
        return self.calc_HZ(sInds, S_outer, A_outer, B_outer, C_outer, **kwargs)
1✔
2206

2207
    def calc_IWA_AU(self, sInds, **kwargs):
1✔
2208
        """
2209

2210
        Convenience function to find the separation from the star of the IWA
2211

2212
        Args:
2213
            sInds (~numpy.ndarray(int)):
2214
                Indices of the stars of interest
2215
            **kwargs (any):
2216
                Extra keyword arguments
2217

2218
        Returns:
2219
            Quantity array:
2220
                separation from the star of the IWA in AU
2221
        """
2222

2223
        # cast sInds to array
2224
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
×
2225
        return (
×
2226
            self.dist[sInds].to(u.parsec).value
2227
            * self.OpticalSystem.IWA.to(u.arcsec).value
2228
            * u.AU
2229
        )
2230

2231
    def calc_HZ(self, sInds, S, A, B, C, arcsec=False):
1✔
2232
        """finds the inner or outer edge of the habitable zone
2233

2234
        This method uses the empirical fit from Kaltinegger et al  (2018) and
2235
        references therein, https://arxiv.org/pdf/1903.11539.pdf
2236

2237
        Args:
2238
            sInds (~numpy.ndarray(int)):
2239
                Indices of the stars of interest
2240
            S (float):
2241
                Constant
2242
            A (float):
2243
                Constant
2244
            B (float):
2245
                Constant
2246
            C (float):
2247
                Constant
2248
            arcsec (bool):
2249
                If True returns result arcseconds instead of AU
2250
        Returns:
2251
            ~astropy.units.Quantity(~numpy.ndarray(float)):
2252
               limit of HZ in AU or arcseconds
2253

2254
        """
2255
        # cast sInds to array
2256
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
2257

2258
        T_eff = self.Teff[sInds]
1✔
2259

2260
        T_star = (5780 * u.K - T_eff).to(u.K).value
1✔
2261

2262
        Seff = S + A * T_star + B * T_star**2 + C * T_star**3
1✔
2263

2264
        d_HZ = np.sqrt((self.L[sInds]) / Seff) * u.AU
1✔
2265

2266
        if arcsec:
1✔
2267
            return (
×
2268
                d_HZ.to(u.AU).value / self.dist[sInds].to(u.parsec).value
2269
            ) * u.arcsecond
2270
        else:
2271
            return d_HZ
1✔
2272

2273
    def calc_EEID(self, sInds, arcsec=False):
1✔
2274
        """Finds the earth equivalent insolation distance (EEID)
2275

2276

2277
        Args:
2278
            sInds (~numpy.ndarray(int)):
2279
                Indices of the stars of interest
2280
            arcsec (bool):
2281
                If True returns result arcseconds instead of AU
2282

2283
        Returns:
2284
            ~astropy.units.Quantity(~numpy.ndarray(float)):
2285
                limit of HZ in AU or arcseconds
2286

2287
        """
2288
        # cast sInds to array
2289
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
×
2290

2291
        d_EEID = (1 / ((1 * u.AU) ** 2 * (self.L[sInds]))) ** (-0.5)
×
2292
        # ((L_sun/(1*AU^2)/(0.25*L_sun)))^(-0.5)
2293
        if arcsec:
×
2294
            return (
×
2295
                d_EEID.to(u.AU).value / self.dist[sInds].to(u.parsec).value
2296
            ) * u.arcsecond
2297
        else:
2298
            return d_EEID
×
2299

2300
    def dump_catalog(self):
1✔
2301
        """Creates a dictionary of stellar properties for archiving use.
2302

2303
        Args:
2304
            None
2305

2306
        Returns:
2307
            dict:
2308
                Dictionary of star catalog properties
2309

2310
        """
2311
        atts = [
×
2312
            "Name",
2313
            "Spec",
2314
            "parx",
2315
            "Umag",
2316
            "Bmag",
2317
            "Vmag",
2318
            "Rmag",
2319
            "Imag",
2320
            "Jmag",
2321
            "Hmag",
2322
            "Kmag",
2323
            "dist",
2324
            "BV",
2325
            "MV",
2326
            "BC",
2327
            "L",
2328
            "coords",
2329
            "pmra",
2330
            "pmdec",
2331
            "rv",
2332
            "Binary_Cut",
2333
            "MsEst",
2334
            "MsTrue",
2335
            "int_comp",
2336
            "I",
2337
        ]
2338
        # Not sure if MsTrue and others can be dumped properly...
2339

2340
        catalog = {atts[i]: getattr(self, atts[i]) for i in np.arange(len(atts))}
×
2341

2342
        return catalog
×
2343

2344
    def queryNEAsystems(self):
1✔
2345
        """Queries NASA Exoplanet Archive system alias service to check for stars
2346
        in the target list that have known planets.
2347

2348
        .. note::
2349

2350
            These queries take a *long* time, so rather than caching individual
2351
            target lists, we'll keep everything we query and initially seed from a
2352
            starting list that's part of the repository.
2353
        """
2354

2355
        # define toggle for writing to disk
2356
        systems_updated = False
×
2357

2358
        # grab cache from disk
2359
        nea_file = Path(self.cachedir, "NASA_EXOPLANET_ARCHIVE_SYSTEMS.json")
×
2360
        if not (nea_file.exists()):
×
2361
            self.vprint("NASA Exoplanet Archive cache not found. Copying from default.")
×
2362

2363
            neacache = os.path.join(
×
2364
                importlib.resources.files("EXOSIMS.TargetList"),
2365
                "NASA_EXOPLANET_ARCHIVE_SYSTEMS.json.gz",
2366
            )
2367

2368
            assert os.path.exists(neacache), (
×
2369
                "NASA Exoplanet Archive default cache file not found in " f"{neacache}"
2370
            )
2371

2372
            with gzip.open(neacache, "rb") as f:
×
2373
                systems = json.loads(f.read())
×
2374
                systems_updated = True
×
2375
        else:
2376
            self.vprint(f"Loading exoplanet archive cached systems from {nea_file}")
×
2377
            with open(nea_file, "r") as ff:
×
2378
                systems = json.loads(ff.read())
×
2379

2380
        # parse every name and alias in the list
2381
        allaliases = []
×
2382
        allaliaskeys = []
×
2383
        for name in systems:
×
2384
            for s in systems[name]["objects"]["stellar_set"]["stars"]:
×
2385
                tmp = systems[name]["objects"]["stellar_set"]["stars"][s]["alias_set"][
×
2386
                    "aliases"
2387
                ]
2388
                allaliases += tmp
×
2389
                allaliaskeys += [name] * len(tmp)
×
2390
        allaliases = np.array(allaliases)
×
2391
        allaliaskeys = np.array(allaliaskeys)
×
2392

2393
        # find any missing ones to downloads
2394
        missing = list(set(self.Name) - set(allaliases))
×
2395
        if len(missing) > 0:
×
2396
            systems_updated = True
×
2397
            for n in tqdm(missing, desc="Querying NASA Exoplanet Archive Lookup"):
×
2398
                dat = getExoplanetArchiveAliases(n)
×
2399
                if dat:
×
2400
                    systems[n] = dat
×
2401

2402
        # write results to disk if updated
2403
        if systems_updated:
×
2404
            with open(nea_file, "w") as outfile:
×
2405
                json.dump(
×
2406
                    systems,
2407
                    outfile,
2408
                    separators=(",", ":"),
2409
                )
2410
            self.vprint(f"Caching exoplanet archive systems to {nea_file}")
×
2411

2412
        # loop through systems data and create known planets boolean
2413
        self.hasKnownPlanet = np.zeros(self.nStars, dtype=bool)
×
2414
        self.targetAliases = np.ndarray((self.nStars), dtype=object)
×
2415
        self.catalog_atts += ["hasKnownPlanet", "targetAliases"]
×
2416
        for j, n in enumerate(self.Name):
×
2417
            if (n in systems) or (n in allaliases):
×
2418
                if n in systems:
×
2419
                    name = n
×
2420
                else:
2421
                    name = allaliaskeys[allaliases == n][0]
×
2422

2423
                key = None
×
2424
                for s in systems[name]["objects"]["stellar_set"]["stars"]:
×
2425
                    if (
×
2426
                        "requested_object"
2427
                        in systems[name]["objects"]["stellar_set"]["stars"][s]
2428
                    ):
2429
                        key = s
×
2430
                        break
×
2431
                self.targetAliases[j] = systems[name]["objects"]["stellar_set"][
×
2432
                    "stars"
2433
                ][key]["alias_set"]["aliases"]
2434
                self.hasKnownPlanet[j] = (
×
2435
                    systems[name]["objects"]["planet_set"]["item_count"] > 0
2436
                )
2437

2438
            else:
2439
                self.targetAliases[j] = []
×
2440

2441
    def starColorFactor(self, mode):
1✔
2442
        """
2443
        Compute and return the color factor for the specified stars and observing
2444
        mode.
2445

2446
        This function calculates the ratio of the specific intensity in the observing
2447
        mode to the specific intensity in V band for the specified stars.
2448
        The results are cached internally in `self.star_color_factors` for
2449
        faster access in subsequent calls.
2450

2451
        These terms are used to convert the intensity of exozodiacal dust in the V band
2452
        to the intensity in the observing mode's bandpass.
2453

2454
        Args:
2455
            mode (dict):
2456
                Observing mode dictionary (see :ref:`OpticalSystem`).
2457

2458
        Returns:
2459
            astropy.units.Quantity (numpy.ndarray[float]):
2460
                Color factors (specific_inensity_mode / specific_inensity_Vband), unitless.
2461
        """
2462
        # Generate a unique filename for the current mode
2463
        fname = (
1✔
2464
            f"TargetList_{self.StarCatalogHex}_"
2465
            f"nStars_{self.nStars}_mode_{mode['hex']}.color_factors"
2466
        )
2467
        color_factor_path = Path(self.cachedir, fname)
1✔
2468

2469
        # Initialize an array to hold color factors, filled with NaN
2470
        sInds = np.arange(self.nStars)
1✔
2471
        color_factors = np.zeros(self.nStars)
1✔
2472

2473
        if color_factor_path.exists():
1✔
2474
            # Load cached color factors from disk
2475
            with open(color_factor_path, "rb") as f:
×
2476
                color_factors = pickle.load(f)
×
2477
            self.vprint(f"Loaded exozodi color factors from {color_factor_path}")
×
2478
        else:
2479
            self.vprint(
1✔
2480
                f"Cache file not found for mode {mode['hex']}. Computing exozodi color factors..."
2481
            )
2482

2483
            # Find unique spectral types to speed up computation
2484
            unique_specs = np.unique(self.Spec)
1✔
2485

2486
            # Create a mapping from spectral type to color factor
2487
            spec_color_factors = {}
1✔
2488

2489
            # Compute color factors for each unique spectral type
2490
            for spec in tqdm(
1✔
2491
                unique_specs,
2492
                desc="Computing exozodi color factors by spectral type",
2493
                delay=2,
2494
            ):
2495
                # Find a representative star index with this spectral type
2496
                rep_sInd = np.where(self.Spec == spec)[0][0]
1✔
2497

2498
                # Obtain the star's exozodi spectrum
2499
                dust_spectrum = self.get_exozodi_spectrum(rep_sInd)
1✔
2500

2501
                # Integrate to get intensity in the observing mode's bandpass
2502
                intensity_mode = Observation(
1✔
2503
                    dust_spectrum, mode["bandpass"], force="taper"
2504
                ).integrate()
2505

2506
                # Normalize by the equivalent width to obtain specific intensity in the mode's bandpass
2507
                specific_intensity_mode = intensity_mode / mode["bandpass"].equivwidth()
1✔
2508

2509
                # Integrate to get intensity in the V band
2510
                intensity_Vband = Observation(
1✔
2511
                    dust_spectrum, self.standard_bands["V"], force="taper"
2512
                ).integrate()
2513
                specific_intensity_Vband = (
1✔
2514
                    intensity_Vband / self.standard_bands["V"].equivwidth()
2515
                )
2516

2517
                # Compute the color scale factor
2518
                color_factor = specific_intensity_mode / specific_intensity_Vband
1✔
2519

2520
                # Store in our mapping
2521
                spec_color_factors[spec] = color_factor
1✔
2522

2523
            # Apply the color factors to all stars based on their spectral type
2524
            for sInd in range(self.nStars):
1✔
2525
                color_factors[sInd] = spec_color_factors[self.Spec[sInd]]
1✔
2526

2527
            # Save the computed color factors to disk
2528
            with open(color_factor_path, "wb") as f:
1✔
2529
                pickle.dump(color_factors, f)
1✔
2530
            self.vprint(f"Star color factors stored in {color_factor_path}")
1✔
2531

2532
        # Extract and return the color factors for the requested star indices
2533
        return color_factors[sInds]
1✔
2534

2535
    def get_exozodi_spectrum(
1✔
2536
        self,
2537
        sInd,
2538
        temp=261.5 * u.K,
2539
        fstar=1.4410428396711273e-07,
2540
        fdust=2490.237961531367,
2541
    ):
2542
        """Create a spectrum that represents the exozodi for the star.
2543

2544
        Args:
2545
            sInd (int):
2546
                Index of the star of interest.
2547
            temp (astropy.units.Quantity):
2548
                Temperature of the dust in Kelvin. Defaults to 261.5 K based on Leinert 1997.
2549
            fstar (float):
2550
                Fraction of starlight scattered by the exozodi. Defaults to 1.44e-7 based on
2551
                the curve fit described in the Fundamental Concepts page of the documentation.
2552
                This constant is dimensionless but it turns the flux of the star into a surface
2553
                brightness (but doesn't change the units because synphot does not support that).
2554
            fdust (float):
2555
                Converts the black body flux into surface brightness. Defaults
2556
                to 2490.24 based on the curve fit described in the Fundamental
2557
                Concepts page of the documentation. Units are the same as
2558
                fstar.
2559
        Returns:
2560
            SourceSpectrum:
2561
                Approximate exozodi spectrum for the star. Note that the fstar and
2562
                fdust factors convert the flux values into specific intensity
2563
                values even though the SourceSpectrum call returns flux units.
2564
        """
2565
        # Using V band for normalization
2566
        star_spectrum = self.get_spectral_template(sInd, None, Vband=True)
1✔
2567

2568
        # The scattering does not contribute past 10 microns, so we can use a simple
2569
        # filter to cut off the spectrum
2570
        filter = SpectralElement(
1✔
2571
            Empirical1D,
2572
            points=[0.01, 10, 10.001, 200] * u.um,
2573
            lookup_table=[1, 1, 0, 0],
2574
        )
2575
        # Combine the various elements to create the spectrum that represents starlight
2576
        # being scattered from the exozodi dust
2577
        starlight_scattering = star_spectrum * fstar * filter
1✔
2578

2579
        # Create a black-body spectrum to describe the dust's thermal emission
2580
        thermal_emission = SourceSpectrum(BlackBodyNorm1D, temperature=temp) * fdust
1✔
2581

2582
        composite_spectrum = starlight_scattering + thermal_emission
1✔
2583
        return composite_spectrum
1✔
2584

2585
    def calc_all_JEZ0(self):
1✔
2586
        """Compute/cache the intensity of exozodi for all stars and observing modes.
2587

2588
        Saves the computed JEZ0 values to disk for faster access in subsequent calls
2589
        on a per-mode basis. These intensity values are later scaled by the number of
2590
        zodis and the planet's orbital radius.
2591
        """
2592
        self.system_fbeta = self.ZodiacalLight.calc_fbeta(self.systemInclination)
1✔
2593
        for mode in self.OpticalSystem.observingModes:
1✔
2594
            fname = (
1✔
2595
                f"TargetList_{self.StarCatalogHex}"
2596
                f"nStars_{self.nStars}_mode_{mode['hex']}.JEZ0"
2597
            )
2598
            JEZ0_path = Path(self.cachedir, fname)
1✔
2599
            if JEZ0_path.exists():
1✔
2600
                # Load cached JEZ0 values from disk
2601
                with open(JEZ0_path, "rb") as f:
1✔
2602
                    self.JEZ0[mode["hex"]] = pickle.load(f)
1✔
2603
                self.vprint(f"Loaded JEZ0 for mode {mode['hex']} from {JEZ0_path}")
1✔
2604
            else:
2605
                color_factors = self.starColorFactor(mode)
1✔
2606
                self.JEZ0[mode["hex"]] = self.ZodiacalLight.calc_JEZ0(
1✔
2607
                    self.MV, self.L, color_factors, mode["bandpass"].equivwidth()
2608
                )
2609
                with open(JEZ0_path, "wb") as f:
1✔
2610
                    pickle.dump(self.JEZ0[mode["hex"]], f)
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