From b2ad66c9e3f54bfe6c4d5c33950d3a79edb48185 Mon Sep 17 00:00:00 2001 From: Sam Wolk <36545842+szvsw@users.noreply.github.com> Date: Thu, 16 Apr 2026 08:56:48 -0400 Subject: [PATCH 01/10] add schedule generator template --- .../sbem/components/schedules/__init__.py | 55 ++ .../deterministic.py} | 0 .../sbem/components/schedules/parametric.py | 560 ++++++++++++++++++ .../sbem/components/schedules/stochastic.py | 378 ++++++++++++ 4 files changed, 993 insertions(+) create mode 100644 epinterface/sbem/components/schedules/__init__.py rename epinterface/sbem/components/{schedules.py => schedules/deterministic.py} (100%) create mode 100644 epinterface/sbem/components/schedules/parametric.py create mode 100644 epinterface/sbem/components/schedules/stochastic.py diff --git a/epinterface/sbem/components/schedules/__init__.py b/epinterface/sbem/components/schedules/__init__.py new file mode 100644 index 0000000..ce69bfc --- /dev/null +++ b/epinterface/sbem/components/schedules/__init__.py @@ -0,0 +1,55 @@ +"""This module contains the definitions for the schedules.""" + +from epinterface.sbem.components.schedules.deterministic import ( + DayComponent, + ScheduleTypeLimitType, + TypeLimits, + WeekComponent, + YearComponent, + YearScheduleCategory, +) +from epinterface.sbem.components.schedules.parametric import ( + ParametericYear, + ParametricSetpoints, +) +from epinterface.sbem.components.schedules.stochastic import ( + ScheduleContext, + StochasticCoolingSetpointScheduleGenerator, + StochasticCoolingSetpointScheduleOutput, + StochasticEquipmentScheduleGenerator, + StochasticEquipmentScheduleOutput, + StochasticHeatingSetpointScheduleGenerator, + StochasticHeatingSetpointScheduleOutput, + StochasticLightingScheduleGenerator, + StochasticLightingScheduleOutput, + StochasticOccupancyScheduleGenerator, + StochasticOccupancyScheduleOutput, + StochasticScheduleGenerator, + StochasticWaterUseScheduleGenerator, + StochasticWaterUseScheduleOutput, +) + +__all__ = [ + "DayComponent", + "ParametericYear", + "ParametricSetpoints", + "ScheduleContext", + "ScheduleTypeLimitType", + "StochasticCoolingSetpointScheduleGenerator", + "StochasticCoolingSetpointScheduleOutput", + "StochasticEquipmentScheduleGenerator", + "StochasticEquipmentScheduleOutput", + "StochasticHeatingSetpointScheduleGenerator", + "StochasticHeatingSetpointScheduleOutput", + "StochasticLightingScheduleGenerator", + "StochasticLightingScheduleOutput", + "StochasticOccupancyScheduleGenerator", + "StochasticOccupancyScheduleOutput", + "StochasticScheduleGenerator", + "StochasticWaterUseScheduleGenerator", + "StochasticWaterUseScheduleOutput", + "TypeLimits", + "WeekComponent", + "YearComponent", + "YearScheduleCategory", +] diff --git a/epinterface/sbem/components/schedules.py b/epinterface/sbem/components/schedules/deterministic.py similarity index 100% rename from epinterface/sbem/components/schedules.py rename to epinterface/sbem/components/schedules/deterministic.py diff --git a/epinterface/sbem/components/schedules/parametric.py b/epinterface/sbem/components/schedules/parametric.py new file mode 100644 index 0000000..da01350 --- /dev/null +++ b/epinterface/sbem/components/schedules/parametric.py @@ -0,0 +1,560 @@ +"""This module contains the definitions for the parametric schedules.""" + +from pydantic import BaseModel, Field + +from epinterface.sbem.components.schedules.deterministic import ( + DayComponent, + WeekComponent, + YearComponent, + YearScheduleCategory, +) + + +class ParametericYear(BaseModel): + """A model for a year schedule that is parameterized by the base, and the interpolation factors.""" + + Base: float = Field(default=..., ge=0, le=1) + """Overnight Baseload""" + + AMInterp: float = Field(default=..., ge=0, le=1) + """AM Hours: 6pm, 7pm, 8pm, Base + AMInterp * (1-Base)""" + + LunchInterp: float = Field(default=..., ge=0, le=1) + """Lunch Hours: 12pm, 1pm Base + LunchInterp * (1-Base)""" + + PMInterp: float = Field(default=..., ge=0, le=1) + """PM Hours: 6pm,7pm,8pm Base + PMInterp * (1-Base)""" + + WeekendPeakInterp: float = Field(default=..., ge=0, le=1) + """Weekend Peak = Base + WeekendPeakInterp * (1-Base)""" + + SummerPeakInterp: float = Field(default=..., ge=0, le=1) + """Summer Peak = Base + SummerPeakInterp * (1-Base)""" + + def to_schedule(self, name: str, category: YearScheduleCategory): + """Convert the parameters to a schedule.""" + peak = 1 + am_inter = self.Base + self.AMInterp * (peak - self.Base) + lunch_inter = self.Base + self.LunchInterp * (peak - self.Base) + pm_inter = self.Base + self.PMInterp * (peak - self.Base) + + we_peak = self.Base + self.WeekendPeakInterp * (peak - self.Base) + we_am_inter_val = self.Base + self.AMInterp * (we_peak - self.Base) + we_lunch_inter_val = self.Base + self.LunchInterp * (we_peak - self.Base) + we_pm_inter_val = self.Base + self.PMInterp * (we_peak - self.Base) + + summer_peak = self.Base + self.SummerPeakInterp * (peak - self.Base) + summer_am_inter = self.Base + self.AMInterp * (summer_peak - self.Base) + summer_lunch_inter = self.Base + self.LunchInterp * (summer_peak - self.Base) + summer_pm_inter = self.Base + self.PMInterp * (summer_peak - self.Base) + + summer_we_peak = self.Base + self.SummerPeakInterp * (we_peak - self.Base) + summer_we_am_inter = self.Base + self.AMInterp * (summer_we_peak - self.Base) + summer_we_lunch_inter = self.Base + self.LunchInterp * ( + summer_we_peak - self.Base + ) + summer_we_pm_inter = self.Base + self.PMInterp * (summer_we_peak - self.Base) + + weekday = DayComponent( + Name=f"{name}_ParametericWeekday", + Type="Fraction", + Hour_00=self.Base, + Hour_01=self.Base, + Hour_02=self.Base, + Hour_03=self.Base, + Hour_04=self.Base, + Hour_05=self.Base, + Hour_06=am_inter, + Hour_07=am_inter, + Hour_08=am_inter, + Hour_09=peak, + Hour_10=peak, + Hour_11=peak, + Hour_12=lunch_inter, + Hour_13=lunch_inter, + Hour_14=peak, + Hour_15=peak, + Hour_16=peak, + Hour_17=peak, + Hour_18=pm_inter, + Hour_19=pm_inter, + Hour_20=pm_inter, + Hour_21=self.Base, + Hour_22=self.Base, + Hour_23=self.Base, + ) + + weekend = DayComponent( + Name=f"{name}_ParametericWeekend", + Type="Fraction", + Hour_00=self.Base, + Hour_01=self.Base, + Hour_02=self.Base, + Hour_03=self.Base, + Hour_04=self.Base, + Hour_05=self.Base, + Hour_06=we_am_inter_val, + Hour_07=we_am_inter_val, + Hour_08=we_am_inter_val, + Hour_09=we_peak, + Hour_10=we_peak, + Hour_11=we_peak, + Hour_12=we_lunch_inter_val, + Hour_13=we_lunch_inter_val, + Hour_14=we_peak, + Hour_15=we_peak, + Hour_16=we_peak, + Hour_17=we_peak, + Hour_18=we_pm_inter_val, + Hour_19=we_pm_inter_val, + Hour_20=we_pm_inter_val, + Hour_21=self.Base, + Hour_22=self.Base, + Hour_23=self.Base, + ) + + summer_weekday = DayComponent( + Name=f"{name}_ParametericSummerWeekday", + Type="Fraction", + Hour_00=self.Base, + Hour_01=self.Base, + Hour_02=self.Base, + Hour_03=self.Base, + Hour_04=self.Base, + Hour_05=self.Base, + Hour_06=summer_am_inter, + Hour_07=summer_am_inter, + Hour_08=summer_am_inter, + Hour_09=summer_peak, + Hour_10=summer_peak, + Hour_11=summer_peak, + Hour_12=summer_lunch_inter, + Hour_13=summer_lunch_inter, + Hour_14=summer_peak, + Hour_15=summer_peak, + Hour_16=summer_peak, + Hour_17=summer_peak, + Hour_18=summer_pm_inter, + Hour_19=summer_pm_inter, + Hour_20=summer_pm_inter, + Hour_21=self.Base, + Hour_22=self.Base, + Hour_23=self.Base, + ) + + summer_weekend = DayComponent( + Name=f"{name}_ParametericSummerWeekend", + Type="Fraction", + Hour_00=self.Base, + Hour_01=self.Base, + Hour_02=self.Base, + Hour_03=self.Base, + Hour_04=self.Base, + Hour_05=self.Base, + Hour_06=summer_we_am_inter, + Hour_07=summer_we_am_inter, + Hour_08=summer_we_am_inter, + Hour_09=summer_we_peak, + Hour_10=summer_we_peak, + Hour_11=summer_we_peak, + Hour_12=summer_we_lunch_inter, + Hour_13=summer_we_lunch_inter, + Hour_14=summer_we_peak, + Hour_15=summer_we_peak, + Hour_16=summer_we_peak, + Hour_17=summer_we_peak, + Hour_18=summer_we_pm_inter, + Hour_19=summer_we_pm_inter, + Hour_20=summer_we_pm_inter, + Hour_21=self.Base, + Hour_22=self.Base, + Hour_23=self.Base, + ) + + regular_week = WeekComponent( + Name=f"{name}_ParametericRegularWeek", + Monday=weekday, + Tuesday=weekday, + Wednesday=weekday, + Thursday=weekday, + Friday=weekday, + Saturday=weekend, + Sunday=weekend, + ) + + summer_week = WeekComponent( + Name=f"{name}_ParametericSummerWeek", + Monday=summer_weekday, + Tuesday=summer_weekday, + Wednesday=summer_weekday, + Thursday=summer_weekday, + Friday=summer_weekday, + Saturday=summer_weekend, + Sunday=summer_weekend, + ) + + year = YearComponent( + Name=f"{name}_ParametericYear", + Type=category, + January=regular_week, + February=regular_week, + March=regular_week, + April=regular_week, + May=regular_week, + June=summer_week, + July=summer_week, + August=summer_week, + September=regular_week, + October=regular_week, + November=regular_week, + December=regular_week, + ) + + return year + + +class ParametricSetpoints(BaseModel): + """A model for a setpoint schedule that is parameterized by the base, and the setbacks.""" + + HeatingSetpoint: float = Field(ge=0, le=22) + DeadBand: float = Field(ge=0, le=10) + HeatingSetback: float = Field(ge=0, le=10) + CoolingSetback: float = Field(ge=0, le=10) + NightSetback: float = Field(ge=0, le=1) + WeekendSetback: float = Field(ge=0, le=1) + SummerSetback: float = Field(ge=0, le=1) + + def to_schedules(self): + """Convert the setpoint parameters to a set of schedules.""" + hsp = self.HeatingSetpoint + csp = hsp + self.DeadBand + hsp_setback = hsp - self.HeatingSetback + csp_setback = csp + self.CoolingSetback + + hsp_night = hsp - self.NightSetback * (hsp - hsp_setback) + csp_night = csp + self.NightSetback * (csp_setback - csp) + + hsp_weekend_base = hsp - self.WeekendSetback * (hsp - hsp_setback) + csp_weekend_base = csp + self.WeekendSetback * (csp_setback - csp) + + hsp_summer_base = hsp - self.SummerSetback * (hsp - hsp_setback) + csp_summer_base = csp + self.SummerSetback * (csp_setback - csp) + + hsp_summer_weekend_base = hsp_summer_base - self.WeekendSetback * ( + hsp_summer_base - hsp_setback + ) + csp_summer_weekend_base = csp_summer_base + self.WeekendSetback * ( + csp_setback - csp_summer_base + ) + + hsp_standard_day = DayComponent( + Name="HSP_Standard_Day", + Type="Temperature", + Hour_00=hsp_night, + Hour_01=hsp_night, + Hour_02=hsp_night, + Hour_03=hsp_night, + Hour_04=hsp_night, + Hour_05=hsp_night, + Hour_06=hsp_night, + Hour_07=hsp, + Hour_08=hsp, + Hour_09=hsp, + Hour_10=hsp, + Hour_11=hsp, + Hour_12=hsp, + Hour_13=hsp, + Hour_14=hsp, + Hour_15=hsp, + Hour_16=hsp, + Hour_17=hsp, + Hour_18=hsp, + Hour_19=hsp_night, + Hour_20=hsp_night, + Hour_21=hsp_night, + Hour_22=hsp_night, + Hour_23=hsp_night, + ) + + hsp_weekend_day = DayComponent( + Name="HSP_Weekend_Day", + Type="Temperature", + Hour_00=hsp_night, + Hour_01=hsp_night, + Hour_02=hsp_night, + Hour_03=hsp_night, + Hour_04=hsp_night, + Hour_05=hsp_night, + Hour_06=hsp_night, + Hour_07=hsp_weekend_base, + Hour_08=hsp_weekend_base, + Hour_09=hsp_weekend_base, + Hour_10=hsp_weekend_base, + Hour_11=hsp_weekend_base, + Hour_12=hsp_weekend_base, + Hour_13=hsp_weekend_base, + Hour_14=hsp_weekend_base, + Hour_15=hsp_weekend_base, + Hour_16=hsp_weekend_base, + Hour_17=hsp_weekend_base, + Hour_18=hsp_weekend_base, + Hour_19=hsp_night, + Hour_20=hsp_night, + Hour_21=hsp_night, + Hour_22=hsp_night, + Hour_23=hsp_night, + ) + + hsp_summer_day = DayComponent( + Name="HSP_Summer_Day", + Type="Temperature", + Hour_00=hsp_night, + Hour_01=hsp_night, + Hour_02=hsp_night, + Hour_03=hsp_night, + Hour_04=hsp_night, + Hour_05=hsp_night, + Hour_06=hsp_night, + Hour_07=hsp_summer_base, + Hour_08=hsp_summer_base, + Hour_09=hsp_summer_base, + Hour_10=hsp_summer_base, + Hour_11=hsp_summer_base, + Hour_12=hsp_summer_base, + Hour_13=hsp_summer_base, + Hour_14=hsp_summer_base, + Hour_15=hsp_summer_base, + Hour_16=hsp_summer_base, + Hour_17=hsp_summer_base, + Hour_18=hsp_summer_base, + Hour_19=hsp_night, + Hour_20=hsp_night, + Hour_21=hsp_night, + Hour_22=hsp_night, + Hour_23=hsp_night, + ) + + hsp_summer_weekend_day = DayComponent( + Name="HSP_Summer_Weekend_Day", + Type="Temperature", + Hour_00=hsp_night, + Hour_01=hsp_night, + Hour_02=hsp_night, + Hour_03=hsp_night, + Hour_04=hsp_night, + Hour_05=hsp_night, + Hour_06=hsp_night, + Hour_07=hsp_summer_weekend_base, + Hour_08=hsp_summer_weekend_base, + Hour_09=hsp_summer_weekend_base, + Hour_10=hsp_summer_weekend_base, + Hour_11=hsp_summer_weekend_base, + Hour_12=hsp_summer_weekend_base, + Hour_13=hsp_summer_weekend_base, + Hour_14=hsp_summer_weekend_base, + Hour_15=hsp_summer_weekend_base, + Hour_16=hsp_summer_weekend_base, + Hour_17=hsp_summer_weekend_base, + Hour_18=hsp_summer_weekend_base, + Hour_19=hsp_night, + Hour_20=hsp_night, + Hour_21=hsp_night, + Hour_22=hsp_night, + Hour_23=hsp_night, + ) + + csp_standard_day = DayComponent( + Name="CSP_Standard_Day", + Type="Temperature", + Hour_00=csp_night, + Hour_01=csp_night, + Hour_02=csp_night, + Hour_03=csp_night, + Hour_04=csp_night, + Hour_05=csp_night, + Hour_06=csp_night, + Hour_07=csp, + Hour_08=csp, + Hour_09=csp, + Hour_10=csp, + Hour_11=csp, + Hour_12=csp, + Hour_13=csp, + Hour_14=csp, + Hour_15=csp, + Hour_16=csp, + Hour_17=csp, + Hour_18=csp, + Hour_19=csp_night, + Hour_20=csp_night, + Hour_21=csp_night, + Hour_22=csp_night, + Hour_23=csp_night, + ) + + csp_weekend_day = DayComponent( + Name="CSP_Weekend_Day", + Type="Temperature", + Hour_00=csp_night, + Hour_01=csp_night, + Hour_02=csp_night, + Hour_03=csp_night, + Hour_04=csp_night, + Hour_05=csp_night, + Hour_06=csp_night, + Hour_07=csp_weekend_base, + Hour_08=csp_weekend_base, + Hour_09=csp_weekend_base, + Hour_10=csp_weekend_base, + Hour_11=csp_weekend_base, + Hour_12=csp_weekend_base, + Hour_13=csp_weekend_base, + Hour_14=csp_weekend_base, + Hour_15=csp_weekend_base, + Hour_16=csp_weekend_base, + Hour_17=csp_weekend_base, + Hour_18=csp_weekend_base, + Hour_19=csp_night, + Hour_20=csp_night, + Hour_21=csp_night, + Hour_22=csp_night, + Hour_23=csp_night, + ) + + csp_summer_day = DayComponent( + Name="CSP_Summer_Day", + Type="Temperature", + Hour_00=csp_night, + Hour_01=csp_night, + Hour_02=csp_night, + Hour_03=csp_night, + Hour_04=csp_night, + Hour_05=csp_night, + Hour_06=csp_night, + Hour_07=csp_summer_base, + Hour_08=csp_summer_base, + Hour_09=csp_summer_base, + Hour_10=csp_summer_base, + Hour_11=csp_summer_base, + Hour_12=csp_summer_base, + Hour_13=csp_summer_base, + Hour_14=csp_summer_base, + Hour_15=csp_summer_base, + Hour_16=csp_summer_base, + Hour_17=csp_summer_base, + Hour_18=csp_summer_base, + Hour_19=csp_night, + Hour_20=csp_night, + Hour_21=csp_night, + Hour_22=csp_night, + Hour_23=csp_night, + ) + + csp_summer_weekend_day = DayComponent( + Name="CSP_Summer_Weekend_Day", + Type="Temperature", + Hour_00=csp_night, + Hour_01=csp_night, + Hour_02=csp_night, + Hour_03=csp_night, + Hour_04=csp_night, + Hour_05=csp_night, + Hour_06=csp_night, + Hour_07=csp_summer_weekend_base, + Hour_08=csp_summer_weekend_base, + Hour_09=csp_summer_weekend_base, + Hour_10=csp_summer_weekend_base, + Hour_11=csp_summer_weekend_base, + Hour_12=csp_summer_weekend_base, + Hour_13=csp_summer_weekend_base, + Hour_14=csp_summer_weekend_base, + Hour_15=csp_summer_weekend_base, + Hour_16=csp_summer_weekend_base, + Hour_17=csp_summer_weekend_base, + Hour_18=csp_summer_weekend_base, + Hour_19=csp_night, + Hour_20=csp_night, + Hour_21=csp_night, + Hour_22=csp_night, + Hour_23=csp_night, + ) + + hsp_standard_week = WeekComponent( + Name="HSP_Standard_Week", + Monday=hsp_standard_day, + Tuesday=hsp_standard_day, + Wednesday=hsp_standard_day, + Thursday=hsp_standard_day, + Friday=hsp_standard_day, + Saturday=hsp_weekend_day, + Sunday=hsp_weekend_day, + ) + + csp_standard_week = WeekComponent( + Name="CSP_Standard_Week", + Monday=csp_standard_day, + Tuesday=csp_standard_day, + Wednesday=csp_standard_day, + Thursday=csp_standard_day, + Friday=csp_standard_day, + Saturday=csp_weekend_day, + Sunday=csp_weekend_day, + ) + + hsp_summer_week = WeekComponent( + Name="HSP_Summer_Week", + Monday=hsp_summer_day, + Tuesday=hsp_summer_day, + Wednesday=hsp_summer_day, + Thursday=hsp_summer_day, + Friday=hsp_summer_day, + Saturday=hsp_summer_weekend_day, + Sunday=hsp_summer_weekend_day, + ) + + csp_summer_week = WeekComponent( + Name="CSP_Summer_Week", + Monday=csp_summer_day, + Tuesday=csp_summer_day, + Wednesday=csp_summer_day, + Thursday=csp_summer_day, + Friday=csp_summer_day, + Saturday=csp_summer_weekend_day, + Sunday=csp_summer_weekend_day, + ) + + hsp_year = YearComponent( + Name="HSP_Year", + Type="Setpoint", + January=hsp_standard_week, + February=hsp_standard_week, + March=hsp_standard_week, + April=hsp_standard_week, + May=hsp_standard_week, + June=hsp_summer_week, + July=hsp_summer_week, + August=hsp_summer_week, + September=hsp_standard_week, + October=hsp_standard_week, + November=hsp_standard_week, + December=hsp_standard_week, + ) + + csp_year = YearComponent( + Name="CSP_Year", + Type="Setpoint", + January=csp_standard_week, + February=csp_standard_week, + March=csp_standard_week, + April=csp_standard_week, + May=csp_standard_week, + June=csp_summer_week, + July=csp_summer_week, + August=csp_summer_week, + September=csp_standard_week, + October=csp_standard_week, + November=csp_standard_week, + December=csp_standard_week, + ) + + return hsp_year, csp_year diff --git a/epinterface/sbem/components/schedules/stochastic.py b/epinterface/sbem/components/schedules/stochastic.py new file mode 100644 index 0000000..8b449fc --- /dev/null +++ b/epinterface/sbem/components/schedules/stochastic.py @@ -0,0 +1,378 @@ +"""This module contains the definitions for the schedule generators.""" + +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import Literal + +import numpy as np +from pydantic import BaseModel, Field + + +class ScheduleOutput(ABC): + """Base class for schedule outputs.""" + + @abstractmethod + def construct_idf_object(self): + """Construct the IDF object for the schedule.""" + pass + + +class FractionalScheduleOutput(ScheduleOutput, BaseModel): + """Base class for fractional schedule outputs.""" + + peak_value: float = Field( + ..., + description="The peak value of the schedule, in the units of the peak_units field.", + ge=0, + ) + normalized_timeseries: Sequence[float] = Field( + ..., description="The normalized timeseries of the schedule." + ) + + +class TemperatureScheduleOutput(ScheduleOutput, BaseModel): + """Base class for temperature schedule outputs.""" + + timeseries: Sequence[float] = Field( + ..., description="The timeseries of the temperature schedule." + ) + + +class StochasticEquipmentScheduleOutput(FractionalScheduleOutput): + """Output for the equipment schedule. + + Inherits from FractionalScheduleOutput, so it will contain a normalized timeseries and a peak value. + """ + + dishwasher_cycles_per_week: int = Field( + ..., ge=0, description="The number of dishwasher cycles per week." + ) + laundry_cycles_per_week: int = Field( + ..., ge=0, description="The number of laundry cycles per week." + ) + peak_units: Literal["W/m2"] = "W/m2" + + +class StochasticLightingScheduleOutput(FractionalScheduleOutput): + """Output for the lighting schedule. + + Inherits from FractionalScheduleOutput, so it will contain a normalized timeseries and a peak value. + """ + + peak_units: Literal["W/m2"] = "W/m2" + + +class StochasticOccupancyScheduleOutput(FractionalScheduleOutput): + """Output for the occupancy schedule. + + Inherits from FractionalScheduleOutput, so it will contain a normalized timeseries and a peak value. + """ + + peak_units: Literal["people"] = "people" + + +class StochasticWaterUseScheduleOutput(FractionalScheduleOutput): + """Output for the water use schedule. + + Inherits from FractionalScheduleOutput, so it will contain a normalized timeseries and a peak value. + """ + + peak_units: Literal["m3/s"] = "m3/s" + + +class StochasticHeatingSetpointScheduleOutput(TemperatureScheduleOutput): + """Output for the heating setpoint schedule. + + Inherits from TemperatureScheduleOutput, so it will contain a timeseries of temperatures. + """ + + timeseries_units: Literal["°C"] = "°C" + + +class StochasticCoolingSetpointScheduleOutput(TemperatureScheduleOutput): + """Output for the cooling setpoint schedule. + + Inherits from TemperatureScheduleOutput, so it will contain a timeseries of temperatures. + """ + + timeseries_units: Literal["°C"] = "°C" + + +ScheduleOutputType = ( + StochasticEquipmentScheduleOutput + | StochasticLightingScheduleOutput + | StochasticOccupancyScheduleOutput + | StochasticWaterUseScheduleOutput + | StochasticHeatingSetpointScheduleOutput + | StochasticCoolingSetpointScheduleOutput +) + + +class ScheduleContext(BaseModel): + """Context for the schedule generation.""" + + equipment: StochasticEquipmentScheduleOutput | None = None + lighting: StochasticLightingScheduleOutput | None = None + occupancy: StochasticOccupancyScheduleOutput | None = None + water_use: StochasticWaterUseScheduleOutput | None = None + heating_setpoint: StochasticHeatingSetpointScheduleOutput | None = None + cooling_setpoint: StochasticCoolingSetpointScheduleOutput | None = None + + @property + def safe_occupancy(self) -> StochasticOccupancyScheduleOutput: + """Get the occupancy schedule, raising an error if it is not set.""" + if self.occupancy is None: + msg = "Occupancy schedule is not set" + raise ValueError(msg) + return self.occupancy + + @property + def safe_equipment(self) -> StochasticEquipmentScheduleOutput: + """Get the equipment schedule, raising an error if it is not set.""" + if self.equipment is None: + msg = "Equipment schedule is not set" + raise ValueError(msg) + return self.equipment + + @property + def safe_lighting(self) -> StochasticLightingScheduleOutput: + """Get the lighting schedule, raising an error if it is not set.""" + if self.lighting is None: + msg = "Lighting schedule is not set" + raise ValueError(msg) + return self.lighting + + @property + def safe_water_use(self) -> StochasticWaterUseScheduleOutput: + """Get the water use schedule, raising an error if it is not set.""" + if self.water_use is None: + msg = "Water use schedule is not set" + raise ValueError(msg) + return self.water_use + + @property + def safe_heating_setpoint(self) -> StochasticHeatingSetpointScheduleOutput: + """Get the heating setpoint schedule, raising an error if it is not set.""" + if self.heating_setpoint is None: + msg = "Heating setpoint schedule is not set" + raise ValueError(msg) + return self.heating_setpoint + + @property + def safe_cooling_setpoint(self) -> StochasticCoolingSetpointScheduleOutput: + """Get the cooling setpoint schedule, raising an error if it is not set.""" + if self.cooling_setpoint is None: + msg = "Cooling setpoint schedule is not set" + raise ValueError(msg) + return self.cooling_setpoint + + +def get_generator(generator: np.random.Generator | int) -> np.random.Generator: + """Get a random generator from an integer or a generator.""" + if isinstance(generator, int): + return np.random.default_rng(generator) + return generator + + +class ScheduleGenerator(ABC, BaseModel): + """Base class for all schedule generators.""" + + @abstractmethod + def generate_schedule( + self, generator: np.random.Generator | int, context: ScheduleContext + ) -> ScheduleOutputType: + """Generate a schedule.""" + pass + + +class StochasticEquipmentScheduleGenerator(ScheduleGenerator): + """Generator for the dishwasher schedule.""" + + # TODO: Other parameters for the equipment schedule generator go here... + dishwasher_frequency_min: float + dishwasher_frequency_max: float + + laundry_frequency_min: float + laundry_frequency_max: float + + def generate_schedule( + self, generator: np.random.Generator | int, context: ScheduleContext + ) -> StochasticEquipmentScheduleOutput: + """Generate a dishwasher schedule.""" + generator = get_generator(generator) + + raise NotImplementedError("DishwasherScheduleGenerator is not implemented") + + +class StochasticLightingScheduleGenerator(ScheduleGenerator): + """Generator for the lighting schedule.""" + + # TODO: Other parameters for the lighting schedule generator go here... + working_hours_start: int = Field( + ..., ge=0, le=24, description="The start of the working hours." + ) + working_hours_end: int = Field( + ..., ge=0, le=24, description="The end of the working hours." + ) + + def generate_schedule( + self, + generator: np.random.Generator | int, + context: ScheduleContext, + ) -> StochasticLightingScheduleOutput: + """Generate a lighting schedule.""" + generator = get_generator(generator) + raise NotImplementedError("LightingScheduleGenerator is not implemented") + + +class StochasticWaterUseScheduleGenerator(ScheduleGenerator): + """Generator for the domestic hot water schedule.""" + + # TODO: Other parameters for the domestic hot water schedule generator go here... + + def generate_schedule( + self, + generator: np.random.Generator | int, + context: ScheduleContext, + ) -> StochasticWaterUseScheduleOutput: + """Generate a domestic hot water schedule.""" + generator = get_generator(generator) + equipment = context.safe_equipment + _laundry_cycles_per_week = equipment.laundry_cycles_per_week + _dishwasher_cycles_per_week = equipment.dishwasher_cycles_per_week + raise NotImplementedError( + "DomesticHotWaterScheduleGenerator is not implemented" + ) + + +class StochasticOccupancyScheduleGenerator(ScheduleGenerator): + """Generator for the occupancy schedule.""" + + # TODO: Other parameters for the occupancy schedule generator go here... + home_hours_start: int = Field( + ..., ge=0, le=24, description="The start of the home hours." + ) + home_hours_end: int = Field( + ..., ge=0, le=24, description="The end of the home hours." + ) + people_min: int = Field(..., ge=0, description="The minimum number of people.") + people_max: int = Field(..., ge=0, description="The maximum number of people.") + + def generate_schedule( + self, + generator: np.random.Generator | int, + context: ScheduleContext, + ) -> StochasticOccupancyScheduleOutput: + """Generate an occupancy schedule.""" + generator = get_generator(generator) + raise NotImplementedError("OccupancyScheduleGenerator is not implemented") + + +class StochasticHeatingSetpointScheduleGenerator(ScheduleGenerator): + """Generator for the heating setpoint schedule.""" + + # TODO: Other parameters for the heating setpoint schedule generator go here... + heating_setpoint_min: float = Field( + ..., ge=0, description="The minimum heating setpoint." + ) + heating_setpoint_max: float = Field( + ..., ge=0, description="The maximum heating setpoint." + ) + + def generate_schedule( + self, + generator: np.random.Generator | int, + context: ScheduleContext, + ) -> StochasticHeatingSetpointScheduleOutput: + """Generate a heating setpoint schedule.""" + generator = get_generator(generator) + raise NotImplementedError("HeatingSetpointScheduleGenerator is not implemented") + + +class StochasticCoolingSetpointScheduleGenerator(ScheduleGenerator): + """Generator for the cooling setpoint schedule.""" + + # TODO: Other parameters for the cooling setpoint schedule generator go here... + cooling_setpoint_min: float = Field( + ..., ge=0, description="The minimum cooling setpoint." + ) + cooling_setpoint_max: float = Field( + ..., ge=0, description="The maximum cooling setpoint." + ) + + def generate_schedule( + self, + generator: np.random.Generator | int, + context: ScheduleContext, + ) -> StochasticCoolingSetpointScheduleOutput: + """Generate a cooling setpoint schedule.""" + generator = get_generator(generator) + raise NotImplementedError("CoolingSetpointScheduleGenerator is not implemented") + + +class StochasticScheduleGenerator(BaseModel): + """Generator for stochastic schedules.""" + + equipment: StochasticEquipmentScheduleGenerator + lighting: StochasticLightingScheduleGenerator + occupancy: StochasticOccupancyScheduleGenerator + water_use: StochasticWaterUseScheduleGenerator + heating_setpoint: StochasticHeatingSetpointScheduleGenerator + cooling_setpoint: StochasticCoolingSetpointScheduleGenerator + + def generate_schedules(self, generator: np.random.Generator | int): + """Generate all the schedules.""" + generator = get_generator(generator) + context = ScheduleContext( + equipment=None, + lighting=None, + occupancy=None, + water_use=None, + heating_setpoint=None, + cooling_setpoint=None, + ) + context.equipment = self.equipment.generate_schedule(generator, context) + context.lighting = self.lighting.generate_schedule(generator, context) + context.occupancy = self.occupancy.generate_schedule(generator, context) + context.water_use = self.water_use.generate_schedule(generator, context) + context.heating_setpoint = self.heating_setpoint.generate_schedule( + generator, context + ) + context.cooling_setpoint = self.cooling_setpoint.generate_schedule( + generator, context + ) + return context + + +if __name__ == "__main__": + import yaml + + generator = StochasticScheduleGenerator( + equipment=StochasticEquipmentScheduleGenerator( + dishwasher_frequency_min=1, + dishwasher_frequency_max=2, + laundry_frequency_min=1, + laundry_frequency_max=2, + ), + lighting=StochasticLightingScheduleGenerator( + working_hours_start=1, + working_hours_end=2, + ), + occupancy=StochasticOccupancyScheduleGenerator( + home_hours_start=1, + home_hours_end=2, + people_min=1, + people_max=2, + ), + water_use=StochasticWaterUseScheduleGenerator(), + heating_setpoint=StochasticHeatingSetpointScheduleGenerator( + heating_setpoint_min=1, + heating_setpoint_max=2, + ), + cooling_setpoint=StochasticCoolingSetpointScheduleGenerator( + cooling_setpoint_min=1, + cooling_setpoint_max=2, + ), + ) + schedules = generator.generate_schedules(12345) + print(yaml.dump(schedules.model_dump(mode="json"), indent=2, sort_keys=False)) From e39ed0414f211b0bc0c052d3e7d86a1ae32bde25 Mon Sep 17 00:00:00 2001 From: Amber Date: Thu, 16 Apr 2026 10:08:14 -0400 Subject: [PATCH 02/10] Test Git --- epinterface/sbem/components/schedules/stochastic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/epinterface/sbem/components/schedules/stochastic.py b/epinterface/sbem/components/schedules/stochastic.py index 8b449fc..3590427 100644 --- a/epinterface/sbem/components/schedules/stochastic.py +++ b/epinterface/sbem/components/schedules/stochastic.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field +# TESTING COMMIT class ScheduleOutput(ABC): """Base class for schedule outputs.""" From f0f32b0def296e2144ce4d0469c370c88f6310b3 Mon Sep 17 00:00:00 2001 From: Amber Date: Sun, 19 Apr 2026 16:04:19 -0400 Subject: [PATCH 03/10] Add Initial Stochastic Schedule Gen Implementation for SBEM - Stocahstic generation for occupancy, lighting, equipment, heating/cooling setpoint and dhw - Add the obgeneration dependency --- .../sbem/components/schedules/stochastic.py | 536 +++++++++++++----- pyproject.toml | 3 +- uv.lock | 20 + 3 files changed, 413 insertions(+), 146 deletions(-) diff --git a/epinterface/sbem/components/schedules/stochastic.py b/epinterface/sbem/components/schedules/stochastic.py index 3590427..40ce27c 100644 --- a/epinterface/sbem/components/schedules/stochastic.py +++ b/epinterface/sbem/components/schedules/stochastic.py @@ -5,10 +5,20 @@ from typing import Literal import numpy as np -from pydantic import BaseModel, Field +from obgeneration.generator.dhw_generator import DHWGenerator +from obgeneration.generator.equipment_generator import EquipmentGenerator +from obgeneration.generator.hvac_generator import HVACGenerator +from obgeneration.generator.lighting_generator import LightingGenerator +from obgeneration.generator.occupancy_generator import ( + HouseholdOccupancyFractions, + OccupancyGenerator, +) +from obgeneration.model import builders +from obgeneration.model.equipment import Equipment +from obgeneration.model.occupancy import MobilityCluster +from pydantic import BaseModel, ConfigDict, Field, model_validator -# TESTING COMMIT class ScheduleOutput(ABC): """Base class for schedule outputs.""" @@ -30,6 +40,10 @@ class FractionalScheduleOutput(ScheduleOutput, BaseModel): ..., description="The normalized timeseries of the schedule." ) + def construct_idf_object(self): + """Construct the IDF object for the schedule.""" + raise NotImplementedError("IDF object construction is not implemented") + class TemperatureScheduleOutput(ScheduleOutput, BaseModel): """Base class for temperature schedule outputs.""" @@ -38,6 +52,10 @@ class TemperatureScheduleOutput(ScheduleOutput, BaseModel): ..., description="The timeseries of the temperature schedule." ) + def construct_idf_object(self): + """Construct the IDF object for the schedule.""" + raise NotImplementedError("IDF object construction is not implemented") + class StochasticEquipmentScheduleOutput(FractionalScheduleOutput): """Output for the equipment schedule. @@ -45,12 +63,6 @@ class StochasticEquipmentScheduleOutput(FractionalScheduleOutput): Inherits from FractionalScheduleOutput, so it will contain a normalized timeseries and a peak value. """ - dishwasher_cycles_per_week: int = Field( - ..., ge=0, description="The number of dishwasher cycles per week." - ) - laundry_cycles_per_week: int = Field( - ..., ge=0, description="The number of laundry cycles per week." - ) peak_units: Literal["W/m2"] = "W/m2" @@ -60,6 +72,10 @@ class StochasticLightingScheduleOutput(FractionalScheduleOutput): Inherits from FractionalScheduleOutput, so it will contain a normalized timeseries and a peak value. """ + dimming_enabled: bool = Field( + ..., + description="Whether daylight dimming is enabled for the lighting schedule.", + ) peak_units: Literal["W/m2"] = "W/m2" @@ -112,60 +128,73 @@ class StochasticCoolingSetpointScheduleOutput(TemperatureScheduleOutput): class ScheduleContext(BaseModel): """Context for the schedule generation.""" - equipment: StochasticEquipmentScheduleOutput | None = None - lighting: StochasticLightingScheduleOutput | None = None - occupancy: StochasticOccupancyScheduleOutput | None = None - water_use: StochasticWaterUseScheduleOutput | None = None - heating_setpoint: StochasticHeatingSetpointScheduleOutput | None = None - cooling_setpoint: StochasticCoolingSetpointScheduleOutput | None = None + occupancy: list[list[HouseholdOccupancyFractions]] | None = None + num_occupants: int | None = None + equipment: Equipment | None = None + dishwasher_cycles: list[list[int]] | None = None + laundry_cycles: list[list[int]] | None = None @property - def safe_occupancy(self) -> StochasticOccupancyScheduleOutput: - """Get the occupancy schedule, raising an error if it is not set.""" + def safe_occupancy(self) -> list[list[HouseholdOccupancyFractions]]: + """Get the occupancy states schedule, raising an error if it is not set.""" if self.occupancy is None: - msg = "Occupancy schedule is not set" + msg = "Occupancy states schedule is not set" raise ValueError(msg) return self.occupancy @property - def safe_equipment(self) -> StochasticEquipmentScheduleOutput: - """Get the equipment schedule, raising an error if it is not set.""" - if self.equipment is None: - msg = "Equipment schedule is not set" + def safe_num_occupants(self) -> int: + """Get the number of occupants, raising an error if it is not set.""" + if self.num_occupants is None: + msg = "Number of occupants is not set" raise ValueError(msg) - return self.equipment + return self.num_occupants @property - def safe_lighting(self) -> StochasticLightingScheduleOutput: - """Get the lighting schedule, raising an error if it is not set.""" - if self.lighting is None: - msg = "Lighting schedule is not set" + def safe_equipment(self) -> Equipment: + """Get the equipment, raising an error if it is not set.""" + if self.equipment is None: + msg = "Equipment is not set" raise ValueError(msg) - return self.lighting + return self.equipment @property - def safe_water_use(self) -> StochasticWaterUseScheduleOutput: - """Get the water use schedule, raising an error if it is not set.""" - if self.water_use is None: - msg = "Water use schedule is not set" + def safe_dishwasher_cycles(self) -> list[list[int]]: + """Get the dishwasher cycles, raising an error if it is not set.""" + if self.dishwasher_cycles is None: + msg = "Dishwasher cycles are not set" raise ValueError(msg) - return self.water_use + return self.dishwasher_cycles @property - def safe_heating_setpoint(self) -> StochasticHeatingSetpointScheduleOutput: - """Get the heating setpoint schedule, raising an error if it is not set.""" - if self.heating_setpoint is None: - msg = "Heating setpoint schedule is not set" + def safe_laundry_cycles(self) -> list[list[int]]: + """Get the laundry cycles, raising an error if it is not set.""" + if self.laundry_cycles is None: + msg = "Laundry cycles are not set" raise ValueError(msg) - return self.heating_setpoint + return self.laundry_cycles - @property - def safe_cooling_setpoint(self) -> StochasticCoolingSetpointScheduleOutput: - """Get the cooling setpoint schedule, raising an error if it is not set.""" - if self.cooling_setpoint is None: - msg = "Cooling setpoint schedule is not set" - raise ValueError(msg) - return self.cooling_setpoint + +class ScheduleResults(BaseModel): + """Results of the schedule generation.""" + + occupancy: StochasticOccupancyScheduleOutput | None = None + heating_setpoint: StochasticHeatingSetpointScheduleOutput | None = None + cooling_setpoint: StochasticCoolingSetpointScheduleOutput | None = None + lighting: StochasticLightingScheduleOutput | None = None + equipment: StochasticEquipmentScheduleOutput | None = None + water_use: StochasticWaterUseScheduleOutput | None = None + + +class ScheduleGenerationConfig(BaseModel): + """Shared configuration for all generated schedules.""" + + model_config = ConfigDict(frozen=True) + resolution_minutes: int = Field( + ..., + description="Minutes represented by each timestep for all generated schedules.", + gt=0, + ) def get_generator(generator: np.random.Generator | int) -> np.random.Generator: @@ -180,140 +209,339 @@ class ScheduleGenerator(ABC, BaseModel): @abstractmethod def generate_schedule( - self, generator: np.random.Generator | int, context: ScheduleContext + self, + generator: np.random.Generator | int, + context: ScheduleContext, + config: ScheduleGenerationConfig, ) -> ScheduleOutputType: """Generate a schedule.""" pass -class StochasticEquipmentScheduleGenerator(ScheduleGenerator): - """Generator for the dishwasher schedule.""" +OccupancyPatternName = Literal[ + "mostly_home", + "long_day_away", + "morning_away", + "afternoon_away", + "evening_night_away", +] + + +class StochasticOccupancyScheduleGenerator(ScheduleGenerator): + """Generator for the occupancy schedule.""" + + weekday_occupancy_patterns: Sequence[OccupancyPatternName] = Field( + ..., + description="Per-occupant weekday occupancy patterns.", + ) + weekend_occupancy_patterns: Sequence[OccupancyPatternName] | None = Field( + None, + description="Per-occupant weekend occupancy patterns. If omitted, weekday patterns are reused.", + ) - # TODO: Other parameters for the equipment schedule generator go here... - dishwasher_frequency_min: float - dishwasher_frequency_max: float + @model_validator(mode="after") + def validate_patterns(self): + """Validate weekday/weekend occupancy pattern presence and lengths.""" + if len(self.weekday_occupancy_patterns) == 0: + msg = "At least one weekday pattern is required" + raise ValueError(msg) - laundry_frequency_min: float - laundry_frequency_max: float + if self.weekend_occupancy_patterns is not None and len( + self.weekend_occupancy_patterns + ) != len(self.weekday_occupancy_patterns): + msg = ( + f"weekend_occupancy_patterns must have the same length as " + f"weekday_occupancy_patterns, but got {len(self.weekend_occupancy_patterns)} and " + f"{len(self.weekday_occupancy_patterns)}, respectively." + ) + raise ValueError(msg) + return self + + @property + def num_occupants(self) -> int: + """Return the number of occupants represented by the weekday patterns.""" + return len(self.weekday_occupancy_patterns) + + @classmethod + def from_uniform_patterns( + cls, + weekday_pattern: OccupancyPatternName, + num_occupants: int, + weekend_pattern: OccupancyPatternName | None = None, + ) -> "StochasticOccupancyScheduleGenerator": + """Create a generator by repeating one pattern for each occupant.""" + if num_occupants <= 0: + msg = f"num_occupants must be positive, but got {num_occupants}." + raise ValueError(msg) + return cls( + weekday_occupancy_patterns=(weekday_pattern,) * num_occupants, + weekend_occupancy_patterns=( + None if weekend_pattern is None else (weekend_pattern,) * num_occupants + ), + ) def generate_schedule( - self, generator: np.random.Generator | int, context: ScheduleContext - ) -> StochasticEquipmentScheduleOutput: - """Generate a dishwasher schedule.""" + self, + generator: np.random.Generator | int, + context: ScheduleContext, + config: ScheduleGenerationConfig, + ) -> StochasticOccupancyScheduleOutput: + """Generate an occupancy schedule.""" generator = get_generator(generator) - - raise NotImplementedError("DishwasherScheduleGenerator is not implemented") + occupancy = builders.build_occupancy( + mobility_clusters=[ + MobilityCluster(pattern) for pattern in self.weekday_occupancy_patterns + ], + weekend_clusters=[ + MobilityCluster(pattern) for pattern in self.weekend_occupancy_patterns + ] + if self.weekend_occupancy_patterns is not None + else None, + ) + occupancy_res = OccupancyGenerator.generate_with_defaults( + occupancy, + config.resolution_minutes, + rng=generator, + ) + context.occupancy = occupancy_res.occupancy_states + context.num_occupants = self.num_occupants + return StochasticOccupancyScheduleOutput( + peak_value=occupancy_res.peak_value, + normalized_timeseries=occupancy_res.schedule, + ) -class StochasticLightingScheduleGenerator(ScheduleGenerator): - """Generator for the lighting schedule.""" +class StochasticHeatingSetpointScheduleGenerator(ScheduleGenerator): + """Generator for the heating setpoint schedule.""" - # TODO: Other parameters for the lighting schedule generator go here... - working_hours_start: int = Field( - ..., ge=0, le=24, description="The start of the working hours." + has_heating: bool = Field(..., description="Whether the building has heating.") + heating_setpoint_active: float = Field( + ..., ge=0, description="Heating setpoint while active." + ) + heating_setpoint_sleep: float | None = Field( + None, ge=0, description="Heating setpoint while asleep." ) - working_hours_end: int = Field( - ..., ge=0, le=24, description="The end of the working hours." + heating_setpoint_away: float | None = Field( + None, ge=0, description="Heating setpoint while away." ) def generate_schedule( self, generator: np.random.Generator | int, context: ScheduleContext, - ) -> StochasticLightingScheduleOutput: - """Generate a lighting schedule.""" + config: ScheduleGenerationConfig, + ) -> StochasticHeatingSetpointScheduleOutput: + """Generate a heating setpoint schedule.""" generator = get_generator(generator) - raise NotImplementedError("LightingScheduleGenerator is not implemented") + heating = builders.build_heating( + self.has_heating, + self.heating_setpoint_active, + self.heating_setpoint_sleep, + self.heating_setpoint_away, + ) + heating_setpoint_res = HVACGenerator.generate_heating_with_defaults( + heating, context.safe_occupancy + ) + if heating_setpoint_res is None: + msg = "Failed to generate heating setpoint schedule." + raise RuntimeError(msg) + return StochasticHeatingSetpointScheduleOutput( + timeseries=heating_setpoint_res.schedule + ) -class StochasticWaterUseScheduleGenerator(ScheduleGenerator): - """Generator for the domestic hot water schedule.""" +class StochasticCoolingSetpointScheduleGenerator(ScheduleGenerator): + """Generator for the cooling setpoint schedule.""" - # TODO: Other parameters for the domestic hot water schedule generator go here... + has_cooling: bool = Field(..., description="Whether the building has cooling.") + cooling_setpoint_active: float = Field( + ..., ge=0, description="Cooling setpoint while active." + ) + cooling_setpoint_sleep: float | None = Field( + None, ge=0, description="Cooling setpoint while asleep." + ) + cooling_setpoint_away: float | None = Field( + None, ge=0, description="Cooling setpoint while away." + ) def generate_schedule( self, generator: np.random.Generator | int, context: ScheduleContext, - ) -> StochasticWaterUseScheduleOutput: - """Generate a domestic hot water schedule.""" + config: ScheduleGenerationConfig, + ) -> StochasticCoolingSetpointScheduleOutput: + """Generate a cooling setpoint schedule.""" generator = get_generator(generator) - equipment = context.safe_equipment - _laundry_cycles_per_week = equipment.laundry_cycles_per_week - _dishwasher_cycles_per_week = equipment.dishwasher_cycles_per_week - raise NotImplementedError( - "DomesticHotWaterScheduleGenerator is not implemented" + cooling = builders.build_cooling( + self.has_cooling, + self.cooling_setpoint_active, + self.cooling_setpoint_sleep, + self.cooling_setpoint_away, + ) + cooling_setpoint_res = HVACGenerator.generate_cooling_with_defaults( + cooling, context.safe_occupancy + ) + if cooling_setpoint_res is None: + msg = "Failed to generate cooling setpoint schedule." + raise RuntimeError(msg) + return StochasticCoolingSetpointScheduleOutput( + timeseries=cooling_setpoint_res.schedule ) -class StochasticOccupancyScheduleGenerator(ScheduleGenerator): - """Generator for the occupancy schedule.""" +class StochasticLightingScheduleGenerator(ScheduleGenerator): + """Generator for the lighting schedule.""" - # TODO: Other parameters for the occupancy schedule generator go here... - home_hours_start: int = Field( - ..., ge=0, le=24, description="The start of the home hours." + if_led: bool = Field( + ..., + description="Whether the building uses LED lighting. This affects the peak value of the schedule.", + ) + when_away: bool | None = Field( + None, description="Whether the lights are on when the building is unoccupied." ) - home_hours_end: int = Field( - ..., ge=0, le=24, description="The end of the home hours." + when_bright: bool | None = Field( + None, description="Whether the lights are on when it is bright outside." ) - people_min: int = Field(..., ge=0, description="The minimum number of people.") - people_max: int = Field(..., ge=0, description="The maximum number of people.") def generate_schedule( self, generator: np.random.Generator | int, context: ScheduleContext, - ) -> StochasticOccupancyScheduleOutput: - """Generate an occupancy schedule.""" + config: ScheduleGenerationConfig, + ) -> StochasticLightingScheduleOutput: + """Generate a lighting schedule.""" generator = get_generator(generator) - raise NotImplementedError("OccupancyScheduleGenerator is not implemented") + lighting = builders.build_lighting( + self.if_led, + self.when_away, + self.when_bright, + ) + lighting_res = LightingGenerator.generate_with_defaults( + lighting, context.safe_occupancy, generator + ) + return StochasticLightingScheduleOutput( + dimming_enabled=lighting_res.dimming_enabled, + peak_value=lighting_res.peak_value, + normalized_timeseries=lighting_res.schedule, + ) -class StochasticHeatingSetpointScheduleGenerator(ScheduleGenerator): - """Generator for the heating setpoint schedule.""" +class StochasticEquipmentScheduleGenerator(ScheduleGenerator): + """Generator for the dishwasher schedule.""" - # TODO: Other parameters for the heating setpoint schedule generator go here... - heating_setpoint_min: float = Field( - ..., ge=0, description="The minimum heating setpoint." + has_washer: bool = Field( + ..., description="Whether the building has a washing machine." + ) + has_dryer: bool = Field(..., description="Whether the building has a dryer.") + has_cooking_provider: bool = Field( + ..., description="Whether the building has a cooking-related appliance." + ) + has_dishwasher: bool = Field( + ..., description="Whether the building has a dishwasher." ) - heating_setpoint_max: float = Field( - ..., ge=0, description="The maximum heating setpoint." + num_refrigerators: int = Field( + ..., ge=0, description="The number of refrigerators in the building." + ) + washer_efficient: bool | None = Field( + None, description="Whether the washing machine is energy efficient." + ) + dryer_efficient: bool | None = Field( + None, description="Whether the dryer is energy efficient." + ) + dishwasher_efficient: bool | None = Field( + None, description="Whether the dishwasher is energy efficient." + ) + refrigerator_efficient: bool | None = Field( + None, description="Whether the refrigerators are energy efficient." + ) + laundry_freq_per_week_min: int = Field( + ..., ge=0, description="Minimum number of laundry cycles per week." + ) + laundry_freq_per_week_max: int = Field( + ..., ge=0, description="Maximum number of laundry cycles per week." + ) + cooking_freq_per_week_min: int = Field( + ..., + ge=0, + description="Minimum number of cooking-related appliance cycles per week.", + ) + cooking_freq_per_week_max: int = Field( + ..., + ge=0, + description="Maximum number of cooking-related appliance cycles per week.", + ) + dishwasher_freq_per_week_min: int = Field( + ..., ge=0, description="Minimum number of dishwasher cycles per week." + ) + dishwasher_freq_per_week_max: int = Field( + ..., ge=0, description="Maximum number of dishwasher cycles per week." ) def generate_schedule( self, generator: np.random.Generator | int, context: ScheduleContext, - ) -> StochasticHeatingSetpointScheduleOutput: - """Generate a heating setpoint schedule.""" + config: ScheduleGenerationConfig, + ) -> StochasticEquipmentScheduleOutput: + """Generate a dishwasher schedule.""" generator = get_generator(generator) - raise NotImplementedError("HeatingSetpointScheduleGenerator is not implemented") + eqp = builders.build_equipment( + self.has_washer, + self.has_dryer, + self.has_cooking_provider, + self.has_dishwasher, + self.num_refrigerators, + self.washer_efficient, + self.dryer_efficient, + self.dishwasher_efficient, + self.refrigerator_efficient, + (self.laundry_freq_per_week_min, self.laundry_freq_per_week_max), + (self.cooking_freq_per_week_min, self.cooking_freq_per_week_max), + (self.dishwasher_freq_per_week_min, self.dishwasher_freq_per_week_max), + ) + eqp_res = EquipmentGenerator.generate_with_defaults( + eqp, + context.safe_occupancy, + int(context.safe_num_occupants), + resolution_mins=config.resolution_minutes, + rng=generator, + ) + context.equipment = eqp + context.laundry_cycles = eqp_res.laundry_cycles + context.dishwasher_cycles = eqp_res.dishwasher_cycles + return StochasticEquipmentScheduleOutput( + peak_value=eqp_res.peak_value, normalized_timeseries=eqp_res.schedule + ) -class StochasticCoolingSetpointScheduleGenerator(ScheduleGenerator): - """Generator for the cooling setpoint schedule.""" - # TODO: Other parameters for the cooling setpoint schedule generator go here... - cooling_setpoint_min: float = Field( - ..., ge=0, description="The minimum cooling setpoint." - ) - cooling_setpoint_max: float = Field( - ..., ge=0, description="The maximum cooling setpoint." - ) +class StochasticWaterUseScheduleGenerator(ScheduleGenerator): + """Generator for the domestic hot water schedule.""" def generate_schedule( self, generator: np.random.Generator | int, context: ScheduleContext, - ) -> StochasticCoolingSetpointScheduleOutput: - """Generate a cooling setpoint schedule.""" + config: ScheduleGenerationConfig, + ) -> StochasticWaterUseScheduleOutput: + """Generate a domestic hot water schedule.""" generator = get_generator(generator) - raise NotImplementedError("CoolingSetpointScheduleGenerator is not implemented") + dhw_res = DHWGenerator.generate_with_defaults( + int(context.safe_num_occupants), + context.safe_equipment, + context.safe_laundry_cycles, + context.safe_dishwasher_cycles, + config.resolution_minutes, + ) + return StochasticWaterUseScheduleOutput( + peak_value=dhw_res.peak_value, normalized_timeseries=dhw_res.schedule + ) class StochasticScheduleGenerator(BaseModel): """Generator for stochastic schedules.""" + config: ScheduleGenerationConfig equipment: StochasticEquipmentScheduleGenerator lighting: StochasticLightingScheduleGenerator occupancy: StochasticOccupancyScheduleGenerator @@ -321,59 +549,79 @@ class StochasticScheduleGenerator(BaseModel): heating_setpoint: StochasticHeatingSetpointScheduleGenerator cooling_setpoint: StochasticCoolingSetpointScheduleGenerator - def generate_schedules(self, generator: np.random.Generator | int): + def generate_schedules( + self, generator: np.random.Generator | int + ) -> ScheduleResults: """Generate all the schedules.""" generator = get_generator(generator) - context = ScheduleContext( - equipment=None, - lighting=None, - occupancy=None, - water_use=None, - heating_setpoint=None, - cooling_setpoint=None, + context = ScheduleContext() + results = ScheduleResults() + results.occupancy = self.occupancy.generate_schedule( + generator, context, self.config + ) + results.heating_setpoint = self.heating_setpoint.generate_schedule( + generator, context, self.config + ) + results.cooling_setpoint = self.cooling_setpoint.generate_schedule( + generator, context, self.config + ) + results.lighting = self.lighting.generate_schedule( + generator, context, self.config ) - context.equipment = self.equipment.generate_schedule(generator, context) - context.lighting = self.lighting.generate_schedule(generator, context) - context.occupancy = self.occupancy.generate_schedule(generator, context) - context.water_use = self.water_use.generate_schedule(generator, context) - context.heating_setpoint = self.heating_setpoint.generate_schedule( - generator, context + results.equipment = self.equipment.generate_schedule( + generator, context, self.config ) - context.cooling_setpoint = self.cooling_setpoint.generate_schedule( - generator, context + results.water_use = self.water_use.generate_schedule( + generator, context, self.config ) - return context + return results if __name__ == "__main__": import yaml generator = StochasticScheduleGenerator( + config=ScheduleGenerationConfig(resolution_minutes=15), equipment=StochasticEquipmentScheduleGenerator( - dishwasher_frequency_min=1, - dishwasher_frequency_max=2, - laundry_frequency_min=1, - laundry_frequency_max=2, + has_washer=True, + has_dryer=True, + has_cooking_provider=True, + has_dishwasher=True, + num_refrigerators=1, + washer_efficient=True, + dryer_efficient=True, + dishwasher_efficient=True, + refrigerator_efficient=True, + laundry_freq_per_week_min=1, + laundry_freq_per_week_max=2, + cooking_freq_per_week_min=7, + cooking_freq_per_week_max=14, + dishwasher_freq_per_week_min=1, + dishwasher_freq_per_week_max=2, ), lighting=StochasticLightingScheduleGenerator( - working_hours_start=1, - working_hours_end=2, + if_led=True, + when_away=False, + when_bright=False, ), - occupancy=StochasticOccupancyScheduleGenerator( - home_hours_start=1, - home_hours_end=2, - people_min=1, - people_max=2, + occupancy=StochasticOccupancyScheduleGenerator.from_uniform_patterns( + weekday_pattern="mostly_home", + weekend_pattern="mostly_home", + num_occupants=2, ), water_use=StochasticWaterUseScheduleGenerator(), heating_setpoint=StochasticHeatingSetpointScheduleGenerator( - heating_setpoint_min=1, - heating_setpoint_max=2, + has_heating=True, + heating_setpoint_active=21, + heating_setpoint_sleep=18, + heating_setpoint_away=16, ), cooling_setpoint=StochasticCoolingSetpointScheduleGenerator( - cooling_setpoint_min=1, - cooling_setpoint_max=2, + has_cooling=True, + cooling_setpoint_active=24, + cooling_setpoint_sleep=26, + cooling_setpoint_away=28, ), ) - schedules = generator.generate_schedules(12345) + schedules = generator.generate_schedules(42) print(yaml.dump(schedules.model_dump(mode="json"), indent=2, sort_keys=False)) diff --git a/pyproject.toml b/pyproject.toml index 48ce9e1..ca57822 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "geopandas~=1.0.1", "httpx~=0.27.2", "ladybug-core>=0.44.30", + "obgeneration>=0.1.0", "openpyxl~=3.1.5", "pandas>=2.2,<2.3", "prisma~=0.15.0", @@ -47,8 +48,6 @@ docs = [ "mkdocs-material>=9.6.12,<10.0", "mkdocstrings[python]>=0.29.1,<1.0", ] - -[tool.uv.sources] # archetypal = { git = "https://github.com/samuelduchesne/archetypal", branch = "main" } [build-system] diff --git a/uv.lock b/uv.lock index 1adfa13..6ddeddd 100644 --- a/uv.lock +++ b/uv.lock @@ -581,6 +581,7 @@ dependencies = [ { name = "geopandas" }, { name = "httpx" }, { name = "ladybug-core" }, + { name = "obgeneration" }, { name = "openpyxl" }, { name = "pandas" }, { name = "prisma" }, @@ -616,6 +617,7 @@ requires-dist = [ { name = "geopandas", specifier = "~=1.0.1" }, { name = "httpx", specifier = "~=0.27.2" }, { name = "ladybug-core", specifier = ">=0.44.30" }, + { name = "obgeneration", specifier = ">=0.1.0" }, { name = "openpyxl", specifier = "~=3.1.5" }, { name = "pandas", specifier = ">=2.2,<2.3" }, { name = "prisma", specifier = "~=0.15.0" }, @@ -2019,6 +2021,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/6b/1c6b515a83d5564b1698a61efa245727c8feecf308f4091f565988519d20/numpy-2.3.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e610832418a2bc09d974cc9fecebfa51e9532d6190223bc5ef6a7402ebf3b5cb", size = 12927246, upload-time = "2025-06-21T12:27:38.618Z" }, ] +[[package]] +name = "obgeneration" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas" }, + { name = "pydantic" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/38/186fdce185078f83093fd72cbfcb1bd3c1fd3c946fdd2e5c7d247afbbb88/obgeneration-0.1.0.tar.gz", hash = "sha256:f38ca88995445758049c51f2b664be75a63b068f85431a0a9198b3929b6eaa48", size = 196567, upload-time = "2026-04-19T19:58:25.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/b3/6a6dd1fd9bbe0661a08573641da3a11431e9636169a550251094025c78a5/obgeneration-0.1.0-py3-none-any.whl", hash = "sha256:8760943240222fe9126b9c1abf5cfd63eb347443c8d1e5e010f940dee9ab69a4", size = 223075, upload-time = "2026-04-19T19:58:26.58Z" }, +] + [[package]] name = "openpyxl" version = "3.1.5" From 394a624dc0209c51c170c46b1d65c213a703434d Mon Sep 17 00:00:00 2001 From: Sam Wolk <36545842+szvsw@users.noreply.github.com> Date: Thu, 21 May 2026 09:49:58 -0400 Subject: [PATCH 04/10] add stochastic schedule to idf object conversion --- epinterface/interface.py | 300 ++++++++++++++++++ .../sbem/components/schedules/stochastic.py | 156 ++++++++- 2 files changed, 449 insertions(+), 7 deletions(-) diff --git a/epinterface/interface.py b/epinterface/interface.py index 59bc9b9..6696537 100644 --- a/epinterface/interface.py +++ b/epinterface/interface.py @@ -1,6 +1,7 @@ """Interface for EnergyPlus IDF objects.""" from collections.abc import Sequence +from datetime import date, timedelta from logging import getLogger from typing import Annotated, Any, ClassVar, Literal @@ -834,6 +835,109 @@ class ScheduleDayHourly(BaseObj, extra="ignore"): Hour_24: float +N_TS_PER_DAY = 96 +"""Number of timesteps per day for 15-minute interval schedules (24 * 60 / 15).""" + +InterpolateToTimestepType = Literal["Average", "Linear", "No"] + + +class ScheduleDayInterval(BaseObj, extra="ignore"): + """Schedule:Day:Interval object for sub-hourly schedule data. + + Fixed at 15-minute intervals (96 values per day). + Each value covers a 15-minute window ending at the corresponding time. + """ + + key: ClassVar[str] = "SCHEDULE:DAY:INTERVAL" + Name: str + Schedule_Type_Limits_Name: str + Interpolate_to_Timestep: InterpolateToTimestepType = "No" + Values: tuple[float, ...] = Field( + ..., min_length=N_TS_PER_DAY, max_length=N_TS_PER_DAY + ) + + def add(self, idf: IDF): + """Add the object to the IDF. + + Generates the Time/Value field pairs for 96 fifteen-minute intervals + (00:15 through 24:00). + + Args: + idf (IDF): The IDF object to add the schedule to. + + Returns: + idf (IDF): The updated IDF object. + """ + fields: dict[str, Any] = { + "Name": self.Name, + "Schedule_Type_Limits_Name": self.Schedule_Type_Limits_Name, + "Interpolate_to_Timestep": self.Interpolate_to_Timestep, + } + for i, val in enumerate(self.Values): + idx = i + 1 + total_minutes = idx * 15 + hours = total_minutes // 60 + minutes = total_minutes % 60 + fields[f"Time_{idx}"] = f"{hours:02d}:{minutes:02d}" + fields[f"Value_Until_Time_{idx}"] = val + idf.newidfobject(self.key, **fields) + return idf + + +class ScheduleDayList(BaseObj, extra="ignore"): + """Schedule:Day:List object for sub-hourly schedule data. + + Uses a flat list of values at a fixed minute interval to describe + a complete 24-hour day. Defaults to 15-minute intervals (96 values). + """ + + key: ClassVar[str] = "SCHEDULE:DAY:LIST" + Name: str + Schedule_Type_Limits_Name: str + Interpolate_to_Timestep: InterpolateToTimestepType = "No" + Minutes_per_Item: int = Field(default=15, ge=1, le=60) + Values: tuple[float, ...] + + @model_validator(mode="after") + def _validate_values_length(self) -> Self: + """Validate that the number of values matches Minutes_per_Item.""" + if 60 % self.Minutes_per_Item != 0: + msg = f"Minutes_per_Item ({self.Minutes_per_Item}) must evenly divide 60" + raise ValueError(msg) + expected = 24 * 60 // self.Minutes_per_Item + if len(self.Values) != expected: + msg = ( + f"Expected {expected} values for " + f"{self.Minutes_per_Item}-minute intervals, " + f"got {len(self.Values)}" + ) + raise ValueError(msg) + return self + + def add(self, idf: IDF): + """Add the object to the IDF. + + Generates the Minutes_per_Item and Value_N fields. + + Args: + idf (IDF): The IDF object to add the schedule to. + + Returns: + idf (IDF): The updated IDF object. + """ + fields: dict[str, Any] = { + "Name": self.Name, + "Schedule_Type_Limits_Name": self.Schedule_Type_Limits_Name, + "Interpolate_to_Timestep": self.Interpolate_to_Timestep, + "Minutes_per_Item": self.Minutes_per_Item, + } + for i in range(1440): + val = self.Values[i] if i < len(self.Values) else None + fields[f"Value_{i + 1}"] = val + idf.newidfobject(self.key, **fields) + return idf + + class ScheduleWeekDaily(BaseObj, extra="ignore"): """ScheduleWeekDaily object.""" @@ -957,6 +1061,202 @@ class ScheduleYear(BaseObj, extra="ignore"): End_Day_12: int | None = None +DayOfWeekType = Literal[ + "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" +] + +_DOW_TO_INDEX: dict[str, int] = { + "Monday": 0, + "Tuesday": 1, + "Wednesday": 2, + "Thursday": 3, + "Friday": 4, + "Saturday": 5, + "Sunday": 6, +} + +_DOW_FIELDS = [ + "Monday_ScheduleDay_Name", + "Tuesday_ScheduleDay_Name", + "Wednesday_ScheduleDay_Name", + "Thursday_ScheduleDay_Name", + "Friday_ScheduleDay_Name", + "Saturday_ScheduleDay_Name", + "Sunday_ScheduleDay_Name", +] + + +class ScheduleYearEntry(BaseModel): + """A single date-range entry within a Schedule:Year.""" + + week_schedule_name: str + start_month: int = Field(..., ge=1, le=12) + start_day: int = Field(..., ge=1, le=31) + end_month: int = Field(..., ge=1, le=12) + end_day: int = Field(..., ge=1, le=31) + + +class DynamicScheduleYear(BaseObj, extra="ignore"): + """Schedule:Year with a dynamic number of week entries (up to 53). + + Use this instead of ScheduleYear when more than 12 date-range entries + are needed (e.g. when every week of the year has a unique schedule). + """ + + key: ClassVar[str] = "SCHEDULE:YEAR" + Name: str + Schedule_Type_Limits_Name: str + entries: Sequence[ScheduleYearEntry] = Field(..., min_length=1, max_length=53) + + def add(self, idf: IDF): + """Add the Schedule:Year to the IDF with all date-range entries. + + Args: + idf (IDF): The IDF object to add the schedule to. + + Returns: + idf (IDF): The updated IDF object. + """ + fields: dict[str, Any] = { + "Name": self.Name, + "Schedule_Type_Limits_Name": self.Schedule_Type_Limits_Name, + } + for i, entry in enumerate(self.entries): + idx = i + 1 + fields[f"ScheduleWeek_Name_{idx}"] = entry.week_schedule_name + fields[f"Start_Month_{idx}"] = entry.start_month + fields[f"Start_Day_{idx}"] = entry.start_day + fields[f"End_Month_{idx}"] = entry.end_month + fields[f"End_Day_{idx}"] = entry.end_day + idf.newidfobject(self.key, **fields) + return idf + + +class ScheduleYearFromDays(BaseModel): + """Builds a complete Schedule:Year hierarchy from per-day schedules. + + Takes a sequence of 365 (or 366) ScheduleDayList objects -- one per + calendar day -- and groups them into calendar weeks based on the + simulation start day of week. Intermediate Schedule:Week:Daily objects + are created so that EnergyPlus can resolve day-of-week correctly within + each week-long date range. The resulting Schedule:Year has at most 53 + entries, which is the EnergyPlus maximum. + + Args: + Name: Name for the resulting Schedule:Year (also used as a prefix + for the generated Schedule:Week:Daily names). + Schedule_Type_Limits_Name: Reference to a ScheduleTypeLimits object. + day_schedules: Exactly 365 (or 366 for leap years) ScheduleDayList + objects, ordered Jan 1 through Dec 31. + start_day_of_week: The day of week for January 1 (must match + RunPeriod.Day_of_Week_for_Start_Day). + summer_design_day_schedule: Optional day schedule for the + SummerDesignDay slot in every generated week schedule. + winter_design_day_schedule: Optional day schedule for the + WinterDesignDay slot in every generated week schedule. + """ + + Name: str + Schedule_Type_Limits_Name: str + day_schedules: Sequence[ScheduleDayList] + start_day_of_week: DayOfWeekType = "Sunday" + summer_design_day_schedule: ScheduleDayList | None = None + winter_design_day_schedule: ScheduleDayList | None = None + + @model_validator(mode="after") + def _validate_day_count(self) -> Self: + n = len(self.day_schedules) + if n not in (365, 366): + msg = f"Expected 365 or 366 day schedules, got {n}" + raise ValueError(msg) + return self + + def add(self, idf: IDF) -> IDF: + """Add all day, week, and year schedule objects to the IDF. + + For each calendar week the method creates a Schedule:Week:Daily + whose day-of-week slots point to the corresponding ScheduleDayList. + Day-of-week slots that fall outside the week's date range are filled + with the first day of that week (they are never accessed by the + simulation because the Schedule:Year date range excludes them). + + Args: + idf (IDF): The IDF object to add schedules to. + + Returns: + idf (IDF): The updated IDF object. + """ + n_days = len(self.day_schedules) + ref_year = 2001 if n_days == 365 else 2000 + ref_start = date(ref_year, 1, 1) + + for ds in self.day_schedules: + ds.add(idf) + if self.summer_design_day_schedule is not None: + self.summer_design_day_schedule.add(idf) + if self.winter_design_day_schedule is not None: + self.winter_design_day_schedule.add(idf) + + start_dow = _DOW_TO_INDEX[self.start_day_of_week] + entries: list[ScheduleYearEntry] = [] + day_idx = 0 + week_num = 0 + + while day_idx < n_days: + week_num += 1 + week_start = ref_start + timedelta(days=day_idx) + mapping: dict[str, str] = {} + first_name = self.day_schedules[day_idx].Name + + while day_idx < n_days: + dow = (start_dow + day_idx) % 7 + mapping[_DOW_FIELDS[dow]] = self.day_schedules[day_idx].Name + day_idx += 1 + if dow == 6: # Sunday ends the EnergyPlus week + break + + week_end = ref_start + timedelta(days=day_idx - 1) + week_name = f"{self.Name}_wk_{week_num}" + + week_kwargs = { + field: mapping.get(field, first_name) for field in _DOW_FIELDS + } + week_sched = ScheduleWeekDaily( + Name=week_name, + **week_kwargs, + SummerDesignDay_ScheduleDay_Name=( + self.summer_design_day_schedule.Name + if self.summer_design_day_schedule + else None + ), + WinterDesignDay_ScheduleDay_Name=( + self.winter_design_day_schedule.Name + if self.winter_design_day_schedule + else None + ), + ) + week_sched.add(idf) + + entries.append( + ScheduleYearEntry( + week_schedule_name=week_name, + start_month=week_start.month, + start_day=week_start.day, + end_month=week_end.month, + end_day=week_end.day, + ) + ) + + year_sched = DynamicScheduleYear( + Name=self.Name, + Schedule_Type_Limits_Name=self.Schedule_Type_Limits_Name, + entries=entries, + ) + year_sched.add(idf) + + return idf + + class ZoneList(BaseModel, extra="ignore"): """ZoneList object.""" diff --git a/epinterface/sbem/components/schedules/stochastic.py b/epinterface/sbem/components/schedules/stochastic.py index 40ce27c..070e731 100644 --- a/epinterface/sbem/components/schedules/stochastic.py +++ b/epinterface/sbem/components/schedules/stochastic.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from collections.abc import Sequence +from pathlib import Path from typing import Literal import numpy as np @@ -18,12 +19,15 @@ from obgeneration.model.occupancy import MobilityCluster from pydantic import BaseModel, ConfigDict, Field, model_validator +from epinterface.interface import ScheduleDayList, ScheduleYearFromDays +from epinterface.weather import WeatherUrl + class ScheduleOutput(ABC): """Base class for schedule outputs.""" @abstractmethod - def construct_idf_object(self): + def construct_idf_object(self) -> ScheduleYearFromDays: """Construct the IDF object for the schedule.""" pass @@ -31,6 +35,10 @@ def construct_idf_object(self): class FractionalScheduleOutput(ScheduleOutput, BaseModel): """Base class for fractional schedule outputs.""" + name: str = Field( + ..., + description="The name of the schedule.", + ) peak_value: float = Field( ..., description="The peak value of the schedule, in the units of the peak_units field.", @@ -42,7 +50,28 @@ class FractionalScheduleOutput(ScheduleOutput, BaseModel): def construct_idf_object(self): """Construct the IDF object for the schedule.""" - raise NotImplementedError("IDF object construction is not implemented") + ts_by_day = ( + np.array(self.normalized_timeseries) + .reshape(-1, 96, 1) + .repeat(15, axis=-1) + .reshape(-1, 1440) + ) + days = [ + ScheduleDayList( + Name=f"{self.name}Day{i:03d}", + Schedule_Type_Limits_Name="Fraction", + Values=tuple(ts_by_day[i]), + Minutes_per_Item=1, + ) + for i in range(365) + ] + year = ScheduleYearFromDays( + Name=f"{self.name}Year", + Schedule_Type_Limits_Name="Fraction", + day_schedules=days, + start_day_of_week="Sunday", # this should be the START DAY OF THE INPUT DAYS + ) + return year class TemperatureScheduleOutput(ScheduleOutput, BaseModel): @@ -306,6 +335,7 @@ def generate_schedule( context.occupancy = occupancy_res.occupancy_states context.num_occupants = self.num_occupants return StochasticOccupancyScheduleOutput( + name="Occupancy", peak_value=occupancy_res.peak_value, normalized_timeseries=occupancy_res.schedule, ) @@ -420,6 +450,7 @@ def generate_schedule( lighting, context.safe_occupancy, generator ) return StochasticLightingScheduleOutput( + name="Lighting", dimming_enabled=lighting_res.dimming_enabled, peak_value=lighting_res.peak_value, normalized_timeseries=lighting_res.schedule, @@ -511,7 +542,9 @@ def generate_schedule( context.laundry_cycles = eqp_res.laundry_cycles context.dishwasher_cycles = eqp_res.dishwasher_cycles return StochasticEquipmentScheduleOutput( - peak_value=eqp_res.peak_value, normalized_timeseries=eqp_res.schedule + name="Equipment", + peak_value=eqp_res.peak_value, + normalized_timeseries=eqp_res.schedule, ) @@ -534,7 +567,9 @@ def generate_schedule( config.resolution_minutes, ) return StochasticWaterUseScheduleOutput( - peak_value=dhw_res.peak_value, normalized_timeseries=dhw_res.schedule + name="WaterUse", + peak_value=dhw_res.peak_value, + normalized_timeseries=dhw_res.schedule, ) @@ -578,8 +613,6 @@ def generate_schedules( if __name__ == "__main__": - import yaml - generator = StochasticScheduleGenerator( config=ScheduleGenerationConfig(resolution_minutes=15), equipment=StochasticEquipmentScheduleGenerator( @@ -624,4 +657,113 @@ def generate_schedules( ), ) schedules = generator.generate_schedules(42) - print(yaml.dump(schedules.model_dump(mode="json"), indent=2, sort_keys=False)) + + if schedules.lighting is None: + msg = "Lighting schedule is not set" + raise ValueError(msg) + if schedules.equipment is None: + msg = "Equipment schedule is not set" + raise ValueError(msg) + + lighting_year = schedules.lighting.construct_idf_object() + equipment_year = schedules.equipment.construct_idf_object() + from epinterface.sbem.flat_model import FlatModel + + flat_model = FlatModel( + F2FHeight=3.25, + Width=40, + Depth=40, + Rotation=45, + WWR=0.3, + NFloors=2, + FacadeRValue=3.0, + RoofRValue=3.0, + SlabRValue=3.0, + WindowUValue=3.0, + WindowSHGF=0.7, + WindowTVis=0.5, + InfiltrationACH=0.5, + VentFlowRatePerArea=0.001, + VentFlowRatePerPerson=0.0085, + VentProvider="Mechanical", + VentHRV="NoHRV", + VentEconomizer="NoEconomizer", + VentDCV="NoDCV", + DHWFlowRatePerPerson=0.010, + DHWFuel="Electricity", + DHWSystemCOP=1.0, + DHWDistributionCOP=1.0, + EquipmentPowerDensity=25, + LightingPowerDensity=10, + OccupantDensity=0.001, + EquipmentBase=0.4, + EquipmentAMInterp=0.5, + EquipmentLunchInterp=0.8, + EquipmentPMInterp=0.5, + EquipmentWeekendPeakInterp=0.25, + EquipmentSummerPeakInterp=0.5, + LightingBase=0.3, + LightingAMInterp=0.75, + LightingLunchInterp=0.75, + LightingPMInterp=0.9, + LightingWeekendPeakInterp=0.75, + LightingSummerPeakInterp=0.9, + OccupancyBase=0.05, + OccupancyAMInterp=0.25, + OccupancyLunchInterp=0.9, + OccupancyPMInterp=0.5, + OccupancyWeekendPeakInterp=0.15, + OccupancySummerPeakInterp=0.85, + # HSPRegularWeekdayWorkhours=21, + # HSPRegularWeekdayNight=21, + # HSPSummerWeekdayWorkhours=21, + # HSPSummerWeekdayNight=21, + # HSPWeekendWorkhours=21, + # HSPWeekendNight=21, + # CSPRegularWeekdayWorkhours=23, + # CSPRegularWeekdayNight=23, + # CSPSummerWeekdayWorkhours=23, + # CSPSummerWeekdayNight=23, + # CSPWeekendWorkhours=23, + # CSPWeekendNight=23, + HeatingSetpointBase=21, + SetpointDeadband=2, + HeatingSetpointSetback=2, + CoolingSetpointSetback=2, + NightSetback=0.5, + WeekendSetback=0.5, + SummerSetback=0.5, + HeatingFuel="Electricity", + CoolingFuel="Electricity", + HeatingSystemCOP=1.0, + CoolingSystemCOP=1.0, + HeatingDistributionCOP=1.0, + CoolingDistributionCOP=1.0, + EPWURI=WeatherUrl( # pyright: ignore [reportCallIssue] + "https://climate.onebuilding.org/WMO_Region_4_North_and_Central_America/USA_United_States_of_America/MA_Massachusetts/USA_MA_Bedford-Hanscom.Field.AP.744900_TMYx.2009-2023.zip" + ), + ) + + outdir = Path("test-out-lighting") + outdir.mkdir(parents=True, exist_ok=True) + # r = flat_model.simulate(eplus_parent_dir=outdir) + model, cb = flat_model.to_model() + + from archetypal.idfclass.idf import IDF + + def callback(idf: IDF) -> IDF: + """Callback to add the schedules to the IDF.""" + idf = cb(idf) + lighting_year.add(idf) + equipment_year.add(idf) + for lightsobj in idf.idfobjects["LIGHTS"]: + lightsobj.Schedule_Name = lighting_year.Name + for equipmentobj in idf.idfobjects["ELECTRICEQUIPMENT"]: + equipmentobj.Schedule_Name = equipment_year.Name + + return idf + + r = model.run(eplus_parent_dir=outdir, post_geometry_callback=callback) + print(r.energy_and_peak.groupby(level=["Measurement", "Aggregation"]).sum()) + + # print(yaml.dump(schedules.model_dump(mode="json"), indent=2, sort_keys=False)) From 05e4b1f3b3a604fe137452070fd86d7186767d36 Mon Sep 17 00:00:00 2001 From: Sam Wolk <36545842+szvsw@users.noreply.github.com> Date: Thu, 21 May 2026 11:01:28 -0400 Subject: [PATCH 05/10] enable post zone callback for schedule mutations --- epinterface/sbem/builder.py | 11 +- .../sbem/components/schedules/stochastic.py | 104 +++++++++++++++--- 2 files changed, 98 insertions(+), 17 deletions(-) diff --git a/epinterface/sbem/builder.py b/epinterface/sbem/builder.py index 4381d4d..2fde4bf 100644 --- a/epinterface/sbem/builder.py +++ b/epinterface/sbem/builder.py @@ -826,12 +826,14 @@ def build( # noqa: C901 self, config: SimulationPathConfig, post_geometry_callback: Callable[[IDF], IDF] | None = None, + post_zone_callback: Callable[[IDF], IDF] | None = None, ) -> IDF: """Build the energy model using the Climate Studio API. Args: config (SimulationConfig): The configuration for the simulation. post_geometry_callback (Callable[[IDF],IDF] | None): A callback to run after the geometry is added. + post_zone_callback (Callable[[IDF],IDF] | None): A callback to run after the zones are added. Returns: idf (IDF): The built energy model. @@ -1010,6 +1012,8 @@ def build( # noqa: C901 idf = self.add_constructions( idf, self.Zone.Envelope.Assemblies, self.Zone.Envelope.Window ) + if post_zone_callback is not None: + idf = post_zone_callback(idf) # > operations # ----> space use @@ -1049,18 +1053,20 @@ def simulate( self, config: SimulationPathConfig, post_geometry_callback: Callable[[IDF], IDF] | None = None, + post_zone_callback: Callable[[IDF], IDF] | None = None, ) -> tuple[IDF, Sql]: """Build and simualte the idf model. Args: config (SimulationConfig): The configuration for the simulation. post_geometry_callback (Callable[[IDF],IDF] | None): A callback to run after the geometry is added. + post_zone_callback (Callable[[IDF],IDF] | None): A callback to run after the zones are added. Returns: idf (IDF): The built energy model. sql (Sql): The sql results file with simulation data. """ - idf = self.build(config, post_geometry_callback) + idf = self.build(config, post_geometry_callback, post_zone_callback) idf.simulate() sql = Sql(idf.sql_file) return idf, sql @@ -1127,6 +1133,7 @@ def run( self, weather_dir: Path | None = None, post_geometry_callback: Callable[[IDF], IDF] | None = None, + post_zone_callback: Callable[[IDF], IDF] | None = None, eplus_parent_dir: Path | None = None, overheating_config: OverheatingAnalysisConfig | None = None, ) -> "ModelRunResults": @@ -1135,6 +1142,7 @@ def run( Args: weather_dir (Path): The directory to store the weather files. post_geometry_callback (Callable[[IDF],IDF] | None): A callback to run after the geometry is added. + post_zone_callback (Callable[[IDF],IDF] | None): A callback to run after the zones are added. eplus_parent_dir (Path | None): The parent directory to store the eplus working directory. If None, a temporary directory will be used. overheating_config (OverheatingAnalysisConfig | None): Configuration for overheating analysis. Skips if None. @@ -1160,6 +1168,7 @@ def run( idf, sql = self.simulate( config, post_geometry_callback=post_geometry_callback, + post_zone_callback=post_zone_callback, ) if not idf.as_version: msg = f"EnergyPlus version not found in IDF file: {idf.idfobjects['VERSION']}" diff --git a/epinterface/sbem/components/schedules/stochastic.py b/epinterface/sbem/components/schedules/stochastic.py index 070e731..e864a49 100644 --- a/epinterface/sbem/components/schedules/stochastic.py +++ b/epinterface/sbem/components/schedules/stochastic.py @@ -19,6 +19,7 @@ from obgeneration.model.occupancy import MobilityCluster from pydantic import BaseModel, ConfigDict, Field, model_validator +from epinterface.geometry import get_zone_floor_area from epinterface.interface import ScheduleDayList, ScheduleYearFromDays from epinterface.weather import WeatherUrl @@ -656,23 +657,12 @@ def generate_schedules( cooling_setpoint_away=28, ), ) - schedules = generator.generate_schedules(42) - - if schedules.lighting is None: - msg = "Lighting schedule is not set" - raise ValueError(msg) - if schedules.equipment is None: - msg = "Equipment schedule is not set" - raise ValueError(msg) - - lighting_year = schedules.lighting.construct_idf_object() - equipment_year = schedules.equipment.construct_idf_object() from epinterface.sbem.flat_model import FlatModel flat_model = FlatModel( F2FHeight=3.25, - Width=40, - Depth=40, + Width=12, + Depth=8, Rotation=45, WWR=0.3, NFloors=2, @@ -683,8 +673,8 @@ def generate_schedules( WindowSHGF=0.7, WindowTVis=0.5, InfiltrationACH=0.5, - VentFlowRatePerArea=0.001, - VentFlowRatePerPerson=0.0085, + VentFlowRatePerArea=0.0002, + VentFlowRatePerPerson=0.0015, VentProvider="Mechanical", VentHRV="NoHRV", VentEconomizer="NoEconomizer", @@ -749,21 +739,103 @@ def generate_schedules( # r = flat_model.simulate(eplus_parent_dir=outdir) model, cb = flat_model.to_model() + import logging + from archetypal.idfclass.idf import IDF + logger = logging.getLogger(__name__) + logging.basicConfig(level=logging.INFO) + def callback(idf: IDF) -> IDF: """Callback to add the schedules to the IDF.""" idf = cb(idf) + + logger.info("Adding schedules to the IDF.") + logger.info("Generating stochastic values.") + schedules = generator.generate_schedules(42) + logger.info("Stochastic values generated.") + + if schedules.lighting is None: + msg = "Lighting schedule is not set" + raise ValueError(msg) + if schedules.equipment is None: + msg = "Equipment schedule is not set" + raise ValueError(msg) + if schedules.occupancy is None: + msg = "Occupancy schedule is not set" + raise ValueError(msg) + if schedules.water_use is None: + msg = "Water use schedule is not set" + raise ValueError(msg) + if schedules.heating_setpoint is None: + msg = "Heating setpoint schedule is not set" + raise ValueError(msg) + if schedules.cooling_setpoint is None: + msg = "Cooling setpoint schedule is not set" + raise ValueError(msg) + equipment_kWh = ( + schedules.equipment.peak_value + * np.array(schedules.equipment.normalized_timeseries).sum() + / 4 + / 1000 + ) + print(f"Equipment kWh: {equipment_kWh}") + # get the total occupied floor area of the building + # TODO: deal with the fact that some zones, e.g. the basement and attic, use a different use fraction + total_occupied_floor_area = sum([ + get_zone_floor_area(idf, zone.Name) for zone in idf.idfobjects["ZONE"] + ]) + print(f"Total occupied floor area: {total_occupied_floor_area}") + print(f"Equipment kWh per m2: {equipment_kWh / total_occupied_floor_area}") + + logger.info("Constructing schedules.") + lighting_year = schedules.lighting.construct_idf_object() + equipment_year = schedules.equipment.construct_idf_object() + occupancy_year = schedules.occupancy.construct_idf_object() + water_use_year = schedules.water_use.construct_idf_object() + logger.info("Schedules constructed.") + logger.info("Adding schedules to the IDF.") lighting_year.add(idf) equipment_year.add(idf) + occupancy_year.add(idf) + water_use_year.add(idf) + logger.info("Schedules added to the IDF.") + + # lighting is already normalized + lpd = schedules.lighting.peak_value + # equipment is not normalized since its based off of discrete pieces of equipment etc + epd = schedules.equipment.peak_value / total_occupied_floor_area + occ_density = schedules.occupancy.peak_value / total_occupied_floor_area + + logger.info("Mutating IDF objects to assign schedules.") for lightsobj in idf.idfobjects["LIGHTS"]: lightsobj.Schedule_Name = lighting_year.Name + lightsobj.Watts_per_Floor_Area = lpd + for equipmentobj in idf.idfobjects["ELECTRICEQUIPMENT"]: equipmentobj.Schedule_Name = equipment_year.Name + equipmentobj.Watts_per_Floor_Area = epd + + for peopleobj in idf.idfobjects["PEOPLE"]: + peopleobj.Number_of_People_Schedule_Name = occupancy_year.Name + peopleobj.People_per_Floor_Area = occ_density + + # for water_use_obj in idf.idfobjects["WATERUSE:EQUIPMENT"]: + # water_use_obj.Flow_Rate_Fraction_Schedule_Name = water_use_year.Name + # for heating_setpoint_obj in idf.idfobjects["HVACTEMPLATE:THERMOSTAT"]: + # heating_setpoint_obj.Heating_Setpoint_Schedule_Name = ( + # heating_setpoint_year.Name + # ) + # for cooling_setpoint_obj in idf.idfobjects["HVACTEMPLATE:THERMOSTAT"]: + # cooling_setpoint_obj.Cooling_Setpoint_Schedule_Name = ( + # cooling_setpoint_year.Name + # ) + logger.info("Mutating IDF objects to assign schedules complete.") + logger.info("Stochastic schedules injected and assigned to IDF objects.") return idf - r = model.run(eplus_parent_dir=outdir, post_geometry_callback=callback) + r = model.run(eplus_parent_dir=outdir, post_zone_callback=callback) print(r.energy_and_peak.groupby(level=["Measurement", "Aggregation"]).sum()) # print(yaml.dump(schedules.model_dump(mode="json"), indent=2, sort_keys=False)) From 5b6e43a9c649dc8749e60a7e1eb02d6a20af46e8 Mon Sep 17 00:00:00 2001 From: Amber Su Date: Sat, 23 May 2026 17:44:12 -0400 Subject: [PATCH 06/10] Add Day Interval Schedule, Design Day for Stochastic Schedule - Change from Day List to Day Interval for stocahstic schedule - Add design day for year schedule --- epinterface/interface.py | 43 ++-- .../sbem/components/schedules/stochastic.py | 186 +++++++++++++++--- uv.lock | 6 +- 3 files changed, 184 insertions(+), 51 deletions(-) diff --git a/epinterface/interface.py b/epinterface/interface.py index 6696537..bf05ee9 100644 --- a/epinterface/interface.py +++ b/epinterface/interface.py @@ -835,32 +835,42 @@ class ScheduleDayHourly(BaseObj, extra="ignore"): Hour_24: float -N_TS_PER_DAY = 96 -"""Number of timesteps per day for 15-minute interval schedules (24 * 60 / 15).""" - InterpolateToTimestepType = Literal["Average", "Linear", "No"] class ScheduleDayInterval(BaseObj, extra="ignore"): """Schedule:Day:Interval object for sub-hourly schedule data. - Fixed at 15-minute intervals (96 values per day). - Each value covers a 15-minute window ending at the corresponding time. + Each value covers a fixed minute window ending at the corresponding time. """ key: ClassVar[str] = "SCHEDULE:DAY:INTERVAL" Name: str Schedule_Type_Limits_Name: str Interpolate_to_Timestep: InterpolateToTimestepType = "No" - Values: tuple[float, ...] = Field( - ..., min_length=N_TS_PER_DAY, max_length=N_TS_PER_DAY - ) + Minutes_per_Item: int = Field(default=15, ge=1, le=60) + Values: tuple[float, ...] + + @model_validator(mode="after") + def _validate_values_length(self) -> Self: + """Validate that the number of values matches Minutes_per_Item.""" + if 60 % self.Minutes_per_Item != 0: + msg = f"Minutes_per_Item ({self.Minutes_per_Item}) must evenly divide 60" + raise ValueError(msg) + expected = 24 * 60 // self.Minutes_per_Item + if len(self.Values) != expected: + msg = ( + f"Expected {expected} values for " + f"{self.Minutes_per_Item}-minute intervals, " + f"got {len(self.Values)}" + ) + raise ValueError(msg) + return self def add(self, idf: IDF): """Add the object to the IDF. - Generates the Time/Value field pairs for 96 fifteen-minute intervals - (00:15 through 24:00). + Generates the Time/Value field pairs for the configured interval. Args: idf (IDF): The IDF object to add the schedule to. @@ -875,7 +885,7 @@ def add(self, idf: IDF): } for i, val in enumerate(self.Values): idx = i + 1 - total_minutes = idx * 15 + total_minutes = idx * self.Minutes_per_Item hours = total_minutes // 60 minutes = total_minutes % 60 fields[f"Time_{idx}"] = f"{hours:02d}:{minutes:02d}" @@ -1135,7 +1145,8 @@ def add(self, idf: IDF): class ScheduleYearFromDays(BaseModel): """Builds a complete Schedule:Year hierarchy from per-day schedules. - Takes a sequence of 365 (or 366) ScheduleDayList objects -- one per + Takes a sequence of 365 (or 366) ScheduleDayList or ScheduleDayInterval + objects -- one per calendar day -- and groups them into calendar weeks based on the simulation start day of week. Intermediate Schedule:Week:Daily objects are created so that EnergyPlus can resolve day-of-week correctly within @@ -1146,7 +1157,7 @@ class ScheduleYearFromDays(BaseModel): Name: Name for the resulting Schedule:Year (also used as a prefix for the generated Schedule:Week:Daily names). Schedule_Type_Limits_Name: Reference to a ScheduleTypeLimits object. - day_schedules: Exactly 365 (or 366 for leap years) ScheduleDayList + day_schedules: Exactly 365 (or 366 for leap years) day schedule objects, ordered Jan 1 through Dec 31. start_day_of_week: The day of week for January 1 (must match RunPeriod.Day_of_Week_for_Start_Day). @@ -1158,10 +1169,10 @@ class ScheduleYearFromDays(BaseModel): Name: str Schedule_Type_Limits_Name: str - day_schedules: Sequence[ScheduleDayList] + day_schedules: Sequence[ScheduleDayList | ScheduleDayInterval] start_day_of_week: DayOfWeekType = "Sunday" - summer_design_day_schedule: ScheduleDayList | None = None - winter_design_day_schedule: ScheduleDayList | None = None + summer_design_day_schedule: ScheduleDayList | ScheduleDayInterval | None = None + winter_design_day_schedule: ScheduleDayList | ScheduleDayInterval | None = None @model_validator(mode="after") def _validate_day_count(self) -> Self: diff --git a/epinterface/sbem/components/schedules/stochastic.py b/epinterface/sbem/components/schedules/stochastic.py index e864a49..75c99a0 100644 --- a/epinterface/sbem/components/schedules/stochastic.py +++ b/epinterface/sbem/components/schedules/stochastic.py @@ -20,7 +20,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from epinterface.geometry import get_zone_floor_area -from epinterface.interface import ScheduleDayList, ScheduleYearFromDays +from epinterface.interface import ScheduleDayInterval, ScheduleYearFromDays from epinterface.weather import WeatherUrl @@ -48,29 +48,72 @@ class FractionalScheduleOutput(ScheduleOutput, BaseModel): normalized_timeseries: Sequence[float] = Field( ..., description="The normalized timeseries of the schedule." ) + summer_design_day: Sequence[float] | None = Field( + default=None, + description=( + "The normalized timeseries of the schedule for the summer design day." + ) + ) + winter_design_day: Sequence[float] | None = Field( + default=None, + description=( + "The normalized timeseries of the schedule for the winter design day." + ) + ) def construct_idf_object(self): """Construct the IDF object for the schedule.""" - ts_by_day = ( - np.array(self.normalized_timeseries) - .reshape(-1, 96, 1) - .repeat(15, axis=-1) - .reshape(-1, 1440) - ) + timeseries = np.array(self.normalized_timeseries) + n_days = 365 + if len(timeseries) % n_days != 0: + msg = ( + f"Expected normalized_timeseries length to divide evenly into " + f"{n_days} days, got {len(timeseries)}" + ) + raise ValueError(msg) + items_per_day = len(timeseries) // n_days + if 24 * 60 % items_per_day != 0: + msg = f"Expected an even number of minutes per item, got {items_per_day} items per day" + raise ValueError(msg) + minutes_per_item = 24 * 60 // items_per_day + ts_by_day = timeseries.reshape(n_days, items_per_day) days = [ - ScheduleDayList( + ScheduleDayInterval( Name=f"{self.name}Day{i:03d}", Schedule_Type_Limits_Name="Fraction", Values=tuple(ts_by_day[i]), - Minutes_per_Item=1, + Minutes_per_Item=minutes_per_item, ) for i in range(365) ] + summer_design_day_schedule = ( + ScheduleDayInterval( + Name=f"{self.name}SummerDesignDay", + Schedule_Type_Limits_Name="Fraction", + Values=tuple(self.summer_design_day), + Minutes_per_Item=minutes_per_item, + ) + if self.summer_design_day is not None + else None + ) + winter_design_day_schedule = ( + ScheduleDayInterval( + Name=f"{self.name}WinterDesignDay", + Schedule_Type_Limits_Name="Fraction", + Values=tuple(self.winter_design_day), + Minutes_per_Item=minutes_per_item, + ) + if self.winter_design_day is not None + else None + ) + year = ScheduleYearFromDays( Name=f"{self.name}Year", Schedule_Type_Limits_Name="Fraction", day_schedules=days, start_day_of_week="Sunday", # this should be the START DAY OF THE INPUT DAYS + summer_design_day_schedule=summer_design_day_schedule, + winter_design_day_schedule=winter_design_day_schedule, ) return year @@ -78,13 +121,77 @@ def construct_idf_object(self): class TemperatureScheduleOutput(ScheduleOutput, BaseModel): """Base class for temperature schedule outputs.""" + name: str = Field( + ..., + description="The name of the schedule.", + ) timeseries: Sequence[float] = Field( ..., description="The timeseries of the temperature schedule." ) + summer_design_day: Sequence[float] | None = Field( + default=None, + description=("The timeseries of the schedule for the summer design day."), + ) + winter_design_day: Sequence[float] | None = Field( + default=None, + description=("The timeseries of the schedule for the winter design day."), + ) def construct_idf_object(self): """Construct the IDF object for the schedule.""" - raise NotImplementedError("IDF object construction is not implemented") + timeseries = np.array(self.timeseries) + n_days = 365 + if len(timeseries) % n_days != 0: + msg = ( + f"Expected timeseries length to divide evenly into " + f"{n_days} days, got {len(timeseries)}" + ) + raise ValueError(msg) + items_per_day = len(timeseries) // n_days + if 24 * 60 % items_per_day != 0: + msg = f"Expected an even number of minutes per item, got {items_per_day} items per day" + raise ValueError(msg) + minutes_per_item = 24 * 60 // items_per_day + ts_by_day = timeseries.reshape(n_days, items_per_day) + days = [ + ScheduleDayInterval( + Name=f"{self.name}Day{i:03d}", + Schedule_Type_Limits_Name="Temperature", + Values=tuple(ts_by_day[i]), + Minutes_per_Item=minutes_per_item, + ) + for i in range(365) + ] + summer_design_day_schedule = ( + ScheduleDayInterval( + Name=f"{self.name}SummerDesignDay", + Schedule_Type_Limits_Name="Temperature", + Values=tuple(self.summer_design_day), + Minutes_per_Item=minutes_per_item, + ) + if self.summer_design_day is not None + else None + ) + winter_design_day_schedule = ( + ScheduleDayInterval( + Name=f"{self.name}WinterDesignDay", + Schedule_Type_Limits_Name="Temperature", + Values=tuple(self.winter_design_day), + Minutes_per_Item=minutes_per_item, + ) + if self.winter_design_day is not None + else None + ) + + year = ScheduleYearFromDays( + Name=f"{self.name}Year", + Schedule_Type_Limits_Name="Temperature", + day_schedules=days, + start_day_of_week="Sunday", # this should be the START DAY OF THE INPUT DAYS + summer_design_day_schedule=summer_design_day_schedule, + winter_design_day_schedule=winter_design_day_schedule, + ) + return year class StochasticEquipmentScheduleOutput(FractionalScheduleOutput): @@ -338,7 +445,9 @@ def generate_schedule( return StochasticOccupancyScheduleOutput( name="Occupancy", peak_value=occupancy_res.peak_value, - normalized_timeseries=occupancy_res.schedule, + normalized_timeseries=occupancy_res.annual_schedule, + summer_design_day=occupancy_res.summer_design_day_schedule, + winter_design_day=occupancy_res.winter_design_day_schedule, ) @@ -377,7 +486,10 @@ def generate_schedule( msg = "Failed to generate heating setpoint schedule." raise RuntimeError(msg) return StochasticHeatingSetpointScheduleOutput( - timeseries=heating_setpoint_res.schedule + name="HeatingSetpoint", + timeseries=heating_setpoint_res.annual_schedule, + summer_design_day=heating_setpoint_res.summer_design_day_schedule, + winter_design_day=heating_setpoint_res.winter_design_day_schedule, ) @@ -416,7 +528,10 @@ def generate_schedule( msg = "Failed to generate cooling setpoint schedule." raise RuntimeError(msg) return StochasticCoolingSetpointScheduleOutput( - timeseries=cooling_setpoint_res.schedule + name="CoolingSetpoint", + timeseries=cooling_setpoint_res.annual_schedule, + summer_design_day=cooling_setpoint_res.summer_design_day_schedule, + winter_design_day=cooling_setpoint_res.winter_design_day_schedule, ) @@ -454,7 +569,9 @@ def generate_schedule( name="Lighting", dimming_enabled=lighting_res.dimming_enabled, peak_value=lighting_res.peak_value, - normalized_timeseries=lighting_res.schedule, + normalized_timeseries=lighting_res.annual_schedule, + summer_design_day=lighting_res.summer_design_day_schedule, + winter_design_day=lighting_res.winter_design_day_schedule, ) @@ -545,7 +662,9 @@ def generate_schedule( return StochasticEquipmentScheduleOutput( name="Equipment", peak_value=eqp_res.peak_value, - normalized_timeseries=eqp_res.schedule, + normalized_timeseries=eqp_res.annual_schedule, + summer_design_day=eqp_res.summer_design_day_schedule, + winter_design_day=eqp_res.winter_design_day_schedule, ) @@ -570,7 +689,9 @@ def generate_schedule( return StochasticWaterUseScheduleOutput( name="WaterUse", peak_value=dhw_res.peak_value, - normalized_timeseries=dhw_res.schedule, + normalized_timeseries=dhw_res.annual_schedule, + summer_design_day=dhw_res.summer_design_day_schedule, + winter_design_day=dhw_res.winter_design_day_schedule, ) @@ -790,35 +911,36 @@ def callback(idf: IDF) -> IDF: logger.info("Constructing schedules.") lighting_year = schedules.lighting.construct_idf_object() - equipment_year = schedules.equipment.construct_idf_object() - occupancy_year = schedules.occupancy.construct_idf_object() - water_use_year = schedules.water_use.construct_idf_object() + + # equipment_year = schedules.equipment.construct_idf_object() + # occupancy_year = schedules.occupancy.construct_idf_object() + # water_use_year = schedules.water_use.construct_idf_object() logger.info("Schedules constructed.") logger.info("Adding schedules to the IDF.") lighting_year.add(idf) - equipment_year.add(idf) - occupancy_year.add(idf) - water_use_year.add(idf) + # equipment_year.add(idf) + # occupancy_year.add(idf) + # water_use_year.add(idf) logger.info("Schedules added to the IDF.") - + breakpoint() # lighting is already normalized lpd = schedules.lighting.peak_value # equipment is not normalized since its based off of discrete pieces of equipment etc - epd = schedules.equipment.peak_value / total_occupied_floor_area - occ_density = schedules.occupancy.peak_value / total_occupied_floor_area + # epd = schedules.equipment.peak_value / total_occupied_floor_area + # occ_density = schedules.occupancy.peak_value / total_occupied_floor_area logger.info("Mutating IDF objects to assign schedules.") for lightsobj in idf.idfobjects["LIGHTS"]: lightsobj.Schedule_Name = lighting_year.Name - lightsobj.Watts_per_Floor_Area = lpd + lightsobj.Watts_per_Zone_Floor_Area = lpd - for equipmentobj in idf.idfobjects["ELECTRICEQUIPMENT"]: - equipmentobj.Schedule_Name = equipment_year.Name - equipmentobj.Watts_per_Floor_Area = epd + # for equipmentobj in idf.idfobjects["ELECTRICEQUIPMENT"]: + # equipmentobj.Schedule_Name = equipment_year.Name + # equipmentobj.Watts_per_Zone_Floor_Area = epd - for peopleobj in idf.idfobjects["PEOPLE"]: - peopleobj.Number_of_People_Schedule_Name = occupancy_year.Name - peopleobj.People_per_Floor_Area = occ_density + # for peopleobj in idf.idfobjects["PEOPLE"]: + # peopleobj.Number_of_People_Schedule_Name = occupancy_year.Name + # peopleobj.People_per_Floor_Area = occ_density # for water_use_obj in idf.idfobjects["WATERUSE:EQUIPMENT"]: # water_use_obj.Flow_Rate_Fraction_Schedule_Name = water_use_year.Name diff --git a/uv.lock b/uv.lock index 6ddeddd..7e7d17a 100644 --- a/uv.lock +++ b/uv.lock @@ -2023,7 +2023,7 @@ wheels = [ [[package]] name = "obgeneration" -version = "0.1.0" +version = "0.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "matplotlib" }, @@ -2034,9 +2034,9 @@ dependencies = [ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/38/186fdce185078f83093fd72cbfcb1bd3c1fd3c946fdd2e5c7d247afbbb88/obgeneration-0.1.0.tar.gz", hash = "sha256:f38ca88995445758049c51f2b664be75a63b068f85431a0a9198b3929b6eaa48", size = 196567, upload-time = "2026-04-19T19:58:25.021Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/c9/af391d173e63f33386ea5cf84c50f0a380b5e5bf003b74821bfbaabfe10b/obgeneration-0.1.1.tar.gz", hash = "sha256:8b0512816fa86d5172859f0ac2a8f8a36c2c6a9eb6df9d20c8ca2df10bbefda5", size = 199471, upload-time = "2026-05-21T20:25:34.325Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/b3/6a6dd1fd9bbe0661a08573641da3a11431e9636169a550251094025c78a5/obgeneration-0.1.0-py3-none-any.whl", hash = "sha256:8760943240222fe9126b9c1abf5cfd63eb347443c8d1e5e010f940dee9ab69a4", size = 223075, upload-time = "2026-04-19T19:58:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/2f9e79fa6c39fd05cbf95d5dc02b4ccfd20c979a7b5eff3c5b79e6853534/obgeneration-0.1.1-py3-none-any.whl", hash = "sha256:b9621d0505d6c49fe08bfdc0c2c49e7489efcee79a955a5264de7a6f4aeb7f17", size = 226239, upload-time = "2026-05-21T20:25:32.985Z" }, ] [[package]] From 979849bbcd2ac5dfc1a691b29cb8eba732958a25 Mon Sep 17 00:00:00 2001 From: Amber Su Date: Mon, 1 Jun 2026 13:32:33 -0400 Subject: [PATCH 07/10] Lights with Dimming --- epinterface/geometry.py | 35 ++++++++++ epinterface/interface.py | 51 ++++++++++++++ .../sbem/components/schedules/stochastic.py | 70 ++++++++++--------- epinterface/sbem/components/space_use.py | 62 ++++++++++++++-- epinterface/sbem/flat_model.py | 5 +- 5 files changed, 185 insertions(+), 38 deletions(-) diff --git a/epinterface/geometry.py b/epinterface/geometry.py index 26d2d71..2d8cf67 100644 --- a/epinterface/geometry.py +++ b/epinterface/geometry.py @@ -905,3 +905,38 @@ def get_zone_glazed_area(idf: IDF, zone_name: str) -> float: raise ValueError(msg) return total_window_area + +def get_zone_center_point( + idf: IDF, + target_zone_name: str, + z: float = 0.8, +) -> tuple[float, float, float]: + """Return a simple floor-plan center point for a zone.""" + floor_surfaces = [ + surface + for surface in idf.idfobjects["BUILDINGSURFACE:DETAILED"] + if surface.Zone_Name == target_zone_name + and surface.Surface_Type.lower() == "floor" + ] + + if not floor_surfaces: + msg = f"No floor surface found for zone {target_zone_name}" + raise ValueError(msg) + + points: list[tuple[float, float]] = [] + for surface in floor_surfaces: + for i in range(1, 50): + x_raw = getattr(surface, f"Vertex_{i}_Xcoordinate", None) + y_raw = getattr(surface, f"Vertex_{i}_Ycoordinate", None) + + if x_raw is None or y_raw is None or x_raw == "" or y_raw == "": + break + + x = float(x_raw) + y = float(y_raw) + points.append((x, y)) + + x = sum(point[0] for point in points) / len(points) + y = sum(point[1] for point in points) / len(points) + + return x, y, z diff --git a/epinterface/interface.py b/epinterface/interface.py index bf05ee9..647b660 100644 --- a/epinterface/interface.py +++ b/epinterface/interface.py @@ -710,6 +710,57 @@ def model_dump(self, **kwargs: Any) -> dict[str, Any]: return data +class DaylightingControls(BaseObj, extra="ignore"): + """Daylighting:Controls object.""" + + key = "DAYLIGHTING:CONTROLS" + Name: str + Zone_or_Space_Name: str + Daylighting_Method: Literal["SplitFlux", "DElight"] = "SplitFlux" + Availability_Schedule_Name: str | None = None + Lighting_Control_Type: Literal["Continuous", "Stepped", "ContinuousOff"] + Minimum_Input_Power_Fraction_for_Continuous_or_ContinuousOff_Dimming_Control: ( + float | None + ) = 0.3 + Minimum_Light_Output_Fraction_for_Continuous_or_ContinuousOff_Dimming_Control: ( + float | None + ) = 0.2 + Number_of_Stepped_Control_Steps: int | None = None + Probability_Lighting_will_be_Reset_When_Needed_in_Manual_Stepped_Control: ( + float | None + ) = 1.0 + Glare_Calculation_Daylighting_Reference_Point_Name: str | None = None + Glare_Calculation_Azimuth_Angle_of_View_Direction_Clockwise_from_Zone_yAxis: ( + float | None + ) = None + Maximum_Allowable_Discomfort_Glare_Index: float | None = None + DElight_Gridding_Resolution: float | None = None + Daylighting_Reference_Point_1_Name: str + Fraction_of_Lights_Controlled_by_Reference_Point_1: float = 1.0 + Illuminance_Setpoint_at_Reference_Point_1: float = 300 + + def add(self, idf: IDF): + obj = idf.newidfobject(self.key, **self.model_dump(exclude_none=True)) + + last_field = "Illuminance_Setpoint_at_Reference_Point_1" + last_idx = obj.fieldnames.index(last_field) + + # obj.obj includes key at index 0, fieldnames also includes key at index 0 + obj.obj = obj.obj[: last_idx + 1] + + return idf + + +class DaylightingReferencePoint(BaseObj, extra="ignore"): + """Daylighting:ReferencePoint object.""" + key = "DAYLIGHTING:REFERENCEPOINT" + Name: str + Zone_or_Space_Name: str + XCoordinate_of_Reference_Point: float + YCoordinate_of_Reference_Point: float + ZCoordinate_of_Reference_Point: float + + InfDesignFlowRateCalculationMethodType = Literal[ "Flow/Zone", "Flow/Area", diff --git a/epinterface/sbem/components/schedules/stochastic.py b/epinterface/sbem/components/schedules/stochastic.py index 75c99a0..0ddc12c 100644 --- a/epinterface/sbem/components/schedules/stochastic.py +++ b/epinterface/sbem/components/schedules/stochastic.py @@ -807,6 +807,7 @@ def generate_schedules( EquipmentPowerDensity=25, LightingPowerDensity=10, OccupantDensity=0.001, + LightingDimmingType="Continuous", EquipmentBase=0.4, EquipmentAMInterp=0.5, EquipmentLunchInterp=0.8, @@ -854,7 +855,6 @@ def generate_schedules( "https://climate.onebuilding.org/WMO_Region_4_North_and_Central_America/USA_United_States_of_America/MA_Massachusetts/USA_MA_Bedford-Hanscom.Field.AP.744900_TMYx.2009-2023.zip" ), ) - outdir = Path("test-out-lighting") outdir.mkdir(parents=True, exist_ok=True) # r = flat_model.simulate(eplus_parent_dir=outdir) @@ -897,7 +897,7 @@ def callback(idf: IDF) -> IDF: equipment_kWh = ( schedules.equipment.peak_value * np.array(schedules.equipment.normalized_timeseries).sum() - / 4 + / (60 / generator.config.resolution_minutes) / 1000 ) print(f"Equipment kWh: {equipment_kWh}") @@ -907,57 +907,63 @@ def callback(idf: IDF) -> IDF: get_zone_floor_area(idf, zone.Name) for zone in idf.idfobjects["ZONE"] ]) print(f"Total occupied floor area: {total_occupied_floor_area}") - print(f"Equipment kWh per m2: {equipment_kWh / total_occupied_floor_area}") logger.info("Constructing schedules.") lighting_year = schedules.lighting.construct_idf_object() - - # equipment_year = schedules.equipment.construct_idf_object() - # occupancy_year = schedules.occupancy.construct_idf_object() - # water_use_year = schedules.water_use.construct_idf_object() + equipment_year = schedules.equipment.construct_idf_object() + occupancy_year = schedules.occupancy.construct_idf_object() + water_use_year = schedules.water_use.construct_idf_object() + heating_setpoint_year = schedules.heating_setpoint.construct_idf_object() + cooling_setpoint_year = schedules.cooling_setpoint.construct_idf_object() logger.info("Schedules constructed.") logger.info("Adding schedules to the IDF.") + lighting_year.add(idf) - # equipment_year.add(idf) - # occupancy_year.add(idf) - # water_use_year.add(idf) + equipment_year.add(idf) + occupancy_year.add(idf) + water_use_year.add(idf) + heating_setpoint_year.add(idf) + cooling_setpoint_year.add(idf) logger.info("Schedules added to the IDF.") - breakpoint() + # lighting is already normalized lpd = schedules.lighting.peak_value # equipment is not normalized since its based off of discrete pieces of equipment etc - # epd = schedules.equipment.peak_value / total_occupied_floor_area - # occ_density = schedules.occupancy.peak_value / total_occupied_floor_area + epd = schedules.equipment.peak_value / total_occupied_floor_area + occ_density = schedules.occupancy.peak_value / total_occupied_floor_area logger.info("Mutating IDF objects to assign schedules.") for lightsobj in idf.idfobjects["LIGHTS"]: lightsobj.Schedule_Name = lighting_year.Name lightsobj.Watts_per_Zone_Floor_Area = lpd - # for equipmentobj in idf.idfobjects["ELECTRICEQUIPMENT"]: - # equipmentobj.Schedule_Name = equipment_year.Name - # equipmentobj.Watts_per_Zone_Floor_Area = epd - - # for peopleobj in idf.idfobjects["PEOPLE"]: - # peopleobj.Number_of_People_Schedule_Name = occupancy_year.Name - # peopleobj.People_per_Floor_Area = occ_density - - # for water_use_obj in idf.idfobjects["WATERUSE:EQUIPMENT"]: - # water_use_obj.Flow_Rate_Fraction_Schedule_Name = water_use_year.Name - # for heating_setpoint_obj in idf.idfobjects["HVACTEMPLATE:THERMOSTAT"]: - # heating_setpoint_obj.Heating_Setpoint_Schedule_Name = ( - # heating_setpoint_year.Name - # ) - # for cooling_setpoint_obj in idf.idfobjects["HVACTEMPLATE:THERMOSTAT"]: - # cooling_setpoint_obj.Cooling_Setpoint_Schedule_Name = ( - # cooling_setpoint_year.Name - # ) + for equipmentobj in idf.idfobjects["ELECTRICEQUIPMENT"]: + equipmentobj.Schedule_Name = equipment_year.Name + equipmentobj.Watts_per_Zone_Floor_Area = epd + + for peopleobj in idf.idfobjects["PEOPLE"]: + peopleobj.Number_of_People_Schedule_Name = occupancy_year.Name + peopleobj.People_per_Floor_Area = occ_density + + #breakpoint() + + for water_use_obj in idf.idfobjects["WATERUSE:EQUIPMENT"]: + water_use_obj.Flow_Rate_Fraction_Schedule_Name = water_use_year.Name + for heating_setpoint_obj in idf.idfobjects["HVACTEMPLATE:THERMOSTAT"]: + heating_setpoint_obj.Heating_Setpoint_Schedule_Name = ( + heating_setpoint_year.Name + ) + for cooling_setpoint_obj in idf.idfobjects["HVACTEMPLATE:THERMOSTAT"]: + cooling_setpoint_obj.Cooling_Setpoint_Schedule_Name = ( + cooling_setpoint_year.Name + ) logger.info("Mutating IDF objects to assign schedules complete.") logger.info("Stochastic schedules injected and assigned to IDF objects.") return idf r = model.run(eplus_parent_dir=outdir, post_zone_callback=callback) + breakpoint() print(r.energy_and_peak.groupby(level=["Measurement", "Aggregation"]).sum()) - + # print(yaml.dump(schedules.model_dump(mode="json"), indent=2, sort_keys=False)) diff --git a/epinterface/sbem/components/space_use.py b/epinterface/sbem/components/space_use.py index fe05c43..cbf62a4 100644 --- a/epinterface/sbem/components/space_use.py +++ b/epinterface/sbem/components/space_use.py @@ -8,7 +8,14 @@ from pydantic import Field from epinterface.constants import assumed_constants, physical_constants -from epinterface.interface import ElectricEquipment, Lights, People +from epinterface.geometry import get_zone_center_point +from epinterface.interface import ( + DaylightingControls, + DaylightingReferencePoint, + ElectricEquipment, + Lights, + People, +) from epinterface.sbem.common import BoolStr, MetadataMixin, NamedObject from epinterface.sbem.components.schedules import YearComponent from epinterface.sbem.exceptions import NotImplementedParameter @@ -96,8 +103,8 @@ def add_people_to_idf_zone( idf = people.add(idf) return idf - -DimmingTypeType = Literal["Off", "Stepped", "Continuous"] +# Disallow Stepped and ContinuousOff for now. +DimmingTypeType = Literal["Off", "Continuous"] class LightingComponent(NamedObject, MetadataMixin, extra="forbid"): @@ -115,6 +122,11 @@ class LightingComponent(NamedObject, MetadataMixin, extra="forbid"): ) Schedule: YearComponent = Field(..., title="Lighting schedule of the object [frac]") IsOn: BoolStr = Field(..., title="Lights are on") + IlluminanceTarget: float = Field( + default=300, + ge=0, + title="Illuminance target at daylighting reference point [lux]", + ) def add_lights_to_idf_zone( self, idf: IDF, target_zone_or_zone_list_name: str @@ -133,10 +145,50 @@ def add_lights_to_idf_zone( if not self.IsOn: return idf + name_prefix = f"{target_zone_or_zone_list_name}_{self.safe_name}_LIGHTS" + + if self.DimmingType not in ("Off", "Continuous"): + raise NotImplementedParameter( + f"DimmingType:{self.DimmingType}", self.Name, "Lights" + ) + if self.DimmingType != "Off": - raise NotImplementedParameter("DimmingType:On", self.Name, "Lights") + if idf.getobject("ZONELIST", target_zone_or_zone_list_name) is not None: + msg = ( + "Daylighting controls must be assigned to a single zone, " + f"not zone list {target_zone_or_zone_list_name}." + ) + raise ValueError(msg) + if idf.getobject("ZONE", target_zone_or_zone_list_name) is None: + msg = ( + "Daylighting target zone not found: " + f"{target_zone_or_zone_list_name}" + ) + raise ValueError(msg) + + x, y, z = get_zone_center_point(idf, target_zone_or_zone_list_name) + + ref_point = DaylightingReferencePoint( + Name=f"{name_prefix}_DaylightRefPt", + Zone_or_Space_Name=target_zone_or_zone_list_name, + XCoordinate_of_Reference_Point=x, + YCoordinate_of_Reference_Point=y, + ZCoordinate_of_Reference_Point=z, + ) + idf = ref_point.add(idf) + + controls = DaylightingControls( + Name=f"{name_prefix}_DaylightControls", + Zone_or_Space_Name=target_zone_or_zone_list_name, + Lighting_Control_Type=self.DimmingType, + Daylighting_Reference_Point_1_Name=ref_point.Name, + Illuminance_Setpoint_at_Reference_Point_1=( + self.IlluminanceTarget or 300 + ), + Fraction_of_Lights_Controlled_by_Reference_Point_1=1.0, + ) + idf = controls.add(idf) - name_prefix = f"{target_zone_or_zone_list_name}_{self.safe_name}_LIGHTS" idf, year_name = self.Schedule.add_year_to_idf( idf, name_prefix=None, diff --git a/epinterface/sbem/flat_model.py b/epinterface/sbem/flat_model.py index b74e125..751fb66 100644 --- a/epinterface/sbem/flat_model.py +++ b/epinterface/sbem/flat_model.py @@ -26,6 +26,7 @@ YearScheduleCategory, ) from epinterface.sbem.components.space_use import ( + DimmingTypeType, EquipmentComponent, LightingComponent, OccupancyComponent, @@ -897,6 +898,8 @@ class FlatModel(BaseModel): LightingPowerDensity: float = Field(ge=0, le=100) OccupantDensity: float = Field(ge=0, le=50) + LightingDimmingType: DimmingTypeType = Field(..., title="Dimming type") + VentFlowRatePerPerson: float VentFlowRatePerArea: float VentProvider: VentilationProvider @@ -1717,7 +1720,7 @@ def to_zone(self) -> ZoneComponent: PowerDensity=self.LightingPowerDensity, Schedule=lighting_schedule, IsOn=True, - DimmingType="Off", + DimmingType= self.LightingDimmingType, ) occupancy = OccupancyComponent( From 46af1be841bb7f7eb75cdac300126aadfe55dddc Mon Sep 17 00:00:00 2001 From: Amber Su Date: Mon, 1 Jun 2026 22:46:24 -0400 Subject: [PATCH 08/10] Unit Tests for Continous Dimming - Test DaylightingControls object and center point finding - Make illuminance target 300 lux as default as opposed to an input --- epinterface/interface.py | 19 +- .../sbem/components/schedules/stochastic.py | 5 +- epinterface/sbem/components/space_use.py | 14 +- tests/test_components/test_space_use_comps.py | 179 +++++++++++++++++- tests/test_geometry.py | 62 +++++- 5 files changed, 252 insertions(+), 27 deletions(-) diff --git a/epinterface/interface.py b/epinterface/interface.py index 647b660..c7ceede 100644 --- a/epinterface/interface.py +++ b/epinterface/interface.py @@ -740,12 +740,12 @@ class DaylightingControls(BaseObj, extra="ignore"): Illuminance_Setpoint_at_Reference_Point_1: float = 300 def add(self, idf: IDF): + """Add the object to the IDF with one daylighting reference point.""" obj = idf.newidfobject(self.key, **self.model_dump(exclude_none=True)) - last_field = "Illuminance_Setpoint_at_Reference_Point_1" - last_idx = obj.fieldnames.index(last_field) - - # obj.obj includes key at index 0, fieldnames also includes key at index 0 + # Eppy emits default values for unused extensible reference point groups. + # Keep only the one reference point currently represented by this model. + last_idx = obj.fieldnames.index("Illuminance_Setpoint_at_Reference_Point_1") obj.obj = obj.obj[: last_idx + 1] return idf @@ -992,10 +992,15 @@ def add(self, idf: IDF): "Interpolate_to_Timestep": self.Interpolate_to_Timestep, "Minutes_per_Item": self.Minutes_per_Item, } - for i in range(1440): - val = self.Values[i] if i < len(self.Values) else None + for i, val in enumerate(self.Values): fields[f"Value_{i + 1}"] = val - idf.newidfobject(self.key, **fields) + + obj = idf.newidfobject(self.key, **fields) + + # Eppy exposes all 1440 possible value fields from the IDD. Keep only + # the values represented by this object's Minutes_per_Item setting. + last_idx = obj.fieldnames.index(f"Value_{len(self.Values)}") + obj.obj = obj.obj[: last_idx + 1] return idf diff --git a/epinterface/sbem/components/schedules/stochastic.py b/epinterface/sbem/components/schedules/stochastic.py index 0ddc12c..e2de45b 100644 --- a/epinterface/sbem/components/schedules/stochastic.py +++ b/epinterface/sbem/components/schedules/stochastic.py @@ -855,11 +855,12 @@ def generate_schedules( "https://climate.onebuilding.org/WMO_Region_4_North_and_Central_America/USA_United_States_of_America/MA_Massachusetts/USA_MA_Bedford-Hanscom.Field.AP.744900_TMYx.2009-2023.zip" ), ) + breakpoint() outdir = Path("test-out-lighting") outdir.mkdir(parents=True, exist_ok=True) # r = flat_model.simulate(eplus_parent_dir=outdir) model, cb = flat_model.to_model() - + breakpoint() import logging from archetypal.idfclass.idf import IDF @@ -876,6 +877,8 @@ def callback(idf: IDF) -> IDF: schedules = generator.generate_schedules(42) logger.info("Stochastic values generated.") + breakpoint() + if schedules.lighting is None: msg = "Lighting schedule is not set" raise ValueError(msg) diff --git a/epinterface/sbem/components/space_use.py b/epinterface/sbem/components/space_use.py index cbf62a4..2969bb4 100644 --- a/epinterface/sbem/components/space_use.py +++ b/epinterface/sbem/components/space_use.py @@ -103,8 +103,9 @@ def add_people_to_idf_zone( idf = people.add(idf) return idf -# Disallow Stepped and ContinuousOff for now. -DimmingTypeType = Literal["Off", "Continuous"] +# Allow legacy/database dimming values to deserialize; unsupported modes +# are rejected when translating the component into EnergyPlus objects. +DimmingTypeType = Literal["Off", "Continuous", "Stepped", "ContinuousOff"] class LightingComponent(NamedObject, MetadataMixin, extra="forbid"): @@ -122,11 +123,6 @@ class LightingComponent(NamedObject, MetadataMixin, extra="forbid"): ) Schedule: YearComponent = Field(..., title="Lighting schedule of the object [frac]") IsOn: BoolStr = Field(..., title="Lights are on") - IlluminanceTarget: float = Field( - default=300, - ge=0, - title="Illuminance target at daylighting reference point [lux]", - ) def add_lights_to_idf_zone( self, idf: IDF, target_zone_or_zone_list_name: str @@ -182,9 +178,7 @@ def add_lights_to_idf_zone( Zone_or_Space_Name=target_zone_or_zone_list_name, Lighting_Control_Type=self.DimmingType, Daylighting_Reference_Point_1_Name=ref_point.Name, - Illuminance_Setpoint_at_Reference_Point_1=( - self.IlluminanceTarget or 300 - ), + Illuminance_Setpoint_at_Reference_Point_1= 300, Fraction_of_Lights_Controlled_by_Reference_Point_1=1.0, ) idf = controls.add(idf) diff --git a/tests/test_components/test_space_use_comps.py b/tests/test_components/test_space_use_comps.py index dda80f4..8655488 100644 --- a/tests/test_components/test_space_use_comps.py +++ b/tests/test_components/test_space_use_comps.py @@ -4,9 +4,15 @@ from archetypal.idfclass.idf import IDF from prisma import Prisma -from epinterface.sbem.components.schedules import YearComponent +from epinterface.data import DefaultEPWPath, DefaultMinimalIDFPath +from epinterface.geometry import ShoeboxGeometry +from epinterface.interface import ZoneList +from epinterface.sbem.components.schedules import ( + DayComponent, + WeekComponent, + YearComponent, +) from epinterface.sbem.components.space_use import ( - DimmingTypeType, EquipmentComponent, LightingComponent, OccupancyComponent, @@ -16,6 +22,7 @@ ) from epinterface.sbem.exceptions import NotImplementedParameter from epinterface.sbem.prisma.client import deep_fetcher +from epinterface.settings import energyplus_settings @pytest.fixture(scope="function") @@ -27,6 +34,53 @@ def schedule(preseeded_readonly_db: Prisma): return year_comp +@pytest.fixture(scope="function") +def simple_fraction_schedule(): + """Build a small in-memory lighting schedule without using Prisma.""" + day = DayComponent( + Name="AlwaysOnDay", + Type="Fraction", + **{f"Hour_{hour:02d}": 1.0 for hour in range(24)}, + ) + week = WeekComponent( + Name="AlwaysOnWeek", + Monday=day, + Tuesday=day, + Wednesday=day, + Thursday=day, + Friday=day, + Saturday=day, + Sunday=day, + ) + return YearComponent( + Name="AlwaysOnYear", + Type="Lighting", + January=week, + February=week, + March=week, + April=week, + May=week, + June=week, + July=week, + August=week, + September=week, + October=week, + November=week, + December=week, + ) + + +@pytest.fixture(scope="function") +def daylighting_idf(): + """Create an IDF for daylighting unit tests without writing to temp files.""" + return IDF( + DefaultMinimalIDFPath.as_posix(), + epw=DefaultEPWPath.as_posix(), + as_version=energyplus_settings.energyplus_version, + file_version=energyplus_settings.energyplus_version, + ) + + @pytest.mark.parametrize("is_on", [True, False]) def test_add_lighting_to_idf_zone(idf: IDF, schedule: YearComponent, is_on: bool): """Test the add_lighting_to_idf_zone method.""" @@ -62,20 +116,129 @@ def test_add_lighting_to_idf_zone(idf: IDF, schedule: YearComponent, is_on: bool assert not idf.idfobjects["SCHEDULE:YEAR"] -@pytest.mark.parametrize("dimming_type", ["Stepped", "Continuous"]) -def test_add_lighting_to_idf_zone_with_dimming( - idf: IDF, schedule: YearComponent, dimming_type: DimmingTypeType +@pytest.mark.parametrize("dimming_type", ["Stepped", "ContinuousOff"]) +def test_add_lighting_to_idf_zone_rejects_unsupported_dimming_type( + daylighting_idf: IDF, simple_fraction_schedule: YearComponent, dimming_type: str ): - """Test the add_lighting_to_idf_zone method with dimming.""" + """Test stored dimming values can deserialize but unsupported modes fail.""" lighting = LightingComponent( Name="new_office", PowerDensity=10, - Schedule=schedule, + Schedule=simple_fraction_schedule, IsOn=True, DimmingType=dimming_type, ) + + # Stepped and ContinuousOff can exist in source data, but are not implemented. with pytest.raises(NotImplementedParameter): - lighting.add_lights_to_idf_zone(idf, "default_zone") + lighting.add_lights_to_idf_zone(daylighting_idf, "default_zone") + + +def test_add_lighting_to_idf_zone_with_continuous_dimming( + daylighting_idf: IDF, simple_fraction_schedule: YearComponent +): + """Test continuous dimming adds lights and daylighting controls.""" + geom = ShoeboxGeometry( + x=0, + y=0, + w=10, + d=10, + h=3.5, + num_stories=1, + zoning="by_storey", + basement=False, + wwr=0.15, + roof_height=None, + ) + idf = geom.add(daylighting_idf) + zone_name = idf.idfobjects["ZONE"][0].Name + + lighting = LightingComponent( + Name="new_office", + PowerDensity=10, + Schedule=simple_fraction_schedule, + IsOn=True, + DimmingType="Continuous", + ) + + idf = lighting.add_lights_to_idf_zone(idf, zone_name) + + lights = idf.idfobjects["LIGHTS"] + reference_points = idf.idfobjects["DAYLIGHTING:REFERENCEPOINT"] + controls = idf.idfobjects["DAYLIGHTING:CONTROLS"] + + assert len(lights) == 1 + assert len(reference_points) == 1 + assert len(controls) == 1 + + light = lights[0] + reference_point = reference_points[0] + control = controls[0] + name_prefix = f"{zone_name}_new_office_LIGHTS" + + assert light.Name == name_prefix + assert light.Zone_or_ZoneList_or_Space_or_SpaceList_Name == zone_name + + # Daylighting currently assumes one center-point sensor for one real zone. + assert reference_point.Name == f"{name_prefix}_DaylightRefPt" + assert reference_point.Zone_or_Space_Name == zone_name + assert control.Name == f"{name_prefix}_DaylightControls" + assert control.Zone_or_Space_Name == zone_name + assert control.Lighting_Control_Type == "Continuous" + assert control.Daylighting_Reference_Point_1_Name == reference_point.Name + + # The current daylighting implementation uses one fixed 300 lux setpoint. + assert control.Fraction_of_Lights_Controlled_by_Reference_Point_1 == 1.0 + assert control.Illuminance_Setpoint_at_Reference_Point_1 == 300 + assert ( + control.Minimum_Input_Power_Fraction_for_Continuous_or_ContinuousOff_Dimming_Control + == 0.3 + ) + assert ( + control.Minimum_Light_Output_Fraction_for_Continuous_or_ContinuousOff_Dimming_Control + == 0.2 + ) + + # Eppy pre-fills unused extensible fields; the wrapper trims after ref point 1. + last_idx = control.fieldnames.index("Illuminance_Setpoint_at_Reference_Point_1") + assert len(control.obj) == last_idx + 1 + assert control.fieldnames.index("Daylighting_Reference_Point_2_Name") >= len( + control.obj + ) + + +def test_add_lighting_to_idf_zone_with_continuous_dimming_rejects_zone_list( + daylighting_idf: IDF, simple_fraction_schedule: YearComponent +): + """Test daylighting dimming rejects zone lists.""" + geom = ShoeboxGeometry( + x=0, + y=0, + w=10, + d=10, + h=3.5, + num_stories=1, + zoning="by_storey", + basement=False, + wwr=0.15, + roof_height=None, + ) + idf = geom.add(daylighting_idf) + zone_name = idf.idfobjects["ZONE"][0].Name + zone_list = ZoneList(Name="Test_Zone_List", Names=[zone_name]) + idf = zone_list.add(idf) + + lighting = LightingComponent( + Name="new_office", + PowerDensity=10, + Schedule=simple_fraction_schedule, + IsOn=True, + DimmingType="Continuous", + ) + + # Daylighting:Controls targets a single zone in this implementation, not a list. + with pytest.raises(ValueError, match="zone list"): + lighting.add_lights_to_idf_zone(idf, zone_list.Name) @pytest.mark.parametrize("is_on,density", [(True, 10), (False, 0)]) diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 86122ed..e8c62e7 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -8,7 +8,11 @@ from shapely import Polygon from epinterface.data import DefaultEPWPath, DefaultMinimalIDFPath -from epinterface.geometry import ShoeboxGeometry, match_idf_to_building_and_neighbors +from epinterface.geometry import ( + ShoeboxGeometry, + get_zone_center_point, + match_idf_to_building_and_neighbors, +) from epinterface.settings import energyplus_settings @@ -24,6 +28,62 @@ def minimal_idf(): yield idf +def _add_simple_shoebox(idf: IDF) -> tuple[IDF, str]: + """Add one simple zone and return its name.""" + geom = ShoeboxGeometry( + x=0, + y=0, + w=10, + d=10, + h=3.5, + num_stories=1, + zoning="by_storey", + basement=False, + wwr=0.15, + roof_height=None, + ) + idf = geom.add(idf) + return idf, idf.idfobjects["ZONE"][0].Name + + +def test_get_zone_center_point_returns_numeric_floor_center(minimal_idf): + """Test the daylighting center-point helper on generated shoebox geometry.""" + idf, zone_name = _add_simple_shoebox(minimal_idf) + + x, y, z = get_zone_center_point(idf, zone_name) + + # This is a coarse daylighting sensor assumption, not a daylight-quality check. + assert isinstance(x, float) + assert isinstance(y, float) + assert np.isfinite(x) + assert np.isfinite(y) + assert z == pytest.approx(0.8) + + +def test_get_zone_center_point_handles_autocalculate_vertex_count(minimal_idf): + """Test the center helper reads vertex fields even with autocalculate count.""" + idf, zone_name = _add_simple_shoebox(minimal_idf) + floor = next( + surface + for surface in idf.idfobjects["BUILDINGSURFACE:DETAILED"] + if surface.Zone_Name == zone_name and surface.Surface_Type.lower() == "floor" + ) + floor.Number_of_Vertices = "autocalculate" + + x, y, z = get_zone_center_point(idf, zone_name) + + # Current geometry rules use compatible world coordinates for this helper. + assert np.isfinite(x) + assert np.isfinite(y) + assert z == pytest.approx(0.8) + + +def test_get_zone_center_point_unknown_zone_raises(minimal_idf): + """Test missing zones fail clearly.""" + with pytest.raises(ValueError, match="No floor surface"): + get_zone_center_point(minimal_idf, "missing_zone") + + # Full factorial parameter combinations f2f_heights = [3.5, 4.321] num_floors = [1, 3] From cda1d6ca5ee9cef532e9ce2886506fe0bf5085ef Mon Sep 17 00:00:00 2001 From: Amber Date: Mon, 29 Jun 2026 11:27:47 -0400 Subject: [PATCH 09/10] Add zone-timestep whole-building electricity meter output --- epinterface/interface.py | 7 +- epinterface/sbem/builder.py | 112 ++++++++++++++++++-------- tests/test_components/test_builder.py | 24 +++++- 3 files changed, 103 insertions(+), 40 deletions(-) diff --git a/epinterface/interface.py b/epinterface/interface.py index c7ceede..d10073e 100644 --- a/epinterface/interface.py +++ b/epinterface/interface.py @@ -1346,11 +1346,12 @@ def add(self, idf: IDF): return idf -def add_default_sim_controls(idf: IDF) -> IDF: +def add_default_sim_controls(idf: IDF, timesteps_per_hour: int = 6) -> IDF: """Helper to add default simulation controls to the IDF model. Args: idf (IDF): The IDF model to add the simulation controls to. + timesteps_per_hour (int): Number of simulation timesteps per hour. Returns: IDF: The IDF model with the added simulation controls. @@ -1383,9 +1384,7 @@ def add_default_sim_controls(idf: IDF) -> IDF: run_period.add(idf) # configure timestep - timestep = Timestep( - Number_of_Timesteps_per_Hour=6, - ) + timestep = Timestep(Number_of_Timesteps_per_Hour=timesteps_per_hour) timestep.add(idf) sizing = SizingParameters( diff --git a/epinterface/sbem/builder.py b/epinterface/sbem/builder.py index 2fde4bf..11f5125 100644 --- a/epinterface/sbem/builder.py +++ b/epinterface/sbem/builder.py @@ -70,6 +70,61 @@ ] AVAILABLE_HOURLY_VARIABLES = get_args(AvailableHourlyVariables) +ZONE_TIMESTEP_WHOLE_BUILDING_METERS = ("Electricity:Facility",) + + +def build_output_meter_requests( + *, + ep_version_major: int, + include_zone_timestep_meters: bool, +) -> list[dict[str, str]]: + """Return the EnergyPlus output requests needed for the SBEM workflow. + + Monthly and hourly requests stay aligned with the standard end-use + postprocessing contract. When zone-timestep meters are enabled, include both + the standard end-use meters and direct whole-building facility electricity so + downstream validation can reconstruct a 15-minute total-load series. + """ + desired_meters = DESIRED_METERS_FOR_VERSION[ep_version_major] + output_meters = ( + [ + { + "key": "OUTPUT:METER", + "Key_Name": meter, + "Reporting_Frequency": "Monthly", + } + for meter in desired_meters + ] + + [ + { + "key": "OUTPUT:METER", + "Key_Name": meter, + "Reporting_Frequency": "Hourly", + } + for meter in desired_meters + ] + + ( + [ + { + "key": "OUTPUT:METER", + "Key_Name": meter, + "Reporting_Frequency": "Zone Timestep", + } + for meter in (*desired_meters, *ZONE_TIMESTEP_WHOLE_BUILDING_METERS) + ] + if include_zone_timestep_meters + else [] + ) + ) + return output_meters + [ + { + "key": "OUTPUT:VARIABLE", + "Key_Value": "*", + "Variable_Name": variable, + "Reporting_Frequency": "Hourly", + } + for variable in AVAILABLE_HOURLY_VARIABLES + ] class SimulationPathConfig(BaseModel): @@ -566,6 +621,8 @@ class Model(BaseWeather, validate_assignment=True): geometry: ShoeboxGeometry Attic: AtticAssumptions Basement: BasementAssumptions + timesteps_per_hour: int = Field(default=6, ge=1) + include_zone_timestep_meters: bool = False # TODO: should we have another field for whether or not the attic is ventilated, i.e. high infiltration? Zone: ZoneComponent @@ -844,33 +901,15 @@ def build( # noqa: C901 shutil.copy(base_filepath, target_base_filepath) epw_path, ddy_path = self.fetch_weather(config.weather_dir) ep_version = energyplus_settings.archetypal_energyplus_version - desired_meters = DESIRED_METERS_FOR_VERSION[ep_version.major] - output_meters = ( - [ - { - "key": "OUTPUT:METER", - "Key_Name": meter, - "Reporting_Frequency": "Monthly", - } - for meter in desired_meters - ] - + [ - { - "key": "OUTPUT:METER", - "Key_Name": meter, - "Reporting_Frequency": "Hourly", - } - for meter in desired_meters - ] - + [ - { - "key": "OUTPUT:VARIABLE", - "Key_Value": "*", - "Variable_Name": variable, - "Reporting_Frequency": "Hourly", - } - for variable in AVAILABLE_HOURLY_VARIABLES - ] + desired_meters = set(DESIRED_METERS_FOR_VERSION[ep_version.major]) + retained_meter_names = desired_meters | ( + set(ZONE_TIMESTEP_WHOLE_BUILDING_METERS) + if self.include_zone_timestep_meters + else set() + ) + output_meters = build_output_meter_requests( + ep_version_major=ep_version.major, + include_zone_timestep_meters=self.include_zone_timestep_meters, ) idf = IDF( target_base_filepath.as_posix(), @@ -884,7 +923,7 @@ def build( # noqa: C901 # Remove undesired outputs from the IDF file. # TODO: test the perfrmance benefits, if any for output in idf.idfobjects["OUTPUT:METER"]: - if output.Key_Name not in desired_meters: + if output.Key_Name not in retained_meter_names: idf.removeidfobject(output) for output in idf.idfobjects["OUTPUT:VARIABLE"]: if output.Variable_Name not in AVAILABLE_HOURLY_VARIABLES: @@ -901,7 +940,10 @@ def build( # noqa: C901 ) ddy_spec.inject_ddy(idf, ddy) - idf = add_default_sim_controls(idf) + idf = add_default_sim_controls( + idf, + timesteps_per_hour=self.timesteps_per_hour, + ) idf, _scheds = add_default_schedules(idf) idf = self.geometry.add(idf) @@ -974,12 +1016,12 @@ def build( # noqa: C901 new_zone_def.Operations.HVAC.Ventilation.Provider = "None" new_zone_def.Operations.HVAC.ConditioningSystems.Heating = None new_zone_def.Operations.HVAC.ConditioningSystems.Cooling = None - else: - # TODO: make this configurable!!!! - print( - "WARNING: Basement conditioned, but Cooling disabled due to MASSACHUSETTS ASSUMPTIONS" - ) - new_zone_def.Operations.HVAC.ConditioningSystems.Cooling = None + # else: + # # TODO: make this configurable!!!! + # print( + # "WARNING: Basement conditioned, but Cooling disabled due to MASSACHUSETTS ASSUMPTIONS" + # ) + # new_zone_def.Operations.HVAC.ConditioningSystems.Cooling = None for zone in added_zone_lists.basement_zone_list.Names: new_zone_def.add_to_idf_zone(idf, zone) diff --git a/tests/test_components/test_builder.py b/tests/test_components/test_builder.py index 2e4d1ac..0711b96 100644 --- a/tests/test_components/test_builder.py +++ b/tests/test_components/test_builder.py @@ -4,7 +4,13 @@ from epinterface.data import DefaultEPWZipPath from epinterface.geometry import ShoeboxGeometry -from epinterface.sbem.builder import AtticAssumptions, BasementAssumptions, Model +from epinterface.sbem.builder import ( + ZONE_TIMESTEP_WHOLE_BUILDING_METERS, + AtticAssumptions, + BasementAssumptions, + Model, + build_output_meter_requests, +) from epinterface.sbem.prisma.client import deep_fetcher @@ -40,5 +46,21 @@ def test_builder(preseeded_readonly_db: Prisma): _r = model.run() +def test_build_output_meter_requests_includes_whole_building_meter_at_zone_timestep(): + requests = build_output_meter_requests( + ep_version_major=24, + include_zone_timestep_meters=True, + ) + + zone_timestep_meters = { + request["Key_Name"] + for request in requests + if request["key"] == "OUTPUT:METER" + and request["Reporting_Frequency"] == "Zone Timestep" + } + + assert set(ZONE_TIMESTEP_WHOLE_BUILDING_METERS).issubset(zone_timestep_meters) + + # TODO: add parameterized tests for different attic/basement configurations # and check almost all individual parameters in the returned idf model. From 323fd8d6b33349527c14f35eac21e991e4766876 Mon Sep 17 00:00:00 2001 From: Amber Date: Mon, 29 Jun 2026 22:09:17 -0400 Subject: [PATCH 10/10] Update for Event-based DHW, Add Missing Doc String --- epinterface/interface.py | 25 +- .../sbem/components/schedules/stochastic.py | 543 ++++++++++-------- pyproject.toml | 2 +- tests/test_components/test_builder.py | 1 + tests/test_components/test_space_use_comps.py | 5 +- uv.lock | 8 +- 6 files changed, 317 insertions(+), 267 deletions(-) diff --git a/epinterface/interface.py b/epinterface/interface.py index d10073e..de34e9a 100644 --- a/epinterface/interface.py +++ b/epinterface/interface.py @@ -753,6 +753,7 @@ def add(self, idf: IDF): class DaylightingReferencePoint(BaseObj, extra="ignore"): """Daylighting:ReferencePoint object.""" + key = "DAYLIGHTING:REFERENCEPOINT" Name: str Zone_or_Space_Name: str @@ -1209,18 +1210,18 @@ class ScheduleYearFromDays(BaseModel): each week-long date range. The resulting Schedule:Year has at most 53 entries, which is the EnergyPlus maximum. - Args: - Name: Name for the resulting Schedule:Year (also used as a prefix - for the generated Schedule:Week:Daily names). - Schedule_Type_Limits_Name: Reference to a ScheduleTypeLimits object. - day_schedules: Exactly 365 (or 366 for leap years) day schedule - objects, ordered Jan 1 through Dec 31. - start_day_of_week: The day of week for January 1 (must match - RunPeriod.Day_of_Week_for_Start_Day). - summer_design_day_schedule: Optional day schedule for the - SummerDesignDay slot in every generated week schedule. - winter_design_day_schedule: Optional day schedule for the - WinterDesignDay slot in every generated week schedule. + Attributes: + Name: Name for the resulting Schedule:Year. Also used as a prefix for + generated `Schedule:Week:Daily` names. + Schedule_Type_Limits_Name: Reference to a `ScheduleTypeLimits` object. + day_schedules: Exactly 365 day schedules, or 366 for leap years, + ordered Jan 1 through Dec 31. + start_day_of_week: Day of week for January 1. This must match + `RunPeriod.Day_of_Week_for_Start_Day`. + summer_design_day_schedule: Optional schedule used for the + `SummerDesignDay` slot in each generated week schedule. + winter_design_day_schedule: Optional schedule used for the + `WinterDesignDay` slot in each generated week schedule. """ Name: str diff --git a/epinterface/sbem/components/schedules/stochastic.py b/epinterface/sbem/components/schedules/stochastic.py index e2de45b..0ef4115 100644 --- a/epinterface/sbem/components/schedules/stochastic.py +++ b/epinterface/sbem/components/schedules/stochastic.py @@ -2,11 +2,10 @@ from abc import ABC, abstractmethod from collections.abc import Sequence -from pathlib import Path from typing import Literal import numpy as np -from obgeneration.generator.dhw_generator import DHWGenerator +from obgeneration.generator.dhw_generator import DHWFlatGenerator, DHWGenerator from obgeneration.generator.equipment_generator import EquipmentGenerator from obgeneration.generator.hvac_generator import HVACGenerator from obgeneration.generator.lighting_generator import LightingGenerator @@ -19,9 +18,7 @@ from obgeneration.model.occupancy import MobilityCluster from pydantic import BaseModel, ConfigDict, Field, model_validator -from epinterface.geometry import get_zone_floor_area from epinterface.interface import ScheduleDayInterval, ScheduleYearFromDays -from epinterface.weather import WeatherUrl class ScheduleOutput(ABC): @@ -52,13 +49,13 @@ class FractionalScheduleOutput(ScheduleOutput, BaseModel): default=None, description=( "The normalized timeseries of the schedule for the summer design day." - ) + ), ) winter_design_day: Sequence[float] | None = Field( default=None, description=( "The normalized timeseries of the schedule for the winter design day." - ) + ), ) def construct_idf_object(self): @@ -270,6 +267,8 @@ class ScheduleContext(BaseModel): equipment: Equipment | None = None dishwasher_cycles: list[list[int]] | None = None laundry_cycles: list[list[int]] | None = None + dishwasher_dhw_event_schedule: list[list[int]] | None = None + laundry_dhw_event_schedule: list[list[int]] | None = None @property def safe_occupancy(self) -> list[list[HouseholdOccupancyFractions]]: @@ -311,6 +310,22 @@ def safe_laundry_cycles(self) -> list[list[int]]: raise ValueError(msg) return self.laundry_cycles + @property + def safe_dishwasher_dhw_event_schedule(self) -> list[list[int]]: + """Get the dishwasher DHW event schedule, raising an error if it is not set.""" + if self.dishwasher_dhw_event_schedule is None: + msg = "Dishwasher DHW event schedule is not set" + raise ValueError(msg) + return self.dishwasher_dhw_event_schedule + + @property + def safe_laundry_dhw_event_schedule(self) -> list[list[int]]: + """Get the laundry DHW event schedule, raising an error if it is not set.""" + if self.laundry_dhw_event_schedule is None: + msg = "Laundry DHW event schedule is not set" + raise ValueError(msg) + return self.laundry_dhw_event_schedule + class ScheduleResults(BaseModel): """Results of the schedule generation.""" @@ -659,6 +674,12 @@ def generate_schedule( context.equipment = eqp context.laundry_cycles = eqp_res.laundry_cycles context.dishwasher_cycles = eqp_res.dishwasher_cycles + context.laundry_dhw_event_schedule = getattr( + eqp_res, "laundry_dhw_event_schedule", None + ) + context.dishwasher_dhw_event_schedule = getattr( + eqp_res, "dishwasher_dhw_event_schedule", None + ) return StochasticEquipmentScheduleOutput( name="Equipment", peak_value=eqp_res.peak_value, @@ -679,13 +700,37 @@ def generate_schedule( ) -> StochasticWaterUseScheduleOutput: """Generate a domestic hot water schedule.""" generator = get_generator(generator) - dhw_res = DHWGenerator.generate_with_defaults( - int(context.safe_num_occupants), - context.safe_equipment, - context.safe_laundry_cycles, - context.safe_dishwasher_cycles, - config.resolution_minutes, - ) + laundry_dhw_event_schedule = context.laundry_dhw_event_schedule + dishwasher_dhw_event_schedule = context.dishwasher_dhw_event_schedule + if ( + laundry_dhw_event_schedule is not None + and dishwasher_dhw_event_schedule is not None + ): + try: + dhw_res = DHWGenerator.generate_with_defaults( + int(context.safe_num_occupants), + context.safe_equipment, + context.safe_occupancy, + context.safe_laundry_dhw_event_schedule, + context.safe_dishwasher_dhw_event_schedule, + config.resolution_minutes, + ) + except TypeError: + dhw_res = DHWFlatGenerator.generate_with_defaults( + int(context.safe_num_occupants), + context.safe_equipment, + context.safe_laundry_cycles, + context.safe_dishwasher_cycles, + config.resolution_minutes, + ) + else: + dhw_res = DHWFlatGenerator.generate_with_defaults( + int(context.safe_num_occupants), + context.safe_equipment, + context.safe_laundry_cycles, + context.safe_dishwasher_cycles, + config.resolution_minutes, + ) return StochasticWaterUseScheduleOutput( name="WaterUse", peak_value=dhw_res.peak_value, @@ -734,239 +779,239 @@ def generate_schedules( return results -if __name__ == "__main__": - generator = StochasticScheduleGenerator( - config=ScheduleGenerationConfig(resolution_minutes=15), - equipment=StochasticEquipmentScheduleGenerator( - has_washer=True, - has_dryer=True, - has_cooking_provider=True, - has_dishwasher=True, - num_refrigerators=1, - washer_efficient=True, - dryer_efficient=True, - dishwasher_efficient=True, - refrigerator_efficient=True, - laundry_freq_per_week_min=1, - laundry_freq_per_week_max=2, - cooking_freq_per_week_min=7, - cooking_freq_per_week_max=14, - dishwasher_freq_per_week_min=1, - dishwasher_freq_per_week_max=2, - ), - lighting=StochasticLightingScheduleGenerator( - if_led=True, - when_away=False, - when_bright=False, - ), - occupancy=StochasticOccupancyScheduleGenerator.from_uniform_patterns( - weekday_pattern="mostly_home", - weekend_pattern="mostly_home", - num_occupants=2, - ), - water_use=StochasticWaterUseScheduleGenerator(), - heating_setpoint=StochasticHeatingSetpointScheduleGenerator( - has_heating=True, - heating_setpoint_active=21, - heating_setpoint_sleep=18, - heating_setpoint_away=16, - ), - cooling_setpoint=StochasticCoolingSetpointScheduleGenerator( - has_cooling=True, - cooling_setpoint_active=24, - cooling_setpoint_sleep=26, - cooling_setpoint_away=28, - ), - ) - from epinterface.sbem.flat_model import FlatModel - - flat_model = FlatModel( - F2FHeight=3.25, - Width=12, - Depth=8, - Rotation=45, - WWR=0.3, - NFloors=2, - FacadeRValue=3.0, - RoofRValue=3.0, - SlabRValue=3.0, - WindowUValue=3.0, - WindowSHGF=0.7, - WindowTVis=0.5, - InfiltrationACH=0.5, - VentFlowRatePerArea=0.0002, - VentFlowRatePerPerson=0.0015, - VentProvider="Mechanical", - VentHRV="NoHRV", - VentEconomizer="NoEconomizer", - VentDCV="NoDCV", - DHWFlowRatePerPerson=0.010, - DHWFuel="Electricity", - DHWSystemCOP=1.0, - DHWDistributionCOP=1.0, - EquipmentPowerDensity=25, - LightingPowerDensity=10, - OccupantDensity=0.001, - LightingDimmingType="Continuous", - EquipmentBase=0.4, - EquipmentAMInterp=0.5, - EquipmentLunchInterp=0.8, - EquipmentPMInterp=0.5, - EquipmentWeekendPeakInterp=0.25, - EquipmentSummerPeakInterp=0.5, - LightingBase=0.3, - LightingAMInterp=0.75, - LightingLunchInterp=0.75, - LightingPMInterp=0.9, - LightingWeekendPeakInterp=0.75, - LightingSummerPeakInterp=0.9, - OccupancyBase=0.05, - OccupancyAMInterp=0.25, - OccupancyLunchInterp=0.9, - OccupancyPMInterp=0.5, - OccupancyWeekendPeakInterp=0.15, - OccupancySummerPeakInterp=0.85, - # HSPRegularWeekdayWorkhours=21, - # HSPRegularWeekdayNight=21, - # HSPSummerWeekdayWorkhours=21, - # HSPSummerWeekdayNight=21, - # HSPWeekendWorkhours=21, - # HSPWeekendNight=21, - # CSPRegularWeekdayWorkhours=23, - # CSPRegularWeekdayNight=23, - # CSPSummerWeekdayWorkhours=23, - # CSPSummerWeekdayNight=23, - # CSPWeekendWorkhours=23, - # CSPWeekendNight=23, - HeatingSetpointBase=21, - SetpointDeadband=2, - HeatingSetpointSetback=2, - CoolingSetpointSetback=2, - NightSetback=0.5, - WeekendSetback=0.5, - SummerSetback=0.5, - HeatingFuel="Electricity", - CoolingFuel="Electricity", - HeatingSystemCOP=1.0, - CoolingSystemCOP=1.0, - HeatingDistributionCOP=1.0, - CoolingDistributionCOP=1.0, - EPWURI=WeatherUrl( # pyright: ignore [reportCallIssue] - "https://climate.onebuilding.org/WMO_Region_4_North_and_Central_America/USA_United_States_of_America/MA_Massachusetts/USA_MA_Bedford-Hanscom.Field.AP.744900_TMYx.2009-2023.zip" - ), - ) - breakpoint() - outdir = Path("test-out-lighting") - outdir.mkdir(parents=True, exist_ok=True) - # r = flat_model.simulate(eplus_parent_dir=outdir) - model, cb = flat_model.to_model() - breakpoint() - import logging - - from archetypal.idfclass.idf import IDF - - logger = logging.getLogger(__name__) - logging.basicConfig(level=logging.INFO) - - def callback(idf: IDF) -> IDF: - """Callback to add the schedules to the IDF.""" - idf = cb(idf) - - logger.info("Adding schedules to the IDF.") - logger.info("Generating stochastic values.") - schedules = generator.generate_schedules(42) - logger.info("Stochastic values generated.") - - breakpoint() - - if schedules.lighting is None: - msg = "Lighting schedule is not set" - raise ValueError(msg) - if schedules.equipment is None: - msg = "Equipment schedule is not set" - raise ValueError(msg) - if schedules.occupancy is None: - msg = "Occupancy schedule is not set" - raise ValueError(msg) - if schedules.water_use is None: - msg = "Water use schedule is not set" - raise ValueError(msg) - if schedules.heating_setpoint is None: - msg = "Heating setpoint schedule is not set" - raise ValueError(msg) - if schedules.cooling_setpoint is None: - msg = "Cooling setpoint schedule is not set" - raise ValueError(msg) - equipment_kWh = ( - schedules.equipment.peak_value - * np.array(schedules.equipment.normalized_timeseries).sum() - / (60 / generator.config.resolution_minutes) - / 1000 - ) - print(f"Equipment kWh: {equipment_kWh}") - # get the total occupied floor area of the building - # TODO: deal with the fact that some zones, e.g. the basement and attic, use a different use fraction - total_occupied_floor_area = sum([ - get_zone_floor_area(idf, zone.Name) for zone in idf.idfobjects["ZONE"] - ]) - print(f"Total occupied floor area: {total_occupied_floor_area}") - - logger.info("Constructing schedules.") - lighting_year = schedules.lighting.construct_idf_object() - equipment_year = schedules.equipment.construct_idf_object() - occupancy_year = schedules.occupancy.construct_idf_object() - water_use_year = schedules.water_use.construct_idf_object() - heating_setpoint_year = schedules.heating_setpoint.construct_idf_object() - cooling_setpoint_year = schedules.cooling_setpoint.construct_idf_object() - logger.info("Schedules constructed.") - logger.info("Adding schedules to the IDF.") - - lighting_year.add(idf) - equipment_year.add(idf) - occupancy_year.add(idf) - water_use_year.add(idf) - heating_setpoint_year.add(idf) - cooling_setpoint_year.add(idf) - logger.info("Schedules added to the IDF.") - - # lighting is already normalized - lpd = schedules.lighting.peak_value - # equipment is not normalized since its based off of discrete pieces of equipment etc - epd = schedules.equipment.peak_value / total_occupied_floor_area - occ_density = schedules.occupancy.peak_value / total_occupied_floor_area - - logger.info("Mutating IDF objects to assign schedules.") - for lightsobj in idf.idfobjects["LIGHTS"]: - lightsobj.Schedule_Name = lighting_year.Name - lightsobj.Watts_per_Zone_Floor_Area = lpd - - for equipmentobj in idf.idfobjects["ELECTRICEQUIPMENT"]: - equipmentobj.Schedule_Name = equipment_year.Name - equipmentobj.Watts_per_Zone_Floor_Area = epd - - for peopleobj in idf.idfobjects["PEOPLE"]: - peopleobj.Number_of_People_Schedule_Name = occupancy_year.Name - peopleobj.People_per_Floor_Area = occ_density - - #breakpoint() - - for water_use_obj in idf.idfobjects["WATERUSE:EQUIPMENT"]: - water_use_obj.Flow_Rate_Fraction_Schedule_Name = water_use_year.Name - for heating_setpoint_obj in idf.idfobjects["HVACTEMPLATE:THERMOSTAT"]: - heating_setpoint_obj.Heating_Setpoint_Schedule_Name = ( - heating_setpoint_year.Name - ) - for cooling_setpoint_obj in idf.idfobjects["HVACTEMPLATE:THERMOSTAT"]: - cooling_setpoint_obj.Cooling_Setpoint_Schedule_Name = ( - cooling_setpoint_year.Name - ) - logger.info("Mutating IDF objects to assign schedules complete.") - logger.info("Stochastic schedules injected and assigned to IDF objects.") - - return idf - - r = model.run(eplus_parent_dir=outdir, post_zone_callback=callback) - breakpoint() - print(r.energy_and_peak.groupby(level=["Measurement", "Aggregation"]).sum()) - - # print(yaml.dump(schedules.model_dump(mode="json"), indent=2, sort_keys=False)) +# if __name__ == "__main__": +# generator = StochasticScheduleGenerator( +# config=ScheduleGenerationConfig(resolution_minutes=15), +# equipment=StochasticEquipmentScheduleGenerator( +# has_washer=True, +# has_dryer=True, +# has_cooking_provider=True, +# has_dishwasher=True, +# num_refrigerators=1, +# washer_efficient=True, +# dryer_efficient=True, +# dishwasher_efficient=True, +# refrigerator_efficient=True, +# laundry_freq_per_week_min=1, +# laundry_freq_per_week_max=2, +# cooking_freq_per_week_min=7, +# cooking_freq_per_week_max=14, +# dishwasher_freq_per_week_min=1, +# dishwasher_freq_per_week_max=2, +# ), +# lighting=StochasticLightingScheduleGenerator( +# if_led=True, +# when_away=False, +# when_bright=False, +# ), +# occupancy=StochasticOccupancyScheduleGenerator.from_uniform_patterns( +# weekday_pattern="mostly_home", +# weekend_pattern="mostly_home", +# num_occupants=2, +# ), +# water_use=StochasticWaterUseScheduleGenerator(), +# heating_setpoint=StochasticHeatingSetpointScheduleGenerator( +# has_heating=True, +# heating_setpoint_active=21, +# heating_setpoint_sleep=18, +# heating_setpoint_away=16, +# ), +# cooling_setpoint=StochasticCoolingSetpointScheduleGenerator( +# has_cooling=True, +# cooling_setpoint_active=24, +# cooling_setpoint_sleep=26, +# cooling_setpoint_away=28, +# ), +# ) +# from epinterface.sbem.flat_model import FlatModel + +# flat_model = FlatModel( +# F2FHeight=3.25, +# Width=12, +# Depth=8, +# Rotation=45, +# WWR=0.3, +# NFloors=2, +# FacadeRValue=3.0, +# RoofRValue=3.0, +# SlabRValue=3.0, +# WindowUValue=3.0, +# WindowSHGF=0.7, +# WindowTVis=0.5, +# InfiltrationACH=0.5, +# VentFlowRatePerArea=0.0002, +# VentFlowRatePerPerson=0.0015, +# VentProvider="Mechanical", +# VentHRV="NoHRV", +# VentEconomizer="NoEconomizer", +# VentDCV="NoDCV", +# DHWFlowRatePerPerson=0.010, +# DHWFuel="Electricity", +# DHWSystemCOP=1.0, +# DHWDistributionCOP=1.0, +# EquipmentPowerDensity=25, +# LightingPowerDensity=10, +# OccupantDensity=0.001, +# LightingDimmingType="Continuous", +# EquipmentBase=0.4, +# EquipmentAMInterp=0.5, +# EquipmentLunchInterp=0.8, +# EquipmentPMInterp=0.5, +# EquipmentWeekendPeakInterp=0.25, +# EquipmentSummerPeakInterp=0.5, +# LightingBase=0.3, +# LightingAMInterp=0.75, +# LightingLunchInterp=0.75, +# LightingPMInterp=0.9, +# LightingWeekendPeakInterp=0.75, +# LightingSummerPeakInterp=0.9, +# OccupancyBase=0.05, +# OccupancyAMInterp=0.25, +# OccupancyLunchInterp=0.9, +# OccupancyPMInterp=0.5, +# OccupancyWeekendPeakInterp=0.15, +# OccupancySummerPeakInterp=0.85, +# # HSPRegularWeekdayWorkhours=21, +# # HSPRegularWeekdayNight=21, +# # HSPSummerWeekdayWorkhours=21, +# # HSPSummerWeekdayNight=21, +# # HSPWeekendWorkhours=21, +# # HSPWeekendNight=21, +# # CSPRegularWeekdayWorkhours=23, +# # CSPRegularWeekdayNight=23, +# # CSPSummerWeekdayWorkhours=23, +# # CSPSummerWeekdayNight=23, +# # CSPWeekendWorkhours=23, +# # CSPWeekendNight=23, +# HeatingSetpointBase=21, +# SetpointDeadband=2, +# HeatingSetpointSetback=2, +# CoolingSetpointSetback=2, +# NightSetback=0.5, +# WeekendSetback=0.5, +# SummerSetback=0.5, +# HeatingFuel="Electricity", +# CoolingFuel="Electricity", +# HeatingSystemCOP=1.0, +# CoolingSystemCOP=1.0, +# HeatingDistributionCOP=1.0, +# CoolingDistributionCOP=1.0, +# EPWURI=WeatherUrl( # pyright: ignore [reportCallIssue] +# "https://climate.onebuilding.org/WMO_Region_4_North_and_Central_America/USA_United_States_of_America/MA_Massachusetts/USA_MA_Bedford-Hanscom.Field.AP.744900_TMYx.2009-2023.zip" +# ), +# ) +# breakpoint() +# outdir = Path("test-out-lighting") +# outdir.mkdir(parents=True, exist_ok=True) +# # r = flat_model.simulate(eplus_parent_dir=outdir) +# model, cb = flat_model.to_model() +# breakpoint() +# import logging + +# from archetypal.idfclass.idf import IDF + +# logger = logging.getLogger(__name__) +# logging.basicConfig(level=logging.INFO) + +# def callback(idf: IDF) -> IDF: +# """Callback to add the schedules to the IDF.""" +# idf = cb(idf) + +# logger.info("Adding schedules to the IDF.") +# logger.info("Generating stochastic values.") +# schedules = generator.generate_schedules(42) +# logger.info("Stochastic values generated.") + +# breakpoint() + +# if schedules.lighting is None: +# msg = "Lighting schedule is not set" +# raise ValueError(msg) +# if schedules.equipment is None: +# msg = "Equipment schedule is not set" +# raise ValueError(msg) +# if schedules.occupancy is None: +# msg = "Occupancy schedule is not set" +# raise ValueError(msg) +# if schedules.water_use is None: +# msg = "Water use schedule is not set" +# raise ValueError(msg) +# if schedules.heating_setpoint is None: +# msg = "Heating setpoint schedule is not set" +# raise ValueError(msg) +# if schedules.cooling_setpoint is None: +# msg = "Cooling setpoint schedule is not set" +# raise ValueError(msg) +# equipment_kWh = ( +# schedules.equipment.peak_value +# * np.array(schedules.equipment.normalized_timeseries).sum() +# / (60 / generator.config.resolution_minutes) +# / 1000 +# ) +# print(f"Equipment kWh: {equipment_kWh}") +# # get the total occupied floor area of the building +# # TODO: deal with the fact that some zones, e.g. the basement and attic, use a different use fraction +# total_occupied_floor_area = sum([ +# get_zone_floor_area(idf, zone.Name) for zone in idf.idfobjects["ZONE"] +# ]) +# print(f"Total occupied floor area: {total_occupied_floor_area}") + +# logger.info("Constructing schedules.") +# lighting_year = schedules.lighting.construct_idf_object() +# equipment_year = schedules.equipment.construct_idf_object() +# occupancy_year = schedules.occupancy.construct_idf_object() +# water_use_year = schedules.water_use.construct_idf_object() +# heating_setpoint_year = schedules.heating_setpoint.construct_idf_object() +# cooling_setpoint_year = schedules.cooling_setpoint.construct_idf_object() +# logger.info("Schedules constructed.") +# logger.info("Adding schedules to the IDF.") + +# lighting_year.add(idf) +# equipment_year.add(idf) +# occupancy_year.add(idf) +# water_use_year.add(idf) +# heating_setpoint_year.add(idf) +# cooling_setpoint_year.add(idf) +# logger.info("Schedules added to the IDF.") + +# # lighting is already normalized +# lpd = schedules.lighting.peak_value +# # equipment is not normalized since its based off of discrete pieces of equipment etc +# epd = schedules.equipment.peak_value / total_occupied_floor_area +# occ_density = schedules.occupancy.peak_value / total_occupied_floor_area + +# logger.info("Mutating IDF objects to assign schedules.") +# for lightsobj in idf.idfobjects["LIGHTS"]: +# lightsobj.Schedule_Name = lighting_year.Name +# lightsobj.Watts_per_Zone_Floor_Area = lpd + +# for equipmentobj in idf.idfobjects["ELECTRICEQUIPMENT"]: +# equipmentobj.Schedule_Name = equipment_year.Name +# equipmentobj.Watts_per_Zone_Floor_Area = epd + +# for peopleobj in idf.idfobjects["PEOPLE"]: +# peopleobj.Number_of_People_Schedule_Name = occupancy_year.Name +# peopleobj.People_per_Floor_Area = occ_density + +# # breakpoint() + +# for water_use_obj in idf.idfobjects["WATERUSE:EQUIPMENT"]: +# water_use_obj.Flow_Rate_Fraction_Schedule_Name = water_use_year.Name +# for heating_setpoint_obj in idf.idfobjects["HVACTEMPLATE:THERMOSTAT"]: +# heating_setpoint_obj.Heating_Setpoint_Schedule_Name = ( +# heating_setpoint_year.Name +# ) +# for cooling_setpoint_obj in idf.idfobjects["HVACTEMPLATE:THERMOSTAT"]: +# cooling_setpoint_obj.Cooling_Setpoint_Schedule_Name = ( +# cooling_setpoint_year.Name +# ) +# logger.info("Mutating IDF objects to assign schedules complete.") +# logger.info("Stochastic schedules injected and assigned to IDF objects.") + +# return idf + +# r = model.run(eplus_parent_dir=outdir, post_zone_callback=callback) +# breakpoint() +# print(r.energy_and_peak.groupby(level=["Measurement", "Aggregation"]).sum()) + +# # print(yaml.dump(schedules.model_dump(mode="json"), indent=2, sort_keys=False)) diff --git a/pyproject.toml b/pyproject.toml index ca57822..ce26ce6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "geopandas~=1.0.1", "httpx~=0.27.2", "ladybug-core>=0.44.30", - "obgeneration>=0.1.0", + "obgeneration>=0.1.2", "openpyxl~=3.1.5", "pandas>=2.2,<2.3", "prisma~=0.15.0", diff --git a/tests/test_components/test_builder.py b/tests/test_components/test_builder.py index 0711b96..70903c5 100644 --- a/tests/test_components/test_builder.py +++ b/tests/test_components/test_builder.py @@ -47,6 +47,7 @@ def test_builder(preseeded_readonly_db: Prisma): def test_build_output_meter_requests_includes_whole_building_meter_at_zone_timestep(): + """Test zone-timestep requests always include the whole-building meter set.""" requests = build_output_meter_requests( ep_version_major=24, include_zone_timestep_meters=True, diff --git a/tests/test_components/test_space_use_comps.py b/tests/test_components/test_space_use_comps.py index 8655488..bd1c728 100644 --- a/tests/test_components/test_space_use_comps.py +++ b/tests/test_components/test_space_use_comps.py @@ -13,6 +13,7 @@ YearComponent, ) from epinterface.sbem.components.space_use import ( + DimmingTypeType, EquipmentComponent, LightingComponent, OccupancyComponent, @@ -118,7 +119,9 @@ def test_add_lighting_to_idf_zone(idf: IDF, schedule: YearComponent, is_on: bool @pytest.mark.parametrize("dimming_type", ["Stepped", "ContinuousOff"]) def test_add_lighting_to_idf_zone_rejects_unsupported_dimming_type( - daylighting_idf: IDF, simple_fraction_schedule: YearComponent, dimming_type: str + daylighting_idf: IDF, + simple_fraction_schedule: YearComponent, + dimming_type: DimmingTypeType, ): """Test stored dimming values can deserialize but unsupported modes fail.""" lighting = LightingComponent( diff --git a/uv.lock b/uv.lock index 7e7d17a..c18dfae 100644 --- a/uv.lock +++ b/uv.lock @@ -617,7 +617,7 @@ requires-dist = [ { name = "geopandas", specifier = "~=1.0.1" }, { name = "httpx", specifier = "~=0.27.2" }, { name = "ladybug-core", specifier = ">=0.44.30" }, - { name = "obgeneration", specifier = ">=0.1.0" }, + { name = "obgeneration", specifier = ">=0.1.2" }, { name = "openpyxl", specifier = "~=3.1.5" }, { name = "pandas", specifier = ">=2.2,<2.3" }, { name = "prisma", specifier = "~=0.15.0" }, @@ -2023,7 +2023,7 @@ wheels = [ [[package]] name = "obgeneration" -version = "0.1.1" +version = "0.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "matplotlib" }, @@ -2034,9 +2034,9 @@ dependencies = [ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/c9/af391d173e63f33386ea5cf84c50f0a380b5e5bf003b74821bfbaabfe10b/obgeneration-0.1.1.tar.gz", hash = "sha256:8b0512816fa86d5172859f0ac2a8f8a36c2c6a9eb6df9d20c8ca2df10bbefda5", size = 199471, upload-time = "2026-05-21T20:25:34.325Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/91/326b81a7d0ed984c9c09311912906365367103a64f19c1efdc2a7e242995/obgeneration-0.1.2.tar.gz", hash = "sha256:d5c9e4517872f4f33dc57ae05386b26f605363f1d736fa0e70d56bc395f3f366", size = 202978, upload-time = "2026-06-30T01:58:46.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/99/2f9e79fa6c39fd05cbf95d5dc02b4ccfd20c979a7b5eff3c5b79e6853534/obgeneration-0.1.1-py3-none-any.whl", hash = "sha256:b9621d0505d6c49fe08bfdc0c2c49e7489efcee79a955a5264de7a6f4aeb7f17", size = 226239, upload-time = "2026-05-21T20:25:32.985Z" }, + { url = "https://files.pythonhosted.org/packages/47/41/1834c3c6f4beded53ac04d8fe1ebe8ca0a1fa2c19a8a2be21eec2af6c782/obgeneration-0.1.2-py3-none-any.whl", hash = "sha256:d6d40f537e5fe3c26313c0a3172115d5ee4659b8c6863f4a315195aad5abb484", size = 230345, upload-time = "2026-06-30T01:58:45.577Z" }, ] [[package]]