Skip to content

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 IDFObject instances straight from an IDFDocument, 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:

Schedule interval: 0–15min = 0.0, 15–30min = 0.5
At 10min: 0.0
At 20min: 0.5

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