"""Defines a helper class for creating light schedules and implements typical schedules used in circadian models"""

# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/01_lights.ipynb.

# %% auto 0
__all__ = ['LightSchedule', 'outdoor_light']

# %% ../nbs/api/01_lights.ipynb 4
import inspect
import warnings
import numpy as np
import pylab as plt
from typing import Callable
from fastcore.basics import patch_to

# %% ../nbs/api/01_lights.ipynb 5
class LightSchedule:
    "Helper class for creating light schedules"
    def __init__(self, 
                 light: Callable[[float], float], # function that takes in a time value and returns a float, if a float is passed, then the light function is a constant set to that lux value 
                 period: float = None, # period in hours, if None, then the light pulse is not repeated. Must be positive
                 ) -> None:
        # period input checking
        period_err_msg = "`period` should be a positive `float` or `int`"
        if period != None:
            if not isinstance(period, (int, float)):
                raise TypeError(period_err_msg)
            elif period <= 0:
                raise ValueError(period_err_msg)
            else:
                period = float(period)
        # light input checking 
        light_input_err_msg = "`light` should be a nonnegative `float`, or a callable with a single `float` parameter which returns a `float`"
        if not callable(light):
            try:
                light = float(light)
            except:
                # catches when the provided light value can't be converted to a float
                raise TypeError(light_input_err_msg)
            if light < 0:
                # catches when the provided light value is negative
                raise ValueError(light_input_err_msg)
            else:
                # create a light function that is a constant set to the provided light value
                light_fn = lambda t: light
        else:
            if len(inspect.signature(light).parameters) != 1:
                # catches when the provided light function does not take in a single parameter
                raise ValueError(light_input_err_msg)
            else: 
                try:
                    test_output = light(0.0)
                    float(test_output)
                except:
                    # catches when the function created from light does not return values that can be cast to float
                    raise ValueError(light_input_err_msg)
            # create a light function that is the provided light function
            if period != None:
                light_fn = lambda t: light(np.mod(t, period))
            else:
                light_fn = light
        # create a vectorized version of the light function that can take in numpy arrays
        self._func = np.vectorize(light_fn, otypes=[float])

    def __call__(self,
                 time: np.ndarray, # time in hours 
                 ):
        "Returns the light intensity at the provided times"
        # t type checking
        time_err_msg = "`time` should be a `float` or a 1d `numpy.ndarray` of `float`"
        try:
            time = np.array(time, dtype=float)
            if time.ndim == 0:
                try:
                    time = float(time)
                    time = np.array([time])
                except:
                    raise ValueError(time_err_msg)
            elif time.ndim != 1:
                raise ValueError(time_err_msg)
        except:
            raise ValueError(time_err_msg) 
        # calculate the light intensity at the provided times
        light_values = self._func(time)
        # throw a warning if any of the light values are negative
        if np.any(light_values < 0):
            warnings.warn("Some light values are negative")
        return light_values

    @classmethod
    def from_pulse(cls,
                   lux: float, # light intensity of the pulse in lux. Must be nonnegative
                   start: float, # start time in hours 
                   duration: float, # duration in hours. Must be positive
                   period: float = None, # period in hours, if None, then the light pulse is not repeated. Must be positive
                   baseline: float = 0.0, # baseline intensity outside of the light pulse in lux. Must be nonnegative
                   ) -> "LightSchedule":
        "Define a light schedule with a single (or a repetitive) light pulse"
        # lux input checking
        lux_err_msg = "`lux` should be a nonnegative `float` or `int`"
        if not isinstance(lux, (int, float)):
            raise TypeError(lux_err_msg)
        elif lux < 0:
            raise ValueError(lux_err_msg)
        else:
            lux = float(lux)
        # start input checking
        start_err_msg = "`start` should be a `float` or `int`"
        if not isinstance(start, (int, float)):
            raise TypeError(start_err_msg)
        else:
            start = float(start)
        # duration input checking
        duration_err_msg = "`duration` should be a positive `float` or `int`"
        if not isinstance(duration, (int, float)):
            raise TypeError(duration_err_msg)
        elif duration <= 0:
            raise ValueError(duration_err_msg)
        else:
            duration = float(duration)
        # period input checking
        period_err_msg = "`period` should be a positive `float` or `int`"
        if period != None:
            if not isinstance(period, (int, float)):
                raise TypeError(period_err_msg)
            elif period <= 0:
                raise ValueError(period_err_msg)
            else:
                period = float(period)
        # baseline input checking
        baseline_err_msg = "`baseline` should be a nonnegative `float` or `int`"
        if not isinstance(baseline, (int, float)):
            raise TypeError(baseline_err_msg)
        elif baseline < 0:
            raise ValueError(baseline_err_msg)
        else:
            baseline = float(baseline)
        # create the light schedule
        def fn(time):
            baseline_zone = (time < start) | (time >= start + duration)
            light_zone = (time >= start) & (time < start + duration)
            conditions = [baseline_zone, light_zone]
            values = [baseline, lux]
            return np.piecewise(time, conditions, values)
        return cls(fn, period=period)

# %% ../nbs/api/01_lights.ipynb 6
@patch_to(LightSchedule)
def __add__(self, 
            schedule: 'LightSchedule' # another LightSchedule object 
            ) -> 'LightSchedule':
    "Calculate the sum of the two LightSchedules" 
    # check that the schedule input is a LightSchedule
    schedule_err_msg = "`schedule` should be a `LightSchedule` object"
    if not isinstance(schedule, LightSchedule):
        raise TypeError(schedule_err_msg)
    fn_1 = self._func
    fn_2 = schedule._func
    lux = lambda t: fn_1(t) + fn_2(t)
    return LightSchedule(lux)

# %% ../nbs/api/01_lights.ipynb 7
@patch_to(LightSchedule)
def __sub__(self,
            schedule: 'LightSchedule' # another LightSchedule object
            ) -> 'LightSchedule':
    "Calculate the difference between two LightSchedules"
    # check that the schedule input is a LightSchedule
    schedule_err_msg = "`schedule` should be a `LightSchedule` object"
    if not isinstance(schedule, LightSchedule):
        raise TypeError(schedule_err_msg)
    fn_1 = self._func
    fn_2 = schedule._func
    lux = lambda t: fn_1(t) - fn_2(t)
    return LightSchedule(lux)

# %% ../nbs/api/01_lights.ipynb 8
@patch_to(LightSchedule)
def concatenate_at(self,
                   schedule: 'LightSchedule', # another LightSchedule object
                   timepoint: float, # timepoint (in hours) at which schedules are concatenated
                   shift_schedule: bool = True, # if True, then the schedule is shifted by the timepoint value
                   ) -> 'LightSchedule':
    "Concatenate two LightSchedules at the provided timepoint. When `shift_schedule=True`, `schedule` is shifted in time by `timepoint`. Not shifted otherwise"
    # check that the schedule input is a LightSchedule
    schedule_err_msg = "`schedule` should be a `LightSchedule` object"
    if not isinstance(schedule, LightSchedule):
        raise TypeError(schedule_err_msg)
    # check that the timepoint input is a float
    timepoint_err_msg = "`timepoint` should be a `float` or `int`"
    if not isinstance(timepoint, (int, float)):
        raise TypeError(timepoint_err_msg)
    else:
        timepoint = float(timepoint)
    # check that the shift_schedule input is a bool
    shift_schedule_err_msg = "`shift_schedule` should be a `bool`"
    if not isinstance(shift_schedule, bool):
        raise TypeError(shift_schedule_err_msg)
    # create the new schedule
    fn_1 = self._func
    fn_2 = schedule._func
    def fn(t):
        func_1_zone = t < timepoint
        func_2_zone = t >= timepoint
        conditions = [func_1_zone, func_2_zone]
        if shift_schedule:
            # shift the schedule by the timepoint value
            values = [fn_1(t), fn_2(t-timepoint)]
        else:
            # don't shift the schedule
            values = [fn_1(t), fn_2(t)]
        return np.piecewise(t, conditions, values)
    return LightSchedule(fn)

# %% ../nbs/api/01_lights.ipynb 9
@patch_to(LightSchedule)
def plot(self, 
         plot_start_time: float, # start time of the plot in hours
         plot_end_time: float, # end time of the plot in hours
         num_samples: int=10000, # number of samples to plot
         ax=None, # matplotlib axis to plot on
         *args,
         **kwargs
         ) -> plt.Axes:
    "Plot the light function between `start_time` and `end_time` with `num_samples` samples. Accepts `matplotlib` `*args` and `**kwargs`"
    # type checking
    if not isinstance(plot_start_time, (float, int)):
        raise ValueError(f"plot_start_time must be a float or int, got {type(plot_start_time)}")
    if not isinstance(plot_end_time, (float, int)):
        raise ValueError(f"plot_end_time must be a float or int, got {type(plot_end_time)}")
    if ax is not None:
        if not isinstance(ax, plt.Axes):
            raise ValueError(f"ax must be a matplotlib Axes object, got {type(ax)}")
    if num_samples is not None:
        if not isinstance(num_samples, int):
            raise ValueError(f"num_samples must be an int, got {type(num_samples)}")
    
    t = np.linspace(plot_start_time, plot_end_time, num_samples)
    vals = self.__call__(t)
    if ax is None:
        plt.figure()
        ax = plt.gca()

    ax.plot(t, vals, *args, **kwargs)
    return ax

# %% ../nbs/api/01_lights.ipynb 10
@patch_to(LightSchedule)
def Regular(lux: float=150.0, # intensity of the light in lux
            lights_on: float=7.0, # time of the day for lights to come on in hours
            lights_off: float=23.0, # time of the day for lights to go off in hours
            ) -> 'LightSchedule':
    "Create a regular light and darkness 24 hour schedule"
    # type checking
    if not isinstance(lux, (float, int)):
        raise TypeError(f"lux must be a nonnegative float or int, got {type(lux)}")
    elif lux < 0.0:
        raise ValueError(f"lux must be a nonnegative float or int, got {lux}")
    if not isinstance(lights_on, (float, int)):
        raise TypeError(f"lights_on must be a float or int, got {type(lights_on)}")
    elif lights_on < 0.0 or lights_on > 24.0:
        raise ValueError(f"lights_on must be between 0.0 and 24.0, got {lights_on}")
    if not isinstance(lights_off, (float, int)):
        raise TypeError(f"lights_off must be a float or int, got {type(lights_off)}")
    elif lights_off < 0.0 or lights_off > 24.0:
        raise ValueError(f"lights_off must be between 0.0 and 24.0, got {lights_off}")
    
    if lights_off > lights_on:
        schedule = LightSchedule.from_pulse(lux, lights_on, lights_off - lights_on, 24.0)
        return schedule
    elif lights_off < lights_on:
        schedule = LightSchedule.from_pulse(lux, lights_on, 24.0 - lights_on, 24.0)
        schedule = schedule + LightSchedule.from_pulse(lux, 0.0, lights_off, 24.0)
        return schedule
    elif lights_off == lights_on:
        raise ValueError("lights_off and lights_on cannot be equal")

# %% ../nbs/api/01_lights.ipynb 11
@patch_to(LightSchedule)
def ShiftWork(lux: float=150.0, # lux intensity of the light. Must be a nonnegative float or int
              days_on: int=5, # number of days on the night shift. Must be a positive int
              days_off: int=2, # number of days off shift. Must be a positive int
              lights_on_workday: float=17.0, # hour of the day for lights to come on on a workday. Must be between 0.0 and 24.0
              lights_off_workday: float=9.0, # hour of the day for lights to go off on a workday. Must be between 0.0 and 24.0
              lights_on_day_off: float=9.0, # hour of the day for lights to come on on a day off. Must be between 0.0 and 24.0
              lights_off_day_off: float=24.0, # hour of the day for lights to go off on a day off. Must be between 0.0 and 24.0
              ) -> 'LightSchedule':
    "Create a light schedule for a shift worker" 
    # type checking
    lux_err_msg = "lux must be a nonnegative float or int, got "
    if not isinstance(lux, (float, int)):
        raise TypeError(lux_err_msg + f"{type(lux)}")
    elif lux < 0.0:
        raise ValueError(lux_err_msg + f"{lux}")
    days_on_err_msg = "days_on must be an int > 1, got "
    if not isinstance(days_on, int):
        raise TypeError(days_on_err_msg + f"{type(days_on)}")
    elif days_on < 2:
        raise ValueError(days_on_err_msg + f"{days_on}")
    days_off_err_msg = "days_off must be an int > 1, got "
    if not isinstance(days_off, int):
        raise TypeError(days_off_err_msg + f"{type(days_off)}")
    elif days_off < 2:
        raise ValueError(days_off_err_msg + f"{days_off}")
    lights_on_workday_err_msg = "lights_on_workday must be a float or int between 0.0 and 24.0, got "
    if not isinstance(lights_on_workday, (float, int)):
        raise TypeError(lights_on_workday_err_msg + f"{type(lights_on_workday)}")
    elif lights_on_workday < 0.0 or lights_on_workday > 24.0:
        raise ValueError(lights_on_workday_err_msg + f"{lights_on_workday}")
    lights_off_workday_err_msg = "lights_off_workday must be a float or int between 0.0 and 24.0, got "
    if not isinstance(lights_off_workday, (float, int)):
        raise TypeError(lights_off_workday_err_msg + f"{type(lights_off_workday)}")
    elif lights_off_workday < 0.0 or lights_off_workday > 24.0:
        raise ValueError(lights_off_workday_err_msg + f"{lights_off_workday}")
    lights_on_day_off_err_msg = "lights_on_day_off must be a float or int between 0.0 and 24.0, got "
    if not isinstance(lights_on_day_off, (float, int)):
        raise TypeError(lights_on_day_off_err_msg + f"{type(lights_on_day_off)}")
    elif lights_on_day_off < 0.0 or lights_on_day_off > 24.0:
        raise ValueError(lights_on_day_off_err_msg + f"{lights_on_day_off}")
    lights_off_day_off_err_msg = "lights_off_day_off must be a float or int between 0.0 and 24.0, got "
    if not isinstance(lights_off_day_off, (float, int)):
        raise TypeError(lights_off_day_off_err_msg + f"{type(lights_off_day_off)}")
    elif lights_off_day_off < 0.0 or lights_off_day_off > 24.0:
        raise ValueError(lights_off_day_off_err_msg + f"{lights_off_day_off}")
    workweek_period = 24.0 * (days_on + days_off)
    # work days regular schedule
    work_schedule = LightSchedule.Regular(lux, lights_on_workday, lights_off_workday)
    # transition between work days and day off - sleep half of the time between `lights_off_workday` and `lights_on_day_off`
    workdays_finish = 24*(days_on - 1) + lights_on_workday
    first_transition_end = 24*days_on + lights_on_day_off
    transition_sleep_time = 0.5 * (workdays_finish + first_transition_end) - workdays_finish
    transition_day = LightSchedule.from_pulse(lux, workdays_finish, transition_sleep_time)
    # days off regular schedule
    days_off_schedule = LightSchedule.Regular(lux, lights_on_day_off, lights_off_day_off)
    # transition between day off and work day - sleep, in two chunks, a third of what's left until next workday
    second_transition_start = 24*(days_on + days_off - 2) + lights_off_day_off 
    workdays_start_again = 24*(days_on + days_off - 1) + lights_on_workday
    sleep_bank = (workdays_start_again - second_transition_start) / 3.0
    transition_sleep = LightSchedule.from_pulse(lux, second_transition_start + sleep_bank, sleep_bank, workweek_period)
    # create the schedule
    total_schedule = work_schedule.concatenate_at(transition_day, workdays_finish, shift_schedule=False)
    total_schedule = total_schedule.concatenate_at(days_off_schedule, first_transition_end, shift_schedule=False)
    total_schedule = total_schedule.concatenate_at(transition_sleep, second_transition_start, shift_schedule=False)
    total_schedule = total_schedule.concatenate_at(work_schedule, workdays_start_again, shift_schedule=False)
    # add workweek periodicity
    final_schedule = LightSchedule(total_schedule, period=workweek_period)
    return final_schedule

# %% ../nbs/api/01_lights.ipynb 12
@patch_to(LightSchedule)
def SlamShift(lux: float=150.0, # intensity of the light in lux
              shift: float=8.0, # shift in the light schedule in hours
              before_days: int=5, # days before the shift occurs 
              starting_lights_on: float=7.0, # time of the day for lights to come on
              starting_lights_off: float=23.0, # time of the day for lights to go off
              ) -> 'LightSchedule':
    "Create a light schedule for a shift worker under a slam shift" 
    # type checking
    if not isinstance(lux, (float, int)):
        raise ValueError(f"lux must be a nonnegative float or int, got {type(lux)}")
    elif lux < 0.0:
        raise ValueError(f"lux must be a nonnegative float or int, got {lux}")
    if not isinstance(shift, (float, int)):
        raise ValueError(f"shift must be a nonnegative float or int, got {type(shift)}")
    elif shift < 0.0:
        raise ValueError(f"shift must be a nonnegative float or int, got {shift}")
    if not isinstance(before_days, int):
        raise ValueError(f"before_days must be a nonnegative int, got {type(before_days)}")
    elif before_days < 0:
        raise ValueError(f"before_days must be a nonnegative int, got {before_days}")
    if not isinstance(starting_lights_on, (float, int)):
        raise ValueError(f"starting_lights_on must be a float or int between 0 and 24, got {type(starting_lights_on)}")
    elif starting_lights_on < 0.0 or starting_lights_on > 24.0:
        raise ValueError(f"starting_lights_on must be a float or int between 0 and 24, got {starting_lights_on}")
    if not isinstance(starting_lights_off, (float, int)):
        raise ValueError(f"starting_lights_off must be a float or int between 0 and 24, got {type(starting_lights_off)}")
    elif starting_lights_off < 0.0 or starting_lights_off > 24.0:
        raise ValueError(f"starting_lights_off must be a float or int between 0 and 24, got {starting_lights_off}")
    # create the schedule
    schedule_before = LightSchedule.Regular(lux, starting_lights_on, starting_lights_off)
    last_lights_off_before = 24.0 * (before_days - 1) + starting_lights_off 
    first_lights_on_after =  24.0 * before_days + starting_lights_on + shift
    # sleep one third of the time between `last_lights_off_before` and `first_lights_on_after`
    transition_sleep_time =  (first_lights_on_after - last_lights_off_before) / 3.0
    transition_schedule = LightSchedule.from_pulse(lux, last_lights_off_before + transition_sleep_time, transition_sleep_time)
    shifted_lights_on = np.mod(starting_lights_on + shift, 24.0)
    shifted_lights_off = np.mod(starting_lights_off + shift, 24.0)
    schedule_after = LightSchedule.Regular(lux, shifted_lights_on, shifted_lights_off)
    final_schedule = schedule_before.concatenate_at(transition_schedule, last_lights_off_before, shift_schedule=False)
    final_schedule = final_schedule.concatenate_at(schedule_after, first_lights_on_after, shift_schedule=False)
    return final_schedule

# %% ../nbs/api/01_lights.ipynb 13
@patch_to(LightSchedule)
def SocialJetlag(lux: float=150.0, # intensity of the light in lux
                 num_regular_days: int=5, # number of days with a regular schedule
                 num_jetlag_days: int=2, # number of days with a delayed schedule
                 hours_delayed: float=2.0, # number of hours to delay the schedule on the jetlag days
                 regular_days_lights_on: float=7.0, # hour of the day for lights to come on
                 regular_days_lights_off: float=23.0, # hour of the day for lights to go off
                 ) -> 'LightSchedule':
    "Create a light schedule that simulates the effects of staying up late on the weekend (social jetlag)"
    # type checking
    if not isinstance(lux, (float, int)):
        raise TypeError(f"lux must be a nonnegative float or int, got {type(lux)}")
    elif lux < 0.0:
        raise ValueError(f"lux must be a nonnegative float or int, got {lux}")
    if not isinstance(num_regular_days, int):
        raise TypeError(f"num_regular_days must be a nonnegative int, got {type(num_regular_days)}")
    elif num_regular_days < 0:
        raise ValueError(f"num_regular_days must be a nonnegative int, got {num_regular_days}")
    if not isinstance(num_jetlag_days, int):
        raise TypeError(f"num_jetlag_days must be a nonnegative int, got {type(num_jetlag_days)}")
    elif num_jetlag_days < 0:
        raise ValueError(f"num_jetlag_days must be a nonnegative int, got {num_jetlag_days}")
    if not isinstance(hours_delayed, (float, int)):
        raise TypeError(f"hours_delayed must be a nonnegative float or int, got {type(hours_delayed)}")
    elif hours_delayed < 0.0:
        raise ValueError(f"hours_delayed must be a nonnegative float or int, got {hours_delayed}")
    if not isinstance(regular_days_lights_on, (float, int)):
        raise TypeError(f"regular_days_lights_on must be a float or int between 0 and 24, got {type(regular_days_lights_on)}")
    elif regular_days_lights_on < 0.0 or regular_days_lights_on > 24.0:
        raise ValueError(f"regular_days_lights_on must be a float or int between 0 and 24, got {regular_days_lights_on}")
    if not isinstance(regular_days_lights_off, (float, int)):
        raise TypeError(f"regular_days_lights_off must be a float or int between 0 and 24, got {type(regular_days_lights_off)}")
    elif regular_days_lights_off < 0.0 or regular_days_lights_off > 24.0:
        raise ValueError(f"regular_days_lights_off must be a float or int between 0 and 24, got {regular_days_lights_off}")
    # create the schedule 
    overall_period = 24.0 * (num_regular_days + num_jetlag_days)
    regular_days = LightSchedule.Regular(lux, lights_on=regular_days_lights_on, lights_off=regular_days_lights_off)
    jetlag_days = LightSchedule.Regular(lux, lights_on=regular_days_lights_on + hours_delayed,
                                             lights_off=np.mod(regular_days_lights_off + hours_delayed, 24))
    timepoint_change = 24.0 * (num_regular_days - 1) + regular_days_lights_off
    total_schedule = regular_days.concatenate_at(jetlag_days, timepoint_change, shift_schedule=False)
    final_schedule = LightSchedule(total_schedule, period=overall_period)
        
    return final_schedule

# %% ../nbs/api/01_lights.ipynb 14
@patch_to(LightSchedule)
def Hilaire12(first_constant_routine_duration: float, # duration of the constant routine in hours
              second_constant_routine_duration: float, # duration of the second constant routine in hours
              regular_lux: float=90, # intensity of the light in lux before the constant routine
              constant_routine_lux: float=3, # intensity of the light in lux during the constant routine
              pulse_duration: float=1, # duration of the light pulse in hours 
              pulse_lux: float=8000, # intensity of the light pulse in lux
              ) -> 'LightSchedule':
    "Create a light schedule matching the Hilaire et al. 2012 experimental protocol. Does not include baseline days."
    # type checking
    if not isinstance(first_constant_routine_duration, (float, int)):
        raise TypeError(f"constant_routine_duration must be a nonnegative float or int, got {type(first_constant_routine_duration)}")
    elif first_constant_routine_duration < 0:
        raise ValueError(f"constant_routine_duration must be a nonnegative float or int, got {first_constant_routine_duration}")
    if not isinstance(second_constant_routine_duration, (float, int)):
        raise TypeError(f"second_constant_routine_duration must be a nonnegative float or int, got {type(second_constant_routine_duration)}")
    elif second_constant_routine_duration < 0:
        raise ValueError(f"second_constant_routine_duration must be a nonnegative float or int, got {second_constant_routine_duration}")
    if not isinstance(regular_lux, (float, int)):
        raise TypeError(f"regular_lux must be a nonnegative float or int, got {type(regular_lux)}")
    elif regular_lux < 0:
        raise ValueError(f"regular_lux must be a nonnegative float or int, got {regular_lux}")
    if not isinstance(constant_routine_lux, (float, int)):
        raise TypeError(f"constant_routine_lux must be a nonnegative float or int, got {type(constant_routine_lux)}")
    elif constant_routine_lux < 0:
        raise ValueError(f"constant_routine_lux must be a nonnegative float or int, got {constant_routine_lux}")
    if not isinstance(pulse_duration, (float, int)):
        raise TypeError(f"pulse_duration must be a nonnegative float or int, got {type(pulse_duration)}")
    elif pulse_duration < 0:
        raise ValueError(f"pulse_duration must be a nonnegative float or int, got {pulse_duration}")
    if not isinstance(pulse_lux, (float, int)):
        raise TypeError(f"pulse_lux must be a nonnegative float or int, got {type(pulse_lux)}")
    elif pulse_lux < 0:
        raise ValueError(f"pulse_lux must be a nonnegative float or int, got {pulse_lux}")
    # create the schedule 
    first_baseline_day = LightSchedule(regular_lux)
    second_baseline_day = LightSchedule.from_pulse(0, 0, 8) + LightSchedule.from_pulse(regular_lux, 8, 16)
    third_baseline_day = LightSchedule.from_pulse(0, 0, 8) + LightSchedule.from_pulse(regular_lux, 8, 8) + LightSchedule.from_pulse(constant_routine_lux, 16, 8)
    constant_routine_1 = LightSchedule.from_pulse(0, 0, 8) + LightSchedule.from_pulse(constant_routine_lux, 8, first_constant_routine_duration)
    pulse_region = LightSchedule.from_pulse(0, 0, 8) + LightSchedule.from_pulse(constant_routine_lux, 8, 16) + LightSchedule.from_pulse(0, 16, 8)
    pulse_region = pulse_region + LightSchedule.from_pulse(pulse_lux - constant_routine_lux, 16 - pulse_duration / 2, pulse_duration)
    constant_routine_2 = LightSchedule.from_pulse(constant_routine_lux, 0, second_constant_routine_duration)

    pulse_region_duration = 8 + 16 + 8
    final_schedule = first_baseline_day.concatenate_at(second_baseline_day, 24)
    final_schedule = final_schedule.concatenate_at(third_baseline_day, 48)
    final_schedule = final_schedule.concatenate_at(constant_routine_1, 72)
    final_schedule = final_schedule.concatenate_at(pulse_region, 72 + 8 + first_constant_routine_duration)
    final_schedule = final_schedule.concatenate_at(constant_routine_2, 72 + 8 + first_constant_routine_duration + pulse_region_duration)

    return final_schedule

# %% ../nbs/api/01_lights.ipynb 15
@patch_to(LightSchedule)
def Chang14(dim_lux: float=3.0, # intensity of the light in reading sessions and constant protocols 
            typical_indoor_lux: float=90.0, # intensity of the light during wakefulness 
            ereader_lux: float=31.73, # intensity of the light during the eReader session. Photopic lux value taken from article
            book_lux: float=0.91, # intensity of the light during the book reading session. Photopic lux value taken from article
            first_reading_condition: str="eReader", # Reading condition for the first set of days. Second reading condition is the opposite
            reading_start_time: float=18.0, # time of the day when the reading sessions start
            reading_duration: float=4.0, # duration of the reading sessions in hours
            ) -> 'LightSchedule':
    "Create a light schedule matching the Chang et al. 2014 experimental protocol for studying the effect of eReaders on sleep."
    # type checking
    if not isinstance(dim_lux, (float, int)):
        raise TypeError(f"dim_lux must be a nonnegative float or int, got {type(dim_lux)}")
    elif dim_lux < 0:
        raise ValueError(f"dim_lux must be a nonnegative float or int, got {dim_lux}")
    if not isinstance(typical_indoor_lux, (float, int)):
        raise TypeError(f"typical_indoor_lux must be a nonnegative float or int, got {type(typical_indoor_lux)}")
    elif typical_indoor_lux < 0:
        raise ValueError(f"typical_indoor_lux must be a nonnegative float or int, got {typical_indoor_lux}")
    if not isinstance(ereader_lux, (float, int)):
        raise TypeError(f"ereader_lux must be a nonnegative float or int, got {type(ereader_lux)}")
    elif ereader_lux < 0:
        raise ValueError(f"ereader_lux must be a nonnegative float or int, got {ereader_lux}")
    if not isinstance(book_lux, (float, int)):
        raise TypeError(f"book_lux must be a nonnegative float or int, got {type(book_lux)}")
    elif book_lux < 0:
        raise ValueError(f"book_lux must be a nonnegative float or int, got {book_lux}")
    if not isinstance(first_reading_condition, str):
        raise TypeError(f"first_reading_condition must be a string, got {type(first_reading_condition)}")
    if first_reading_condition not in ["eReader", "Book"]:
        raise ValueError(f"first_reading_condition must be 'eReader' or 'Book', got {first_reading_condition}")
    if not isinstance(reading_start_time, (float, int)):
        raise TypeError(f"reading_start_time must be a float or int, got {type(reading_start_time)}")
    if not isinstance(reading_duration, (float, int)):
        raise TypeError(f"reading_duration must be a float or int, got {type(reading_duration)}")
    # create the schedule 
    first_day = LightSchedule.from_pulse(typical_indoor_lux, 6, 6) + LightSchedule.from_pulse(dim_lux, 12, 10)
    reading_lux = [ereader_lux, book_lux] if first_reading_condition == "eReader" else [book_lux, ereader_lux]
    second_day = LightSchedule.from_pulse(dim_lux, 6, 6) + LightSchedule.from_pulse(typical_indoor_lux, 12, 6)
    second_day = second_day + LightSchedule.from_pulse(dim_lux, 18, 4)
    second_day_first_condition = second_day + LightSchedule.from_pulse(reading_lux[0], reading_start_time, reading_duration)
    second_day_second_condition = second_day + LightSchedule.from_pulse(reading_lux[1], reading_start_time, reading_duration)
    reading_day_baseline = LightSchedule.from_pulse(typical_indoor_lux, 6, 12) + LightSchedule.from_pulse(dim_lux, 18, 4)
    reading_day_first_condition = reading_day_baseline + LightSchedule.from_pulse(reading_lux[0], reading_start_time, reading_duration)
    reading_day_second_condition = reading_day_baseline + LightSchedule.from_pulse(reading_lux[1], reading_start_time, reading_duration)

    # first reading condition
    final_schedule = first_day.concatenate_at(second_day_first_condition, 24)
    for i in range(4):
        final_schedule = final_schedule.concatenate_at(reading_day_first_condition, 48 + 24*i)
    # second reading condition
    final_schedule = final_schedule.concatenate_at(first_day, 48 + 24*4)
    final_schedule = final_schedule.concatenate_at(second_day_second_condition, 48 + 24*5)
    for i in range(4):
        final_schedule = final_schedule.concatenate_at(reading_day_second_condition, 48 + 24*6 + 24*i)
    # final days
    final_schedule = final_schedule.concatenate_at(first_day, 48 + 24*10)
    final_schedule = final_schedule.concatenate_at(second_day, 48 + 24*11)

    return final_schedule

# %% ../nbs/api/01_lights.ipynb 16
def outdoor_light(t: float, sunrise_time: float, sunset_time: float, peak_lux: float, baseline_lux: float, slope: float):
    rise = np.tanh(slope * (t - sunrise_time))
    set = np.tanh(slope * (sunset_time - t))
    amplitude = (peak_lux - baseline_lux) / 2
    return baseline_lux + amplitude * (rise + set)

# %% ../nbs/api/01_lights.ipynb 17
@patch_to(LightSchedule)
def School(school_start_time: float=8.0, # time of the day when school starts
           school_duration: float=6.0, # duration of the school day in hours
           school_lux: float=500.0, # intensity of the light during school hours
           school_prep_duration: float=1.0, # duration of the school preparation time in hours
           school_prep_lux: float=200.0, # intensity of the light during school preparation
           outdoor_light_sunrise_time: float=6.0, # time of the day when the sun rises
           outdoor_light_sunset_time: float=18.0, # time of the day when the sun sets
           outdoor_peak_lux: float=10000.0, # peak intensity of the outdoor light in lux
           outdoor_baseline_lux: float=0.0, # baseline intensity of the outdoor light in lux
           outdoor_light_slope: float=1.0, # slope of the outdoor light intensity
           num_school_days: int=5, # number of school days in the week
           num_weekend_days: int=2, # number of weekend days in the week
           ) -> 'LightSchedule':
    "Create a weekly school schedule where students have to wake up for school (with prep time) and when not in school, they are exposed to outdoor light"
    # type checking
    if not isinstance(school_start_time, (float, int)):
        raise TypeError(f"school_start_time must be a float or int, got {type(school_start_time)}")
    if not isinstance(school_duration, (float, int)):
        raise TypeError(f"school_duration must be a float or int, got {type(school_duration)}")
    elif school_duration < 0:
        raise ValueError(f"school_duration must be a nonnegative float or int, got {school_duration}")
    if not isinstance(school_lux, (float, int)):
        raise TypeError(f"school_lux must be a nonnegative float or int, got {type(school_lux)}")
    elif school_lux < 0:
        raise ValueError(f"school_lux must be a nonnegative float or int, got {school_lux}")
    if not isinstance(school_prep_duration, (float, int)):
        raise TypeError(f"school_prep_duration must be a float or int, got {type(school_prep_duration)}")
    elif school_prep_duration < 0:
        raise ValueError(f"school_prep_duration must be a nonnegative float or int, got {school_prep_duration}")
    if not isinstance(school_prep_lux, (float, int)):
        raise TypeError(f"school_prep_lux must be a nonnegative float or int, got {type(school_prep_lux)}")
    elif school_prep_lux < 0:
        raise ValueError(f"school_prep_lux must be a nonnegative float or int, got {school_prep_lux}")
    if not isinstance(outdoor_light_sunrise_time, (float, int)):
        raise TypeError(f"outdoor_light_sunrise_time must be a float or int, got {type(outdoor_light_sunrise_time)}")
    if not isinstance(outdoor_light_sunset_time, (float, int)):
        raise TypeError(f"outdoor_light_sunset_time must be a float or int, got {type(outdoor_light_sunset_time)}")
    if not isinstance(outdoor_peak_lux, (float, int)):
        raise TypeError(f"outdoor_peak_lux must be a nonnegative float or int, got {type(outdoor_peak_lux)}")
    elif outdoor_peak_lux < 0:
        raise ValueError(f"outdoor_peak_lux must be a nonnegative float or int, got {outdoor_peak_lux}")
    if not isinstance(outdoor_baseline_lux, (float, int)):
        raise TypeError(f"outdoor_baseline_lux must be a nonnegative float or int, got {type(outdoor_baseline_lux)}")
    elif outdoor_baseline_lux < 0:
        raise ValueError(f"outdoor_baseline_lux must be a nonnegative float or int, got {outdoor_baseline_lux}")
    if not isinstance(outdoor_light_slope, (float, int)):
        raise TypeError(f"outdoor_light_slope must be a nonnegative float or int, got {type(outdoor_light_slope)}")
    elif outdoor_light_slope < 0:
        raise ValueError(f"outdoor_light_slope must be a nonnegative float or int, got {outdoor_light_slope}")
    # School day
    prep_time_pulse = LightSchedule.from_pulse(school_prep_lux, school_start_time - school_prep_duration, school_prep_duration)
    school_time_pulse = LightSchedule.from_pulse(school_lux, school_start_time, school_duration)
    outdoor_light_fn = lambda t: outdoor_light(t, outdoor_light_sunrise_time, outdoor_light_sunset_time, outdoor_peak_lux, outdoor_baseline_lux, outdoor_light_slope)
    outdoor_light_schedule = LightSchedule(outdoor_light_fn)
    school_day = prep_time_pulse.concatenate_at(school_time_pulse, school_start_time, shift_schedule=False)
    school_day = school_day.concatenate_at(outdoor_light_schedule, school_start_time + school_duration, shift_schedule=False)
    # Weekend
    weekend = LightSchedule(outdoor_light_fn)
    # Whole week
    for i in range(num_school_days):
        if i == 0:
            week_schedule = school_day
        else:
            week_schedule = week_schedule.concatenate_at(school_day, 24*i)
    for i in range(num_weekend_days):
        week_schedule = week_schedule.concatenate_at(weekend, 24*(num_school_days + i))
    period = 24*(num_school_days + num_weekend_days)
    final_schedule = LightSchedule(week_schedule, period=period)
    return final_schedule
