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

1import numpy as np 

2from lsst.sims.featureScheduler import features 

3from lsst.sims.featureScheduler import utils 

4from lsst.sims.featureScheduler.utils import int_rounded 

5import healpy as hp 

6from lsst.sims.skybrightness_pre import M5percentiles 

7import matplotlib.pylab as plt 

8import warnings 

9from lsst.sims.utils import _hpid2RaDec 

10from astropy.coordinates import SkyCoord 

11from astropy import units as u 

12 

13 

14__all__ = ['Base_basis_function', 'Constant_basis_function', 'Target_map_basis_function', 

15 'Avoid_long_gaps_basis_function', 

16 'Avoid_Fast_Revists', 'Visit_repeat_basis_function', 'M5_diff_basis_function', 

17 'Strict_filter_basis_function', 'Goal_Strict_filter_basis_function', 

18 'Filter_change_basis_function', 'Slewtime_basis_function', 

19 'Aggressive_Slewtime_basis_function', 'Skybrightness_limit_basis_function', 

20 'CableWrap_unwrap_basis_function', 'Cadence_enhance_basis_function', 

21 'Cadence_enhance_trapezoid_basis_function', 'Azimuth_basis_function', 

22 'Az_modulo_basis_function', 'Dec_modulo_basis_function', 'Map_modulo_basis_function', 

23 'Template_generate_basis_function', 

24 'Footprint_nvis_basis_function', 'Third_observation_basis_function', 'Season_coverage_basis_function', 

25 'N_obs_per_year_basis_function', 'Cadence_in_season_basis_function', 'Near_sun_twilight_basis_function', 

26 'N_obs_high_am_basis_function', 'Good_seeing_basis_function', 'Observed_twice_basis_function', 

27 'Ecliptic_basis_function'] 

28 

29 

30class Base_basis_function(object): 

31 """Class that takes features and computes a reward function when called. 

32 """ 

33 

34 def __init__(self, nside=None, filtername=None, **kwargs): 

35 

36 # Set if basis function needs to be recalculated if there is a new observation 

37 self.update_on_newobs = True 

38 # Set if basis function needs to be recalculated if conditions change 

39 self.update_on_mjd = True 

40 # Dict to hold all the features we want to track 

41 self.survey_features = {} 

42 # Keep track of the last time the basis function was called. If mjd doesn't change, use cached value 

43 self.mjd_last = None 

44 self.value = 0 

45 # list the attributes to compare to check if basis functions are equal. 

46 self.attrs_to_compare = [] 

47 # Do we need to recalculate the basis function 

48 self.recalc = True 

49 # Basis functions don't technically all need an nside, but so many do might as well set it here 

50 if nside is None: 

51 self.nside = utils.set_default_nside() 

52 else: 

53 self.nside = nside 

54 

55 self.filtername = filtername 

56 

57 def add_observation(self, observation, indx=None): 

58 """ 

59 Parameters 

60 ---------- 

61 observation : np.array 

62 An array with information about the input observation 

63 indx : np.array 

64 The indices of the healpix map that the observation overlaps with 

65 """ 

66 for feature in self.survey_features: 

67 self.survey_features[feature].add_observation(observation, indx=indx) 

68 if self.update_on_newobs: 

69 self.recalc = True 

70 

71 def check_feasibility(self, conditions): 

72 """If there is logic to decide if something is feasible (e.g., only if moon is down), 

73 it can be calculated here. Helps prevent full __call__ from being called more than needed. 

74 """ 

75 return True 

76 

77 def _calc_value(self, conditions, **kwargs): 

78 self.value = 0 

79 # Update the last time we had an mjd 

80 self.mjd_last = conditions.mjd + 0 

81 self.recalc = False 

82 return self.value 

83 

84 def __eq__(self): 

85 # XXX--to work on if we need to make a registry of basis functions. 

86 pass 

87 

88 def __ne__(self): 

89 pass 

90 

91 def __call__(self, conditions, **kwargs): 

92 """ 

93 Parameters 

94 ---------- 

95 conditions : lsst.sims.featureScheduler.features.conditions object 

96 Object that has attributes for all the current conditions. 

97 

98 Return a reward healpix map or a reward scalar. 

99 """ 

100 # If we are not feasible, return -inf 

101 if not self.check_feasibility(conditions): 

102 return -np.inf 

103 if self.recalc: 

104 self.value = self._calc_value(conditions, **kwargs) 

105 if self.update_on_mjd: 

106 if conditions.mjd != self.mjd_last: 

107 self.value = self._calc_value(conditions, **kwargs) 

108 return self.value 

109 

110 

111class Constant_basis_function(Base_basis_function): 

112 """Just add a constant 

113 """ 

114 def __call__(self, conditions, **kwargs): 

115 return 1 

116 

117 

118class Avoid_long_gaps_basis_function(Base_basis_function): 

119 """ 

120 Boost the reward on parts of the survey that haven't been observed for a while 

121 """ 

122 

123 def __init__(self, filtername=None, nside=None, footprint=None, min_gap=4., max_gap=40., 

124 ha_limit=3.5): 

125 super(Avoid_long_gaps_basis_function, self).__init__(nside=nside, filtername=filtername) 

126 self.min_gap = min_gap 

127 self.max_gap = max_gap 

128 self.filtername = filtername 

129 self.footprint = footprint 

130 self.ha_limit = 2.*np.pi*ha_limit/24. # To radians 

131 self.survey_features = {} 

132 self.survey_features['last_observed'] = features.Last_observed(nside=nside, filtername=filtername) 

133 self.result = np.zeros(hp.nside2npix(self.nside)) 

134 

135 def _calc_value(self, conditions, indx=None): 

136 result = self.result.copy() 

137 

138 gap = conditions.mjd - self.survey_features['last_observed'].feature 

139 in_range = np.where((gap > self.min_gap) & (gap < self.max_gap) & (self.footprint > 0)) 

140 result[in_range] = 1 

141 

142 # mask out areas beyond the hour angle limit. 

143 out_ha = np.where((conditions.HA > self.ha_limit) & (conditions.HA < (2.*np.pi - self.ha_limit)))[0] 

144 result[out_ha] = 0 

145 

146 return result 

147 

148 

149class Target_map_basis_function(Base_basis_function): 

150 """Basis function that tracks number of observations and tries to match a specified spatial distribution 

151 

152 Parameters 

153 ---------- 

154 filtername: (string 'r') 

155 The name of the filter for this target map. 

156 nside: int (default_nside) 

157 The healpix resolution. 

158 target_map : numpy array (None) 

159 A healpix map showing the ratio of observations desired for all points on the sky 

160 norm_factor : float (0.00010519) 

161 for converting target map to number of observations. Should be the area of the camera 

162 divided by the area of a healpixel divided by the sum of all your goal maps. Default 

163 value assumes LSST foV has 1.75 degree radius and the standard goal maps. If using 

164 mulitple filters, see lsst.sims.featureScheduler.utils.calc_norm_factor for a utility 

165 that computes norm_factor. 

166 out_of_bounds_val : float (-10.) 

167 Reward value to give regions where there are no observations requested (unitless). 

168 """ 

169 def __init__(self, filtername='r', nside=None, target_map=None, 

170 norm_factor=None, 

171 out_of_bounds_val=-10.): 

172 

173 super(Target_map_basis_function, self).__init__(nside=nside, filtername=filtername) 

174 

175 if norm_factor is None: 

176 warnings.warn('No norm_factor set, use utils.calc_norm_factor if using multiple filters.') 

177 self.norm_factor = 0.00010519 

178 else: 

179 self.norm_factor = norm_factor 

180 

181 self.survey_features = {} 

182 # Map of the number of observations in filter 

183 self.survey_features['N_obs'] = features.N_observations(filtername=filtername, nside=self.nside) 

184 # Count of all the observations 

185 self.survey_features['N_obs_count_all'] = features.N_obs_count(filtername=None) 

186 if target_map is None: 

187 self.target_map = utils.generate_goal_map(filtername=filtername, nside=self.nside) 

188 else: 

189 self.target_map = target_map 

190 self.out_of_bounds_area = np.where(self.target_map == 0)[0] 

191 self.out_of_bounds_val = out_of_bounds_val 

192 self.result = np.zeros(hp.nside2npix(self.nside), dtype=float) 

193 self.all_indx = np.arange(self.result.size) 

194 

195 def _calc_value(self, conditions, indx=None): 

196 """ 

197 Parameters 

198 ---------- 

199 indx : list (None) 

200 Index values to compute, if None, full map is computed 

201 Returns 

202 ------- 

203 Healpix reward map 

204 """ 

205 result = self.result.copy() 

206 if indx is None: 

207 indx = self.all_indx 

208 

209 # Find out how many observations we want now at those points 

210 goal_N = self.target_map[indx] * self.survey_features['N_obs_count_all'].feature * self.norm_factor 

211 

212 result[indx] = goal_N - self.survey_features['N_obs'].feature[indx] 

213 result[self.out_of_bounds_area] = self.out_of_bounds_val 

214 

215 return result 

216 

217 

218def azRelPoint(azs, pointAz): 

219 azRelMoon = (azs - pointAz) % (2.0*np.pi) 

220 if isinstance(azs, np.ndarray): 

221 over = np.where(azRelMoon > np.pi) 

222 azRelMoon[over] = 2. * np.pi - azRelMoon[over] 

223 else: 

224 if azRelMoon > np.pi: 

225 azRelMoon = 2.0 * np.pi - azRelMoon 

226 return azRelMoon 

227 

228 

229class N_obs_high_am_basis_function(Base_basis_function): 

230 """Reward only reward/count observations at high airmass 

231 """ 

232 

233 def __init__(self, nside=None, filtername='r', footprint=None, n_obs=3, season=300., 

234 am_limits=[1.5, 2.2], out_of_bounds_val=np.nan): 

235 super(N_obs_high_am_basis_function, self).__init__(nside=nside, filtername=filtername) 

236 self.footprint = footprint 

237 self.out_footprint = np.where((footprint == 0) | np.isnan(footprint)) 

238 self.am_limits = am_limits 

239 self.season = season 

240 self.survey_features['last_n_mjds'] = features.Last_N_obs_times(nside=nside, filtername=filtername, 

241 n_obs=n_obs) 

242 

243 self.result = np.zeros(hp.nside2npix(self.nside), dtype=float) + out_of_bounds_val 

244 self.out_of_bounds_val = out_of_bounds_val 

245 

246 def add_observation(self, observation, indx=None): 

247 """ 

248 Parameters 

249 ---------- 

250 observation : np.array 

251 An array with information about the input observation 

252 indx : np.array 

253 The indices of the healpix map that the observation overlaps with 

254 """ 

255 

256 # Only count the observations if they are at the airmass limits 

257 if (observation['airmass'] > np.min(self.am_limits)) & (observation['airmass'] < np.max(self.am_limits)): 

258 for feature in self.survey_features: 

259 self.survey_features[feature].add_observation(observation, indx=indx) 

260 if self.update_on_newobs: 

261 self.recalc = True 

262 

263 def check_feasibility(self, conditions): 

264 """If there is logic to decide if something is feasible (e.g., only if moon is down), 

265 it can be calculated here. Helps prevent full __call__ from being called more than needed. 

266 """ 

267 result = True 

268 reward = self._calc_value(conditions) 

269 # If there are no non-NaN values, we're not feasible now 

270 if True not in np.isfinite(reward): 

271 result = False 

272 

273 return result 

274 

275 def _calc_value(self, conditions, indx=None): 

276 result = self.result.copy() 

277 behind_pix = np.where((int_rounded(conditions.mjd-self.survey_features['last_n_mjds'].feature[0]) > int_rounded(self.season)) & 

278 (int_rounded(conditions.airmass) > int_rounded(np.min(self.am_limits))) & 

279 (int_rounded(conditions.airmass) < int_rounded(np.max(self.am_limits)))) 

280 result[behind_pix] = 1 

281 result[self.out_footprint] = self.out_of_bounds_val 

282 

283 # Update the last time we had an mjd 

284 self.mjd_last = conditions.mjd + 0 

285 self.recalc = False 

286 self.value = result 

287 

288 return result 

289 

290 

291class Ecliptic_basis_function(Base_basis_function): 

292 """Mark the area around the ecliptic 

293 """ 

294 

295 def __init__(self, nside=None, distance_to_eclip=25.): 

296 super(Ecliptic_basis_function, self).__init__(nside=nside) 

297 self.distance_to_eclip = np.radians(distance_to_eclip) 

298 ra, dec = _hpid2RaDec(nside, np.arange(hp.nside2npix(self.nside))) 

299 self.result = np.zeros(ra.size) 

300 coord = SkyCoord(ra=ra*u.rad, dec=dec*u.rad) 

301 eclip_lat = coord.barycentrictrueecliptic.lat.radian 

302 good = np.where(np.abs(eclip_lat) < self.distance_to_eclip) 

303 self.result[good] += 1 

304 

305 def __call__(self, conditions, indx=None): 

306 return self.result 

307 

308 

309class N_obs_per_year_basis_function(Base_basis_function): 

310 """Reward areas that have not been observed N-times in the last year 

311 

312 Parameters 

313 ---------- 

314 filtername : str ('r') 

315 The filter to track 

316 footprint : np.array 

317 Should be a HEALpix map. Values of 0 or np.nan will be ignored. 

318 n_obs : int (3) 

319 The number of observations to demand 

320 season : float (300) 

321 The amount of time to allow pass before marking a region as "behind". Default 365.25 (days). 

322 season_start_hour : float (-2) 

323 When to start the season relative to RA 180 degrees away from the sun (hours) 

324 season_end_hour : float (2) 

325 When to consider a season ending, the RA relative to the sun + 180 degrees. (hours) 

326 """ 

327 def __init__(self, filtername='r', nside=None, footprint=None, n_obs=3, season=300, 

328 season_start_hour=-4., season_end_hour=2.): 

329 super(N_obs_per_year_basis_function, self).__init__(nside=nside, filtername=filtername) 

330 self.footprint = footprint 

331 self.n_obs = n_obs 

332 self.season = season 

333 self.season_start_hour = (season_start_hour)*np.pi/12. # To radians 

334 self.season_end_hour = season_end_hour*np.pi/12. # To radians 

335 

336 self.survey_features['last_n_mjds'] = features.Last_N_obs_times(nside=nside, filtername=filtername, 

337 n_obs=n_obs) 

338 self.result = np.zeros(hp.nside2npix(self.nside), dtype=float) 

339 self.out_footprint = np.where((footprint == 0) | np.isnan(footprint)) 

340 

341 def _calc_value(self, conditions, indx=None): 

342 

343 result = self.result.copy() 

344 behind_pix = np.where((conditions.mjd-self.survey_features['last_n_mjds'].feature[0]) > self.season) 

345 result[behind_pix] = 1 

346 

347 # let's ramp up the weight depending on how far into the observing season the healpix is 

348 mid_season_ra = (conditions.sunRA + np.pi) % (2.*np.pi) 

349 # relative RA 

350 relative_ra = (conditions.ra - mid_season_ra) % (2.*np.pi) 

351 relative_ra = (self.season_end_hour - relative_ra) % (2.*np.pi) 

352 # ok, now  

353 relative_ra[np.where(int_rounded(relative_ra) > int_rounded(self.season_end_hour-self.season_start_hour))] = 0 

354 

355 weight = relative_ra/(self.season_end_hour - self.season_start_hour) 

356 result *= weight 

357 

358 # mask off anything outside the footprint 

359 result[self.out_footprint] = 0 

360 

361 return result 

362 

363 

364class Cadence_in_season_basis_function(Base_basis_function): 

365 """Drive observations at least every N days in a given area 

366 

367 Parameters 

368 ---------- 

369 drive_map : np.array 

370 A HEALpix map with values of 1 where the cadence should be driven. 

371 filtername : str 

372 The filters that can count 

373 season_span : float (2.5) 

374 How long to consider a spot "in_season" (hours) 

375 cadence : float (2.5) 

376 How long to wait before activating the basis function (days) 

377 """ 

378 

379 def __init__(self, drive_map, filtername='griz', season_span=2.5, cadence=2.5, nside=None): 

380 super(Cadence_in_season_basis_function, self).__init__(nside=nside, filtername=filtername) 

381 self.drive_map = drive_map 

382 self.season_span = season_span/12.*np.pi # To radians 

383 self.cadence = cadence 

384 self.survey_features['last_observed'] = features.Last_observed(nside=nside, filtername=filtername) 

385 self.result = np.zeros(hp.nside2npix(self.nside), dtype=float) 

386 

387 def _calc_value(self, conditions, indx=None): 

388 result = self.result.copy() 

389 ra_mid_season = (conditions.sunRA + np.pi) % (2.*np.pi) 

390 

391 angle_to_mid_season = np.abs(conditions.ra - ra_mid_season) 

392 over = np.where(int_rounded(angle_to_mid_season) > int_rounded(np.pi)) 

393 angle_to_mid_season[over] = 2.*np.pi - angle_to_mid_season[over] 

394 

395 days_lag = conditions.mjd - self.survey_features['last_observed'].feature 

396 

397 active_pix = np.where((int_rounded(days_lag) >= int_rounded(self.cadence)) & 

398 (self.drive_map == 1) & 

399 (int_rounded(angle_to_mid_season) < int_rounded(self.season_span))) 

400 result[active_pix] = 1. 

401 

402 return result 

403 

404 

405class Season_coverage_basis_function(Base_basis_function): 

406 """Basis function to encourage N observations per observing season 

407 

408 Parameters 

409 ---------- 

410 footprint : healpix map (None) 

411 The footprint where one should demand coverage every season 

412 n_per_season : int (3) 

413 The number of observations to attempt to gather every season 

414 offset : healpix map 

415 The offset to apply when computing the current season over the sky. utils.create_season_offset 

416 is helpful for making this 

417 season_frac_start : float (0.5) 

418 Only start trying to gather observations after a season is fractionally this far over. 

419 """ 

420 def __init__(self, filtername='r', nside=None, footprint=None, n_per_season=3, offset=None, 

421 season_frac_start=0.5): 

422 super(Season_coverage_basis_function, self).__init__(nside=nside, filtername=filtername) 

423 

424 self.n_per_season = n_per_season 

425 self.footprint = footprint 

426 self.survey_features['n_obs_season'] = features.N_observations_current_season(filtername=filtername, 

427 nside=nside, offset=offset) 

428 self.result = np.zeros(hp.nside2npix(self.nside), dtype=float) 

429 self.season_frac_start = season_frac_start 

430 self.offset = offset 

431 

432 def _calc_value(self, conditions, indx=None): 

433 result = self.result.copy() 

434 season = utils.season_calc(conditions.night, offset=self.offset, floor=False) 

435 # Find the area that still needs observation 

436 feature = self.survey_features['n_obs_season'].feature 

437 not_enough = np.where((self.footprint > 0) & (feature < self.n_per_season) & 

438 ((int_rounded(season-np.floor(season)) > int_rounded(self.season_frac_start))) & 

439 (season >= 0)) 

440 result[not_enough] = 1 

441 return result 

442 

443 

444class Footprint_nvis_basis_function(Base_basis_function): 

445 """Basis function to drive observations of a given footprint. Good to target of opportunity targets 

446 where one might want to observe a region 3 times. 

447 

448 Parameters 

449 ---------- 

450 footprint : np.array 

451 A healpix array (1 for desired, 0 for not desired) of the target footprint. 

452 nvis : int (1) 

453 The number of visits to try and gather 

454 """ 

455 def __init__(self, filtername='r', nside=None, footprint=None, 

456 nvis=1, out_of_bounds_val=np.nan): 

457 super(Footprint_nvis_basis_function, self).__init__(nside=nside, filtername=filtername) 

458 self.footprint = footprint 

459 self.nvis = nvis 

460 

461 # Have a feature that tracks how many observations we have 

462 self.survey_features = {} 

463 # Map of the number of observations in filter 

464 self.survey_features['N_obs'] = features.N_observations(filtername=filtername, nside=self.nside) 

465 self.result = np.zeros(hp.nside2npix(nside)) 

466 self.result.fill(out_of_bounds_val) 

467 self.out_of_bounds_val = out_of_bounds_val 

468 

469 def _calc_value(self, conditions, indx=None): 

470 result = self.result.copy() 

471 diff = int_rounded(self.footprint*self.nvis - self.survey_features['N_obs'].feature) 

472 

473 result[np.where(diff > 0)] = 1 

474 

475 # Any spot where we have enough visits is out of bounds now. 

476 result[np.where(diff <= 0)] = self.out_of_bounds_val 

477 return result 

478 

479 

480class Third_observation_basis_function(Base_basis_function): 

481 """If there have been observations in two filters long enough ago, go for a third 

482 

483 Parameters 

484 ---------- 

485 gap_min : float (40.) 

486 The minimum time gap to consider a pixel good (minutes) 

487 gap_max : float (120) 

488 The maximum time to consider going for a pair (minutes) 

489 """ 

490 

491 def __init__(self, nside=32, filtername1='r', filtername2='z', gap_min=40., gap_max=120.): 

492 super(Third_observation_basis_function, self).__init__(nside=nside) 

493 self.filtername1 = filtername1 

494 self.filtername2 = filtername2 

495 self.gap_min = int_rounded(gap_min/60./24.) 

496 self.gap_max = int_rounded(gap_max/60./24.) 

497 

498 self.survey_features = {} 

499 self.survey_features['last_obs_f1'] = features.Last_observed(filtername=filtername1, nside=nside) 

500 self.survey_features['last_obs_f2'] = features.Last_observed(filtername=filtername2, nside=nside) 

501 self.result = np.empty(hp.nside2npix(self.nside)) 

502 self.result.fill(np.nan) 

503 

504 def _calc_value(self, conditions, indx=None): 

505 result = self.result.copy() 

506 d1 = int_rounded(conditions.mjd - self.survey_features['last_obs_f1'].feature) 

507 d2 = int_rounded(conditions.mjd - self.survey_features['last_obs_f2'].feature) 

508 good = np.where((d1 > self.gap_min) & (d1 < self.gap_max) & 

509 (d2 > self.gap_min) & (d2 < self.gap_max)) 

510 result[good] = 1 

511 return result 

512 

513 

514class Avoid_Fast_Revists(Base_basis_function): 

515 """Marks targets as unseen if they are in a specified time window in order to avoid fast revisits. 

516 

517 Parameters 

518 ---------- 

519 filtername: (string 'r') 

520 The name of the filter for this target map. 

521 gap_min : float (25.) 

522 Minimum time for the gap (minutes). 

523 nside: int (default_nside) 

524 The healpix resolution. 

525 penalty_val : float (np.nan) 

526 The reward value to use for regions to penalize. Will be masked if set to np.nan (default). 

527 """ 

528 def __init__(self, filtername='r', nside=None, gap_min=25., 

529 penalty_val=np.nan): 

530 super(Avoid_Fast_Revists, self).__init__(nside=nside, filtername=filtername) 

531 

532 self.filtername = filtername 

533 self.penalty_val = penalty_val 

534 

535 self.gap_min = int_rounded(gap_min/60./24.) 

536 self.nside = nside 

537 

538 self.survey_features = dict() 

539 self.survey_features['Last_observed'] = features.Last_observed(filtername=filtername, nside=nside) 

540 

541 def _calc_value(self, conditions, indx=None): 

542 result = np.ones(hp.nside2npix(self.nside), dtype=float) 

543 if indx is None: 

544 indx = np.arange(result.size) 

545 diff = int_rounded(conditions.mjd - self.survey_features['Last_observed'].feature[indx]) 

546 bad = np.where(diff < self.gap_min)[0] 

547 result[indx[bad]] = self.penalty_val 

548 return result 

549 

550 

551class Near_sun_twilight_basis_function(Base_basis_function): 

552 """Reward looking into the twilight for NEOs at high airmass 

553 

554 Parameters 

555 ---------- 

556 max_airmass : float (2.5) 

557 The maximum airmass to try and observe (unitless) 

558 """ 

559 

560 def __init__(self, nside=None, max_airmass=2.5, penalty=np.nan): 

561 super(Near_sun_twilight_basis_function, self).__init__(nside=nside) 

562 self.max_airmass = int_rounded(max_airmass) 

563 self.result = np.empty(hp.nside2npix(self.nside)) 

564 self.result.fill(penalty) 

565 

566 def _calc_value(self, conditions, indx=None): 

567 result = self.result.copy() 

568 good_pix = np.where((conditions.airmass >= 1.) & 

569 (int_rounded(conditions.airmass) < self.max_airmass) & 

570 (int_rounded(np.abs(conditions.az_to_sun)) < int_rounded(np.pi/2.))) 

571 result[good_pix] = conditions.airmass[good_pix] / self.max_airmass.initial 

572 return result 

573 

574 

575class Visit_repeat_basis_function(Base_basis_function): 

576 """ 

577 Basis function to reward re-visiting an area on the sky. Looking for Solar System objects. 

578 

579 Parameters 

580 ---------- 

581 gap_min : float (15.) 

582 Minimum time for the gap (minutes) 

583 gap_max : float (45.) 

584 Maximum time for a gap 

585 filtername : str ('r') 

586 The filter(s) to count with pairs 

587 npairs : int (1) 

588 The number of pairs of observations to attempt to gather 

589 """ 

590 def __init__(self, gap_min=25., gap_max=45., 

591 filtername='r', nside=None, npairs=1): 

592 

593 super(Visit_repeat_basis_function, self).__init__(nside=nside, filtername=filtername) 

594 

595 self.gap_min = int_rounded(gap_min/60./24.) 

596 self.gap_max = int_rounded(gap_max/60./24.) 

597 self.npairs = npairs 

598 

599 self.survey_features = {} 

600 # Track the number of pairs that have been taken in a night 

601 self.survey_features['Pair_in_night'] = features.Pair_in_night(filtername=filtername, 

602 gap_min=gap_min, gap_max=gap_max, 

603 nside=nside) 

604 # When was it last observed 

605 # XXX--since this feature is also in Pair_in_night, I should just access that one! 

606 self.survey_features['Last_observed'] = features.Last_observed(filtername=filtername, 

607 nside=nside) 

608 

609 def _calc_value(self, conditions, indx=None): 

610 result = np.zeros(hp.nside2npix(self.nside), dtype=float) 

611 if indx is None: 

612 indx = np.arange(result.size) 

613 diff = int_rounded(conditions.mjd - self.survey_features['Last_observed'].feature[indx]) 

614 good = np.where((diff >= self.gap_min) & (diff <= self.gap_max) & 

615 (self.survey_features['Pair_in_night'].feature[indx] < self.npairs))[0] 

616 result[indx[good]] += 1. 

617 return result 

618 

619 

620class M5_diff_basis_function(Base_basis_function): 

621 """Basis function based on the 5-sigma depth. 

622 Look up the best depth a healpixel achieves, and compute 

623 the limiting depth difference given current conditions 

624 """ 

625 def __init__(self, filtername='r', nside=None): 

626 

627 super(M5_diff_basis_function, self).__init__(nside=nside, filtername=filtername) 

628 # Need to look up the deepest m5 values for all the healpixels 

629 m5p = M5percentiles() 

630 self.dark_map = m5p.dark_map(filtername=filtername, nside_out=self.nside) 

631 

632 def _calc_value(self, conditions, indx=None): 

633 # No way to get the sign on this right the first time. 

634 result = conditions.M5Depth[self.filtername] - self.dark_map 

635 return result 

636 

637 

638class Strict_filter_basis_function(Base_basis_function): 

639 """Remove the bonus for staying in the same filter if certain conditions are met. 

640 

641 If the moon rises/sets or twilight starts/ends, it makes a lot of sense to consider 

642 a filter change. This basis function rewards if it matches the current filter, the moon rises or sets, 

643 twilight starts or stops, or there has been a large gap since the last observation. 

644 

645 Paramters 

646 --------- 

647 time_lag : float (10.) 

648 If there is a gap between observations longer than this, let the filter change (minutes) 

649 twi_change : float (-18.) 

650 The sun altitude to consider twilight starting/ending (degrees) 

651 note_free : str ('DD') 

652 No penalty for changing filters if the last observation note field includes string.  

653 Useful for giving a free filter change after deep drilling sequence 

654 """ 

655 def __init__(self, time_lag=10., filtername='r', twi_change=-18., note_free='DD'): 

656 

657 super(Strict_filter_basis_function, self).__init__(filtername=filtername) 

658 

659 self.time_lag = time_lag/60./24. # Convert to days 

660 self.twi_change = np.radians(twi_change) 

661 

662 self.survey_features = {} 

663 self.survey_features['Last_observation'] = features.Last_observation() 

664 self.note_free = note_free 

665 

666 def _calc_value(self, conditions, **kwargs): 

667 # Did the moon set or rise since last observation? 

668 moon_changed = conditions.moonAlt * self.survey_features['Last_observation'].feature['moonAlt'] < 0 

669 

670 # Are we already in the filter (or at start of night)? 

671 in_filter = (conditions.current_filter == self.filtername) | (conditions.current_filter is None) 

672 

673 # Has enough time past? 

674 time_past = int_rounded(conditions.mjd - self.survey_features['Last_observation'].feature['mjd']) > int_rounded(self.time_lag) 

675 

676 # Did twilight start/end? 

677 twi_changed = (conditions.sunAlt - self.twi_change) * (self.survey_features['Last_observation'].feature['sunAlt']- self.twi_change) < 0 

678 

679 # Did we just finish a DD sequence 

680 wasDD = self.note_free in self.survey_features['Last_observation'].feature['note'] 

681 

682 # Is the filter mounted? 

683 mounted = self.filtername in conditions.mounted_filters 

684 

685 if (moon_changed | in_filter | time_past | twi_changed | wasDD) & mounted: 

686 result = 1. 

687 else: 

688 result = 0. 

689 

690 return result 

691 

692 

693class Goal_Strict_filter_basis_function(Base_basis_function): 

694 """Remove the bonus for staying in the same filter if certain conditions are met. 

695 

696 If the moon rises/sets or twilight starts/ends, it makes a lot of sense to consider 

697 a filter change. This basis function rewards if it matches the current filter, the moon rises or sets, 

698 twilight starts or stops, or there has been a large gap since the last observation. 

699 

700 Parameters 

701 --------- 

702 time_lag_min: Minimum time after a filter change for which a new filter change will receive zero reward, or 

703 be denied at all (see unseen_before_lag). 

704 time_lag_max: Time after a filter change where the reward for changing filters achieve its maximum. 

705 time_lag_boost: Time after a filter change to apply a boost on the reward. 

706 boost_gain: A multiplier factor for the reward after time_lag_boost. 

707 unseen_before_lag: If True will make it impossible to switch filter before time_lag has passed. 

708 filtername: The filter for which this basis function will be used. 

709 tag: When using filter proportion use only regions with this tag to count for observations. 

710 twi_change: Switch reward on when twilight changes. 

711 proportion: The expected filter proportion distribution. 

712 aways_available: If this is true the basis function will aways be computed regardless of the feasibility. If 

713 False a more detailed feasibility check is performed. When set to False, it may speed up the computation 

714 process by avoiding to compute the reward functions paired with this bf, when observation is not feasible. 

715 

716 """ 

717 

718 def __init__(self, time_lag_min=10., time_lag_max=30., 

719 time_lag_boost=60., boost_gain=2.0, unseen_before_lag=False, 

720 filtername='r', tag=None, twi_change=-18., proportion=1.0, aways_available=False): 

721 

722 super(Goal_Strict_filter_basis_function, self).__init__(filtername=filtername) 

723 

724 self.time_lag_min = time_lag_min / 60. / 24. # Convert to days 

725 self.time_lag_max = time_lag_max / 60. / 24. # Convert to days 

726 self.time_lag_boost = time_lag_boost / 60. / 24. 

727 self.boost_gain = boost_gain 

728 self.unseen_before_lag = unseen_before_lag 

729 

730 self.twi_change = np.radians(twi_change) 

731 self.proportion = proportion 

732 self.aways_available = aways_available 

733 

734 self.survey_features = {} 

735 self.survey_features['Last_observation'] = features.Last_observation() 

736 self.survey_features['Last_filter_change'] = features.LastFilterChange() 

737 self.survey_features['N_obs_all'] = features.N_obs_count(filtername=None) 

738 self.survey_features['N_obs'] = features.N_obs_count(filtername=filtername, 

739 tag=tag) 

740 

741 def filter_change_bonus(self, time): 

742 

743 lag_min = self.time_lag_min 

744 lag_max = self.time_lag_max 

745 

746 a = 1. / (lag_max - lag_min) 

747 b = -a * lag_min 

748 

749 bonus = a * time + b 

750 # How far behind we are with respect to proportion? 

751 nobs = self.survey_features['N_obs'].feature 

752 nobs_all = self.survey_features['N_obs_all'].feature 

753 goal = self.proportion 

754 # need = 1. - nobs / nobs_all + goal if nobs_all > 0 else 1. + goal 

755 need = goal / nobs * nobs_all if nobs > 0 else 1. 

756 # need /= goal 

757 if hasattr(time, '__iter__'): 

758 before_lag = np.where(time <= lag_min) 

759 bonus[before_lag] = -np.inf if self.unseen_before_lag else 0. 

760 after_lag = np.where(time >= lag_max) 

761 bonus[after_lag] = 1. if time < self.time_lag_boost else self.boost_gain 

762 elif int_rounded(time) <= int_rounded(lag_min): 

763 return -np.inf if self.unseen_before_lag else 0. 

764 elif int_rounded(time) >= int_rounded(lag_max): 

765 return 1. if int_rounded(time) < int_rounded(self.time_lag_boost) else self.boost_gain 

766 

767 return bonus * need 

768 

769 def check_feasibility(self, conditions): 

770 """ 

771 This method makes a pre-check of the feasibility of this basis function. If a basis function return False 

772 on the feasibility check, it won't computed at all. 

773 

774 :return: 

775 """ 

776 

777 # Make a quick check about the feasibility of this basis function. If current filter is none, telescope 

778 # is parked and we could, in principle, switch to any filter. If this basis function computes reward for 

779 # the current filter, then it is also feasible. At last we check for an "aways_available" flag. Meaning, we 

780 # force this basis function to be aways be computed. 

781 if conditions.current_filter is None or conditions.current_filter == self.filtername or self.aways_available: 

782 return True 

783 

784 # If we arrive here, we make some extra checks to make sure this bf is feasible and should be computed. 

785 

786 # Did the moon set or rise since last observation? 

787 moon_changed = conditions.moonAlt * self.survey_features['Last_observation'].feature['moonAlt'] < 0 

788 

789 # Are we already in the filter (or at start of night)? 

790 not_in_filter = (conditions.current_filter != self.filtername) 

791 

792 # Has enough time past? 

793 lag = conditions.mjd - self.survey_features['Last_filter_change'].feature['mjd'] 

794 time_past = int_rounded(lag) > int_rounded(self.time_lag_min) 

795 

796 # Did twilight start/end? 

797 twi_changed = (conditions.sunAlt - self.twi_change) * \ 

798 (self.survey_features['Last_observation'].feature['sunAlt'] - self.twi_change) < 0 

799 

800 # Did we just finish a DD sequence 

801 wasDD = self.survey_features['Last_observation'].feature['note'] == 'DD' 

802 

803 # Is the filter mounted? 

804 mounted = self.filtername in conditions.mounted_filters 

805 

806 if (moon_changed | time_past | twi_changed | wasDD) & mounted & not_in_filter: 

807 return True 

808 else: 

809 return False 

810 

811 def _calc_value(self, conditions, **kwargs): 

812 

813 if conditions.current_filter is None: 

814 return 0. # no bonus if no filter is mounted 

815 # elif self.condition_features['Current_filter'].feature == self.filtername: 

816 # return 0. # no bonus if on the filter already 

817 

818 # Did the moon set or rise since last observation? 

819 moon_changed = conditions.moonAlt * \ 

820 self.survey_features['Last_observation'].feature['moonAlt'] < 0 

821 

822 # Are we already in the filter (or at start of night)? 

823 # not_in_filter = (self.condition_features['Current_filter'].feature != self.filtername) 

824 

825 # Has enough time past? 

826 lag = conditions.mjd - self.survey_features['Last_filter_change'].feature['mjd'] 

827 time_past = lag > self.time_lag_min 

828 

829 # Did twilight start/end? 

830 twi_changed = (conditions.sunAlt - self.twi_change) * ( 

831 self.survey_features['Last_observation'].feature['sunAlt'] - self.twi_change) < 0 

832 

833 # Did we just finish a DD sequence 

834 wasDD = self.survey_features['Last_observation'].feature['note'] == 'DD' 

835 

836 # Is the filter mounted? 

837 mounted = self.filtername in conditions.mounted_filters 

838 

839 if (moon_changed | time_past | twi_changed | wasDD) & mounted: 

840 result = self.filter_change_bonus(lag) if time_past else 0. 

841 else: 

842 result = -100. if self.unseen_before_lag else 0. 

843 

844 return result 

845 

846 

847class Filter_change_basis_function(Base_basis_function): 

848 """Reward staying in the current filter. 

849 """ 

850 def __init__(self, filtername='r'): 

851 super(Filter_change_basis_function, self).__init__(filtername=filtername) 

852 

853 def _calc_value(self, conditions, **kwargs): 

854 

855 if (conditions.current_filter == self.filtername) | (conditions.current_filter is None): 

856 result = 1. 

857 else: 

858 result = 0. 

859 return result 

860 

861 

862class Slewtime_basis_function(Base_basis_function): 

863 """Reward slews that take little time 

864 

865 Parameters 

866 ---------- 

867 max_time : float (135) 

868 The estimated maximum slewtime (seconds). Used to normalize so the basis function 

869 spans ~ -1-0 in reward units. 

870 """ 

871 def __init__(self, max_time=135., filtername='r', nside=None): 

872 super(Slewtime_basis_function, self).__init__(nside=nside, filtername=filtername) 

873 

874 self.maxtime = max_time 

875 self.nside = nside 

876 self.filtername = filtername 

877 self.result = np.zeros(hp.nside2npix(nside), dtype=float) 

878 

879 def add_observation(self, observation, indx=None): 

880 # No tracking of observations in this basis function. Purely based on conditions. 

881 pass 

882 

883 def _calc_value(self, conditions, indx=None): 

884 # If we are in a different filter, the Filter_change_basis_function will take it 

885 if conditions.current_filter != self.filtername: 

886 result = 0 

887 else: 

888 # Need to make sure smaller slewtime is larger reward. 

889 if np.size(conditions.slewtime) > 1: 

890 result = self.result.copy() 

891 good = ~np.isnan(conditions.slewtime) 

892 result[good] = -conditions.slewtime[good]/self.maxtime 

893 else: 

894 result = -conditions.slewtime/self.maxtime 

895 return result 

896 

897 

898class Aggressive_Slewtime_basis_function(Base_basis_function): 

899 """Reward slews that take little time 

900 

901 XXX--not sure how this is different from Slewtime_basis_function? 

902 Looks like it's checking the slewtime to the field position rather than the healpix maybe? 

903 """ 

904 

905 def __init__(self, max_time=135., order=1., hard_max=None, filtername='r', nside=None): 

906 super(Aggressive_Slewtime_basis_function, self).__init__(nside=nside, filtername=filtername) 

907 

908 self.maxtime = max_time 

909 self.hard_max = hard_max 

910 self.order = order 

911 self.result = np.zeros(hp.nside2npix(nside), dtype=float) 

912 

913 def _calc_value(self, conditions, indx=None): 

914 # If we are in a different filter, the Filter_change_basis_function will take it 

915 if conditions.current_filter != self.filtername: 

916 result = 0. 

917 else: 

918 # Need to make sure smaller slewtime is larger reward. 

919 if np.size(self.condition_features['slewtime'].feature) > 1: 

920 result = self.result.copy() 

921 result.fill(np.nan) 

922 

923 good = np.where(np.bitwise_and(conditions.slewtime > 0., 

924 conditions.slewtime < self.maxtime)) 

925 result[good] = ((self.maxtime - conditions.slewtime[good]) / 

926 self.maxtime) ** self.order 

927 if self.hard_max is not None: 

928 not_so_good = np.where(conditions.slewtime > self.hard_max) 

929 result[not_so_good] -= 10. 

930 fields = np.unique(conditions.hp2fields[good]) 

931 for field in fields: 

932 hp_indx = np.where(conditions.hp2fields == field) 

933 result[hp_indx] = np.min(result[hp_indx]) 

934 else: 

935 result = (self.maxtime - conditions.slewtime) / self.maxtime 

936 return result 

937 

938 

939class Skybrightness_limit_basis_function(Base_basis_function): 

940 """Mask regions that are outside a sky brightness limit 

941 

942 XXX--TODO: This should probably go to the mask basis functions. 

943 

944 Parameters 

945 ---------- 

946 min : float (20.) 

947 The minimum sky brightness (mags). 

948 max : float (30.) 

949 The maximum sky brightness (mags). 

950 

951 """ 

952 def __init__(self, nside=None, filtername='r', sbmin=20., sbmax=30.): 

953 super(Skybrightness_limit_basis_function, self).__init__(nside=nside, filtername=filtername) 

954 

955 self.min = int_rounded(sbmin) 

956 self.max = int_rounded(sbmax) 

957 self.result = np.empty(hp.nside2npix(self.nside), dtype=float) 

958 self.result.fill(np.nan) 

959 

960 def _calc_value(self, conditions, indx=None): 

961 result = self.result.copy() 

962 

963 good = np.where(np.bitwise_and(int_rounded(conditions.skybrightness[self.filtername]) > self.min, 

964 int_rounded(conditions.skybrightness[self.filtername]) < self.max)) 

965 result[good] = 1.0 

966 

967 return result 

968 

969 

970class CableWrap_unwrap_basis_function(Base_basis_function): 

971 """ 

972 Parameters 

973 ---------- 

974 minAz : float (20.) 

975 The minimum azimuth to activate bf (degrees) 

976 maxAz : float (82.) 

977 The maximum azimuth to activate bf (degrees) 

978 unwrap_until: float (90.) 

979 The window in which the bf is activated (degrees) 

980 """ 

981 def __init__(self, nside=None, minAz=-270., maxAz=270., minAlt=20., maxAlt=82., 

982 activate_tol=20., delta_unwrap=1.2, unwrap_until=70., max_duration=30.): 

983 super(CableWrap_unwrap_basis_function, self).__init__(nside=nside) 

984 

985 self.minAz = np.radians(minAz) 

986 self.maxAz = np.radians(maxAz) 

987 

988 self.activate_tol = np.radians(activate_tol) 

989 self.delta_unwrap = np.radians(delta_unwrap) 

990 self.unwrap_until = np.radians(unwrap_until) 

991 

992 self.minAlt = np.radians(minAlt) 

993 self.maxAlt = np.radians(maxAlt) 

994 # Convert to half-width for convienence 

995 self.nside = nside 

996 self.active = False 

997 self.unwrap_direction = 0. # either -1., 0., 1. 

998 self.max_duration = max_duration/60./24. # Convert to days 

999 self.activation_time = None 

1000 self.result = np.zeros(hp.nside2npix(self.nside), dtype=float) 

1001 

1002 def _calc_value(self, conditions, indx=None): 

1003 

1004 result = self.result.copy() 

1005 

1006 current_abs_rad = np.radians(conditions.az) 

1007 unseen = np.where(np.bitwise_or(conditions.alt < self.minAlt, 

1008 conditions.alt > self.maxAlt)) 

1009 result[unseen] = np.nan 

1010 

1011 if (self.minAz + self.activate_tol < current_abs_rad < self.maxAz - self.activate_tol) and not self.active: 

1012 return result 

1013 elif self.active and self.unwrap_direction == 1 and current_abs_rad > self.minAz+self.unwrap_until: 

1014 self.active = False 

1015 self.unwrap_direction = 0. 

1016 self.activation_time = None 

1017 return result 

1018 elif self.active and self.unwrap_direction == -1 and current_abs_rad < self.maxAz-self.unwrap_until: 

1019 self.active = False 

1020 self.unwrap_direction = 0. 

1021 self.activation_time = None 

1022 return result 

1023 elif (self.activation_time is not None and 

1024 conditions.mjd - self.activation_time > self.max_duration): 

1025 self.active = False 

1026 self.unwrap_direction = 0. 

1027 self.activation_time = None 

1028 return result 

1029 

1030 if not self.active: 

1031 self.activation_time = conditions.mjd 

1032 if current_abs_rad < 0.: 

1033 self.unwrap_direction = 1 # clock-wise unwrap 

1034 else: 

1035 self.unwrap_direction = -1 # counter-clock-wise unwrap 

1036 

1037 self.active = True 

1038 

1039 max_abs_rad = self.maxAz 

1040 min_abs_rad = self.minAz 

1041 

1042 TWOPI = 2.*np.pi 

1043 

1044 # Compute distance and accumulated az. 

1045 norm_az_rad = np.divmod(conditions.az - min_abs_rad, TWOPI)[1] + min_abs_rad 

1046 distance_rad = divmod(norm_az_rad - current_abs_rad, TWOPI)[1] 

1047 get_shorter = np.where(distance_rad > np.pi) 

1048 distance_rad[get_shorter] -= TWOPI 

1049 accum_abs_rad = current_abs_rad + distance_rad 

1050 

1051 # Compute wrap regions and fix distances 

1052 mask_max = np.where(accum_abs_rad > max_abs_rad) 

1053 distance_rad[mask_max] -= TWOPI 

1054 mask_min = np.where(accum_abs_rad < min_abs_rad) 

1055 distance_rad[mask_min] += TWOPI 

1056 

1057 # Step-2: Repeat but now with compute reward to unwrap using specified delta_unwrap 

1058 unwrap_current_abs_rad = current_abs_rad - (np.abs(self.delta_unwrap) if self.unwrap_direction > 0 

1059 else -np.abs(self.delta_unwrap)) 

1060 unwrap_distance_rad = divmod(norm_az_rad - unwrap_current_abs_rad, TWOPI)[1] 

1061 unwrap_get_shorter = np.where(unwrap_distance_rad > np.pi) 

1062 unwrap_distance_rad[unwrap_get_shorter] -= TWOPI 

1063 unwrap_distance_rad = np.abs(unwrap_distance_rad) 

1064 

1065 if self.unwrap_direction < 0: 

1066 mask = np.where(accum_abs_rad > unwrap_current_abs_rad) 

1067 else: 

1068 mask = np.where(accum_abs_rad < unwrap_current_abs_rad) 

1069 

1070 # Finally build reward map 

1071 result = (1. - unwrap_distance_rad/np.max(unwrap_distance_rad))**2. 

1072 result[mask] = 0. 

1073 result[unseen] = np.nan 

1074 

1075 return result 

1076 

1077 

1078class Cadence_enhance_basis_function(Base_basis_function): 

1079 """Drive a certain cadence 

1080 Parameters 

1081 ---------- 

1082 filtername : str ('gri') 

1083 The filter(s) that should be grouped together 

1084 supress_window : list of float 

1085 The start and stop window for when observations should be repressed (days) 

1086 apply_area : healpix map 

1087 The area over which to try and drive the cadence. Good values as 1, no candece drive 0. 

1088 Probably works as a bool array too.""" 

1089 def __init__(self, filtername='gri', nside=None, 

1090 supress_window=[0, 1.8], supress_val=-0.5, 

1091 enhance_window=[2.1, 3.2], enhance_val=1., 

1092 apply_area=None): 

1093 super(Cadence_enhance_basis_function, self).__init__(nside=nside, filtername=filtername) 

1094 

1095 self.supress_window = np.sort(supress_window) 

1096 self.supress_val = supress_val 

1097 self.enhance_window = np.sort(enhance_window) 

1098 self.enhance_val = enhance_val 

1099 

1100 self.survey_features = {} 

1101 self.survey_features['last_observed'] = features.Last_observed(filtername=filtername) 

1102 

1103 self.empty = np.zeros(hp.nside2npix(self.nside), dtype=float) 

1104 # No map, try to drive the whole area 

1105 if apply_area is None: 

1106 self.apply_indx = np.arange(self.empty.size) 

1107 else: 

1108 self.apply_indx = np.where(apply_area != 0)[0] 

1109 

1110 def _calc_value(self, conditions, indx=None): 

1111 # copy an empty array 

1112 result = self.empty.copy() 

1113 if indx is not None: 

1114 ind = np.intersect1d(indx, self.apply_indx) 

1115 else: 

1116 ind = self.apply_indx 

1117 if np.size(ind) == 0: 

1118 result = 0 

1119 else: 

1120 mjd_diff = conditions.mjd - self.survey_features['last_observed'].feature[ind] 

1121 to_supress = np.where((int_rounded(mjd_diff) > int_rounded(self.supress_window[0])) & 

1122 (int_rounded(mjd_diff) < int_rounded(self.supress_window[1]))) 

1123 result[ind[to_supress]] = self.supress_val 

1124 to_enhance = np.where((int_rounded(mjd_diff) > int_rounded(self.enhance_window[0])) & 

1125 (int_rounded(mjd_diff) < int_rounded(self.enhance_window[1]))) 

1126 result[ind[to_enhance]] = self.enhance_val 

1127 return result 

1128 

1129 

1130# https://docs.astropy.org/en/stable/_modules/astropy/modeling/functional_models.html#Trapezoid1D 

1131def trapezoid(x, amplitude, x_0, width, slope): 

1132 """One dimensional Trapezoid model function""" 

1133 # Compute the four points where the trapezoid changes slope 

1134 # x1 <= x2 <= x3 <= x4 

1135 x2 = x_0 - width / 2. 

1136 x3 = x_0 + width / 2. 

1137 x1 = x2 - amplitude / slope 

1138 x4 = x3 + amplitude / slope 

1139 

1140 result = x*0 

1141 

1142 # Compute model values in pieces between the change points 

1143 range_a = np.logical_and(x >= x1, x < x2) 

1144 range_b = np.logical_and(x >= x2, x < x3) 

1145 range_c = np.logical_and(x >= x3, x < x4) 

1146 

1147 result[range_a] = slope * (x[range_a] - x1) 

1148 result[range_b] = amplitude 

1149 result[range_c] = slope * (x4 - x[range_c]) 

1150 

1151 return result 

1152 

1153 

1154class Cadence_enhance_trapezoid_basis_function(Base_basis_function): 

1155 """Drive a certain cadence, like Cadence_enhance_basis_function but with smooth transitions 

1156 Parameters 

1157 ---------- 

1158 filtername : str ('gri') 

1159 The filter(s) that should be grouped together 

1160 

1161 XXX--fill out doc string! 

1162 """ 

1163 def __init__(self, filtername='gri', nside=None, 

1164 delay_width=2, delay_slope=2., delay_peak=0, delay_amp=0.5, 

1165 enhance_width=3., enhance_slope=2., enhance_peak=4., enhance_amp=1., 

1166 apply_area=None, season_limit=None): 

1167 super(Cadence_enhance_trapezoid_basis_function, self).__init__(nside=nside, filtername=filtername) 

1168 

1169 self.delay_width = delay_width 

1170 self.delay_slope = delay_slope 

1171 self.delay_peak = delay_peak 

1172 self.delay_amp = delay_amp 

1173 self.enhance_width = enhance_width 

1174 self.enhance_slope = enhance_slope 

1175 self.enhance_peak = enhance_peak 

1176 self.enhance_amp = enhance_amp 

1177 

1178 self.season_limit = season_limit/12*np.pi # To radians 

1179 

1180 self.survey_features = {} 

1181 self.survey_features['last_observed'] = features.Last_observed(filtername=filtername) 

1182 

1183 self.empty = np.zeros(hp.nside2npix(self.nside), dtype=float) 

1184 # No map, try to drive the whole area 

1185 if apply_area is None: 

1186 self.apply_indx = np.arange(self.empty.size) 

1187 else: 

1188 self.apply_indx = np.where(apply_area != 0)[0] 

1189 

1190 def suppress_enhance(self, x): 

1191 result = x*0 

1192 result -= trapezoid(x, self.delay_amp, self.delay_peak, self.delay_width, self.delay_slope) 

1193 result += trapezoid(x, self.enhance_amp, self.enhance_peak, self.enhance_width, self.enhance_slope) 

1194 

1195 return result 

1196 

1197 def season_calc(self, conditions): 

1198 ra_mid_season = (conditions.sunRA + np.pi) % (2.*np.pi) 

1199 angle_to_mid_season = np.abs(conditions.ra - ra_mid_season) 

1200 over = np.where(int_rounded(angle_to_mid_season) > int_rounded(np.pi)) 

1201 angle_to_mid_season[over] = 2.*np.pi - angle_to_mid_season[over] 

1202 

1203 return angle_to_mid_season 

1204 

1205 def _calc_value(self, conditions, indx=None): 

1206 # copy an empty array 

1207 result = self.empty.copy() 

1208 if indx is not None: 

1209 ind = np.intersect1d(indx, self.apply_indx) 

1210 else: 

1211 ind = self.apply_indx 

1212 if np.size(ind) == 0: 

1213 result = 0 

1214 else: 

1215 mjd_diff = conditions.mjd - self.survey_features['last_observed'].feature[ind] 

1216 result[ind] += self.suppress_enhance(mjd_diff) 

1217 

1218 if self.season_limit is not None: 

1219 radians_to_midseason = self.season_calc(conditions) 

1220 outside_season = np.where(radians_to_midseason > self.season_limit) 

1221 result[outside_season] = 0 

1222 

1223 

1224 return result 

1225 

1226 

1227class Azimuth_basis_function(Base_basis_function): 

1228 """Reward staying in the same azimuth range. Possibly better than using slewtime, especially when selecting a large area of sky. 

1229 

1230 Parameters 

1231 ---------- 

1232 

1233 """ 

1234 

1235 def __init__(self, nside=None): 

1236 super(Azimuth_basis_function, self).__init__(nside=nside) 

1237 

1238 def _calc_value(self, conditions, indx=None): 

1239 az_dist = conditions.az - conditions.telAz 

1240 az_dist = az_dist % (2.*np.pi) 

1241 over = np.where(az_dist > np.pi) 

1242 az_dist[over] = 2. * np.pi - az_dist[over] 

1243 # Normalize sp between 0 and 1 

1244 result = az_dist/np.pi 

1245 return result 

1246 

1247 

1248class Az_modulo_basis_function(Base_basis_function): 

1249 """Try to replicate the Rothchild et al cadence forcing by only observing on limited az ranges per night. 

1250 

1251 Parameters 

1252 ---------- 

1253 az_limits : list of float pairs (None) 

1254 The azimuth limits (degrees) to use. 

1255 """ 

1256 def __init__(self, nside=None, az_limits=None, out_of_bounds_val=-1.): 

1257 super(Az_modulo_basis_function, self).__init__(nside=nside) 

1258 self.result = np.ones(hp.nside2npix(self.nside)) 

1259 if az_limits is None: 

1260 spread = 100./2. 

1261 self.az_limits = np.radians([[360-spread, spread], 

1262 [90.-spread, 90.+spread], 

1263 [180.-spread, 180.+spread]]) 

1264 else: 

1265 self.az_limits = np.radians(az_limits) 

1266 self.mod_val = len(self.az_limits) 

1267 self.out_of_bounds_val = out_of_bounds_val 

1268 

1269 def _calc_value(self, conditions, indx=None): 

1270 result = self.result.copy() 

1271 az_lim = self.az_limits[np.max(conditions.night) % self.mod_val] 

1272 

1273 if az_lim[0] < az_lim[1]: 

1274 out_pix = np.where((int_rounded(conditions.az) < int_rounded(az_lim[0])) | 

1275 (int_rounded(conditions.az) > int_rounded(az_lim[1]))) 

1276 else: 

1277 out_pix = np.where((int_rounded(conditions.az) < int_rounded(az_lim[0])) | 

1278 (int_rounded(conditions.az) > int_rounded(az_lim[1])))[0] 

1279 result[out_pix] = self.out_of_bounds_val 

1280 return result 

1281 

1282 

1283class Dec_modulo_basis_function(Base_basis_function): 

1284 """Emphasize dec bands on a nightly varying basis 

1285 

1286 Parameters 

1287 ---------- 

1288 dec_limits : list of float pairs (None) 

1289 The azimuth limits (degrees) to use. 

1290 """ 

1291 def __init__(self, nside=None, dec_limits=None, out_of_bounds_val=-1.): 

1292 super(Dec_modulo_basis_function, self).__init__(nside=nside) 

1293 

1294 npix = hp.nside2npix(nside) 

1295 hpids = np.arange(npix) 

1296 ra, dec = _hpid2RaDec(nside, hpids) 

1297 

1298 self.results = [] 

1299 

1300 if dec_limits is None: 

1301 self.dec_limits = np.radians([[-90., -32.8], 

1302 [-32.8, -12.], 

1303 [-12., 35.]]) 

1304 else: 

1305 self.dec_limits = np.radians(dec_limits) 

1306 self.mod_val = len(self.dec_limits) 

1307 self.out_of_bounds_val = out_of_bounds_val 

1308 

1309 for limits in self.dec_limits: 

1310 good = np.where((dec >= limits[0]) & (dec < limits[1]))[0] 

1311 tmp = np.zeros(npix) 

1312 tmp[good] = 1 

1313 self.results.append(tmp) 

1314 

1315 def _calc_value(self, conditions, indx=None): 

1316 night_index = np.max(conditions.night % self.mod_val) 

1317 result = self.results[night_index] 

1318 

1319 return result 

1320 

1321 

1322class Map_modulo_basis_function(Base_basis_function): 

1323 """Similar to Dec_modulo, but now use input masks 

1324 

1325 Parameters 

1326 ---------- 

1327 inmaps : list of hp arrays 

1328 """ 

1329 def __init__(self, inmaps): 

1330 nside = hp.npix2nside(np.size(inmaps[0])) 

1331 super(Map_modulo_basis_function, self).__init__(nside=nside) 

1332 self.maps = inmaps 

1333 self.mod_val = len(inmaps) 

1334 

1335 def _calc_value(self, conditions, indx=None): 

1336 indx = np.max(conditions.night % self.mod_val) 

1337 result = self.maps[indx] 

1338 return result 

1339 

1340 

1341class Good_seeing_basis_function(Base_basis_function): 

1342 """Drive observations in good seeing conditions""" 

1343 

1344 def __init__(self, nside=None, filtername='r', footprint=None, FWHMeff_limit=0.8, 

1345 mag_diff=0.75): 

1346 super(Good_seeing_basis_function, self).__init__(nside=nside) 

1347 

1348 self.filtername = filtername 

1349 self.FWHMeff_limit = int_rounded(FWHMeff_limit) 

1350 if footprint is None: 

1351 fp = utils.standard_goals(nside=nside)[filtername] 

1352 else: 

1353 fp = footprint 

1354 self.out_of_bounds = np.where(fp == 0)[0] 

1355 self.result = fp*0 

1356 

1357 self.mag_diff = int_rounded(mag_diff) 

1358 self.survey_features = {} 

1359 self.survey_features['coadd_depth_all'] = features.Coadded_depth(filtername=filtername, 

1360 nside=nside) 

1361 self.survey_features['coadd_depth_good'] = features.Coadded_depth(filtername=filtername, 

1362 nside=nside, 

1363 FWHMeff_limit=FWHMeff_limit) 

1364 

1365 def _calc_value(self, conditions, **kwargs): 

1366 # Seeing is "bad" 

1367 if int_rounded(conditions.FWHMeff[self.filtername].min()) > self.FWHMeff_limit: 

1368 return 0 

1369 result = self.result.copy() 

1370 

1371 diff = self.survey_features['coadd_depth_all'].feature - self.survey_features['coadd_depth_good'].feature 

1372 # Where are there things we want to observe? 

1373 good_pix = np.where((int_rounded(diff) > self.mag_diff) & 

1374 (int_rounded(conditions.FWHMeff[self.filtername]) <= self.FWHMeff_limit)) 

1375 # Hm, should this scale by the mag differences? Probably. 

1376 result[good_pix] = diff[good_pix] 

1377 result[self.out_of_bounds] = 0 

1378 

1379 return result 

1380 

1381 

1382class Template_generate_basis_function(Base_basis_function): 

1383 """Emphasize areas that have not been observed in a long time 

1384 

1385 Parameters 

1386 ---------- 

1387 day_gap : float (250.) 

1388 How long to wait before boosting the reward (days) 

1389 footprint : np.array (None) 

1390 The indices of the healpixels to apply the boost to. Uses the default footprint if None 

1391 """ 

1392 def __init__(self, nside=None, day_gap=250., filtername='r', footprint=None): 

1393 super(Template_generate_basis_function, self).__init__(nside=nside) 

1394 self.day_gap = day_gap 

1395 self.filtername = filtername 

1396 self.survey_features = {} 

1397 self.survey_features['Last_observed'] = features.Last_observed(filtername=filtername) 

1398 self.result = np.zeros(hp.nside2npix(self.nside)) 

1399 if footprint is None: 

1400 fp = utils.standard_goals(nside=nside)[filtername] 

1401 else: 

1402 fp = footprint 

1403 self.out_of_bounds = np.where(fp == 0) 

1404 

1405 def _calc_value(self, conditions, **kwargs): 

1406 result = self.result.copy() 

1407 overdue = np.where((int_rounded(conditions.mjd - self.survey_features['Last_observed'].feature)) > int_rounded(self.day_gap)) 

1408 result[overdue] = 1 

1409 result[self.out_of_bounds] = 0 

1410 

1411 return result 

1412 

1413 

1414class Observed_twice_basis_function(Base_basis_function): 

1415 """Mask out pixels that haven't been observed in the night 

1416 """ 

1417 def __init__(self, nside=None, filtername='r', n_obs_needed=2, n_obs_in_filt_needed=1): 

1418 super(Observed_twice_basis_function, self).__init__(nside=nside) 

1419 self.n_obs_needed = n_obs_needed 

1420 self.n_obs_in_filt_needed = n_obs_in_filt_needed 

1421 self.filtername = filtername 

1422 self.survey_features = {} 

1423 self.survey_features['N_obs_infilt'] = features.N_obs_night(nside=nside, filtername=filtername) 

1424 self.survey_features['N_obs_all'] = features.N_obs_night(nside=nside, filtername='') 

1425 

1426 self.result = np.zeros(hp.nside2npix(self.nside)) 

1427 

1428 def _calc_value(self, conditions, **kwargs): 

1429 result = self.result.copy() 

1430 good_pix = np.where((self.survey_features['N_obs_infilt'].feature >= self.n_obs_in_filt_needed) & 

1431 (self.survey_features['N_obs_all'].feature >= self.n_obs_needed))[0] 

1432 result[good_pix] = 1 

1433 

1434 return result