Source code for rubin_scheduler.scheduler.model_observatory.model_observatory

__all__ = ("ModelObservatory",)

import copy
import warnings

import healpy as hp
import numpy as np
from astropy.coordinates import EarthLocation
from astropy.time import Time

import rubin_scheduler.skybrightness_pre as sb
from rubin_scheduler.data import data_versions
from rubin_scheduler.scheduler.features import Conditions
from rubin_scheduler.scheduler.model_observatory import KinemModel

# For backwards compatibility
from rubin_scheduler.site_models import (
    Almanac,
    CloudData,
    ConstantCloudData,
    ConstantSeeingData,
    ScheduledDowntimeData,
    SeeingData,
    SeeingModel,
    UnscheduledDowntimeMoreY1Data,
)
from rubin_scheduler.utils import (
    DEFAULT_NSIDE,
    SURVEY_START_MJD,
    Site,
    _angular_separation,
    _approx_altaz2pa,
    _approx_ra_dec2_alt_az,
    _hpid2_ra_dec,
    _ra_dec2_hpid,
    calc_lmst,
    calc_season,
    m5_flat_sed,
    rotation_converter,
)


[docs] class ModelObservatory: """Generate a realistic telemetry stream for the scheduler in simulations, including simulating the acquisition of observations. Parameters ---------- nside : `int`, optional The healpix nside resolution. Default None uses `set_default_nside()`. mjd : `float`, optional The MJD to start the model observatory for observations. Used to set current conditions and load sky. Default None uses mjd_start. mjd_start : `float`, optional The MJD of the start of the survey. This must be set to start of whole survey, for tracking purposes. Default None uses `survey_start_mjd()`. alt_min : `float`, optional The minimum altitude to compute models at (degrees). lax_dome : `bool`, optional Passed to observatory model. If true, allows dome creep. cloud_limit : `float`, optional. The limit to stop taking observations if the cloud model returns something equal or higher. Default of 0.3 is validated as a "somewhat pessimistic" weather downtime choice. sim_to_o : `sim_targetoO` object, optional If one would like to inject simulated ToOs into the telemetry stream. Default None adds no ToOs. park_after : `float`, optional Park the telescope after a gap longer than park_after (minutes). Default 10 minutes is used to park the telescope during downtime. init_load_length : `int`, optional The length of pre-scheduled sky brightness values to load initially (days). The default is 10 days; shorter values can be used for quicker load times. kinem_model : `~.scheduler.model_observatory.Kinem_model`, optional An instantiated rubin_scheduler Kinem_model object. Default of None uses a default Kinem_model. seeing_db : `str`, optional The filename of the seeing data database, if one would like to use an alternate seeing database. Default None uses the default seeing database. seeing_data : `~.site_models.SeeingData`-like, optional If one wants to replace the default seeing_data object. Should be an object with a __call__ method that takes MJD and returns zenith fwhm_500 in arcsec. Set to "ideal" to have constant 0.7" seeing. cloud_db : `str`, optional The filename of the cloud data database. Default of None uses the default database from rubin_sim_data. cloud_offset_year : `int`, optional The year offset to be passed to CloudData. Default 0. cloud_data : `~.site_models.CloudData`-like, optional If one wants to replace the default cloud data. Should be an object with a __call__ method that takes MJD and returns cloudy level. Set to "ideal" for no clouds. downtimes : `np.ndarray`, (N,3) or None, optional If one wants to replace the default downtimes. Should be a np.array with columns of "start" and "end" with MJD values and should include both scheduled and unscheduled downtime. Set to "ideal" for no downtime. Default of None will use the downtime models from `~.site_models.ScheduledDowntime` and `~.site_models.UnscheduledDowntime`. no_sky : `bool`, optional If True, then don't load any skybrightness files. Handy if one wants a well filled out Conditions object, but doesn't need the sky since that can be slower to load. Default False. wind_data : ~.site_models.WindData`-like, optional If one wants to replace the default wind_data object. Should be an object with a __call__ method that takes the time and returns a tuple with the wind speed (m/s) and originating direction (radians east of north). Default of None uses an idealized WindData object with no wind. starting_time_key : `str`, optional What key in the almanac to use to determine the start of observing on a night. Default "sun_n12_setting", e.g., sun at -12 degrees and setting. Other options are "sun_n18_setting" and "sunset". If surveys are not configured to wait until the sun is lower in the sky, observing will start as soon as the time passes the time returned by this key, in each night. ending_time_key : `str`, optional What key in the almanac to use to signify it is time to skip to the next night. Default "sun_n12_rising", e.g., sun at -12 degrees and rising. Other options are "sun_n18_rising" and "sunrise". sky_az_limits : `list` [[`float`, `float`]] A list of lists giving valid azimuth ranges. e.g., [0, 180] would mean all azimuth values are valid, while [[0, 90], [270, 360]] or [270, 90] would mean anywhere in the south is invalid. Degrees. sky_alt_limits : `list` [[`float`, `float`]] A list of lists giving valid altitude ranges. Degrees. For both the alt and az limits, if a particular alt (or az) value is included in any limit, it is valid for all of them. Altitude limits of [[20, 40], [40, 60]] would allow altitudes betwen 20-40 and 40-60, but [[20, 40], [40, 60], [20, 86]] will allow altitudes anywhere between 20-86 degrees. telescope : `str` Telescope name for rotation computations. Default "rubin". """ def __init__( self, nside=DEFAULT_NSIDE, mjd=None, mjd_start=SURVEY_START_MJD, alt_min=5.0, lax_dome=True, cloud_limit=0.3, sim_to_o=None, park_after=10.0, init_load_length=10, kinem_model=None, seeing_db=None, seeing_data=None, cloud_db=None, cloud_offset_year=0, cloud_data=None, downtimes=None, no_sky=False, wind_data=None, starting_time_key="sun_n12_setting", ending_time_key="sun_n12_rising", sky_alt_limits=None, sky_az_limits=None, telescope="rubin", ): self.nside = nside # Set the time now - mjd # and the time of the survey start self.mjd_start = mjd_start if mjd is None: mjd = mjd_start self.filterlist = ["u", "g", "r", "i", "z", "y"] self.cloud_limit = cloud_limit self.no_sky = no_sky self.alt_min = np.radians(alt_min) self.lax_dome = lax_dome self.starting_time_key = starting_time_key self.ending_time_key = ending_time_key self.sim__to_o = sim_to_o self.park_after = park_after / 60.0 / 24.0 # To days # Rotation converter self.rc = rotation_converter(telescope=telescope) # Create an astropy location self.site = Site("LSST") self.location = EarthLocation( lat=self.site.latitude, lon=self.site.longitude, height=self.site.height ) # Set up the almanac - use mjd_start to keep "night" count the same. self.almanac = Almanac(mjd_start=self.mjd_start) # Load up all the models we need # Use mjd_start to ensure models always initialize to the same # starting point in time. mjd_start_time = Time(self.mjd_start, format="mjd") # Set up the downtime if isinstance(downtimes, str): if downtimes == "ideal": self.downtimes = np.array( list(zip([], [])), dtype=list(zip(["start", "end"], [float, float])), ) else: warnings.warn("Downtimes should be a string equal to " "'ideal', an array or None") elif downtimes is None: self.down_nights = [] self.sched_downtime_data = ScheduledDowntimeData(mjd_start_time) self.unsched_downtime_data = UnscheduledDowntimeMoreY1Data(mjd_start_time) sched_downtimes = self.sched_downtime_data() unsched_downtimes = self.unsched_downtime_data() down_starts = [] down_ends = [] for dt in sched_downtimes: down_starts.append(dt["start"].mjd) down_ends.append(dt["end"].mjd) for dt in unsched_downtimes: down_starts.append(dt["start"].mjd) down_ends.append(dt["end"].mjd) self.downtimes = np.array( list(zip(down_starts, down_ends)), dtype=list(zip(["start", "end"], [float, float])), ) self.downtimes.sort(order="start") # Make sure there aren't any overlapping downtimes diff = self.downtimes["start"][1:] - self.downtimes["end"][0:-1] while np.min(diff) < 0: # Should be able to do this without a loop, but this works for i, dt in enumerate(self.downtimes[0:-1]): if self.downtimes["start"][i + 1] < dt["end"]: new_end = np.max([dt["end"], self.downtimes["end"][i + 1]]) self.downtimes[i]["end"] = new_end self.downtimes[i + 1]["end"] = new_end good = np.where(self.downtimes["end"] - np.roll(self.downtimes["end"], 1) != 0) self.downtimes = self.downtimes[good] diff = self.downtimes["start"][1:] - self.downtimes["end"][0:-1] else: self.downtimes = downtimes # Set the wind data self.wind_data = wind_data # Set up the seeing data if seeing_data == "ideal": self.seeing_data = ConstantSeeingData() elif seeing_data is not None: self.seeing_data = seeing_data else: self.seeing_data = SeeingData(mjd_start_time, seeing_db=seeing_db) self.seeing_model = SeeingModel() self.seeing_indx_dict = {} for i, filtername in enumerate(self.seeing_model.filter_list): self.seeing_indx_dict[filtername] = i self.seeing_fwhm_eff = {} for key in self.filterlist: self.seeing_fwhm_eff[key] = np.zeros(hp.nside2npix(self.nside), dtype=float) # Set up the cloud data if cloud_data == "ideal": self.cloud_data = ConstantCloudData() elif cloud_data is not None: self.cloud_data = cloud_data else: self.cloud_data = CloudData(mjd_start_time, cloud_db=cloud_db, offset_year=cloud_offset_year) # Set up the skybrightness if not self.no_sky: self.sky_model = sb.SkyModelPre(init_load_length=init_load_length) else: self.sky_model = None # Set up the kinematic model if kinem_model is None: self.observatory = KinemModel(mjd0=self.mjd_start) else: self.observatory = kinem_model # Pick up tel alt and az limits from kwargs if sky_alt_limits is not None: self.sky_alt_limits = np.radians(sky_alt_limits) else: self.sky_alt_limits = None if sky_az_limits is not None: self.sky_az_limits = np.radians(sky_az_limits) else: self.sky_az_limits = None # Add the observatory alt/az limits to the user-defined limits # But we do have to be careful that we're not overriding more # restrictive limits that were already set. # So we'll just keep these separate. self.tel_alt_limits = copy.deepcopy( [ self.observatory.telalt_minpos_rad, self.observatory.telalt_maxpos_rad, ] ) self.tel_az_limits = copy.deepcopy( [self.observatory.telaz_minpos_rad, self.observatory.telaz_maxpos_rad] ) # Each of these limits will be treated as hard limits that we don't # want pointings to stray into, so add a pad around the values self.altaz_limit_pad = np.radians(2.0) # Let's make sure we're at an openable MJD good_mjd = False to_set_mjd = mjd while not good_mjd: good_mjd, to_set_mjd = self.check_mjd(to_set_mjd) self.mjd = to_set_mjd # Create the map of the season offsets - this map is constant ra, dec = _hpid2_ra_dec(nside, np.arange(hp.nside2npix(self.nside))) ra_deg = np.degrees(ra) self.season_map = calc_season(ra_deg, [self.mjd_start], self.mjd_start).flatten() # Set the sun_ra_start information, for the rolling footprints sun_moon_info = self.almanac.get_sun_moon_positions(self.mjd_start) self.sun_ra_start = sun_moon_info["sun_RA"] + 0 # Conditions object to update and return on request # (at present, this is not updated -- recreated, below). self.conditions = Conditions( nside=self.nside, ) self.obs_id_counter = 0
[docs] def get_info(self): """ Returns ------- Array with model versions that were instantiated """ # Could add in the data version result = [] versions = data_versions() for key in versions: result.append([key, versions[key]]) return result
[docs] def return_conditions(self): """ Returns ------- rubin_scheduler.scheduler.features.conditions object """ self.conditions = Conditions( nside=self.nside, mjd=self.mjd, ) self.conditions.night = int(self.night) # Current time as astropy time current_time = Time(self.mjd, format="mjd") # Clouds. XXX--just the raw value self.conditions.bulk_cloud = self.cloud_data(current_time) # use conditions object itself to get approx altitude of each # healpx alts = self.conditions.alt azs = self.conditions.az good = np.where(alts > self.alt_min) # reset the seeing for key in self.seeing_fwhm_eff: self.seeing_fwhm_eff[key].fill(np.nan) # Use the model to get the seeing at this time and airmasses. fwhm_500 = self.seeing_data(current_time) self.fwhm_500 = fwhm_500 seeing_dict = self.seeing_model(fwhm_500, self.conditions.airmass[good]) fwhm_eff = seeing_dict["fwhmEff"] for i, key in enumerate(self.seeing_model.filter_list): self.seeing_fwhm_eff[key][good] = fwhm_eff[i, :] self.conditions.fwhm_eff = self.seeing_fwhm_eff # sky brightness if self.sky_model is not None: self.conditions.skybrightness = self.sky_model.return_mags(self.mjd) self.conditions.mounted_filters = self.observatory.mounted_filters self.conditions.current_filter = self.observatory.current_filter[0] # Compute the slewtimes slewtimes = np.empty(alts.size, dtype=float) slewtimes.fill(np.nan) # If there has been a gap, park the telescope gap = self.mjd - self.observatory.last_mjd if gap > self.park_after: self.observatory.park() slewtimes[good] = self.observatory.slew_times( 0.0, 0.0, self.mjd, alt_rad=alts[good], az_rad=azs[good], filtername=self.observatory.current_filter, lax_dome=self.lax_dome, update_tracking=False, ) self.conditions.slewtime = slewtimes # Let's get the sun and moon sun_moon_info = self.almanac.get_sun_moon_positions(self.mjd) # convert these to scalars for key in sun_moon_info: sun_moon_info[key] = sun_moon_info[key].max() self.conditions.moon_phase = sun_moon_info["moon_phase"] self.conditions.moon_alt = sun_moon_info["moon_alt"] self.conditions.moon_az = sun_moon_info["moon_az"] self.conditions.moon_ra = sun_moon_info["moon_RA"] self.conditions.moon_dec = sun_moon_info["moon_dec"] self.conditions.sun_alt = sun_moon_info["sun_alt"] self.conditions.sun_ra = sun_moon_info["sun_RA"] self.conditions.sun_dec = sun_moon_info["sun_dec"] self.conditions.lmst = calc_lmst(self.mjd, self.site.longitude_rad) self.conditions.tel_ra = self.observatory.current_ra_rad self.conditions.tel_dec = self.observatory.current_dec_rad self.conditions.tel_alt = self.observatory.last_alt_rad self.conditions.tel_az = self.observatory.last_az_rad self.conditions.rot_tel_pos = self.observatory.last_rot_tel_pos_rad self.conditions.cumulative_azimuth_rad = self.observatory.cumulative_azimuth_rad # Add in the almanac information self.conditions.sunset = self.almanac.sunsets["sunset"][self.almanac_indx] self.conditions.sun_n12_setting = self.almanac.sunsets["sun_n12_setting"][self.almanac_indx] self.conditions.sun_n18_setting = self.almanac.sunsets["sun_n18_setting"][self.almanac_indx] self.conditions.sun_n18_rising = self.almanac.sunsets["sun_n18_rising"][self.almanac_indx] self.conditions.sun_n12_rising = self.almanac.sunsets["sun_n12_rising"][self.almanac_indx] self.conditions.sunrise = self.almanac.sunsets["sunrise"][self.almanac_indx] self.conditions.moonrise = self.almanac.sunsets["moonrise"][self.almanac_indx] self.conditions.moonset = self.almanac.sunsets["moonset"][self.almanac_indx] sun_moon_info_start_of_night = self.almanac.get_sun_moon_positions(self.conditions.sunset) self.conditions.moon_phase_sunset = sun_moon_info_start_of_night["moon_phase"] # Telescope limits self.conditions.sky_az_limits = self.sky_az_limits self.conditions.sky_alt_limits = self.sky_alt_limits self.conditions.tel_alt_limits = self.tel_alt_limits self.conditions.tel_az_limits = self.tel_az_limits # Planet positions from almanac self.conditions.planet_positions = self.almanac.get_planet_positions(self.mjd) # See if there are any ToOs to include if self.sim__to_o is not None: toos = self.sim__to_o(self.mjd) if toos is not None: self.conditions.targets_of_opportunity = toos if self.wind_data is not None: wind_speed, wind_direction = self.wind_data(current_time) self.conditions.wind_speed = wind_speed self.conditions.wind_direction = wind_direction return self.conditions
@property def mjd(self): return self._mjd @mjd.setter def mjd(self, value): self._mjd = value self.almanac_indx = self.almanac.mjd_indx(value) self.night = np.max(self.almanac.sunsets["night"][self.almanac_indx])
[docs] def observation_add_data(self, observation): """ Fill in the metadata for a completed observation """ current_time = Time(self.mjd, format="mjd") observation["clouds"] = self.cloud_data(current_time) observation["airmass"] = 1.0 / np.cos(np.pi / 2.0 - observation["alt"]) # Seeing fwhm_500 = self.seeing_data(current_time) seeing_dict = self.seeing_model(fwhm_500, observation["airmass"]) observation["FWHMeff"] = seeing_dict["fwhmEff"][self.seeing_indx_dict[observation["filter"][0]]] observation["FWHM_geometric"] = seeing_dict["fwhmGeom"][ self.seeing_indx_dict[observation["filter"][0]] ] observation["FWHM_500"] = fwhm_500 observation["night"] = self.night observation["mjd"] = self.mjd if self.sky_model is not None: hpid = _ra_dec2_hpid(self.sky_model.nside, observation["RA"], observation["dec"]) observation["skybrightness"] = self.sky_model.return_mags( self.mjd, indx=[hpid], extrapolate=True )[observation["filter"][0]] observation["fivesigmadepth"] = m5_flat_sed( observation["filter"][0], observation["skybrightness"], observation["FWHMeff"], observation["exptime"] / observation["nexp"], observation["airmass"], nexp=observation["nexp"], ) lmst = calc_lmst(self.mjd, self.site.longitude_rad) observation["lmst"] = lmst sun_moon_info = self.almanac.get_sun_moon_positions(self.mjd) observation["sunAlt"] = sun_moon_info["sun_alt"] observation["sunAz"] = sun_moon_info["sun_az"] observation["sunRA"] = sun_moon_info["sun_RA"] observation["sunDec"] = sun_moon_info["sun_dec"] observation["moonAlt"] = sun_moon_info["moon_alt"] observation["moonAz"] = sun_moon_info["moon_az"] observation["moonRA"] = sun_moon_info["moon_RA"] observation["moonDec"] = sun_moon_info["moon_dec"] observation["moonDist"] = _angular_separation( observation["RA"], observation["dec"], observation["moonRA"], observation["moonDec"], ) observation["solarElong"] = _angular_separation( observation["RA"], observation["dec"], observation["sunRA"], observation["sunDec"], ) observation["moonPhase"] = sun_moon_info["moon_phase"] observation["ID"] = self.obs_id_counter self.obs_id_counter += 1 return observation
[docs] def check_up(self, mjd): """See if we are in downtime Returns -------- is_up, mjd : `bool`, `float` Returns (True, current_mjd) if telescope is up and (False, downtime_ends_mjd) if in downtime """ result = True, mjd indx = np.searchsorted(self.downtimes["start"], mjd, side="right") - 1 if indx >= 0: if mjd < self.downtimes["end"][indx]: result = False, self.downtimes["end"][indx] return result
[docs] def check_mjd(self, mjd, cloud_skip=20.0): """See if an mjd is ok to observe Parameters ---------- cloud_skip : float (20) How much time to skip ahead if it's cloudy (minutes) Returns ------- mjd_ok : `bool` mdj : `float` If True, the input mjd. If false, a good mjd to skip forward to. """ passed = True new_mjd = mjd + 0 # Maybe set this to a while loop to make sure we don't # land on another cloudy time? # or just make this an entire recursive call? clouds = self.cloud_data(Time(mjd, format="mjd")) if clouds > self.cloud_limit: passed = False while clouds > self.cloud_limit: new_mjd = new_mjd + cloud_skip / 60.0 / 24.0 clouds = self.cloud_data(Time(new_mjd, format="mjd")) alm_indx = np.searchsorted(self.almanac.sunsets[self.starting_time_key], mjd, side="right") - 1 # at the end of the night, advance to the next setting twilight if mjd > self.almanac.sunsets[self.ending_time_key][alm_indx]: passed = False new_mjd = self.almanac.sunsets[self.starting_time_key][alm_indx + 1] if mjd < self.almanac.sunsets[self.starting_time_key][alm_indx]: passed = False new_mjd = self.almanac.sunsets[self.starting_time_key][alm_indx + 1] # We're in a down time, if down, advance to the end of the downtime if not self.check_up(mjd)[0]: passed = False new_mjd = self.check_up(mjd)[1] # recursive call to make sure we skip far enough ahead if not passed: while not passed: passed, new_mjd = self.check_mjd(new_mjd) return False, new_mjd else: return True, mjd
def _update_rot_sky_pos(self, observation): """If we have an undefined rotSkyPos, try to fill it out.""" # Grab the rotator limit from the observatory model rot_limit = [ self.observatory.telrot_minpos_rad + 2.0 * np.pi, self.observatory.telrot_maxpos_rad, ] alt, az = _approx_ra_dec2_alt_az( observation["RA"], observation["dec"], self.site.latitude_rad, self.site.longitude_rad, self.mjd, ) obs_pa = _approx_altaz2pa(alt, az, self.site.latitude_rad) # If the observation has a rotTelPos set, use it to compute rotSkyPos if np.isfinite(observation["rotTelPos"]): observation["rotSkyPos"] = self.rc._rottelpos2rotskypos(observation["rotTelPos"], obs_pa) observation["rotTelPos"] = np.nan else: # Try to fall back to rotSkyPos_desired possible_rot_tel_pos = self.rc._rotskypos2rottelpos(observation["rotSkyPos_desired"], obs_pa) # If in range, use rotSkyPos_desired for rotSkyPos if (possible_rot_tel_pos > rot_limit[0]) | (possible_rot_tel_pos < rot_limit[1]): observation["rotSkyPos"] = observation["rotSkyPos_desired"] observation["rotTelPos"] = np.nan else: # Fall back to the backup rotation angle if needed. observation["rotSkyPos"] = np.nan observation["rotTelPos"] = observation["rotTelPos_backup"] return observation
[docs] def observe(self, observation): """Try to make an observation Returns ------- observation : observation object None if there was no observation taken. Completed observation with meta data filled in. new_night : bool Have we started a new night. """ start_night = self.night.copy() if np.isnan(observation["rotSkyPos"]): observation = self._update_rot_sky_pos(observation) # If there has been a long gap, assume telescope stopped # tracking and parked gap = self.mjd - self.observatory.last_mjd if gap > self.park_after: self.observatory.park() # Compute what alt,az we have tracked to (or are parked at) start_alt, start_az, start_rot_tel_pos = self.observatory.current_alt_az(self.mjd) # Slew to new position and execute observation. Use the # requested rotTelPos position, obsevation['rotSkyPos'] will # be ignored. slewtime, visittime = self.observatory.observe( observation, self.mjd, rot_tel_pos=observation["rotTelPos"], lax_dome=self.lax_dome, ) # inf slewtime means the observation failed (probably outside # alt limits) if ~np.all(np.isfinite(slewtime)): return None, False observation_worked, new_mjd = self.check_mjd(self.mjd + (slewtime + visittime) / 24.0 / 3600.0) if observation_worked: observation["visittime"] = visittime observation["slewtime"] = slewtime observation["slewdist"] = _angular_separation( start_az, start_alt, self.observatory.last_az_rad, self.observatory.last_alt_rad, ) self.mjd = self.mjd + slewtime / 24.0 / 3600.0 # Reach into the observatory model to pull out the # relevant data it has calculated # Not bothering to fetch alt,az,pa,rottelpos as those # were computed before the slew was executed # so will be off by seconds to minutes. And they # shouldn't be needed by the scheduler. observation["rotSkyPos"] = self.observatory.current_rot_sky_pos_rad observation["cummTelAz"] = self.observatory.cumulative_azimuth_rad # But we do need the altitude to # get the airmass and then 5-sigma depth # this altitude should get clobbered later # by sim_runner. observation["alt"] = self.observatory.last_alt_rad # Metadata on observation is after slew and settle, # so at start of exposure. result = self.observation_add_data(observation) self.mjd = self.mjd + visittime / 24.0 / 3600.0 new_night = False else: result = None self.observatory.park() # Skip to next legitimate mjd self.mjd = new_mjd now_night = self.night if now_night == start_night: new_night = False else: new_night = True return result, new_night
# methods to reach through and adjust the kinematic model if desired def setup_camera(self, **kwargs): self.observatory.setup_camera(**kwargs) def setup_dome(self, **kwargs): self.observatory.setup_dome(**kwargs) def setup_telescope(self, **kwargs): self.observatory.setup_telescope(**kwargs) def setup_setup_optics(self, **kwargs): self.observatory.setup_optics(**kwargs)