diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 3c52212f..1dc40c64 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,16 +1,14 @@ ## Expected Behavior - ## Actual Behavior - ## Steps to Reproduce the Problem 1. -1. -1. +2. +3. ## Specifications - Version: -- Platform: \ No newline at end of file +- Platform: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 00550b6b..d86bf2a5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ -Fixes # +Fixes #\ > It's a good idea to open an issue first for discussion. - [ ] Tests pass -- [ ] Appropriate changes to documentation are included in the PR \ No newline at end of file +- [ ] Appropriate changes to documentation are included in the PR diff --git a/smart_control/simulator/enhanced_occupancy.py b/smart_control/simulator/enhanced_occupancy.py new file mode 100644 index 00000000..93150076 --- /dev/null +++ b/smart_control/simulator/enhanced_occupancy.py @@ -0,0 +1,469 @@ +"""An enhanced stochastic occupancy model for building simulation +with minute-level control and different worker types. This model is based on the +LIGHTSWITCHOccupancy model from stochastic_occupancy.py to include minute-level +control instead of hour-level control. It has the same arrival/departure/lunch +logic but it provides more fine-grained control and realistic occupant +behaviour, such as optional weekend work and daily changing work hours and lunch +break times (instead of a constant set of parameters for each occupant leading +to a static occupancy profile that repeats itself every weekday of the year). +The model samples new work and lunch time parameters for each occupant every +day, caches them for the day and clears the cache on the next day to ensure +consistency. It is possible to model low occupancy levels on the weekends by +using different worker types. +""" + +import datetime +import enum +from typing import Dict, Union + +from absl import logging +import gin +import numpy as np +import pandas as pd + +from smart_control.models.base_occupancy import BaseOccupancy +from smart_control.utils import conversion_utils + +# Seeds for np.random.RandomState must be integers in [0, 2**32 - 1]. +# We use modulo SEED_MOD_32 to constrain hashes into this valid range. +SEED_MOD_32 = 2**32 + + +class OccupancyStateEnum(enum.Enum): + AWAY = 1 + WORK = 2 + + +class WorkerType(enum.Enum): + WEEKDAY_ONLY = 1 + WEEKEND_REGULAR = 2 # worker types that works on weekends and weekdays + WEEKEND_OCCASIONAL = ( + 3 # regular weekday workers who need to work on weekends occasionally + ) + + +class MinuteLevelZoneOccupant: + """MinuteLevelZoneOccupant with minute-level control and day-specific + parameters. + This class samples a full daily schedule (arrival, lunch, departure) at minute + resolution, caches it per occupant per day, and supports weekend worker types. + We intentionally do not inherit from the legacy occupant classes because their + semantics differ: + - stochastic_occupancy.ZoneOccupant: samples fixed hour‑level times once at + initialisation and repeats the same schedule every workday. + - randomized_arrival_departure_occupancy.ZoneOccupant: uses independent + per-step Bernoulli draws in hour-level arrival/departure windows. + Inheritance would require overriding most behaviours and would reduce clarity, + so we keep the implementations separate. + """ + + def __init__( + self, + earliest_expected_arrival_min: int, + latest_expected_arrival_min: int, + earliest_expected_departure_min: int, + latest_expected_departure_min: int, + lunch_start_min: int, + lunch_end_min: int, + step_size: pd.Timedelta, + random_state: np.random.RandomState, + time_zone: Union[datetime.tzinfo, str] = "UTC", + worker_type: WorkerType = WorkerType.WEEKDAY_ONLY, + weekend_work_prob: float = 0.10, + occupant_id: int = 0, + lunch_duration_min: int = 30, + lunch_duration_max: int = 90, + ): + if not ( + earliest_expected_arrival_min + < latest_expected_arrival_min + < earliest_expected_departure_min + < latest_expected_departure_min + ): + raise ValueError( + "Expected arrival/departure minutes to satisfy:" + " earliest_expected_arrival_min < latest_expected_arrival_min <" + " earliest_expected_departure_min < latest_expected_departure_min" + f" (got {earliest_expected_arrival_min}," + f" {latest_expected_arrival_min}, {earliest_expected_departure_min}," + f" {latest_expected_departure_min})." + ) + if not lunch_start_min < lunch_end_min: + raise ValueError( + f"Expected lunch_start_min < lunch_end_min (got {lunch_start_min} >=" + f" {lunch_end_min})." + ) + + self._earliest_expected_arrival_min = earliest_expected_arrival_min + self._latest_expected_arrival_min = latest_expected_arrival_min + self._earliest_expected_departure_min = earliest_expected_departure_min + self._latest_expected_departure_min = latest_expected_departure_min + self._lunch_start_min = lunch_start_min + self._lunch_end_min = lunch_end_min + self._step_size = step_size + self._random_state = random_state + + if time_zone is None: + raise ValueError( + "time_zone must be provided (e.g., 'UTC' or an IANA zone)." + ) + try: + _ = pd.Timestamp("2000-01-01", tz=time_zone) + except Exception as e: + raise ValueError(f"Invalid time_zone: {time_zone!r}") from e + + self._time_zone = time_zone + self.state = OccupancyStateEnum.AWAY + self.daily_cache = {} + self.worker_type = worker_type + self.weekend_work_prob = weekend_work_prob + self.occupant_id = occupant_id + self.daily_work_cache = {} + self._lunch_duration_min = lunch_duration_min + self._lunch_duration_max = lunch_duration_max + + def _generate_cpf(self, start, end, random_state=None): + if random_state is None: + random_state = self._random_state + values = np.arange(start, end + 1) + probabilities = random_state.rand(len(values)) + cumulative_probabilities = np.cumsum(probabilities / probabilities.sum()) + return values, cumulative_probabilities + + def _sample_event_time(self, start, end, random_state=None): + if random_state is None: + random_state = self._random_state + values, cumulative_probabilities = self._generate_cpf( + start, end, random_state + ) + random_value = random_state.rand() + index = np.searchsorted(cumulative_probabilities, random_value) + logging.info( + "Sampled event time: start=%s, end=%s, value=%s", + start, + end, + values[index], + ) + return values[index] + + def _sample_lunch_duration(self, random_state=None): + if random_state is None: + random_state = self._random_state + values, cumulative_probabilities = self._generate_cpf( + self._lunch_duration_min, self._lunch_duration_max, random_state + ) + random_value = random_state.rand() + index = np.searchsorted(cumulative_probabilities, random_value) + logging.info("Sampled lunch duration: %s minutes", values[index]) + return values[index] + + def _to_local_time(self, timestamp: pd.Timestamp) -> pd.Timestamp: + """Return timestamp localised/converted to this occupant's time zone.""" + if timestamp.tz is None: + return timestamp.tz_localize(self._time_zone) + return timestamp.tz_convert(self._time_zone) + + def _get_daily_params(self, timestamp: pd.Timestamp) -> Dict[str, int]: + local_timestamp = self._to_local_time(timestamp) + date_key = local_timestamp.date() + + if self.daily_cache and list(self.daily_cache.keys())[0] != date_key: + self.daily_cache.clear() + logging.info( + "MinuteLevelZoneOccupant: cleared day cache for new date %s", date_key + ) + + if date_key in self.daily_cache: + return self.daily_cache[date_key] + + day_seed = hash(str(date_key) + str(self.occupant_id) + "daily_params") % ( + SEED_MOD_32 + ) + day_random_state = np.random.RandomState(day_seed) + + arrival = self._sample_event_time( + self._earliest_expected_arrival_min, + self._latest_expected_arrival_min, + day_random_state, + ) + departure = self._sample_event_time( + self._earliest_expected_departure_min, + self._latest_expected_departure_min, + day_random_state, + ) + lunch_start = self._sample_event_time( + self._lunch_start_min, self._lunch_end_min, day_random_state + ) + lunch_duration = self._sample_lunch_duration(day_random_state) + + self.daily_cache[date_key] = { + "arrival_time": arrival, + "departure_time": departure, + "lunch_start_time": lunch_start, + "lunch_duration": lunch_duration, + } + return self.daily_cache[date_key] + + def _minutes_since_midnight(self, local_timestamp: pd.Timestamp) -> int: + return local_timestamp.hour * 60 + local_timestamp.minute + + def _should_work_today(self, timestamp: pd.Timestamp) -> bool: + local_timestamp = self._to_local_time(timestamp) + + day = pd.Timestamp( + year=local_timestamp.year, + month=local_timestamp.month, + day=local_timestamp.day, + ) + date_key = day.date() + + if date_key in self.daily_work_cache: + return self.daily_work_cache[date_key] + + if conversion_utils.is_work_day(day): + self.daily_work_cache[date_key] = True + return True + + if self.worker_type == WorkerType.WEEKDAY_ONLY: + self.daily_work_cache[date_key] = False + return False + + if self.worker_type == WorkerType.WEEKEND_REGULAR: + self.daily_work_cache[date_key] = True + return True + + elif self.worker_type == WorkerType.WEEKEND_OCCASIONAL: + seed = hash(str(date_key) + str(self.occupant_id)) % SEED_MOD_32 + random_state = np.random.RandomState(seed) + work_today = random_state.rand() < self.weekend_work_prob + self.daily_work_cache[date_key] = work_today + return work_today + + self.daily_work_cache[date_key] = False + return False + + def _occupant_arrived(self, timestamp: pd.Timestamp) -> bool: + local_timestamp = self._to_local_time(timestamp) + + current_min = self._minutes_since_midnight(local_timestamp) + params = self._get_daily_params(timestamp) + + arrived = current_min >= params["arrival_time"] + logging.info( + "Arrival check: hour=%s, arrival_time=%s, arrived=%s", + local_timestamp.hour, + params["arrival_time"], + arrived, + ) + return arrived + + def _occupant_departed(self, timestamp: pd.Timestamp) -> bool: + local_timestamp = self._to_local_time(timestamp) + + current_min = self._minutes_since_midnight(local_timestamp) + params = self._get_daily_params(timestamp) + + departed = current_min >= params["departure_time"] + logging.info( + "Departure check: hour=%s, departure_time=%s, departed=%s", + local_timestamp.hour, + params["departure_time"], + departed, + ) + return departed + + def peek(self, current_time: pd.Timestamp) -> OccupancyStateEnum: + """Checks the current occupancy state based on the provided timestamp. + + This method determines the occupancy state (AWAY or WORK) based on + the current time, considering workdays, weekends, arrival/departure times, + and a lunch break. + + Args: + current_time: The current timestamp to evaluate. + + Returns: + The current `OccupancyStateEnum` (AWAY or WORK). + """ + local_timestamp = self._to_local_time(current_time) + + logging.info( + "Peek called: current_time=%s, local_time=%s, state_before=%s", + current_time, + local_timestamp, + self.state, + ) + + if not self._should_work_today(current_time): + self.state = OccupancyStateEnum.AWAY + return self.state + + if self._occupant_arrived(current_time) and not self._occupant_departed( + current_time + ): + self.state = OccupancyStateEnum.WORK + else: + self.state = OccupancyStateEnum.AWAY + + if self.state == OccupancyStateEnum.WORK: + current_min = self._minutes_since_midnight(local_timestamp) + params = self._get_daily_params(current_time) + lunch_start = params["lunch_start_time"] + lunch_end = lunch_start + params["lunch_duration"] + if lunch_start <= current_min < lunch_end: + self.state = OccupancyStateEnum.AWAY + return OccupancyStateEnum.AWAY + + logging.info("Peek result state=%s", self.state) + + return self.state + + +@gin.configurable +class EnhancedOccupancy(BaseOccupancy): + """Enhanced occupancy model with minute-level control and different + worker types. + """ + + def __init__( + self, + zone_assignment: int, + earliest_expected_arrival_hour: int, + latest_expected_arrival_hour: int, + earliest_expected_departure_hour: int, + latest_expected_departure_hour: int, + lunch_start_hour: int = 12, + lunch_end_hour: int = 14, + time_step: pd.Timedelta = pd.Timedelta(minutes=5), + time_zone: str = "UTC", + # 5% of the workforce are regular weekend workers + weekend_regular_pct: float = 0.05, + # 5% of the workforce are occasional weekend worker + weekend_occasional_pct: float = 0.05, + # 10% chance per weekend day that an occasional worker will work + occasional_daily_prob: float = 0.10, + ): + self._zone_assignment = zone_assignment + self._zone_occupants = {} + self._step_size = time_step + self._earliest_expected_arrival = earliest_expected_arrival_hour * 60 + self._latest_expected_arrival = latest_expected_arrival_hour * 60 + self._earliest_expected_departure = earliest_expected_departure_hour * 60 + self._latest_expected_departure = latest_expected_departure_hour * 60 + self._lunch_start = lunch_start_hour * 60 + self._lunch_end = lunch_end_hour * 60 + self._time_zone = time_zone + self._weekend_regular_pct = weekend_regular_pct + self._weekend_occasional_pct = weekend_occasional_pct + self._occasional_prob = occasional_daily_prob + + total_pct = weekend_regular_pct + weekend_occasional_pct + if total_pct > 1.0: + raise ValueError( + "Total percentage of weekend workers must be less than or equal to 1" + ) + + def _initialize_zone(self, zone_id: str): + if zone_id not in self._zone_occupants: + self._zone_occupants[zone_id] = [] + for i in range(self._zone_assignment): + worker_random_state = np.random.RandomState( + hash(f"{zone_id}_{i}") % SEED_MOD_32 + ) + u = worker_random_state.rand() + if u < self._weekend_regular_pct: + worker_type = WorkerType.WEEKEND_REGULAR + weekend_prob = 1.0 + elif u < self._weekend_regular_pct + self._weekend_occasional_pct: + worker_type = WorkerType.WEEKEND_OCCASIONAL + weekend_prob = self._occasional_prob + else: + worker_type = WorkerType.WEEKDAY_ONLY + weekend_prob = 0.0 + + occupant_random_state = np.random.RandomState( + (hash(f"{zone_id}_{i}_behaviour") % SEED_MOD_32) + ) + + self._zone_occupants[zone_id].append( + MinuteLevelZoneOccupant( + self._earliest_expected_arrival, + self._latest_expected_arrival, + self._earliest_expected_departure, + self._latest_expected_departure, + self._lunch_start, + self._lunch_end, + self._step_size, + occupant_random_state, + self._time_zone, + worker_type=worker_type, + weekend_work_prob=weekend_prob, + occupant_id=i, + ) + ) + + def average_zone_occupancy( + self, zone_id: str, start_time: pd.Timestamp, end_time: pd.Timestamp + ) -> float: + """Calculates the average occupancy within a time interval for a zone. + + Args: + zone_id: specific zone identifier for the building. + start_time: **local time** with TZ for the beginning of the interval. + end_time: **local time** with TZ for the end of the interval. + + Raises: + ValueError: If start_time or end_time is timezone-naive, or if end_time + is not after start_time. + + Returns: + Average number of people in the zone for the interval. + """ + self._initialize_zone(zone_id) + + if start_time.tz is None or end_time.tz is None: + raise ValueError("start_time and end_time must be timezone-aware.") + if start_time >= end_time: + raise ValueError("end_time must be after start_time.") + + current_time = start_time + total_occupancy = 0 + steps = 0 + + while current_time < end_time: + num_occupants = 0 + for occupant in self._zone_occupants[zone_id]: + state = occupant.peek(current_time) + if state == OccupancyStateEnum.WORK: + num_occupants += 1 + + total_occupancy += num_occupants + steps += 1 + current_time += self._step_size + + return total_occupancy / steps if steps > 0 else 0.0 + + def get_worker_distribution(self, zone_id: str) -> Dict[str, int]: + """Returns the distribution of worker types in the given zone. + + Args: + zone_id: The specific zone identifier for the building. + + Returns: + A dictionary with counts for each worker type: + { + "weekday_only": int, + "weekend_regular": int, + "weekend_occasional": int, + }. + """ + self._initialize_zone(zone_id) + counts = {"weekday_only": 0, "weekend_regular": 0, "weekend_occasional": 0} + for occupant in self._zone_occupants[zone_id]: + if occupant.worker_type == WorkerType.WEEKDAY_ONLY: + counts["weekday_only"] += 1 + elif occupant.worker_type == WorkerType.WEEKEND_REGULAR: + counts["weekend_regular"] += 1 + elif occupant.worker_type == WorkerType.WEEKEND_OCCASIONAL: + counts["weekend_occasional"] += 1 + return counts diff --git a/smart_control/simulator/enhanced_occupancy_test.py b/smart_control/simulator/enhanced_occupancy_test.py new file mode 100644 index 00000000..517cd6b7 --- /dev/null +++ b/smart_control/simulator/enhanced_occupancy_test.py @@ -0,0 +1,282 @@ +"""Test the enhanced occupancy model.""" + +from absl.testing import absltest +from absl.testing import parameterized +import numpy as np +import pandas as pd + +from smart_control.simulator.enhanced_occupancy import EnhancedOccupancy +from smart_control.simulator.enhanced_occupancy import MinuteLevelZoneOccupant +from smart_control.simulator.enhanced_occupancy import OccupancyStateEnum +from smart_control.simulator.enhanced_occupancy import WorkerType + +DEBUG_PRINT = False +SEED = 511211 +TIME_STEP = pd.Timedelta(minutes=5) +EARLIEST_EXPECTED_ARRIVAL_HOUR = 8 +LATEST_EXPECTED_ARRIVAL_HOUR = 10 +EARLIEST_EXPECTED_DEPARTURE_HOUR = 16 +LATEST_EXPECTED_DEPARTURE_HOUR = 18 +LUNCH_START_HOUR = 12 +LUNCH_END_HOUR = 14 +NUM_OCCUPANTS = 10 +DEFAULT_TIMEZONE = 'UTC' +NO_WEEKEND_WORKERS_REGULAR_PCT = 0.0 +NO_WEEKEND_WORKERS_OCCASIONAL_PCT = 0.0 +NO_WEEKEND_WORKERS_DAILY_PROB = 0.0 +REGULAR_WEEKEND_WORKERS_REGULAR_PCT = 0.2 +REGULAR_WEEKEND_WORKERS_OCCASIONAL_PCT = 0.0 +REGULAR_WEEKEND_WORKERS_DAILY_PROB = 0.0 +TEST_SETUP_REGULAR_PCT = 0.1 +TEST_SETUP_OCCASIONAL_PCT = 0.2 +TEST_SETUP_DAILY_PROB = 0.3 + + +class EnhancedOccupancyTest(parameterized.TestCase): + + @parameterized.parameters('UTC', 'US/Pacific', 'US/Eastern') + def test_average_occupancy_weekday(self, tz): + occupancy = EnhancedOccupancy( + zone_assignment=NUM_OCCUPANTS, + earliest_expected_arrival_hour=EARLIEST_EXPECTED_ARRIVAL_HOUR, + latest_expected_arrival_hour=LATEST_EXPECTED_ARRIVAL_HOUR, + earliest_expected_departure_hour=EARLIEST_EXPECTED_DEPARTURE_HOUR, + latest_expected_departure_hour=LATEST_EXPECTED_DEPARTURE_HOUR, + lunch_start_hour=LUNCH_START_HOUR, + lunch_end_hour=LUNCH_END_HOUR, + time_step=TIME_STEP, + time_zone=tz, + weekend_regular_pct=NO_WEEKEND_WORKERS_REGULAR_PCT, + weekend_occasional_pct=NO_WEEKEND_WORKERS_OCCASIONAL_PCT, + occasional_daily_prob=NO_WEEKEND_WORKERS_DAILY_PROB, + ) + + current_time = pd.Timestamp('2021-09-01 00:00', tz=tz) + occupancies = [] + while current_time < pd.Timestamp('2021-09-02 00:00', tz=tz): + n = occupancy.average_zone_occupancy( + 'zone_0', current_time, current_time + TIME_STEP + ) + occupancies.append(n) + current_time += TIME_STEP + + early_morning_avg = np.mean(occupancies[0:48]) # 48 time steps = 4 hours + morning_avg = np.mean(occupancies[96:132]) # 8-11 + lunch_avg = np.mean(occupancies[144:168]) # 12-14 + afternoon_avg = np.mean(occupancies[180:204]) # 15-17 + evening_avg = np.mean(occupancies[240:288]) # 20-24 + + self.assertEqual(early_morning_avg, 0.0) + self.assertEqual(evening_avg, 0.0) + self.assertGreater(morning_avg, 0.0) + self.assertGreater(afternoon_avg, 0.0) + self.assertLess(lunch_avg, NUM_OCCUPANTS) + + def test_weekend_occupancy(self): + weekday_only_occupancy = EnhancedOccupancy( + zone_assignment=100, + earliest_expected_arrival_hour=EARLIEST_EXPECTED_ARRIVAL_HOUR, + latest_expected_arrival_hour=LATEST_EXPECTED_ARRIVAL_HOUR, + earliest_expected_departure_hour=EARLIEST_EXPECTED_DEPARTURE_HOUR, + latest_expected_departure_hour=LATEST_EXPECTED_DEPARTURE_HOUR, + lunch_start_hour=LUNCH_START_HOUR, + lunch_end_hour=LUNCH_END_HOUR, + time_step=TIME_STEP, + time_zone=DEFAULT_TIMEZONE, + weekend_regular_pct=NO_WEEKEND_WORKERS_REGULAR_PCT, + weekend_occasional_pct=NO_WEEKEND_WORKERS_OCCASIONAL_PCT, + occasional_daily_prob=NO_WEEKEND_WORKERS_DAILY_PROB, + ) + + weekend_regular_occupancy = EnhancedOccupancy( + zone_assignment=100, + earliest_expected_arrival_hour=EARLIEST_EXPECTED_ARRIVAL_HOUR, + latest_expected_arrival_hour=LATEST_EXPECTED_ARRIVAL_HOUR, + earliest_expected_departure_hour=EARLIEST_EXPECTED_DEPARTURE_HOUR, + latest_expected_departure_hour=LATEST_EXPECTED_DEPARTURE_HOUR, + lunch_start_hour=LUNCH_START_HOUR, + lunch_end_hour=LUNCH_END_HOUR, + time_step=TIME_STEP, + time_zone=DEFAULT_TIMEZONE, + weekend_regular_pct=REGULAR_WEEKEND_WORKERS_REGULAR_PCT, + weekend_occasional_pct=REGULAR_WEEKEND_WORKERS_OCCASIONAL_PCT, + occasional_daily_prob=REGULAR_WEEKEND_WORKERS_DAILY_PROB, + ) + saturday_morning_start = pd.Timestamp( + '2021-09-04 08:00', tz=DEFAULT_TIMEZONE + ) + saturday_morning_end = pd.Timestamp('2021-09-04 12:00', tz=DEFAULT_TIMEZONE) + weekday_only_occupancy = weekday_only_occupancy.average_zone_occupancy( + 'zone_0', saturday_morning_start, saturday_morning_end + ) + weekend_regular_occupancy = ( + weekend_regular_occupancy.average_zone_occupancy( + 'zone_0', saturday_morning_start, saturday_morning_end + ) + ) + self.assertEqual(weekday_only_occupancy, 0.0) + self.assertGreater(weekend_regular_occupancy, 0.0) + + def test_worker_distribution(self): + occupancy = EnhancedOccupancy( + zone_assignment=1000, + earliest_expected_arrival_hour=EARLIEST_EXPECTED_ARRIVAL_HOUR, + latest_expected_arrival_hour=LATEST_EXPECTED_ARRIVAL_HOUR, + earliest_expected_departure_hour=EARLIEST_EXPECTED_DEPARTURE_HOUR, + latest_expected_departure_hour=LATEST_EXPECTED_DEPARTURE_HOUR, + lunch_start_hour=LUNCH_START_HOUR, + lunch_end_hour=LUNCH_END_HOUR, + time_step=TIME_STEP, + time_zone=DEFAULT_TIMEZONE, + weekend_regular_pct=TEST_SETUP_REGULAR_PCT, + weekend_occasional_pct=TEST_SETUP_OCCASIONAL_PCT, + occasional_daily_prob=TEST_SETUP_DAILY_PROB, + ) + distribution = occupancy.get_worker_distribution('zone_0') + total_workers = sum(distribution.values()) + self.assertEqual(total_workers, 1000) + + expected_regular = 1000 * TEST_SETUP_REGULAR_PCT + expected_occasional = 1000 * TEST_SETUP_OCCASIONAL_PCT + expected_weekday = 1000 * ( + 1.0 - TEST_SETUP_REGULAR_PCT - TEST_SETUP_OCCASIONAL_PCT + ) + self.assertAlmostEqual( + distribution['weekday_only'], expected_weekday, delta=100 + ) + self.assertAlmostEqual( + distribution['weekend_regular'], expected_regular, delta=100 + ) + self.assertAlmostEqual( + distribution['weekend_occasional'], expected_occasional, delta=100 + ) + + def test_parameter_variation(self): + occupant = MinuteLevelZoneOccupant( + earliest_expected_arrival_min=EARLIEST_EXPECTED_ARRIVAL_HOUR * 60, + latest_expected_arrival_min=LATEST_EXPECTED_ARRIVAL_HOUR * 60, + earliest_expected_departure_min=EARLIEST_EXPECTED_DEPARTURE_HOUR * 60, + latest_expected_departure_min=LATEST_EXPECTED_DEPARTURE_HOUR * 60, + lunch_start_min=LUNCH_START_HOUR * 60, + lunch_end_min=LUNCH_END_HOUR * 60, + step_size=TIME_STEP, + random_state=np.random.RandomState(seed=SEED), + time_zone=DEFAULT_TIMEZONE, + worker_type=WorkerType.WEEKDAY_ONLY, + weekend_work_prob=NO_WEEKEND_WORKERS_DAILY_PROB, + occupant_id=0, + ) + day1_morning = pd.Timestamp('2021-09-01 09:00', tz=DEFAULT_TIMEZONE) + day1_afternoon = pd.Timestamp('2021-09-01 15:00', tz=DEFAULT_TIMEZONE) + day2_morning = pd.Timestamp('2021-09-02 09:00', tz=DEFAULT_TIMEZONE) + params1_morning = occupant._get_daily_params(day1_morning) + params1_afternoon = occupant._get_daily_params(day1_afternoon) + params2_morning = occupant._get_daily_params(day2_morning) + self.assertEqual(params1_morning, params1_afternoon) + self.assertNotEqual(params1_morning, params2_morning) + + @parameterized.parameters('UTC', 'US/Eastern', 'US/Pacific') + def test_occupant_peek(self, tz): + occupant = MinuteLevelZoneOccupant( + earliest_expected_arrival_min=EARLIEST_EXPECTED_ARRIVAL_HOUR * 60, + latest_expected_arrival_min=LATEST_EXPECTED_ARRIVAL_HOUR * 60, + earliest_expected_departure_min=EARLIEST_EXPECTED_DEPARTURE_HOUR * 60, + latest_expected_departure_min=LATEST_EXPECTED_DEPARTURE_HOUR * 60, + lunch_start_min=LUNCH_START_HOUR * 60, + lunch_end_min=LUNCH_END_HOUR * 60, + step_size=TIME_STEP, + random_state=np.random.RandomState(seed=SEED), + time_zone=tz, + worker_type=WorkerType.WEEKDAY_ONLY, + weekend_work_prob=NO_WEEKEND_WORKERS_DAILY_PROB, + occupant_id=0, + ) + day = pd.Timestamp('2021-09-01 00:00', tz=tz) + params = occupant._get_daily_params(day) + day1_early_morning = pd.Timestamp('2021-09-01 06:00', tz=tz) + day1_work_morning = pd.Timestamp('2021-09-01 10:00', tz=tz) + day1_afternoon = pd.Timestamp('2021-09-01 15:00', tz=tz) + day1_evening = pd.Timestamp('2021-09-01 20:00', tz=tz) + weekend = pd.Timestamp('2021-09-05 08:00', tz=tz) + + def expected_state(ts: pd.Timestamp): + ts_local = ts.tz_convert(tz) + minutes = ts_local.hour * 60 + ts_local.minute + in_work = params['arrival_time'] <= minutes < params['departure_time'] + in_lunch = ( + params['lunch_start_time'] + <= minutes + < params['lunch_start_time'] + params['lunch_duration'] + ) + return ( + OccupancyStateEnum.WORK + if (in_work and not in_lunch) + else OccupancyStateEnum.AWAY + ) + + self.assertEqual(occupant.peek(day1_early_morning), OccupancyStateEnum.AWAY) + self.assertEqual( + occupant.peek(day1_work_morning), expected_state(day1_work_morning) + ) + self.assertEqual( + occupant.peek(day1_afternoon), expected_state(day1_afternoon) + ) + self.assertEqual(occupant.peek(day1_evening), OccupancyStateEnum.AWAY) + self.assertEqual(occupant.peek(weekend), OccupancyStateEnum.AWAY) + + def test_occasional_worker(self): + occupant = MinuteLevelZoneOccupant( + earliest_expected_arrival_min=EARLIEST_EXPECTED_ARRIVAL_HOUR * 60, + latest_expected_arrival_min=LATEST_EXPECTED_ARRIVAL_HOUR * 60, + earliest_expected_departure_min=EARLIEST_EXPECTED_DEPARTURE_HOUR * 60, + latest_expected_departure_min=LATEST_EXPECTED_DEPARTURE_HOUR * 60, + lunch_start_min=LUNCH_START_HOUR * 60, + lunch_end_min=LUNCH_END_HOUR * 60, + step_size=TIME_STEP, + random_state=np.random.RandomState(seed=SEED), + time_zone=DEFAULT_TIMEZONE, + worker_type=WorkerType.WEEKEND_OCCASIONAL, + weekend_work_prob=0.5, + occupant_id=13, + ) + saturday_morning = pd.Timestamp('2021-09-04 08:00', tz=DEFAULT_TIMEZONE) + saturday_afternoon = pd.Timestamp('2021-09-04 15:00', tz=DEFAULT_TIMEZONE) + work_decision_morning = occupant._should_work_today(saturday_morning) + work_decision_afternoon = occupant._should_work_today(saturday_afternoon) + self.assertEqual(work_decision_morning, work_decision_afternoon) + + def test_minutes_precision(self): + occupancy = EnhancedOccupancy( + zone_assignment=NUM_OCCUPANTS, + earliest_expected_arrival_hour=EARLIEST_EXPECTED_ARRIVAL_HOUR, + latest_expected_arrival_hour=LATEST_EXPECTED_ARRIVAL_HOUR, + earliest_expected_departure_hour=EARLIEST_EXPECTED_DEPARTURE_HOUR, + latest_expected_departure_hour=LATEST_EXPECTED_DEPARTURE_HOUR, + lunch_start_hour=LUNCH_START_HOUR, + lunch_end_hour=LUNCH_END_HOUR, + time_step=TIME_STEP, + time_zone=DEFAULT_TIMEZONE, + weekend_regular_pct=NO_WEEKEND_WORKERS_REGULAR_PCT, + weekend_occasional_pct=NO_WEEKEND_WORKERS_OCCASIONAL_PCT, + occasional_daily_prob=NO_WEEKEND_WORKERS_DAILY_PROB, + ) + time_758 = pd.Timestamp('2021-09-01 07:58', tz=DEFAULT_TIMEZONE) + time_759 = pd.Timestamp('2021-09-01 07:59', tz=DEFAULT_TIMEZONE) + morning_start = pd.Timestamp('2021-09-01 08:30', tz=DEFAULT_TIMEZONE) + morning_end = pd.Timestamp('2021-09-01 11:30', tz=DEFAULT_TIMEZONE) + occ_758 = occupancy.average_zone_occupancy( + 'zone_0', time_758, time_758 + pd.Timedelta(minutes=1) + ) + occ_759 = occupancy.average_zone_occupancy( + 'zone_0', time_759, time_759 + pd.Timedelta(minutes=1) + ) + morning_occupancy = occupancy.average_zone_occupancy( + 'zone_0', morning_start, morning_end + ) + self.assertEqual(occ_758, 0.0) + self.assertEqual(occ_759, 0.0) + self.assertGreater(morning_occupancy, 0.0) + + +if __name__ == '__main__': + absltest.main() diff --git a/smart_control/simulator/randomized_arrival_departure_occupancy.py b/smart_control/simulator/randomized_arrival_departure_occupancy.py index fc6948c1..f2f100fa 100644 --- a/smart_control/simulator/randomized_arrival_departure_occupancy.py +++ b/smart_control/simulator/randomized_arrival_departure_occupancy.py @@ -197,8 +197,15 @@ def average_zone_occupancy( ) ) - num_occupants = 0.0 - for occupant in self._zone_occupants[zone_id]: - if occupant.peek(start_time) == OccupancyStateEnum.WORK: - num_occupants += 1.0 - return num_occupants + current_time = start_time + total_occupants = 0.0 + steps = 0 + while current_time < end_time: + num_occupants = 0.0 + for occupant in self._zone_occupants[zone_id]: + if occupant.peek(current_time) == OccupancyStateEnum.WORK: + num_occupants += 1.0 + total_occupants += num_occupants + steps += 1 + current_time += self._step_size + return total_occupants / steps if steps > 0 else 0.0 diff --git a/smart_control/simulator/randomized_arrival_departure_occupancy_test.py b/smart_control/simulator/randomized_arrival_departure_occupancy_test.py index ac12b4a0..ea691791 100644 --- a/smart_control/simulator/randomized_arrival_departure_occupancy_test.py +++ b/smart_control/simulator/randomized_arrival_departure_occupancy_test.py @@ -6,6 +6,8 @@ import pandas as pd from smart_control.simulator import randomized_arrival_departure_occupancy +from smart_control.simulator.randomized_arrival_departure_occupancy import OccupancyStateEnum +from smart_control.simulator.randomized_arrival_departure_occupancy import RandomizedArrivalDepartureOccupancy # fmt: off # pylint: disable=bad-continuation @@ -139,6 +141,43 @@ def test_peek(self, tz): ) current_time += pd.Timedelta(5, unit='minute') + def test_average_zone_occupancy_matches_manual_two_steps(self): + """average_zone_occupancy should equal the mean of per-step counts.""" + step = pd.Timedelta(minutes=5) + tz = 'UTC' + + occ = RandomizedArrivalDepartureOccupancy( + zone_assignment=7, + earliest_expected_arrival_hour=8, + latest_expected_arrival_hour=12, + earliest_expected_departure_hour=16, + latest_expected_departure_hour=20, + time_step_sec=step.total_seconds(), + seed=55213, + time_zone=tz, + ) + + t0 = pd.Timestamp('2021-09-01 10:00', tz=tz) + t1 = t0 + 2 * step + + # initialise the zone + _ = occ.average_zone_occupancy('zone_0', t0, t0 + step) + + manual_counts = [] + for cur in (t0, t0 + step): + c = 0.0 + for zocc in occ._zone_occupants['zone_0']: + if zocc.peek(cur) == OccupancyStateEnum.WORK: + c += 1.0 + manual_counts.append(c) + manual_avg = sum(manual_counts) / 2.0 + + result = occ.average_zone_occupancy('zone_0', t0, t1) + + # In the old implementation this would have returned manual_counts[0] + # and the assertion would fail. With the fix, it matches the average. + assert result == manual_avg + if __name__ == '__main__': absltest.main()