Coverage for python/lsst/sims/maf/metrics/moMetrics.py : 11%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1from builtins import zip
2import numpy as np
4from .baseMetric import BaseMetric
6__all__ = ['BaseMoMetric', 'NObsMetric', 'NObsNoSinglesMetric',
7 'NNightsMetric', 'ObsArcMetric',
8 'DiscoveryMetric', 'Discovery_N_ChancesMetric', 'Discovery_N_ObsMetric',
9 'Discovery_TimeMetric', 'Discovery_DistanceMetric',
10 'Discovery_RADecMetric', 'Discovery_EcLonLatMetric',
11 'Discovery_VelocityMetric',
12 'ActivityOverTimeMetric', 'ActivityOverPeriodMetric',
13 'MagicDiscoveryMetric',
14 'HighVelocityMetric', 'HighVelocityNightsMetric',
15 'LightcurveInversion_AsteroidMetric', 'Color_AsteroidMetric',
16 'InstantaneousColorMetric', 'LightcurveColor_OuterMetric',
17 'PeakVMagMetric', 'KnownObjectsMetric']
20def _setVis(ssoObs, snrLimit, snrCol, visCol):
21 if snrLimit is not None:
22 vis = np.where(ssoObs[snrCol] >= snrLimit)[0]
23 else:
24 vis = np.where(ssoObs[visCol] > 0)[0]
25 return vis
28class BaseMoMetric(BaseMetric):
29 """Base class for the moving object metrics.
30 Intended to be used with the Moving Object Slicer."""
32 def __init__(self, cols=None, metricName=None, units='#', badval=0,
33 comment=None, childMetrics=None,
34 appMagCol='appMag', appMagVCol='appMagV', m5Col='fiveSigmaDepth',
35 nightCol='night', mjdCol='observationStartMJD',
36 snrCol='SNR', visCol='vis',
37 raCol='ra', decCol='dec', seeingCol='seeingFwhmGeom',
38 expTimeCol='visitExposureTime', filterCol='filter'):
39 # Set metric name.
40 self.name = metricName
41 if self.name is None:
42 self.name = self.__class__.__name__.replace('Metric', '', 1)
43 # Set badval and units, leave space for 'comment' (tied to displayDict).
44 self.badval = badval
45 self.units = units
46 self.comment = comment
47 # Set some commonly used column names.
48 self.m5Col = m5Col
49 self.appMagCol = appMagCol
50 self.appMagVCol = appMagVCol
51 self.nightCol = nightCol
52 self.mjdCol = mjdCol
53 self.snrCol = snrCol
54 self.visCol = visCol
55 self.raCol = raCol
56 self.decCol = decCol
57 self.seeingCol = seeingCol
58 self.expTimeCol = expTimeCol
59 self.filterCol = filterCol
60 self.colsReq = [self.appMagCol, self.m5Col,
61 self.nightCol, self.mjdCol,
62 self.snrCol, self.visCol]
63 if cols is not None:
64 for col in cols:
65 self.colsReq.append(col)
67 if childMetrics is None:
68 try:
69 if not isinstance(self.childMetrics, dict):
70 raise ValueError('self.childMetrics must be a dictionary (possibly empty)')
71 except AttributeError:
72 self.childMetrics = {}
73 self.metricDtype = 'float'
74 else:
75 if not isinstance(childMetrics, dict):
76 raise ValueError('childmetrics must be provided as a dictionary.')
77 self.childMetrics = childMetrics
78 self.metricDtype = 'object'
79 self.shape = 1
81 def run(self, ssoObs, orb, Hval):
82 """Calculate the metric value.
84 Parameters
85 ----------
86 ssoObs: np.ndarray
87 The input data to the metric (same as the parent metric).
88 orb: np.ndarray
89 The information about the orbit for which the metric is being calculated.
90 Hval : float
91 The H value for which the metric is being calculated.
93 Returns
94 -------
95 float or np.ndarray or dict
96 """
97 raise NotImplementedError
100class BaseChildMetric(BaseMoMetric):
101 """Base class for child metrics.
103 Parameters
104 ----------
105 parentDiscoveryMetric: BaseMoMetric
106 The 'parent' metric which generated the metric data used to calculate this 'child' metric.
107 badval: float, opt
108 Value to return when metric cannot be calculated.
109 """
110 def __init__(self, parentDiscoveryMetric, badval=0, **kwargs):
111 super().__init__(badval=badval, **kwargs)
112 self.parentMetric = parentDiscoveryMetric
113 self.childMetrics = {}
114 if 'metricDtype' in kwargs:
115 self.metricDtype = kwargs['metricDtype']
116 else:
117 self.metricDtype = 'float'
119 def run(self, ssoObs, orb, Hval, metricValues):
120 """Calculate the child metric value.
122 Parameters
123 ----------
124 ssoObs: np.ndarray
125 The input data to the metric (same as the parent metric).
126 orb: np.ndarray
127 The information about the orbit for which the metric is being calculated.
128 Hval : float
129 The H value for which the metric is being calculated.
130 metricValues : dict or np.ndarray
131 The return value from the parent metric.
133 Returns
134 -------
135 float
136 """
137 raise NotImplementedError
140class NObsMetric(BaseMoMetric):
141 """
142 Count the total number of observations where an SSobject was 'visible'.
143 """
144 def __init__(self, snrLimit=None, **kwargs):
145 """
146 @ snrLimit .. if snrLimit is None, this uses the _calcVis method/completeness
147 if snrLimit is not None, this uses that value as a cutoff instead.
148 """
149 super().__init__(**kwargs)
150 self.snrLimit = snrLimit
152 def run(self, ssoObs, orb, Hval):
153 if self.snrLimit is not None:
154 vis = np.where(ssoObs[self.snrCol] >= self.snrLimit)[0]
155 return vis.size
156 else:
157 vis = np.where(ssoObs[self.visCol] > 0)[0]
158 return vis.size
161class NObsNoSinglesMetric(BaseMoMetric):
162 """
163 Count the number of observations for an SSobject, without singles.
164 Don't include any observations where it was a single observation on a night.
165 """
166 def __init__(self, snrLimit=None, **kwargs):
167 super().__init__(**kwargs)
168 self.snrLimit = snrLimit
170 def run(self, ssoObs, orb, Hval):
171 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
172 if len(vis) == 0:
173 return 0
174 nights = ssoObs[self.nightCol][vis]
175 nights = nights.astype('int')
176 ncounts = np.bincount(nights)
177 nobs = ncounts[np.where(ncounts > 1)].sum()
178 return nobs
181class NNightsMetric(BaseMoMetric):
182 """Count the number of distinct nights an SSobject is observed.
183 """
184 def __init__(self, snrLimit=None, **kwargs):
185 """
186 @ snrLimit : if SNRlimit is None, this uses _calcVis method/completeness
187 else if snrLimit is not None, it uses that value as a cutoff.
188 """
189 super().__init__(**kwargs)
190 self.snrLimit = snrLimit
192 def run(self, ssoObs, orb, Hval):
193 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
194 if len(vis) == 0:
195 return 0
196 nights = len(np.unique(ssoObs[self.nightCol][vis]))
197 return nights
200class ObsArcMetric(BaseMoMetric):
201 """Calculate the difference between the first and last observation of an SSobject.
202 """
203 def __init__(self, snrLimit=None, **kwargs):
204 super().__init__(**kwargs)
205 self.snrLimit = snrLimit
207 def run(self, ssoObs, orb, Hval):
208 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
209 if len(vis) == 0:
210 return 0
211 arc = ssoObs[self.mjdCol][vis].max() - ssoObs[self.mjdCol][vis].min()
212 return arc
215class DiscoveryMetric(BaseMoMetric):
216 """Identify the discovery opportunities for an SSobject.
218 Parameters
219 ----------
220 nObsPerNight : int, opt
221 Number of observations required within a single night. Default 2.
222 tMin : float, opt
223 Minimum time span between observations in a single night, in days.
224 Default 5 minutes (5/60/24).
225 tMax : float, opt
226 Maximum time span between observations in a single night, in days.
227 Default 90 minutes.
228 nNightsPerWindow : int, opt
229 Number of nights required with observations, within the track window. Default 3.
230 tWindow : int, opt
231 Number of nights included in the track window. Default 15.
232 snrLimit : None or float, opt
233 SNR limit to use for observations. If snrLimit is None, (default), then it uses
234 the completeness calculation added to the 'vis' column (probabilistic visibility,
235 based on 5-sigma limit). If snrLimit is not None, it uses this SNR value as a cutoff.
236 metricName : str, opt
237 The metric name to use.
238 Default will be to construct Discovery_nObsPerNightxnNightsPerWindowintWindow.
239 """
240 def __init__(self, nObsPerNight=2,
241 tMin=5./60.0/24.0, tMax=90./60./24.0,
242 nNightsPerWindow=3, tWindow=15,
243 snrLimit=None, badval=None, **kwargs):
244 # Define anything needed by the child metrics first.
245 self.snrLimit = snrLimit
246 self.childMetrics = {'N_Chances': Discovery_N_ChancesMetric(self),
247 'N_Obs': Discovery_N_ObsMetric(self),
248 'Time': Discovery_TimeMetric(self),
249 'Distance': Discovery_DistanceMetric(self),
250 'RADec': Discovery_RADecMetric(self),
251 'EcLonLat': Discovery_EcLonLatMetric(self)}
252 if 'metricName' in kwargs:
253 metricName = kwargs.get('metricName')
254 del kwargs['metricName']
255 else:
256 metricName = 'Discovery_%.0fx%.0fin%.0f' % (nObsPerNight, nNightsPerWindow, tWindow)
257 # Set up for inheriting from __init__.
258 super().__init__(metricName=metricName, childMetrics=self.childMetrics,
259 badval=badval, **kwargs)
260 # Define anything needed for this metric.
261 self.nObsPerNight = nObsPerNight
262 self.tMin = tMin
263 self.tMax = tMax
264 self.nNightsPerWindow = nNightsPerWindow
265 self.tWindow = tWindow
267 def run(self, ssoObs, orb, Hval):
268 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
269 if len(vis) == 0:
270 return self.badval
271 # Identify discovery opportunities.
272 # Identify visits where the 'night' changes.
273 visSort = np.argsort(ssoObs[self.mjdCol][vis])
274 nights = ssoObs[self.nightCol][vis][visSort]
275 #print 'all nights', nights
276 n = np.unique(nights)
277 # Identify all the indexes where the night changes in value.
278 nIdx = np.searchsorted(nights, n)
279 #print 'nightchanges', nights[nIdx]
280 # Count the number of observations per night (except last night)
281 obsPerNight = (nIdx - np.roll(nIdx, 1))[1:]
282 # Add the number of observations on the last night.
283 obsLastNight = np.array([len(nights) - nIdx[-1]])
284 obsPerNight = np.concatenate((obsPerNight, obsLastNight))
285 # Find the nights with more than nObsPerNight.
286 nWithXObs = n[np.where(obsPerNight >= self.nObsPerNight)]
287 nIdxMany = np.searchsorted(nights, nWithXObs)
288 nIdxManyEnd = np.searchsorted(nights, nWithXObs, side='right') - 1
289 # Check that nObsPerNight observations are within tMin/tMax
290 timesStart = ssoObs[self.mjdCol][vis][visSort][nIdxMany]
291 timesEnd = ssoObs[self.mjdCol][vis][visSort][nIdxManyEnd]
292 # Identify the nights with 'clearly good' observations.
293 good = np.where((timesEnd - timesStart >= self.tMin) & (timesEnd - timesStart <= self.tMax), 1, 0)
294 # Identify the nights where we need more investigation
295 # (a subset of the visits may be within the interval).
296 check = np.where((good==0) & (nIdxManyEnd + 1 - nIdxMany > self.nObsPerNight)
297 & (timesEnd-timesStart > self.tMax))[0]
298 for i, j, c in zip(visSort[nIdxMany][check], visSort[nIdxManyEnd][check], check):
299 t = ssoObs[self.mjdCol][vis][visSort][i:j+1]
300 dtimes = (np.roll(t, 1- self.nObsPerNight) - t)[:-1]
301 tidx = np.where((dtimes >= self.tMin) & (dtimes <= self.tMax))[0]
302 if len(tidx) > 0:
303 good[c] = 1
304 # 'good' provides mask for observations which could count as 'good to make tracklets'
305 # against ssoObs[visSort][nIdxMany]. Now identify tracklets which can make tracks.
306 goodIdx = visSort[nIdxMany][good == 1]
307 goodIdxEnds = visSort[nIdxManyEnd][good == 1]
308 #print 'good tracklets', nights[goodIdx]
309 if len(goodIdx) < self.nNightsPerWindow:
310 return self.badval
311 deltaNights = np.roll(ssoObs[self.nightCol][vis][goodIdx], 1 - self.nNightsPerWindow) \
312 - ssoObs[self.nightCol][vis][goodIdx]
313 # Identify the index in ssoObs[vis][goodIdx] (sorted by mjd) where the discovery opportunity starts.
314 startIdxs = np.where((deltaNights >= 0) & (deltaNights <= self.tWindow))[0]
315 # Identify the index where the discovery opportunity ends.
316 endIdxs = np.zeros(len(startIdxs), dtype='int')
317 for i, sIdx in enumerate(startIdxs):
318 inWindow = np.where(ssoObs[self.nightCol][vis][goodIdx]
319 - ssoObs[self.nightCol][vis][goodIdx][sIdx] <= self.tWindow)[0]
320 endIdxs[i] = np.array([inWindow.max()])
321 # Convert back to index based on ssoObs[vis] (sorted by expMJD).
322 startIdxs = goodIdx[startIdxs]
323 endIdxs = goodIdxEnds[endIdxs]
324 #print 'start', startIdxs, nights[startIdxs]#, orb['objId'], Hval
325 #print 'end', endIdxs, nights[endIdxs]#, orb['objId'], Hval
326 return {'start':startIdxs, 'end':endIdxs, 'trackletNights':ssoObs[self.nightCol][vis][goodIdx]}
329class Discovery_N_ChancesMetric(BaseChildMetric):
330 """Calculate total number of discovery opportunities for an SSobject.
332 Calculates total number of discovery opportunities between nightStart / nightEnd.
333 Child metric to be used with the Discovery Metric.
334 """
335 def __init__(self, parentDiscoveryMetric, nightStart=None, nightEnd=None, badval=0, **kwargs):
336 super().__init__(parentDiscoveryMetric, badval=badval, **kwargs)
337 self.nightStart = nightStart
338 self.nightEnd = nightEnd
339 self.snrLimit = parentDiscoveryMetric.snrLimit
340 # Update the metric name to use the nightStart/nightEnd values, if an overriding name is not given.
341 if 'metricName' not in kwargs:
342 if nightStart is not None:
343 self.name = self.name + '_n%d' % (nightStart)
344 if nightEnd is not None:
345 self.name = self.name + '_n%d' % (nightEnd)
347 def run(self, ssoObs, orb, Hval, metricValues):
348 """Return the number of different discovery chances we had for each object/H combination.
349 """
350 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
351 if len(vis) == 0:
352 return self.badval
353 if self.nightStart is None and self.nightEnd is None:
354 return len(metricValues['start'])
355 # Otherwise, we have to sort out what night the discovery chances happened on.
356 visSort = np.argsort(ssoObs[self.mjdCol][vis])
357 nights = ssoObs[self.nightCol][vis][visSort]
358 startNights = nights[metricValues['start']]
359 endNights = nights[metricValues['end']]
360 if self.nightEnd is None and self.nightStart is not None:
361 valid = np.where(startNights >= self.nightStart)[0]
362 elif self.nightStart is None and self.nightEnd is not None:
363 valid = np.where(endNights <= self.nightEnd)[0]
364 else:
365 # And we only end up here if both were not None.
366 valid = np.where((startNights >= self.nightStart) & (endNights <= self.nightEnd))[0]
367 return len(valid)
370class Discovery_N_ObsMetric(BaseChildMetric):
371 """Calculates the number of observations in the i-th discovery track of an SSobject.
372 """
373 def __init__(self, parentDiscoveryMetric, i=0, badval=0, **kwargs):
374 super().__init__(parentDiscoveryMetric, badval=badval, **kwargs)
375 # The number of the discovery chance to use.
376 self.i = i
378 def run(self, ssoObs, orb, Hval, metricValues):
379 if self.i >= len(metricValues['start']):
380 return 0
381 startIdx = metricValues['start'][self.i]
382 endIdx = metricValues['end'][self.i]
383 nobs = endIdx - startIdx
384 return nobs
387class Discovery_TimeMetric(BaseChildMetric):
388 """Returns the time of the i-th discovery track of an SSobject.
389 """
390 def __init__(self, parentDiscoveryMetric, i=0, tStart=None, badval=-999, **kwargs):
391 super().__init__(parentDiscoveryMetric, badval=badval, **kwargs)
392 self.i = i
393 self.tStart = tStart
394 self.snrLimit = parentDiscoveryMetric.snrLimit
396 def run(self, ssoObs, orb, Hval, metricValues):
397 if self.i>=len(metricValues['start']):
398 return self.badval
399 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
400 if len(vis) == 0:
401 return self.badval
402 visSort = np.argsort(ssoObs[self.mjdCol][vis])
403 times = ssoObs[self.mjdCol][vis][visSort]
404 startIdx = metricValues['start'][self.i]
405 tDisc = times[startIdx]
406 if self.tStart is not None:
407 tDisc = tDisc - self.tStart
408 return tDisc
411class Discovery_DistanceMetric(BaseChildMetric):
412 """Returns the distance of the i-th discovery track of an SSobject.
413 """
414 def __init__(self, parentDiscoveryMetric, i=0, distanceCol='geo_dist', badval=-999, **kwargs):
415 super().__init__(parentDiscoveryMetric, badval=badval, **kwargs)
416 self.i = i
417 self.distanceCol = distanceCol
418 self.snrLimit = parentDiscoveryMetric.snrLimit
420 def run(self, ssoObs, orb, Hval, metricValues):
421 if self.i>=len(metricValues['start']):
422 return self.badval
423 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
424 if len(vis) == 0:
425 return self.badval
426 visSort = np.argsort(ssoObs[self.mjdCol][vis])
427 dists = ssoObs[self.distanceCol][vis][visSort]
428 startIdx = metricValues['start'][self.i]
429 distDisc = dists[startIdx]
430 return distDisc
433class Discovery_RADecMetric(BaseChildMetric):
434 """Returns the RA/Dec of the i-th discovery track of an SSobject.
435 """
436 def __init__(self, parentDiscoveryMetric, i=0, badval=None, **kwargs):
437 super().__init__(parentDiscoveryMetric, badval=badval, **kwargs)
438 self.i = i
439 self.snrLimit = parentDiscoveryMetric.snrLimit
440 self.metricDtype = 'object'
442 def run(self, ssoObs, orb, Hval, metricValues):
443 if self.i>=len(metricValues['start']):
444 return self.badval
445 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
446 if len(vis) == 0:
447 return self.badval
448 visSort = np.argsort(ssoObs[self.mjdCol][vis])
449 ra = ssoObs[self.raCol][vis][visSort]
450 dec = ssoObs[self.decCol][vis][visSort]
451 startIdx = metricValues['start'][self.i]
452 return (ra[startIdx], dec[startIdx])
455class Discovery_EcLonLatMetric(BaseChildMetric):
456 """Returns the ecliptic lon/lat and solar elong of the i-th discovery track of an SSobject.
457 """
458 def __init__(self, parentDiscoveryMetric, i=0, badval=None, **kwargs):
459 super().__init__(parentDiscoveryMetric, badval=badval, **kwargs)
460 self.i = i
461 self.snrLimit = parentDiscoveryMetric.snrLimit
462 self.metricDtype = 'object'
464 def run(self, ssoObs, orb, Hval, metricValues):
465 if self.i>=len(metricValues['start']):
466 return self.badval
467 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
468 if len(vis) == 0:
469 return self.badval
470 visSort = np.argsort(ssoObs[self.mjdCol][vis])
471 ecLon = ssoObs['ecLon'][vis][visSort]
472 ecLat = ssoObs['ecLat'][vis][visSort]
473 solarElong = ssoObs['solarElong'][vis][visSort]
474 startIdx = metricValues['start'][self.i]
475 return (ecLon[startIdx], ecLat[startIdx], solarElong[startIdx])
478class Discovery_VelocityMetric(BaseChildMetric):
479 """Returns the sky velocity of the i-th discovery track of an SSobject.
480 """
481 def __init__(self, parentDiscoveryMetric, i=0, badval=-999, **kwargs):
482 super().__init__(parentDiscoveryMetric, badval=badval, **kwargs)
483 self.i = i
484 self.snrLimit = parentDiscoveryMetric.snrLimit
486 def run(self, ssoObs, orb, Hval, metricValues):
487 if self.i>=len(metricValues['start']):
488 return self.badval
489 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
490 if len(vis) == 0:
491 return self.badval
492 visSort = np.argsort(ssoObs[self.mjdCol][vis])
493 velocity = ssoObs['velocity'][vis][visSort]
494 startIdx = metricValues['start'][self.i]
495 return velocity[startIdx]
497class ActivityOverTimeMetric(BaseMoMetric):
498 """Count fraction of survey we could identify activity for an SSobject.
500 Counts the time periods where we would have a chance to detect activity on
501 a moving object.
502 Splits observations into time periods set by 'window', then looks for observations within each window,
503 and reports what fraction of the total windows receive 'nObs' visits.
504 """
505 def __init__(self, window, snrLimit=5, surveyYears=10.0, metricName=None, **kwargs):
506 if metricName is None:
507 metricName = 'Chance of detecting activity lasting %.0f days' %(window)
508 super().__init__(metricName=metricName, **kwargs)
509 self.snrLimit = snrLimit
510 self.window = window
511 self.surveyYears = surveyYears
512 self.windowBins = np.arange(0, self.surveyYears*365 + self.window/2.0, self.window)
513 self.nWindows = len(self.windowBins)
514 self.units = '%.1f Day Windows' %(self.window)
516 def run(self, ssoObs, orb, Hval):
517 # For cometary activity, expect activity at the same point in its orbit at the same time, mostly
518 # For collisions, expect activity at random times
519 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
520 if len(vis) == 0:
521 return self.badval
522 n, b = np.histogram(ssoObs[vis][self.nightCol], bins=self.windowBins)
523 activityWindows = np.where(n>0)[0].size
524 return activityWindows / float(self.nWindows)
527class ActivityOverPeriodMetric(BaseMoMetric):
528 """Count fraction of object period we could identify activity for an SSobject.
530 Count the fraction of the orbit (when split into nBins) that receive
531 observations, in order to have a chance to detect activity.
532 """
533 def __init__(self, binsize, snrLimit=5,
534 qCol='q', eCol='e', aCol='a', tPeriCol='tPeri', anomalyCol='meanAnomaly',
535 metricName=None, **kwargs):
536 """
537 @ binsize : size of orbit slice, in degrees.
538 """
539 if metricName is None:
540 metricName = 'Chance of detecting activity covering %.1f of the orbit' %(binsize)
541 super().__init__(metricName=metricName, **kwargs)
542 self.qCol = qCol
543 self.eCol = eCol
544 self.aCol = aCol
545 self.tPeriCol = tPeriCol
546 self.anomalyCol = anomalyCol
547 self.snrLimit = snrLimit
548 self.binsize = np.radians(binsize)
549 self.anomalyBins = np.arange(0, 2 * np.pi, self.binsize)
550 self.anomalyBins = np.concatenate([self.anomalyBins, np.array([2 * np.pi])])
551 self.nBins = len(self.anomalyBins) - 1
552 self.units = '%.1f deg' %(np.degrees(self.binsize))
554 def run(self, ssoObs, orb, Hval):
555 # For cometary activity, expect activity at the same point in its orbit at the same time, mostly
556 # For collisions, expect activity at random times
557 if self.aCol in orb.keys():
558 a = (orb[self.aCol])
559 elif self.qCol in orb.keys():
560 a = orb[self.qCol] / (1 - orb[self.eCol])
561 else:
562 return self.badval
564 period = np.power(a, 3./2.) * 365.25 # days
566 if self.anomalyCol in orb.keys():
567 curranomaly = np.radians(orb[self.anomalyCol] + \
568 (ssoObs[self.mjdCol] - orb['epoch'])/ period * 360.0) % (2 * np.pi)
569 elif self.tPeriCol in orb.keys():
570 curranomaly = ((ssoObs[self.mjdCol] - orb[self.tPeriCol]) / period) % (2 * np.pi)
571 else:
572 return self.badval
574 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
575 if len(vis) == 0:
576 return self.badval
577 n, b = np.histogram(curranomaly[vis], bins=self.anomalyBins)
578 activityWindows = np.where(n>0)[0].size
579 return activityWindows / float(self.nBins)
582class MagicDiscoveryMetric(BaseMoMetric):
583 """Count the number of discovery opportunities with very good software for an SSobject.
584 """
585 def __init__(self, nObs=6, tWindow=60, snrLimit=None, **kwargs):
586 """
587 @ nObs = the total number of observations required for 'discovery'
588 @ tWindow = the timespan of the discovery window.
589 @ snrLimit .. if snrLimit is None then uses 'completeness' calculation,
590 .. if snrLimit is not None, then uses this value as a cutoff.
591 """
592 super().__init__(**kwargs)
593 self.snrLimit = snrLimit
594 self.nObs = nObs
595 self.tWindow = tWindow
596 self.badval = 0
598 def run(self, ssoObs, orb, Hval):
599 """SsoObs = Dataframe, orb=Dataframe, Hval=single number."""
600 # Calculate visibility for this orbit at this H.
601 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
602 if len(vis) == 0:
603 return self.badval
604 tNights = np.sort(ssoObs[self.nightCol][vis])
605 deltaNights = np.roll(tNights, 1-self.nObs) - tNights
606 nDisc = np.where((deltaNights < self.tWindow) & (deltaNights >= 0))[0].size
607 return nDisc
610class HighVelocityMetric(BaseMoMetric):
611 """Count number of times an SSobject appears trailed.
613 Count the number of times an asteroid is observed with a velocity high enough to make it appear
614 trailed by a factor of (psfFactor)*PSF - i.e. velocity >= psfFactor * seeing / visitExpTime.
615 Simply counts the total number of observations with high velocity.
616 """
617 def __init__(self, psfFactor=2.0, snrLimit=None, velocityCol='velocity', **kwargs):
618 """
619 @ psfFactor = factor to multiply seeing/visitExpTime by
620 (velocity(deg/day) >= 24*psfFactor*seeing(")/visitExptime(s))
621 """
622 super().__init__(**kwargs)
623 self.velocityCol = velocityCol
624 self.snrLimit = snrLimit
625 self.psfFactor = psfFactor
626 self.badval = 0
628 def run(self, ssoObs, orb, Hval):
629 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
630 if len(vis) == 0:
631 return self.badval
632 highVelocityObs = np.where(ssoObs[self.velocityCol][vis] >=
633 (24.* self.psfFactor * ssoObs[self.seeingCol][vis] /
634 ssoObs[self.expTimeCol][vis]))[0]
635 return highVelocityObs.size
638class HighVelocityNightsMetric(BaseMoMetric):
639 """Count the number of discovery opportunities (via trailing) for an SSobject.
641 Determine the first time an asteroid is observed is observed with a velocity high enough to make
642 it appear trailed by a factor of psfFactor*PSF with nObsPerNight observations within a given night.
644 Parameters
645 ----------
646 psfFactor: float, opt
647 Object velocity (deg/day) must be >= 24 * psfFactor * seeingGeom (") / visitExpTime (s).
648 Default is 2 (i.e. object trailed over 2 psf's).
649 nObsPerNight: int, opt
650 Number of observations per night required. Default 2.
651 snrLimit: float or None
652 If snrLimit is set as a float, then requires object to be above snrLimit SNR in the image.
653 If snrLimit is None, this uses the probabilistic 'visibility' calculated by the vis stacker,
654 which means SNR ~ 5. Default is None.
655 velocityCol: str, opt
656 Name of the velocity column in the obs file. Default 'velocity'. (note this is deg/day).
658 Returns
659 -------
660 float
661 The time of the first detection where the conditions are satisifed.
662 """
663 def __init__(self, psfFactor=2.0, nObsPerNight=2, snrLimit=None, velocityCol='velocity', **kwargs):
664 super().__init__(**kwargs)
665 self.velocityCol = velocityCol
666 self.snrLimit = snrLimit
667 self.psfFactor = psfFactor
668 self.nObsPerNight = nObsPerNight
670 def run(self, ssoObs, orb, Hval):
671 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
672 if len(vis) == 0:
673 return self.badval
674 highVelocityObs = np.where(ssoObs[self.velocityCol][vis] >=
675 (24. * self.psfFactor * ssoObs[self.seeingCol][vis]
676 / ssoObs[self.expTimeCol][vis]))[0]
677 if len(highVelocityObs) == 0:
678 return 0
679 nights = ssoObs[self.nightCol][vis][highVelocityObs]
680 n = np.unique(nights)
681 nIdx = np.searchsorted(nights, n)
682 # Count the number of observations per night (except last night)
683 obsPerNight = (nIdx - np.roll(nIdx, 1))[1:]
684 # Add the number of observations on the last night.
685 obsLastNight = np.array([len(nights) - nIdx[-1]])
686 obsPerNight = np.concatenate((obsPerNight, obsLastNight))
687 # Find the nights with at least nObsPerNight visits
688 # (this is already looking at only high velocity observations).
689 nWithXObs = n[np.where(obsPerNight >= self.nObsPerNight)]
690 if len(nWithXObs) > 0:
691 found = ssoObs[np.where(ssoObs[self.nightCol] == nWithXObs[0])][self.mjdCol][0]
692 else:
693 found = self.badval
694 return found
697class LightcurveInversion_AsteroidMetric(BaseMoMetric):
698 """
699 This metric is generally applicable to NEOs and MBAs - inner solar system objects.
701 Determine if the cumulative sum of observations of a target are enough to enable lightcurve
702 inversion for shape modeling. For this to be true, multiple conditions need to be
703 satisfied:
705 1) The SNR-weighted number of observations (each observation is weighted by its SNR, up to a max of 100)
706 must be larger than the threshhold weightDet (default 50)
707 2) Ecliptic longitudinal coverage needs to be at least 90 degrees, and the absolute deviation
708 needs to be at least 1/8th the longitudinal coverage.
709 3) The phase angle coverage needs to span at least 5 degrees.
711 For evaluation of condition 2, the median ecliptic longitude is subtracted from all longitudes,
712 and the modulo 360 of those values is taken. This ensures that the wrap around 360 is handled
713 correctly.
715 For more information on the above conditions, please see
716 https://docs.google.com/document/d/1GAriM7trpTS08uanjUF7PyKALB2JBTjVT7Y6R30i0-8/edit?usp=sharing
717 Contributed by Steve Chesley, Wes Fraser, Josef Durech, and the inner solar system working group.
719 Parameters
720 ----------
721 weightDet: float, opt
722 The SNR-weighted number of detections required (per bandpass in any ONE of the filters in filterlist).
723 Default 50.
724 snrLimit: float or None, opt
725 If snrLimit is set as a float, then requires object to be above snrLimit SNR in the image.
726 If snrLimit is None, this uses the probabilistic 'visibility' calculated by the vis stacker,
727 which means SNR ~ 5. Default is None.
728 snrMax: float, opt
729 Maximum value toward the SNR-weighting to consider. Default 100.
730 filterlist: list of str, opt
731 The filters which the lightcurve inversion could be based on. Requirements must be met in one of
732 these filters.
734 Returns
735 -------
736 int
737 0 (could not perform lightcurve inversion) or 1 (could)
738 """
740 def __init__(self, weightDet=50, snrLimit=None, snrMax=100,
741 filterlist=('u', 'g', 'r', 'i', 'z', 'y'), **kwargs):
742 super().__init__(**kwargs)
743 self.snrLimit = snrLimit
744 self.snrMax = snrMax
745 self.weightDet = weightDet
746 self.filterlist = filterlist
748 def run(self, ssoObs, orb, Hval):
749 # Calculate the clipped SNR - ranges from snrLimit / SNR+vis to snrMax.
750 clipSnr = np.minimum(ssoObs[self.snrCol], self.snrMax)
751 if self.snrLimit is not None:
752 clipSnr = np.where(ssoObs[self.snrCol] <= self.snrLimit, 0, clipSnr)
753 else:
754 clipSnr = np.where(ssoObs[self.visCol] == 0, 0, clipSnr)
755 if len(np.where(clipSnr > 0)[0]) == 0:
756 return 0
757 # Check each filter in filterlist:
758 # stop as soon as find a filter that matches requirements.
759 inversion_possible = 0
760 for f in self.filterlist:
761 # Is the SNR-weight sum of observations in this filter high enough?
762 match = np.where(ssoObs[self.filterCol] == f)
763 snrSum = np.sum(clipSnr[match]) / self.snrMax
764 if snrSum < self.weightDet:
765 # Do not have enough SNR-weighted observations, so skip on to the next filter.
766 continue
767 # Is the ecliptic longitude coverage for the visible observations sufficient?
768 # Is the phase coverage sufficient?
769 vis = np.where(clipSnr[match] > 0)
770 ecL = ssoObs['ecLon'][match][vis]
771 phaseAngle = ssoObs['phase'][match][vis]
772 # Calculate the absolute deviation and range of ecliptic longitude.
773 ecL_centred = (ecL - np.median(ecL)) % 360.0
774 aDev = np.sum(np.abs(ecL_centred - np.mean(ecL_centred))) / len(ecL_centred)
775 dL = np.max(ecL) - np.min(ecL)
776 # Calculate the range of the phase angle
777 dp = np.max(phaseAngle) - np.min(phaseAngle)
778 # Metric requirement is that dL >= 90 deg, absolute deviation is greater than dL/8
779 # and then that the phase coverage is more than 5 degrees.
780 # Stop as soon as find a case where this is true.
781 if dL >= 90.0 and aDev >= dL / 8 and dp >= 5:
782 inversion_possible += 1
783 break
784 return inversion_possible
787class Color_AsteroidMetric(BaseMoMetric):
788 """
789 This metric is appropriate for MBAs and NEOs, and other inner solar system objects.
791 The metric evaluates if the SNR-weighted number of observations are enough to
792 determine an approximate lightcurve and phase function -- and from this,
793 then a color for the asteroid can be determined.
794 The assumption is that you must fit the lightcurve/phase function in each bandpass,
795 and could do this well-enough if you have at least weightDet SNR-weighted observations
796 in the bandpass.
797 e.g. to find a g-r color, you must have 10 (SNR-weighted) obs in g and 10 in r.
799 For more details, see
800 https://docs.google.com/document/d/1GAriM7trpTS08uanjUF7PyKALB2JBTjVT7Y6R30i0-8/edit?usp=sharing
801 Contributed by Wes Fraser, Steven Chesley & the inner solar system working group.
803 Parameters
804 ----------
805 weightDet: float, opt
806 The SNR-weighted number of detections required (per bandpass in any ONE of the filters in filterlist).
807 Default 10.
808 snrLimit: float or None, opt
809 If snrLimit is set as a float, then requires object to be above snrLimit SNR in the image.
810 If snrLimit is None, this uses the probabilistic 'visibility' calculated by the vis stacker,
811 which means SNR ~ 5. Default is None.
812 snrMax: float, opt
813 Maximum value toward the SNR-weighting to consider. Default 20.
815 Returns
816 -------
817 int
818 An integer 'flag' that indicates whether the mean magnitude (and thus a color) was determined in:
819 0 = no bands
820 1 = g and (r or i) and (z or y). i.e. obtain colors g-r or g-i PLUS g-z or g-y
821 2 = Any 4 different filters (from grizy). i.e. colors = g-r, r-i, i-z, OR r-i, i-z, z-y..
822 3 = All 5 from grizy. i.e. colors g-r, r-i, i-z, z-y.
823 4 = All 6 filters (ugrizy) -- best possible! add u-g.
824 """
826 def __init__(self, weightDet=10, snrMax=20, snrLimit=None, **kwargs):
827 super().__init__(**kwargs)
828 self.weightDet = weightDet
829 self.snrLimit = snrLimit
830 self.snrMax = snrMax
831 self.filterlist = ('u', 'g', 'r', 'i', 'z', 'y')
833 def run(self, ssoObs, orb, Hval):
834 clipSnr = np.minimum(ssoObs[self.snrCol], self.snrMax)
835 if self.snrLimit is not None:
836 clipSnr = np.where(ssoObs[self.snrCol] <= self.snrLimit, 0, clipSnr)
837 else:
838 clipSnr = np.where(ssoObs[self.visCol] == 0, 0, clipSnr)
839 if len(np.where(clipSnr > 0)[0]) == 0:
840 return self.badval
842 # Evaluate SNR-weighted number of observations in each filter.
843 filterWeight = {}
844 for f in self.filterlist:
845 match = np.where(ssoObs[self.filterCol] == f)
846 snrweight = np.sum(clipSnr[match]) / self.snrMax
847 # If the snrweight exceeds the weightDet, add it to the dictionary.
848 if snrweight > self.weightDet:
849 filterWeight[f] = snrweight
851 # Now assign a flag:
852 # 0 = no bands
853 # 1 = g and (r or i) and (z or y). i.e. obtain colors g-r or g-i PLUS g-z or g-y
854 # 2 = Any 4 different filters (from grizy). i.e. colors = g-r, r-i, i-z, OR r-i, i-z, z-y..
855 # 3 = All 5 from grizy. i.e. colors g-r, r-i, i-z, z-y.
856 # 4 = All 6 filters (ugrizy) -- best possible! add u-g.
857 all_six = set(self.filterlist)
858 good_five = set(['g', 'r', 'i', 'z', 'y'])
860 if len(filterWeight) == 0: # this lets us stop evaluating here if possible.
861 flag = 0
862 elif all_six.intersection(filterWeight) == all_six:
863 flag = 4
864 elif good_five.intersection(filterWeight) == good_five:
865 flag = 3
866 elif len(good_five.intersection(filterWeight)) == 4:
867 flag = 2
868 elif 'g' in filterWeight:
869 # Have 'g' - do we have (r or i) and (z or y)
870 if ('r' in filterWeight or 'i' in filterWeight) and ('z' in filterWeight or 'y' in filterWeight):
871 flag = 1
872 else:
873 flag = 0
874 else:
875 flag = 0
877 return flag
880class LightcurveColor_OuterMetric(BaseMoMetric):
881 """
882 This metric is appropriate for outer solar system objects, such as TNOs and SDOs.
884 This metric evaluates whether the number of observations is sufficient to fit a lightcurve
885 in a primary and secondary bandpass. The primary bandpass requires more observations than
886 the secondary. Essentially, it's a complete lightcurve in one or both bandpasses, with at
887 least a semi-complete lightcurve in the secondary band.
889 The lightcurve/color can be calculated with any two of the bandpasses in filterlist.
890 Contributed by Wes Fraser.
892 Parameters
893 ----------
894 snrLimit: float or None, opt
895 If snrLimit is set as a float, then requires object to be above snrLimit SNR in the image.
896 If snrLimit is None, this uses the probabilistic 'visibility' calculated by the vis stacker,
897 which means SNR ~ 5. Default is None.
898 numReq: int, opt
899 Number of observations required for a lightcurve fitting. Default 30.
900 numSecFilt: int, opt
901 Number of observations required in a secondary band for color only. Default 20.
902 filterlist: list of str, opt
903 Filters that the primary/secondary measurements can be in.
905 Returns
906 -------
907 int
908 A flag that indicates whether a color/lightcurve was generated in:
909 0 = no lightcurve (although may have had 'color' in one or more band)
910 1 = a lightcurve in a single filter (but no additional color information)
911 2+ = lightcurves in more than one filter (or lightcurve + color)
912 e.g. lightcurve in 2 bands, with additional color information in another = 3.
913 """
915 def __init__(self, snrLimit=None, numReq=30, numSecFilt=20,
916 filterlist=('u', 'g', 'r', 'i', 'z', 'y'), **kwargs):
917 super().__init__(**kwargs)
918 self.snrLimit = snrLimit
919 self.numReq = numReq
920 self.numSecFilt = numSecFilt
921 self.filterlist = filterlist
923 def run(self, ssoObs, orb, Hval):
924 vis = _setVis(ssoObs, self.snrLimit, self.snrCol, self.visCol)
925 if len(vis) == 0:
926 return 0
928 lightcurves = set()
929 colors = set()
930 for f in self.filterlist:
931 nmatch = np.where(ssoObs[vis][self.filterCol] == f)[0]
932 if len(nmatch) >= self.numReq:
933 lightcurves.add(f)
934 if len(nmatch) >= self.numSecFilt:
935 colors.add(f)
937 # Set the flags - first the number of filters with lightcurves.
938 flag = len(lightcurves)
939 # And check if there were extra filters which had enough for a color
940 # but not enough for a full lightcurve.
941 if len(colors.difference(lightcurves)) > 0:
942 # If there was no lightcurve available to match against:
943 if len(lightcurves) == 0:
944 flag = 0
945 else:
946 # We had a lightcurve and now can add a color.
947 flag += 1
948 return flag
951class InstantaneousColorMetric(BaseMoMetric):
952 """Identify SSobjects which could have observations suitable to determine colors.
954 Generally, this is not the mode LSST would work in - the lightcurves of the objects
955 mean that the time interval would have to be quite short.
957 This is roughly defined as objects which have more than nPairs pairs of observations
958 with SNR greater than snrLimit, in bands bandOne and bandTwo, within nHours.
960 Parameters
961 ----------
962 nPairs: int, opt
963 The number of pairs of observations (in each band) that must be within nHours
964 Default 1
965 snrLimit: float, opt
966 The SNR limit for the observations. Default 10.
967 nHours: float, opt
968 The time interval between observations in the two bandpasses (hours). Default 0.5 hours.
969 bOne: str, opt
970 The first bandpass for the color. Default 'g'.
971 bTwo: str, opt
972 The second bandpass for the color. Default 'r'.
974 Returns
975 -------
976 int
977 0 (no color possible under these constraints) or 1 (color possible).
978 """
979 def __init__(self, nPairs=1, snrLimit=10, nHours=0.5, bOne='g', bTwo='r', **kwargs):
980 super().__init__(**kwargs)
981 self.nPairs = nPairs
982 self.snrLimit = snrLimit
983 self.nHours = nHours
984 self.bOne = bOne
985 self.bTwo = bTwo
986 self.badval = -666
988 def run(self, ssoObs, orb, Hval):
989 vis = np.where(ssoObs[self.snrCol] >= self.snrLimit)[0]
990 if len(vis) < self.nPairs * 2:
991 return 0
992 bOneObs = np.where(ssoObs[self.filterCol][vis] == self.bOne)[0]
993 bTwoObs = np.where(ssoObs[self.filterCol][vis] == self.bTwo)[0]
994 timesbOne = ssoObs[self.mjdCol][vis][bOneObs]
995 timesbTwo = ssoObs[self.mjdCol][vis][bTwoObs]
996 if len(timesbOne) == 0 or len(timesbTwo) == 0:
997 return 0
998 dTime = self.nHours / 24.0
999 # Calculate the time between the closest pairs of observations.
1000 inOrder = np.searchsorted(timesbOne, timesbTwo, 'right')
1001 inOrder = np.where(inOrder - 1 > 0, inOrder - 1, 0)
1002 dtPairs = timesbTwo - timesbOne[inOrder]
1003 if len(np.where(dtPairs < dTime)[0]) >= self.nPairs:
1004 found = 1
1005 else:
1006 found = 0
1007 return found
1010class PeakVMagMetric(BaseMoMetric):
1011 """Pull out the peak V magnitude of all observations of the SSobject.
1012 """
1013 def __init__(self, **kwargs):
1014 super().__init__(**kwargs)
1016 def run(self, ssoObs, orb, Hval):
1017 peakVmag = np.min(ssoObs[self.appMagVCol])
1018 return peakVmag
1021class KnownObjectsMetric(BaseMoMetric):
1022 """Identify SSobjects which could be classified as 'previously known' based on their peak V magnitude.
1023 This is most appropriate for NEO surveys, where most of the sky has been covered so the exact location
1024 (beyond being in the visible sky) is not as important.
1026 Default parameters tuned to match NEO survey capabilities.
1027 Returns the time at which each first reached that threshold V magnitude.
1028 The default values are calibrated using the NEOs larger than 140m discovered in the last 20 years
1029 and assuming a 30% completeness in 2017.
1031 Parameters
1032 -----------
1033 elongThresh : float, opt
1034 The cutoff in solar elongation to consider an object 'visible'. Default 100 deg.
1035 vMagThresh1 : float, opt
1036 The magnitude threshold for previously known objects. Default 20.0.
1037 eff1 : float, opt
1038 The likelihood of actually achieving each individual input observation.
1039 If the input observations include one observation per day, an 'eff' value of 0.3 would
1040 mean that (on average) only one third of these observations would be achieved.
1041 This is similar to the level for LSST, which can cover the visible sky every 3-4 days.
1042 Default 0.1
1043 tSwitch1 : float, opt
1044 The (MJD) time to switch between vMagThresh1 + eff1 to vMagThresh2 + eff2, e.g.
1045 the end of the first period.
1046 Default 53371 (2005).
1047 vMagThresh2 : float, opt
1048 The magnitude threshhold for previously known objects. Default 22.0.
1049 This is based on assuming PS and other surveys will be efficient down to V=22.
1050 eff2 : float, opt
1051 The efficiency of observations during the second period of time. Default 0.1
1052 tSwitch2 : float, opt
1053 The (MJD) time to switch between vMagThresh2 + eff2 to vMagThresh3 + eff3.
1054 Default 57023 (2015).
1055 vMagThresh3 : float, opt
1056 The magnitude threshold during the third period. Default 22.0, based on PS1 + Catalina.
1057 eff3 : float, opt
1058 The efficiency of observations during the third period. Default 0.1
1059 tSwitch3 : float, opt
1060 The (MJD) time to switch between vMagThresh3 + eff3 to vMagThresh4 + eff4.
1061 Default 59580 (2022).
1062 vMagThresh4 : float, opt
1063 The magnitude threshhold during the fourth (last) period. Default 22.0, based on PS1 + Catalina.
1064 eff4 : float, opt
1065 The efficiency of observations during the fourth (last) period. Default 0.2
1066 """
1067 def __init__(self, elongThresh=100., vMagThresh1=20.0, eff1=0.1, tSwitch1=53371,
1068 vMagThresh2=21.5, eff2=0.1, tSwitch2=57023,
1069 vMagThresh3=22.0, eff3=0.1, tSwitch3=59580,
1070 vMagThresh4=22.0, eff4=0.2,
1071 elongCol='Elongation', mjdCol='MJD(UTC)', **kwargs):
1072 super().__init__(**kwargs)
1073 self.elongThresh = elongThresh
1074 self.elongCol = elongCol
1075 self.vMagThresh1 = vMagThresh1
1076 self.eff1 = eff1
1077 self.tSwitch1 = tSwitch1
1078 self.vMagThresh2 = vMagThresh2
1079 self.eff2 = eff2
1080 self.tSwitch2 = tSwitch2
1081 self.vMagThresh3 = vMagThresh3
1082 self.eff3 = eff3
1083 self.tSwitch3 = tSwitch3
1084 self.vMagThresh4 = vMagThresh4
1085 self.eff4 = eff4
1086 self.mjdCol = mjdCol
1087 self.badval = int(tSwitch3) + 365*1000
1089 def _pickObs(self, potentialObsTimes, eff):
1090 # From a set of potential observations, apply an efficiency
1091 # And return the minimum time (if any)
1092 randPick = np.random.rand(len(potentialObsTimes))
1093 picked = np.where(randPick <= eff)[0]
1094 if len(picked) > 0:
1095 discTime = potentialObsTimes[picked].min()
1096 else:
1097 discTime = None
1098 return discTime
1100 def run(self, ssoObs, orb, Hval):
1101 visible = np.where(ssoObs[self.elongCol] >= self.elongThresh, 1, 0)
1102 discoveryTime = None
1103 # Look for discovery in any of the three periods.
1104 # First period.
1105 obs1 = np.where((ssoObs[self.mjdCol] < self.tSwitch1) & visible)[0]
1106 overPeak = np.where(ssoObs[self.appMagVCol][obs1] <= self.vMagThresh1)[0]
1107 if len(overPeak) > 0:
1108 discoveryTime = self._pickObs(ssoObs[self.mjdCol][obs1][overPeak], self.eff1)
1109 # Second period.
1110 if discoveryTime is None:
1111 obs2 = np.where((ssoObs[self.mjdCol] >= self.tSwitch1) &
1112 (ssoObs[self.mjdCol] < self.tSwitch2) & visible)[0]
1113 overPeak = np.where(ssoObs[self.appMagVCol][obs2] <= self.vMagThresh2)[0]
1114 if len(overPeak) > 0:
1115 discoveryTime = self._pickObs(ssoObs[self.mjdCol][obs2][overPeak], self.eff2)
1116 # Third period.
1117 if discoveryTime is None:
1118 obs3 = np.where((ssoObs[self.mjdCol] >= self.tSwitch2) &
1119 (ssoObs[self.mjdCol] < self.tSwitch3) & visible)[0]
1120 overPeak = np.where(ssoObs[self.appMagVCol][obs3] <= self.vMagThresh3)[0]
1121 if len(overPeak) > 0:
1122 discoveryTime = self._pickObs(ssoObs[self.mjdCol][obs3][overPeak], self.eff3)
1123 # Fourth period.
1124 if discoveryTime is None:
1125 obs4 = np.where((ssoObs[self.mjdCol] >= self.tSwitch3) & visible)[0]
1126 overPeak = np.where(ssoObs[self.appMagVCol][obs4] <= self.vMagThresh4)[0]
1127 if len(overPeak) > 0:
1128 discoveryTime = self._pickObs(ssoObs[self.mjdCol][obs4][overPeak], self.eff4)
1129 if discoveryTime is None:
1130 discoveryTime = self.badval
1131 return discoveryTime