# coding=utf-8
"""Schedule describing a single day."""
from __future__ import division
from .typelimit import ScheduleTypeLimit
from ..reader import parse_idf_string
from ..writer import generate_idf_string
from honeybee._lockable import lockable
from honeybee.typing import valid_ep_string, tuple_with_length
from ladybug.datacollection import HourlyContinuousCollection
from ladybug.header import Header
from ladybug.analysisperiod import AnalysisPeriod
from ladybug.dt import Date, Time
from ladybug.datatype.generic import GenericType
from collections import deque
try:
from collections.abc import Iterable # python < 3.7
except ImportError:
from collections import Iterable # python >= 3.8
[docs]
@lockable
class ScheduleDay(object):
"""Schedule for a single day.
Note that a ScheduleDay cannot be assigned to Rooms, Shades, etc. The ScheduleDay
must be added to a ScheduleRuleset or a ScheduleRule and then the ScheduleRuleset
can be applied to such objects.
Args:
identifier: Text string for a unique ScheduleDay ID. Must be < 100 characters
and not contain any EnergyPlus special characters. This will be used to
identify the object across a model and in the exported IDF.
values: A list of floats or integers for the values of the schedule.
The length of this list must match the length of the times list.
times: A list of ladybug Time objects with the same length as the input
values. Each time represents the time of day that the corresponding
value begins to take effect. For example [0:00, 9:00, 17:00] in
combination with the values [0, 1, 0] denotes a schedule value of
0 from 0:00 to 9:00, a value of 1 from 9:00 to 17:00 and 0 from 17:00
to the end of the day.
If this input is None, the default will be a single time at 0:00,
indicating the `values` input should be a single constant value that
goes all of the way until the end of the day.
Note that these times follow a different convention than EnergyPlus,
which uses "time until" instead of "time of beginning".
interpolate: Boolean to note whether values in between times should be
linearly interpolated or whether successive values should take effect
immediately upon the beginning time corresponding to them. Default: False
Properties:
* identifier
* display_name
* times
* values
* interpolate
* is_constant
"""
__slots__ = ('_identifier', '_display_name', '_values', '_times',
'_interpolate', '_parent', '_locked')
_start_of_day = Time(0, 0)
VALIDTIMESTEPS = (1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, 60)
def __init__(self, identifier, values, times=None, interpolate=False):
"""Initialize Schedule Day."""
self._locked = False # unlocked by default
self._parent = None # no parent ScheduleRuleset by default
self.identifier = identifier
self._display_name = None
# assign the times and values
if times is None:
self._times = (self._start_of_day,)
else:
if not isinstance(times, tuple):
try:
times = tuple(times)
except (ValueError, TypeError):
raise TypeError('ScheduleDay times must be iterable.')
for time in times:
self._check_time(time)
self._times = times
self.values = values
# if times are not ordered chronologically, sort them
if not self._are_chronological(self._times):
self._times, self._values = zip(*sorted(zip(self._times, self._values)))
# ensure that the schedule always starts from 0:00
assert self._times[0] == self._start_of_day, 'ScheduleDay times must always ' \
'start with 0:00. Got {}.'.format(self._times[0])
self.interpolate = interpolate
@property
def identifier(self):
"""Get or set a text string for a unique schedule day identifier."""
return self._identifier
@identifier.setter
def identifier(self, identifier):
self._identifier = valid_ep_string(identifier, 'schedule day identifier')
@property
def display_name(self):
"""Get or set a string for the object name without any character restrictions.
If not set, this will be equal to the identifier.
"""
if self._display_name is None:
return self._identifier
return self._display_name
@display_name.setter
def display_name(self, value):
if value is not None:
try:
value = str(value)
except UnicodeEncodeError: # Python 2 machine lacking the character set
pass # keep it as unicode
self._display_name = value
@property
def values(self):
"""Get or set the schedule's numerical values, which correspond to the times."""
return self._values
@values.setter
def values(self, values):
self._values = self._check_values(values)
@property
def times(self):
"""Get or set the Schedule's times, which correspond to the numerical values."""
return self._times
@times.setter
def times(self, times):
self._times = self._check_times(times)
@property
def interpolate(self):
"""Get or set a boolean noting whether values should be interpolated."""
return self._interpolate
@interpolate.setter
def interpolate(self, interpolate):
self._interpolate = bool(interpolate)
@property
def is_constant(self):
"""Boolean noting whether the schedule is representable with a single value."""
return len(self) == 1
[docs]
def add_value(self, value, time):
"""Add a value to the schedule along with the time it begins to take effect.
Args:
value: A number for the schedule value.
time: The ladybug Time object for the time at which the value begins to
take effect.
"""
self._check_time(time)
value = self._check_value(value)
self._times = self._times + (time,)
self._values = self._values + (value,)
if self._times[-1] < self._times[-2]: # ensure times are chronological
self._times, self._values = zip(*sorted(zip(self._times, self._values)))
[docs]
def remove_value(self, value_index):
"""Remove a value from the schedule by its index.
Args:
value_index: An integer for the index of the value to remove.
"""
assert len(self._values) > 1, 'ScheduleDay must have at least one value.'
assert value_index != 0, 'ScheduleDay cannot remove value at index 0.'
if value_index < 0:
value_index = len(self._values) + value_index
self._values = tuple(x for i, x in enumerate(self._values) if i != value_index)
self._times = tuple(x for i, x in enumerate(self._times) if i != value_index)
[docs]
def remove_value_by_time(self, time):
"""Remove a value from the schedule by its time in the times property.
Args:
time: An ladybug Time for the time and the value to remove.
"""
self.remove_value(self._times.index(time))
[docs]
def replace_value(self, value_index, new_value):
"""Replace an existing value in the schedule with a new one.
Args:
value_index: An integer for the index of the value to replace.
new_value: A number for the new value to use at the given index.
"""
val_list = list(self._values)
val_list[value_index] = self._check_value(new_value)
self._values = tuple(val_list)
[docs]
def replace_value_by_time(self, time, new_value):
"""Replace an existing value in the schedule using its time.
Args:
time: An ladybug Time for the time and the value to replace.
new_value: A number for the new value to use at the given time.
"""
self.replace_value(self._times.index(time), new_value)
[docs]
def values_at_timestep(self, timestep=1):
"""Get a list of sequential schedule values over the day at a given timestep.
Note that there are two possible ways that these values can be mapped to
corresponding times (here referred to as the "Ladybug Tools Interpretation"
and the "EnergyPlus Interpretation"). Both of these interpretations ultimately
refer to the exact same schedule in the calculations of EnergyPlus but the
times of day that each of the values are mapped to differ.
Ladybug Tools Interpretation - The first value in the returned list here
corresponds to the time 0:00 and the value for this time is applied over
the rest of the following timestep. In this way, an office schedule that is set
to be occupied from 9:00 until 17:00 will show 9:00 as occupied but 17:00 as
unoccupied.
EnergyPlus Interpretation - The first value in the returned list here
corresponds to the timestep after 0:00. For example, if the timestep is 1,
the time mapped to the first value is 1:00. If the timestep is 6, the first
value corresponds to 0:10. In this interpretation, the value for this time is
applied over all of the previous timestep. In this way, an office schedule that
is set to be occupied from 9:00 until 17:00 will show 9:00 as unoccupied but
17:00 as occupied.
Args:
timestep: An integer for the number of steps per hour at which to return
the resulting values.
"""
assert timestep in self.VALIDTIMESTEPS, 'ScheduleDay timestep "{}" is invalid.' \
' Must be one of the following:\n{}'.format(timestep, self.VALIDTIMESTEPS)
values = []
minute_delta = 60 / timestep
mod = 0 # track the minute of day through iteration
time_index = 1 # track the index of the next time of change
until_mod = self._get_until_mod(time_index) # get the mod of the next change
if not self.interpolate:
for _ in range(24 * timestep):
if mod >= until_mod:
time_index += 1
until_mod = self._get_until_mod(time_index)
values.append(self._values[time_index - 1])
mod += minute_delta
else:
for _ in range(24 * timestep):
if mod >= until_mod:
i = 0
delta = self._values[time_index] - self._values[time_index - 1]
until_mod = self._get_until_mod(time_index + 1)
n_steps = (until_mod - self._times[time_index].mod) / minute_delta
values.append(self._values[time_index - 1])
time_index += 1
elif time_index == 1:
values.append(self._values[time_index - 1])
else:
i += 1
values.append(self._values[time_index - 2] + ((i / n_steps) * delta))
mod += minute_delta
del values[0] # delete first value, which is makes interpolation off by one
values.append(self._values[-1]) # add the final value that is reached
return values
[docs]
def data_collection(self, date=Date(1, 1), schedule_type_limit=None, timestep=1):
"""Get a ladybug DataCollection representing this schedule at a given timestep.
Note that ladybug DataCollections always follow the "Ladybug Tools
Interpretation" of date time values as noted in the values_at_timestep
documentation.
Args:
date: A ladybug Date object for the day of the year the DataCollection
is representing. (Default: 1 Jan)
schedule_type_limit: A ScheduleTypeLimit object that describes the schedule,
which will be used to make the header for the DataCollection. If None,
a generic "Unknown" type will be used. (Default: None)
timestep: An integer for the number of steps per hour at which to make
the resulting DataCollection.
"""
assert isinstance(date, Date), \
'Expected ladybug Date. Got {}.'.format(type(date))
if schedule_type_limit is not None:
assert isinstance(schedule_type_limit, ScheduleTypeLimit), 'Expected ' \
'Honeybee ScheduleTypeLimit. Got {}.'.format(type(schedule_type_limit))
d_type = schedule_type_limit.data_type
unit = schedule_type_limit.unit
else:
d_type = GenericType('Unknown Data Type', 'unknown')
unit = 'unknown'
a_period = AnalysisPeriod(date.month, date.day, 0, date.month, date.day, 23,
timestep, date.leap_year)
header = Header(d_type, unit, a_period, metadata={'schedule': self.identifier})
return HourlyContinuousCollection(header, self.values_at_timestep(timestep))
[docs]
def shift_by_step(self, step_count=1, timestep=1):
"""Get a version of this object where the values are shifted in time.
This is useful when attempting to derive a set of diversified schedules
from a single average schedule.
Args:
step_count: An integer for the number of timesteps at which the schedule
will be shifted. Positive values indicate a shift of values forward
in time while negative values indicate a shift backwards in
time. (Default: 1).
timestep: An integer for the number of timesteps per hour at which the
shifting is occurring. This must be a value between 1 and 60, which
is evenly divisible by 60. 1 indicates that each step is an hour
while 60 indicates that each step is a minute. (Default: 1)
"""
value_deque = deque(self.values_at_timestep(timestep))
value_deque.rotate(step_count)
new_id = '{}_Shift_{}mins'.format(
self.identifier, int((60 / timestep) * step_count))
return ScheduleDay.from_values_at_timestep(new_id, list(value_deque), timestep)
[docs]
@classmethod
def from_values_at_timestep(cls, identifier, values, timestep=1,
remove_repeated=True):
"""Make a ScheduleDay from a list of values at a certain timestep.
Args:
identifier: Text string for a unique Schedule ID. Must be < 100 characters
and not contain any EnergyPlus special characters. This will be used to
identify the object across a model and in the exported IDF.
values: A list of numerical values with a length equal to 24 * timestep.
timestep: An integer for the number of steps per hour that the input
values correspond to. For example, if each value represents 30
minutes, the timestep is 2. For 15 minutes, it is 4. Default is 1,
meaning each value represents a single hour. Must be one of the
following: (1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, 60)
remove_repeated: Boolean to note whether sequentially repeated values
should be removed from the resulting `values` and `times` comprising
the schedule. Default is True, which results in a lighter, more compact
schedule. However, you may want to set this to False when planning to
set the schedule's `interpolate` property to True as this avoids
interpolation over long, multi-hour periods.
"""
# check the inputs
assert timestep in cls.VALIDTIMESTEPS, 'ScheduleDay timestep "{}" is invalid.' \
' Must be one of the following:\n{}'.format(timestep, cls.VALIDTIMESTEPS)
n_vals = 24 * timestep
assert len(values) == n_vals, 'There must be {} ScheduleDay values when' \
'the timestep is {}. Got {}.'.format(n_vals, timestep, len(values))
# build the list of schedule values and times
schedule_times = [cls._start_of_day]
minute_delta = 60 / timestep
mod = minute_delta
if remove_repeated:
schedule_values = [values[0]]
for i in range(1, n_vals):
if values[i] != schedule_values[-1]: # non-repeated value
schedule_times.append(Time.from_mod(mod))
schedule_values.append(values[i])
mod += minute_delta
else:
schedule_values = values # we don't care if there are repeated values
for i in range(1, n_vals):
schedule_times.append(Time.from_mod(mod))
mod += minute_delta
return cls(identifier, schedule_values, schedule_times)
[docs]
@classmethod
def from_idf(cls, idf_string):
"""Create a ScheduleDay from an EnergyPlus IDF text string.
Note that this method can accept all 3 types of EnergyPlus Schedule:Day
(Schedule:Day:Interval, Schedule:Day:Hourly, and Schedule:Day:List).
Args:
idf_string: A text string fully describing an EnergyPlus
Schedule:Day:Interval.
"""
if idf_string.startswith('Schedule:Day:Hourly,'):
ep_strs = parse_idf_string(idf_string)
hour_vals = [float(val) for val in ep_strs[2:]]
return cls.from_values_at_timestep(ep_strs[0], hour_vals)
if idf_string.startswith('Schedule:Day:List,'):
ep_strs = parse_idf_string(idf_string)
interpolate = False if ep_strs[2] == 'No' or ep_strs[2] == '' else True
timestep = int(60 / int(ep_strs[3]))
timestep_vals = [float(val) for val in ep_strs[4:]]
remove_repeated = True if not interpolate else False
sched_day = cls.from_values_at_timestep(
ep_strs[0], timestep_vals, timestep, remove_repeated)
sched_day.interpolate = interpolate
return sched_day
else:
ep_strs = parse_idf_string(idf_string, 'Schedule:Day:Interval,')
interpolate = False if ep_strs[2] == 'No' or ep_strs[2] == '' else True
length = len(ep_strs)
values = tuple(float(ep_strs[i]) for i in range(4, length + 1, 2))
times = [cls._start_of_day]
for i in range(3, length, 2):
try:
times.append(Time.from_time_string(ep_strs[i]))
except ValueError: # 24:00
pass
return cls(ep_strs[0], values, times, interpolate)
[docs]
@classmethod
def from_dict(cls, data):
"""Create a ScheduleDay from a dictionary.
Args:
data: ScheduleDay dictionary following the format below.
.. code-block:: python
{
"type": 'ScheduleDay',
"identifier": 'Office_Occ_900_1700',
"display_name": 'Office Occupancy',
"values": [0, 1, 0],
"times": [(0, 0), (9, 0), (17, 0)],
"interpolate": False
}
"""
assert data['type'] == 'ScheduleDay', \
'Expected ScheduleDay. Got {}.'.format(data['type'])
if 'times' in data and data['times'] is not None:
times = tuple(Time.from_array(tim) for tim in data['times'])
else:
times = None
interpolate = data['interpolate'] if 'interpolate' in data else None
new_obj = cls(data['identifier'], data['values'], times, interpolate)
if 'display_name' in data and data['display_name'] is not None:
new_obj.display_name = data['display_name']
return new_obj
[docs]
def to_idf(self, schedule_type_limit=None):
"""IDF string representation of ScheduleDay object.
Args:
schedule_type_limits: Optional ScheduleTypeLimit object, which will
be written into the IDF string in order to validate the values
within the schedule during the EnergyPlus run.
.. code-block:: shell
Schedule:Day:Interval,
dd winter rel humidity, !- Name
Percent, !- Schedule Type Limits Name
No, !- Interpolate to Timestep
until: 24:00, !- Time 1
74; !- Value Until Time 1
"""
fields = [self.identifier, ''] if schedule_type_limit is None else \
[self.identifier, schedule_type_limit.identifier]
fields.append('No' if not self.interpolate else 'Linear')
comments = ['schedule name', 'schedule type limits', 'interpolate to timestep']
for i in range(len(self._values)):
count = i + 1
try:
fields.append(self._times[count])
except IndexError: # the last "time until"
fields.append('24:00')
comments.append('time %s {hh:mm}' % count)
fields.append(self._values[i])
comments.append('value until time %s' % count)
return generate_idf_string('Schedule:Day:Interval', fields, comments)
[docs]
def to_dict(self):
"""ScheduleDay dictionary representation."""
base = {'type': 'ScheduleDay'}
base['identifier'] = self.identifier
base['values'] = self.values
base['times'] = [time.to_array() for time in self.times]
base['interpolate'] = self.interpolate
if self._display_name is not None:
base['display_name'] = self.display_name
return base
[docs]
def duplicate(self):
"""Get a copy of this object."""
return self.__copy__()
[docs]
@staticmethod
def average_schedules(identifier, schedules, weights=None, timestep_resolution=1):
"""Create a ScheduleDay that is a weighted average between other ScheduleDays.
Args:
identifier: Text string for a unique ID for the new unique ScheduleDay.
Must be < 100 characters and not contain any EnergyPlus special
characters. This will be used to identify the object across a
model and in the exported IDF.
schedules: A list of ScheduleDay objects that will be averaged together
to make a new ScheduleDay.
weights: An optional list of fractional numbers with the same length
as the input schedules that sum to 1. These will be used to weight
each of the ScheduleDay objects in the resulting average schedule.
If None, the individual schedules will be weighted equally.
timestep_resolution: An optional integer for the timestep resolution
at which the schedules will be averaged. Any schedule details
smaller than this timestep will be lost in the averaging process.
Default: 1.
"""
# check the inputs
assert isinstance(schedules, (list, tuple)), 'Expected a list of ScheduleDay ' \
'objects for average_schedules. Got {}.'.format(type(schedules))
if weights is None:
weight = 1 / len(schedules)
weights = [weight for i in schedules]
else:
weights = tuple_with_length(weights, len(schedules), float,
'average schedules weights')
assert sum(weights) == 1, 'Average schedule weights must sum to 1. ' \
'Got {}.'.format(sum(weights))
# create a weighted average list of values
all_values = [sch.values_at_timestep(timestep_resolution) for sch in schedules]
sch_vals = [sum([val * weights[i] for i, val in enumerate(values)])
for values in zip(*all_values)]
# return the final list
return ScheduleDay.from_values_at_timestep(
identifier, sch_vals, timestep_resolution)
def _get_until_mod(self, time_index):
"""Get the minute of the day until a value is applied given a time_index."""
try:
return self._times[time_index].mod
except IndexError: # constant value until the end of the day
return 1440
def _check_values(self, values):
"""Check values whenever they come through the values setter."""
assert isinstance(values, Iterable) and not \
isinstance(values, (str, dict, bytes, bytearray)), \
'values should be a list or tuple. Got {}'.format(type(values))
assert len(values) == len(self._times), \
'Length of values list must match length of times list. {} != {}'.format(
len(values), len(self._times))
assert len(values) > 0, 'ScheduleDay must include at least one value.'
try:
return tuple(float(val) for val in values)
except (ValueError, TypeError):
raise TypeError('ScheduleDay values must be numbers.')
def _check_times(self, times):
"""Check times whenever they come through the times setter."""
if not isinstance(times, tuple):
try:
times = tuple(times)
except (ValueError, TypeError):
raise TypeError('ScheduleDay times must be iterable.')
for time in times:
self._check_time(time)
assert len(times) == len(self._values), \
'Length of values list must match length of datetimes list. {} != {}'.format(
len(times), len(self._values))
if not self._are_chronological(times):
times, self._values = zip(*sorted(zip(times, self._values)))
# ensure that the schedule always starts from 0:00
assert times[0] == self._start_of_day, \
'ScheduleDay times must always start with 0:00. Got {}.'.format(times[0])
return times
@staticmethod
def _check_value(value):
"""Check that an individual input value is a number."""
try:
return float(value)
except (ValueError, TypeError):
raise TypeError('ScheduleDay values must be numbers.')
@staticmethod
def _check_time(time):
"""Check that an individual time value is a ladybug Time."""
assert isinstance(time, Time), \
'Expected ladybug Time for ScheduleDay. Got {}.'.format(type(time))
@staticmethod
def _are_chronological(times):
"""Test whether a list of times is chronological."""
return all(times[i] < times[i + 1] for i in range(len(times) - 1))
def __len__(self):
return len(self.values)
def __getitem__(self, key):
return self.values[key]
def __iter__(self):
return iter(self.values)
def __key(self):
"""A tuple based on the object properties, useful for hashing."""
return (self.identifier,) + self.values + tuple(hash(t) for t in self.times) + \
(self.interpolate,)
def __hash__(self):
return hash(self.__key())
def __eq__(self, other):
return isinstance(other, ScheduleDay) and self.__key() == other.__key()
def __ne__(self, other):
return not self.__eq__(other)
def __copy__(self):
new_obj = ScheduleDay(self.identifier, self.values, self.times, self.interpolate)
new_obj._display_name = self._display_name
return new_obj
[docs]
def ToString(self):
"""Overwrite .NET ToString."""
return self.__repr__()
def __repr__(self):
return self.to_idf()