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, **kwarge): 

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

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

562 self.max_airmass = int_rounded(max_airmass) 

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

564 

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

566 result = self.result.copy() 

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

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

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

570 return result 

571 

572 

573class Visit_repeat_basis_function(Base_basis_function): 

574 """ 

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

576 

577 Parameters 

578 ---------- 

579 gap_min : float (15.) 

580 Minimum time for the gap (minutes) 

581 gap_max : float (45.) 

582 Maximum time for a gap 

583 filtername : str ('r') 

584 The filter(s) to count with pairs 

585 npairs : int (1) 

586 The number of pairs of observations to attempt to gather 

587 """ 

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

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

590 

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

592 

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

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

595 self.npairs = npairs 

596 

597 self.survey_features = {} 

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

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

600 gap_min=gap_min, gap_max=gap_max, 

601 nside=nside) 

602 # When was it last observed 

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

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

605 nside=nside) 

606 

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

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

609 if indx is None: 

610 indx = np.arange(result.size) 

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

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

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

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

615 return result 

616 

617 

618class M5_diff_basis_function(Base_basis_function): 

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

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

621 the limiting depth difference given current conditions 

622 """ 

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

624 

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

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

627 m5p = M5percentiles() 

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

629 

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

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

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

633 return result 

634 

635 

636class Strict_filter_basis_function(Base_basis_function): 

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

638 

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

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

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

642 

643 Paramters 

644 --------- 

645 time_lag : float (10.) 

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

647 twi_change : float (-18.) 

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

649 note_free : str ('DD') 

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

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

652 """ 

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

654 

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

656 

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

658 self.twi_change = np.radians(twi_change) 

659 

660 self.survey_features = {} 

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

662 self.note_free = note_free 

663 

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

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

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

667 

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

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

670 

671 # Has enough time past? 

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

673 

674 # Did twilight start/end? 

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

676 

677 # Did we just finish a DD sequence 

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

679 

680 # Is the filter mounted? 

681 mounted = self.filtername in conditions.mounted_filters 

682 

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

684 result = 1. 

685 else: 

686 result = 0. 

687 

688 return result 

689 

690 

691class Goal_Strict_filter_basis_function(Base_basis_function): 

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

693 

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

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

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

697 

698 Parameters 

699 --------- 

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

701 be denied at all (see unseen_before_lag). 

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

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

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

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

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

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

708 twi_change: Switch reward on when twilight changes. 

709 proportion: The expected filter proportion distribution. 

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

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

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

713 

714 """ 

715 

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

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

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

719 

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

721 

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

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

724 self.time_lag_boost = time_lag_boost / 60. / 24. 

725 self.boost_gain = boost_gain 

726 self.unseen_before_lag = unseen_before_lag 

727 

728 self.twi_change = np.radians(twi_change) 

729 self.proportion = proportion 

730 self.aways_available = aways_available 

731 

732 self.survey_features = {} 

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

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

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

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

737 tag=tag) 

738 

739 def filter_change_bonus(self, time): 

740 

741 lag_min = self.time_lag_min 

742 lag_max = self.time_lag_max 

743 

744 a = 1. / (lag_max - lag_min) 

745 b = -a * lag_min 

746 

747 bonus = a * time + b 

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

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

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

751 goal = self.proportion 

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

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

754 # need /= goal 

755 if hasattr(time, '__iter__'): 

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

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

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

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

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

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

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

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

764 

765 return bonus * need 

766 

767 def check_feasibility(self, conditions): 

768 """ 

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

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

771 

772 :return: 

773 """ 

774 

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

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

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

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

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

780 return True 

781 

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

783 

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

785 moon_changed = conditions.moonAlt * 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 = (conditions.current_filter != self.filtername) 

789 

790 # Has enough time past? 

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

792 time_past = int_rounded(lag) > int_rounded(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 & not_in_filter: 

805 return True 

806 else: 

807 return False 

808 

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

810 

811 if conditions.current_filter is None: 

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

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

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

815 

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

817 moon_changed = conditions.moonAlt * \ 

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

819 

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

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

822 

823 # Has enough time past? 

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

825 time_past = lag > self.time_lag_min 

826 

827 # Did twilight start/end? 

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

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

830 

831 # Did we just finish a DD sequence 

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

833 

834 # Is the filter mounted? 

835 mounted = self.filtername in conditions.mounted_filters 

836 

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

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

839 else: 

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

841 

842 return result 

843 

844 

845class Filter_change_basis_function(Base_basis_function): 

846 """Reward staying in the current filter. 

847 """ 

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

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

850 

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

852 

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

854 result = 1. 

855 else: 

856 result = 0. 

857 return result 

858 

859 

860class Slewtime_basis_function(Base_basis_function): 

861 """Reward slews that take little time 

862 

863 Parameters 

864 ---------- 

865 max_time : float (135) 

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

867 spans ~ -1-0 in reward units. 

868 """ 

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

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

871 

872 self.maxtime = max_time 

873 self.nside = nside 

874 self.filtername = filtername 

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

876 

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

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

879 pass 

880 

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

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

883 if conditions.current_filter != self.filtername: 

884 result = 0 

885 else: 

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

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

888 result = self.result.copy() 

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

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

891 else: 

892 result = -conditions.slewtime/self.maxtime 

893 return result 

894 

895 

896class Aggressive_Slewtime_basis_function(Base_basis_function): 

897 """Reward slews that take little time 

898 

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

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

901 """ 

902 

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

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

905 

906 self.maxtime = max_time 

907 self.hard_max = hard_max 

908 self.order = order 

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

910 

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

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

913 if conditions.current_filter != self.filtername: 

914 result = 0. 

915 else: 

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

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

918 result = self.result.copy() 

919 result.fill(np.nan) 

920 

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

922 conditions.slewtime < self.maxtime)) 

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

924 self.maxtime) ** self.order 

925 if self.hard_max is not None: 

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

927 result[not_so_good] -= 10. 

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

929 for field in fields: 

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

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

932 else: 

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

934 return result 

935 

936 

937class Skybrightness_limit_basis_function(Base_basis_function): 

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

939 

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

941 

942 Parameters 

943 ---------- 

944 min : float (20.) 

945 The minimum sky brightness (mags). 

946 max : float (30.) 

947 The maximum sky brightness (mags). 

948 

949 """ 

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

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

952 

953 self.min = int_rounded(sbmin) 

954 self.max = int_rounded(sbmax) 

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

956 self.result.fill(np.nan) 

957 

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

959 result = self.result.copy() 

960 

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

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

963 result[good] = 1.0 

964 

965 return result 

966 

967 

968class CableWrap_unwrap_basis_function(Base_basis_function): 

969 """ 

970 Parameters 

971 ---------- 

972 minAz : float (20.) 

973 The minimum azimuth to activate bf (degrees) 

974 maxAz : float (82.) 

975 The maximum azimuth to activate bf (degrees) 

976 unwrap_until: float (90.) 

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

978 """ 

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

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

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

982 

983 self.minAz = np.radians(minAz) 

984 self.maxAz = np.radians(maxAz) 

985 

986 self.activate_tol = np.radians(activate_tol) 

987 self.delta_unwrap = np.radians(delta_unwrap) 

988 self.unwrap_until = np.radians(unwrap_until) 

989 

990 self.minAlt = np.radians(minAlt) 

991 self.maxAlt = np.radians(maxAlt) 

992 # Convert to half-width for convienence 

993 self.nside = nside 

994 self.active = False 

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

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

997 self.activation_time = None 

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

999 

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

1001 

1002 result = self.result.copy() 

1003 

1004 current_abs_rad = np.radians(conditions.az) 

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

1006 conditions.alt > self.maxAlt)) 

1007 result[unseen] = np.nan 

1008 

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

1010 return result 

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

1012 self.active = False 

1013 self.unwrap_direction = 0. 

1014 self.activation_time = None 

1015 return result 

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

1017 self.active = False 

1018 self.unwrap_direction = 0. 

1019 self.activation_time = None 

1020 return result 

1021 elif (self.activation_time is not None and 

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

1023 self.active = False 

1024 self.unwrap_direction = 0. 

1025 self.activation_time = None 

1026 return result 

1027 

1028 if not self.active: 

1029 self.activation_time = conditions.mjd 

1030 if current_abs_rad < 0.: 

1031 self.unwrap_direction = 1 # clock-wise unwrap 

1032 else: 

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

1034 

1035 self.active = True 

1036 

1037 max_abs_rad = self.maxAz 

1038 min_abs_rad = self.minAz 

1039 

1040 TWOPI = 2.*np.pi 

1041 

1042 # Compute distance and accumulated az. 

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

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

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

1046 distance_rad[get_shorter] -= TWOPI 

1047 accum_abs_rad = current_abs_rad + distance_rad 

1048 

1049 # Compute wrap regions and fix distances 

1050 mask_max = np.where(accum_abs_rad > max_abs_rad) 

1051 distance_rad[mask_max] -= TWOPI 

1052 mask_min = np.where(accum_abs_rad < min_abs_rad) 

1053 distance_rad[mask_min] += TWOPI 

1054 

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

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

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

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

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

1060 unwrap_distance_rad[unwrap_get_shorter] -= TWOPI 

1061 unwrap_distance_rad = np.abs(unwrap_distance_rad) 

1062 

1063 if self.unwrap_direction < 0: 

1064 mask = np.where(accum_abs_rad > unwrap_current_abs_rad) 

1065 else: 

1066 mask = np.where(accum_abs_rad < unwrap_current_abs_rad) 

1067 

1068 # Finally build reward map 

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

1070 result[mask] = 0. 

1071 result[unseen] = np.nan 

1072 

1073 return result 

1074 

1075 

1076class Cadence_enhance_basis_function(Base_basis_function): 

1077 """Drive a certain cadence 

1078 Parameters 

1079 ---------- 

1080 filtername : str ('gri') 

1081 The filter(s) that should be grouped together 

1082 supress_window : list of float 

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

1084 apply_area : healpix map 

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

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

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

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

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

1090 apply_area=None): 

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

1092 

1093 self.supress_window = np.sort(supress_window) 

1094 self.supress_val = supress_val 

1095 self.enhance_window = np.sort(enhance_window) 

1096 self.enhance_val = enhance_val 

1097 

1098 self.survey_features = {} 

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

1100 

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

1102 # No map, try to drive the whole area 

1103 if apply_area is None: 

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

1105 else: 

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

1107 

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

1109 # copy an empty array 

1110 result = self.empty.copy() 

1111 if indx is not None: 

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

1113 else: 

1114 ind = self.apply_indx 

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

1116 result = 0 

1117 else: 

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

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

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

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

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

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

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

1125 return result 

1126 

1127 

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

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

1130 """One dimensional Trapezoid model function""" 

1131 # Compute the four points where the trapezoid changes slope 

1132 # x1 <= x2 <= x3 <= x4 

1133 x2 = x_0 - width / 2. 

1134 x3 = x_0 + width / 2. 

1135 x1 = x2 - amplitude / slope 

1136 x4 = x3 + amplitude / slope 

1137 

1138 result = x*0 

1139 

1140 # Compute model values in pieces between the change points 

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

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

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

1144 

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

1146 result[range_b] = amplitude 

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

1148 

1149 return result 

1150 

1151 

1152class Cadence_enhance_trapezoid_basis_function(Base_basis_function): 

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

1154 Parameters 

1155 ---------- 

1156 filtername : str ('gri') 

1157 The filter(s) that should be grouped together 

1158 

1159 XXX--fill out doc string! 

1160 """ 

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

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

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

1164 apply_area=None, season_limit=None): 

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

1166 

1167 self.delay_width = delay_width 

1168 self.delay_slope = delay_slope 

1169 self.delay_peak = delay_peak 

1170 self.delay_amp = delay_amp 

1171 self.enhance_width = enhance_width 

1172 self.enhance_slope = enhance_slope 

1173 self.enhance_peak = enhance_peak 

1174 self.enhance_amp = enhance_amp 

1175 

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

1177 

1178 self.survey_features = {} 

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

1180 

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

1182 # No map, try to drive the whole area 

1183 if apply_area is None: 

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

1185 else: 

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

1187 

1188 def suppress_enhance(self, x): 

1189 result = x*0 

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

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

1192 

1193 return result 

1194 

1195 def season_calc(self, conditions): 

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

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

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

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

1200 

1201 return angle_to_mid_season 

1202 

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

1204 # copy an empty array 

1205 result = self.empty.copy() 

1206 if indx is not None: 

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

1208 else: 

1209 ind = self.apply_indx 

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

1211 result = 0 

1212 else: 

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

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

1215 

1216 if self.season_limit is not None: 

1217 radians_to_midseason = self.season_calc(conditions) 

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

1219 result[outside_season] = 0 

1220 

1221 

1222 return result 

1223 

1224 

1225class Azimuth_basis_function(Base_basis_function): 

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

1227 

1228 Parameters 

1229 ---------- 

1230 

1231 """ 

1232 

1233 def __init__(self, nside=None): 

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

1235 

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

1237 az_dist = conditions.az - conditions.telAz 

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

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

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

1241 # Normalize sp between 0 and 1 

1242 result = az_dist/np.pi 

1243 return result 

1244 

1245 

1246class Az_modulo_basis_function(Base_basis_function): 

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

1248 

1249 Parameters 

1250 ---------- 

1251 az_limits : list of float pairs (None) 

1252 The azimuth limits (degrees) to use. 

1253 """ 

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

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

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

1257 if az_limits is None: 

1258 spread = 100./2. 

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

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

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

1262 else: 

1263 self.az_limits = np.radians(az_limits) 

1264 self.mod_val = len(self.az_limits) 

1265 self.out_of_bounds_val = out_of_bounds_val 

1266 

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

1268 result = self.result.copy() 

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

1270 

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

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

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

1274 else: 

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

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

1277 result[out_pix] = self.out_of_bounds_val 

1278 return result 

1279 

1280 

1281class Dec_modulo_basis_function(Base_basis_function): 

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

1283 

1284 Parameters 

1285 ---------- 

1286 dec_limits : list of float pairs (None) 

1287 The azimuth limits (degrees) to use. 

1288 """ 

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

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

1291 

1292 npix = hp.nside2npix(nside) 

1293 hpids = np.arange(npix) 

1294 ra, dec = _hpid2RaDec(nside, hpids) 

1295 

1296 self.results = [] 

1297 

1298 if dec_limits is None: 

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

1300 [-32.8, -12.], 

1301 [-12., 35.]]) 

1302 else: 

1303 self.dec_limits = np.radians(dec_limits) 

1304 self.mod_val = len(self.dec_limits) 

1305 self.out_of_bounds_val = out_of_bounds_val 

1306 

1307 for limits in self.dec_limits: 

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

1309 tmp = np.zeros(npix) 

1310 tmp[good] = 1 

1311 self.results.append(tmp) 

1312 

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

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

1315 result = self.results[night_index] 

1316 

1317 return result 

1318 

1319 

1320class Map_modulo_basis_function(Base_basis_function): 

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

1322 

1323 Parameters 

1324 ---------- 

1325 inmaps : list of hp arrays 

1326 """ 

1327 def __init__(self, inmaps): 

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

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

1330 self.maps = inmaps 

1331 self.mod_val = len(inmaps) 

1332 

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

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

1335 result = self.maps[indx] 

1336 return result 

1337 

1338 

1339class Good_seeing_basis_function(Base_basis_function): 

1340 """Drive observations in good seeing conditions""" 

1341 

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

1343 mag_diff=0.75): 

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

1345 

1346 self.filtername = filtername 

1347 self.FWHMeff_limit = int_rounded(FWHMeff_limit) 

1348 if footprint is None: 

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

1350 else: 

1351 fp = footprint 

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

1353 self.result = fp*0 

1354 

1355 self.mag_diff = int_rounded(mag_diff) 

1356 self.survey_features = {} 

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

1358 nside=nside) 

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

1360 nside=nside, 

1361 FWHMeff_limit=FWHMeff_limit) 

1362 

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

1364 # Seeing is "bad" 

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

1366 return 0 

1367 result = self.result.copy() 

1368 

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

1370 # Where are there things we want to observe? 

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

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

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

1374 result[good_pix] = diff[good_pix] 

1375 result[self.out_of_bounds] = 0 

1376 

1377 return result 

1378 

1379 

1380class Template_generate_basis_function(Base_basis_function): 

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

1382 

1383 Parameters 

1384 ---------- 

1385 day_gap : float (250.) 

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

1387 footprint : np.array (None) 

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

1389 """ 

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

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

1392 self.day_gap = day_gap 

1393 self.filtername = filtername 

1394 self.survey_features = {} 

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

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

1397 if footprint is None: 

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

1399 else: 

1400 fp = footprint 

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

1402 

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

1404 result = self.result.copy() 

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

1406 result[overdue] = 1 

1407 result[self.out_of_bounds] = 0 

1408 

1409 return result 

1410 

1411 

1412class Observed_twice_basis_function(Base_basis_function): 

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

1414 """ 

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

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

1417 self.n_obs_needed = n_obs_needed 

1418 self.n_obs_in_filt_needed = n_obs_in_filt_needed 

1419 self.filtername = filtername 

1420 self.survey_features = {} 

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

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

1423 

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

1425 

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

1427 result = self.result.copy() 

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

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

1430 result[good_pix] = 1 

1431 

1432 return result