diff --git a/smart_control/reinforcement_learning/policies/schedule_policy.py b/smart_control/reinforcement_learning/policies/schedule_policy.py index 375185b4..eaf0e6a3 100644 --- a/smart_control/reinforcement_learning/policies/schedule_policy.py +++ b/smart_control/reinforcement_learning/policies/schedule_policy.py @@ -80,6 +80,28 @@ def get_active_setpoint( return series.loc[prior_events[-1]] +_HOURS_IN_DAY = 24 + + +def local_schedule_time(hod: float, utc_offset: pd.Timedelta) -> pd.Timedelta: + """Converts a UTC hour of day into the local time of day used by schedules. + + A negative UTC offset pushes the early UTC hours before midnight (UTC hour 0 + at -07:00 is -7 hours). Schedule events start between 0 and 24 hours, so + without wrapping, those times match no event and the lookup falls back to the + last event of the day, switching to the evening setpoints early. + + Args: + hod: Hour of day of the simulator timestamp, in UTC, from 0 to 24. + utc_offset: Offset of the building's local time zone from UTC. + + Returns: + The local time since midnight, from 0 up to but excluding 24 hours. + """ + local = pd.Timedelta(hod, unit='hour') + utc_offset + return local % pd.Timedelta(_HOURS_IN_DAY, unit='hour') + + class SchedulePolicy(tf_policy.TFPolicy): """Policy that selects actions based on time-dependent schedules.""" @@ -152,9 +174,7 @@ def _get_action_map(self, time_step) -> Dict: # pylint: disable=g-bare-generic hod = to_hod(hod_sin, hod_cos) # Create timestamp - timestamp = ( - pd.Timedelta(hod, unit='hour') + self.local_start_time.utcoffset() - ) + timestamp = local_schedule_time(hod, self.local_start_time.utcoffset()) # Use appropriate schedule based on day type schedule = self.weekday_schedule if dow < 5 else self.weekend_schedule diff --git a/smart_control/reinforcement_learning/policies/schedule_policy_test.py b/smart_control/reinforcement_learning/policies/schedule_policy_test.py new file mode 100644 index 00000000..8a9adf1f --- /dev/null +++ b/smart_control/reinforcement_learning/policies/schedule_policy_test.py @@ -0,0 +1,94 @@ +"""Tests for the schedule policy time handling.""" + +import unittest + +import pandas as pd + +from smart_control.reinforcement_learning.policies import schedule_policy + +DAY = 'day' +NIGHT = 'night' +PDT = pd.Timedelta(hours=-7) +IST = pd.Timedelta(hours=5, minutes=30) + + +def _schedule(): + """A schedule that switches to day at 6:00 and to night at 19:00.""" + return [ + schedule_policy.ScheduleEvent( + pd.Timedelta(6, unit='hour'), + schedule_policy.DeviceType.AC, + 'supply_air_heating_temperature_setpoint', + DAY, + ), + schedule_policy.ScheduleEvent( + pd.Timedelta(19, unit='hour'), + schedule_policy.DeviceType.AC, + 'supply_air_heating_temperature_setpoint', + NIGHT, + ), + ] + + +def _setpoint_at_utc_hour(hod, utc_offset): + return schedule_policy.get_active_setpoint( + _schedule(), + schedule_policy.DeviceType.AC, + 'supply_air_heating_temperature_setpoint', + schedule_policy.local_schedule_time(hod, utc_offset), + ) + + +class LocalScheduleTimeTest(unittest.TestCase): + + def test_negative_offset_wraps_to_previous_evening(self): + # 00:00 UTC is 17:00 the previous day at UTC-7. + self.assertEqual( + schedule_policy.local_schedule_time(0, PDT), + pd.Timedelta(17, unit='hour'), + ) + + def test_offset_without_wrapping_is_unchanged(self): + # 13:00 UTC is 06:00 at UTC-7. + self.assertEqual( + schedule_policy.local_schedule_time(13, PDT), + pd.Timedelta(6, unit='hour'), + ) + + def test_local_midnight_is_zero(self): + self.assertEqual( + schedule_policy.local_schedule_time(7, PDT), pd.Timedelta(0) + ) + + def test_positive_offset_wraps_past_midnight(self): + # 20:00 UTC is 01:30 the next day at UTC+5:30. + self.assertEqual( + schedule_policy.local_schedule_time(20, IST), + pd.Timedelta(hours=1, minutes=30), + ) + + +class BaselineScheduleSwitchTest(unittest.TestCase): + + def test_day_setpoint_holds_until_local_evening_start(self): + # 17:00 and 18:00 local are 00:00 and 01:00 UTC. They are still day. + self.assertEqual(_setpoint_at_utc_hour(0, PDT), DAY) + self.assertEqual(_setpoint_at_utc_hour(1, PDT), DAY) + + def test_night_setpoint_starts_at_local_evening_start(self): + # 19:00 local is 02:00 UTC. + self.assertEqual(_setpoint_at_utc_hour(2, PDT), NIGHT) + + def test_night_setpoint_holds_overnight(self): + # 23:00 local is 06:00 UTC, and local midnight is 07:00 UTC. + self.assertEqual(_setpoint_at_utc_hour(6, PDT), NIGHT) + self.assertEqual(_setpoint_at_utc_hour(7, PDT), NIGHT) + + def test_day_setpoint_starts_at_local_morning_start(self): + # 06:00 local is 13:00 UTC, and 16:00 local is 23:00 UTC. + self.assertEqual(_setpoint_at_utc_hour(13, PDT), DAY) + self.assertEqual(_setpoint_at_utc_hour(23, PDT), DAY) + + +if __name__ == '__main__': + unittest.main()