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_Fast_Revists', 'Visit_repeat_basis_function', 'M5_diff_basis_function', 

16 'Strict_filter_basis_function', 'Goal_Strict_filter_basis_function', 

17 'Filter_change_basis_function', 'Slewtime_basis_function', 

18 'Aggressive_Slewtime_basis_function', 'Skybrightness_limit_basis_function', 

19 'CableWrap_unwrap_basis_function', 'Cadence_enhance_basis_function', 'Azimuth_basis_function', 

20 'Az_modulo_basis_function', 'Dec_modulo_basis_function', 'Map_modulo_basis_function', 

21 'Template_generate_basis_function', 

22 'Footprint_nvis_basis_function', 'Third_observation_basis_function', 'Season_coverage_basis_function', 

23 'N_obs_per_year_basis_function', 'Cadence_in_season_basis_function', 'Near_sun_twilight_basis_function', 

24 'N_obs_high_am_basis_function', 'Good_seeing_basis_function', 'Observed_twice_basis_function', 

25 'Ecliptic_basis_function'] 

26 

27 

28class Base_basis_function(object): 

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

30 """ 

31 

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

33 

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

35 self.update_on_newobs = True 

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

37 self.update_on_mjd = True 

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

39 self.survey_features = {} 

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

41 self.mjd_last = None 

42 self.value = 0 

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

44 self.attrs_to_compare = [] 

45 # Do we need to recalculate the basis function 

46 self.recalc = True 

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

48 if nside is None: 

49 self.nside = utils.set_default_nside() 

50 else: 

51 self.nside = nside 

52 

53 self.filtername = filtername 

54 

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

56 """ 

57 Parameters 

58 ---------- 

59 observation : np.array 

60 An array with information about the input observation 

61 indx : np.array 

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

63 """ 

64 for feature in self.survey_features: 

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

66 if self.update_on_newobs: 

67 self.recalc = True 

68 

69 def check_feasibility(self, conditions): 

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

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

72 """ 

73 return True 

74 

75 def _calc_value(self, conditions, **kwarge): 

76 self.value = 0 

77 # Update the last time we had an mjd 

78 self.mjd_last = conditions.mjd + 0 

79 self.recalc = False 

80 return self.value 

81 

82 def __eq__(self): 

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

84 pass 

85 

86 def __ne__(self): 

87 pass 

88 

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

90 """ 

91 Parameters 

92 ---------- 

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

94 Object that has attributes for all the current conditions. 

95 

96 Return a reward healpix map or a reward scalar. 

97 """ 

98 # If we are not feasible, return -inf 

99 if not self.check_feasibility(conditions): 

100 return -np.inf 

101 if self.recalc: 

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

103 if self.update_on_mjd: 

104 if conditions.mjd != self.mjd_last: 

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

106 return self.value 

107 

108 

109class Constant_basis_function(Base_basis_function): 

110 """Just add a constant 

111 """ 

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

113 return 1 

114 

115 

116class Target_map_basis_function(Base_basis_function): 

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

118 

119 Parameters 

120 ---------- 

121 filtername: (string 'r') 

122 The name of the filter for this target map. 

123 nside: int (default_nside) 

124 The healpix resolution. 

125 target_map : numpy array (None) 

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

127 norm_factor : float (0.00010519) 

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

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

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

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

132 that computes norm_factor. 

133 out_of_bounds_val : float (-10.) 

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

135 """ 

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

137 norm_factor=None, 

138 out_of_bounds_val=-10.): 

139 

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

141 

142 if norm_factor is None: 

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

144 self.norm_factor = 0.00010519 

145 else: 

146 self.norm_factor = norm_factor 

147 

148 self.survey_features = {} 

149 # Map of the number of observations in filter 

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

151 # Count of all the observations 

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

153 if target_map is None: 

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

155 else: 

156 self.target_map = target_map 

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

158 self.out_of_bounds_val = out_of_bounds_val 

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

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

161 

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

163 """ 

164 Parameters 

165 ---------- 

166 indx : list (None) 

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

168 Returns 

169 ------- 

170 Healpix reward map 

171 """ 

172 result = self.result.copy() 

173 if indx is None: 

174 indx = self.all_indx 

175 

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

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

178 

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

180 result[self.out_of_bounds_area] = self.out_of_bounds_val 

181 

182 return result 

183 

184 

185def azRelPoint(azs, pointAz): 

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

187 if isinstance(azs, np.ndarray): 

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

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

190 else: 

191 if azRelMoon > np.pi: 

192 azRelMoon = 2.0 * np.pi - azRelMoon 

193 return azRelMoon 

194 

195 

196class N_obs_high_am_basis_function(Base_basis_function): 

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

198 """ 

199 

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

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

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

203 self.footprint = footprint 

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

205 self.am_limits = am_limits 

206 self.season = season 

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

208 n_obs=n_obs) 

209 

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

211 self.out_of_bounds_val = out_of_bounds_val 

212 

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

214 """ 

215 Parameters 

216 ---------- 

217 observation : np.array 

218 An array with information about the input observation 

219 indx : np.array 

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

221 """ 

222 

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

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

225 for feature in self.survey_features: 

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

227 if self.update_on_newobs: 

228 self.recalc = True 

229 

230 def check_feasibility(self, conditions): 

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

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

233 """ 

234 result = True 

235 reward = self._calc_value(conditions) 

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

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

238 result = False 

239 

240 return result 

241 

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

243 result = self.result.copy() 

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

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

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

247 result[behind_pix] = 1 

248 result[self.out_footprint] = self.out_of_bounds_val 

249 

250 # Update the last time we had an mjd 

251 self.mjd_last = conditions.mjd + 0 

252 self.recalc = False 

253 self.value = result 

254 

255 return result 

256 

257 

258class Ecliptic_basis_function(Base_basis_function): 

259 """Mark the area around the ecliptic 

260 """ 

261 

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

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

264 self.distance_to_eclip = np.radians(distance_to_eclip) 

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

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

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

268 eclip_lat = coord.barycentrictrueecliptic.lat.radian 

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

270 self.result[good] += 1 

271 

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

273 return self.result 

274 

275 

276class N_obs_per_year_basis_function(Base_basis_function): 

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

278 

279 Parameters 

280 ---------- 

281 filtername : str ('r') 

282 The filter to track 

283 footprint : np.array 

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

285 n_obs : int (3) 

286 The number of observations to demand 

287 season : float (300) 

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

289 season_start_hour : float (-2) 

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

291 season_end_hour : float (2) 

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

293 """ 

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

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

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

297 self.footprint = footprint 

298 self.n_obs = n_obs 

299 self.season = season 

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

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

302 

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

304 n_obs=n_obs) 

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

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

307 

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

309 

310 result = self.result.copy() 

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

312 result[behind_pix] = 1 

313 

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

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

316 # relative RA 

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

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

319 # ok, now  

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

321 

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

323 result *= weight 

324 

325 # mask off anything outside the footprint 

326 result[self.out_footprint] = 0 

327 

328 return result 

329 

330 

331class Cadence_in_season_basis_function(Base_basis_function): 

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

333 

334 Parameters 

335 ---------- 

336 drive_map : np.array 

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

338 filtername : str 

339 The filters that can count 

340 season_span : float (2.5) 

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

342 cadence : float (2.5) 

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

344 """ 

345 

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

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

348 self.drive_map = drive_map 

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

350 self.cadence = cadence 

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

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

353 

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

355 result = self.result.copy() 

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

357 

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

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

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

361 

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

363 

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

365 (self.drive_map == 1) & 

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

367 result[active_pix] = 1. 

368 

369 return result 

370 

371 

372class Season_coverage_basis_function(Base_basis_function): 

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

374 

375 Parameters 

376 ---------- 

377 footprint : healpix map (None) 

378 The footprint where one should demand coverage every season 

379 n_per_season : int (3) 

380 The number of observations to attempt to gather every season 

381 offset : healpix map 

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

383 is helpful for making this 

384 season_frac_start : float (0.5) 

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

386 """ 

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

388 season_frac_start=0.5): 

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

390 

391 self.n_per_season = n_per_season 

392 self.footprint = footprint 

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

394 nside=nside, offset=offset) 

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

396 self.season_frac_start = season_frac_start 

397 self.offset = offset 

398 

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

400 result = self.result.copy() 

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

402 # Find the area that still needs observation 

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

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

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

406 (season >= 0)) 

407 result[not_enough] = 1 

408 return result 

409 

410 

411class Footprint_nvis_basis_function(Base_basis_function): 

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

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

414 

415 Parameters 

416 ---------- 

417 footprint : np.array 

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

419 nvis : int (1) 

420 The number of visits to try and gather 

421 """ 

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

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

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

425 self.footprint = footprint 

426 self.nvis = nvis 

427 

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

429 self.survey_features = {} 

430 # Map of the number of observations in filter 

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

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

433 self.result.fill(out_of_bounds_val) 

434 self.out_of_bounds_val = out_of_bounds_val 

435 

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

437 result = self.result.copy() 

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

439 

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

441 

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

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

444 return result 

445 

446 

447class Third_observation_basis_function(Base_basis_function): 

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

449 

450 Parameters 

451 ---------- 

452 gap_min : float (40.) 

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

454 gap_max : float (120) 

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

456 """ 

457 

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

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

460 self.filtername1 = filtername1 

461 self.filtername2 = filtername2 

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

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

464 

465 self.survey_features = {} 

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

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

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

469 self.result.fill(np.nan) 

470 

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

472 result = self.result.copy() 

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

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

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

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

477 result[good] = 1 

478 return result 

479 

480 

481class Avoid_Fast_Revists(Base_basis_function): 

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

483 

484 Parameters 

485 ---------- 

486 filtername: (string 'r') 

487 The name of the filter for this target map. 

488 gap_min : float (25.) 

489 Minimum time for the gap (minutes). 

490 nside: int (default_nside) 

491 The healpix resolution. 

492 penalty_val : float (np.nan) 

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

494 """ 

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

496 penalty_val=np.nan): 

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

498 

499 self.filtername = filtername 

500 self.penalty_val = penalty_val 

501 

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

503 self.nside = nside 

504 

505 self.survey_features = dict() 

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

507 

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

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

510 if indx is None: 

511 indx = np.arange(result.size) 

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

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

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

515 return result 

516 

517 

518class Near_sun_twilight_basis_function(Base_basis_function): 

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

520 

521 Parameters 

522 ---------- 

523 max_airmass : float (2.5) 

524 The maximum airmass to try and observe (unitless) 

525 """ 

526 

527 def __init__(self, nside=None, max_airmass=2.5): 

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

529 self.max_airmass = int_rounded(max_airmass) 

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

531 

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

533 result = self.result.copy() 

534 good_pix = np.where((int_rounded(conditions.airmass) < self.max_airmass) & 

535 (int_rounded(conditions.az_to_sun) < int_rounded(np.pi/2.))) 

536 result[good_pix] = conditions.airmass[good_pix] / self.max_airmass.value 

537 return result 

538 

539 

540class Visit_repeat_basis_function(Base_basis_function): 

541 """ 

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

543 

544 Parameters 

545 ---------- 

546 gap_min : float (15.) 

547 Minimum time for the gap (minutes) 

548 gap_max : float (45.) 

549 Maximum time for a gap 

550 filtername : str ('r') 

551 The filter(s) to count with pairs 

552 npairs : int (1) 

553 The number of pairs of observations to attempt to gather 

554 """ 

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

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

557 

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

559 

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

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

562 self.npairs = npairs 

563 

564 self.survey_features = {} 

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

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

567 gap_min=gap_min, gap_max=gap_max, 

568 nside=nside) 

569 # When was it last observed 

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

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

572 nside=nside) 

573 

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

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

576 if indx is None: 

577 indx = np.arange(result.size) 

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

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

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

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

582 return result 

583 

584 

585class M5_diff_basis_function(Base_basis_function): 

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

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

588 the limiting depth difference given current conditions 

589 """ 

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

591 

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

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

594 m5p = M5percentiles() 

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

596 

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

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

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

600 return result 

601 

602 

603class Strict_filter_basis_function(Base_basis_function): 

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

605 

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

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

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

609 

610 Paramters 

611 --------- 

612 time_lag : float (10.) 

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

614 twi_change : float (-18.) 

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

616 note_free : str ('DD') 

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

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

619 """ 

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

621 

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

623 

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

625 self.twi_change = np.radians(twi_change) 

626 

627 self.survey_features = {} 

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

629 self.note_free = note_free 

630 

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

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

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

634 

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

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

637 

638 # Has enough time past? 

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

640 

641 # Did twilight start/end? 

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

643 

644 # Did we just finish a DD sequence 

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

646 

647 # Is the filter mounted? 

648 mounted = self.filtername in conditions.mounted_filters 

649 

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

651 result = 1. 

652 else: 

653 result = 0. 

654 

655 return result 

656 

657 

658class Goal_Strict_filter_basis_function(Base_basis_function): 

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

660 

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

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

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

664 

665 Parameters 

666 --------- 

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

668 be denied at all (see unseen_before_lag). 

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

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

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

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

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

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

675 twi_change: Switch reward on when twilight changes. 

676 proportion: The expected filter proportion distribution. 

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

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

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

680 

681 """ 

682 

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

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

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

686 

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

688 

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

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

691 self.time_lag_boost = time_lag_boost / 60. / 24. 

692 self.boost_gain = boost_gain 

693 self.unseen_before_lag = unseen_before_lag 

694 

695 self.twi_change = np.radians(twi_change) 

696 self.proportion = proportion 

697 self.aways_available = aways_available 

698 

699 self.survey_features = {} 

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

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

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

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

704 tag=tag) 

705 

706 def filter_change_bonus(self, time): 

707 

708 lag_min = self.time_lag_min 

709 lag_max = self.time_lag_max 

710 

711 a = 1. / (lag_max - lag_min) 

712 b = -a * lag_min 

713 

714 bonus = a * time + b 

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

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

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

718 goal = self.proportion 

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

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

721 # need /= goal 

722 if hasattr(time, '__iter__'): 

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

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

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

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

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

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

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

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

731 

732 return bonus * need 

733 

734 def check_feasibility(self, conditions): 

735 """ 

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

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

738 

739 :return: 

740 """ 

741 

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

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

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

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

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

747 return True 

748 

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

750 

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

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

753 

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

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

756 

757 # Has enough time past? 

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

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

760 

761 # Did twilight start/end? 

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

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

764 

765 # Did we just finish a DD sequence 

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

767 

768 # Is the filter mounted? 

769 mounted = self.filtername in conditions.mounted_filters 

770 

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

772 return True 

773 else: 

774 return False 

775 

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

777 

778 if conditions.current_filter is None: 

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

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

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

782 

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

784 moon_changed = conditions.moonAlt * \ 

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

786 

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

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

789 

790 # Has enough time past? 

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

792 time_past = lag > self.time_lag_min 

793 

794 # Did twilight start/end? 

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

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

797 

798 # Did we just finish a DD sequence 

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

800 

801 # Is the filter mounted? 

802 mounted = self.filtername in conditions.mounted_filters 

803 

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

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

806 else: 

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

808 

809 return result 

810 

811 

812class Filter_change_basis_function(Base_basis_function): 

813 """Reward staying in the current filter. 

814 """ 

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

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

817 

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

819 

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

821 result = 1. 

822 else: 

823 result = 0. 

824 return result 

825 

826 

827class Slewtime_basis_function(Base_basis_function): 

828 """Reward slews that take little time 

829 

830 Parameters 

831 ---------- 

832 max_time : float (135) 

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

834 spans ~ -1-0 in reward units. 

835 """ 

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

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

838 

839 self.maxtime = max_time 

840 self.nside = nside 

841 self.filtername = filtername 

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

843 

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

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

846 pass 

847 

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

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

850 if conditions.current_filter != self.filtername: 

851 result = 0 

852 else: 

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

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

855 result = self.result.copy() 

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

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

858 else: 

859 result = -conditions.slewtime/self.maxtime 

860 return result 

861 

862 

863class Aggressive_Slewtime_basis_function(Base_basis_function): 

864 """Reward slews that take little time 

865 

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

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

868 """ 

869 

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

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

872 

873 self.maxtime = max_time 

874 self.hard_max = hard_max 

875 self.order = order 

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

877 

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

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

880 if conditions.current_filter != self.filtername: 

881 result = 0. 

882 else: 

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

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

885 result = self.result.copy() 

886 result.fill(np.nan) 

887 

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

889 conditions.slewtime < self.maxtime)) 

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

891 self.maxtime) ** self.order 

892 if self.hard_max is not None: 

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

894 result[not_so_good] -= 10. 

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

896 for field in fields: 

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

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

899 else: 

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

901 return result 

902 

903 

904class Skybrightness_limit_basis_function(Base_basis_function): 

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

906 

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

908 

909 Parameters 

910 ---------- 

911 min : float (20.) 

912 The minimum sky brightness (mags). 

913 max : float (30.) 

914 The maximum sky brightness (mags). 

915 

916 """ 

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

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

919 

920 self.min = int_rounded(sbmin) 

921 self.max = int_rounded(sbmax) 

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

923 self.result.fill(np.nan) 

924 

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

926 result = self.result.copy() 

927 

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

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

930 result[good] = 1.0 

931 

932 return result 

933 

934 

935class CableWrap_unwrap_basis_function(Base_basis_function): 

936 """ 

937 Parameters 

938 ---------- 

939 minAz : float (20.) 

940 The minimum azimuth to activate bf (degrees) 

941 maxAz : float (82.) 

942 The maximum azimuth to activate bf (degrees) 

943 unwrap_until: float (90.) 

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

945 """ 

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

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

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

949 

950 self.minAz = np.radians(minAz) 

951 self.maxAz = np.radians(maxAz) 

952 

953 self.activate_tol = np.radians(activate_tol) 

954 self.delta_unwrap = np.radians(delta_unwrap) 

955 self.unwrap_until = np.radians(unwrap_until) 

956 

957 self.minAlt = np.radians(minAlt) 

958 self.maxAlt = np.radians(maxAlt) 

959 # Convert to half-width for convienence 

960 self.nside = nside 

961 self.active = False 

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

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

964 self.activation_time = None 

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

966 

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

968 

969 result = self.result.copy() 

970 

971 current_abs_rad = np.radians(conditions.az) 

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

973 conditions.alt > self.maxAlt)) 

974 result[unseen] = np.nan 

975 

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

977 return result 

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

979 self.active = False 

980 self.unwrap_direction = 0. 

981 self.activation_time = None 

982 return result 

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

984 self.active = False 

985 self.unwrap_direction = 0. 

986 self.activation_time = None 

987 return result 

988 elif (self.activation_time is not None and 

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

990 self.active = False 

991 self.unwrap_direction = 0. 

992 self.activation_time = None 

993 return result 

994 

995 if not self.active: 

996 self.activation_time = conditions.mjd 

997 if current_abs_rad < 0.: 

998 self.unwrap_direction = 1 # clock-wise unwrap 

999 else: 

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

1001 

1002 self.active = True 

1003 

1004 max_abs_rad = self.maxAz 

1005 min_abs_rad = self.minAz 

1006 

1007 TWOPI = 2.*np.pi 

1008 

1009 # Compute distance and accumulated az. 

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

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

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

1013 distance_rad[get_shorter] -= TWOPI 

1014 accum_abs_rad = current_abs_rad + distance_rad 

1015 

1016 # Compute wrap regions and fix distances 

1017 mask_max = np.where(accum_abs_rad > max_abs_rad) 

1018 distance_rad[mask_max] -= TWOPI 

1019 mask_min = np.where(accum_abs_rad < min_abs_rad) 

1020 distance_rad[mask_min] += TWOPI 

1021 

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

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

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

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

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

1027 unwrap_distance_rad[unwrap_get_shorter] -= TWOPI 

1028 unwrap_distance_rad = np.abs(unwrap_distance_rad) 

1029 

1030 if self.unwrap_direction < 0: 

1031 mask = np.where(accum_abs_rad > unwrap_current_abs_rad) 

1032 else: 

1033 mask = np.where(accum_abs_rad < unwrap_current_abs_rad) 

1034 

1035 # Finally build reward map 

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

1037 result[mask] = 0. 

1038 result[unseen] = np.nan 

1039 

1040 return result 

1041 

1042 

1043class Cadence_enhance_basis_function(Base_basis_function): 

1044 """Drive a certain cadence 

1045 Parameters 

1046 ---------- 

1047 filtername : str ('gri') 

1048 The filter(s) that should be grouped together 

1049 supress_window : list of float 

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

1051 apply_area : healpix map 

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

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

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

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

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

1057 apply_area=None): 

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

1059 

1060 self.supress_window = np.sort(supress_window) 

1061 self.supress_val = supress_val 

1062 self.enhance_window = np.sort(enhance_window) 

1063 self.enhance_val = enhance_val 

1064 

1065 self.survey_features = {} 

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

1067 

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

1069 # No map, try to drive the whole area 

1070 if apply_area is None: 

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

1072 else: 

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

1074 

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

1076 # copy an empty array 

1077 result = self.empty.copy() 

1078 if indx is not None: 

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

1080 else: 

1081 ind = self.apply_indx 

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

1083 result = 0 

1084 else: 

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

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

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

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

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

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

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

1092 return result 

1093 

1094 

1095class Azimuth_basis_function(Base_basis_function): 

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

1097 

1098 Parameters 

1099 ---------- 

1100 

1101 """ 

1102 

1103 def __init__(self, nside=None): 

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

1105 

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

1107 az_dist = conditions.az - conditions.telAz 

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

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

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

1111 # Normalize sp between 0 and 1 

1112 result = az_dist/np.pi 

1113 return result 

1114 

1115 

1116class Az_modulo_basis_function(Base_basis_function): 

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

1118 

1119 Parameters 

1120 ---------- 

1121 az_limits : list of float pairs (None) 

1122 The azimuth limits (degrees) to use. 

1123 """ 

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

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

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

1127 if az_limits is None: 

1128 spread = 100./2. 

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

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

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

1132 else: 

1133 self.az_limits = np.radians(az_limits) 

1134 self.mod_val = len(self.az_limits) 

1135 self.out_of_bounds_val = out_of_bounds_val 

1136 

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

1138 result = self.result.copy() 

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

1140 

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

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

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

1144 else: 

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

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

1147 result[out_pix] = self.out_of_bounds_val 

1148 return result 

1149 

1150 

1151class Dec_modulo_basis_function(Base_basis_function): 

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

1153 

1154 Parameters 

1155 ---------- 

1156 dec_limits : list of float pairs (None) 

1157 The azimuth limits (degrees) to use. 

1158 """ 

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

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

1161 

1162 npix = hp.nside2npix(nside) 

1163 hpids = np.arange(npix) 

1164 ra, dec = _hpid2RaDec(nside, hpids) 

1165 

1166 self.results = [] 

1167 

1168 if dec_limits is None: 

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

1170 [-32.8, -12.], 

1171 [-12., 35.]]) 

1172 else: 

1173 self.dec_limits = np.radians(dec_limits) 

1174 self.mod_val = len(self.dec_limits) 

1175 self.out_of_bounds_val = out_of_bounds_val 

1176 

1177 for limits in self.dec_limits: 

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

1179 tmp = np.zeros(npix) 

1180 tmp[good] = 1 

1181 self.results.append(tmp) 

1182 

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

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

1185 result = self.results[night_index] 

1186 

1187 return result 

1188 

1189 

1190class Map_modulo_basis_function(Base_basis_function): 

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

1192 

1193 Parameters 

1194 ---------- 

1195 inmaps : list of hp arrays 

1196 """ 

1197 def __init__(self, inmaps): 

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

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

1200 self.maps = inmaps 

1201 self.mod_val = len(inmaps) 

1202 

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

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

1205 result = self.maps[indx] 

1206 return result 

1207 

1208 

1209class Good_seeing_basis_function(Base_basis_function): 

1210 """Drive observations in good seeing conditions""" 

1211 

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

1213 mag_diff=0.75): 

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

1215 

1216 self.filtername = filtername 

1217 self.FWHMeff_limit = int_rounded(FWHMeff_limit) 

1218 if footprint is None: 

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

1220 else: 

1221 fp = footprint 

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

1223 self.result = fp*0 

1224 

1225 self.mag_diff = int_rounded(mag_diff) 

1226 self.survey_features = {} 

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

1228 nside=nside) 

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

1230 nside=nside, 

1231 FWHMeff_limit=FWHMeff_limit) 

1232 

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

1234 # Seeing is "bad" 

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

1236 return 0 

1237 result = self.result.copy() 

1238 

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

1240 # Where are there things we want to observe? 

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

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

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

1244 result[good_pix] = diff[good_pix] 

1245 result[self.out_of_bounds] = 0 

1246 

1247 return result 

1248 

1249 

1250class Template_generate_basis_function(Base_basis_function): 

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

1252 

1253 Parameters 

1254 ---------- 

1255 day_gap : float (250.) 

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

1257 footprint : np.array (None) 

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

1259 """ 

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

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

1262 self.day_gap = day_gap 

1263 self.filtername = filtername 

1264 self.survey_features = {} 

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

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

1267 if footprint is None: 

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

1269 else: 

1270 fp = footprint 

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

1272 

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

1274 result = self.result.copy() 

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

1276 result[overdue] = 1 

1277 result[self.out_of_bounds] = 0 

1278 

1279 return result 

1280 

1281 

1282class Observed_twice_basis_function(Base_basis_function): 

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

1284 """ 

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

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

1287 self.n_obs_needed = n_obs_needed 

1288 self.n_obs_in_filt_needed = n_obs_in_filt_needed 

1289 self.filtername = filtername 

1290 self.survey_features = {} 

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

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

1293 

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

1295 

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

1297 result = self.result.copy() 

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

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

1300 result[good_pix] = 1 

1301 

1302 return result