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

dsavransky / EXOSIMS / 14320216378

07 Apr 2025 09:52PM UTC coverage: 66.144% (+0.7%) from 65.466%
14320216378

Pull #408

github

web-flow
Merge 411a00577 into 207b4b0d8
Pull Request #408: Performance optimization

770 of 932 new or added lines in 32 files covered. (82.62%)

19 existing lines in 8 files now uncovered.

9905 of 14975 relevant lines covered (66.14%)

0.66 hits per line

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

63.06
/EXOSIMS/Prototypes/SurveySimulation.py
1
# -*- coding: utf-8 -*-
2
import copy
1✔
3
import hashlib
1✔
4
import inspect
1✔
5
import json
1✔
6
import logging
1✔
7
import os
1✔
8
import random as py_random
1✔
9
import re
1✔
10
import sys
1✔
11
import time
1✔
12
from pathlib import Path
1✔
13
from typing import Any, Dict, Optional
1✔
14

15
import astropy.constants as const
1✔
16
import astropy.units as u
1✔
17
import numpy as np
1✔
18
from astropy.time import Time
1✔
19

20
from EXOSIMS.util._numpy_compat import copy_if_needed
1✔
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.vprint import vprint
1✔
25
from EXOSIMS.util.version_util import get_version
1✔
26

27

28
Logger = logging.getLogger(__name__)
1✔
29

30

31
class SurveySimulation(object):
1✔
32
    """:ref:`SurveySimulation` Prototype
33

34
    Args:
35

36
        scriptfile (str, optional):
37
            JSON script file.  If not set, assumes that dictionary has been
38
            passed through specs. Defaults to None\
39
        ntFlux (int):
40
            Number of intervals to split integration into for computing
41
            total SNR. When greater than 1, SNR is effectively computed as a
42
            Reimann sum. Defaults to 1
43
        nVisitsMax (int):
44
            Maximum number of observations (in detection mode) per star.
45
            Defaults to 5
46
        charMargin (float):
47
            Integration time margin for characterization. Defaults to 0.15
48
        dt_max (float):
49
            Maximum time for revisit window (in days). Defaults to 1.
50
        record_counts_path (str, optional):
51
            If set, write out photon count info to file specified by this keyword.
52
            Defaults to None.
53
        revisit_wait (float, optional):
54
            If set, it is the minimum time (in days) to wait before revisiting current target. Defaults to None.
55
        nokoMap (bool):
56
            Skip generating keepout map. Only useful if you're not planning on
57
            actually running a mission simulation. Defaults to False.
58
        nofZ (bool):
59
            Skip precomputing zodical light minima.  Only useful if you're not
60
            planning on actually running a mission simulation.  Defaults to False.
61
        cachedir (str, optional):
62
            Full path to cachedir.
63
            If None (default) use default (see :ref:`EXOSIMSCACHE`)
64
        defaultAddExoplanetObsTime (bool):
65
            If True, time advancement when no targets are observable will add
66
            to exoplanetObsTime (i.e., wasting time is counted against you).
67
            Defaults to True
68
        find_known_RV (bool):
69
            Identify stars with known planets. Defaults to False
70
        include_known_RV (str, optional):
71
            Path to file including known planets to include. Defaults to None
72
        make_debug_bird_plots (bool, optional):
73
            If True, makes completeness bird plots for every observation that
74
            are saved in the cache directory
75
        debug_plot_path (str, optional):
76
            Path to save the debug plots in, must be set if
77
            make_debug_bird_plots is True
78
        **specs:
79
            :ref:`sec:inputspec`
80

81
    Attributes:
82
        _outspec (dict):
83
            :ref:`sec:outspec`
84
        absTimefZmin (astropy.time.core.Time):
85
            Absolute time of local zodi minima
86
        BackgroundSources (:ref:`BackgroundSources`):
87
            BackgroundSources object
88
        cachedir (str):
89
            Path to the EXOSIMS cache directory (see :ref:`EXOSIMSCACHE`)
90
        cachefname (str):
91
            Base filename for cache files.
92
        charMargin (float):
93
            Integration time margin for characterization.
94
        Completeness (:ref:`Completeness`):
95
            Completeness object
96
        count_lines (list):
97
            Photon counts.  Only used when ``record_counts_path`` is set
98
        defaultAddExoplanetObsTime (bool):
99
            If True, time advancement when no targets are observable will add
100
            to exoplanetObsTime (i.e., wasting time is counted against you).
101
        DRM (list):
102
            The mission simulation.  List of observation dictionaries.
103
        dt_max (astropy.units.quantity.Quantity):
104
            Maximum time for revisit window.
105
        revisit_wait (astropy.units.quantity.Quantity):
106
            Minimum time (in days) to wait before revisiting.
107
        find_known_RV (bool):
108
            Identify stars with known planets.
109
        fullSpectra (numpy.ndarray(bool)):
110
            Array of booleans indicating whether a planet's spectrum has been
111
            fully observed.
112
        fZmins (dict):
113
            Dictionary of local zodi minimum value candidates for each observing mode
114
        fZtypes (dict):
115
            Dictionary of type of local zodi minimum candidates for each observing mode
116
        include_known_RV (str, optional):
117
            Path to file including known planets to include.
118
        intTimeFilterInds (numpy.ndarray(ind)):
119
            Indices of targets where integration times fall below cutoff value
120
        intTimesIntTimeFilter (astropy.units.quantity.Quantity):
121
            Default integration times for pre-filtering targets.
122
        known_earths (numpy.ndarray):
123
            Indices of Earth-like planets
124
        known_rocky (list):
125
            Indices of rocky planets
126
        known_stars (list):
127
            Stars with known planets
128
        koMaps (dict):
129
            Keepout Maps
130
        koTimes (astropy.time.core.Time):
131
            Times corresponding to keepout map array entries.
132
        lastDetected (numpy.ndarray):
133
            ntarg x 4. For each target, contains 4 lists with planets' detected
134
            status (boolean),
135
            exozodi brightness (in units of 1/arcsec2), delta magnitude,
136
            and working angles (in units of arcsec)
137
        lastObsTimes (astropy.units.quantity.Quantity):
138
            Contains the last observation start time for future completeness update
139
            in units of day
140
        logger (logging.Logger):
141
            Logger object
142
        modules (dict):
143
            Modules dictionary.
144
        ntFlux (int):
145
            Number of intervals to split integration into for computing
146
            total SNR. When greater than 1, SNR is effectively computed as a
147
            Reimann sum.
148
        nVisitsMax (int):
149
            Maximum number of observations (in detection mode) per star.
150
        Observatory (:ref:`Observatory`):
151
            Observatory object.
152
        OpticalSystem (:ref:`OpticalSystem`):
153
            Optical system object.
154
        partialSpectra (numpy.ndarray):
155
            Array of booleans indicating whether a planet's spectrum has been
156
            partially observed.
157
        PlanetPhysicalModel (:ref:`PlanetPhysicalModel`):
158
            Planet pysical model object
159
        PlanetPopulation (:ref:`PlanetPopulation`):
160
            Planet population object
161
        PostProcessing (:ref:`PostProcessing`):
162
            Postprocessing object
163
        propagTimes (astropy.units.quantity.Quantity):
164
            Contains the current time at each target system.
165
        record_counts_path (str, optional):
166
            If set, write out photon count info to file specified by this keyword.
167
        seed (int):
168
            Seed for random number generation
169
        SimulatedUniverse (:ref:`SimulatedUniverse`):
170
            Simulated universe object
171
        StarCatalog (:ref:`StarCatalog`):
172
            Star catalog object (only if ``keepStarCatalog`` input is True.
173
        starExtended (numpy.ndarray):
174
            TBD
175
        starRevisit (numpy.ndarray):
176
            ntargs x 2. Contains indices of targets to revisit and revisit times
177
            of these targets in units of day
178
        starVisits (numpy.ndarray):
179
            ntargs x 1. Contains the number of times each target was visited
180
        TargetList (:ref:`TargetList`):
181
            TargetList object.
182
        TimeKeeping (:ref:`TimeKeeping`):
183
            Timekeeping object
184
        valfZmin (astropy.units.quantity.Quantity):
185
            Minimum local zodi for each target.
186
        ZodiacalLight (:ref:`ZodiacalLight`):
187
            Zodiacal light object.
188

189
    """
190

191
    _modtype = "SurveySimulation"
1✔
192

193
    def __init__(
1✔
194
        self,
195
        scriptfile=None,
196
        ntFlux=1,
197
        nVisitsMax=5,
198
        charMargin=0.15,
199
        dt_max=1.0,
200
        record_counts_path=None,
201
        revisit_wait=None,
202
        nokoMap=False,
203
        nofZ=False,
204
        cachedir=None,
205
        defaultAddExoplanetObsTime=True,
206
        find_known_RV=False,
207
        include_known_RV=None,
208
        make_debug_bird_plots=False,
209
        debug_plot_path=None,
210
        **specs,
211
    ):
212
        # Commonly used units
213
        self.zero_d = 0 << u.d
1✔
214
        self.zero_arcsec = 0 * u.arcsec
1✔
215
        self.inv_arcsec2 = 1 / u.arcsec**2
1✔
216
        self.inv_s = 1 / u.s
1✔
217
        self.JEZ_unit = u.ph / u.s / u.m**2 / u.arcsec**2
1✔
218
        self.AU2pc = (1 * u.AU).to_value(u.pc)
1✔
219
        self.rad2arcsec = (1 * u.rad).to_value(u.arcsec)
1✔
220
        self.day2sec = (1 * u.d).to_value(u.s)
1✔
221

222
        # start the outspec
223
        self._outspec = {}
1✔
224

225
        # if a script file is provided read it in. If not set, assumes that
226
        # dictionary has been passed through specs.
227
        if scriptfile is not None:
1✔
228
            assert os.path.isfile(scriptfile), "%s is not a file." % scriptfile
1✔
229

230
            try:
1✔
231
                with open(scriptfile) as ff:
1✔
232
                    script = ff.read()
1✔
233
                specs.update(json.loads(script))
1✔
234
            except ValueError:
1✔
235
                sys.stderr.write("Script file `%s' is not valid JSON." % scriptfile)
1✔
236
                # must re-raise, or the error will be masked
237
                raise
1✔
238

239
            # modules array must be present
240
            if "modules" not in specs:
1✔
241
                raise ValueError("No modules field found in script.")
×
242

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

246
        # count dict contains all of the C info for each star index
247
        self.record_counts_path = record_counts_path
1✔
248
        self.count_lines = []
1✔
249
        self._outspec["record_counts_path"] = record_counts_path
1✔
250

251
        # mission simulation logger
252
        self.logger = specs.get("logger", logging.getLogger(__name__))
1✔
253

254
        # set up numpy random number (generate it if not in specs)
255
        self.seed = int(specs.get("seed", py_random.randint(1, int(1e9))))
1✔
256
        self.vprint("Numpy random seed is: %s" % self.seed)
1✔
257
        np.random.seed(self.seed)
1✔
258
        self._outspec["seed"] = self.seed
1✔
259

260
        # cache directory
261
        self.cachedir = get_cache_dir(cachedir)
1✔
262
        self._outspec["cachedir"] = self.cachedir
1✔
263
        # N.B.: cachedir is going to be used by everything, so let's make sure that
264
        # it doesn't get popped out of specs
265
        specs["cachedir"] = self.cachedir
1✔
266

267
        # if any of the modules is a string, assume that they are all strings
268
        # and we need to initalize
269
        if isinstance(next(iter(specs["modules"].values())), str):
1✔
270
            # import desired module names (prototype or specific)
271
            self.SimulatedUniverse = get_module(
1✔
272
                specs["modules"]["SimulatedUniverse"], "SimulatedUniverse"
273
            )(**specs)
274
            self.Observatory = get_module(
1✔
275
                specs["modules"]["Observatory"], "Observatory"
276
            )(**specs)
277
            self.TimeKeeping = get_module(
1✔
278
                specs["modules"]["TimeKeeping"], "TimeKeeping"
279
            )(**specs)
280

281
            # bring inherited class objects to top level of Survey Simulation
282
            SU = self.SimulatedUniverse
1✔
283
            self.StarCatalog = SU.StarCatalog
1✔
284
            self.PlanetPopulation = SU.PlanetPopulation
1✔
285
            self.PlanetPhysicalModel = SU.PlanetPhysicalModel
1✔
286
            self.OpticalSystem = SU.OpticalSystem
1✔
287
            self.ZodiacalLight = SU.ZodiacalLight
1✔
288
            self.BackgroundSources = SU.BackgroundSources
1✔
289
            self.PostProcessing = SU.PostProcessing
1✔
290
            self.Completeness = SU.Completeness
1✔
291
            self.TargetList = SU.TargetList
1✔
292

293
        else:
294
            # these are the modules that must be present if passing instantiated objects
295
            neededObjMods = [
1✔
296
                "PlanetPopulation",
297
                "PlanetPhysicalModel",
298
                "OpticalSystem",
299
                "ZodiacalLight",
300
                "BackgroundSources",
301
                "PostProcessing",
302
                "Completeness",
303
                "TargetList",
304
                "SimulatedUniverse",
305
                "Observatory",
306
                "TimeKeeping",
307
            ]
308

309
            # ensure that you have the minimal set
310
            for modName in neededObjMods:
1✔
311
                if modName not in specs["modules"]:
1✔
312
                    raise ValueError(
×
313
                        "%s module is required but was not provided." % modName
314
                    )
315

316
            for modName in specs["modules"]:
1✔
317
                assert specs["modules"][modName]._modtype == modName, (
1✔
318
                    "Provided instance of %s has incorrect modtype." % modName
319
                )
320

321
                setattr(self, modName, specs["modules"][modName])
1✔
322

323
        # create a dictionary of all modules, except StarCatalog
324
        self.modules = {}
1✔
325
        self.modules["PlanetPopulation"] = self.PlanetPopulation
1✔
326
        self.modules["PlanetPhysicalModel"] = self.PlanetPhysicalModel
1✔
327
        self.modules["OpticalSystem"] = self.OpticalSystem
1✔
328
        self.modules["ZodiacalLight"] = self.ZodiacalLight
1✔
329
        self.modules["BackgroundSources"] = self.BackgroundSources
1✔
330
        self.modules["PostProcessing"] = self.PostProcessing
1✔
331
        self.modules["Completeness"] = self.Completeness
1✔
332
        self.modules["TargetList"] = self.TargetList
1✔
333
        self.modules["SimulatedUniverse"] = self.SimulatedUniverse
1✔
334
        self.modules["Observatory"] = self.Observatory
1✔
335
        self.modules["TimeKeeping"] = self.TimeKeeping
1✔
336
        # add yourself to modules list for bookkeeping purposes
337
        self.modules["SurveySimulation"] = self
1✔
338

339
        # observation time sampling
340
        self.ntFlux = int(ntFlux)
1✔
341
        self._outspec["ntFlux"] = self.ntFlux
1✔
342

343
        # maximum number of observations per star
344
        self.nVisitsMax = int(nVisitsMax)
1✔
345
        self._outspec["nVisitsMax"] = self.nVisitsMax
1✔
346

347
        # integration time margin for characterization
348
        self.charMargin = float(charMargin)
1✔
349
        self._outspec["charMargin"] = self.charMargin
1✔
350

351
        # maximum time for revisit window
352
        self.dt_max = float(dt_max) * u.week
1✔
353
        self._outspec["dt_max"] = self.dt_max.value
1✔
354

355
        # minimum time for revisit window
356
        if revisit_wait is not None:
1✔
357
            self.revisit_wait = revisit_wait * u.d
×
358
        else:
359
            self.revisit_wait = revisit_wait
1✔
360

361
        # list of detected earth-like planets aroung promoted stars
362
        self.known_earths = np.array([])
1✔
363

364
        self.find_known_RV = find_known_RV
1✔
365
        self._outspec["find_known_RV"] = find_known_RV
1✔
366
        self._outspec["include_known_RV"] = include_known_RV
1✔
367
        if self.find_known_RV:
1✔
368
            # select specific knonw RV stars if a file exists
369
            if include_known_RV is not None:
×
370
                if os.path.isfile(include_known_RV):
×
371
                    with open(include_known_RV, "r") as rv_file:
×
372
                        self.include_known_RV = [
×
373
                            hip.strip() for hip in rv_file.read().split(",")
374
                        ]
375
                        self.vprint(
×
376
                            "Including known RV stars: {}".format(self.include_known_RV)
377
                        )
378
                else:
379
                    self.include_known_RV = None
×
380
                    self.vprint(
×
381
                        "WARNING: Known RV file: {} does not exist!!".format(
382
                            include_known_RV
383
                        )
384
                    )
385
            else:
386
                self.include_known_RV = None
×
387
            self.known_stars, self.known_rocky = self.find_known_plans()
×
388
        else:
389
            self.include_known_RV = None
1✔
390
            self.known_stars = []
1✔
391
            self.known_rocky = []
1✔
392

393
        # defaultAddExoplanetObsTime Tells us time advanced when no targets available
394
        # counts agains exoplanetObsTime (when True)
395
        self.defaultAddExoplanetObsTime = defaultAddExoplanetObsTime
1✔
396
        self._outspec["defaultAddExoplanetObsTime"] = defaultAddExoplanetObsTime
1✔
397

398
        # If inputs are scalars, save scalars to outspec, otherwise save full lists
399
        OS = self.OpticalSystem
1✔
400
        TL = self.TargetList
1✔
401
        SU = self.SimulatedUniverse
1✔
402
        TK = self.TimeKeeping
1✔
403

404
        # initialize arrays updated in run_sim()
405
        self.initializeStorageArrays()
1✔
406

407
        # Generate File Hashnames and loction
408
        self.cachefname = self.generateHashfName(specs)
1✔
409

410
        # choose observing modes selected for detection (default marked with a flag)
411
        allModes = OS.observingModes
1✔
412
        det_mode = list(filter(lambda mode: mode["detectionMode"], allModes))[0]
1✔
413

414
        # getting keepout map for entire mission
415
        startTime = self.TimeKeeping.missionStart.copy()
1✔
416
        endTime = self.TimeKeeping.missionFinishAbs.copy()
1✔
417

418
        nSystems = len(allModes)
1✔
419
        systNames = np.unique(
1✔
420
            [allModes[x]["syst"]["name"] for x in np.arange(nSystems)]
421
        )
422
        systOrder = np.argsort(systNames)
1✔
423
        koStr = ["koAngles_Sun", "koAngles_Moon", "koAngles_Earth", "koAngles_Small"]
1✔
424
        koangles = np.zeros([len(systNames), 4, 2])
1✔
425

426
        for x in systOrder:
1✔
427
            rel_mode = list(
1✔
428
                filter(lambda mode: mode["syst"]["name"] == systNames[x], allModes)
429
            )[0]
430
            koangles[x] = np.asarray([rel_mode["syst"][k] for k in koStr])
1✔
431

432
        # Precalculate Earth positions and create a cubic spline interpolator
433
        # to save time during orbit calculations
434
        dt = 0.1  # days
1✔
435
        self.Observatory.generate_Earth_interpolator(startTime, endTime, dt)
1✔
436

437
        self._outspec["nokoMap"] = nokoMap
1✔
438
        if not (nokoMap):
1✔
439
            koMaps, self.koTimes = self.Observatory.generate_koMap(
1✔
440
                TL, startTime, endTime, koangles
441
            )
442
            self.koTimes_mjd = self.koTimes.mjd
1✔
443
            self.koMaps = {}
1✔
444
            for x, n in zip(systOrder, systNames[systOrder]):
1✔
445
                print(n)
1✔
446
                self.koMaps[n] = koMaps[x, :, :]
1✔
447

448
        self._outspec["nofZ"] = nofZ
1✔
449

450
        # TODO: do we still want a nofZ option?  probably not.
451
        self.fZmins = {}
1✔
452
        self.fZtypes = {}
1✔
453
        for x, n in zip(systOrder, systNames[systOrder]):
1✔
454
            self.fZmins[n] = np.array([])
1✔
455
            self.fZtypes[n] = np.array([])
1✔
456

457
        # TODO: do we need to do this for all modes? doing det only breaks other
458
        # schedulers, but maybe there's a better approach here.
459
        sInds = np.arange(TL.nStars)  # Initialize some sInds array
1✔
460
        for mode in allModes:
1✔
461
            # This instantiates fZMap arrays for every starlight suppresion system
462
            # that is actually used in a mode
463
            modeHashName = (
1✔
464
                f'{self.cachefname[0:-1]}_{TL.nStars}_{mode["syst"]["name"]}'
465
                f"_{startTime.mjd:0}_{endTime.mjd:0}."
466
            )
467

468
            if (np.size(self.fZmins[mode["syst"]["name"]]) == 0) or (
1✔
469
                np.size(self.fZtypes[mode["syst"]["name"]]) == 0
470
            ):
471
                self.ZodiacalLight.generate_fZ(
1✔
472
                    self.Observatory,
473
                    TL,
474
                    self.TimeKeeping,
475
                    mode,
476
                    modeHashName,
477
                    self.koTimes,
478
                )
479

480
                (
1✔
481
                    self.fZmins[mode["syst"]["name"]],
482
                    self.fZtypes[mode["syst"]["name"]],
483
                ) = self.ZodiacalLight.calcfZmin(
484
                    sInds,
485
                    self.Observatory,
486
                    TL,
487
                    self.TimeKeeping,
488
                    mode,
489
                    modeHashName,
490
                    self.koMaps[mode["syst"]["name"]],
491
                    self.koTimes,
492
                )
493

494
        # Precalculating intTimeFilter for coronagraph
495
        # find fZmin to use in intTimeFilter
496
        self.valfZmin, self.absTimefZmin = self.ZodiacalLight.extractfZmin(
1✔
497
            self.fZmins[det_mode["syst"]["name"]], sInds, self.koTimes
498
        )
499
        JEZ0 = self.TargetList.JEZ0[det_mode["hex"]]
1✔
500
        dMag = TL.int_dMag[sInds]  # grabbing dMag
1✔
501
        WA = TL.int_WA[sInds]  # grabbing WA
1✔
502
        self.intTimesIntTimeFilter = (
1✔
503
            self.OpticalSystem.calc_intTime(
504
                TL, sInds, self.valfZmin, JEZ0, dMag, WA, det_mode, TK=TK
505
            )
506
            * det_mode["timeMultiplier"]
507
        )  # intTimes to filter by
508
        # These indices are acceptable for use simulating
509
        self.intTimeFilterInds = np.where(
1✔
510
            (
511
                (self.intTimesIntTimeFilter > 0)
512
                & (self.intTimesIntTimeFilter <= self.OpticalSystem.intCutoff)
513
            )
514
        )[0]
515

516
        self.make_debug_bird_plots = make_debug_bird_plots
1✔
517
        if self.make_debug_bird_plots:
1✔
UNCOV
518
            assert (
×
519
                debug_plot_path is not None
520
            ), "debug_plot_path must be set by input if make_debug_bird_plots is True"
521
            self.obs_plot_path = Path(f"{debug_plot_path}/{self.seed}")
×
522
            # Make directory if it doesn't exist
523
            if not self.obs_plot_path.exists():
×
524
                vprint(f"Making plot directory: {self.obs_plot_path}")
×
525
                self.obs_plot_path.mkdir(parents=True, exist_ok=True)
×
526
            self.obs_n_counter = 0
×
527

528
    def initializeStorageArrays(self):
1✔
529
        """
530
        Initialize all storage arrays based on # of stars and targets
531
        """
532

533
        self.DRM = []
1✔
534
        self.fullSpectra = np.zeros(self.SimulatedUniverse.nPlans, dtype=int)
1✔
535
        self.partialSpectra = np.zeros(self.SimulatedUniverse.nPlans, dtype=int)
1✔
536
        self.propagTimes = np.zeros(self.TargetList.nStars) * u.d
1✔
537
        self.lastObsTimes = np.zeros(self.TargetList.nStars) * u.d
1✔
538
        self.starVisits = np.zeros(
1✔
539
            self.TargetList.nStars, dtype=int
540
        )  # contains the number of times each star was visited
541
        self.starRevisit = np.array([])
1✔
542
        self.starExtended = np.array([], dtype=int)
1✔
543
        self.lastDetected = np.empty((self.TargetList.nStars, 4), dtype=object)
1✔
544

545
    def __str__(self):
1✔
546
        """String representation of the Survey Simulation object
547

548
        When the command 'print' is used on the Survey Simulation object, this
549
        method will return the values contained in the object
550

551
        """
552

553
        for att in self.__dict__:
1✔
554
            print("%s: %r" % (att, getattr(self, att)))
1✔
555

556
        return "Survey Simulation class object attributes"
1✔
557

558
    def run_sim(self):
1✔
559
        """Performs the survey simulation"""
560

561
        OS = self.OpticalSystem
1✔
562
        TL = self.TargetList
1✔
563
        SU = self.SimulatedUniverse
1✔
564
        Obs = self.Observatory
1✔
565
        TK = self.TimeKeeping
1✔
566

567
        # TODO: start using this self.currentSep
568
        # set occulter separation if haveOcculter
569
        if OS.haveOcculter:
1✔
570
            self.currentSep = Obs.occulterSep
×
571

572
        # choose observing modes selected for detection (default marked with a flag)
573
        allModes = OS.observingModes
1✔
574
        det_mode = list(filter(lambda mode: mode["detectionMode"], allModes))[0]
1✔
575
        # and for characterization (default is first spectro/IFS mode)
576
        spectroModes = list(
1✔
577
            filter(lambda mode: "spec" in mode["inst"]["name"], allModes)
578
        )
579
        if np.any(spectroModes):
1✔
580
            char_mode = spectroModes[0]
×
581
        # if no spectro mode, default char mode is first observing mode
582
        else:
583
            char_mode = allModes[0]
1✔
584

585
        # begin Survey, and loop until mission is finished
586
        log_begin = "OB%s: survey beginning." % (TK.OBnumber)
1✔
587
        self.logger.info(log_begin)
1✔
588
        self.vprint(log_begin)
1✔
589
        t0 = time.time()
1✔
590
        sInd = None
1✔
591
        ObsNum = 0
1✔
592
        while not TK.mission_is_over(OS, Obs, det_mode):
1✔
593
            # acquire the NEXT TARGET star index and create DRM
594
            old_sInd = sInd  # used to save sInd if returned sInd is None
1✔
595
            DRM, sInd, det_intTime, waitTime = self.next_target(sInd, det_mode)
1✔
596

597
            if sInd is not None:
1✔
598
                ObsNum += (
1✔
599
                    1  # we're making an observation so increment observation number
600
                )
601

602
                if OS.haveOcculter:
1✔
603
                    # advance to start of observation (add slew time for selected target
604
                    _ = TK.advanceToAbsTime(TK.currentTimeAbs.copy() + waitTime)
×
605

606
                # beginning of observation, start to populate DRM
607
                DRM["star_ind"] = sInd
1✔
608
                DRM["star_name"] = TL.Name[sInd]
1✔
609
                DRM["arrival_time"] = TK.currentTimeNorm.to("day").copy()
1✔
610
                DRM["OB_nb"] = TK.OBnumber
1✔
611
                DRM["ObsNum"] = ObsNum
1✔
612
                pInds = np.where(SU.plan2star == sInd)[0]
1✔
613
                DRM["plan_inds"] = pInds.astype(int)
1✔
614
                log_obs = (
1✔
615
                    "  Observation #%s, star ind %s (of %s) with %s planet(s), "
616
                    + "mission time at Obs start: %s, exoplanetObsTime: %s"
617
                ) % (
618
                    ObsNum,
619
                    sInd,
620
                    TL.nStars,
621
                    len(pInds),
622
                    TK.currentTimeNorm.to("day").copy().round(2),
623
                    TK.exoplanetObsTime.to("day").copy().round(2),
624
                )
625
                self.logger.info(log_obs)
1✔
626
                self.vprint(log_obs)
1✔
627

628
                # PERFORM DETECTION and populate revisit list attribute
629
                (
1✔
630
                    detected,
631
                    det_fZ,
632
                    det_JEZ,
633
                    det_systemParams,
634
                    det_SNR,
635
                    FA,
636
                ) = self.observation_detection(sInd, det_intTime.copy(), det_mode)
637
                # update the occulter wet mass
638
                if OS.haveOcculter:
1✔
639
                    DRM = self.update_occulter_mass(
×
640
                        DRM, sInd, det_intTime.copy(), "det"
641
                    )
642
                # populate the DRM with detection results
643
                DRM["det_time"] = det_intTime.to("day")
1✔
644
                DRM["det_status"] = detected
1✔
645
                DRM["det_SNR"] = det_SNR
1✔
646
                DRM["det_fZ"] = det_fZ.to("1/arcsec2")
1✔
647
                DRM["det_JEZ"] = det_JEZ.to("ph/(s m2 arcsec2)")
1✔
648
                DRM["det_params"] = det_systemParams
1✔
649

650
                # PERFORM CHARACTERIZATION and populate spectra list attribute
651
                if char_mode["SNR"] not in [0, np.inf]:
1✔
652
                    (
1✔
653
                        characterized,
654
                        char_fZ,
655
                        char_JEZ,
656
                        char_systemParams,
657
                        char_SNR,
658
                        char_intTime,
659
                    ) = self.observation_characterization(sInd, char_mode)
660
                else:
661
                    char_intTime = None
×
662
                    lenChar = len(pInds) + 1 if FA else len(pInds)
×
663
                    characterized = np.zeros(lenChar, dtype=float)
×
664
                    char_SNR = np.zeros(lenChar, dtype=float)
×
665
                    char_fZ = 0.0 / u.arcsec**2
×
666
                    char_systemParams = SU.dump_system_params(sInd)
×
667
                assert char_intTime != 0, "Integration time can't be 0."
1✔
668
                # update the occulter wet mass
669
                if OS.haveOcculter and (char_intTime is not None):
1✔
670
                    DRM = self.update_occulter_mass(DRM, sInd, char_intTime, "char")
×
671
                # populate the DRM with characterization results
672
                DRM["char_time"] = (
1✔
673
                    char_intTime.to("day") if char_intTime is not None else 0.0 * u.day
674
                )
675
                DRM["char_status"] = characterized[:-1] if FA else characterized
1✔
676
                DRM["char_SNR"] = char_SNR[:-1] if FA else char_SNR
1✔
677
                DRM["char_fZ"] = char_fZ.to("1/arcsec2")
1✔
678
                DRM["char_params"] = char_systemParams
1✔
679
                # populate the DRM with FA results
680
                DRM["FA_det_status"] = int(FA)
1✔
681
                DRM["FA_char_status"] = characterized[-1] if FA else 0
1✔
682
                DRM["FA_char_SNR"] = char_SNR[-1] if FA else 0.0
1✔
683
                DRM["FA_char_JEZ"] = (
1✔
684
                    self.lastDetected[sInd, 1][-1]
685
                    if FA
686
                    else 0.0 * u.ph / u.s / u.m**2 / u.arcsec**2
687
                )
688
                DRM["FA_char_dMag"] = self.lastDetected[sInd, 2][-1] if FA else 0.0
1✔
689
                DRM["FA_char_WA"] = (
1✔
690
                    self.lastDetected[sInd, 3][-1] * u.arcsec if FA else 0.0 * u.arcsec
691
                )
692

693
                # populate the DRM with observation modes
694
                DRM["det_mode"] = dict(det_mode)
1✔
695
                del DRM["det_mode"]["inst"], DRM["det_mode"]["syst"]
1✔
696
                DRM["char_mode"] = dict(char_mode)
1✔
697
                del DRM["char_mode"]["inst"], DRM["char_mode"]["syst"]
1✔
698
                DRM["exoplanetObsTime"] = TK.exoplanetObsTime.copy()
1✔
699

700
                # append result values to self.DRM
701
                self.DRM.append(DRM)
1✔
702

703
                # handle case of inf OBs and missionPortion < 1
704
                if np.isinf(TK.OBduration) and (TK.missionPortion < 1.0):
1✔
705
                    self.arbitrary_time_advancement(
1✔
706
                        TK.currentTimeNorm.to("day").copy() - DRM["arrival_time"]
707
                    )
708

709
            else:  # sInd == None
710
                sInd = old_sInd  # Retain the last observed star
×
711
                if (
×
712
                    TK.currentTimeNorm.copy() >= TK.OBendTimes[TK.OBnumber]
713
                ):  # currentTime is at end of OB
714
                    # Conditional Advance To Start of Next OB
715
                    if not TK.mission_is_over(
×
716
                        OS, Obs, det_mode
717
                    ):  # as long as the mission is not over
718
                        TK.advancetToStartOfNextOB()  # Advance To Start of Next OB
×
719
                elif waitTime is not None:
×
720
                    # CASE 1: Advance specific wait time
721
                    _ = TK.advanceToAbsTime(
×
722
                        TK.currentTimeAbs.copy() + waitTime,
723
                        self.defaultAddExoplanetObsTime,
724
                    )
725
                    self.vprint("waitTime is not None")
×
726
                else:
727
                    startTimes = (
×
728
                        TK.currentTimeAbs.copy() + np.zeros(TL.nStars) * u.d
729
                    )  # Start Times of Observations
730
                    observableTimes = Obs.calculate_observableTimes(
×
731
                        TL,
732
                        np.arange(TL.nStars),
733
                        startTimes,
734
                        self.koMaps,
735
                        self.koTimes,
736
                        det_mode,
737
                    )[0]
738
                    # CASE 2 If There are no observable targets for the rest of the
739
                    # mission
740
                    if (
×
741
                        observableTimes[
742
                            (
743
                                TK.missionFinishAbs.copy().value * u.d
744
                                > observableTimes.value * u.d
745
                            )
746
                            * (
747
                                observableTimes.value * u.d
748
                                >= TK.currentTimeAbs.copy().value * u.d
749
                            )
750
                        ].shape[0]
751
                    ) == 0:
752
                        self.vprint(
×
753
                            (
754
                                "No Observable Targets for Remainder of mission at "
755
                                "currentTimeNorm = {}"
756
                            ).format(TK.currentTimeNorm)
757
                        )
758
                        # Manually advancing time to mission end
759
                        TK.currentTimeNorm = TK.missionLife
×
760
                        TK.currentTimeAbs = TK.missionFinishAbs
×
761
                    # CASE 3 nominal wait time if at least 1 target is still in list
762
                    # and observable
763
                    else:
764
                        # TODO: ADD ADVANCE TO WHEN FZMIN OCURS
765
                        inds1 = np.arange(TL.nStars)[
×
766
                            observableTimes.value * u.d
767
                            > TK.currentTimeAbs.copy().value * u.d
768
                        ]
769
                        # apply intTime filter
770
                        inds2 = np.intersect1d(self.intTimeFilterInds, inds1)
×
771
                        # apply revisit Filter #NOTE this means stars you added to the
772
                        # revisit list
773
                        inds3 = self.revisitFilter(
×
774
                            inds2, TK.currentTimeNorm.copy() + self.dt_max.to(u.d)
775
                        )
776
                        self.vprint(
×
777
                            "Filtering %d stars from advanceToAbsTime"
778
                            % (TL.nStars - len(inds3))
779
                        )
780
                        oTnowToEnd = observableTimes[inds3]
×
781
                        # there is at least one observableTime between now and the end
782
                        # of the mission
783
                        if not oTnowToEnd.value.shape[0] == 0:
×
784
                            # advance to that observable time
785
                            tAbs = np.min(oTnowToEnd)
×
786
                        else:
787
                            tAbs = (
×
788
                                TK.missionStart + TK.missionLife
789
                            )  # advance to end of mission
790
                        tmpcurrentTimeNorm = TK.currentTimeNorm.copy()
×
791
                        # Advance Time to this time OR start of next
792
                        # OB following this time
793
                        _ = TK.advanceToAbsTime(tAbs, self.defaultAddExoplanetObsTime)
×
794
                        self.vprint(
×
795
                            (
796
                                "No Observable Targets a currentTimeNorm= {:.2f}. "
797
                                "Advanced To {:.2f}"
798
                            ).format(
799
                                tmpcurrentTimeNorm.to("day"),
800
                                TK.currentTimeNorm.to("day"),
801
                            )
802
                        )
803
        else:  # TK.mission_is_over()
804
            dtsim = (time.time() - t0) * u.s
1✔
805
            log_end = (
1✔
806
                "Mission complete: no more time available.\n"
807
                + "Simulation duration: %s.\n" % dtsim.astype("int")
808
                + "Results stored in SurveySimulation.DRM (Design Reference Mission)."
809
            )
810
            self.logger.info(log_end)
1✔
811
            self.vprint(log_end)
1✔
812

813
    def arbitrary_time_advancement(self, dt):
1✔
814
        """Handles fully dynamically scheduled case where OBduration is infinite and
815
        missionPortion is less than 1.
816

817
        Args:
818
            dt (~astropy.units.quantity.Quantity):
819
                Total amount of time, including all overheads and extras used for the
820
                previous observation.
821

822
        Returns:
823
            None
824
        """
825

826
        self.TimeKeeping.allocate_time(
1✔
827
            dt
828
            * (1.0 - self.TimeKeeping.missionPortion)
829
            / self.TimeKeeping.missionPortion,
830
            addExoplanetObsTime=False,
831
        )
832

833
    def next_target(self, old_sInd, mode):
1✔
834
        """Finds index of next target star and calculates its integration time.
835

836
        This method chooses the next target star index based on which
837
        stars are available, their integration time, and maximum completeness.
838
        Returns None if no target could be found.
839

840
        Args:
841
            old_sInd (int):
842
                Index of the previous target star
843
            mode (dict):
844
                Selected observing mode for detection
845

846
        Returns:
847
            tuple:
848
                DRM (dict):
849
                    Design Reference Mission, contains the results of one complete
850
                    observation (detection and characterization)
851
                sInd (int):
852
                    Index of next target star. Defaults to None.
853
                intTime (astropy.units.Quantity):
854
                    Selected star integration time for detection in units of day.
855
                    Defaults to None.
856
                waitTime (astropy.units.Quantity):
857
                    a strategically advantageous amount of time to wait in the case
858
                    of an occulter for slew times
859

860
        """
861
        OS = self.OpticalSystem
1✔
862
        TL = self.TargetList
1✔
863
        Obs = self.Observatory
1✔
864
        TK = self.TimeKeeping
1✔
865

866
        # create DRM
867
        DRM = {}
1✔
868

869
        # allocate settling time + overhead time
870
        tmpCurrentTimeAbs = (
1✔
871
            TK.currentTimeAbs.copy() + Obs.settlingTime + mode["syst"]["ohTime"]
872
        )
873
        tmpCurrentTimeNorm = (
1✔
874
            TK.currentTimeNorm.copy() + Obs.settlingTime + mode["syst"]["ohTime"]
875
        )
876

877
        # create appropriate koMap
878
        koMap = self.koMaps[mode["syst"]["name"]]
1✔
879

880
        # look for available targets
881
        # 1. initialize arrays
882
        slewTimes = np.zeros(TL.nStars) * u.d
1✔
883
        # fZs = np.zeros(TL.nStars) / u.arcsec**2.0
884
        dV = np.zeros(TL.nStars) * u.m / u.s
1✔
885
        intTimes = np.zeros(TL.nStars) * u.d
1✔
886
        obsTimes = np.zeros([2, TL.nStars]) * u.d
1✔
887
        sInds = np.arange(TL.nStars)
1✔
888

889
        # 2. find spacecraft orbital START positions (if occulter, positions
890
        # differ for each star) and filter out unavailable targets
891
        sd = None
1✔
892
        if OS.haveOcculter:
1✔
893
            sd = Obs.star_angularSep(TL, old_sInd, sInds, tmpCurrentTimeAbs)
×
894
            obsTimes = Obs.calculate_observableTimes(
×
895
                TL, sInds, tmpCurrentTimeAbs, self.koMaps, self.koTimes, mode
896
            )
897
            slewTimes = Obs.calculate_slewTimes(
×
898
                TL, old_sInd, sInds, sd, obsTimes, tmpCurrentTimeAbs
899
            )
900

901
        # 2.1 filter out totTimes > integration cutoff
902
        if len(sInds.tolist()) > 0:
1✔
903
            sInds = np.intersect1d(self.intTimeFilterInds, sInds)
1✔
904

905
        # start times, including slew times
906
        startTimes = tmpCurrentTimeAbs.copy() + slewTimes
1✔
907
        startTimesNorm = tmpCurrentTimeNorm.copy() + slewTimes
1✔
908

909
        # 2.5 Filter stars not observable at startTimes
910
        try:
1✔
911
            tmpIndsbool = list()
1✔
912
            for i in np.arange(len(sInds)):
1✔
913
                koTimeInd = np.where(
1✔
914
                    np.round(startTimes[sInds[i]].value) - self.koTimes.value == 0
915
                )[0][
916
                    0
917
                ]  # find indice where koTime is startTime[0]
918
                tmpIndsbool.append(
1✔
919
                    koMap[sInds[i]][koTimeInd].astype(bool)
920
                )  # Is star observable at time ind
921
            sInds = sInds[tmpIndsbool]
1✔
922
            del tmpIndsbool
1✔
923
        except:  # noqa: E722 # If there are no target stars to observe
×
924
            sInds = np.asarray([], dtype=int)
×
925

926
        # 3. filter out all previously (more-)visited targets, unless in
927
        if len(sInds.tolist()) > 0:
1✔
928
            sInds = self.revisitFilter(sInds, tmpCurrentTimeNorm)
1✔
929

930
        # 4.1 calculate integration times for ALL preselected targets
931
        (
1✔
932
            maxIntTimeOBendTime,
933
            maxIntTimeExoplanetObsTime,
934
            maxIntTimeMissionLife,
935
        ) = TK.get_ObsDetectionMaxIntTime(Obs, mode)
936
        maxIntTime = min(
1✔
937
            maxIntTimeOBendTime, maxIntTimeExoplanetObsTime, maxIntTimeMissionLife
938
        )  # Maximum intTime allowed
939

940
        if len(sInds.tolist()) > 0:
1✔
941
            if OS.haveOcculter and (old_sInd is not None):
1✔
942
                (
×
943
                    sInds,
944
                    slewTimes[sInds],
945
                    intTimes[sInds],
946
                    dV[sInds],
947
                ) = self.refineOcculterSlews(
948
                    old_sInd, sInds, slewTimes, obsTimes, sd, mode
949
                )
950
                endTimes = tmpCurrentTimeAbs.copy() + intTimes + slewTimes
×
951
            else:
952
                intTimes[sInds] = self.calc_targ_intTime(sInds, startTimes[sInds], mode)
1✔
953
                sInds = sInds[
1✔
954
                    np.where(intTimes[sInds] <= maxIntTime)
955
                ]  # Filters targets exceeding end of OB
956
                endTimes = tmpCurrentTimeAbs.copy() + intTimes
1✔
957

958
                if maxIntTime.value <= 0:
1✔
959
                    sInds = np.asarray([], dtype=int)
×
960

961
        # 5.1 TODO Add filter to filter out stars entering and exiting keepout
962
        # between startTimes and endTimes
963

964
        # 5.2 find spacecraft orbital END positions (for each candidate target),
965
        # and filter out unavailable targets
966
        if len(sInds.tolist()) > 0 and Obs.checkKeepoutEnd:
1✔
967
            # endTimes may exist past koTimes so we have an exception to hand this case
968
            try:
1✔
969
                tmpIndsbool = list()
1✔
970
                for i in np.arange(len(sInds)):
1✔
971
                    koTimeInd = np.where(
1✔
972
                        np.round(endTimes[sInds[i]].value) - self.koTimes.value == 0
973
                    )[0][
974
                        0
975
                    ]  # find indice where koTime is endTime[0]
976
                    tmpIndsbool.append(
1✔
977
                        koMap[sInds[i]][koTimeInd].astype(bool)
978
                    )  # Is star observable at time ind
979
                sInds = sInds[tmpIndsbool]
1✔
980
                del tmpIndsbool
1✔
981
            except:  # noqa: E722
×
982
                sInds = np.asarray([], dtype=int)
×
983

984
        # 6. choose best target from remaining
985
        if len(sInds.tolist()) > 0:
1✔
986
            # choose sInd of next target
987
            sInd, waitTime = self.choose_next_target(
1✔
988
                old_sInd, sInds, slewTimes, intTimes[sInds]
989
            )
990

991
            # Should Choose Next Target decide there are no stars it wishes to
992
            # observe at this time.
993
            if sInd is None and (waitTime is not None):
1✔
994
                self.vprint(
×
995
                    "There are no stars available to observe. Waiting {}".format(
996
                        waitTime
997
                    )
998
                )
999
                return DRM, None, None, waitTime
×
1000
            elif (sInd is None) and (waitTime is None):
1✔
1001
                self.vprint(
×
1002
                    "There are no stars available to observe and waitTime is None."
1003
                )
1004
                return DRM, None, None, waitTime
×
1005
            # store selected star integration time
1006
            intTime = intTimes[sInd]
1✔
1007

1008
        # if no observable target, advanceTime to next Observable Target
1009
        else:
1010
            self.vprint(
×
1011
                "No Observable Targets at currentTimeNorm= "
1012
                + str(TK.currentTimeNorm.copy())
1013
            )
1014
            return DRM, None, None, None
×
1015

1016
        # update visited list for selected star
1017
        self.starVisits[sInd] += 1
1✔
1018
        # store normalized start time for future completeness update
1019
        self.lastObsTimes[sInd] = startTimesNorm[sInd]
1✔
1020

1021
        # populate DRM with occulter related values
1022
        if OS.haveOcculter:
1✔
1023
            DRM = Obs.log_occulterResults(
×
1024
                DRM, slewTimes[sInd], sInd, sd[sInd], dV[sInd]
1025
            )
1026
            return DRM, sInd, intTime, slewTimes[sInd]
×
1027

1028
        return DRM, sInd, intTime, waitTime
1✔
1029

1030
    def calc_targ_intTime(self, sInds, startTimes, mode):
1✔
1031
        """Helper method for next_target to aid in overloading for alternative
1032
        implementations.
1033

1034
        Given a subset of targets, calculate their integration times given the
1035
        start of observation time.
1036

1037
        Prototype just calculates integration times for fixed contrast depth.
1038

1039
        Args:
1040
            sInds (~numpy.ndarray(int)):
1041
                Indices of available targets
1042
            startTimes (astropy quantity array):
1043
                absolute start times of observations.
1044
                must be of the same size as sInds
1045
            mode (dict):
1046
                Selected observing mode for detection
1047

1048
        Returns:
1049
            ~astropy.units.Quantity(~numpy.ndarray(float)):
1050
                Integration times for detection
1051
                same dimension as sInds
1052

1053
        .. note::
1054

1055
            next_target filter will discard targets with zero integration times.
1056

1057
        """
1058

1059
        TL = self.TargetList
1✔
1060

1061
        # assumed values for detection
1062
        fZ = self.ZodiacalLight.fZ(
1✔
1063
            self.Observatory, self.TargetList, sInds, startTimes, mode
1064
        )
1065
        # Use the default exozodi intensities
1066
        JEZ = TL.JEZ0[mode["hex"]][sInds]
1✔
1067
        dMag = TL.int_dMag[sInds]
1✔
1068
        WA = TL.int_WA[sInds]
1✔
1069

1070
        # save out file containing photon count info
1071
        if self.record_counts_path is not None and len(self.count_lines) == 0:
1✔
1072
            C_p, C_b, C_sp, C_extra = self.OpticalSystem.Cp_Cb_Csp(
×
1073
                self.TargetList, sInds, fZ, JEZ, dMag, WA, mode, returnExtra=True
1074
            )
1075
            import csv
×
1076

1077
            count_fpath = os.path.join(self.record_counts_path, "counts")
×
1078

1079
            if not os.path.exists(count_fpath):
×
1080
                os.mkdir(count_fpath)
×
1081

1082
            outfile = os.path.join(count_fpath, str(self.seed) + ".csv")
×
1083
            self.count_lines.append(
×
1084
                [
1085
                    "sInd",
1086
                    "HIPs",
1087
                    "C_F0",
1088
                    "C_p0",
1089
                    "C_sr",
1090
                    "C_z",
1091
                    "C_ez",
1092
                    "C_dc",
1093
                    "C_cc",
1094
                    "C_rn",
1095
                    "C_p",
1096
                    "C_b",
1097
                    "C_sp",
1098
                ]
1099
            )
1100

1101
            for i, sInd in enumerate(sInds):
×
1102
                self.count_lines.append(
×
1103
                    [
1104
                        sInd,
1105
                        self.TargetList.Name[sInd],
1106
                        C_extra["C_F0"][0].value,
1107
                        C_extra["C_sr"][i].value,
1108
                        C_extra["C_z"][i].value,
1109
                        C_extra["C_ez"][i].value,
1110
                        C_extra["C_dc"][i].value,
1111
                        C_extra["C_cc"][i].value,
1112
                        C_extra["C_rn"][i].value,
1113
                        C_p[i].value,
1114
                        C_b[i].value,
1115
                        C_sp[i].value,
1116
                    ]
1117
                )
1118

1119
            with open(outfile, "w") as csvfile:
×
1120
                c = csv.writer(csvfile)
×
1121
                c.writerows(self.count_lines)
×
1122

1123
        intTimes = self.OpticalSystem.calc_intTime(
1✔
1124
            self.TargetList, sInds, fZ, JEZ, dMag, WA, mode
1125
        )
1126
        intTimes[~np.isfinite(intTimes)] = 0 * u.d
1✔
1127

1128
        return intTimes
1✔
1129

1130
    def choose_next_target(self, old_sInd, sInds, slewTimes, intTimes):
1✔
1131
        """Helper method for method next_target to simplify alternative implementations.
1132

1133
        Given a subset of targets (pre-filtered by method next_target or some
1134
        other means), select the best next one. The prototype uses completeness
1135
        as the sole heuristic.
1136

1137
        Args:
1138
            old_sInd (int):
1139
                Index of the previous target star
1140
            sInds (~numpy.ndarray(int)):
1141
                Indices of available targets
1142
            slewTimes (~astropy.units.Quantity(~numpy.ndarray(float))):
1143
                slew times to all stars (must be indexed by sInds)
1144
            intTimes (~astropy.units.Quantity(~numpy.ndarray(float))):
1145
                Integration times for detection in units of day
1146

1147
        Returns:
1148
            tuple:
1149
                sInd (int):
1150
                    Index of next target star
1151
                waitTime (:py:class:`~astropy.units.Quantity`):
1152
                    Some strategic amount of time to wait in case an occulter slew is
1153
                    desired (default is None)
1154

1155
        """
1156

1157
        Comp = self.Completeness
1✔
1158
        TL = self.TargetList
1✔
1159
        TK = self.TimeKeeping
1✔
1160
        OS = self.OpticalSystem
1✔
1161
        Obs = self.Observatory
1✔
1162
        allModes = OS.observingModes
1✔
1163

1164
        # cast sInds to array
1165
        sInds = np.array(sInds, ndmin=1, copy=copy_if_needed)
1✔
1166
        # calculate dt since previous observation
1167
        dt = TK.currentTimeNorm.copy() + slewTimes[sInds] - self.lastObsTimes[sInds]
1✔
1168
        # get dynamic completeness values
1169
        comps = Comp.completeness_update(TL, sInds, self.starVisits[sInds], dt)
1✔
1170
        # choose target with maximum completeness
1171
        sInd = np.random.choice(sInds[comps == max(comps)])
1✔
1172

1173
        # Check if exoplanetObsTime would be exceeded
1174
        mode = list(filter(lambda mode: mode["detectionMode"], allModes))[0]
1✔
1175
        (
1✔
1176
            maxIntTimeOBendTime,
1177
            maxIntTimeExoplanetObsTime,
1178
            maxIntTimeMissionLife,
1179
        ) = TK.get_ObsDetectionMaxIntTime(Obs, mode)
1180
        maxIntTime = min(
1✔
1181
            maxIntTimeOBendTime, maxIntTimeExoplanetObsTime, maxIntTimeMissionLife
1182
        )  # Maximum intTime allowed
1183
        intTimes2 = self.calc_targ_intTime(
1✔
1184
            np.array([sInd]), TK.currentTimeAbs.copy(), mode
1185
        )
1186
        if (
1✔
1187
            intTimes2 > maxIntTime
1188
        ):  # check if max allowed integration time would be exceeded
1189
            self.vprint("max allowed integration time would be exceeded")
×
1190
            sInd = None
×
1191
            waitTime = 1.0 * u.d
×
1192
            return sInd, waitTime
×
1193

1194
        return (
1✔
1195
            sInd,
1196
            slewTimes[sInd],
1197
        )  # if coronagraph or first sInd, waitTime will be 0 days
1198

1199
    def refineOcculterSlews(self, old_sInd, sInds, slewTimes, obsTimes, sd, mode):
1✔
1200
        """Refines/filters/chooses occulter slews based on time constraints
1201

1202
        Refines the selection of occulter slew times by filtering based on mission time
1203
        constraints and selecting the best slew time for each star. This method calls on
1204
        other occulter methods within SurveySimulation depending on how slew times were
1205
        calculated prior to calling this function (i.e. depending on which
1206
        implementation of the Observatory module is used).
1207

1208
        Args:
1209
            old_sInd (int):
1210
                Index of the previous target star
1211
            sInds (~numpy.ndarray(int)):
1212
                Indices of available targets
1213
            slewTimes (astropy quantity array):
1214
                slew times to all stars (must be indexed by sInds)
1215
            obsTimes (~astropy.units.Quantity(~numpy.ndarray(float))):
1216
                A binary array with TargetList.nStars rows and
1217
                (missionFinishAbs-missionStart)/dt columns
1218
                where dt is 1 day by default. A value of 1 indicates the star is in
1219
                keepout (and therefore cannot be observed). A value of 0 indicates the
1220
                star is not in keepout and may be observed.
1221
            sd (~astropy.units.Quantity(~numpy.ndarray(float))):
1222
                Angular separation between stars in rad
1223
            mode (dict):
1224
                Selected observing mode for detection
1225

1226
        Returns:
1227
            tuple:
1228
                sInds (int):
1229
                    Indeces of next target star
1230
                slewTimes (astropy.units.Quantity(numpy.ndarray(float))):
1231
                    slew times to all stars (must be indexed by sInds)
1232
                intTimes (astropy.units.Quantity(numpy.ndarray(float))):
1233
                    Integration times for detection in units of day
1234
                dV (astropy.units.Quantity(numpy.ndarray(float))):
1235
                    Delta-V used to transfer to new star line of sight in unis of m/s
1236
        """
1237

1238
        Obs = self.Observatory
×
1239
        TL = self.TargetList
×
1240

1241
        # initializing arrays
1242
        obsTimeArray = np.zeros([TL.nStars, 50]) * u.d
×
1243
        intTimeArray = np.zeros([TL.nStars, 2]) * u.d
×
1244

1245
        for n in sInds:
×
1246
            obsTimeArray[n, :] = (
×
1247
                np.linspace(obsTimes[0, n].value, obsTimes[1, n].value, 50) * u.d
1248
            )
1249
        intTimeArray[sInds, 0] = self.calc_targ_intTime(
×
1250
            sInds, Time(obsTimeArray[sInds, 0], format="mjd", scale="tai"), mode
1251
        )
1252
        intTimeArray[sInds, 1] = self.calc_targ_intTime(
×
1253
            sInds, Time(obsTimeArray[sInds, -1], format="mjd", scale="tai"), mode
1254
        )
1255

1256
        # determining which scheme to use to filter slews
1257
        obsModName = Obs.__class__.__name__
×
1258

1259
        # slew times have not been calculated/decided yet (SotoStarshade)
1260
        if obsModName == "SotoStarshade":
×
1261
            sInds, intTimes, slewTimes, dV = self.findAllowableOcculterSlews(
×
1262
                sInds,
1263
                old_sInd,
1264
                sd[sInds],
1265
                slewTimes[sInds],
1266
                obsTimeArray[sInds, :],
1267
                intTimeArray[sInds, :],
1268
                mode,
1269
            )
1270

1271
        # slew times were calculated/decided beforehand (Observatory Prototype)
1272
        else:
1273
            sInds, intTimes, slewTimes = self.filterOcculterSlews(
×
1274
                sInds,
1275
                slewTimes[sInds],
1276
                obsTimeArray[sInds, :],
1277
                intTimeArray[sInds, :],
1278
                mode,
1279
            )
1280
            dV = np.zeros(len(sInds)) * u.m / u.s
×
1281

1282
        return sInds, slewTimes, intTimes, dV
×
1283

1284
    def filterOcculterSlews(self, sInds, slewTimes, obsTimeArray, intTimeArray, mode):
1✔
1285
        """Filters occulter slews that have already been calculated/selected.
1286

1287
        Used by the refineOcculterSlews method when slew times have been selected
1288
        a priori. This method filters out slews that are not within desired observing
1289
        blocks, the maximum allowed integration time, and are outside of future
1290
        keepouts.
1291

1292
        Args:
1293
            sInds (~numpy.ndarray(int)):
1294
                Indices of available targets
1295
            slewTimes (~astropy.units.Quantity(~numpy.ndarray(float))):
1296
                slew times to all stars (must be indexed by sInds)
1297
            obsTimeArray (~astropy.units.Quantity(~numpy.ndarray(float))):
1298
                Array of times during which a star is out of keepout, has shape
1299
                nx50 where n is the number of stars in sInds. Unit of days
1300
            intTimeArray (~astropy.units.Quantity(~numpy.ndarray(float))):
1301
                Array of integration times for each time in obsTimeArray, has shape
1302
                nx2 where n is the number of stars in sInds. Unit of days
1303
            mode (dict):
1304
                Selected observing mode for detection
1305

1306
        Returns:
1307
            tuple:
1308
                sInds (int):
1309
                    Indeces of next target star
1310
                intTimes (astropy.units.Quantity(numpy.ndarray(float))):
1311
                    Integration times for detection in units of day
1312
                slewTimes (astropy.units.Quantity(numpy.ndarray(float))):
1313
                    slew times to all stars (must be indexed by sInds)
1314
        """
1315

1316
        TK = self.TimeKeeping
×
1317
        Obs = self.Observatory
×
1318

1319
        # allocate settling time + overhead time
1320
        tmpCurrentTimeAbs = (
×
1321
            TK.currentTimeAbs.copy() + Obs.settlingTime + mode["syst"]["ohTime"]
1322
        )
1323
        tmpCurrentTimeNorm = (
×
1324
            TK.currentTimeNorm.copy() + Obs.settlingTime + mode["syst"]["ohTime"]
1325
        )
1326

1327
        # 0. lambda function that linearly interpolates Integration Time
1328
        # between obsTimes
1329
        linearInterp = lambda y, x, t: np.diff(y) / np.diff(x) * (  # noqa: E731
×
1330
            t - np.array(x[:, 0]).reshape(len(t), 1)
1331
        ) + np.array(y[:, 0]).reshape(len(t), 1)
1332

1333
        # 1. initializing arrays
1334
        obsTimesRange = np.array(
×
1335
            [obsTimeArray[:, 0], obsTimeArray[:, -1]]
1336
        )  # nx2 array with start and end times of obsTimes for each star
1337
        intTimesRange = np.array([intTimeArray[:, 0], intTimeArray[:, -1]])
×
1338

1339
        OBnumbers = np.zeros(
×
1340
            [len(sInds), 1]
1341
        )  # for each sInd, will show during which OB observations will take place
1342
        maxIntTimes = np.zeros([len(sInds), 1]) * u.d
×
1343

1344
        intTimes = (
×
1345
            linearInterp(
1346
                intTimesRange.T,
1347
                obsTimesRange.T,
1348
                (tmpCurrentTimeAbs + slewTimes).reshape(len(sInds), 1).value,
1349
            )
1350
            * u.d
1351
        )  # calculate intTimes for each slew time
1352

1353
        minObsTimeNorm = (obsTimesRange[0, :] - tmpCurrentTimeAbs.value).reshape(
×
1354
            [len(sInds), 1]
1355
        )
1356
        maxObsTimeNorm = (obsTimesRange[1, :] - tmpCurrentTimeAbs.value).reshape(
×
1357
            [len(sInds), 1]
1358
        )
1359
        ObsTimeRange = maxObsTimeNorm - minObsTimeNorm
×
1360

1361
        # 2. find OBnumber for each sInd's slew time
1362
        if len(TK.OBendTimes) > 1:
×
1363
            for i in range(len(sInds)):
×
1364
                S = np.where(
×
1365
                    TK.OBstartTimes.value - tmpCurrentTimeNorm.value
1366
                    < slewTimes[i].value
1367
                )[0][-1]
1368
                F = np.where(
×
1369
                    TK.OBendTimes.value - tmpCurrentTimeNorm.value < slewTimes[i].value
1370
                )[0]
1371

1372
                # case when slews are in the first OB
1373
                if F.shape[0] == 0:
×
1374
                    F = -1
×
1375
                else:
1376
                    F = F[-1]
×
1377

1378
                # slew occurs within an OB (nth OB has started but hasn't ended)
1379
                if S != F:
×
1380
                    OBnumbers[i] = S
×
1381
                    (
×
1382
                        maxIntTimeOBendTime,
1383
                        maxIntTimeExoplanetObsTime,
1384
                        maxIntTimeMissionLife,
1385
                    ) = TK.get_ObsDetectionMaxIntTime(Obs, mode, TK.OBstartTimes[S], S)
1386
                    maxIntTimes[i] = min(
×
1387
                        maxIntTimeOBendTime,
1388
                        maxIntTimeExoplanetObsTime,
1389
                        maxIntTimeMissionLife,
1390
                    )  # Maximum intTime allowed
1391

1392
                # slew occurs between OBs, badbadnotgood
1393
                else:
1394
                    OBnumbers[i] = -1
×
1395
                    maxIntTimes[i] = 0 * u.d
×
1396
            OBstartTimeNorm = (
×
1397
                TK.OBstartTimes[np.array(OBnumbers, dtype=int)].value
1398
                - tmpCurrentTimeNorm.value
1399
            )
1400
        else:
1401
            (
×
1402
                maxIntTimeOBendTime,
1403
                maxIntTimeExoplanetObsTime,
1404
                maxIntTimeMissionLife,
1405
            ) = TK.get_ObsDetectionMaxIntTime(Obs, mode, tmpCurrentTimeNorm)
1406
            maxIntTimes[:] = min(
×
1407
                maxIntTimeOBendTime, maxIntTimeExoplanetObsTime, maxIntTimeMissionLife
1408
            )  # Maximum intTime allowed
1409
            OBstartTimeNorm = np.zeros(OBnumbers.shape)
×
1410

1411
        # finding the minimum possible slew time
1412
        # (either OBstart or when star JUST leaves keepout)
1413
        minAllowedSlewTime = np.max([OBstartTimeNorm, minObsTimeNorm], axis=0)
×
1414

1415
        # 3. start filtering process
1416
        good_inds = np.where((OBnumbers >= 0) & (ObsTimeRange > intTimes.value))[0]
×
1417
        # star slew within OB -AND- can finish observing
1418
        # before it goes back into keepout
1419
        if good_inds.shape[0] > 0:
×
1420
            # the good ones
1421
            sInds = sInds[good_inds]
×
1422
            slewTimes = slewTimes[good_inds]
×
1423
            intTimes = intTimes[good_inds]
×
1424
            OBstartTimeNorm = OBstartTimeNorm[good_inds]
×
1425
            minAllowedSlewTime = minAllowedSlewTime[good_inds]
×
1426

1427
            # maximum allowed slew time based on integration times
1428
            maxAllowedSlewTime = maxIntTimes[good_inds].value - intTimes.value
×
1429
            maxAllowedSlewTime[maxAllowedSlewTime < 0] = -np.inf
×
1430
            maxAllowedSlewTime += OBstartTimeNorm  # calculated rel to currentTime norm
×
1431

1432
            # checking to see if slewTimes are allowed
1433
            good_inds = np.where(
×
1434
                (slewTimes.reshape([len(sInds), 1]).value > minAllowedSlewTime)
1435
                & (slewTimes.reshape([len(sInds), 1]).value < maxAllowedSlewTime)
1436
            )[0]
1437

1438
            slewTimes = slewTimes[good_inds]
×
1439
        else:
1440
            slewTimes = slewTimes[good_inds]
×
1441

1442
        return sInds[good_inds], intTimes[good_inds].flatten(), slewTimes
×
1443

1444
    def findAllowableOcculterSlews(
1✔
1445
        self, sInds, old_sInd, sd, slewTimes, obsTimeArray, intTimeArray, mode
1446
    ):
1447
        """Finds an array of allowable slew times for each star
1448

1449
        Used by the refineOcculterSlews method when slew times have NOT been selected
1450
        a priori. This method creates nx50 arrays (where the row corresponds to a
1451
        specific star and the column corresponds to a future point in time relative to
1452
        currentTime).
1453

1454
        These arrays are initially zero but are populated with the corresponding values
1455
        (slews, intTimes, etc) if slewing to that time point (i.e. beginning an
1456
        observation) would lead to a successful observation. A "successful observation"
1457
        is defined by certain conditions relating to keepout and the komap, observing
1458
        blocks, mission lifetime, and some constraints on the dVmap calculation in
1459
        SotoStarshade. Each star will likely have a range of slewTimes that would lead
1460
        to a successful observation -- another method is then called to select the best
1461
        of these slewTimes.
1462

1463
        Args:
1464
            sInds (~numpy.ndarray(int)):
1465
                Indices of available targets
1466
            old_sInd (int):
1467
                Index of the previous target star
1468
            sd (~astropy.units.Quantity(~numpy.ndarray(float))):
1469
                Angular separation between stars in rad
1470
            slewTimes (astropy quantity array):
1471
                slew times to all stars (must be indexed by sInds)
1472
            obsTimeArray (~astropy.units.Quantity(~numpy.ndarray(float))):
1473
                Array of times during which a star is out of keepout, has shape
1474
                nx50 where n is the number of stars in sInds
1475
            intTimeArray (~astropy.units.Quantity(~numpy.ndarray(float))):
1476
                Array of integration times for each time in obsTimeArray, has shape
1477
                nx50 where n is the number of stars in sInds
1478
            mode (dict):
1479
                Selected observing mode for detection
1480

1481
        Returns:
1482
            tuple:
1483
                sInds (numpy.ndarray(int)):
1484
                    Indices of next target star
1485
                slewTimes (astropy.units.Quantity(numpy.ndarray(float))):
1486
                    slew times to all stars (must be indexed by sInds)
1487
                intTimes (astropy.units.Quantity(numpy.ndarray(float))):
1488
                    Integration times for detection in units of day
1489
                dV (astropy.units.Quantity(numpy.ndarray(float))):
1490
                    Delta-V used to transfer to new star line of sight in unis of m/s
1491
        """
1492
        TK = self.TimeKeeping
×
1493
        Obs = self.Observatory
×
1494
        TL = self.TargetList
×
1495

1496
        # 0. lambda function that linearly interpolates Integration
1497
        # Time between obsTimes
1498
        linearInterp = lambda y, x, t: np.diff(y) / np.diff(x) * (  # noqa: E731
×
1499
            t - np.array(x[:, 0]).reshape(len(t), 1)
1500
        ) + np.array(y[:, 0]).reshape(len(t), 1)
1501

1502
        # allocate settling time + overhead time
1503
        tmpCurrentTimeAbs = (
×
1504
            TK.currentTimeAbs.copy() + Obs.settlingTime + mode["syst"]["ohTime"]
1505
        )
1506
        tmpCurrentTimeNorm = (
×
1507
            TK.currentTimeNorm.copy() + Obs.settlingTime + mode["syst"]["ohTime"]
1508
        )
1509

1510
        # 1. initializing arrays
1511
        obsTimes = np.array(
×
1512
            [obsTimeArray[:, 0], obsTimeArray[:, -1]]
1513
        )  # nx2 array with start and end times of obsTimes for each star
1514
        intTimes_int = (
×
1515
            np.zeros(obsTimeArray.shape) * u.d
1516
        )  # initializing intTimes of shape nx50 then interpolating
1517
        intTimes_int = (
×
1518
            np.hstack(
1519
                [
1520
                    intTimeArray[:, 0].reshape(len(sInds), 1).value,
1521
                    linearInterp(
1522
                        intTimeArray.value, obsTimes.T, obsTimeArray[:, 1:].value
1523
                    ),
1524
                ]
1525
            )
1526
            * u.d
1527
        )
1528
        allowedSlewTimes = np.zeros(obsTimeArray.shape) * u.d
×
1529
        allowedintTimes = np.zeros(obsTimeArray.shape) * u.d
×
1530
        allowedCharTimes = np.zeros(obsTimeArray.shape) * u.d
×
1531
        obsTimeArrayNorm = obsTimeArray.value - tmpCurrentTimeAbs.value
×
1532

1533
        # obsTimes -> relative to current Time
1534
        try:
×
1535
            minObsTimeNorm = np.array([np.min(v[v > 0]) for v in obsTimeArrayNorm])
×
1536
        except:  # noqa: E722
×
1537
            # an error pops up sometimes at the end of the mission, this fixes it
1538
            # TODO: define the error type that occurs
1539
            # rewrite to avoid a try/except if possible
1540
            minObsTimeNorm = obsTimes[1, :].T - tmpCurrentTimeAbs.value
×
1541

1542
        maxObsTimeNorm = obsTimes[1, :].T - tmpCurrentTimeAbs.value
×
1543
        ObsTimeRange = maxObsTimeNorm - minObsTimeNorm
×
1544

1545
        # getting max possible intTime
1546
        (
×
1547
            maxIntTimeOBendTime,
1548
            maxIntTimeExoplanetObsTime,
1549
            maxIntTimeMissionLife,
1550
        ) = TK.get_ObsDetectionMaxIntTime(Obs, mode, tmpCurrentTimeNorm, TK.OBnumber)
1551
        maxIntTime = min(
×
1552
            maxIntTimeOBendTime, maxIntTimeExoplanetObsTime, maxIntTimeMissionLife
1553
        )  # Maximum intTime allowed
1554

1555
        # 2. giant array of min and max slew times, starts at current time, ends when
1556
        # stars enter keepout (all same size). Each entry either has a slew time value
1557
        # if a slew is allowed at that date or 0 if slewing is not allowed
1558

1559
        # first filled in for the current OB
1560
        minAllowedSlewTimes = np.array(
×
1561
            [minObsTimeNorm.T] * len(intTimes_int.T)
1562
        ).T  # just to make it nx50 so it plays nice with the other arrays
1563
        maxAllowedSlewTimes = maxIntTime.value - intTimes_int.value
×
1564
        maxAllowedSlewTimes[maxAllowedSlewTimes > Obs.occ_dtmax.value] = (
×
1565
            Obs.occ_dtmax.value
1566
        )
1567

1568
        # conditions that must be met to define an allowable slew time
1569
        cond1 = (
×
1570
            minAllowedSlewTimes >= Obs.occ_dtmin.value
1571
        )  # minimum dt time in dV map interpolant
1572
        cond2 = (
×
1573
            maxAllowedSlewTimes <= Obs.occ_dtmax.value
1574
        )  # maximum dt time in dV map interpolant
1575
        cond3 = maxAllowedSlewTimes > minAllowedSlewTimes
×
1576
        cond4 = intTimes_int.value < ObsTimeRange.reshape(len(sInds), 1)
×
1577

1578
        conds = cond1 & cond2 & cond3 & cond4
×
1579
        minAllowedSlewTimes[np.invert(conds)] = (
×
1580
            np.inf
1581
        )  # these are filtered during the next filter
1582
        maxAllowedSlewTimes[np.invert(conds)] = -np.inf
×
1583

1584
        # one last condition to meet
1585
        map_i, map_j = np.where(
×
1586
            (obsTimeArrayNorm > minAllowedSlewTimes)
1587
            & (obsTimeArrayNorm < maxAllowedSlewTimes)
1588
        )
1589

1590
        # 2.5 if any stars are slew-able to within this OB block, populate
1591
        # "allowedSlewTimes", a running tally of possible slews
1592
        # within the time range a star is observable (out of keepout)
1593
        if map_i.shape[0] > 0 and map_j.shape[0] > 0:
×
1594
            allowedSlewTimes[map_i, map_j] = obsTimeArrayNorm[map_i, map_j] * u.d
×
1595
            allowedintTimes[map_i, map_j] = intTimes_int[map_i, map_j]
×
1596
            allowedCharTimes[map_i, map_j] = maxIntTime - intTimes_int[map_i, map_j]
×
1597

1598
        # 3. search future OBs
1599
        OB_withObsStars = (
×
1600
            TK.OBstartTimes.value - np.min(obsTimeArrayNorm) - tmpCurrentTimeNorm.value
1601
        )  # OBs within which any star is observable
1602

1603
        if any(OB_withObsStars > 0):
×
1604
            nOBstart = np.argmin(np.abs(OB_withObsStars))
×
1605
            nOBend = np.argmax(OB_withObsStars)
×
1606

1607
            # loop through the next 5 OBs (or until mission is over if there are less
1608
            # than 5 OBs in the future)
1609
            for i in np.arange(nOBstart, np.min([nOBend, nOBstart + 5])):
×
1610
                # max int Times for the next OB
UNCOV
1611
                (
×
1612
                    maxIntTimeOBendTime,
1613
                    maxIntTimeExoplanetObsTime,
1614
                    maxIntTimeMissionLife,
1615
                ) = TK.get_ObsDetectionMaxIntTime(
1616
                    Obs, mode, TK.OBstartTimes[i + 1], i + 1
1617
                )
1618
                maxIntTime_nOB = min(
×
1619
                    maxIntTimeOBendTime,
1620
                    maxIntTimeExoplanetObsTime,
1621
                    maxIntTimeMissionLife,
1622
                )  # Maximum intTime allowed
1623

1624
                # min and max slew times rel to current Time (norm)
1625
                nOBstartTimeNorm = np.array(
×
1626
                    [TK.OBstartTimes[i + 1].value - tmpCurrentTimeNorm.value]
1627
                    * len(sInds)
1628
                )
1629

1630
                # min slew times for stars start either whenever the star first leaves
1631
                # keepout or when next OB stars, whichever comes last
1632
                minAllowedSlewTimes_nOB = np.array(
×
1633
                    [np.max([minObsTimeNorm, nOBstartTimeNorm], axis=0).T]
1634
                    * len(maxAllowedSlewTimes.T)
1635
                ).T
1636
                maxAllowedSlewTimes_nOB = (
×
1637
                    nOBstartTimeNorm.reshape(len(sInds), 1)
1638
                    + maxIntTime_nOB.value
1639
                    - intTimes_int.value
1640
                )
1641
                maxAllowedSlewTimes_nOB[
×
1642
                    maxAllowedSlewTimes_nOB > Obs.occ_dtmax.value
1643
                ] = Obs.occ_dtmax.value
1644

1645
                # amount of time left when the stars are still out of keepout
1646
                ObsTimeRange_nOB = (
×
1647
                    maxObsTimeNorm
1648
                    - np.max([minObsTimeNorm, nOBstartTimeNorm], axis=0).T
1649
                )
1650

1651
                # condition to be met for an allowable slew time
1652
                cond1 = minAllowedSlewTimes_nOB >= Obs.occ_dtmin.value
×
1653
                cond2 = maxAllowedSlewTimes_nOB <= Obs.occ_dtmax.value
×
1654
                cond3 = maxAllowedSlewTimes_nOB > minAllowedSlewTimes_nOB
×
1655
                cond4 = intTimes_int.value < ObsTimeRange_nOB.reshape(len(sInds), 1)
×
1656
                cond5 = intTimes_int.value < maxIntTime_nOB.value
×
1657
                conds = cond1 & cond2 & cond3 & cond4 & cond5
×
1658

1659
                minAllowedSlewTimes_nOB[np.invert(conds)] = np.inf
×
1660
                maxAllowedSlewTimes_nOB[np.invert(conds)] = -np.inf
×
1661

1662
                # one last condition
1663
                map_i, map_j = np.where(
×
1664
                    (obsTimeArrayNorm > minAllowedSlewTimes_nOB)
1665
                    & (obsTimeArrayNorm < maxAllowedSlewTimes_nOB)
1666
                )
1667

1668
                # 3.33 populate the running tally of allowable slew times if it meets
1669
                # all conditions
1670
                if map_i.shape[0] > 0 and map_j.shape[0] > 0:
×
1671
                    allowedSlewTimes[map_i, map_j] = (
×
1672
                        obsTimeArrayNorm[map_i, map_j] * u.d
1673
                    )
1674
                    allowedintTimes[map_i, map_j] = intTimes_int[map_i, map_j]
×
1675
                    allowedCharTimes[map_i, map_j] = (
×
1676
                        maxIntTime_nOB - intTimes_int[map_i, map_j]
1677
                    )
1678

1679
        # 3.67 filter out any stars that are not observable at all
1680
        filterDuds = np.sum(allowedSlewTimes, axis=1) > 0.0
×
1681
        sInds = sInds[filterDuds]
×
1682

1683
        # 4. choose a slew time for each available star
1684
        # calculate dVs for each possible slew time for each star
1685
        allowed_dVs = Obs.calculate_dV(
×
1686
            TL,
1687
            old_sInd,
1688
            sInds,
1689
            sd[filterDuds],
1690
            allowedSlewTimes[filterDuds, :],
1691
            tmpCurrentTimeAbs,
1692
        )
1693

1694
        if len(sInds.tolist()) > 0:
×
1695
            # select slew time for each star
1696
            dV_inds = np.arange(0, len(sInds))
×
1697
            sInds, intTime, slewTime, dV = self.chooseOcculterSlewTimes(
×
1698
                sInds,
1699
                allowedSlewTimes[filterDuds, :],
1700
                allowed_dVs[dV_inds, :],
1701
                allowedintTimes[filterDuds, :],
1702
                allowedCharTimes[filterDuds, :],
1703
            )
1704

1705
            return sInds, intTime, slewTime, dV
×
1706

1707
        else:
1708
            empty = np.asarray([], dtype=int)
×
1709
            return empty, empty * u.d, empty * u.d, empty * u.m / u.s
×
1710

1711
    def chooseOcculterSlewTimes(self, sInds, slewTimes, dV, intTimes, charTimes):
1✔
1712
        """Selects the best slew time for each star
1713

1714
        This method searches through an array of permissible slew times for
1715
        each star and chooses the best slew time for the occulter based on
1716
        maximizing possible characterization time for that particular star (as
1717
        a default).
1718

1719
        Args:
1720
            sInds (~numpy.ndarray(int)):
1721
                Indices of available targets
1722
            slewTimes (astropy quantity array):
1723
                slew times to all stars (must be indexed by sInds)
1724
            dV (~astropy.units.Quantity(~numpy.ndarray(float))):
1725
                Delta-V used to transfer to new star line of sight in unis of m/s
1726
            intTimes (~astropy.units.Quantity(~numpy.ndarray(float))):
1727
                Integration times for detection in units of day
1728
            charTimes (~astropy.units.Quantity(~numpy.ndarray(float))):
1729
                Time left over after integration which could be used for
1730
                characterization in units of day
1731

1732
        Returns:
1733
            tuple:
1734
                sInds (int):
1735
                    Indeces of next target star
1736
                slewTimes (astropy.units.Quantity(numpy.ndarray(float))):
1737
                    slew times to all stars (must be indexed by sInds)
1738
                intTimes (astropy.units.Quantity(numpy.ndarray(float))):
1739
                    Integration times for detection in units of day
1740
                dV (astropy.units.Quantity(numpy.ndarray(float))):
1741
                    Delta-V used to transfer to new star line of sight in unis of m/s
1742
        """
1743

1744
        # selection criteria for each star slew
1745
        good_j = np.argmax(
×
1746
            charTimes, axis=1
1747
        )  # maximum possible characterization time available
1748
        good_i = np.arange(0, len(sInds))
×
1749

1750
        dV = dV[good_i, good_j]
×
1751
        intTime = intTimes[good_i, good_j]
×
1752
        slewTime = slewTimes[good_i, good_j]
×
1753

1754
        return sInds, intTime, slewTime, dV
×
1755

1756
    def observation_detection(self, sInd, intTime, mode):
1✔
1757
        """Determines SNR and detection status for a given integration time
1758
        for detection. Also updates the lastDetected and starRevisit lists.
1759

1760
        Args:
1761
            sInd (int):
1762
                Integer index of the star of interest
1763
            intTime (~astropy.units.Quantity(~numpy.ndarray(float))):
1764
                Selected star integration time for detection in units of day.
1765
                Defaults to None.
1766
            mode (dict):
1767
                Selected observing mode for detection
1768

1769
        Returns:
1770
            tuple:
1771
                detected (numpy.ndarray(int)):
1772
                    Detection status for each planet orbiting the observed target star:
1773
                    1 is detection, 0 missed detection, -1 below IWA, and -2 beyond OWA
1774
                fZ (astropy.units.Quantity(numpy.ndarray(float))):
1775
                    Surface brightness of local zodiacal light in units of 1/arcsec2
1776
                JEZ (astropy.units.Quantity(numpy.ndarray(float))):
1777
                    Intensity of exo-zodiacal light in units of photons/s/m2/arcsec2
1778
                systemParams (dict):
1779
                    Dictionary of time-dependant planet properties averaged over the
1780
                    duration of the integration
1781
                SNR (numpy.darray(float)):
1782
                    Detection signal-to-noise ratio of the observable planets
1783
                FA (bool):
1784
                    False alarm (false positive) boolean
1785

1786
        """
1787

1788
        PPop = self.PlanetPopulation
1✔
1789
        ZL = self.ZodiacalLight
1✔
1790
        PPro = self.PostProcessing
1✔
1791
        TL = self.TargetList
1✔
1792
        SU = self.SimulatedUniverse
1✔
1793
        Obs = self.Observatory
1✔
1794
        TK = self.TimeKeeping
1✔
1795

1796
        # Save Current Time before attempting time allocation
1797
        currentTimeNorm = TK.currentTimeNorm.copy()
1✔
1798
        currentTimeAbs = TK.currentTimeAbs.copy()
1✔
1799

1800
        # Allocate Time
1801
        extraTime = intTime * (mode["timeMultiplier"] - 1.0)  # calculates extraTime
1✔
1802
        success = TK.allocate_time(
1✔
1803
            intTime + extraTime + Obs.settlingTime + mode["syst"]["ohTime"], True
1804
        )
1805
        assert success, "Could not allocate observation detection time ({}).".format(
1✔
1806
            intTime + extraTime + Obs.settlingTime + mode["syst"]["ohTime"]
1807
        )
1808
        # calculates partial time to be added for every ntFlux
1809
        dt = intTime / float(self.ntFlux)
1✔
1810
        # find indices of planets around the target
1811
        pInds = np.where(SU.plan2star == sInd)[0]
1✔
1812

1813
        # initialize outputs
1814
        detected = np.array([], dtype=int)
1✔
1815
        # write current system params by default
1816
        systemParams = SU.dump_system_params(sInd)
1✔
1817
        SNR = np.zeros(len(pInds))
1✔
1818

1819
        # if any planet, calculate SNR
1820
        if len(pInds) > 0:
1✔
1821
            # initialize arrays for SNR integration
1822
            fZs = np.zeros(self.ntFlux) / u.arcsec**2
1✔
1823
            systemParamss = np.empty(self.ntFlux, dtype="object")
1✔
1824
            JEZs = (
1✔
1825
                np.zeros((self.ntFlux, len(pInds))) * u.ph / u.s / u.m**2 / u.arcsec**2
1826
            )
1827
            Ss = np.zeros((self.ntFlux, len(pInds)))
1✔
1828
            Ns = np.zeros((self.ntFlux, len(pInds)))
1✔
1829
            # accounts for the time since the current time
1830
            timePlus = Obs.settlingTime.copy() + mode["syst"]["ohTime"].copy()
1✔
1831
            # integrate the signal (planet flux) and noise
1832
            for i in range(self.ntFlux):
1✔
1833
                # allocate first half of dt
1834
                timePlus += dt / 2.0
1✔
1835
                # calculate current zodiacal light brightness
1836
                fZs[i] = ZL.fZ(Obs, TL, sInd, currentTimeAbs + timePlus, mode)[0]
1✔
1837
                # propagate the system to match up with current time
1838
                SU.propag_system(
1✔
1839
                    sInd, currentTimeNorm + timePlus - self.propagTimes[sInd]
1840
                )
1841
                # Calculate the exozodi intensity
1842
                JEZs[i] = SU.scale_JEZ(sInd, mode)
1✔
1843
                self.propagTimes[sInd] = currentTimeNorm + timePlus
1✔
1844
                # save planet parameters
1845
                systemParamss[i] = SU.dump_system_params(sInd)
1✔
1846
                # calculate signal and noise (electron count rates)
1847
                Ss[i, :], Ns[i, :] = self.calc_signal_noise(
1✔
1848
                    sInd, pInds, dt, mode, fZ=fZs[i], JEZ=JEZs[i]
1849
                )
1850
                # allocate second half of dt
1851
                timePlus += dt / 2.0
1✔
1852

1853
            # average output parameters
1854
            fZ = np.mean(fZs)
1✔
1855
            JEZ = np.mean(JEZs, axis=0)
1✔
1856
            systemParams = {
1✔
1857
                key: sum([systemParamss[x][key] for x in range(self.ntFlux)])
1858
                / float(self.ntFlux)
1859
                for key in sorted(systemParamss[0])
1860
            }
1861
            # calculate SNR
1862
            S = Ss.sum(0)
1✔
1863
            N = Ns.sum(0)
1✔
1864
            SNR[N > 0] = S[N > 0] / N[N > 0]
1✔
1865

1866
        # if no planet, just save zodiacal brightness in the middle of the integration
1867
        else:
1868
            totTime = intTime * (mode["timeMultiplier"])
1✔
1869
            fZ = ZL.fZ(Obs, TL, sInd, currentTimeAbs + totTime / 2.0, mode)[0]
1✔
1870
            # Use the default star value if no planets
1871
            JEZ = TL.JEZ0[mode["hex"]][sInd]
1✔
1872

1873
        # find out if a false positive (false alarm) or any false negative
1874
        # (missed detections) have occurred
1875
        FA, MD = PPro.det_occur(SNR, mode, TL, sInd, intTime)
1✔
1876

1877
        # populate detection status array
1878
        # 1:detected, 0:missed, -1:below IWA, -2:beyond OWA
1879
        if len(pInds) > 0:
1✔
1880
            detected = (~MD).astype(int)
1✔
1881
            WA = (
1✔
1882
                np.array(
1883
                    [
1884
                        systemParamss[x]["WA"].to("arcsec").value
1885
                        for x in range(len(systemParamss))
1886
                    ]
1887
                )
1888
                * u.arcsec
1889
            )
1890
            detected[np.all(WA < mode["IWA"], 0)] = -1
1✔
1891
            detected[np.all(WA > mode["OWA"], 0)] = -2
1✔
1892

1893
        # if planets are detected, calculate the minimum apparent separation
1894
        smin = None
1✔
1895
        det = detected == 1  # If any of the planets around the star have been detected
1✔
1896
        if np.any(det):
1✔
1897
            smin = np.min(SU.s[pInds[det]])
1✔
1898
            log_det = "   - Detected planet inds %s (%s/%s)" % (
1✔
1899
                pInds[det],
1900
                len(pInds[det]),
1901
                len(pInds),
1902
            )
1903
            self.logger.info(log_det)
1✔
1904
            self.vprint(log_det)
1✔
1905

1906
        # populate the lastDetected array by storing det, JEZ, dMag, and WA
1907
        self.lastDetected[sInd, :] = [
1✔
1908
            det,
1909
            JEZ.flatten(),
1910
            systemParams["dMag"],
1911
            systemParams["WA"].to("arcsec").value,
1912
        ]
1913

1914
        # in case of a FA, generate a random delta mag (between PPro.FAdMag0 and
1915
        # TL.intCutoff_dMag) and working angle (between IWA and min(OWA, a_max))
1916
        if FA:
1✔
1917
            WA = (
×
1918
                np.random.uniform(
1919
                    mode["IWA"].to("arcsec").value,
1920
                    np.minimum(mode["OWA"], np.arctan(max(PPop.arange) / TL.dist[sInd]))
1921
                    .to("arcsec")
1922
                    .value,
1923
                )
1924
                * u.arcsec
1925
            )
1926
            dMag = np.random.uniform(PPro.FAdMag0(WA), TL.intCutoff_dMag)
×
1927
            self.lastDetected[sInd, 0] = np.append(self.lastDetected[sInd, 0], True)
×
1928
            self.lastDetected[sInd, 1] = np.append(
×
1929
                self.lastDetected[sInd, 1], TL.JEZ0[mode["hex"]][sInd].flatten()
1930
            )
1931
            self.lastDetected[sInd, 2] = np.append(self.lastDetected[sInd, 2], dMag)
×
1932
            self.lastDetected[sInd, 3] = np.append(
×
1933
                self.lastDetected[sInd, 3], WA.to("arcsec").value
1934
            )
1935
            sminFA = np.tan(WA) * TL.dist[sInd].to("AU")
×
1936
            smin = np.minimum(smin, sminFA) if smin is not None else sminFA
×
1937
            log_FA = "   - False Alarm (WA=%s, dMag=%s)" % (
×
1938
                np.round(WA, 3),
1939
                np.round(dMag, 1),
1940
            )
1941
            self.logger.info(log_FA)
×
1942
            self.vprint(log_FA)
×
1943

1944
        # Schedule Target Revisit
1945
        self.scheduleRevisit(sInd, smin, det, pInds)
1✔
1946

1947
        if self.make_debug_bird_plots:
1✔
1948
            from tools.obs_plot import obs_plot
×
1949

1950
            obs_plot(self, systemParams, mode, sInd, pInds, SNR, detected)
×
1951

1952
        return detected.astype(int), fZ, JEZ, systemParams, SNR, FA
1✔
1953

1954
    def scheduleRevisit(self, sInd, smin, det, pInds):
1✔
1955
        """A Helper Method for scheduling revisits after observation detection
1956

1957
        Updates self.starRevisit attribute only
1958

1959
        Args:
1960
            sInd (int):
1961
                sInd of the star just detected
1962
            smin (~astropy.units.Quantity):
1963
                minimum separation of the planet to star of planet just detected
1964
            det (~np.ndarray(bool)):
1965
                Detection status of all planets in target system
1966
            pInds (~np.ndarray(int)):
1967
                Indices of planets in the target system
1968

1969
        Returns:
1970
            None
1971

1972
        """
1973
        TK = self.TimeKeeping
1✔
1974
        TL = self.TargetList
1✔
1975
        SU = self.SimulatedUniverse
1✔
1976

1977
        # in both cases (detection or false alarm), schedule a revisit
1978
        # based on minimum separation
1979
        Ms = TL.MsTrue[sInd]
1✔
1980
        if smin is not None:  # smin is None if no planet was detected
1✔
1981
            sp = smin
1✔
1982
            if np.any(det):
1✔
1983
                pInd_smin = pInds[det][np.argmin(SU.s[pInds[det]])]
1✔
1984
                Mp = SU.Mp[pInd_smin]
1✔
1985
            else:
1986
                Mp = SU.Mp.mean()
×
1987
            mu = const.G * (Mp + Ms)
1✔
1988
            T = 2.0 * np.pi * np.sqrt(sp**3.0 / mu)
1✔
1989
            t_rev = TK.currentTimeNorm.copy() + T / 2.0
1✔
1990
        # otherwise, revisit based on average of population semi-major axis and mass
1991
        else:
1992
            sp = SU.s.mean()
1✔
1993
            Mp = SU.Mp.mean()
1✔
1994
            mu = const.G * (Mp + Ms)
1✔
1995
            T = 2.0 * np.pi * np.sqrt(sp**3.0 / mu)
1✔
1996
            t_rev = TK.currentTimeNorm.copy() + 0.75 * T
1✔
1997

1998
        if self.revisit_wait is not None:
1✔
1999
            t_rev = TK.currentTimeNorm.copy() + self.revisit_wait
×
2000
        # finally, populate the revisit list (NOTE: sInd becomes a float)
2001
        revisit = np.array([sInd, t_rev.to("day").value])
1✔
2002
        if self.starRevisit.size == 0:  # If starRevisit has nothing in it
1✔
2003
            self.starRevisit = np.array([revisit])  # initialize sterRevisit
1✔
2004
        else:
2005
            revInd = np.where(self.starRevisit[:, 0] == sInd)[
1✔
2006
                0
2007
            ]  # indices of the first column of the starRevisit list containing sInd
2008
            if revInd.size == 0:
1✔
2009
                self.starRevisit = np.vstack((self.starRevisit, revisit))
1✔
2010
            else:
2011
                self.starRevisit[revInd, 1] = revisit[1]
×
2012

2013
    def observation_characterization(self, sInd, mode):
1✔
2014
        """Finds if characterizations are possible and relevant information
2015

2016
        Args:
2017
            sInd (int):
2018
                Integer index of the star of interest
2019
            mode (dict):
2020
                Selected observing mode for characterization
2021

2022
        Returns:
2023
            tuple:
2024
                characterized (list(int)):
2025
                    Characterization status for each planet orbiting the observed
2026
                    target star including False Alarm if any, where 1 is full spectrum,
2027
                    -1 partial spectrum, and 0 not characterized
2028
                fZ (astropy.units.Quantity(numpy.ndarray(float))):
2029
                    Surface brightness of local zodiacal light in units of 1/arcsec2
2030
                JEZ (astropy.units.Quantity(numpy.ndarray(float))):
2031
                    Intensity of exo-zodiacal light in units of photons/s/m2/arcsec
2032
                systemParams (dict):
2033
                    Dictionary of time-dependant planet properties averaged over the
2034
                    duration of the integration
2035
                SNR (numpy.ndarray(float)):
2036
                    Characterization signal-to-noise ratio of the observable planets.
2037
                    Defaults to None.
2038
                intTime (astropy.units.Quantity(numpy.ndarray(float))):
2039
                    Selected star characterization time in units of day.
2040
                    Defaults to None.
2041

2042
        """
2043

2044
        OS = self.OpticalSystem
1✔
2045
        ZL = self.ZodiacalLight
1✔
2046
        TL = self.TargetList
1✔
2047
        SU = self.SimulatedUniverse
1✔
2048
        Obs = self.Observatory
1✔
2049
        TK = self.TimeKeeping
1✔
2050

2051
        # selecting appropriate koMap
2052
        koMap = self.koMaps[mode["syst"]["name"]]
1✔
2053

2054
        # find indices of planets around the target
2055
        pInds = np.where(SU.plan2star == sInd)[0]
1✔
2056

2057
        # get the detected status, and check if there was a FA
2058
        det = self.lastDetected[sInd, 0]
1✔
2059
        FA = len(det) == len(pInds) + 1
1✔
2060
        if FA:
1✔
2061
            pIndsDet = np.append(pInds, -1)[det]
×
2062
        else:
2063
            pIndsDet = pInds[det]
1✔
2064

2065
        # initialize outputs, and check if there's anything (planet or FA)
2066
        # to characterize
2067
        characterized = np.zeros(len(det), dtype=int)
1✔
2068
        fZ = 0.0 * self.inv_arcsec2
1✔
2069
        JEZ = 0.0 * self.JEZ_unit
1✔
2070
        # write current system params by default
2071
        systemParams = SU.dump_system_params(sInd)
1✔
2072
        SNR = np.zeros(len(det))
1✔
2073
        intTime = None
1✔
2074
        if len(det) == 0:  # nothing to characterize
1✔
2075
            return characterized, fZ, JEZ, systemParams, SNR, intTime
1✔
2076

2077
        # look for last detected planets that have not been fully characterized
2078
        if not (FA):  # only true planets, no FA
1✔
2079
            tochar = self.fullSpectra[pIndsDet] == 0
1✔
2080
        else:  # mix of planets and a FA
2081
            truePlans = pIndsDet[:-1]
×
2082
            tochar = np.append((self.fullSpectra[truePlans] == 0), True)
×
2083

2084
        # 1/ find spacecraft orbital START position including overhead time,
2085
        # and check keepout angle
2086
        if np.any(tochar):
1✔
2087
            # start times
2088
            startTime = (
1✔
2089
                TK.currentTimeAbs.copy() + mode["syst"]["ohTime"] + Obs.settlingTime
2090
            )
2091

2092
            # if we're beyond mission end, bail out
2093
            if startTime >= TK.missionFinishAbs:
1✔
2094
                return characterized, fZ, systemParams, SNR, intTime
×
2095

2096
            startTimeNorm = (
1✔
2097
                TK.currentTimeNorm.copy() + mode["syst"]["ohTime"] + Obs.settlingTime
2098
            )
2099
            # planets to characterize
2100
            koTimeInd = np.where(np.round(startTime.value) - self.koTimes.value == 0)[
1✔
2101
                0
2102
            ][
2103
                0
2104
            ]  # find indice where koTime is startTime[0]
2105
            # wherever koMap is 1, the target is observable
2106
            tochar[tochar] = koMap[sInd][koTimeInd]
1✔
2107

2108
        # 2/ if any planet to characterize, find the characterization times
2109
        # at the detected JEZ, dMag, and WA
2110
        if np.any(tochar):
1✔
2111
            fZ = ZL.fZ(Obs, TL, [sInd], startTime, mode)
1✔
2112
            JEZ = self.lastDetected[sInd, 1][det][tochar]
1✔
2113
            dMag = self.lastDetected[sInd, 2][det][tochar]
1✔
2114
            WA = self.lastDetected[sInd, 3][det][tochar] * u.arcsec
1✔
2115
            intTimes = np.zeros(len(tochar)) << u.day
1✔
2116
            intTimes[tochar] = OS.calc_intTime(TL, sInd, fZ, JEZ, dMag, WA, mode, TK=TK)
1✔
2117
            intTimes[~np.isfinite(intTimes)] = self.zero_d
1✔
2118
            # add a predetermined margin to the integration times
2119
            intTimes = intTimes * (1.0 + self.charMargin)
1✔
2120
            # apply time multiplier
2121
            totTimes = intTimes * (mode["timeMultiplier"])
1✔
2122

2123
            # Filter totTimes to make nan integration times correspond to the
2124
            # maximum float value because Time cannot handle nan values
2125
            totTimes[np.where(np.isnan(totTimes))[0]] = np.finfo(np.float64).max * u.d
1✔
2126

2127
            # end times
2128
            endTimes = startTime + totTimes
1✔
2129
            endTimesNorm = startTimeNorm + totTimes
1✔
2130
            # planets to characterize
2131
            tochar = (
1✔
2132
                (totTimes > 0)
2133
                & (totTimes <= OS.intCutoff)
2134
                & (endTimesNorm <= TK.OBendTimes[TK.OBnumber])
2135
            )
2136

2137
        # 3/ is target still observable at the end of any char time?
2138
        if np.any(tochar) and Obs.checkKeepoutEnd:
1✔
2139
            koTimeInds = np.zeros(len(endTimes.value[tochar]), dtype=int)
1✔
2140
            # find index in koMap where each endTime is closest to koTimes
2141
            for t, endTime in enumerate(endTimes.value[tochar]):
1✔
2142
                if endTime > self.koTimes.value[-1]:
1✔
2143
                    # case where endTime exceeds largest koTimes element
2144
                    endTimeInBounds = np.where(
×
2145
                        np.floor(endTime) - self.koTimes.value == 0
2146
                    )[0]
2147
                    koTimeInds[t] = (
×
2148
                        endTimeInBounds[0] if endTimeInBounds.size != 0 else -1
2149
                    )
2150
                else:
2151
                    koTimeInds[t] = np.where(
1✔
2152
                        np.round(endTime) - self.koTimes.value == 0
2153
                    )[0][
2154
                        0
2155
                    ]  # find indice where koTime is endTimes[0]
2156
            tochar[tochar] = [koMap[sInd][koT] if koT >= 0 else 0 for koT in koTimeInds]
1✔
2157

2158
        # 4/ if yes, allocate the overhead time, and perform the characterization
2159
        # for the maximum char time
2160
        if np.any(tochar):
1✔
2161
            # Save Current Time before attempting time allocation
2162
            currentTimeNorm = TK.currentTimeNorm.copy()
1✔
2163
            currentTimeAbs = TK.currentTimeAbs.copy()
1✔
2164

2165
            # Allocate Time
2166
            intTime = np.max(intTimes[tochar])
1✔
2167
            extraTime = intTime * (mode["timeMultiplier"] - 1.0)
1✔
2168
            success = TK.allocate_time(
1✔
2169
                intTime + extraTime + mode["syst"]["ohTime"] + Obs.settlingTime, True
2170
            )  # allocates time
2171
            if not (success):  # Time was not successfully allocated
1✔
2172
                char_intTime = None
1✔
2173
                lenChar = len(pInds) + 1 if FA else len(pInds)
1✔
2174
                characterized = np.zeros(lenChar, dtype=float)
1✔
2175
                char_JEZ = np.zeros(lenChar, dtype=float) * self.JEZ_unit
1✔
2176
                char_SNR = np.zeros(lenChar, dtype=float)
1✔
2177
                char_fZ = 0.0 * self.inv_arcsec2
1✔
2178
                char_systemParams = SU.dump_system_params(sInd)
1✔
2179
                return (
1✔
2180
                    characterized,
2181
                    char_fZ,
2182
                    char_JEZ,
2183
                    char_systemParams,
2184
                    char_SNR,
2185
                    char_intTime,
2186
                )
2187

2188
            pIndsChar = pIndsDet[tochar]
1✔
2189
            log_char = "   - Charact. planet inds %s (%s/%s detected)" % (
1✔
2190
                pIndsChar,
2191
                len(pIndsChar),
2192
                len(pIndsDet),
2193
            )
2194
            self.logger.info(log_char)
1✔
2195
            self.vprint(log_char)
1✔
2196

2197
            # SNR CALCULATION:
2198
            # first, calculate SNR for observable planets (without false alarm)
2199
            planinds = pIndsChar[:-1] if pIndsChar[-1] == -1 else pIndsChar
1✔
2200
            SNRplans = np.zeros(len(planinds))
1✔
2201
            if len(planinds) > 0:
1✔
2202
                # initialize arrays for SNR integration
2203
                fZs = np.zeros(self.ntFlux) << self.inv_arcsec2
1✔
2204
                systemParamss = np.empty(self.ntFlux, dtype="object")
1✔
2205
                JEZs = np.zeros((self.ntFlux, len(planinds))) << self.JEZ_unit
1✔
2206
                Ss = np.zeros((self.ntFlux, len(planinds)))
1✔
2207
                Ns = np.zeros((self.ntFlux, len(planinds)))
1✔
2208
                # integrate the signal (planet flux) and noise
2209
                dt = intTime / float(self.ntFlux)
1✔
2210
                timePlus = (
1✔
2211
                    Obs.settlingTime.copy() + mode["syst"]["ohTime"].copy()
2212
                )  # accounts for the time since the current time
2213
                for i in range(self.ntFlux):
1✔
2214
                    # allocate first half of dt
2215
                    timePlus += dt / 2.0
1✔
2216
                    # calculate current zodiacal light brightness
2217
                    fZs[i] = ZL.fZ(Obs, TL, sInd, currentTimeAbs + timePlus, mode)[0]
1✔
2218
                    # propagate the system to match up with current time
2219
                    SU.propag_system(
1✔
2220
                        sInd, currentTimeNorm + timePlus - self.propagTimes[sInd]
2221
                    )
2222
                    self.propagTimes[sInd] = currentTimeNorm + timePlus
1✔
2223
                    # Calculate the exozodi intensity
2224
                    JEZs[i] = SU.scale_JEZ(sInd, mode, pInds=planinds)
1✔
2225
                    # save planet parameters
2226
                    systemParamss[i] = SU.dump_system_params(sInd)
1✔
2227
                    # calculate signal and noise (electron count rates)
2228
                    Ss[i, :], Ns[i, :] = self.calc_signal_noise(
1✔
2229
                        sInd, planinds, dt, mode, fZ=fZs[i], JEZ=JEZs[i]
2230
                    )
2231
                    # allocate second half of dt
2232
                    timePlus += dt / 2.0
1✔
2233

2234
                # average output parameters
2235
                fZ = np.mean(fZs)
1✔
2236
                JEZ = np.mean(JEZs, axis=0)
1✔
2237
                systemParams = {
1✔
2238
                    key: sum([systemParamss[x][key] for x in range(self.ntFlux)])
2239
                    / float(self.ntFlux)
2240
                    for key in sorted(systemParamss[0])
2241
                }
2242
                # calculate planets SNR
2243
                S = Ss.sum(0)
1✔
2244
                N = Ns.sum(0)
1✔
2245
                SNRplans[N > 0] = S[N > 0] / N[N > 0]
1✔
2246
                # allocate extra time for timeMultiplier
2247

2248
            # if only a FA, just save zodiacal brightness in the middle of the
2249
            # integration
2250
            else:
2251
                totTime = intTime * (mode["timeMultiplier"])
×
2252
                fZ = ZL.fZ(Obs, TL, sInd, currentTimeAbs + totTime / 2.0, mode)[0]
×
2253
                # Use the default star value if no planets
NEW
2254
                JEZ = TL.JEZ0[mode["hex"]][sInd]
×
2255

2256
            # calculate the false alarm SNR (if any)
2257
            SNRfa = []
1✔
2258
            if pIndsChar[-1] == -1:
1✔
NEW
2259
                JEZ = self.lastDetected[sInd, 1][-1]
×
2260
                dMag = self.lastDetected[sInd, 2][-1]
×
NEW
2261
                WA = self.lastDetected[sInd, 3][-1] << u.arcsec
×
NEW
2262
                C_p, C_b, C_sp = OS.Cp_Cb_Csp(TL, sInd, fZ, JEZ, dMag, WA, mode, TK=TK)
×
2263
                S = (C_p * intTime).decompose().value
×
2264
                N = np.sqrt((C_b * intTime + (C_sp * intTime) ** 2.0).decompose().value)
×
2265
                SNRfa = S / N if N > 0.0 else 0.0
×
2266

2267
            # save all SNRs (planets and FA) to one array
2268
            SNRinds = np.where(det)[0][tochar]
1✔
2269
            SNR[SNRinds] = np.append(SNRplans, SNRfa)
1✔
2270

2271
            # now, store characterization status: 1 for full spectrum,
2272
            # -1 for partial spectrum, 0 for not characterized
2273
            char = SNR >= mode["SNR"]
1✔
2274
            # initialize with full spectra
2275
            characterized = char.astype(int)
1✔
2276
            WAchar = self.lastDetected[sInd, 3][char] << u.arcsec
1✔
2277
            # find the current WAs of characterized planets
2278
            WAs = systemParams["WA"]
1✔
2279
            if FA:
1✔
NEW
2280
                WAs = np.append(WAs, self.lastDetected[sInd, 3][-1] << u.arcsec)
×
2281
            # check for partial spectra (for coronagraphs only)
2282
            if not (mode["syst"]["occulter"]):
1✔
2283
                IWA_max = mode["IWA"] * (1.0 + mode["BW"] / 2.0)
1✔
2284
                OWA_min = mode["OWA"] * (1.0 - mode["BW"] / 2.0)
1✔
2285
                char[char] = (WAchar < IWA_max) | (WAchar > OWA_min)
1✔
2286
                characterized[char] = -1
1✔
2287
            # encode results in spectra lists (only for planets, not FA)
2288
            charplans = characterized[:-1] if FA else characterized
1✔
2289
            self.fullSpectra[pInds[charplans == 1]] += 1
1✔
2290
            self.partialSpectra[pInds[charplans == -1]] += 1
1✔
2291

2292
        if self.make_debug_bird_plots:
1✔
2293
            from tools.obs_plot import obs_plot
×
2294

2295
            obs_plot(self, systemParams, mode, sInd, pInds, SNR, characterized)
×
2296

2297
        return characterized.astype(int), fZ, JEZ, systemParams, SNR, intTime
1✔
2298

2299
    def calc_signal_noise(
1✔
2300
        self, sInd, pInds, t_int, mode, fZ=None, JEZ=None, dMag=None, WA=None
2301
    ):
2302
        """Calculates the signal and noise fluxes for a given time interval. Called
2303
        by observation_detection and observation_characterization methods in the
2304
        SurveySimulation module.
2305

2306
        Args:
2307
            sInd (int):
2308
                Integer index of the star of interest
2309
            t_int (~astropy.units.Quantity(~numpy.ndarray(float))):
2310
                Integration time interval in units of day
2311
            pInds (int):
2312
                Integer indices of the planets of interest
2313
            mode (dict):
2314
                Selected observing mode (from OpticalSystem)
2315
            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2316
                Surface brightness of local zodiacal light in units of 1/arcsec2
2317
            JEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
2318
                Intensity of exo-zodiacal light in units of ph/s/m2/arcsec2
2319
            dMag (~numpy.ndarray(float)):
2320
                Differences in magnitude between planets and their host star
2321
            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
2322
                Working angles of the planets of interest in units of arcsec
2323

2324
        Returns:
2325
            tuple:
2326
                Signal (float):
2327
                    Counts of signal
2328
                Noise (float):
2329
                    Counts of background noise variance
2330

2331
        """
2332

2333
        OS = self.OpticalSystem
1✔
2334
        ZL = self.ZodiacalLight
1✔
2335
        TL = self.TargetList
1✔
2336
        SU = self.SimulatedUniverse
1✔
2337
        Obs = self.Observatory
1✔
2338
        TK = self.TimeKeeping
1✔
2339

2340
        # calculate optional parameters if not provided
2341
        fZ = (
1✔
2342
            fZ
2343
            if (fZ is not None)
2344
            else ZL.fZ(Obs, TL, sInd, TK.currentTimeAbs.copy(), mode)
2345
        )
2346
        JEZ = (
1✔
2347
            u.Quantity(JEZ, ndmin=1)
2348
            if (JEZ is not None)
2349
            else SU.scale_JEZ(sInd, mode, pInds=pInds)
2350
        )
2351

2352
        # if lucky_planets, use lucky planet params for dMag and WA
2353
        if SU.lucky_planets and mode in list(
1✔
2354
            filter(lambda mode: "spec" in mode["inst"]["name"], OS.observingModes)
2355
        ):
2356
            phi = (1 / np.pi) * np.ones(len(SU.d))
×
2357
            dMag = deltaMag(SU.p, SU.Rp, SU.d, phi)[pInds]  # delta magnitude
×
2358
            # working angle
NEW
2359
            WA = (
×
2360
                np.arctan(
2361
                    SU.a.to_value(u.AU)
2362
                    * self.AU2pc
2363
                    / TL.dist[SU.plan2star].to_value(u.pc)
2364
                )
2365
                * self.rad2arcsec
2366
                << u.arcsec
2367
            )[pInds]
2368
        else:
2369
            dMag = dMag if (dMag is not None) else SU.dMag[pInds]
1✔
2370
            WA = WA if (WA is not None) else SU.WA[pInds]
1✔
2371

2372
        # initialize Signal and Noise arrays
2373
        Signal = np.zeros(len(pInds))
1✔
2374
        Noise = np.zeros(len(pInds))
1✔
2375

2376
        # find observable planets wrt IWA-OWA range
2377
        obs = (WA > mode["IWA"]) & (WA < mode["OWA"])
1✔
2378

2379
        if np.any(obs):
1✔
2380
            # find electron counts
2381
            C_p, C_b, C_sp = OS.Cp_Cb_Csp(
1✔
2382
                TL, sInd, fZ, JEZ[obs], dMag[obs], WA[obs], mode, TK=TK
2383
            )
2384
            # calculate signal and noise levels (based on Nemati14 formula)
2385
            _t_int_s = t_int.to_value(u.d) * self.day2sec
1✔
2386

2387
            Signal[obs] = C_p.to_value(self.inv_s) * _t_int_s
1✔
2388
            Noise[obs] = np.sqrt(
1✔
2389
                (
2390
                    C_b.to_value(self.inv_s) * _t_int_s
2391
                    + (C_sp.to_value(self.inv_s) * _t_int_s) ** 2
2392
                )
2393
            )
2394

2395
        return Signal, Noise
1✔
2396

2397
    def update_occulter_mass(self, DRM, sInd, t_int, skMode):
1✔
2398
        """Updates the occulter wet mass in the Observatory module, and stores all
2399
        the occulter related values in the DRM array.
2400

2401
        Args:
2402
            DRM (dict):
2403
                Design Reference Mission, contains the results of one complete
2404
                observation (detection and characterization)
2405
            sInd (int):
2406
                Integer index of the star of interest
2407
            t_int (~astropy.units.Quantity(~numpy.ndarray(float))):
2408
                Selected star integration time (for detection or characterization)
2409
                in units of day
2410
            skMode (str):
2411
                Station keeping observing mode type ('det' or 'char')
2412

2413
        Returns:
2414
            dict:
2415
                Design Reference Mission dictionary, contains the results of one
2416
                complete observation
2417

2418
        """
2419

2420
        TL = self.TargetList
×
2421
        Obs = self.Observatory
×
2422
        TK = self.TimeKeeping
×
2423

2424
        assert skMode in ("det", "char"), "Observing mode type must be 'det' or 'char'."
×
2425

2426
        # decrement mass for station-keeping
2427
        dF_lateral, dF_axial, intMdot, mass_used, deltaV = Obs.mass_dec_sk(
×
2428
            TL, sInd, TK.currentTimeAbs.copy(), t_int
2429
        )
2430

2431
        DRM[skMode + "_dV"] = deltaV.to("m/s")
×
2432
        DRM[skMode + "_mass_used"] = mass_used.to("kg")
×
2433
        DRM[skMode + "_dF_lateral"] = dF_lateral.to("N")
×
2434
        DRM[skMode + "_dF_axial"] = dF_axial.to("N")
×
2435
        # update spacecraft mass
2436
        Obs.scMass = Obs.scMass - mass_used
×
2437
        DRM["scMass"] = Obs.scMass.to("kg")
×
2438
        if Obs.twotanks:
×
2439
            Obs.skMass = Obs.skMass - mass_used
×
2440
            DRM["skMass"] = Obs.skMass.to("kg")
×
2441

2442
        return DRM
×
2443

2444
    def reset_sim(self, genNewPlanets=True, rewindPlanets=True, seed=None):
1✔
2445
        """Performs a full reset of the current simulation.
2446

2447
        This will reinitialize the TimeKeeping, Observatory, and SurveySimulation
2448
        objects with their own outspecs.
2449

2450
        Args:
2451
            genNewPlanets (bool):
2452
                Generate all new planets based on the original input specification.
2453
                If False, then the original planets will remain. Setting to True forces
2454
                ``rewindPlanets`` to be True as well. Defaults True.
2455
            rewindPlanets (bool):
2456
                Reset the current set of planets to their original orbital phases.
2457
                If both genNewPlanets and rewindPlanet  are False, the original planets
2458
                will be retained and will not be rewound to their initial starting
2459
                locations (i.e., all systems will remain at the times they were at the
2460
                end of the last run, thereby effectively randomizing planet phases.
2461
                Defaults True.
2462
            seed (int, optional):
2463
                Random seed to use for all random number generation. If None (default)
2464
                a new random seed will be generated when re-initializing the
2465
                SurveySimulation.
2466

2467
        """
2468

2469
        SU = self.SimulatedUniverse
1✔
2470
        TK = self.TimeKeeping
1✔
2471
        TL = self.TargetList
1✔
2472

2473
        # re-initialize SurveySimulation arrays
2474
        specs = self._outspec
1✔
2475
        specs["modules"] = self.modules
1✔
2476

2477
        if seed is None:  # pop the seed so a new one is generated
1✔
2478
            if "seed" in specs:
1✔
2479
                specs.pop("seed")
1✔
2480
        else:  # if seed is provided, replace seed with provided seed
2481
            specs["seed"] = seed
×
2482

2483
        # reset mission time, re-init surveysim and observatory
2484
        TK.__init__(**TK._outspec)
1✔
2485
        self.__init__(**specs)
1✔
2486
        self.Observatory.__init__(**self.Observatory._outspec)
1✔
2487

2488
        # generate new planets if requested (default)
2489
        if genNewPlanets:
1✔
2490
            TL.stellar_mass()
1✔
2491
            TL.I = TL.gen_inclinations(TL.PlanetPopulation.Irange)  # noqa: E741
1✔
2492
            SU.gen_physical_properties(**SU._outspec)
1✔
2493
            rewindPlanets = True
1✔
2494
        # re-initialize systems if requested (default)
2495
        if rewindPlanets:
1✔
2496
            SU.init_systems()
1✔
2497

2498
        # reset helper arrays
2499
        self.initializeStorageArrays()
1✔
2500

2501
        self.vprint("Simulation reset.")
1✔
2502

2503
    def genOutSpec(
1✔
2504
        self,
2505
        tofile: Optional[str] = None,
2506
        starting_outspec: Optional[Dict[str, Any]] = None,
2507
        modnames: bool = False,
2508
    ) -> Dict[str, Any]:
2509
        """Join all _outspec dicts from all modules into one output dict
2510
        and optionally write out to JSON file on disk.
2511

2512
        Args:
2513
            tofile (str, optional):
2514
                Name of the file containing all output specifications (outspecs).
2515
                Defaults to None.
2516
            starting_outspec (dict, optional):
2517
                Initial outspec (from MissionSim). Defaults to None.
2518
            modnames (bool):
2519
                If True, populate outspec dictionary with the module it originated from,
2520
                instead of the actual value of the keyword. Defaults False.
2521

2522
        Returns:
2523
            dict:
2524
                Dictionary containing the full :ref:`sec:inputspec`, including all
2525
                filled-in default values. Combination of all individual module _outspec
2526
                attributes.
2527

2528
        """
2529

2530
        # start with our own outspec
2531
        if modnames:
1✔
2532
            out = copy.copy(self._outspec)
1✔
2533
            for k in out:
1✔
2534
                out[k] = self.__class__.__name__
1✔
2535
        else:
2536
            out = copy.deepcopy(self._outspec)
1✔
2537

2538
        # Add any provided other outspec
2539
        if starting_outspec is not None:
1✔
2540
            out.update(starting_outspec)
1✔
2541

2542
        # add in all modules _outspec's
2543
        for module in self.modules.values():
1✔
2544
            if modnames:
1✔
2545
                tmp = copy.copy(module._outspec)
1✔
2546
                for k in tmp:
1✔
2547
                    tmp[k] = module.__class__.__name__
1✔
2548
            else:
2549
                tmp = module._outspec
1✔
2550
            out.update(tmp)
1✔
2551

2552
        # add in the specific module names used
2553
        out["modules"] = {}
1✔
2554
        for mod_name, module in self.modules.items():
1✔
2555
            # find the module file
2556
            mod_name_full = module.__module__
1✔
2557
            if mod_name_full.startswith("EXOSIMS"):
1✔
2558
                # take just its short name if it is in EXOSIMS
2559
                mod_name_short = mod_name_full.split(".")[-1]
1✔
2560
            else:
2561
                # take its full path if it is not in EXOSIMS - changing .pyc -> .py
2562
                mod_name_short = re.sub(
×
2563
                    r"\.pyc$", ".py", inspect.getfile(module.__class__)
2564
                )
2565
            out["modules"][mod_name] = mod_name_short
1✔
2566
        # add catalog name
2567
        if self.TargetList.keepStarCatalog:
1✔
2568
            module = self.TargetList.StarCatalog
×
2569
            mod_name_full = module.__module__
×
2570
            if mod_name_full.startswith("EXOSIMS"):
×
2571
                # take just its short name if it is in EXOSIMS
2572
                mod_name_short = mod_name_full.split(".")[-1]
×
2573
            else:
2574
                # take its full path if it is not in EXOSIMS - changing .pyc -> .py
2575
                mod_name_short = re.sub(
×
2576
                    r"\.pyc$", ".py", inspect.getfile(module.__class__)
2577
                )
2578
            out["modules"][mod_name] = mod_name_short
×
2579
        else:
2580
            out["modules"][
1✔
2581
                "StarCatalog"
2582
            ] = self.TargetList.StarCatalog  # we just copy the StarCatalog string
2583

2584
        # if we don't know about the SurveyEnsemble, just write a blank to the output
2585
        if "SurveyEnsemble" not in out["modules"]:
1✔
2586
            out["modules"]["SurveyEnsemble"] = " "
1✔
2587

2588
        # get version and Git information
2589
        out["Version"] = get_version()
1✔
2590

2591
        # dump to file
2592
        if tofile is not None:
1✔
2593
            with open(tofile, "w") as outfile:
1✔
2594
                json.dump(
1✔
2595
                    out,
2596
                    outfile,
2597
                    sort_keys=True,
2598
                    indent=4,
2599
                    ensure_ascii=False,
2600
                    separators=(",", ": "),
2601
                    default=array_encoder,
2602
                )
2603

2604
        return out
1✔
2605

2606
    def generateHashfName(self, specs):
1✔
2607
        """Generate cached file Hashname
2608

2609
        Requires a .XXX appended to end of hashname for each individual use case
2610

2611
        Args:
2612
            specs (dict):
2613
                :ref:`sec:inputspec`
2614

2615
        Returns:
2616
            str:
2617
                Unique indentifier string for cahce products from this set of modules
2618
                and inputs
2619
        """
2620
        # Allows cachefname to be predefined
2621
        if "cachefname" in specs:
1✔
2622
            return specs["cachefname"]
×
2623

2624
        cachefname = ""  # declares cachefname
1✔
2625
        mods = ["Completeness", "TargetList", "OpticalSystem"]  # modules to look at
1✔
2626
        tmp = (
1✔
2627
            self.Completeness.PlanetPopulation.__class__.__name__
2628
            + self.Completeness.PlanetPhysicalModel.__class__.__name__
2629
            + self.PlanetPopulation.__class__.__name__
2630
            + self.SimulatedUniverse.__class__.__name__
2631
            + self.PlanetPhysicalModel.__class__.__name__
2632
        )
2633

2634
        if "selectionMetric" in specs:
1✔
2635
            tmp += specs["selectionMetric"]
×
2636
        if "Izod" in specs:
1✔
2637
            tmp += specs["Izod"]
×
2638
        if "maxiter" in specs:
1✔
2639
            tmp += str(specs["maxiter"])
×
2640
        if "ftol" in specs:
1✔
2641
            tmp += str(specs["ftol"])
×
2642
        if "missionLife" in specs:
1✔
2643
            tmp += str(specs["missionLife"])
1✔
2644
        if "missionPortion" in specs:
1✔
2645
            tmp += str(specs["missionPortion"])
1✔
2646
        if "smaknee" in specs:
1✔
2647
            tmp += str(specs["smaknee"])
×
2648
        if "koAngleMax" in specs:
1✔
2649
            tmp += str(specs["koAngleMax"])
×
2650
        tmp += str(np.sum(self.Completeness.PlanetPopulation.arange.value))
1✔
2651
        tmp += str(np.sum(self.Completeness.PlanetPopulation.Rprange.value))
1✔
2652
        tmp += str(np.sum(self.Completeness.PlanetPopulation.erange))
1✔
2653
        tmp += str(
1✔
2654
            self.Completeness.PlanetPopulation.PlanetPhysicalModel.whichPlanetPhaseFunction  # noqa: E501
2655
        )
2656
        tmp += str(np.sum(self.PlanetPopulation.arange.value))
1✔
2657
        tmp += str(np.sum(self.PlanetPopulation.Rprange.value))
1✔
2658
        tmp += str(np.sum(self.PlanetPopulation.erange))
1✔
2659
        tmp += str(self.PlanetPopulation.PlanetPhysicalModel.whichPlanetPhaseFunction)
1✔
2660

2661
        for mod in mods:
1✔
2662
            cachefname += self.modules[mod].__module__.split(".")[
1✔
2663
                -1
2664
            ]  # add module name to end of cachefname
2665
        cachefname += hashlib.md5(
1✔
2666
            (str(self.TargetList.Name) + tmp).encode("utf-8")
2667
        ).hexdigest()  # turn cachefname into hashlib
2668
        cachefname = os.path.join(
1✔
2669
            self.cachedir, cachefname + os.extsep
2670
        )  # join into filepath and fname
2671
        # Needs file terminator (.starkt0, .t0, etc) appended done by each individual
2672
        # use case.
2673
        return cachefname
1✔
2674

2675
    def revisitFilter(self, sInds, tmpCurrentTimeNorm):
1✔
2676
        """Helper method for Overloading Revisit Filtering
2677

2678
        Args:
2679
            sInds (~numpy.ndarray(int)):
2680
                Indices of stars still in observation list
2681
            tmpCurrentTimeNorm (astropy.units.Quantity):
2682
                Normalized simulation time
2683

2684
        Returns:
2685
            ~numpy.ndarray(int):
2686
                indices of stars still in observation list
2687
        """
2688

2689
        tovisit = np.zeros(self.TargetList.nStars, dtype=bool)
1✔
2690
        if len(sInds) > 0:
1✔
2691
            # Check that no star has exceeded the number of revisits and the indicies
2692
            # of all considered stars have minimum number of observations
2693
            # This should prevent revisits so long as all stars have not
2694
            # been observed
2695
            tovisit[sInds] = (self.starVisits[sInds] == min(self.starVisits[sInds])) & (
1✔
2696
                self.starVisits[sInds] < self.nVisitsMax
2697
            )
2698
            if self.starRevisit.size != 0:
1✔
2699
                ind_rev = self.revisit_inds(sInds, tmpCurrentTimeNorm)
1✔
2700
                tovisit[ind_rev] = self.starVisits[ind_rev] < self.nVisitsMax
1✔
2701
            sInds = np.where(tovisit)[0]
1✔
2702
        return sInds
1✔
2703

2704
    def is_earthlike(self, plan_inds, sInd):
1✔
2705
        """Is the planet earthlike? Returns boolean array that's True for Earth-like
2706
        planets.
2707

2708

2709
        Args:
2710
            plan_inds(~numpy.ndarray(int)):
2711
                Planet indices
2712
            sInd (int):
2713
                Star index
2714

2715
        Returns:
2716
            ~numpy.ndarray(bool):
2717
                Array of same dimension as plan_inds input that's True for Earthlike
2718
                planets and false otherwise.
2719
        """
2720
        TL = self.TargetList
1✔
2721
        SU = self.SimulatedUniverse
1✔
2722
        PPop = self.PlanetPopulation
1✔
2723

2724
        # extract planet and star properties
2725
        Rp_plan = SU.Rp[plan_inds].value
1✔
2726
        L_star = TL.L[sInd]
1✔
2727
        if PPop.scaleOrbits:
1✔
2728
            a_plan = (SU.a[plan_inds] / np.sqrt(L_star)).value
×
2729
        else:
2730
            a_plan = (SU.a[plan_inds]).value
1✔
2731
        # Definition: planet radius (in earth radii) and solar-equivalent luminosity
2732
        # must be between the given bounds.
2733
        Rp_plan_lo = 0.80 / np.sqrt(a_plan)
1✔
2734
        # We use the numpy versions so that plan_ind can be a numpy vector.
2735
        return np.logical_and(
1✔
2736
            np.logical_and(Rp_plan >= Rp_plan_lo, Rp_plan <= 1.4),
2737
            np.logical_and(a_plan >= 0.95, a_plan <= 1.67),
2738
        )
2739

2740
    def find_known_plans(self):
1✔
2741
        """
2742
        Find and return list of known RV stars and list of stars with earthlike planets
2743
        based on info from David Plavchan dated 12/24/2018
2744
        """
2745
        TL = self.TargetList
×
2746
        SU = self.SimulatedUniverse
×
2747
        PPop = self.PlanetPopulation
×
2748
        L_star = TL.L[SU.plan2star]
×
2749

2750
        c = 28.4 * u.m / u.s
×
2751
        Mj = 317.8 * u.earthMass
×
2752
        Mpj = SU.Mp / Mj  # planet masses in jupiter mass units
×
2753
        Ms = TL.MsTrue[SU.plan2star]
×
2754
        Teff = TL.Teff[SU.plan2star]
×
2755
        mu = const.G * (SU.Mp + Ms)
×
2756
        T = (2.0 * np.pi * np.sqrt(SU.a**3 / mu)).to(u.yr)
×
2757
        e = SU.e
×
2758

2759
        # pinds in correct temp range
2760
        t_filt = np.where((Teff.value > 3000) & (Teff.value < 6800))[0]
×
2761

2762
        K = (
×
2763
            (c / np.sqrt(1 - e[t_filt]))
2764
            * Mpj[t_filt]
2765
            * np.sin(SU.I[t_filt])
2766
            * Ms[t_filt] ** (-2 / 3)
2767
            * T[t_filt] ** (-1 / 3)
2768
        )
2769

2770
        K_filter = (T[t_filt].to(u.d) / 10**4).value  # create period-filter
×
2771
        # if period-filter value is lower than .03, set to .03
2772
        K_filter[np.where(K_filter < 0.03)[0]] = 0.03
×
2773
        k_filt = t_filt[np.where(K.value > K_filter)[0]]  # pinds in the correct K range
×
2774

2775
        if PPop.scaleOrbits:
×
2776
            a_plan = (SU.a / np.sqrt(L_star)).value
×
2777
        else:
2778
            a_plan = SU.a.value
×
2779

2780
        Rp_plan_lo = 0.80 / np.sqrt(a_plan)
×
2781

2782
        # pinds in habitable zone
2783
        a_filt = k_filt[np.where((a_plan[k_filt] > 0.95) & (a_plan[k_filt] < 1.67))[0]]
×
2784
        # rocky planets
2785
        r_filt = a_filt[
×
2786
            np.where(
2787
                (SU.Rp.value[a_filt] >= Rp_plan_lo[a_filt])
2788
                & (SU.Rp.value[a_filt] < 1.4)
2789
            )[0]
2790
        ]
2791
        self.known_earths = np.union1d(self.known_earths, r_filt).astype(int)
×
2792

2793
        # these are known_rv stars with earths around them
2794
        known_stars = np.unique(SU.plan2star[k_filt])  # these are known_rv stars
×
2795
        known_rocky = np.unique(SU.plan2star[r_filt])
×
2796

2797
        # if include_known_RV, then filter out all other sInds
2798
        if self.include_known_RV is not None:
×
2799
            HIP_sInds = np.where(np.in1d(TL.Name, self.include_known_RV))[0]
×
2800
            known_stars = np.intersect1d(HIP_sInds, known_stars)
×
2801
            known_rocky = np.intersect1d(HIP_sInds, known_rocky)
×
2802
        return known_stars.astype(int), known_rocky.astype(int)
×
2803

2804
    def find_char_SNR(self, sInd, pIndsChar, startTime, intTime, mode):
1✔
2805
        """Finds the SNR achieved by an observing mode after intTime days
2806

2807
        The observation window (which includes settling and overhead times)
2808
        is a superset of the integration window (in which photons are collected).
2809

2810
        The observation window begins at startTime. The integration window
2811
        begins at startTime + Obs.settlingTime + mode["ohTime"],
2812
        and the integration itself has a duration of intTime.
2813

2814
        Args:
2815
            sInd (int):
2816
                Integer index of the star of interest
2817
            pIndsChar (int numpy.ndarray):
2818
                Observable planets to characterize. Place (-1) at end to put
2819
                False Alarm parameters at end of returned tuples.
2820
            startTime (astropy.units.Quantity):
2821
                Beginning of observation window in units of day.
2822
            intTime (astropy.units.Quantity):
2823
                Selected star characterization integration time in units of day.
2824
            mode (dict):
2825
                Observing mode for the characterization
2826

2827
        Returns:
2828
            tuple:
2829
                SNR (float numpy.ndarray):
2830
                    Characterization signal-to-noise ratio of the observable planets.
2831
                    Defaults to None. [TBD]
2832
                fZ (astropy.units.Quantity):
2833
                    Surface brightness of local zodiacal light in units of 1/arcsec2
2834
                systemParams (dict):
2835
                    Dictionary of time-dependent planet properties averaged over the
2836
                    duration of the integration.
2837

2838
        """
2839

2840
        OS = self.OpticalSystem
×
2841
        ZL = self.ZodiacalLight
×
2842
        TL = self.TargetList
×
2843
        SU = self.SimulatedUniverse
×
2844
        Obs = self.Observatory
×
2845
        TK = self.TimeKeeping
×
2846

2847
        # time at start of integration window
2848
        currentTimeNorm = startTime
×
2849
        currentTimeAbs = TK.missionStart + startTime
×
2850

2851
        # first, calculate SNR for observable planets (without false alarm)
2852
        planinds = pIndsChar[:-1] if pIndsChar[-1] == -1 else pIndsChar
×
2853
        SNRplans = np.zeros(len(planinds))
×
2854
        if len(planinds) > 0:
×
2855
            # initialize arrays for SNR integration
2856
            fZs = np.zeros(self.ntFlux) / u.arcsec**2.0
×
2857
            systemParamss = np.empty(self.ntFlux, dtype="object")
×
2858
            Ss = np.zeros((self.ntFlux, len(planinds)))
×
2859
            Ns = np.zeros((self.ntFlux, len(planinds)))
×
2860
            # integrate the signal (planet flux) and noise
2861
            dt = intTime / float(self.ntFlux)
×
2862
            timePlus = (
×
2863
                Obs.settlingTime.copy() + mode["syst"]["ohTime"].copy()
2864
            )  # accounts for the time since the current time
2865
            for i in range(self.ntFlux):
×
2866
                # calculate signal and noise (electron count rates)
2867
                if SU.lucky_planets:
×
2868
                    fZs[i] = ZL.fZ(Obs, TL, sInd, currentTimeAbs, mode)[0]
×
2869
                    Ss[i, :], Ns[i, :] = self.calc_signal_noise(
×
2870
                        sInd, planinds, dt, mode, fZ=fZs[i]
2871
                    )
2872
                # allocate first half of dt
2873
                timePlus += dt / 2.0
×
2874
                # calculate current zodiacal light brightness
2875
                fZs[i] = ZL.fZ(Obs, TL, sInd, currentTimeAbs + timePlus, mode)[0]
×
2876
                # propagate the system to match up with current time
2877
                SU.propag_system(
×
2878
                    sInd, currentTimeNorm + timePlus - self.propagTimes[sInd]
2879
                )
2880
                self.propagTimes[sInd] = currentTimeNorm + timePlus
×
2881
                # time-varying planet params (WA, dMag, phi, fEZ, d)
2882
                systemParamss[i] = SU.dump_system_params(sInd)
×
2883
                # calculate signal and noise (electron count rates)
2884
                if not SU.lucky_planets:
×
2885
                    Ss[i, :], Ns[i, :] = self.calc_signal_noise(
×
2886
                        sInd, planinds, dt, mode, fZ=fZs[i]
2887
                    )
2888
                # allocate second half of dt
2889
                timePlus += dt / 2.0
×
2890

2891
            # average output parameters
2892
            fZ = np.mean(fZs)
×
2893
            systemParams = {
×
2894
                key: sum([systemParamss[x][key] for x in range(self.ntFlux)])
2895
                / float(self.ntFlux)
2896
                for key in sorted(systemParamss[0])
2897
            }
2898
            # calculate planets SNR
2899
            S = Ss.sum(0)
×
2900
            N = Ns.sum(0)
×
2901
            SNRplans[N > 0] = S[N > 0] / N[N > 0]
×
2902
            # allocate extra time for timeMultiplier
2903

2904
        # if only a FA, just save zodiacal brightness
2905
        # in the middle of the integration
2906
        else:
2907
            totTime = intTime * (mode["timeMultiplier"])
×
2908
            fZ = ZL.fZ(Obs, TL, sInd, currentTimeAbs.copy() + totTime / 2.0, mode)[0]
×
2909

2910
        # calculate the false alarm SNR (if any)
2911
        SNRfa = []
×
2912
        if pIndsChar[-1] == -1:
×
2913
            # Note: these attributes may not exist for all schedulers
2914
            fEZ = self.lastDetected[sInd, 1][-1] / u.arcsec**2.0
×
2915
            dMag = self.lastDetected[sInd, 2][-1]
×
2916
            WA = self.lastDetected[sInd, 3][-1] * u.arcsec
×
2917
            C_p, C_b, C_sp = OS.Cp_Cb_Csp(TL, sInd, fZ, fEZ, dMag, WA, mode)
×
2918
            S = (C_p * intTime).decompose().value
×
2919
            N = np.sqrt((C_b * intTime + (C_sp * intTime) ** 2.0).decompose().value)
×
2920
            SNRfa = S / N if N > 0.0 else 0.0
×
2921

2922
        # SNR vector is of length (#planets), plus 1 if FA
2923
        # This routine leaves SNR = 0 if unknown or not found
2924
        pInds = np.where(SU.plan2star == sInd)[0]
×
2925
        FA_present = pIndsChar[-1] == -1
×
2926
        # there will be one SNR for each entry of pInds_FA
2927
        pInds_FA = np.append(pInds, [-1] if FA_present else np.zeros(0, dtype=int))
×
2928

2929
        # boolean index vector into SNR
2930
        #   True iff we have computed a SNR for that planet
2931
        #   False iff we didn't find an SNR (and will leave 0 there)
2932
        #   if FA_present, SNR_plug_in[-1] = True
2933
        SNR_plug_in = np.isin(pInds_FA, pIndsChar)
×
2934

2935
        # save all SNRs (planets and FA) to one array
2936
        SNR = np.zeros(len(pInds_FA))
×
2937
        # plug in the SNR's we computed (pIndsChar and optional FA)
2938
        SNR[SNR_plug_in] = np.append(SNRplans, SNRfa)
×
2939

2940
        return SNR, fZ, systemParams
×
2941

2942
    def revisit_inds(self, sInds, tmpCurrentTimeNorm):
1✔
2943
        """Return subset of star indices that are scheduled for revisit.
2944

2945
        Args:
2946
            sInds (~numpy.ndarray(int)):
2947
                Indices of stars to consider
2948
            tmpCurrentTimeNorm (astropy.units.Quantity):
2949
                Normalized simulation time
2950

2951
        Returns:
2952
            ~numpy.ndarray(int):
2953
                Indices of stars whose revisit is scheduled for within `self.dt_max` of
2954
                the current time
2955

2956
        """
2957
        dt_rev = np.abs(self.starRevisit[:, 1] * u.day - tmpCurrentTimeNorm)
1✔
2958
        ind_rev = [
1✔
2959
            int(x) for x in self.starRevisit[dt_rev < self.dt_max, 0] if x in sInds
2960
        ]
2961

2962
        return ind_rev
1✔
2963

2964
    def keepout_filter(self, sInds, startTimes, endTimes, koMap):
1✔
2965
        """Filters stars not observable due to keepout
2966

2967
        Args:
2968
            sInds (~numpy.ndarray(int)):
2969
                indices of stars still in observation list
2970
            startTimes (~numpy.ndarray(float)):
2971
                start times of observations
2972
            endTimes (~numpy.ndarray(float)):
2973
                end times of observations
2974
            koMap (~numpy.ndarray(bool)):
2975
                map where True is for a target unobstructed and observable,
2976
                False is for a target obstructed and unobservable due to keepout zone
2977

2978
        Returns:
2979
            ~numpy.ndarray(int):
2980
                sInds - filtered indices of stars still in observation list
2981

2982
        """
2983
        # find the indices of keepout times that pertain to the start and end times of
2984
        # observation
2985
        startInds = np.searchsorted(self.koTimes.value, startTimes)
×
2986
        endInds = np.searchsorted(self.koTimes.value, endTimes)
×
2987

2988
        # boolean array of available targets (unobstructed between observation start and
2989
        # end time) we include a -1 in the start and +1 in the end to include keepout
2990
        # days enclosing start and end times
2991
        availableTargets = np.array(
×
2992
            [
2993
                np.all(
2994
                    koMap[
2995
                        sInd,
2996
                        max(startInds[sInd] - 1, 0) : min(
2997
                            endInds[sInd] + 1, len(self.koTimes.value) + 1
2998
                        ),
2999
                    ]
3000
                )
3001
                for sInd in sInds
3002
            ],
3003
            dtype="bool",
3004
        )
3005

3006
        return sInds[availableTargets]
×
3007

3008

3009
def array_encoder(obj):
1✔
3010
    r"""Encodes numpy arrays, astropy Times, and astropy Quantities, into JSON.
3011

3012
    Called from json.dump for types that it does not already know how to represent,
3013
    like astropy Quantity's, numpy arrays, etc.  The json.dump() method encodes types
3014
    like ints, strings, and lists itself, so this code does not see these types.
3015
    Likewise, this routine can and does return such objects, which is OK as long as
3016
    they unpack recursively into types for which encoding is known
3017

3018
    Args:
3019
        obj (Any):
3020
            Object to encode.
3021

3022
        Returns:
3023
            Any:
3024
                Encoded object
3025

3026
    """
3027

3028
    from astropy.coordinates import SkyCoord
1✔
3029
    from astropy.time import Time
1✔
3030

3031
    if isinstance(obj, Time):
1✔
3032
        # astropy Time -> time string
3033
        return obj.fits  # isot also makes sense here
×
3034
    if isinstance(obj, u.quantity.Quantity):
1✔
3035
        # note: it is possible to have a numpy ndarray wrapped in a Quantity.
3036
        # NB: alternatively, can return (obj.value, obj.unit.name)
3037
        return obj.value
×
3038
    if isinstance(obj, SkyCoord):
1✔
3039
        return dict(
×
3040
            lon=obj.heliocentrictrueecliptic.lon.value,
3041
            lat=obj.heliocentrictrueecliptic.lat.value,
3042
            distance=obj.heliocentrictrueecliptic.distance.value,
3043
        )
3044
    if isinstance(obj, (np.ndarray, np.number)):
1✔
3045
        # ndarray -> list of numbers
3046
        return obj.tolist()
1✔
3047
    if isinstance(obj, complex):
1✔
3048
        # complex -> (real, imag) pair
3049
        return [obj.real, obj.imag]
×
3050
    if callable(obj):
1✔
3051
        # this case occurs for interpolants like PSF and QE
3052
        # We cannot simply "write" the function to JSON, so we make up a string
3053
        # to keep from throwing an error.
3054
        # The fix is simple: when generating the interpolant, add a _outspec attribute
3055
        # to the function (or the lambda), containing (e.g.) the fits filename, or the
3056
        # explicit number -- whatever string was used.  Then, here, check for that
3057
        # attribute and write it out instead of this dummy string.  (Attributes can
3058
        # be transparently attached to python functions, even lambda's.)
3059
        return "interpolant_function"
1✔
3060
    if isinstance(obj, set):
×
3061
        return list(obj)
×
3062
    if isinstance(obj, bytes):
×
3063
        return obj.decode()
×
3064
    # an EXOSIMS object
3065
    if hasattr(obj, "_modtype"):
×
3066
        return obj.__dict__
×
3067
    # an object for which no encoding is defined yet
3068
    #   as noted above, ordinary types (lists, ints, floats) do not take this path
3069
    raise ValueError("Could not JSON-encode an object of type %s" % type(obj))
×
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