From 99ffb211be5a850aa31ee8dd948096dfcb81b586 Mon Sep 17 00:00:00 2001 From: amcberkes Date: Fri, 29 Aug 2025 14:22:34 +0100 Subject: [PATCH 1/2] Added enhancec occupancy model with minute-level control and different occupant types to model weekend behaviour, included daily parameter sampling for realistic behaviour that changes from day to day. Added test file for the new occupancy model. Fixed bug in randomiued_arrival_departure_occupancy.py --- .github/ISSUE_TEMPLATE.md | 8 +- .github/PULL_REQUEST_TEMPLATE.md | 4 +- smart_control/simulator/enhanced_occupancy.py | 416 ++++++++++++++++++ .../simulator/enhanced_occupancy_test.py | 259 +++++++++++ .../randomized_arrival_departure_occupancy.py | 17 +- 5 files changed, 692 insertions(+), 12 deletions(-) create mode 100644 smart_control/simulator/enhanced_occupancy.py create mode 100644 smart_control/simulator/enhanced_occupancy_test.py 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..8cf809ba --- /dev/null +++ b/smart_control/simulator/enhanced_occupancy.py @@ -0,0 +1,416 @@ +"""A 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 repreats 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 + +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 + +debug_print = False # Set to False to disable debugging + + +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 EnhancedZoneOccupant: + """EnhancedZone Occupant with minute-level contril and day-specific + parameters.""" + + 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, + ): + assert ( + earliest_expected_arrival_min + < latest_expected_arrival_min + < earliest_expected_departure_min + < latest_expected_departure_min + ) + assert lunch_start_min < 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 + 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.id = occupant_id + self.daily_work_cache = {} + + 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) + if debug_print: + print( + f"Sampled event time: start={start}, end={end}, value={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(30, 90, random_state) + random_value = random_state.rand() + index = np.searchsorted(cumulative_probabilities, random_value) + if debug_print: + print(f"Sampled lunch duration: {values[index]} minutes") + return values[index] + + def _to_local_time(self, timestamp: pd.Timestamp) -> pd.Timestamp: + if timestamp.tz is None: + if self._time_zone is None: + return timestamp + return timestamp.tz_localize(self._time_zone) + return timestamp.tz_convert(self._time_zone) + + def _get_daily_params( + self, timestamp: pd.Timestamp, local_timestamp: pd.Timestamp = None + ) -> Dict[str, int]: + if local_timestamp is None: + 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() + + 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") % ( + 2**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, local_timestamp: pd.Timestamp = None + ) -> bool: + if local_timestamp is None: + 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.id)) % 2**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, local_timestamp: pd.Timestamp = None + ) -> bool: + if local_timestamp is None: + local_timestamp = self._to_local_time(timestamp) + + current_min = self._minutes_since_midnight(local_timestamp) + params = self._get_daily_params(timestamp, local_timestamp) + + arrived = current_min >= params["arrival_time"] + if debug_print: + print( + f"Check arrival: local_time_hour={local_timestamp.hour}," + f" arrival_time={params['arrival_time']}, arrived={arrived}" + ) + return arrived + + def _occupant_departed( + self, timestamp: pd.Timestamp, local_timestamp: pd.Timestamp = None + ) -> bool: + if local_timestamp is None: + local_timestamp = self._to_local_time(timestamp) + + current_min = self._minutes_since_midnight(local_timestamp) + params = self._get_daily_params(timestamp, local_timestamp) + + departed = current_min >= params["departure_time"] + if debug_print: + print( + f"Check departure: local_time_hour={local_timestamp.hour}," + f" departure_time={params['departure_time']}, departed={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) + + if debug_print: + print( + f"Peek called: current_time={current_time}," + f" local_time={local_timestamp}, state={self.state}" + ) + + if not self._should_work_today(current_time, local_timestamp): + self.state = OccupancyStateEnum.AWAY + return self.state + + # Check arrival and departure + if self._occupant_arrived( + current_time, local_timestamp + ) and not self._occupant_departed(current_time, local_timestamp): + self.state = OccupancyStateEnum.WORK + else: + self.state = OccupancyStateEnum.AWAY + + # Handle lunch break + if self.state == OccupancyStateEnum.WORK: + current_min = self._minutes_since_midnight(local_timestamp) + params = self._get_daily_params(current_time, local_timestamp) + 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 + + if debug_print: + print(f"Occupancy state: {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.0" + ) + + 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}") % 2**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}_behavior") % 2**32) + ) + + self._zone_occupants[zone_id].append( + EnhancedZoneOccupant( + 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. + + Returns: + Average number of people in the zone for the interval. + """ + self._initialize_zone(zone_id) + + 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]: + 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..14d7afee --- /dev/null +++ b/smart_control/simulator/enhanced_occupancy_test.py @@ -0,0 +1,259 @@ +"""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 EnhancedZoneOccupant +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') + occupancies = [] + while current_time < pd.Timestamp('2021-09-02 00:00'): + 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') + saturday_morning_end = pd.Timestamp('2021-09-04 12:00') + 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 = EnhancedZoneOccupant( + 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') + day1_afternoon = pd.Timestamp('2021-09-01 15:00') + day2_morning = pd.Timestamp('2021-09-02 09:00') + 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(None, 'UTC', 'US/Eastern', 'US/Pacific') + def test_occupant_peek(self, tz): + occupant = EnhancedZoneOccupant( + 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, + ) + day1_early_morning = pd.Timestamp('2021-09-01 06:00') + day1_work_morning = pd.Timestamp('2021-09-01 10:00') + day1_afternoon = pd.Timestamp('2021-09-01 15:00') + day1_evening = pd.Timestamp('2021-09-01 20:00') + weekend = pd.Timestamp('2021-09-05 08:00') + + self.assertEqual(occupant.peek(day1_early_morning), OccupancyStateEnum.AWAY) + self.assertEqual(occupant.peek(day1_work_morning), OccupancyStateEnum.WORK) + self.assertEqual(occupant.peek(day1_afternoon), OccupancyStateEnum.WORK) + self.assertEqual(occupant.peek(day1_evening), OccupancyStateEnum.AWAY) + self.assertEqual(occupant.peek(weekend), OccupancyStateEnum.AWAY) + + def test_occasional_worker(self): + occupant = EnhancedZoneOccupant( + 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') + saturday_afternoon = pd.Timestamp('2021-09-04 15:00') + 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 From a093061bc2faf87dffabe27f2534f8a5a83d8999 Mon Sep 17 00:00:00 2001 From: amcberkes Date: Fri, 5 Sep 2025 11:49:16 +0100 Subject: [PATCH 2/2] addressed review comments --- smart_control/simulator/enhanced_occupancy.py | 203 +++++++++++------- .../simulator/enhanced_occupancy_test.py | 65 ++++-- ...omized_arrival_departure_occupancy_test.py | 39 ++++ 3 files changed, 211 insertions(+), 96 deletions(-) diff --git a/smart_control/simulator/enhanced_occupancy.py b/smart_control/simulator/enhanced_occupancy.py index 8cf809ba..93150076 100644 --- a/smart_control/simulator/enhanced_occupancy.py +++ b/smart_control/simulator/enhanced_occupancy.py @@ -1,11 +1,11 @@ -"""A enhanced stochastic occupancy model for building simulation -with minute-level control and different worker types.This model is based on the +"""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 repreats itself every weekday of the year). +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 @@ -16,6 +16,7 @@ import enum from typing import Dict, Union +from absl import logging import gin import numpy as np import pandas as pd @@ -23,7 +24,9 @@ from smart_control.models.base_occupancy import BaseOccupancy from smart_control.utils import conversion_utils -debug_print = False # Set to False to disable debugging +# 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): @@ -39,9 +42,20 @@ class WorkerType(enum.Enum): ) -class EnhancedZoneOccupant: - """EnhancedZone Occupant with minute-level contril and day-specific - parameters.""" +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, @@ -57,14 +71,28 @@ def __init__( 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, ): - assert ( + if not ( earliest_expected_arrival_min < latest_expected_arrival_min < earliest_expected_departure_min < latest_expected_departure_min - ) - assert lunch_start_min < lunch_end_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 @@ -74,14 +102,25 @@ def __init__( 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.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: @@ -99,45 +138,46 @@ def _sample_event_time(self, start, end, random_state=None): ) random_value = random_state.rand() index = np.searchsorted(cumulative_probabilities, random_value) - if debug_print: - print( - f"Sampled event time: start={start}, end={end}, value={values[index]}" - ) + 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(30, 90, 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) - if debug_print: - print(f"Sampled lunch duration: {values[index]} minutes") + 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: - if self._time_zone is None: - return timestamp return timestamp.tz_localize(self._time_zone) return timestamp.tz_convert(self._time_zone) - def _get_daily_params( - self, timestamp: pd.Timestamp, local_timestamp: pd.Timestamp = None - ) -> Dict[str, int]: - if local_timestamp is None: - local_timestamp = self._to_local_time(timestamp) - + 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") % ( - 2**32 + SEED_MOD_32 ) day_random_state = np.random.RandomState(day_seed) @@ -167,11 +207,8 @@ def _get_daily_params( 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, local_timestamp: pd.Timestamp = None - ) -> bool: - if local_timestamp is None: - local_timestamp = self._to_local_time(timestamp) + def _should_work_today(self, timestamp: pd.Timestamp) -> bool: + local_timestamp = self._to_local_time(timestamp) day = pd.Timestamp( year=local_timestamp.year, @@ -196,7 +233,7 @@ def _should_work_today( return True elif self.worker_type == WorkerType.WEEKEND_OCCASIONAL: - seed = hash(str(date_key) + str(self.id)) % 2**32 + 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 @@ -205,38 +242,34 @@ def _should_work_today( self.daily_work_cache[date_key] = False return False - def _occupant_arrived( - self, timestamp: pd.Timestamp, local_timestamp: pd.Timestamp = None - ) -> bool: - if local_timestamp is None: - local_timestamp = self._to_local_time(timestamp) + 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, local_timestamp) + params = self._get_daily_params(timestamp) arrived = current_min >= params["arrival_time"] - if debug_print: - print( - f"Check arrival: local_time_hour={local_timestamp.hour}," - f" arrival_time={params['arrival_time']}, arrived={arrived}" - ) + 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, local_timestamp: pd.Timestamp = None - ) -> bool: - if local_timestamp is None: - local_timestamp = self._to_local_time(timestamp) + 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, local_timestamp) + params = self._get_daily_params(timestamp) departed = current_min >= params["departure_time"] - if debug_print: - print( - f"Check departure: local_time_hour={local_timestamp.hour}," - f" departure_time={params['departure_time']}, departed={departed}" - ) + 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: @@ -254,36 +287,34 @@ def peek(self, current_time: pd.Timestamp) -> OccupancyStateEnum: """ local_timestamp = self._to_local_time(current_time) - if debug_print: - print( - f"Peek called: current_time={current_time}," - f" local_time={local_timestamp}, state={self.state}" - ) + 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, local_timestamp): + if not self._should_work_today(current_time): self.state = OccupancyStateEnum.AWAY return self.state - # Check arrival and departure - if self._occupant_arrived( - current_time, local_timestamp - ) and not self._occupant_departed(current_time, local_timestamp): + if self._occupant_arrived(current_time) and not self._occupant_departed( + current_time + ): self.state = OccupancyStateEnum.WORK else: self.state = OccupancyStateEnum.AWAY - # Handle lunch break if self.state == OccupancyStateEnum.WORK: current_min = self._minutes_since_midnight(local_timestamp) - params = self._get_daily_params(current_time, 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 - if debug_print: - print(f"Occupancy state: {self.state}") + logging.info("Peek result state=%s", self.state) return self.state @@ -291,7 +322,8 @@ def peek(self, current_time: pd.Timestamp) -> OccupancyStateEnum: @gin.configurable class EnhancedOccupancy(BaseOccupancy): """Enhanced occupancy model with minute-level control and different - worker types.""" + worker types. + """ def __init__( self, @@ -328,8 +360,7 @@ def __init__( 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.0" + "Total percentage of weekend workers must be less than or equal to 1" ) def _initialize_zone(self, zone_id: str): @@ -337,7 +368,7 @@ def _initialize_zone(self, zone_id: str): self._zone_occupants[zone_id] = [] for i in range(self._zone_assignment): worker_random_state = np.random.RandomState( - hash(f"{zone_id}_{i}") % 2**32 + hash(f"{zone_id}_{i}") % SEED_MOD_32 ) u = worker_random_state.rand() if u < self._weekend_regular_pct: @@ -351,11 +382,11 @@ def _initialize_zone(self, zone_id: str): weekend_prob = 0.0 occupant_random_state = np.random.RandomState( - (hash(f"{zone_id}_{i}_behavior") % 2**32) + (hash(f"{zone_id}_{i}_behaviour") % SEED_MOD_32) ) self._zone_occupants[zone_id].append( - EnhancedZoneOccupant( + MinuteLevelZoneOccupant( self._earliest_expected_arrival, self._latest_expected_arrival, self._earliest_expected_departure, @@ -381,11 +412,20 @@ def average_zone_occupancy( 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 @@ -404,6 +444,19 @@ def average_zone_occupancy( 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]: diff --git a/smart_control/simulator/enhanced_occupancy_test.py b/smart_control/simulator/enhanced_occupancy_test.py index 14d7afee..517cd6b7 100644 --- a/smart_control/simulator/enhanced_occupancy_test.py +++ b/smart_control/simulator/enhanced_occupancy_test.py @@ -6,7 +6,7 @@ import pandas as pd from smart_control.simulator.enhanced_occupancy import EnhancedOccupancy -from smart_control.simulator.enhanced_occupancy import EnhancedZoneOccupant +from smart_control.simulator.enhanced_occupancy import MinuteLevelZoneOccupant from smart_control.simulator.enhanced_occupancy import OccupancyStateEnum from smart_control.simulator.enhanced_occupancy import WorkerType @@ -51,9 +51,9 @@ def test_average_occupancy_weekday(self, tz): occasional_daily_prob=NO_WEEKEND_WORKERS_DAILY_PROB, ) - current_time = pd.Timestamp('2021-09-01 00:00') + current_time = pd.Timestamp('2021-09-01 00:00', tz=tz) occupancies = [] - while current_time < pd.Timestamp('2021-09-02 00:00'): + 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 ) @@ -102,8 +102,10 @@ def test_weekend_occupancy(self): 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') - saturday_morning_end = pd.Timestamp('2021-09-04 12:00') + 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 ) @@ -150,7 +152,7 @@ def test_worker_distribution(self): ) def test_parameter_variation(self): - occupant = EnhancedZoneOccupant( + 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, @@ -164,18 +166,18 @@ def test_parameter_variation(self): weekend_work_prob=NO_WEEKEND_WORKERS_DAILY_PROB, occupant_id=0, ) - day1_morning = pd.Timestamp('2021-09-01 09:00') - day1_afternoon = pd.Timestamp('2021-09-01 15:00') - day2_morning = pd.Timestamp('2021-09-02 09:00') + 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(None, 'UTC', 'US/Eastern', 'US/Pacific') + @parameterized.parameters('UTC', 'US/Eastern', 'US/Pacific') def test_occupant_peek(self, tz): - occupant = EnhancedZoneOccupant( + 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, @@ -189,20 +191,41 @@ def test_occupant_peek(self, tz): weekend_work_prob=NO_WEEKEND_WORKERS_DAILY_PROB, occupant_id=0, ) - day1_early_morning = pd.Timestamp('2021-09-01 06:00') - day1_work_morning = pd.Timestamp('2021-09-01 10:00') - day1_afternoon = pd.Timestamp('2021-09-01 15:00') - day1_evening = pd.Timestamp('2021-09-01 20:00') - weekend = pd.Timestamp('2021-09-05 08:00') + 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), OccupancyStateEnum.WORK) - self.assertEqual(occupant.peek(day1_afternoon), OccupancyStateEnum.WORK) + 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 = EnhancedZoneOccupant( + 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, @@ -216,8 +239,8 @@ def test_occasional_worker(self): weekend_work_prob=0.5, occupant_id=13, ) - saturday_morning = pd.Timestamp('2021-09-04 08:00') - saturday_afternoon = pd.Timestamp('2021-09-04 15:00') + 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) 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()