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 59bc9b9..de34e9a 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 @@ -709,6 +710,58 @@ 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): + """Add the object to the IDF with one daylighting reference point.""" + obj = idf.newidfobject(self.key, **self.model_dump(exclude_none=True)) + + # 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 + + +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", @@ -834,6 +887,124 @@ class ScheduleDayHourly(BaseObj, extra="ignore"): Hour_24: float +InterpolateToTimestepType = Literal["Average", "Linear", "No"] + + +class ScheduleDayInterval(BaseObj, extra="ignore"): + """Schedule:Day:Interval object for sub-hourly schedule data. + + 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" + 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 the configured interval. + + 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 * self.Minutes_per_Item + 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, val in enumerate(self.Values): + fields[f"Value_{i + 1}"] = val + + 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 + + class ScheduleWeekDaily(BaseObj, extra="ignore"): """ScheduleWeekDaily object.""" @@ -957,6 +1128,203 @@ 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 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 + each week-long date range. The resulting Schedule:Year has at most 53 + entries, which is the EnergyPlus maximum. + + 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 + Schedule_Type_Limits_Name: str + day_schedules: Sequence[ScheduleDayList | ScheduleDayInterval] + start_day_of_week: DayOfWeekType = "Sunday" + 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: + 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.""" @@ -979,11 +1347,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. @@ -1016,9 +1385,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 4381d4d..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 @@ -826,12 +883,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. @@ -842,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(), @@ -882,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: @@ -899,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) @@ -972,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) @@ -1010,6 +1054,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 +1095,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 +1175,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 +1184,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 +1210,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/__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..0ef4115 --- /dev/null +++ b/epinterface/sbem/components/schedules/stochastic.py @@ -0,0 +1,1017 @@ +"""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 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 +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 + +from epinterface.interface import ScheduleDayInterval, ScheduleYearFromDays + + +class ScheduleOutput(ABC): + """Base class for schedule outputs.""" + + @abstractmethod + def construct_idf_object(self) -> ScheduleYearFromDays: + """Construct the IDF object for the schedule.""" + pass + + +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.", + ge=0, + ) + 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.""" + 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 = [ + ScheduleDayInterval( + Name=f"{self.name}Day{i:03d}", + Schedule_Type_Limits_Name="Fraction", + 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="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 + + +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.""" + 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): + """Output for the equipment schedule. + + Inherits from FractionalScheduleOutput, so it will contain a normalized timeseries and a peak value. + """ + + 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. + """ + + dimming_enabled: bool = Field( + ..., + description="Whether daylight dimming is enabled for the lighting schedule.", + ) + 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.""" + + 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 + 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]]: + """Get the occupancy states schedule, raising an error if it is not set.""" + if self.occupancy is None: + msg = "Occupancy states schedule is not set" + raise ValueError(msg) + return self.occupancy + + @property + 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.num_occupants + + @property + 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.equipment + + @property + 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.dishwasher_cycles + + @property + 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.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.""" + + 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: + """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, + config: ScheduleGenerationConfig, + ) -> ScheduleOutputType: + """Generate a schedule.""" + pass + + +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.", + ) + + @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) + + 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, + config: ScheduleGenerationConfig, + ) -> StochasticOccupancyScheduleOutput: + """Generate an occupancy schedule.""" + generator = get_generator(generator) + 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( + name="Occupancy", + peak_value=occupancy_res.peak_value, + normalized_timeseries=occupancy_res.annual_schedule, + summer_design_day=occupancy_res.summer_design_day_schedule, + winter_design_day=occupancy_res.winter_design_day_schedule, + ) + + +class StochasticHeatingSetpointScheduleGenerator(ScheduleGenerator): + """Generator for the heating setpoint schedule.""" + + 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." + ) + 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, + config: ScheduleGenerationConfig, + ) -> StochasticHeatingSetpointScheduleOutput: + """Generate a heating setpoint schedule.""" + generator = get_generator(generator) + 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( + 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, + ) + + +class StochasticCoolingSetpointScheduleGenerator(ScheduleGenerator): + """Generator for the cooling setpoint schedule.""" + + 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, + config: ScheduleGenerationConfig, + ) -> StochasticCoolingSetpointScheduleOutput: + """Generate a cooling setpoint schedule.""" + generator = get_generator(generator) + 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( + 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, + ) + + +class StochasticLightingScheduleGenerator(ScheduleGenerator): + """Generator for the lighting schedule.""" + + 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." + ) + when_bright: bool | None = Field( + None, description="Whether the lights are on when it is bright outside." + ) + + def generate_schedule( + self, + generator: np.random.Generator | int, + context: ScheduleContext, + config: ScheduleGenerationConfig, + ) -> StochasticLightingScheduleOutput: + """Generate a lighting schedule.""" + generator = get_generator(generator) + 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( + name="Lighting", + dimming_enabled=lighting_res.dimming_enabled, + peak_value=lighting_res.peak_value, + normalized_timeseries=lighting_res.annual_schedule, + summer_design_day=lighting_res.summer_design_day_schedule, + winter_design_day=lighting_res.winter_design_day_schedule, + ) + + +class StochasticEquipmentScheduleGenerator(ScheduleGenerator): + """Generator for the dishwasher schedule.""" + + 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." + ) + 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, + config: ScheduleGenerationConfig, + ) -> StochasticEquipmentScheduleOutput: + """Generate a dishwasher schedule.""" + generator = get_generator(generator) + + 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 + 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, + normalized_timeseries=eqp_res.annual_schedule, + summer_design_day=eqp_res.summer_design_day_schedule, + winter_design_day=eqp_res.winter_design_day_schedule, + ) + + +class StochasticWaterUseScheduleGenerator(ScheduleGenerator): + """Generator for the domestic hot water schedule.""" + + def generate_schedule( + self, + generator: np.random.Generator | int, + context: ScheduleContext, + config: ScheduleGenerationConfig, + ) -> StochasticWaterUseScheduleOutput: + """Generate a domestic hot water schedule.""" + generator = get_generator(generator) + 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, + normalized_timeseries=dhw_res.annual_schedule, + summer_design_day=dhw_res.summer_design_day_schedule, + winter_design_day=dhw_res.winter_design_day_schedule, + ) + + +class StochasticScheduleGenerator(BaseModel): + """Generator for stochastic schedules.""" + + config: ScheduleGenerationConfig + equipment: StochasticEquipmentScheduleGenerator + lighting: StochasticLightingScheduleGenerator + occupancy: StochasticOccupancyScheduleGenerator + water_use: StochasticWaterUseScheduleGenerator + heating_setpoint: StochasticHeatingSetpointScheduleGenerator + cooling_setpoint: StochasticCoolingSetpointScheduleGenerator + + def generate_schedules( + self, generator: np.random.Generator | int + ) -> ScheduleResults: + """Generate all the schedules.""" + generator = get_generator(generator) + 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 + ) + results.equipment = self.equipment.generate_schedule( + generator, context, self.config + ) + results.water_use = self.water_use.generate_schedule( + generator, context, self.config + ) + 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)) diff --git a/epinterface/sbem/components/space_use.py b/epinterface/sbem/components/space_use.py index fe05c43..2969bb4 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,9 @@ def add_people_to_idf_zone( idf = people.add(idf) return idf - -DimmingTypeType = Literal["Off", "Stepped", "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"): @@ -133,10 +141,48 @@ 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= 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( diff --git a/pyproject.toml b/pyproject.toml index 48ce9e1..ce26ce6 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.2", "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/tests/test_components/test_builder.py b/tests/test_components/test_builder.py index 2e4d1ac..70903c5 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,22 @@ def test_builder(preseeded_readonly_db: Prisma): _r = model.run() +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, + ) + + 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. diff --git a/tests/test_components/test_space_use_comps.py b/tests/test_components/test_space_use_comps.py index dda80f4..bd1c728 100644 --- a/tests/test_components/test_space_use_comps.py +++ b/tests/test_components/test_space_use_comps.py @@ -4,7 +4,14 @@ 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, @@ -16,6 +23,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 +35,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 +117,131 @@ 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: DimmingTypeType, ): - """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] diff --git a/uv.lock b/uv.lock index 1adfa13..c18dfae 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.2" }, { 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.2" +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/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/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]] name = "openpyxl" version = "3.1.5"