How the schedule evaluator works¶
idfkit can compute the value of any EnergyPlus schedule at any moment — or across a whole year — without running a simulation. This page explains how that evaluator is built and why it behaves the way it does. If you just want to call it, see How to evaluate schedules; for the exact signatures, see the API reference.
Not in JavaScript yet
Schedule evaluation is available in Python and is not in JavaScript today. A temporary gap, not a boundary. The port is tracked in idfkit-js#18.
The full entry, including the vocabulary this capability owns, is on the capability parity page.
Why evaluate schedules without simulating¶
EnergyPlus schedules encode when a building is occupied, when lights and equipment run, and what setpoints apply. Answering "what is this schedule doing at 2pm on a summer Tuesday?" normally means running a full simulation and reading the output. That is slow, and it fails as a preview: you can't see the profile you're building until after you've built and run the whole model.
The evaluator interprets schedule objects directly, in pure Python. Its design goals shape everything below:
- Stdlib only for the core. Point evaluation and annual series need nothing beyond the standard library; pandas and matplotlib are optional conveniences.
- Operates on the live model. It reads
IDFObjectinstances straight from anIDFDocument, so it sees exactly what you'll write out. - Matches EnergyPlus semantics. The interesting design work is reproducing E+'s interpretation of schedule syntax faithfully — the rest of this page is mostly about those semantics.
The schedule hierarchy¶
EnergyPlus schedules are layered. A Schedule:Year names date ranges, each
pointing at a Schedule:Week:*, which in turn names a Schedule:Day:* for each
kind of day. Evaluating a datetime means walking that hierarchy from the top:
def evaluate_year(
obj: IDFObject,
dt: datetime,
doc: IDFDocument,
day_type: DayType = DayType.NORMAL,
holidays: set[date] | None = None,
custom_day_1: set[date] | None = None,
custom_day_2: set[date] | None = None,
interpolation: Interpolation = Interpolation.NO,
) -> float:
# 1. Find which date range contains dt
# 2. Get the referenced week schedule name
# 3. Look up week schedule in document
# 4. Evaluate week schedule for dt
week_name = find_week_for_date(obj, dt)
week_obj = doc.get_schedule(week_name) or doc[week_type][week_name]
return evaluate_week(week_obj, dt, doc)
def evaluate_week_daily(
obj: IDFObject,
dt: datetime,
doc: IDFDocument,
day_type: DayType = DayType.NORMAL,
holidays: set[date] | None = None,
custom_day_1: set[date] | None = None,
custom_day_2: set[date] | None = None,
interpolation: Interpolation = Interpolation.NO,
) -> float:
# Schedule:Week:Daily has 12 fields: Sunday-Saturday + Holiday + Summer/Winter DD + Custom
day_index = dt.weekday() # 0=Mon, need to map to E+ order (Sun=0)
field_map = {6: 0, 0: 1, 1: 2, 2: 3, 3: 4, 4: 5, 5: 6} # Python weekday → E+ field
day_name = obj[field_name_for_index(field_map[day_index])]
day_obj = doc[day_schedule_type][day_name]
return evaluate_day(day_obj, dt, doc)
Each level resolves a reference and hands the datetime down to the next. Because idfkit already indexes every object by name, each lookup is O(1) — resolving a year schedule for one instant is a handful of dictionary hits, not a scan.
Reading the Compact DSL¶
Schedule:Compact collapses that whole hierarchy into one object using a
mini-DSL of Through: (date ranges), For: (day types), and Until:
(time/value pairs):
Schedule:Compact,
Office Occupancy, ! Name
Fraction, ! Schedule Type Limits
Through: 12/31, ! Date range (implicit start 1/1)
For: Weekdays, ! Day types
Until: 08:00, 0.0, ! Time, Value pairs
Until: 18:00, 1.0,
Until: 24:00, 0.0,
For: Weekends Holidays,
Until: 24:00, 0.0;
The parser turns those flat fields into structured periods and day rules, so
evaluation becomes "find the period containing the date, find the day rule
matching the day type, find the first Until: time at or after the moment":
@dataclass
class CompactPeriod:
"""A 'Through:' block covering a date range."""
end_month: int
end_day: int
day_rules: list[CompactDayRule]
@dataclass
class CompactDayRule:
"""A 'For:' block with day types and time-value pairs."""
day_types: set[str] # {"Weekdays", "Weekends", "Holidays", ...}
time_values: list[tuple[time, float]] # [(08:00, 0.0), (18:00, 1.0), ...]
def parse_compact(obj: IDFObject) -> tuple[list[CompactPeriod], Interpolation]:
"""Parse Schedule:Compact fields into structured data.
Returns the periods alongside the schedule's ``Interpolate to Timestep``
setting, which the day-level evaluator needs. Results are cached per object
identity, so re-evaluating the same schedule doesn't re-parse it.
"""
Matching day types to the calendar¶
Every level of the hierarchy ultimately asks: what kind of day is this? The
evaluator maps EnergyPlus day types onto Python's datetime.weekday():
| E+ Day Type | Python weekday() |
|---|---|
| Sunday | 6 |
| Monday | 0 |
| Tuesday | 1 |
| Wednesday | 2 |
| Thursday | 3 |
| Friday | 4 |
| Saturday | 5 |
| Weekdays | 0–4 |
| Weekends | 5–6 |
| AllDays | 0–6 |
| Holidays | (requires the holiday list) |
| SummerDesignDay | (special) |
| WinterDesignDay | (special) |
| AllOtherDays | (fallback) |
The special day types don't fall out of the calendar alone — they're why holidays and design days each need their own handling, below.
Evaluating a day schedule¶
At the bottom of the hierarchy, a day schedule gives values by hour or by
interval. Schedule:Day:Hourly is 24 explicit values, one per hour:
def evaluate_day_hourly(obj: IDFObject, dt: datetime) -> float:
hour = dt.hour # 0-23
field_name = f"Hour {hour + 1}" # "Hour 1" through "Hour 24"
return float(obj[field_name])
Schedule:Day:Interval gives time/value pairs, where each value applies until
its stated time — a step function unless interpolation is requested:
def evaluate_day_interval(
obj: IDFObject,
dt: datetime,
interpolation: Interpolation = Interpolation.NO,
) -> float:
# Fields: Time 1, Value Until Time 1, Time 2, Value Until Time 2, ...
current_time = dt.time()
last_value = 0.0
for i in range(1, 145): # Max 144 intervals
time_field = f"Time {i}"
value_field = f"Value Until Time {i}"
if not obj.get(time_field):
break
until_time = parse_time(obj[time_field]) # "HH:MM"
if current_time < until_time:
return float(obj[value_field])
last_value = float(obj[value_field])
return last_value
Interpolation: step vs. average¶
EnergyPlus offers two ways to resolve a moment that falls between a schedule's native intervals, and the evaluator reproduces both. With interpolation off (the default), the schedule is a step function — the value at the start of an interval holds until the next one:
With average interpolation, values are blended linearly when the evaluation timestep doesn't align with the interval boundaries:
Schedule interval: 0–15min = 0.0, 15–30min = 0.5
At 10min: 0.0
At 20min: 0.25 (average of 0.0 and 0.5)
Matching this exactly matters: an occupancy fraction previewed with the wrong interpolation mode won't match what EnergyPlus actually simulates.
Design decisions¶
A few behaviours don't follow mechanically from the schedule syntax and had to be decided deliberately.
Holidays¶
Holidays aren't in the schedule objects themselves — they come from
RunPeriodControl:SpecialDays in the document. The evaluator extracts them so
that a For: Holidays rule resolves against the model's actual holiday
calendar:
@dataclass
class SpecialDay:
"""A special day period from RunPeriodControl:SpecialDays."""
name: str
start_date: date # Parsed from "January 1" or "1/1" etc.
duration: int # Days
day_type: str # "Holiday", "CustomDay1", "CustomDay2", etc.
def extract_special_days(doc: IDFDocument, year: int) -> list[SpecialDay]:
"""Parse all RunPeriodControl:SpecialDays objects.
The year is required because EnergyPlus writes special days as month/day
without a year, so the concrete dates depend on the calendar year.
"""
...
def get_holidays(doc: IDFDocument, year: int) -> set[date]:
"""Get all dates marked as Holiday for a given year."""
...
The same mechanism carries CustomDay1 and CustomDay2, EnergyPlus's
user-defined special-day types.
Design days¶
SummerDesignDay and WinterDesignDay never occur on the calendar — they're
sizing conditions. Rather than guess when they apply, the evaluator exposes them
through an explicit day_type override, so sizing previews are opt-in:
class DayType(Enum):
"""Special day type for schedule evaluation."""
NORMAL = "normal" # Use the calendar day
SUMMER_DESIGN = "summer" # Use the SummerDesignDay schedule
WINTER_DESIGN = "winter" # Use the WinterDesignDay schedule
HOLIDAY = "holiday" # Treat as a holiday regardless of date
CUSTOM_DAY_1 = "customday1" # Use the CustomDay1 schedule
CUSTOM_DAY_2 = "customday2" # Use the CustomDay2 schedule
def evaluate(
schedule: IDFObject,
dt: datetime,
document: IDFDocument | None = None,
day_type: DayTypeInput | None = None,
fs: FileSystem | None = None,
base_path: Path | str | None = None,
) -> float:
"""
Get schedule value at a specific datetime.
Args:
day_type: Override the calendar day with a design day, holiday, or
custom day schedule. Accepts a DayType or its string value
("summer", "holiday", ...). Used for sizing calculations.
fs: FileSystem used to read Schedule:File CSVs.
base_path: Base directory for resolving relative Schedule:File paths.
"""
Schedule:File¶
Schedule:File reads values from an external CSV. Reusing idfkit's
FileSystem protocol here means the same schedule works whether the CSV lives
on local disk or in remote storage, matching how the simulation module reads and
writes everything else:
def evaluate_schedule_file(
obj: IDFObject,
dt: datetime,
fs: FileSystem | None = None,
base_path: Path | str | None = None,
cache: ScheduleFileCache | None = None,
interpolation: Interpolation = Interpolation.NO,
) -> float:
"""
Evaluate a Schedule:File at a specific datetime.
Args:
obj: The Schedule:File IDF object
dt: Datetime to evaluate
fs: FileSystem for reading the CSV (default: LocalFileSystem)
base_path: Base directory for resolving relative file paths
(default: directory containing the IDF)
cache: Reuses parsed CSV columns across calls, so evaluating a full
year doesn't re-read the file 8,760 times
interpolation: Whether to interpolate between sub-hourly items
"""
The evaluator reads four fields off the object to locate a value, each with an EnergyPlus default it falls back to when the field is blank:
| Field | Meaning | Default |
|---|---|---|
Column Number |
Which CSV column holds the values, counting from 1 | 1 |
Rows to Skip at Top |
Header rows to discard before reading values | 0 |
Column Separator |
One of Comma, Tab, Space, Semicolon |
Comma |
Minutes per Item |
Minutes each row covers: 60, 30, 15, 10, 5, or 1 |
60 |
Minutes per Item is what turns a row index into a datetime: the evaluator
computes minutes elapsed since January 1 and divides, so a 15-minute file
resolves four rows per hour. Parsed columns are cached per object, keyed on file
path plus column, rows-skipped, and separator, so two Schedule:File objects
reading different columns of the same CSV never share cached values.
See also¶
- How to evaluate schedules — the task-oriented recipes.
- API reference: schedules — signatures and types.