Hide keyboard shortcuts

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 

3 

4from .baseMetric import BaseMetric 

5 

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'] 

18 

19 

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 

26 

27 

28class BaseMoMetric(BaseMetric): 

29 """Base class for the moving object metrics. 

30 Intended to be used with the Moving Object Slicer.""" 

31 

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) 

66 

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 

80 

81 def run(self, ssoObs, orb, Hval): 

82 """Calculate the metric value. 

83 

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. 

92 

93 Returns 

94 ------- 

95 float or np.ndarray or dict 

96 """ 

97 raise NotImplementedError 

98 

99 

100class BaseChildMetric(BaseMoMetric): 

101 """Base class for child metrics. 

102 

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' 

118 

119 def run(self, ssoObs, orb, Hval, metricValues): 

120 """Calculate the child metric value. 

121 

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. 

132 

133 Returns 

134 ------- 

135 float 

136 """ 

137 raise NotImplementedError 

138 

139 

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 

151 

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 

159 

160 

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 

169 

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 

179 

180 

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 

191 

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 

198 

199 

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 

206 

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 

213 

214 

215class DiscoveryMetric(BaseMoMetric): 

216 """Identify the discovery opportunities for an SSobject. 

217 

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 

266 

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]} 

327 

328 

329class Discovery_N_ChancesMetric(BaseChildMetric): 

330 """Calculate total number of discovery opportunities for an SSobject. 

331 

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) 

346 

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) 

368 

369 

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 

377 

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 

385 

386 

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 

395 

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 

409 

410 

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 

419 

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 

431 

432 

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' 

441 

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]) 

453 

454 

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' 

463 

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]) 

476 

477 

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 

485 

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] 

496 

497class ActivityOverTimeMetric(BaseMoMetric): 

498 """Count fraction of survey we could identify activity for an SSobject. 

499 

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) 

515 

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) 

525 

526 

527class ActivityOverPeriodMetric(BaseMoMetric): 

528 """Count fraction of object period we could identify activity for an SSobject. 

529 

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)) 

553 

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 

563 

564 period = np.power(a, 3./2.) * 365.25 # days 

565 

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 

573 

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) 

580 

581 

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 

597 

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 

608 

609 

610class HighVelocityMetric(BaseMoMetric): 

611 """Count number of times an SSobject appears trailed. 

612 

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 

627 

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 

636 

637 

638class HighVelocityNightsMetric(BaseMoMetric): 

639 """Count the number of discovery opportunities (via trailing) for an SSobject. 

640 

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. 

643 

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). 

657 

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 

669 

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 

695 

696 

697class LightcurveInversion_AsteroidMetric(BaseMoMetric): 

698 """ 

699 This metric is generally applicable to NEOs and MBAs - inner solar system objects. 

700 

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: 

704 

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. 

710 

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. 

714 

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. 

718 

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. 

733 

734 Returns 

735 ------- 

736 int 

737 0 (could not perform lightcurve inversion) or 1 (could) 

738 """ 

739 

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 

747 

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 

785 

786 

787class Color_AsteroidMetric(BaseMoMetric): 

788 """ 

789 This metric is appropriate for MBAs and NEOs, and other inner solar system objects. 

790 

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. 

798 

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. 

802 

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. 

814 

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 """ 

825 

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') 

832 

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 

841 

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 

850 

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']) 

859 

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 

876 

877 return flag 

878 

879 

880class LightcurveColor_OuterMetric(BaseMoMetric): 

881 """ 

882 This metric is appropriate for outer solar system objects, such as TNOs and SDOs. 

883 

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. 

888 

889 The lightcurve/color can be calculated with any two of the bandpasses in filterlist. 

890 Contributed by Wes Fraser. 

891 

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. 

904 

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 """ 

914 

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 

922 

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 

927 

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) 

936 

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 

949 

950 

951class InstantaneousColorMetric(BaseMoMetric): 

952 """Identify SSobjects which could have observations suitable to determine colors. 

953 

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. 

956 

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. 

959 

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'. 

973 

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 

987 

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 

1008 

1009 

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) 

1015 

1016 def run(self, ssoObs, orb, Hval): 

1017 peakVmag = np.min(ssoObs[self.appMagVCol]) 

1018 return peakVmag 

1019 

1020 

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. 

1025 

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. 

1030 

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 

1088 

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 

1099 

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