diff --git a/epinterface/analysis/zone_energy.py b/epinterface/analysis/zone_energy.py new file mode 100644 index 0000000..8f78dbd --- /dev/null +++ b/epinterface/analysis/zone_energy.py @@ -0,0 +1,325 @@ +"""Zone and floor annual energy summaries from EnergyPlus SQL output. + +Energy-accounting caveats (important for interpreting the columns below): + +- ``sim_lighting_site_kwh`` and ``sim_equipment_site_kwh`` are electric *site* + energy (the electricity consumed by lights and plug loads). +- ``sim_heating_delivered_kwh`` and ``sim_cooling_delivered_kwh`` are *delivered* + ideal-load thermal energy, i.e. the heating/cooling the ideal-loads air system + added to / removed from the zone air. They are NOT fuel/utility energy: no COP + or fuel mapping has been applied. +- ``sim_total_included_kwh`` is the sum of the four columns above. It mixes site + electricity with delivered thermal energy, so it is neither true site energy, + utility energy, nor source energy. It is "the simulated zone energy that is + currently included" and should be labelled as such in any downstream report. +- Domestic hot water (DHW) is NOT included in zone or floor results yet. + +The metadata columns (``floor_index``, ``role``, ``category``, ``ep_storey_index``, +``floor_area_m2``) are owned by the assignment summary; see +``merge_assignment_and_energy`` for how duplicate metadata is avoided on join. +""" + +from __future__ import annotations + +from collections.abc import Hashable, Sequence +from typing import Any + +import pandas as pd +from archetypal.idfclass import IDF +from archetypal.idfclass.sql import Sql + +from epinterface.geometry import ShoeboxGeometry, get_zone_floor_area +from epinterface.sbem.zone_assignment import ( + ZoneAssignmentResolver, +) + +J_TO_KWH = 1.0 / 3_600_000.0 + +# (EnergyPlus output variable name, result column name). Column names encode the +# energy-accounting meaning: "site" = electric site energy, "delivered" = ideal +# load thermal energy (no COP/fuel applied). See the module docstring. +_ZONE_ENERGY_VARS: tuple[tuple[str, str], ...] = ( + ("Zone Lights Electricity Energy", "sim_lighting_site_kwh"), + ("Zone Electric Equipment Electricity Energy", "sim_equipment_site_kwh"), + ("Zone Ideal Loads Zone Total Heating Energy", "sim_heating_delivered_kwh"), + ("Zone Ideal Loads Zone Total Cooling Energy", "sim_cooling_delivered_kwh"), +) + +TOTAL_ENERGY_COLUMN = "sim_total_included_kwh" + +# Metadata columns produced by both the assignment summary and the energy +# summary; dropped from the energy frame before joining so the merge does not +# create ``*_x`` / ``*_y`` duplicates. +_ENERGY_METADATA_COLUMNS: frozenset[str] = frozenset({ + "floor_area_m2", + "ep_storey_index", + "floor_index", + "role", + "category", +}) + + +def _normalized_zone_name(name: str) -> str: + return name.replace("_", " ").upper() + + +def _zone_name_lookup(zone_names: list[str]) -> dict[str, str]: + return {_normalized_zone_name(zone_name): zone_name for zone_name in zone_names} + + +def _match_zone_name(key_value: str, zone_lookup: dict[str, str]) -> str | None: + """Map SQL KeyValue strings to generated EnergyPlus zone names.""" + key_value = key_value.strip() + if key_value in zone_lookup.values(): + return key_value + normalized = _normalized_zone_name(key_value) + if normalized in zone_lookup: + return zone_lookup[normalized] + suffix = " IDEAL LOADS AIR SYSTEM" + if normalized.endswith(suffix): + candidate = normalized[: -len(suffix)] + return zone_lookup.get(candidate) + return None + + +def _key_value_from_column( + column: Any, + column_names: Sequence[Hashable | None], +) -> str: + """Extract the SQL KeyValue portion from a timeseries column label.""" + if isinstance(column, tuple): + values = list(column) + if not values: + return "" + column_names_list = list(column_names) + if "KeyValue" in column_names_list: + key_value_index = column_names_list.index("KeyValue") + if key_value_index < len(values): + return str(values[key_value_index]) + return str(values[1] if len(values) > 1 else values[0]) + return str(column) + + +def _annual_kwh_from_hourly( + sql: Sql, + variable_name: str, + idf: IDF, +) -> dict[str, float]: + """Sum hourly zone energy values from Joules to annual kWh.""" + zone_names = [zone.Name for zone in idf.idfobjects["ZONE"]] + zone_lookup = _zone_name_lookup(zone_names) + try: + hourly = sql.timeseries_by_name([variable_name], "Hourly") + except Exception: + return {} + if hourly.empty: + return {} + + totals: dict[str, float] = {} + column_names = ( + list(hourly.columns.names) if isinstance(hourly.columns, pd.MultiIndex) else [] + ) + for column in hourly.columns: + key_value = _key_value_from_column(column, column_names) + zone_name = _match_zone_name(key_value, zone_lookup) + if zone_name is None: + continue + totals[zone_name] = totals.get(zone_name, 0.0) + float(hourly[column].sum()) + + return {zone_name: joules * J_TO_KWH for zone_name, joules in totals.items()} + + +def _context_from_model( + model: Any | None, +) -> tuple[ + ZoneAssignmentResolver | None, + ShoeboxGeometry | None, + float | None, + bool, + float | None, + bool, +]: + """Duck-type either a builder Model or BuildingFlatModel into resolver context.""" + if model is None: + return None, None, None, False, None, False + + if hasattr(model, "zone_assignments") and hasattr(model, "geometry"): + return ( + model.zone_assignments, + model.geometry, + model.Attic.UseFraction, + model.Attic.Conditioned, + model.Basement.UseFraction, + model.Basement.Conditioned, + ) + + if hasattr(model, "assignment_resolver") and hasattr(model, "to_model"): + builder_model, _ = model.to_model() + return ( + model.assignment_resolver(), + builder_model.geometry, + model.attic.UseFraction, + model.attic.Conditioned, + model.basement.UseFraction, + model.basement.Conditioned, + ) + + return None, None, None, False, None, False + + +def zone_energy_summary( + sql: Sql, + idf: IDF, + *, + model: Any | None = None, + resolver: ZoneAssignmentResolver | None = None, + geometry: ShoeboxGeometry | None = None, +) -> pd.DataFrame: + """Return annual simulated energy by zone with raw kWh and kWh/m2 columns.""" + ( + model_resolver, + model_geometry, + attic_use_fraction, + attic_conditioned, + basement_use_fraction, + basement_conditioned, + ) = _context_from_model(model) + resolver = resolver or model_resolver + geometry = geometry or model_geometry + + zone_names = [zone.Name for zone in idf.idfobjects["ZONE"]] + area_by_zone = { + zone_name: float(get_zone_floor_area(idf, zone_name)) + for zone_name in zone_names + } + col_data = { + column_name: _annual_kwh_from_hourly(sql, variable_name, idf) + for variable_name, column_name in _ZONE_ENERGY_VARS + } + + metadata_by_zone: dict[str, dict[str, Any]] = {} + if resolver is not None and geometry is not None: + for resolved in resolver.resolved_zone_table( + idf, + geometry, + attic_use_fraction=attic_use_fraction, + attic_conditioned=attic_conditioned, + basement_use_fraction=basement_use_fraction, + basement_conditioned=basement_conditioned, + ): + metadata_by_zone[resolved.ep_zone_name] = { + "ep_storey_index": resolved.ep_storey_index, + "floor_index": resolved.floor_index, + "role": resolved.role.value, + "category": resolved.category, + } + + rows: list[dict[str, Any]] = [] + for zone_name in zone_names: + area = area_by_zone[zone_name] + row: dict[str, Any] = { + "ep_zone_name": zone_name, + "floor_area_m2": area, + **metadata_by_zone.get(zone_name, {}), + } + raw_values: list[float] = [] + for _, column_name in _ZONE_ENERGY_VARS: + value = col_data[column_name].get(zone_name) + row[column_name] = value + row[f"{column_name}_per_m2"] = None if value is None else value / area + if value is not None: + raw_values.append(value) + total = sum(raw_values) if raw_values else None + row[TOTAL_ENERGY_COLUMN] = total + row[f"{TOTAL_ENERGY_COLUMN}_per_m2"] = None if total is None else total / area + rows.append(row) + return pd.DataFrame(rows) + + +def floor_energy_summary( + zone_energy: pd.DataFrame, + *, + n_floors: int | None = None, +) -> pd.DataFrame: + """Aggregate zone energy to one row per above-grade floor. + + Raw zone kWh are summed first and only then normalized by the summed floor + area, so the per-m2 values are area-weighted correctly (rather than averaging + already-normalized zone values). Only ``category == "main"`` zones are + included, so basement/attic rows never leak into the floor table. + + Args: + zone_energy: Per-zone energy table from :func:`zone_energy_summary`. + n_floors: If provided, assert the result has exactly one row per + above-grade floor index ``0..n_floors-1``. This enforces the + "simulation produces n rows where n is the number of floors" + contract for assignment-backed runs (important for ``core/perim`` + zoning and basement geometries). + + Raises: + ValueError: If ``n_floors`` is provided and the aggregated floor indices + do not match ``set(range(n_floors))``. + """ + if zone_energy.empty or "floor_index" not in zone_energy.columns: + if n_floors is not None and n_floors > 0: + msg = ( + "floor_energy_summary produced no rows but expected " + f"{n_floors} above-grade floor(s)." + ) + raise ValueError(msg) + return pd.DataFrame() + main = zone_energy[zone_energy["category"] == "main"].copy() + if main.empty: + if n_floors is not None and n_floors > 0: + msg = ( + "floor_energy_summary found no 'main' zones but expected " + f"{n_floors} above-grade floor(s)." + ) + raise ValueError(msg) + return pd.DataFrame() + + raw_cols = [column for _, column in _ZONE_ENERGY_VARS] + [TOTAL_ENERGY_COLUMN] + grouped = ( + main.groupby("floor_index", dropna=False)[["floor_area_m2", *raw_cols]] + .sum(min_count=1) + .reset_index() + ) + for column in raw_cols: + grouped[f"{column}_per_m2"] = grouped[column] / grouped["floor_area_m2"] + grouped["floor_index"] = grouped["floor_index"].astype(int) + grouped = grouped.sort_values("floor_index").reset_index(drop=True) + + if n_floors is not None: + found = set(grouped["floor_index"].tolist()) + expected = set(range(n_floors)) + if found != expected: + missing = sorted(expected - found) + extra = sorted(found - expected) + msg = ( + "floor_energy_summary expected exactly one row per above-grade " + f"floor 0..{n_floors - 1}, but got floor indices {sorted(found)}." + ) + if missing: + msg += f" Missing floors: {missing}." + if extra: + msg += f" Unexpected floors: {extra}." + raise ValueError(msg) + + return grouped + + +def merge_assignment_and_energy( + assignment: pd.DataFrame, + energy: pd.DataFrame, +) -> pd.DataFrame: + """Join resolved assignment summary with simulated zone energy. + + The energy frame repeats metadata columns (``floor_index``, ``role``, + ``category``, ``ep_storey_index``, ``floor_area_m2``) that the assignment + frame already owns. Those are dropped from the energy frame before the join + so pandas does not emit ``*_x`` / ``*_y`` suffixed duplicates. + """ + energy_for_merge = energy.drop( + columns=[c for c in _ENERGY_METADATA_COLUMNS if c in energy.columns] + ) + return assignment.merge(energy_for_merge, on="ep_zone_name", how="left") diff --git a/epinterface/geometry.py b/epinterface/geometry.py index 26d2d71..6127445 100644 --- a/epinterface/geometry.py +++ b/epinterface/geometry.py @@ -905,3 +905,28 @@ def get_zone_glazed_area(idf: IDF, zone_name: str) -> float: raise ValueError(msg) return total_window_area + + +def get_zone_exterior_wall_area(idf: IDF, zone_name: str) -> float: + """Get the gross exterior (outdoor-facing) wall area of a zone [m2]. + + This is the sum of the base wall-surface polygon areas, which in EnergyPlus + includes the area covered by any window sub-surfaces. It is therefore the + correct denominator for a window-to-wall ratio (``glazed_area / wall_area``). + + Args: + idf (IDF): The IDF model. + zone_name (str): The name of the zone to measure. + + Returns: + area (float): The gross exterior wall area of the zone [m2]. + """ + total_wall_area = 0.0 + for srf in idf.idfobjects["BUILDINGSURFACE:DETAILED"]: + if ( + str(srf.Zone_Name).lower() == zone_name.lower() + and str(srf.Surface_Type).lower() == "wall" + and str(srf.Outside_Boundary_Condition).lower() == "outdoors" + ): + total_wall_area += float(Polygon3D(srf.coords).area) + return total_wall_area diff --git a/epinterface/sbem/builder.py b/epinterface/sbem/builder.py index 4381d4d..c922078 100644 --- a/epinterface/sbem/builder.py +++ b/epinterface/sbem/builder.py @@ -7,6 +7,7 @@ import shutil import sys import tempfile +import warnings from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -56,6 +57,10 @@ from epinterface.sbem.components.zones import ZoneComponent from epinterface.sbem.exceptions import NotImplementedParameter from epinterface.sbem.prisma.client import PrismaSettings +from epinterface.sbem.zone_assignment import ( + ZoneAssignmentResolver, + safe_zone_tag, +) from epinterface.settings import energyplus_settings from epinterface.weather import BaseWeather @@ -69,7 +74,19 @@ "Zone Mean Radiant Temperature", ] +ZoneEnergyHourlyVariables = Literal[ + "Zone Lights Electricity Energy", + "Zone Electric Equipment Electricity Energy", + "Zone Ideal Loads Zone Total Heating Energy", + "Zone Ideal Loads Zone Total Cooling Energy", +] + AVAILABLE_HOURLY_VARIABLES = get_args(AvailableHourlyVariables) +ZONE_ENERGY_HOURLY_VARIABLES = get_args(ZoneEnergyHourlyVariables) +ALL_HOURLY_OUTPUT_VARIABLES = ( + *AVAILABLE_HOURLY_VARIABLES, + *ZONE_ENERGY_HOURLY_VARIABLES, +) class SimulationPathConfig(BaseModel): @@ -93,6 +110,7 @@ class SurfaceHandler(BaseModel): original_surface_type: str | None surface_group: Literal["glazing", "opaque", "internal_mass"] zone_name_contains: str | None + zone_name_equals: str | None = None outside_boundary_condition_object_contains: str | None @model_validator(mode="after") @@ -142,8 +160,16 @@ def assign_constructions_to_objs( "BoundaryCondition", self.surface_group, "Glazing" ) + wall_zone_lookup = { + wall.Name: wall.Zone_Name + for wall in idf.idfobjects["BUILDINGSURFACE:DETAILED"] + } # Now we can find the matching idf objects that need a construction. - srfs = [srf for srf in idf.idfobjects[obj_type_key] if self.check_srf(srf)] + srfs = [ + srf + for srf in idf.idfobjects[obj_type_key] + if self.check_srf(srf, wall_zone_lookup) + ] idf = construction.add_to_idf(idf) # and then we can finally assign the construction to the surfaces. @@ -151,11 +177,12 @@ def assign_constructions_to_objs( srf.Construction_Name = construction.Name return idf - def check_srf(self, srf): + def check_srf(self, srf, wall_zone_lookup: dict[str, str]): """Check if the surface matches the filters. Args: srf (eppy.IDF.BLOCK): The surface to check. + wall_zone_lookup: Building surface Name to Zone_Name lookup for glazing. Returns: match (bool): True if the surface matches the filters. @@ -164,7 +191,7 @@ def check_srf(self, srf): self.check_construction_type(srf) and self.check_boundary(srf) and self.check_construction_name(srf) - and self.check_zone_name(srf) + and self.check_zone_name(srf, wall_zone_lookup) and self.check_outside_boundary_condition_object(srf) ) @@ -219,16 +246,17 @@ def check_construction_name(self, srf): # Check the original construction name return srf.Construction_Name.lower() == self.original_construction_name.lower() - # TODO: convert this to a regex for better control - def check_zone_name(self, srf): - """Check that the zone name for the surface contains the expected substring.""" - if self.zone_name_contains is None: - # Ignore the zone name check when filter not provided - return True + def check_zone_name(self, srf, wall_zone_lookup: dict[str, str]): + """Check that the zone name for the surface matches the expected filters.""" if self.surface_group == "glazing": - # Ignore the zone name check for windows + if self.zone_name_equals is None: + return True + wall_name = getattr(srf, "Building_Surface_Name", "") + return wall_zone_lookup.get(str(wall_name)) == self.zone_name_equals + if self.zone_name_equals is not None: + return srf.Zone_Name == self.zone_name_equals + if self.zone_name_contains is None: return True - # Check the zone name zone_name = srf.Zone_Name return self.zone_name_contains.lower() in zone_name.lower() @@ -530,6 +558,29 @@ def handle_envelope( ) return idf + def assign_facade_and_glazing_for_zone( + self, + idf: IDF, + facade_assembly: ConstructionAssemblyComponent, + window: GlazingConstructionSimpleComponent | None, + zone_name: str, + ) -> IDF: + """Assign facade and glazing constructions only to one thermal zone.""" + facade_handler = self.Facade.model_copy( + update={"zone_name_equals": zone_name, "zone_name_contains": None} + ) + idf = facade_handler.assign_constructions_to_objs( + idf=idf, construction=facade_assembly + ) + if window is not None: + window_handler = self.Window.model_copy( + update={"zone_name_equals": zone_name, "zone_name_contains": None} + ) + idf = window_handler.assign_constructions_to_objs( + idf=idf, construction=window + ) + return idf + @dataclass class AddedZoneLists: @@ -567,7 +618,18 @@ class Model(BaseWeather, validate_assignment=True): Attic: AtticAssumptions Basement: BasementAssumptions # TODO: should we have another field for whether or not the attic is ventilated, i.e. high infiltration? - Zone: ZoneComponent + Zone: ZoneComponent | None = Field(default=None) + zone_assignments: ZoneAssignmentResolver | None = Field(default=None) + + @model_validator(mode="after") + def validate_zone_source(self): + """Require exactly one zone source.""" + has_uniform_zone = self.Zone is not None + has_assignments = self.zone_assignments is not None + if has_uniform_zone == has_assignments: + msg = "Exactly one of Zone or zone_assignments must be set on Model." + raise ValueError(msg) + return self @field_validator("geometry", mode="after") @classmethod @@ -602,25 +664,72 @@ def total_conditioned_area(self) -> float: @property def total_people(self) -> float: - """The total number of people in the model. + """Approximate the total number of people in the model (uniform path). - Returns: - ppl (float): The total number of people in the model + This is only valid for the legacy uniform ``Zone`` path, where occupant + density is constant across the building. For assignment-backed models + occupant density varies per floor/zone, so a single building-wide density + is wrong; use :meth:`total_people_count` with the built IDF instead. + Raises: + NotImplementedError: If the model uses ``zone_assignments``. + ValueError: If the model has no ``Zone``. """ - raise NotImplementedError( - "Total people is not yet implemented because of attics/basements etc." - ) + if self.zone_assignments is not None: + msg = ( + "total_people is not resolver-aware. For assignment-backed models, " + "call total_people_count(idf) with the built IDF so per-zone occupant " + "densities and floor areas can be summed." + ) + raise NotImplementedError(msg) + if self.Zone is None: + msg = "Model has no Zone for occupant count." + raise ValueError(msg) ppl_per_m2 = ( self.Zone.Operations.SpaceUse.Occupancy.PeopleDensity if self.Zone.Operations.SpaceUse.Occupancy.IsOn else 0 ) - total_area = self.total_conditioned_area # this is wrong - it should be based off of occupied area which may be different depending on attic/basement use fractions. - total_ppl = ppl_per_m2 * total_area - return total_ppl + return float(ppl_per_m2 * self.total_conditioned_area) + + def total_people_count(self, idf: IDF | None = None) -> float: + """Total occupants, resolver-aware for assignment-backed models. + + For the legacy uniform path this matches :attr:`total_people`. For + assignment-backed models it sums each zone's resolved occupant density + times its built floor area (resolved densities already include + attic/basement use-fraction scaling, so unoccupied zones contribute 0). - # validate the attic conditioning + Args: + idf (IDF | None): The built IDF, required for assignment-backed models + to read per-zone floor areas. + + Raises: + NotImplementedError: If ``zone_assignments`` is set but ``idf`` is None. + """ + if self.zone_assignments is None: + return self.total_people + if idf is None: + msg = ( + "total_people_count requires the built IDF for assignment-backed " + "models so per-zone floor areas can be used." + ) + raise NotImplementedError(msg) + total = 0.0 + for zone in idf.idfobjects["ZONE"]: + area = float(get_zone_floor_area(idf, zone.Name)) + resolved = self.zone_assignments.resolve_zone( + zone.Name, + self.geometry, + attic_use_fraction=self.Attic.UseFraction, + attic_conditioned=self.Attic.Conditioned, + basement_use_fraction=self.Basement.UseFraction, + basement_conditioned=self.Basement.Conditioned, + ) + total += resolved.params.OccupantDensity * area + return total + + # validate the attic conditioning @model_validator(mode="after") def attic_check(self): @@ -757,26 +866,76 @@ def add_zone_lists( ) # part of the HVAC/conditioning system - def compute_dhw(self) -> float: + def compute_dhw(self, idf: IDF | None = None) -> float: """Explicitly compute the domestic hot water energy demand. This is useful as a gut-check to make sure the DHW has been added correctly. + For assignment-backed models this is resolver-aware: it sums each zone's + DHW energy using that zone's resolved water-use flow rate, occupant count + (resolved occupant density times built floor area), and DHW supply/inlet + temperatures. The legacy uniform ``Zone`` path is unchanged. + + Args: + idf (IDF | None): The built IDF, required for assignment-backed models + so per-zone occupant counts can be computed. + Returns: energy (float): The annual domestic hot water energy demand (kWh/m2) + + Raises: + NotImplementedError: If ``zone_assignments`` is set but ``idf`` is None. """ + water_density = physical_constants.WaterDensity_kg_per_m3 # kg/m3 + c = physical_constants.WaterSpecificHeat_J_per_kg_degK # J/kg.K + + if self.zone_assignments is not None: + from epinterface.sbem.flat_model import zone_template_to_zone_component + + if idf is None: + msg = ( + "compute_dhw requires the built IDF for assignment-backed models " + "so per-zone occupant counts can be computed." + ) + raise NotImplementedError(msg) + total_energy_J = 0.0 + for zone in idf.idfobjects["ZONE"]: + area = float(get_zone_floor_area(idf, zone.Name)) + resolved = self.zone_assignments.resolve_zone( + zone.Name, + self.geometry, + attic_use_fraction=self.Attic.UseFraction, + attic_conditioned=self.Attic.Conditioned, + basement_use_fraction=self.Basement.UseFraction, + basement_conditioned=self.Basement.Conditioned, + ) + ops = zone_template_to_zone_component(resolved.params).Operations + if not ops.DHW.IsOn: + continue + people = resolved.params.OccupantDensity * area + flow_rate_per_person = ops.SpaceUse.WaterUse.FlowRatePerPerson + temperature_rise = ( + ops.DHW.WaterSupplyTemperature - ops.DHW.WaterTemperatureInlet + ) # K + total_flow_rate = flow_rate_per_person * people # m3/day + total_volume = total_flow_rate * 365 # m3 / yr + total_mass = total_volume * water_density # kg + total_energy_J += total_mass * temperature_rise * c # J / yr + total_energy_kWh = total_energy_J / physical_constants.J_to_kWh + return total_energy_kWh / self.total_conditioned_area + + if self.Zone is None: + msg = "Model has no Zone for DHW computation." + raise ValueError(msg) + ops = self.Zone.Operations + # TODO: this should be computed from the DHW schedule - if not self.Zone.Operations.DHW.IsOn: + if not ops.DHW.IsOn: return 0 - flow_rate_per_person = ( - self.Zone.Operations.SpaceUse.WaterUse.FlowRatePerPerson - ) # m3/day/person, average + flow_rate_per_person = ops.SpaceUse.WaterUse.FlowRatePerPerson temperature_rise = ( - self.Zone.Operations.DHW.WaterSupplyTemperature - - self.Zone.Operations.DHW.WaterTemperatureInlet + ops.DHW.WaterSupplyTemperature - ops.DHW.WaterTemperatureInlet ) # K - water_density = physical_constants.WaterDensity_kg_per_m3 # kg/m3 - c = physical_constants.WaterSpecificHeat_J_per_kg_degK # J/kg.K total_flow_rate = flow_rate_per_person * self.total_people # m3/day total_volume = total_flow_rate * 365 # m3 / yr total_mass = total_volume * water_density # kg @@ -867,7 +1026,7 @@ def build( # noqa: C901 "Variable_Name": variable, "Reporting_Frequency": "Hourly", } - for variable in AVAILABLE_HOURLY_VARIABLES + for variable in ALL_HOURLY_OUTPUT_VARIABLES ] ) idf = IDF( @@ -885,7 +1044,7 @@ def build( # noqa: C901 if output.Key_Name not in desired_meters: idf.removeidfobject(output) for output in idf.idfobjects["OUTPUT:VARIABLE"]: - if output.Variable_Name not in AVAILABLE_HOURLY_VARIABLES: + if output.Variable_Name not in ALL_HOURLY_OUTPUT_VARIABLES: idf.removeidfobject(output) ddy = IDF( @@ -909,107 +1068,293 @@ def build( # noqa: C901 # construct zone lists idf, added_zone_lists = self.add_zone_lists(idf) - # Handle main zones - for zone in added_zone_lists.main_zone_list.Names: - self.Zone.add_to_idf_zone(idf, zone) + if self.zone_assignments is not None: + from epinterface.sbem.flat_model import zone_template_to_zone_component + + resolver = self.zone_assignments + component_by_zone: dict[str, ZoneComponent] = {} + + def resolve_component(zone_name: str) -> ZoneComponent: + if zone_name not in component_by_zone: + resolved = resolver.resolve_zone( + zone_name, + self.geometry, + attic_use_fraction=self.Attic.UseFraction, + attic_conditioned=self.Attic.Conditioned, + basement_use_fraction=self.Basement.UseFraction, + basement_conditioned=self.Basement.Conditioned, + ) + component_by_zone[zone_name] = zone_template_to_zone_component( + resolved.params, + id_tag=safe_zone_tag(zone_name), + ) + return component_by_zone[zone_name] + + def apply_zone_wwr(zone_name: str) -> None: + resolved = resolver.resolve_zone( + zone_name, + self.geometry, + attic_use_fraction=self.Attic.UseFraction, + attic_conditioned=self.Attic.Conditioned, + basement_use_fraction=self.Basement.UseFraction, + basement_conditioned=self.Basement.Conditioned, + ) + exterior_walls = [ + srf + for srf in idf.idfobjects["BUILDINGSURFACE:DETAILED"] + if str(srf.Zone_Name) == zone_name + and str(srf.Surface_Type).lower() == "wall" + and str(srf.Outside_Boundary_Condition).lower() == "outdoors" + ] + if exterior_walls: + idf.set_wwr( + wwr=resolved.params.WWR, + construction="Project External Window", + force=True, + surfaces=exterior_walls, + ) - # Handle setting ground temperature - subtractor = ( - 4 if (self.geometry.basement and not self.Basement.Conditioned) else 2 - ) - has_heating = self.Zone.Operations.HVAC.ConditioningSystems.Heating is not None - has_cooling = self.Zone.Operations.HVAC.ConditioningSystems.Cooling is not None - hsp = self.Zone.Operations.SpaceUse.Thermostat.HeatingSchedule - csp = self.Zone.Operations.SpaceUse.Thermostat.CoolingSchedule - epw = EPW(epw_path.as_posix()) - epw_ground_vals_all = epw.monthly_ground_temperature - if self.geometry.basement: - # if there is a basement, we use the 2m depth to account for the basement depth. - epw_ground_vals = epw_ground_vals_all[4].values - else: - # if there is no basement, we use the 0.5m depth to account for the ground temperature. - epw_ground_vals = epw_ground_vals_all[0.5].values - low_ground_val = min(epw_ground_vals) - high_ground_val = max(epw_ground_vals) - phase = (np.array(epw_ground_vals) - low_ground_val) / ( - high_ground_val - low_ground_val - ) - if has_heating and has_cooling: - winter_line = np.array(hsp.MonthlyAverageValues) - subtractor - summer_line = np.array(csp.MonthlyAverageValues) - subtractor - elif has_heating: - winter_line = np.array(hsp.MonthlyAverageValues) - subtractor - summer_line = np.array(hsp.MonthlyAverageValues) - elif has_cooling: - winter_line = np.array(csp.MonthlyAverageValues) - subtractor - summer_line = np.array(csp.MonthlyAverageValues) - subtractor - else: - # No heating or cooling, so we use the default ground temperature, should not matter much. - winter_line = np.array(assumed_constants.SiteGroundTemperature_degC) - summer_line = np.array(assumed_constants.SiteGroundTemperature_degC) - interp_temp = phase * np.abs(summer_line - winter_line) + winter_line - ground_vals = [max(epw_ground_vals[i], interp_temp[i]) for i in range(12)] - idf = SiteGroundTemperature.FromValues(ground_vals).add(idf) - - # handle basements - if self.Basement.UseFraction or self.Basement.Conditioned: - new_zone_def = self.Zone.model_copy(deep=True) - frac = self.Basement.UseFraction or 0 - pd = new_zone_def.Operations.SpaceUse.Occupancy.PeopleDensity - epd = new_zone_def.Operations.SpaceUse.Equipment.PowerDensity - lpd = new_zone_def.Operations.SpaceUse.Lighting.PowerDensity - - new_zone_def.Operations.SpaceUse.Equipment.PowerDensity = frac * epd - new_zone_def.Operations.SpaceUse.Lighting.PowerDensity = frac * lpd - new_zone_def.Operations.SpaceUse.Occupancy.PeopleDensity = frac * pd - - # # handle infiltration for basements, which we are assuming is 0 (or whatever is in assumed constants) - new_zone_def.Envelope.Infiltration = ( - new_zone_def.Envelope.BasementInfiltration + for zone in added_zone_lists.main_zone_list.Names: + apply_zone_wwr(zone) + zc = resolve_component(zone) + idf = zc.add_to_idf_zone(idf, zone) + + if self.Basement.UseFraction or self.Basement.Conditioned: + if self.Basement.Conditioned: + print( + "WARNING: Basement conditioned, but Cooling disabled due to MASSACHUSETTS ASSUMPTIONS" + ) + for zone in added_zone_lists.basement_zone_list.Names: + zc = resolve_component(zone) + idf = zc.add_to_idf_zone(idf, zone) + + if self.Attic.UseFraction or self.Attic.Conditioned: + for zone in added_zone_lists.attic_zone_list.Names: + zc = resolve_component(zone) + idf = zc.add_to_idf_zone(idf, zone) + + subtractor = ( + 4 if (self.geometry.basement and not self.Basement.Conditioned) else 2 ) + # Ground-contact heat transfer is driven by the lowest ground-coupled + # zones, not by upper floors. Use the basement when present, otherwise + # the lowest above-grade floor (floor_index == 0). This prevents a + # high-setpoint or high-load upper floor from shifting the slab's + # ground temperature in a heterogeneous building. + if self.geometry.basement: + ground_contact_zone_names = list( + added_zone_lists.basement_zone_list.Names + ) + else: + ground_contact_zone_names = [ + zone + for zone in added_zone_lists.main_zone_list.Names + if resolver.resolve_zone( + zone, + self.geometry, + attic_use_fraction=self.Attic.UseFraction, + attic_conditioned=self.Attic.Conditioned, + basement_use_fraction=self.Basement.UseFraction, + basement_conditioned=self.Basement.Conditioned, + ).floor_index + == 0 + ] + hsp_acc = np.zeros(12) + csp_acc = np.zeros(12) + area_sum = 0.0 + has_heating = False + has_cooling = False + for zone in ground_contact_zone_names: + zone_area = float(get_zone_floor_area(idf, zone)) + zc = resolve_component(zone) + hsp_acc += ( + np.asarray( + zc.Operations.SpaceUse.Thermostat.HeatingSchedule.MonthlyAverageValues, + dtype=float, + ) + * zone_area + ) + csp_acc += ( + np.asarray( + zc.Operations.SpaceUse.Thermostat.CoolingSchedule.MonthlyAverageValues, + dtype=float, + ) + * zone_area + ) + area_sum += zone_area + cond = zc.Operations.HVAC.ConditioningSystems + has_heating = has_heating or cond.Heating is not None + has_cooling = has_cooling or cond.Cooling is not None + + if area_sum > 0: + hsp_line = hsp_acc / area_sum + csp_line = csp_acc / area_sum + else: + # No conditioned ground-contact zones (e.g. an unconditioned + # basement): fall back to default ground temperatures below. + has_heating = False + has_cooling = False + hsp_line = np.array(assumed_constants.SiteGroundTemperature_degC) + csp_line = np.array(assumed_constants.SiteGroundTemperature_degC) + + epw = EPW(epw_path.as_posix()) + epw_ground_vals_all = epw.monthly_ground_temperature + if self.geometry.basement: + epw_ground_vals = epw_ground_vals_all[4].values + else: + epw_ground_vals = epw_ground_vals_all[0.5].values + low_ground_val = min(epw_ground_vals) + high_ground_val = max(epw_ground_vals) + phase = (np.array(epw_ground_vals) - low_ground_val) / ( + high_ground_val - low_ground_val + ) + if has_heating and has_cooling: + winter_line = np.asarray(hsp_line) - subtractor + summer_line = np.asarray(csp_line) - subtractor + elif has_heating: + winter_line = np.asarray(hsp_line) - subtractor + summer_line = np.asarray(hsp_line) + elif has_cooling: + winter_line = np.asarray(csp_line) - subtractor + summer_line = np.asarray(csp_line) - subtractor + else: + winter_line = np.array(assumed_constants.SiteGroundTemperature_degC) + summer_line = np.array(assumed_constants.SiteGroundTemperature_degC) + interp_temp = phase * np.abs(summer_line - winter_line) + winter_line + ground_vals = [max(epw_ground_vals[i], interp_temp[i]) for i in range(12)] + idf = SiteGroundTemperature.FromValues(ground_vals).add(idf) + + ref_zone = zone_template_to_zone_component(resolver.defaults) + idf = self.add_constructions( + idf, + ref_zone.Envelope.Assemblies, + ref_zone.Envelope.Window, + ) + handlers = SurfaceHandlers.Default( + basement_suffix=self.geometry.basement_suffix + if self.geometry.basement + else "NO-OP" + ) + for zone in added_zone_lists.conditioned_zone_list.Names: + zc = resolve_component(zone) + idf = handlers.assign_facade_and_glazing_for_zone( + idf, + zc.Envelope.Assemblies.FacadeAssembly, + zc.Envelope.Window, + zone, + ) + else: + if self.Zone is None: + msg = "Model has no Zone for legacy build path." + raise ValueError(msg) - if not self.Basement.Conditioned: - new_zone_def.Operations.HVAC.Ventilation.Provider = "None" - new_zone_def.Operations.HVAC.ConditioningSystems.Heating = None - new_zone_def.Operations.HVAC.ConditioningSystems.Cooling = None + # Handle main zones + for zone in added_zone_lists.main_zone_list.Names: + self.Zone.add_to_idf_zone(idf, zone) + + # Handle setting ground temperature + subtractor = ( + 4 if (self.geometry.basement and not self.Basement.Conditioned) else 2 + ) + has_heating = ( + self.Zone.Operations.HVAC.ConditioningSystems.Heating is not None + ) + has_cooling = ( + self.Zone.Operations.HVAC.ConditioningSystems.Cooling is not None + ) + hsp = self.Zone.Operations.SpaceUse.Thermostat.HeatingSchedule + csp = self.Zone.Operations.SpaceUse.Thermostat.CoolingSchedule + epw = EPW(epw_path.as_posix()) + epw_ground_vals_all = epw.monthly_ground_temperature + if self.geometry.basement: + # if there is a basement, we use the 2m depth to account for the basement depth. + epw_ground_vals = epw_ground_vals_all[4].values + else: + # if there is no basement, we use the 0.5m depth to account for the ground temperature. + epw_ground_vals = epw_ground_vals_all[0.5].values + low_ground_val = min(epw_ground_vals) + high_ground_val = max(epw_ground_vals) + phase = (np.array(epw_ground_vals) - low_ground_val) / ( + high_ground_val - low_ground_val + ) + if has_heating and has_cooling: + winter_line = np.array(hsp.MonthlyAverageValues) - subtractor + summer_line = np.array(csp.MonthlyAverageValues) - subtractor + elif has_heating: + winter_line = np.array(hsp.MonthlyAverageValues) - subtractor + summer_line = np.array(hsp.MonthlyAverageValues) + elif has_cooling: + winter_line = np.array(csp.MonthlyAverageValues) - subtractor + summer_line = np.array(csp.MonthlyAverageValues) - subtractor else: - # TODO: make this configurable!!!! - print( - "WARNING: Basement conditioned, but Cooling disabled due to MASSACHUSETTS ASSUMPTIONS" + # No heating or cooling, so we use the default ground temperature, should not matter much. + winter_line = np.array(assumed_constants.SiteGroundTemperature_degC) + summer_line = np.array(assumed_constants.SiteGroundTemperature_degC) + interp_temp = phase * np.abs(summer_line - winter_line) + winter_line + ground_vals = [max(epw_ground_vals[i], interp_temp[i]) for i in range(12)] + idf = SiteGroundTemperature.FromValues(ground_vals).add(idf) + + # handle basements + if self.Basement.UseFraction or self.Basement.Conditioned: + new_zone_def = self.Zone.model_copy(deep=True) + frac = self.Basement.UseFraction or 0 + pd = new_zone_def.Operations.SpaceUse.Occupancy.PeopleDensity + epd = new_zone_def.Operations.SpaceUse.Equipment.PowerDensity + lpd = new_zone_def.Operations.SpaceUse.Lighting.PowerDensity + + new_zone_def.Operations.SpaceUse.Equipment.PowerDensity = frac * epd + new_zone_def.Operations.SpaceUse.Lighting.PowerDensity = frac * lpd + new_zone_def.Operations.SpaceUse.Occupancy.PeopleDensity = frac * pd + + # # handle infiltration for basements, which we are assuming is 0 (or whatever is in assumed constants) + new_zone_def.Envelope.Infiltration = ( + new_zone_def.Envelope.BasementInfiltration + ) + + if not self.Basement.Conditioned: + 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 + + for zone in added_zone_lists.basement_zone_list.Names: + new_zone_def.add_to_idf_zone(idf, zone) + + if self.Attic.UseFraction or self.Attic.Conditioned: + new_zone_def = self.Zone.model_copy(deep=True) + frac = self.Attic.UseFraction or 0 + pd = new_zone_def.Operations.SpaceUse.Occupancy.PeopleDensity + epd = new_zone_def.Operations.SpaceUse.Equipment.PowerDensity + lpd = new_zone_def.Operations.SpaceUse.Lighting.PowerDensity + + new_zone_def.Operations.SpaceUse.Equipment.PowerDensity = frac * epd + new_zone_def.Operations.SpaceUse.Lighting.PowerDensity = frac * lpd + new_zone_def.Operations.SpaceUse.Occupancy.PeopleDensity = frac * pd + + # handle infiltration for roofs + # because the *regular* infiltration object is the one that gets added, we simply copy + # the desired attic infiltration into the relevant section. + new_zone_def.Envelope.Infiltration = ( + new_zone_def.Envelope.AtticInfiltration ) - 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) - - if self.Attic.UseFraction or self.Attic.Conditioned: - new_zone_def = self.Zone.model_copy(deep=True) - frac = self.Attic.UseFraction or 0 - pd = new_zone_def.Operations.SpaceUse.Occupancy.PeopleDensity - epd = new_zone_def.Operations.SpaceUse.Equipment.PowerDensity - lpd = new_zone_def.Operations.SpaceUse.Lighting.PowerDensity - - new_zone_def.Operations.SpaceUse.Equipment.PowerDensity = frac * epd - new_zone_def.Operations.SpaceUse.Lighting.PowerDensity = frac * lpd - new_zone_def.Operations.SpaceUse.Occupancy.PeopleDensity = frac * pd - - # handle infiltration for roofs - # because the *regular* infiltration object is the one that gets added, we simply copy - # the desired attic infiltration into the relevant section. - new_zone_def.Envelope.Infiltration = new_zone_def.Envelope.AtticInfiltration - - if not self.Attic.Conditioned: - new_zone_def.Operations.HVAC.Ventilation.Provider = "None" - new_zone_def.Operations.HVAC.ConditioningSystems.Heating = None - new_zone_def.Operations.HVAC.ConditioningSystems.Cooling = None - - # TODO: handle mutating infiltration object when "ventilated attics" are set - for zone in added_zone_lists.attic_zone_list.Names: - new_zone_def.add_to_idf_zone(idf, zone) - - idf = self.add_constructions( - idf, self.Zone.Envelope.Assemblies, self.Zone.Envelope.Window - ) + + if not self.Attic.Conditioned: + new_zone_def.Operations.HVAC.Ventilation.Provider = "None" + new_zone_def.Operations.HVAC.ConditioningSystems.Heating = None + new_zone_def.Operations.HVAC.ConditioningSystems.Cooling = None + + # TODO: handle mutating infiltration object when "ventilated attics" are set + for zone in added_zone_lists.attic_zone_list.Names: + new_zone_def.add_to_idf_zone(idf, zone) + + idf = self.add_constructions( + idf, self.Zone.Envelope.Assemblies, self.Zone.Envelope.Window + ) # > operations # ----> space use @@ -1082,7 +1427,7 @@ def get_warnings(self, idf: IDF) -> str: return err_text def standard_results_postprocess( - self, sql: Sql, ep_version_major: int + self, sql: Sql, ep_version_major: int, idf: IDF | None = None ) -> pd.Series: """Postprocess the sql file to get the standard results. @@ -1093,22 +1438,118 @@ def standard_results_postprocess( Args: sql (Sql): The sql file to postprocess. ep_version_major (int): The major version of EnergyPlus. + idf (IDF | None): The built IDF, used for area-weighted COPs when + zone assignments are active. Returns: series (pd.Series): The postprocessed results. """ - ops = self.Zone.Operations - cond_sys = ops.HVAC.ConditioningSystems - heat_cop = ( - cond_sys.Heating.effective_system_cop if cond_sys.Heating is not None else 1 - ) - cool_cop = ( - cond_sys.Cooling.effective_system_cop if cond_sys.Cooling is not None else 1 - ) - dhw_cop = ops.DHW.effective_system_cop - heat_fuel = cond_sys.Heating.Fuel if cond_sys.Heating is not None else None - cool_fuel = cond_sys.Cooling.Fuel if cond_sys.Cooling is not None else None - dhw_fuel = ops.DHW.FuelType + if self.zone_assignments is None: + if self.Zone is None: + msg = "Model has no Zone for standard results postprocess." + raise ValueError(msg) + ops = self.Zone.Operations + cond_sys = ops.HVAC.ConditioningSystems + heat_cop = ( + cond_sys.Heating.effective_system_cop + if cond_sys.Heating is not None + else 1 + ) + cool_cop = ( + cond_sys.Cooling.effective_system_cop + if cond_sys.Cooling is not None + else 1 + ) + dhw_cop = ops.DHW.effective_system_cop + heat_fuel = cond_sys.Heating.Fuel if cond_sys.Heating is not None else None + cool_fuel = cond_sys.Cooling.Fuel if cond_sys.Cooling is not None else None + dhw_fuel = ops.DHW.FuelType + else: + from epinterface.sbem.flat_model import zone_template_to_zone_component + + ref_ops = zone_template_to_zone_component( + self.zone_assignments.defaults + ).Operations + cond_ref = ref_ops.HVAC.ConditioningSystems + heat_fuel = cond_ref.Heating.Fuel if cond_ref.Heating is not None else None + cool_fuel = cond_ref.Cooling.Fuel if cond_ref.Cooling is not None else None + dhw_fuel = ref_ops.DHW.FuelType + if idf is None: + heat_cop = ( + cond_ref.Heating.effective_system_cop + if cond_ref.Heating is not None + else 1 + ) + cool_cop = ( + cond_ref.Cooling.effective_system_cop + if cond_ref.Cooling is not None + else 1 + ) + dhw_cop = ref_ops.DHW.effective_system_cop + else: + heat_num = 0.0 + cool_num = 0.0 + dhw_num = 0.0 + area_sum = 0.0 + heating_fuels: set[str] = set() + cooling_fuels: set[str] = set() + dhw_fuels: set[str] = set() + for zone in idf.idfobjects["ZONE"]: + area = float(get_zone_floor_area(idf, zone.Name)) + resolved = self.zone_assignments.resolve_zone( + zone.Name, + self.geometry, + attic_use_fraction=self.Attic.UseFraction, + attic_conditioned=self.Attic.Conditioned, + basement_use_fraction=self.Basement.UseFraction, + basement_conditioned=self.Basement.Conditioned, + ) + zc = zone_template_to_zone_component(resolved.params) + cond = zc.Operations.HVAC.ConditioningSystems + heat_num += ( + cond.Heating.effective_system_cop + if cond.Heating is not None + else 1.0 + ) * area + cool_num += ( + cond.Cooling.effective_system_cop + if cond.Cooling is not None + else 1.0 + ) * area + dhw_num += zc.Operations.DHW.effective_system_cop * area + area_sum += area + if cond.Heating is not None: + heating_fuels.add(str(cond.Heating.Fuel)) + if cond.Cooling is not None: + cooling_fuels.add(str(cond.Cooling.Fuel)) + dhw_fuels.add(str(zc.Operations.DHW.FuelType)) + heat_cop = heat_num / area_sum if area_sum else 1.0 + cool_cop = cool_num / area_sum if area_sum else 1.0 + dhw_cop = dhw_num / area_sum if area_sum else 1.0 + + # The building-level energy_and_peak utility mapping assigns a + # single fuel per end use (taken from defaults) and an area-weighted + # COP. That is only exact when every zone shares the same fuel per + # end use. Warn loudly otherwise so callers do not mistake the + # building-level utility split for a true heterogeneous-fuel result. + mixed = { + end_use: sorted(fuels) + for end_use, fuels in ( + ("heating", heating_fuels), + ("cooling", cooling_fuels), + ("DHW", dhw_fuels), + ) + if len(fuels) > 1 + } + if mixed: + warnings.warn( + "Building-level energy_and_peak results are approximate for " + "this heterogeneous model: multiple fuels were resolved across " + f"zones ({mixed}). Utility/fuel mapping uses the default fuel " + "per end use and an area-weighted COP. Use per-zone/per-floor " + "results for fuel-accurate accounting.", + stacklevel=2, + ) all_fuel_names = sorted({*get_args(FuelType), *get_args(DHWFuelType)}) return energy_and_peak_postprocess( sql, @@ -1165,8 +1606,20 @@ def run( msg = f"EnergyPlus version not found in IDF file: {idf.idfobjects['VERSION']}" raise ValueError(msg) results = self.standard_results_postprocess( - sql, ep_version_major=idf.as_version.major + sql, ep_version_major=idf.as_version.major, idf=idf ) + zone_results = None + floor_results = None + if self.zone_assignments is not None: + from epinterface.analysis.zone_energy import ( + floor_energy_summary, + zone_energy_summary, + ) + + zone_results = zone_energy_summary(sql, idf, model=self) + floor_results = floor_energy_summary( + zone_results, n_floors=self.geometry.num_stories + ) zone_weights, zone_names = self.get_zone_weights_and_names(idf) overheating_results = ( @@ -1193,6 +1646,8 @@ def run( err_text=err_text, output_dir=output_dir_result, overheating_results=overheating_results, + zone_results=zone_results, + floor_results=floor_results, ) @staticmethod @@ -1298,6 +1753,8 @@ class ModelRunResults: err_text: str output_dir: Path | None overheating_results: OverheatingAnalysisResults | None = None + zone_results: pd.DataFrame | None = None + floor_results: pd.DataFrame | None = None if __name__ == "__main__": diff --git a/epinterface/sbem/building_flat_model.py b/epinterface/sbem/building_flat_model.py new file mode 100644 index 0000000..89057bc --- /dev/null +++ b/epinterface/sbem/building_flat_model.py @@ -0,0 +1,306 @@ +"""Building-level flat model with typed per-floor configuration.""" + +from __future__ import annotations + +import tempfile +from collections.abc import Callable +from pathlib import Path +from typing import cast + +import pandas as pd +from archetypal import IDF +from pydantic import BaseModel, Field +from pydantic.functional_validators import model_validator + +from epinterface.analysis.overheating import OverheatingAnalysisConfig +from epinterface.geometry import ( + ShoeboxGeometry, + ZoningType, + get_zone_exterior_wall_area, + get_zone_glazed_area, +) +from epinterface.sbem.builder import ( + AtticAssumptions, + BasementAssumptions, + Model, + ModelRunResults, + SimulationPathConfig, +) +from epinterface.sbem.zone_assignment import ( + FloorBand, + PartialZoneTemplate, + ZoneAssignmentResolver, + ZoneTemplate, + roles_for_zoning, +) +from epinterface.weather import WeatherUrl + + +class BuildingShell(BaseModel, extra="forbid"): + """Building-only inputs shared by all floor templates.""" + + EPWURI: WeatherUrl | Path + zoning: ZoningType = "core/perim" + Width: float = Field(gt=0) + Depth: float = Field(gt=0) + F2FHeight: float = Field(gt=0) + NFloors: int = Field(ge=1) + Rotation: float = 0.0 + RoofHeight: float | None = Field(default=None, ge=0) + Basement: bool = False + ExposedBasementFraction: float = Field(default=0.0, ge=0, lt=1) + + def to_geometry(self, *, default_wwr: float) -> ShoeboxGeometry: + """Create shoebox geometry; per-floor WWR is applied after geometry creation.""" + return ShoeboxGeometry( + x=0, + y=0, + w=self.Width, + d=self.Depth, + h=self.F2FHeight, + num_stories=self.NFloors, + zoning=self.zoning, + roof_height=self.RoofHeight, + wwr=default_wwr, + basement=self.Basement, + exposed_basement_frac=self.ExposedBasementFraction, + ) + + +class BuildingFlatModel(BaseModel, extra="forbid"): + """A floor-first flat model for heterogeneous shoebox buildings.""" + + shell: BuildingShell + defaults: ZoneTemplate + floor_bands: list[FloorBand] = Field(default_factory=list) + attic: AtticAssumptions = Field( + default_factory=lambda: AtticAssumptions(UseFraction=None, Conditioned=False) + ) + basement: BasementAssumptions = Field( + default_factory=lambda: BasementAssumptions(UseFraction=None, Conditioned=False) + ) + + @model_validator(mode="after") + def validate_roles(self) -> BuildingFlatModel: + """Reject role overrides that do not exist for the shell zoning strategy.""" + valid_roles = roles_for_zoning(self.shell.zoning) + for band in self.floor_bands: + invalid = set(band.role_overrides).difference(valid_roles) + if invalid: + msg = ( + f"Invalid role override(s) for zoning {self.shell.zoning!r}: " + f"{sorted(role.value for role in invalid)}." + ) + raise ValueError(msg) + return self + + def assignment_resolver(self) -> ZoneAssignmentResolver: + """Return a resolver for this building's floor bands.""" + return ZoneAssignmentResolver( + defaults=self.defaults, + n_floors=self.shell.NFloors, + floor_bands=self.floor_bands, + ) + + def to_model(self) -> tuple[Model, Callable[[IDF], IDF]]: + """Return a runnable Model plus post-geometry rotation callback.""" + geometry = self.shell.to_geometry(default_wwr=self.defaults.WWR) + rotation = self.shell.Rotation + + def post_geometry_callback(idf: IDF) -> IDF: + idf.rotate(rotation) + return idf + + return ( + Model( + geometry=geometry, + Zone=None, + zone_assignments=self.assignment_resolver(), + Attic=self.attic, + Basement=self.basement, + Weather=self.shell.EPWURI, + ), + post_geometry_callback, + ) + + def build_idf( + self, + *, + output_dir: Path | None = None, + weather_dir: Path | None = None, + ) -> IDF: + """Build an IDF without running EnergyPlus.""" + model, callback = self.to_model() + out = ( + Path(tempfile.mkdtemp(prefix="epinterface_floor_build_")) + if output_dir is None + else Path(output_dir) + ) + out.mkdir(parents=True, exist_ok=True) + config = ( + SimulationPathConfig(output_dir=out, weather_dir=weather_dir) + if weather_dir is not None + else SimulationPathConfig(output_dir=out) + ) + return model.build(config, post_geometry_callback=callback) + + def zone_assignment_summary(self, idf: IDF | None = None) -> pd.DataFrame: + """Resolved per-zone inputs, including requested and as-built WWR/areas. + + ``WWR`` is the *requested* window-to-wall ratio from the resolved + template. ``actual_wwr`` is the as-built ratio measured from the IDF + (``actual_glazed_area_m2 / actual_exterior_wall_area_m2``); these differ + for zones with no exterior walls (e.g. ``core`` zones in ``core/perim`` + zoning), where the requested WWR has no physical effect. + """ + model, _ = self.to_model() + idf_built = idf or self.build_idf() + rows = [] + for resolved in self.assignment_resolver().resolved_zone_table( + idf_built, + model.geometry, + attic_use_fraction=self.attic.UseFraction, + attic_conditioned=self.attic.Conditioned, + basement_use_fraction=self.basement.UseFraction, + basement_conditioned=self.basement.Conditioned, + ): + params = resolved.params + glazed_area = float(get_zone_glazed_area(idf_built, resolved.ep_zone_name)) + wall_area = float( + get_zone_exterior_wall_area(idf_built, resolved.ep_zone_name) + ) + actual_wwr = glazed_area / wall_area if wall_area > 0 else 0.0 + rows.append({ + "ep_zone_name": resolved.ep_zone_name, + "ep_storey_index": resolved.ep_storey_index, + "floor_index": resolved.floor_index, + "role": resolved.role.value, + "category": resolved.category, + "floor_area_m2": resolved.floor_area_m2, + "LightingPowerDensity": params.LightingPowerDensity, + "EquipmentPowerDensity": params.EquipmentPowerDensity, + "OccupantDensity": params.OccupantDensity, + "HeatingSetpointBase": params.HeatingSetpointBase, + "SetpointDeadband": params.SetpointDeadband, + "InfiltrationACH": params.InfiltrationACH, + "FacadeRValue": params.FacadeRValue, + "WindowUValue": params.WindowUValue, + "WindowSHGF": params.WindowSHGF, + "WindowTVis": params.WindowTVis, + "WWR": params.WWR, + "actual_glazed_area_m2": glazed_area, + "actual_exterior_wall_area_m2": wall_area, + "actual_wwr": actual_wwr, + "HeatingFuel": params.HeatingFuel, + "CoolingFuel": params.CoolingFuel, + "HeatingSystemCOP": params.HeatingSystemCOP, + "CoolingSystemCOP": params.CoolingSystemCOP, + "DHWFlowRatePerPerson": params.DHWFlowRatePerPerson, + "DHWFuel": params.DHWFuel, + }) + return pd.DataFrame(rows) + + def floor_assignment_summary(self, idf: IDF | None = None) -> pd.DataFrame: + """Floor-area-weighted input summary with exactly NFloors main rows. + + Scalar operating inputs (LPD, EPD, occupancy, setpoints, infiltration, + facade/window) are floor-area-weighted. WWR is reported two ways: + + - ``WWR``: floor-area-weighted *requested* WWR (kept for reference; can be + misleading because it averages over core zones that have no glazing). + - ``actual_wwr``: the as-built ratio summed over exterior walls + (``sum(glazed) / sum(exterior wall)``), which is the physically + meaningful facade glazing ratio for the floor. + """ + zone_summary = self.zone_assignment_summary(idf=idf) + main = zone_summary[zone_summary["category"] == "main"].copy() + if main.empty: + return pd.DataFrame() + + numeric_cols = [ + "LightingPowerDensity", + "EquipmentPowerDensity", + "OccupantDensity", + "InfiltrationACH", + "FacadeRValue", + "WindowUValue", + "WindowSHGF", + "WindowTVis", + "WWR", + ] + rows = [] + for floor_index, group in main.groupby("floor_index", dropna=False): + area = group["floor_area_m2"].astype(float) + total_area = float(area.sum()) + floor_index_int = int(cast(int, floor_index)) + row: dict[str, float | int] = { + "floor_index": floor_index_int, + "floor_area_m2": total_area, + } + for col in numeric_cols: + vals = group[col].astype(float) + row[col] = float((vals * area).sum() / total_area) + glazed = float(group["actual_glazed_area_m2"].astype(float).sum()) + wall = float(group["actual_exterior_wall_area_m2"].astype(float).sum()) + row["actual_glazed_area_m2"] = glazed + row["actual_exterior_wall_area_m2"] = wall + row["actual_wwr"] = glazed / wall if wall > 0 else 0.0 + rows.append(row) + return pd.DataFrame(rows).sort_values("floor_index").reset_index(drop=True) + + def zone_simulation_summary(self, results: ModelRunResults) -> pd.DataFrame: + """Assignment summary joined with simulated zone energy.""" + from epinterface.analysis.zone_energy import ( + merge_assignment_and_energy, + zone_energy_summary, + ) + + assignment = self.zone_assignment_summary(idf=results.idf) + energy = zone_energy_summary(results.sql, results.idf, model=self) + return merge_assignment_and_energy(assignment, energy) + + def simulate( + self, + overheating_config: OverheatingAnalysisConfig | None = None, + eplus_parent_dir: Path | None = None, + ) -> ModelRunResults: + """Run EnergyPlus and return standard plus floor/zone results.""" + model, callback = self.to_model() + return model.run( + post_geometry_callback=callback, + eplus_parent_dir=eplus_parent_dir, + overheating_config=overheating_config, + ) + + @classmethod + def from_uniform_flat_model( + cls, + flat_model, + *, + zoning: ZoningType | None = None, + ) -> BuildingFlatModel: + """Create a BuildingFlatModel from an existing uniform FlatModel. + + ``zoning`` defaults to the source model's own ``zoning`` so the converted + building keeps the same zoning strategy unless explicitly overridden. + """ + template = flat_model.zone_template() + shell = BuildingShell( + EPWURI=flat_model.EPWURI, + zoning=zoning or flat_model.zoning, + Width=flat_model.Width, + Depth=flat_model.Depth, + F2FHeight=flat_model.F2FHeight, + NFloors=flat_model.NFloors, + Rotation=flat_model.Rotation, + ) + return cls(shell=shell, defaults=template) + + +__all__ = [ + "BuildingFlatModel", + "BuildingShell", + "FloorBand", + "PartialZoneTemplate", + "ZoneTemplate", +] diff --git a/epinterface/sbem/flat_model.py b/epinterface/sbem/flat_model.py index b74e125..b29c689 100644 --- a/epinterface/sbem/flat_model.py +++ b/epinterface/sbem/flat_model.py @@ -2,13 +2,15 @@ from collections.abc import Callable from pathlib import Path +from typing import Any from archetypal import IDF from pydantic import BaseModel, Field from epinterface.analysis.overheating import OverheatingAnalysisConfig -from epinterface.geometry import ShoeboxGeometry +from epinterface.geometry import ShoeboxGeometry, ZoningType from epinterface.sbem.builder import AtticAssumptions, BasementAssumptions, Model +from epinterface.sbem.common import NamedObject from epinterface.sbem.components.envelope import ( ConstructionAssemblyComponent, ConstructionLayerComponent, @@ -47,6 +49,7 @@ ZoneHVACComponent, ) from epinterface.sbem.components.zones import ZoneComponent +from epinterface.sbem.zone_assignment import ZoneTemplate from epinterface.weather import WeatherUrl xps_board = ConstructionMaterialComponent( @@ -927,1162 +930,22 @@ class FlatModel(BaseModel): Rotation: float EPWURI: WeatherUrl | Path + zoning: ZoningType = "core/perim" - def to_zone(self) -> ZoneComponent: - """Convert the flat model to a full zone.""" - # occ_regular_workday = DayComponent( - # Name="Occupancy_Regular_Workday", - # Type="Fraction", - # Hour_00=self.OccupancyRegularWeekdayNight, - # Hour_01=self.OccupancyRegularWeekdayNight, - # Hour_02=self.OccupancyRegularWeekdayNight, - # Hour_03=self.OccupancyRegularWeekdayNight, - # Hour_04=self.OccupancyRegularWeekdayNight, - # Hour_05=self.OccupancyRegularWeekdayNight, - # Hour_06=self.OccupancyRegularWeekdayEarlyMorning, - # Hour_07=self.OccupancyRegularWeekdayEarlyMorning, - # Hour_08=self.OccupancyRegularWeekdayEarlyMorning, - # Hour_09=self.OccupancyRegularWeekdayMorning, - # Hour_10=self.OccupancyRegularWeekdayMorning, - # Hour_11=self.OccupancyRegularWeekdayMorning, - # Hour_12=self.OccupancyRegularWeekdayLunch, - # Hour_13=self.OccupancyRegularWeekdayLunch, - # Hour_14=self.OccupancyRegularWeekdayAfternoon, - # Hour_15=self.OccupancyRegularWeekdayAfternoon, - # Hour_16=self.OccupancyRegularWeekdayAfternoon, - # Hour_17=self.OccupancyRegularWeekdayAfternoon, - # Hour_18=self.OccupancyRegularWeekdayEvening, - # Hour_19=self.OccupancyRegularWeekdayEvening, - # Hour_20=self.OccupancyRegularWeekdayEvening, - # Hour_21=self.OccupancyRegularWeekdayNight, - # Hour_22=self.OccupancyRegularWeekdayNight, - # Hour_23=self.OccupancyRegularWeekdayNight, - # ) - - # occ_regular_weekend = DayComponent( - # Name="Occupancy_Regular_Weekend", - # Type="Fraction", - # Hour_00=self.OccupancyRegularWeekendNight, - # Hour_01=self.OccupancyRegularWeekendNight, - # Hour_02=self.OccupancyRegularWeekendNight, - # Hour_03=self.OccupancyRegularWeekendNight, - # Hour_04=self.OccupancyRegularWeekendNight, - # Hour_05=self.OccupancyRegularWeekendNight, - # Hour_06=self.OccupancyRegularWeekendEarlyMorning, - # Hour_07=self.OccupancyRegularWeekendEarlyMorning, - # Hour_08=self.OccupancyRegularWeekendEarlyMorning, - # Hour_09=self.OccupancyRegularWeekendMorning, - # Hour_10=self.OccupancyRegularWeekendMorning, - # Hour_11=self.OccupancyRegularWeekendMorning, - # Hour_12=self.OccupancyRegularWeekendLunch, - # Hour_13=self.OccupancyRegularWeekendLunch, - # Hour_14=self.OccupancyRegularWeekendAfternoon, - # Hour_15=self.OccupancyRegularWeekendAfternoon, - # Hour_16=self.OccupancyRegularWeekendAfternoon, - # Hour_17=self.OccupancyRegularWeekendAfternoon, - # Hour_18=self.OccupancyRegularWeekendEvening, - # Hour_19=self.OccupancyRegularWeekendEvening, - # Hour_20=self.OccupancyRegularWeekendEvening, - # Hour_21=self.OccupancyRegularWeekendNight, - # Hour_22=self.OccupancyRegularWeekendNight, - # Hour_23=self.OccupancyRegularWeekendNight, - # ) - - # occ_summer_workday = DayComponent( - # Name="Occupancy_Summer_Workday", - # Type="Fraction", - # Hour_00=self.OccupancySummerWeekdayNight, - # Hour_01=self.OccupancySummerWeekdayNight, - # Hour_02=self.OccupancySummerWeekdayNight, - # Hour_03=self.OccupancySummerWeekdayNight, - # Hour_04=self.OccupancySummerWeekdayNight, - # Hour_05=self.OccupancySummerWeekdayNight, - # Hour_06=self.OccupancySummerWeekdayEarlyMorning, - # Hour_07=self.OccupancySummerWeekdayEarlyMorning, - # Hour_08=self.OccupancySummerWeekdayEarlyMorning, - # Hour_09=self.OccupancySummerWeekdayMorning, - # Hour_10=self.OccupancySummerWeekdayMorning, - # Hour_11=self.OccupancySummerWeekdayMorning, - # Hour_12=self.OccupancySummerWeekdayLunch, - # Hour_13=self.OccupancySummerWeekdayLunch, - # Hour_14=self.OccupancySummerWeekdayAfternoon, - # Hour_15=self.OccupancySummerWeekdayAfternoon, - # Hour_16=self.OccupancySummerWeekdayAfternoon, - # Hour_17=self.OccupancySummerWeekdayAfternoon, - # Hour_18=self.OccupancySummerWeekdayEvening, - # Hour_19=self.OccupancySummerWeekdayEvening, - # Hour_20=self.OccupancySummerWeekdayEvening, - # Hour_21=self.OccupancySummerWeekdayNight, - # Hour_22=self.OccupancySummerWeekdayNight, - # Hour_23=self.OccupancySummerWeekdayNight, - # ) - - # occ_summer_weekend = DayComponent( - # Name="Occupancy_Summer_Weekend", - # Type="Fraction", - # Hour_00=self.OccupancySummerWeekendNight, - # Hour_01=self.OccupancySummerWeekendNight, - # Hour_02=self.OccupancySummerWeekendNight, - # Hour_03=self.OccupancySummerWeekendNight, - # Hour_04=self.OccupancySummerWeekendNight, - # Hour_05=self.OccupancySummerWeekendNight, - # Hour_06=self.OccupancySummerWeekendEarlyMorning, - # Hour_07=self.OccupancySummerWeekendEarlyMorning, - # Hour_08=self.OccupancySummerWeekendEarlyMorning, - # Hour_09=self.OccupancySummerWeekendMorning, - # Hour_10=self.OccupancySummerWeekendMorning, - # Hour_11=self.OccupancySummerWeekendMorning, - # Hour_12=self.OccupancySummerWeekendLunch, - # Hour_13=self.OccupancySummerWeekendLunch, - # Hour_14=self.OccupancySummerWeekendAfternoon, - # Hour_15=self.OccupancySummerWeekendAfternoon, - # Hour_16=self.OccupancySummerWeekendAfternoon, - # Hour_17=self.OccupancySummerWeekendAfternoon, - # Hour_18=self.OccupancySummerWeekendEvening, - # Hour_19=self.OccupancySummerWeekendEvening, - # Hour_20=self.OccupancySummerWeekendEvening, - # Hour_21=self.OccupancySummerWeekendNight, - # Hour_22=self.OccupancySummerWeekendNight, - # Hour_23=self.OccupancySummerWeekendNight, - # ) - - # occ_regular_week = WeekComponent( - # Name="Occupancy_Regular_Week", - # Monday=occ_regular_workday, - # Tuesday=occ_regular_workday, - # Wednesday=occ_regular_workday, - # Thursday=occ_regular_workday, - # Friday=occ_regular_workday, - # Saturday=occ_regular_weekend, - # Sunday=occ_regular_weekend, - # ) - - # occ_summer_week = WeekComponent( - # Name="Occupancy_Summer_Week", - # Monday=occ_summer_workday, - # Tuesday=occ_summer_workday, - # Wednesday=occ_summer_workday, - # Thursday=occ_summer_workday, - # Friday=occ_summer_workday, - # Saturday=occ_summer_weekend, - # Sunday=occ_summer_weekend, - # ) - - # occ_year = YearComponent( - # Name="Occupancy_Schedule", - # Type="Occupancy", - # January=occ_regular_week, - # February=occ_regular_week, - # March=occ_regular_week, - # April=occ_regular_week, - # May=occ_regular_week, - # June=occ_summer_week, - # July=occ_summer_week, - # August=occ_summer_week, - # September=occ_regular_week, - # October=occ_regular_week, - # November=occ_regular_week, - # December=occ_regular_week, - # ) - - # lighting_regular_workday = DayComponent( - # Name="Lighting_Regular_Workday", - # Type="Fraction", - # Hour_00=self.LightingRegularWeekdayNight, - # Hour_01=self.LightingRegularWeekdayNight, - # Hour_02=self.LightingRegularWeekdayNight, - # Hour_03=self.LightingRegularWeekdayNight, - # Hour_04=self.LightingRegularWeekdayNight, - # Hour_05=self.LightingRegularWeekdayNight, - # Hour_06=self.LightingRegularWeekdayEarlyMorning, - # Hour_07=self.LightingRegularWeekdayEarlyMorning, - # Hour_08=self.LightingRegularWeekdayEarlyMorning, - # Hour_09=self.LightingRegularWeekdayMorning, - # Hour_10=self.LightingRegularWeekdayMorning, - # Hour_11=self.LightingRegularWeekdayMorning, - # Hour_12=self.LightingRegularWeekdayLunch, - # Hour_13=self.LightingRegularWeekdayLunch, - # Hour_14=self.LightingRegularWeekdayAfternoon, - # Hour_15=self.LightingRegularWeekdayAfternoon, - # Hour_16=self.LightingRegularWeekdayAfternoon, - # Hour_17=self.LightingRegularWeekdayAfternoon, - # Hour_18=self.LightingRegularWeekdayEvening, - # Hour_19=self.LightingRegularWeekdayEvening, - # Hour_20=self.LightingRegularWeekdayEvening, - # Hour_21=self.LightingRegularWeekdayNight, - # Hour_22=self.LightingRegularWeekdayNight, - # Hour_23=self.LightingRegularWeekdayNight, - # ) - - # lighting_regular_weekend = DayComponent( - # Name="Lighting_Regular_Weekend", - # Type="Fraction", - # Hour_00=self.LightingRegularWeekendNight, - # Hour_01=self.LightingRegularWeekendNight, - # Hour_02=self.LightingRegularWeekendNight, - # Hour_03=self.LightingRegularWeekendNight, - # Hour_04=self.LightingRegularWeekendNight, - # Hour_05=self.LightingRegularWeekendNight, - # Hour_06=self.LightingRegularWeekendEarlyMorning, - # Hour_07=self.LightingRegularWeekendEarlyMorning, - # Hour_08=self.LightingRegularWeekendEarlyMorning, - # Hour_09=self.LightingRegularWeekendMorning, - # Hour_10=self.LightingRegularWeekendMorning, - # Hour_11=self.LightingRegularWeekendMorning, - # Hour_12=self.LightingRegularWeekendLunch, - # Hour_13=self.LightingRegularWeekendLunch, - # Hour_14=self.LightingRegularWeekendAfternoon, - # Hour_15=self.LightingRegularWeekendAfternoon, - # Hour_16=self.LightingRegularWeekendAfternoon, - # Hour_17=self.LightingRegularWeekendAfternoon, - # Hour_18=self.LightingRegularWeekendEvening, - # Hour_19=self.LightingRegularWeekendEvening, - # Hour_20=self.LightingRegularWeekendEvening, - # Hour_21=self.LightingRegularWeekendNight, - # Hour_22=self.LightingRegularWeekendNight, - # Hour_23=self.LightingRegularWeekendNight, - # ) - - # lighting_summer_workday = DayComponent( - # Name="Lighting_Summer_Workday", - # Type="Fraction", - # Hour_00=self.LightingSummerWeekdayNight, - # Hour_01=self.LightingSummerWeekdayNight, - # Hour_02=self.LightingSummerWeekdayNight, - # Hour_03=self.LightingSummerWeekdayNight, - # Hour_04=self.LightingSummerWeekdayNight, - # Hour_05=self.LightingSummerWeekdayNight, - # Hour_06=self.LightingSummerWeekdayEarlyMorning, - # Hour_07=self.LightingSummerWeekdayEarlyMorning, - # Hour_08=self.LightingSummerWeekdayEarlyMorning, - # Hour_09=self.LightingSummerWeekdayMorning, - # Hour_10=self.LightingSummerWeekdayMorning, - # Hour_11=self.LightingSummerWeekdayMorning, - # Hour_12=self.LightingSummerWeekdayLunch, - # Hour_13=self.LightingSummerWeekdayLunch, - # Hour_14=self.LightingSummerWeekdayAfternoon, - # Hour_15=self.LightingSummerWeekdayAfternoon, - # Hour_16=self.LightingSummerWeekdayAfternoon, - # Hour_17=self.LightingSummerWeekdayAfternoon, - # Hour_18=self.LightingSummerWeekdayEvening, - # Hour_19=self.LightingSummerWeekdayEvening, - # Hour_20=self.LightingSummerWeekdayEvening, - # Hour_21=self.LightingSummerWeekdayNight, - # Hour_22=self.LightingSummerWeekdayNight, - # Hour_23=self.LightingSummerWeekdayNight, - # ) - - # lighting_summer_weekend = DayComponent( - # Name="Lighting_Summer_Weekend", - # Type="Fraction", - # Hour_00=self.LightingSummerWeekendNight, - # Hour_01=self.LightingSummerWeekendNight, - # Hour_02=self.LightingSummerWeekendNight, - # Hour_03=self.LightingSummerWeekendNight, - # Hour_04=self.LightingSummerWeekendNight, - # Hour_05=self.LightingSummerWeekendNight, - # Hour_06=self.LightingSummerWeekendEarlyMorning, - # Hour_07=self.LightingSummerWeekendEarlyMorning, - # Hour_08=self.LightingSummerWeekendEarlyMorning, - # Hour_09=self.LightingSummerWeekendMorning, - # Hour_10=self.LightingSummerWeekendMorning, - # Hour_11=self.LightingSummerWeekendMorning, - # Hour_12=self.LightingSummerWeekendLunch, - # Hour_13=self.LightingSummerWeekendLunch, - # Hour_14=self.LightingSummerWeekendAfternoon, - # Hour_15=self.LightingSummerWeekendAfternoon, - # Hour_16=self.LightingSummerWeekendAfternoon, - # Hour_17=self.LightingSummerWeekendAfternoon, - # Hour_18=self.LightingSummerWeekendEvening, - # Hour_19=self.LightingSummerWeekendEvening, - # Hour_20=self.LightingSummerWeekendEvening, - # Hour_21=self.LightingSummerWeekendNight, - # Hour_22=self.LightingSummerWeekendNight, - # Hour_23=self.LightingSummerWeekendNight, - # ) - - # lighting_regular_week = WeekComponent( - # Name="Lighting_Regular_Week", - # Monday=lighting_regular_workday, - # Tuesday=lighting_regular_workday, - # Wednesday=lighting_regular_workday, - # Thursday=lighting_regular_workday, - # Friday=lighting_regular_workday, - # Saturday=lighting_regular_weekend, - # Sunday=lighting_regular_weekend, - # ) - - # lighting_summer_week = WeekComponent( - # Name="Lighting_Summer_Week", - # Monday=lighting_summer_workday, - # Tuesday=lighting_summer_workday, - # Wednesday=lighting_summer_workday, - # Thursday=lighting_summer_workday, - # Friday=lighting_summer_workday, - # Saturday=lighting_summer_weekend, - # Sunday=lighting_summer_weekend, - # ) - - # lighting_year = YearComponent( - # Name="Lighting_Schedule", - # Type="Lighting", - # January=lighting_regular_week, - # February=lighting_regular_week, - # March=lighting_regular_week, - # April=lighting_regular_week, - # May=lighting_regular_week, - # June=lighting_summer_week, - # July=lighting_summer_week, - # August=lighting_summer_week, - # September=lighting_regular_week, - # October=lighting_regular_week, - # November=lighting_regular_week, - # December=lighting_regular_week, - # ) - - # equipment_regular_workday = DayComponent( - # Name="Equipment_Regular_Workday", - # Type="Fraction", - # Hour_00=self.EquipmentRegularWeekdayNight, - # Hour_01=self.EquipmentRegularWeekdayNight, - # Hour_02=self.EquipmentRegularWeekdayNight, - # Hour_03=self.EquipmentRegularWeekdayNight, - # Hour_04=self.EquipmentRegularWeekdayNight, - # Hour_05=self.EquipmentRegularWeekdayNight, - # Hour_06=self.EquipmentRegularWeekdayEarlyMorning, - # Hour_07=self.EquipmentRegularWeekdayEarlyMorning, - # Hour_08=self.EquipmentRegularWeekdayEarlyMorning, - # Hour_09=self.EquipmentRegularWeekdayMorning, - # Hour_10=self.EquipmentRegularWeekdayMorning, - # Hour_11=self.EquipmentRegularWeekdayMorning, - # Hour_12=self.EquipmentRegularWeekdayLunch, - # Hour_13=self.EquipmentRegularWeekdayLunch, - # Hour_14=self.EquipmentRegularWeekdayAfternoon, - # Hour_15=self.EquipmentRegularWeekdayAfternoon, - # Hour_16=self.EquipmentRegularWeekdayAfternoon, - # Hour_17=self.EquipmentRegularWeekdayAfternoon, - # Hour_18=self.EquipmentRegularWeekdayEvening, - # Hour_19=self.EquipmentRegularWeekdayEvening, - # Hour_20=self.EquipmentRegularWeekdayEvening, - # Hour_21=self.EquipmentRegularWeekdayNight, - # Hour_22=self.EquipmentRegularWeekdayNight, - # Hour_23=self.EquipmentRegularWeekdayNight, - # ) - - # equipment_regular_weekend = DayComponent( - # Name="Equipment_Regular_Weekend", - # Type="Fraction", - # Hour_00=self.EquipmentRegularWeekendNight, - # Hour_01=self.EquipmentRegularWeekendNight, - # Hour_02=self.EquipmentRegularWeekendNight, - # Hour_03=self.EquipmentRegularWeekendNight, - # Hour_04=self.EquipmentRegularWeekendNight, - # Hour_05=self.EquipmentRegularWeekendNight, - # Hour_06=self.EquipmentRegularWeekendEarlyMorning, - # Hour_07=self.EquipmentRegularWeekendEarlyMorning, - # Hour_08=self.EquipmentRegularWeekendEarlyMorning, - # Hour_09=self.EquipmentRegularWeekendMorning, - # Hour_10=self.EquipmentRegularWeekendMorning, - # Hour_11=self.EquipmentRegularWeekendMorning, - # Hour_12=self.EquipmentRegularWeekendLunch, - # Hour_13=self.EquipmentRegularWeekendLunch, - # Hour_14=self.EquipmentRegularWeekendAfternoon, - # Hour_15=self.EquipmentRegularWeekendAfternoon, - # Hour_16=self.EquipmentRegularWeekendAfternoon, - # Hour_17=self.EquipmentRegularWeekendAfternoon, - # Hour_18=self.EquipmentRegularWeekendEvening, - # Hour_19=self.EquipmentRegularWeekendEvening, - # Hour_20=self.EquipmentRegularWeekendEvening, - # Hour_21=self.EquipmentRegularWeekendNight, - # Hour_22=self.EquipmentRegularWeekendNight, - # Hour_23=self.EquipmentRegularWeekendNight, - # ) - - # equipment_summer_workday = DayComponent( - # Name="Equipment_Summer_Workday", - # Type="Fraction", - # Hour_00=self.EquipmentSummerWeekdayNight, - # Hour_01=self.EquipmentSummerWeekdayNight, - # Hour_02=self.EquipmentSummerWeekdayNight, - # Hour_03=self.EquipmentSummerWeekdayNight, - # Hour_04=self.EquipmentSummerWeekdayNight, - # Hour_05=self.EquipmentSummerWeekdayNight, - # Hour_06=self.EquipmentSummerWeekdayEarlyMorning, - # Hour_07=self.EquipmentSummerWeekdayEarlyMorning, - # Hour_08=self.EquipmentSummerWeekdayEarlyMorning, - # Hour_09=self.EquipmentSummerWeekdayMorning, - # Hour_10=self.EquipmentSummerWeekdayMorning, - # Hour_11=self.EquipmentSummerWeekdayMorning, - # Hour_12=self.EquipmentSummerWeekdayLunch, - # Hour_13=self.EquipmentSummerWeekdayLunch, - # Hour_14=self.EquipmentSummerWeekdayAfternoon, - # Hour_15=self.EquipmentSummerWeekdayAfternoon, - # Hour_16=self.EquipmentSummerWeekdayAfternoon, - # Hour_17=self.EquipmentSummerWeekdayAfternoon, - # Hour_18=self.EquipmentSummerWeekdayEvening, - # Hour_19=self.EquipmentSummerWeekdayEvening, - # Hour_20=self.EquipmentSummerWeekdayEvening, - # Hour_21=self.EquipmentSummerWeekdayNight, - # Hour_22=self.EquipmentSummerWeekdayNight, - # Hour_23=self.EquipmentSummerWeekdayNight, - # ) - - # equipment_summer_weekend = DayComponent( - # Name="Equipment_Summer_Weekend", - # Type="Fraction", - # Hour_00=self.EquipmentSummerWeekendNight, - # Hour_01=self.EquipmentSummerWeekendNight, - # Hour_02=self.EquipmentSummerWeekendNight, - # Hour_03=self.EquipmentSummerWeekendNight, - # Hour_04=self.EquipmentSummerWeekendNight, - # Hour_05=self.EquipmentSummerWeekendNight, - # Hour_06=self.EquipmentSummerWeekendEarlyMorning, - # Hour_07=self.EquipmentSummerWeekendEarlyMorning, - # Hour_08=self.EquipmentSummerWeekendEarlyMorning, - # Hour_09=self.EquipmentSummerWeekendMorning, - # Hour_10=self.EquipmentSummerWeekendMorning, - # Hour_11=self.EquipmentSummerWeekendMorning, - # Hour_12=self.EquipmentSummerWeekendLunch, - # Hour_13=self.EquipmentSummerWeekendLunch, - # Hour_14=self.EquipmentSummerWeekendAfternoon, - # Hour_15=self.EquipmentSummerWeekendAfternoon, - # Hour_16=self.EquipmentSummerWeekendAfternoon, - # Hour_17=self.EquipmentSummerWeekendAfternoon, - # Hour_18=self.EquipmentSummerWeekendEvening, - # Hour_19=self.EquipmentSummerWeekendEvening, - # Hour_20=self.EquipmentSummerWeekendEvening, - # Hour_21=self.EquipmentSummerWeekendNight, - # Hour_22=self.EquipmentSummerWeekendNight, - # Hour_23=self.EquipmentSummerWeekendNight, - # ) - - # equipment_regular_week = WeekComponent( - # Name="Equipment_Regular_Week", - # Monday=equipment_regular_workday, - # Tuesday=equipment_regular_workday, - # Wednesday=equipment_regular_workday, - # Thursday=equipment_regular_workday, - # Friday=equipment_regular_workday, - # Saturday=equipment_regular_weekend, - # Sunday=equipment_regular_weekend, - # ) - - # equipment_summer_week = WeekComponent( - # Name="Equipment_Summer_Week", - # Monday=equipment_summer_workday, - # Tuesday=equipment_summer_workday, - # Wednesday=equipment_summer_workday, - # Thursday=equipment_summer_workday, - # Friday=equipment_summer_workday, - # Saturday=equipment_summer_weekend, - # Sunday=equipment_summer_weekend, - # ) - - # equipment_year = YearComponent( - # Name="equipment_Schedule", - # Type="Equipment", - # January=equipment_regular_week, - # February=equipment_regular_week, - # March=equipment_regular_week, - # April=equipment_regular_week, - # May=equipment_regular_week, - # June=equipment_summer_week, - # July=equipment_summer_week, - # August=equipment_summer_week, - # September=equipment_regular_week, - # October=equipment_regular_week, - # November=lighting_regular_week, - # December=equipment_regular_week, - # ) - - equipment_paramteric = ParametericYear( - Base=self.EquipmentBase, - AMInterp=self.EquipmentAMInterp, - LunchInterp=self.EquipmentLunchInterp, - PMInterp=self.EquipmentPMInterp, - WeekendPeakInterp=self.EquipmentWeekendPeakInterp, - SummerPeakInterp=self.EquipmentSummerPeakInterp, - ) - - lighting_paramteric = ParametericYear( - Base=self.LightingBase, - AMInterp=self.LightingAMInterp, - LunchInterp=self.LightingLunchInterp, - PMInterp=self.LightingPMInterp, - WeekendPeakInterp=self.LightingWeekendPeakInterp, - SummerPeakInterp=self.LightingSummerPeakInterp, - ) - - occupancy_paramteric = ParametericYear( - Base=self.OccupancyBase, - AMInterp=self.OccupancyAMInterp, - LunchInterp=self.OccupancyLunchInterp, - PMInterp=self.OccupancyPMInterp, - WeekendPeakInterp=self.OccupancyWeekendPeakInterp, - SummerPeakInterp=self.OccupancySummerPeakInterp, - ) - - equipment_schedule = equipment_paramteric.to_schedule( - name="Equipment", category="Equipment" - ) - lighting_schedule = lighting_paramteric.to_schedule( - name="Lighting", category="Lighting" - ) - occupancy_schedule = occupancy_paramteric.to_schedule( - name="Occupancy", category="Occupancy" - ) - - # hsp_regular_workday = DayComponent( - # Name="HeatingSetpoint_Regular_Workday", - # Type="Temperature", - # Hour_00=self.HSPRegularWeekdayNight, - # Hour_01=self.HSPRegularWeekdayNight, - # Hour_02=self.HSPRegularWeekdayNight, - # Hour_03=self.HSPRegularWeekdayNight, - # Hour_04=self.HSPRegularWeekdayNight, - # Hour_05=self.HSPRegularWeekdayNight, - # Hour_06=self.HSPRegularWeekdayWorkhours, - # Hour_07=self.HSPRegularWeekdayWorkhours, - # Hour_08=self.HSPRegularWeekdayWorkhours, - # Hour_09=self.HSPRegularWeekdayWorkhours, - # Hour_10=self.HSPRegularWeekdayWorkhours, - # Hour_11=self.HSPRegularWeekdayWorkhours, - # Hour_12=self.HSPRegularWeekdayWorkhours, - # Hour_13=self.HSPRegularWeekdayWorkhours, - # Hour_14=self.HSPRegularWeekdayWorkhours, - # Hour_15=self.HSPRegularWeekdayWorkhours, - # Hour_16=self.HSPRegularWeekdayWorkhours, - # Hour_17=self.HSPRegularWeekdayWorkhours, - # Hour_18=self.HSPRegularWeekdayWorkhours, - # Hour_19=self.HSPRegularWeekdayNight, - # Hour_20=self.HSPRegularWeekdayNight, - # Hour_21=self.HSPRegularWeekdayNight, - # Hour_22=self.HSPRegularWeekdayNight, - # Hour_23=self.HSPRegularWeekdayNight, - # ) - - # hsp_regular_weekend = DayComponent( - # Name="HeatingSetpoint_Regular_Weekend", - # Type="Temperature", - # Hour_00=self.HSPWeekendNight, - # Hour_01=self.HSPWeekendNight, - # Hour_02=self.HSPWeekendNight, - # Hour_03=self.HSPWeekendNight, - # Hour_04=self.HSPWeekendNight, - # Hour_05=self.HSPWeekendNight, - # Hour_06=self.HSPWeekendWorkhours, - # Hour_07=self.HSPWeekendWorkhours, - # Hour_08=self.HSPWeekendWorkhours, - # Hour_09=self.HSPWeekendWorkhours, - # Hour_10=self.HSPWeekendWorkhours, - # Hour_11=self.HSPWeekendWorkhours, - # Hour_12=self.HSPWeekendWorkhours, - # Hour_13=self.HSPWeekendWorkhours, - # Hour_14=self.HSPWeekendWorkhours, - # Hour_15=self.HSPWeekendWorkhours, - # Hour_16=self.HSPWeekendWorkhours, - # Hour_17=self.HSPWeekendWorkhours, - # Hour_18=self.HSPWeekendWorkhours, - # Hour_19=self.HSPWeekendNight, - # Hour_20=self.HSPWeekendNight, - # Hour_21=self.HSPWeekendNight, - # Hour_22=self.HSPWeekendNight, - # Hour_23=self.HSPWeekendNight, - # ) - - # hsp_summer_workday = DayComponent( - # Name="HeatingSetpoint_Summer_Workday", - # Type="Temperature", - # Hour_00=self.HSPSummerWeekdayNight, - # Hour_01=self.HSPSummerWeekdayNight, - # Hour_02=self.HSPSummerWeekdayNight, - # Hour_03=self.HSPSummerWeekdayNight, - # Hour_04=self.HSPSummerWeekdayNight, - # Hour_05=self.HSPSummerWeekdayNight, - # Hour_06=self.HSPSummerWeekdayWorkhours, - # Hour_07=self.HSPSummerWeekdayWorkhours, - # Hour_08=self.HSPSummerWeekdayWorkhours, - # Hour_09=self.HSPSummerWeekdayWorkhours, - # Hour_10=self.HSPSummerWeekdayWorkhours, - # Hour_11=self.HSPSummerWeekdayWorkhours, - # Hour_12=self.HSPSummerWeekdayWorkhours, - # Hour_13=self.HSPSummerWeekdayWorkhours, - # Hour_14=self.HSPSummerWeekdayWorkhours, - # Hour_15=self.HSPSummerWeekdayWorkhours, - # Hour_16=self.HSPSummerWeekdayWorkhours, - # Hour_17=self.HSPSummerWeekdayWorkhours, - # Hour_18=self.HSPSummerWeekdayWorkhours, - # Hour_19=self.HSPSummerWeekdayNight, - # Hour_20=self.HSPSummerWeekdayNight, - # Hour_21=self.HSPSummerWeekdayNight, - # Hour_22=self.HSPSummerWeekdayNight, - # Hour_23=self.HSPSummerWeekdayNight, - # ) - - # hsp_regular_week = WeekComponent( - # Name="HeatingSetpoint_Regular_Week", - # Monday=hsp_regular_workday, - # Tuesday=hsp_regular_workday, - # Wednesday=hsp_regular_workday, - # Thursday=hsp_regular_workday, - # Friday=hsp_regular_workday, - # Saturday=hsp_regular_weekend, - # Sunday=hsp_regular_weekend, - # ) - - # hsp_summer_week = WeekComponent( - # Name="HeatingSetpoint_Summer_Week", - # Monday=hsp_summer_workday, - # Tuesday=hsp_summer_workday, - # Wednesday=hsp_summer_workday, - # Thursday=hsp_summer_workday, - # Friday=hsp_summer_workday, - # Saturday=hsp_regular_weekend, - # Sunday=hsp_regular_weekend, - # ) - - # hsp_year = YearComponent( - # Name="HeatingSetpoint_Schedule", - # Type="Setpoint", - # January=hsp_regular_week, - # February=hsp_regular_week, - # March=hsp_regular_week, - # April=hsp_regular_week, - # May=hsp_regular_week, - # June=hsp_summer_week, - # July=hsp_summer_week, - # August=hsp_summer_week, - # September=hsp_regular_week, - # October=hsp_regular_week, - # November=hsp_regular_week, - # December=hsp_regular_week, - # ) - - # csp_regular_workday = DayComponent( - # Name="CoolingSetpoint_Regular_Workday", - # Type="Temperature", - # Hour_00=self.CSPRegularWeekdayNight, - # Hour_01=self.CSPRegularWeekdayNight, - # Hour_02=self.CSPRegularWeekdayNight, - # Hour_03=self.CSPRegularWeekdayNight, - # Hour_04=self.CSPRegularWeekdayNight, - # Hour_05=self.CSPRegularWeekdayNight, - # Hour_06=self.CSPRegularWeekdayWorkhours, - # Hour_07=self.CSPRegularWeekdayWorkhours, - # Hour_08=self.CSPRegularWeekdayWorkhours, - # Hour_09=self.CSPRegularWeekdayWorkhours, - # Hour_10=self.CSPRegularWeekdayWorkhours, - # Hour_11=self.CSPRegularWeekdayWorkhours, - # Hour_12=self.CSPRegularWeekdayWorkhours, - # Hour_13=self.CSPRegularWeekdayWorkhours, - # Hour_14=self.CSPRegularWeekdayWorkhours, - # Hour_15=self.CSPRegularWeekdayWorkhours, - # Hour_16=self.CSPRegularWeekdayWorkhours, - # Hour_17=self.CSPRegularWeekdayWorkhours, - # Hour_18=self.CSPRegularWeekdayWorkhours, - # Hour_19=self.CSPRegularWeekdayNight, - # Hour_20=self.CSPRegularWeekdayNight, - # Hour_21=self.CSPRegularWeekdayNight, - # Hour_22=self.CSPRegularWeekdayNight, - # Hour_23=self.CSPRegularWeekdayNight, - # ) - - # csp_regular_weekend = DayComponent( - # Name="CoolingSetpoint_Regular_Weekend", - # Type="Temperature", - # Hour_00=self.CSPWeekendNight, - # Hour_01=self.CSPWeekendNight, - # Hour_02=self.CSPWeekendNight, - # Hour_03=self.CSPWeekendNight, - # Hour_04=self.CSPWeekendNight, - # Hour_05=self.CSPWeekendNight, - # Hour_06=self.CSPWeekendWorkhours, - # Hour_07=self.CSPWeekendWorkhours, - # Hour_08=self.CSPWeekendWorkhours, - # Hour_09=self.CSPWeekendWorkhours, - # Hour_10=self.CSPWeekendWorkhours, - # Hour_11=self.CSPWeekendWorkhours, - # Hour_12=self.CSPWeekendWorkhours, - # Hour_13=self.CSPWeekendWorkhours, - # Hour_14=self.CSPWeekendWorkhours, - # Hour_15=self.CSPWeekendWorkhours, - # Hour_16=self.CSPWeekendWorkhours, - # Hour_17=self.CSPWeekendWorkhours, - # Hour_18=self.CSPWeekendWorkhours, - # Hour_19=self.CSPWeekendNight, - # Hour_20=self.CSPWeekendNight, - # Hour_21=self.CSPWeekendNight, - # Hour_22=self.CSPWeekendNight, - # Hour_23=self.CSPWeekendNight, - # ) - - # csp_summer_workday = DayComponent( - # Name="CoolingSetpoint_Summer_Workday", - # Type="Temperature", - # Hour_00=self.CSPSummerWeekdayNight, - # Hour_01=self.CSPSummerWeekdayNight, - # Hour_02=self.CSPSummerWeekdayNight, - # Hour_03=self.CSPSummerWeekdayNight, - # Hour_04=self.CSPSummerWeekdayNight, - # Hour_05=self.CSPSummerWeekdayNight, - # Hour_06=self.CSPSummerWeekdayWorkhours, - # Hour_07=self.CSPSummerWeekdayWorkhours, - # Hour_08=self.CSPSummerWeekdayWorkhours, - # Hour_09=self.CSPSummerWeekdayWorkhours, - # Hour_10=self.CSPSummerWeekdayWorkhours, - # Hour_11=self.CSPSummerWeekdayWorkhours, - # Hour_12=self.CSPSummerWeekdayWorkhours, - # Hour_13=self.CSPSummerWeekdayWorkhours, - # Hour_14=self.CSPSummerWeekdayWorkhours, - # Hour_15=self.CSPSummerWeekdayWorkhours, - # Hour_16=self.CSPSummerWeekdayWorkhours, - # Hour_17=self.CSPSummerWeekdayWorkhours, - # Hour_18=self.CSPSummerWeekdayWorkhours, - # Hour_19=self.CSPSummerWeekdayNight, - # Hour_20=self.CSPSummerWeekdayNight, - # Hour_21=self.CSPSummerWeekdayNight, - # Hour_22=self.CSPSummerWeekdayNight, - # Hour_23=self.CSPSummerWeekdayNight, - # ) - - # csp_regular_week = WeekComponent( - # Name="CoolingSetpoint_Regular_Week", - # Monday=csp_regular_workday, - # Tuesday=csp_regular_workday, - # Wednesday=csp_regular_workday, - # Thursday=csp_regular_workday, - # Friday=csp_regular_workday, - # Saturday=csp_regular_weekend, - # Sunday=csp_regular_weekend, - # ) - - # csp_summer_week = WeekComponent( - # Name="CoolingSetpoint_Summer_Week", - # Monday=csp_summer_workday, - # Tuesday=csp_summer_workday, - # Wednesday=csp_summer_workday, - # Thursday=csp_summer_workday, - # Friday=csp_summer_workday, - # Saturday=csp_regular_weekend, - # Sunday=csp_regular_weekend, - # ) - - # csp_year = YearComponent( - # Name="CoolingSetpoint_Schedule", - # Type="Setpoint", - # January=csp_regular_week, - # February=csp_regular_week, - # March=csp_regular_week, - # April=csp_regular_week, - # May=csp_regular_week, - # June=csp_summer_week, - # July=csp_summer_week, - # August=csp_summer_week, - # September=csp_regular_week, - # October=csp_regular_week, - # November=csp_regular_week, - # December=csp_regular_week, - # ) - - setpoint_parametric = ParametricSetpoints( - HeatingSetpoint=self.HeatingSetpointBase, - DeadBand=self.SetpointDeadband, - HeatingSetback=self.HeatingSetpointSetback, - CoolingSetback=self.CoolingSetpointSetback, - NightSetback=self.NightSetback, - WeekendSetback=self.WeekendSetback, - SummerSetback=self.SummerSetback, - ) - - hsp_year, csp_year = setpoint_parametric.to_schedules() - - thermostat = ThermostatComponent( - Name="Thermostat", - IsOn=True, - HeatingSetpoint=hsp_year.January.Monday.Hour_12, - CoolingSetpoint=csp_year.January.Monday.Hour_12, - HeatingSchedule=hsp_year, - CoolingSchedule=csp_year, - ) - - equipment = EquipmentComponent( - Name="Equipment", - PowerDensity=self.EquipmentPowerDensity, - Schedule=equipment_schedule, - IsOn=True, - ) - - lighting = LightingComponent( - Name="Lighting", - PowerDensity=self.LightingPowerDensity, - Schedule=lighting_schedule, - IsOn=True, - DimmingType="Off", - ) - - occupancy = OccupancyComponent( - Name="Occupancy", - PeopleDensity=self.OccupantDensity, - Schedule=occupancy_schedule, - IsOn=True, - ) - - water_use = WaterUseComponent( - Name="WaterUse", - FlowRatePerPerson=self.DHWFlowRatePerPerson, - Schedule=occupancy_schedule, - ) - - space_use = ZoneSpaceUseComponent( - Name="SpaceUse", - Occupancy=occupancy, - Lighting=lighting, - Equipment=equipment, - Thermostat=thermostat, - WaterUse=water_use, - ) - - heating_system = ThermalSystemComponent( - Name="HeatingSystem", - ConditioningType="Heating", - Fuel=self.HeatingFuel, - SystemCOP=self.HeatingSystemCOP, - DistributionCOP=self.HeatingDistributionCOP, - ) - - cooling_system = ThermalSystemComponent( - Name="CoolingSystem", - ConditioningType="Cooling", - Fuel=self.CoolingFuel, - SystemCOP=self.CoolingSystemCOP, - DistributionCOP=self.CoolingDistributionCOP, - ) - - conditioning_system = ConditioningSystemsComponent( - Name="ConditioningSystem", - Heating=heating_system, - Cooling=cooling_system, - ) - - all_off_day = DayComponent( - Name="AllOffDayComponent", - Type="Fraction", - Hour_00=0.0, - Hour_01=0.0, - Hour_02=0.0, - Hour_03=0.0, - Hour_04=0.0, - Hour_05=0.0, - Hour_06=0.0, - Hour_07=0.0, - Hour_08=0.0, - Hour_09=0.0, - Hour_10=0.0, - Hour_11=0.0, - Hour_12=0.0, - Hour_13=0.0, - Hour_14=0.0, - Hour_15=0.0, - Hour_16=0.0, - Hour_17=0.0, - Hour_18=0.0, - Hour_19=0.0, - Hour_20=0.0, - Hour_21=0.0, - Hour_22=0.0, - Hour_23=0.0, - ) - - all_off_week = WeekComponent( - Name="AllOffWeekComponent", - Monday=all_off_day, - Tuesday=all_off_day, - Wednesday=all_off_day, - Thursday=all_off_day, - Friday=all_off_day, - Saturday=all_off_day, - Sunday=all_off_day, - ) - - all_off_year = YearComponent( - Name="AllOffYearComponent", - Type="Window", - January=all_off_week, - February=all_off_week, - March=all_off_week, - April=all_off_week, - May=all_off_week, - June=all_off_week, - July=all_off_week, - August=all_off_week, - September=all_off_week, - October=all_off_week, - November=all_off_week, - December=all_off_week, - ) - - ventilation_system = VentilationComponent( - Name="VentilationSystem", - FreshAirPerFloorArea=self.VentFlowRatePerArea, - FreshAirPerPerson=self.VentFlowRatePerPerson, - Provider=self.VentProvider, - # TODO: should hrv sensible/latent efficiency be configurable? (e.g. high/medium/low) - HRV=self.VentHRV, - Economizer=self.VentEconomizer, - DCV=self.VentDCV, - # TODO: this controls natvnent and should - # be configurable for residential models - Schedule=all_off_year, - ) - - hvac = ZoneHVACComponent( - Name="HVAC", - ConditioningSystems=conditioning_system, - Ventilation=ventilation_system, - ) - - dhw = DHWComponent( - Name="DHW", - SystemCOP=self.DHWSystemCOP, - DistributionCOP=self.DHWDistributionCOP, - # TODO: should these be configurable? - WaterTemperatureInlet=10, - WaterSupplyTemperature=55, - IsOn=True, - FuelType=self.DHWFuel, - ) - - operations = ZoneOperationsComponent( - Name="Operations", - SpaceUse=space_use, - HVAC=hvac, - DHW=dhw, - ) - - window_assembly = GlazingConstructionSimpleComponent( - Name="WindowAssembly", - UValue=self.WindowUValue, - SHGF=self.WindowSHGF, - TVis=self.WindowTVis, - Type="Single", - ) - - infiltration = InfiltrationComponent( - Name="Infiltration", - IsOn=True, - CalculationMethod="AirChanges/Hour", - AirChangesPerHour=self.InfiltrationACH, - ConstantCoefficient=0.0, - TemperatureCoefficient=0.0, - WindVelocityCoefficient=0.0, - WindVelocitySquaredCoefficient=0.0, - AFNAirMassFlowCoefficientCrack=0.0, - FlowPerExteriorSurfaceArea=0.0, - ) - - # TODO: verify interior/exterior - # TODO: are we okaky with mass assumptions? - facade = ConstructionAssemblyComponent( - Name="Facade", - Type="Facade", - Layers=[ - ConstructionLayerComponent( - ConstructionMaterial=clay_brick, - Thickness=0.002, - LayerOrder=0, - ), - ConstructionLayerComponent( - ConstructionMaterial=concrete_block_h, - Thickness=0.15, - LayerOrder=1, - ), - ConstructionLayerComponent( - ConstructionMaterial=fiberglass_batts, - Thickness=0.05, - LayerOrder=2, - ), - ConstructionLayerComponent( - ConstructionMaterial=gypsum_board, - Thickness=0.015, - LayerOrder=3, - ), - ], - ) - - facade_r_value_without_fiberglass = ( - facade.r_value - facade.sorted_layers[2].r_value - ) - - facade_r_value_delta = self.FacadeRValue - facade_r_value_without_fiberglass - required_fiberglass_thickness = ( - fiberglass_batts.Conductivity * facade_r_value_delta - ) - - if required_fiberglass_thickness < 0.003: - msg = f"Required Facade Fiberglass thickness is less than 3mm because the desired total facade R-value is {self.FacadeRValue} m²K/W but the concrete and gypsum layers already have a total R-value of {facade_r_value_without_fiberglass} m²K/W." - raise ValueError(msg) - - facade.sorted_layers[2].Thickness = required_fiberglass_thickness - - roof = ConstructionAssemblyComponent( - Name="Roof", - Type="FlatRoof", - Layers=[ - ConstructionLayerComponent( - ConstructionMaterial=xps_board, - Thickness=0.1, - LayerOrder=0, - ), - ConstructionLayerComponent( - ConstructionMaterial=concrete_mc_light, - Thickness=0.15, - LayerOrder=1, - ), - ConstructionLayerComponent( - ConstructionMaterial=concrete_rc_dense, - Thickness=0.2, - LayerOrder=2, - ), - ConstructionLayerComponent( - ConstructionMaterial=gypsum_board, - Thickness=0.02, - LayerOrder=3, - ), - ], - ) - - roof_r_value_without_xps = roof.r_value - roof.sorted_layers[0].r_value - roof_r_value_delta = self.RoofRValue - roof_r_value_without_xps - required_xps_thickness = xps_board.Conductivity * roof_r_value_delta - if required_xps_thickness < 0.003: - msg = f"Required Roof XPS thickness is less than 3mm because the desired total roof R-value is {self.RoofRValue} m²K/W but the concrete layers already have a total R-value of {roof_r_value_without_xps} m²K/W." - raise ValueError(msg) - - roof.sorted_layers[0].Thickness = required_xps_thickness - - partition = ConstructionAssemblyComponent( - Name="Partition", - Type="Partition", - Layers=[ - ConstructionLayerComponent( - ConstructionMaterial=gypsum_plaster, - Thickness=0.02, - LayerOrder=0, - ), - ConstructionLayerComponent( - ConstructionMaterial=softwood_general, - Thickness=0.02, - LayerOrder=1, - ), - ConstructionLayerComponent( - ConstructionMaterial=gypsum_plaster, - Thickness=0.02, - LayerOrder=2, - ), - ], - ) - - floor_ceiling = ConstructionAssemblyComponent( - Name="FloorCeiling", - Type="FloorCeiling", - Layers=[ - ConstructionLayerComponent( - ConstructionMaterial=urethane_carpet, - Thickness=0.02, - LayerOrder=0, - ), - ConstructionLayerComponent( - ConstructionMaterial=cement_mortar, - Thickness=0.02, - LayerOrder=1, - ), - ConstructionLayerComponent( - ConstructionMaterial=concrete_rc_dense, - Thickness=0.15, - LayerOrder=2, - ), - ConstructionLayerComponent( - ConstructionMaterial=gypsum_board, - Thickness=0.02, - LayerOrder=3, - ), - ], - ) - - ground_slab_assembly = ConstructionAssemblyComponent( - Name="GroundSlabAssembly", - Type="GroundSlab", - Layers=[ - ConstructionLayerComponent( - ConstructionMaterial=xps_board, - Thickness=0.02, - LayerOrder=0, - ), - ConstructionLayerComponent( - ConstructionMaterial=concrete_rc_dense, - Thickness=0.15, - LayerOrder=1, - ), - ConstructionLayerComponent( - ConstructionMaterial=concrete_mc_light, - Thickness=0.04, - LayerOrder=2, - ), - ConstructionLayerComponent( - ConstructionMaterial=cement_mortar, - Thickness=0.03, - LayerOrder=3, - ), - ConstructionLayerComponent( - ConstructionMaterial=ceramic_tile, - Thickness=0.02, - LayerOrder=4, - ), - ], - ) - - ground_slab_r_value_without_xps = ( - ground_slab_assembly.r_value - ground_slab_assembly.sorted_layers[0].r_value - ) - ground_slab_r_value_delta = self.SlabRValue - ground_slab_r_value_without_xps - required_xps_thickness = xps_board.Conductivity * ground_slab_r_value_delta - if required_xps_thickness < 0.003: - msg = f"Required Ground Slab XPS thickness is less than 3mm because the desired total slab R-value is {self.SlabRValue} m²K/W but the concrete layers already have a total R-value of {ground_slab_r_value_without_xps} m²K/W." - raise ValueError(msg) - - ground_slab_assembly.sorted_layers[0].Thickness = required_xps_thickness - - assemblies = EnvelopeAssemblyComponent( - Name="EnvelopeAssemblies", - FacadeAssembly=facade, - FlatRoofAssembly=roof, - AtticRoofAssembly=roof, - PartitionAssembly=partition, - FloorCeilingAssembly=floor_ceiling, - AtticFloorAssembly=floor_ceiling, - BasementCeilingAssembly=floor_ceiling, - GroundSlabAssembly=ground_slab_assembly, - GroundWallAssembly=ground_slab_assembly, - ExternalFloorAssembly=ground_slab_assembly, - ) - - basement_infiltration = infiltration.model_copy(deep=True) - envelope = ZoneEnvelopeComponent( - Name="Envelope", - AtticInfiltration=infiltration, - BasementInfiltration=basement_infiltration, - Window=window_assembly, - Infiltration=infiltration, - Assemblies=assemblies, - ) + def zone_template(self) -> ZoneTemplate: + """Return the shell-free zone template fields from this flat model. - zone = ZoneComponent( - Name="Zone", - Operations=operations, - Envelope=envelope, + ``FlatModel`` carries shell-only fields (geometry, weather, zoning) that + are not part of ``ZoneTemplate``. ``ZoneTemplate`` forbids extras, so we + must project onto its fields explicitly rather than dumping everything. + """ + return ZoneTemplate.model_validate( + self.model_dump(include=set(ZoneTemplate.model_fields)) ) - return zone + def to_zone(self) -> ZoneComponent: + """Convert the flat model to a full zone.""" + return zone_template_to_zone_component(self.zone_template()) def to_model(self) -> tuple[Model, Callable[[IDF], IDF]]: """Returns a tuple of a Model and a post-geometry callback.""" @@ -2096,7 +959,7 @@ def to_model(self) -> tuple[Model, Callable[[IDF], IDF]]: h=self.F2FHeight, num_stories=self.NFloors, # TODO: should core/perim be dependent on width, depth > 9m? - zoning="core/perim", + zoning=self.zoning, roof_height=None, wwr=self.WWR, basement=False, @@ -2139,6 +1002,481 @@ def simulate( return r + def to_building_model(self): + """Convert a uniform legacy flat model to the floor-aware building model.""" + from epinterface.sbem.building_flat_model import BuildingFlatModel + + return BuildingFlatModel.from_uniform_flat_model(self, zoning=self.zoning) + + +def _suffix_named_objects(value: Any, suffix: str, seen: set[int]) -> None: + """Suffix component object names recursively, skipping shared material definitions.""" + obj_id = id(value) + if obj_id in seen: + return + seen.add(obj_id) + + if isinstance(value, ConstructionMaterialComponent): + return + + if isinstance(value, NamedObject): + value.Name = f"{value.Name}_{suffix}" + + if isinstance(value, BaseModel): + for child in value.__dict__.values(): + _suffix_named_objects(child, suffix, seen) + elif isinstance(value, dict): + for child in value.values(): + _suffix_named_objects(child, suffix, seen) + elif isinstance(value, list | tuple | set): + for child in value: + _suffix_named_objects(child, suffix, seen) + + +def zone_template_to_zone_component( + params: ZoneTemplate, + *, + id_tag: str = "", +) -> ZoneComponent: + """Convert a resolved zone template into a uniquely named ZoneComponent.""" + equipment_paramteric = ParametericYear( + Base=params.EquipmentBase, + AMInterp=params.EquipmentAMInterp, + LunchInterp=params.EquipmentLunchInterp, + PMInterp=params.EquipmentPMInterp, + WeekendPeakInterp=params.EquipmentWeekendPeakInterp, + SummerPeakInterp=params.EquipmentSummerPeakInterp, + ) + + lighting_paramteric = ParametericYear( + Base=params.LightingBase, + AMInterp=params.LightingAMInterp, + LunchInterp=params.LightingLunchInterp, + PMInterp=params.LightingPMInterp, + WeekendPeakInterp=params.LightingWeekendPeakInterp, + SummerPeakInterp=params.LightingSummerPeakInterp, + ) + + occupancy_paramteric = ParametericYear( + Base=params.OccupancyBase, + AMInterp=params.OccupancyAMInterp, + LunchInterp=params.OccupancyLunchInterp, + PMInterp=params.OccupancyPMInterp, + WeekendPeakInterp=params.OccupancyWeekendPeakInterp, + SummerPeakInterp=params.OccupancySummerPeakInterp, + ) + + equipment_schedule = equipment_paramteric.to_schedule( + name="Equipment", category="Equipment" + ) + lighting_schedule = lighting_paramteric.to_schedule( + name="Lighting", category="Lighting" + ) + occupancy_schedule = occupancy_paramteric.to_schedule( + name="Occupancy", category="Occupancy" + ) + + setpoint_parametric = ParametricSetpoints( + HeatingSetpoint=params.HeatingSetpointBase, + DeadBand=params.SetpointDeadband, + HeatingSetback=params.HeatingSetpointSetback, + CoolingSetback=params.CoolingSetpointSetback, + NightSetback=params.NightSetback, + WeekendSetback=params.WeekendSetback, + SummerSetback=params.SummerSetback, + ) + + hsp_year, csp_year = setpoint_parametric.to_schedules() + + thermostat = ThermostatComponent( + Name="Thermostat", + IsOn=True, + HeatingSetpoint=hsp_year.January.Monday.Hour_12, + CoolingSetpoint=csp_year.January.Monday.Hour_12, + HeatingSchedule=hsp_year, + CoolingSchedule=csp_year, + ) + + equipment = EquipmentComponent( + Name="Equipment", + PowerDensity=params.EquipmentPowerDensity, + Schedule=equipment_schedule, + IsOn=True, + ) + + lighting = LightingComponent( + Name="Lighting", + PowerDensity=params.LightingPowerDensity, + Schedule=lighting_schedule, + IsOn=True, + DimmingType="Off", + ) + + occupancy = OccupancyComponent( + Name="Occupancy", + PeopleDensity=params.OccupantDensity, + Schedule=occupancy_schedule, + IsOn=True, + ) + + water_use = WaterUseComponent( + Name="WaterUse", + FlowRatePerPerson=params.DHWFlowRatePerPerson, + Schedule=occupancy_schedule, + ) + + space_use = ZoneSpaceUseComponent( + Name="SpaceUse", + Occupancy=occupancy, + Lighting=lighting, + Equipment=equipment, + Thermostat=thermostat, + WaterUse=water_use, + ) + + heating_system = ThermalSystemComponent( + Name="HeatingSystem", + ConditioningType="Heating", + Fuel=params.HeatingFuel, + SystemCOP=params.HeatingSystemCOP, + DistributionCOP=params.HeatingDistributionCOP, + ) + + cooling_system = ThermalSystemComponent( + Name="CoolingSystem", + ConditioningType="Cooling", + Fuel=params.CoolingFuel, + SystemCOP=params.CoolingSystemCOP, + DistributionCOP=params.CoolingDistributionCOP, + ) + + conditioning_system = ConditioningSystemsComponent( + Name="ConditioningSystem", + Heating=heating_system, + Cooling=cooling_system, + ) + + all_off_day = DayComponent( + Name="AllOffDayComponent", + Type="Fraction", + Hour_00=0.0, + Hour_01=0.0, + Hour_02=0.0, + Hour_03=0.0, + Hour_04=0.0, + Hour_05=0.0, + Hour_06=0.0, + Hour_07=0.0, + Hour_08=0.0, + Hour_09=0.0, + Hour_10=0.0, + Hour_11=0.0, + Hour_12=0.0, + Hour_13=0.0, + Hour_14=0.0, + Hour_15=0.0, + Hour_16=0.0, + Hour_17=0.0, + Hour_18=0.0, + Hour_19=0.0, + Hour_20=0.0, + Hour_21=0.0, + Hour_22=0.0, + Hour_23=0.0, + ) + + all_off_week = WeekComponent( + Name="AllOffWeekComponent", + Monday=all_off_day, + Tuesday=all_off_day, + Wednesday=all_off_day, + Thursday=all_off_day, + Friday=all_off_day, + Saturday=all_off_day, + Sunday=all_off_day, + ) + + all_off_year = YearComponent( + Name="AllOffYearComponent", + Type="Window", + January=all_off_week, + February=all_off_week, + March=all_off_week, + April=all_off_week, + May=all_off_week, + June=all_off_week, + July=all_off_week, + August=all_off_week, + September=all_off_week, + October=all_off_week, + November=all_off_week, + December=all_off_week, + ) + + ventilation_system = VentilationComponent( + Name="VentilationSystem", + FreshAirPerFloorArea=params.VentFlowRatePerArea, + FreshAirPerPerson=params.VentFlowRatePerPerson, + Provider=params.VentProvider, + # TODO: should hrv sensible/latent efficiency be configurable? (e.g. high/medium/low) + HRV=params.VentHRV, + Economizer=params.VentEconomizer, + DCV=params.VentDCV, + # TODO: this controls natvnent and should + # be configurable for residential models + Schedule=all_off_year, + ) + + hvac = ZoneHVACComponent( + Name="HVAC", + ConditioningSystems=conditioning_system, + Ventilation=ventilation_system, + ) + + dhw = DHWComponent( + Name="DHW", + SystemCOP=params.DHWSystemCOP, + DistributionCOP=params.DHWDistributionCOP, + # TODO: should these be configurable? + WaterTemperatureInlet=10, + WaterSupplyTemperature=55, + IsOn=True, + FuelType=params.DHWFuel, + ) + + operations = ZoneOperationsComponent( + Name="Operations", + SpaceUse=space_use, + HVAC=hvac, + DHW=dhw, + ) + + window_assembly = GlazingConstructionSimpleComponent( + Name="WindowAssembly", + UValue=params.WindowUValue, + SHGF=params.WindowSHGF, + TVis=params.WindowTVis, + Type="Single", + ) + + infiltration = InfiltrationComponent( + Name="Infiltration", + IsOn=True, + CalculationMethod="AirChanges/Hour", + AirChangesPerHour=params.InfiltrationACH, + ConstantCoefficient=0.0, + TemperatureCoefficient=0.0, + WindVelocityCoefficient=0.0, + WindVelocitySquaredCoefficient=0.0, + AFNAirMassFlowCoefficientCrack=0.0, + FlowPerExteriorSurfaceArea=0.0, + ) + + # TODO: verify interior/exterior + # TODO: are we okaky with mass assumptions? + facade = ConstructionAssemblyComponent( + Name="Facade", + Type="Facade", + Layers=[ + ConstructionLayerComponent( + ConstructionMaterial=clay_brick, + Thickness=0.002, + LayerOrder=0, + ), + ConstructionLayerComponent( + ConstructionMaterial=concrete_block_h, + Thickness=0.15, + LayerOrder=1, + ), + ConstructionLayerComponent( + ConstructionMaterial=fiberglass_batts, + Thickness=0.05, + LayerOrder=2, + ), + ConstructionLayerComponent( + ConstructionMaterial=gypsum_board, + Thickness=0.015, + LayerOrder=3, + ), + ], + ) + + facade_r_value_without_fiberglass = facade.r_value - facade.sorted_layers[2].r_value + + facade_r_value_delta = params.FacadeRValue - facade_r_value_without_fiberglass + required_fiberglass_thickness = fiberglass_batts.Conductivity * facade_r_value_delta + + if required_fiberglass_thickness < 0.003: + msg = f"Required Facade Fiberglass thickness is less than 3mm because the desired total facade R-value is {params.FacadeRValue} m²K/W but the concrete and gypsum layers already have a total R-value of {facade_r_value_without_fiberglass} m²K/W." + raise ValueError(msg) + + facade.sorted_layers[2].Thickness = required_fiberglass_thickness + + roof = ConstructionAssemblyComponent( + Name="Roof", + Type="FlatRoof", + Layers=[ + ConstructionLayerComponent( + ConstructionMaterial=xps_board, + Thickness=0.1, + LayerOrder=0, + ), + ConstructionLayerComponent( + ConstructionMaterial=concrete_mc_light, + Thickness=0.15, + LayerOrder=1, + ), + ConstructionLayerComponent( + ConstructionMaterial=concrete_rc_dense, + Thickness=0.2, + LayerOrder=2, + ), + ConstructionLayerComponent( + ConstructionMaterial=gypsum_board, + Thickness=0.02, + LayerOrder=3, + ), + ], + ) + + roof_r_value_without_xps = roof.r_value - roof.sorted_layers[0].r_value + roof_r_value_delta = params.RoofRValue - roof_r_value_without_xps + required_xps_thickness = xps_board.Conductivity * roof_r_value_delta + if required_xps_thickness < 0.003: + msg = f"Required Roof XPS thickness is less than 3mm because the desired total roof R-value is {params.RoofRValue} m²K/W but the concrete layers already have a total R-value of {roof_r_value_without_xps} m²K/W." + raise ValueError(msg) + + roof.sorted_layers[0].Thickness = required_xps_thickness + + partition = ConstructionAssemblyComponent( + Name="Partition", + Type="Partition", + Layers=[ + ConstructionLayerComponent( + ConstructionMaterial=gypsum_plaster, + Thickness=0.02, + LayerOrder=0, + ), + ConstructionLayerComponent( + ConstructionMaterial=softwood_general, + Thickness=0.02, + LayerOrder=1, + ), + ConstructionLayerComponent( + ConstructionMaterial=gypsum_plaster, + Thickness=0.02, + LayerOrder=2, + ), + ], + ) + + floor_ceiling = ConstructionAssemblyComponent( + Name="FloorCeiling", + Type="FloorCeiling", + Layers=[ + ConstructionLayerComponent( + ConstructionMaterial=urethane_carpet, + Thickness=0.02, + LayerOrder=0, + ), + ConstructionLayerComponent( + ConstructionMaterial=cement_mortar, + Thickness=0.02, + LayerOrder=1, + ), + ConstructionLayerComponent( + ConstructionMaterial=concrete_rc_dense, + Thickness=0.15, + LayerOrder=2, + ), + ConstructionLayerComponent( + ConstructionMaterial=gypsum_board, + Thickness=0.02, + LayerOrder=3, + ), + ], + ) + + ground_slab_assembly = ConstructionAssemblyComponent( + Name="GroundSlabAssembly", + Type="GroundSlab", + Layers=[ + ConstructionLayerComponent( + ConstructionMaterial=xps_board, + Thickness=0.02, + LayerOrder=0, + ), + ConstructionLayerComponent( + ConstructionMaterial=concrete_rc_dense, + Thickness=0.15, + LayerOrder=1, + ), + ConstructionLayerComponent( + ConstructionMaterial=concrete_mc_light, + Thickness=0.04, + LayerOrder=2, + ), + ConstructionLayerComponent( + ConstructionMaterial=cement_mortar, + Thickness=0.03, + LayerOrder=3, + ), + ConstructionLayerComponent( + ConstructionMaterial=ceramic_tile, + Thickness=0.02, + LayerOrder=4, + ), + ], + ) + + ground_slab_r_value_without_xps = ( + ground_slab_assembly.r_value - ground_slab_assembly.sorted_layers[0].r_value + ) + ground_slab_r_value_delta = params.SlabRValue - ground_slab_r_value_without_xps + required_xps_thickness = xps_board.Conductivity * ground_slab_r_value_delta + if required_xps_thickness < 0.003: + msg = f"Required Ground Slab XPS thickness is less than 3mm because the desired total slab R-value is {params.SlabRValue} m²K/W but the concrete layers already have a total R-value of {ground_slab_r_value_without_xps} m²K/W." + raise ValueError(msg) + + ground_slab_assembly.sorted_layers[0].Thickness = required_xps_thickness + + assemblies = EnvelopeAssemblyComponent( + Name="EnvelopeAssemblies", + FacadeAssembly=facade, + FlatRoofAssembly=roof, + AtticRoofAssembly=roof, + PartitionAssembly=partition, + FloorCeilingAssembly=floor_ceiling, + AtticFloorAssembly=floor_ceiling, + BasementCeilingAssembly=floor_ceiling, + GroundSlabAssembly=ground_slab_assembly, + GroundWallAssembly=ground_slab_assembly, + ExternalFloorAssembly=ground_slab_assembly, + ) + + basement_infiltration = infiltration.model_copy(deep=True) + envelope = ZoneEnvelopeComponent( + Name="Envelope", + AtticInfiltration=infiltration, + BasementInfiltration=basement_infiltration, + Window=window_assembly, + Infiltration=infiltration, + Assemblies=assemblies, + ) + + zone = ZoneComponent( + Name="Zone", + Operations=operations, + Envelope=envelope, + ) + if not params.IdealLoadsHeatingOn: + zone.Operations.HVAC.ConditioningSystems.Heating = None + if not params.IdealLoadsCoolingOn: + zone.Operations.HVAC.ConditioningSystems.Cooling = None + if id_tag: + _suffix_named_objects(zone, id_tag, set()) + return zone + if __name__ == "__main__": flat_model = FlatModel( diff --git a/epinterface/sbem/zone_assignment.py b/epinterface/sbem/zone_assignment.py new file mode 100644 index 0000000..2907c53 --- /dev/null +++ b/epinterface/sbem/zone_assignment.py @@ -0,0 +1,498 @@ +"""Typed floor and zone assignment models for shoebox SBEM runs.""" + +from __future__ import annotations + +import re +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, Field, field_validator, model_validator + +from epinterface.geometry import ShoeboxGeometry, ZoningType, get_zone_floor_area +from epinterface.sbem.components.systems import ( + DCVMethod, + DHWFuelType, + EconomizerMethod, + FuelType, + HRVMethod, + VentilationProvider, +) + +ZoneCategory = Literal["main", "attic", "basement"] + + +class ZoneRole(StrEnum): + """Thermal-zone role within a storey.""" + + core = "core" + perim_1 = "perim_1" + perim_2 = "perim_2" + perim_3 = "perim_3" + perim_4 = "perim_4" + floor = "floor" + attic = "attic" + basement = "basement" + + +class ZoneTemplate(BaseModel, extra="forbid"): + """Shell-free zone/floor inputs used to construct a ZoneComponent.""" + + EquipmentBase: float = Field(ge=0, le=1) + EquipmentAMInterp: float = Field(ge=0, le=1) + EquipmentLunchInterp: float = Field(ge=0, le=1) + EquipmentPMInterp: float = Field(ge=0, le=1) + EquipmentWeekendPeakInterp: float = Field(ge=0, le=1) + EquipmentSummerPeakInterp: float = Field(ge=0, le=1) + + LightingBase: float = Field(ge=0, le=1) + LightingAMInterp: float = Field(ge=0, le=1) + LightingLunchInterp: float = Field(ge=0, le=1) + LightingPMInterp: float = Field(ge=0, le=1) + LightingWeekendPeakInterp: float = Field(ge=0, le=1) + LightingSummerPeakInterp: float = Field(ge=0, le=1) + + OccupancyBase: float = Field(ge=0, le=1) + OccupancyAMInterp: float = Field(ge=0, le=1) + OccupancyLunchInterp: float = Field(ge=0, le=1) + OccupancyPMInterp: float = Field(ge=0, le=1) + OccupancyWeekendPeakInterp: float = Field(ge=0, le=1) + OccupancySummerPeakInterp: float = Field(ge=0, le=1) + + HeatingSetpointBase: float = Field(ge=0, le=23) + SetpointDeadband: float = Field(ge=0, le=10) + HeatingSetpointSetback: float = Field(ge=0, le=10) + CoolingSetpointSetback: 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) + + HeatingFuel: FuelType + CoolingFuel: FuelType + HeatingSystemCOP: float = Field(ge=0) + CoolingSystemCOP: float = Field(ge=0) + HeatingDistributionCOP: float = Field(ge=0) + CoolingDistributionCOP: float = Field(ge=0) + + EquipmentPowerDensity: float = Field(ge=0, le=200) + LightingPowerDensity: float = Field(ge=0, le=100) + OccupantDensity: float = Field(ge=0, le=50) + + VentFlowRatePerPerson: float = Field(ge=0) + VentFlowRatePerArea: float = Field(ge=0) + VentProvider: VentilationProvider + VentHRV: HRVMethod + VentEconomizer: EconomizerMethod + VentDCV: DCVMethod + + DHWFlowRatePerPerson: float = Field(ge=0) + DHWFuel: DHWFuelType + DHWSystemCOP: float = Field(ge=0) + DHWDistributionCOP: float = Field(ge=0) + + InfiltrationACH: float = Field(ge=0) + + WindowUValue: float = Field(ge=0) + WindowSHGF: float = Field(ge=0, le=1) + WindowTVis: float = Field(ge=0, le=1) + + FacadeRValue: float = Field(gt=0) + RoofRValue: float = Field(gt=0) + SlabRValue: float = Field(gt=0) + WWR: float = Field(ge=0, le=1) + + IdealLoadsHeatingOn: bool = True + IdealLoadsCoolingOn: bool = True + + +class PartialZoneTemplate(BaseModel, extra="forbid"): + """Sparse patch for a floor or role template.""" + + EquipmentBase: float | None = Field(default=None, ge=0, le=1) + EquipmentAMInterp: float | None = Field(default=None, ge=0, le=1) + EquipmentLunchInterp: float | None = Field(default=None, ge=0, le=1) + EquipmentPMInterp: float | None = Field(default=None, ge=0, le=1) + EquipmentWeekendPeakInterp: float | None = Field(default=None, ge=0, le=1) + EquipmentSummerPeakInterp: float | None = Field(default=None, ge=0, le=1) + + LightingBase: float | None = Field(default=None, ge=0, le=1) + LightingAMInterp: float | None = Field(default=None, ge=0, le=1) + LightingLunchInterp: float | None = Field(default=None, ge=0, le=1) + LightingPMInterp: float | None = Field(default=None, ge=0, le=1) + LightingWeekendPeakInterp: float | None = Field(default=None, ge=0, le=1) + LightingSummerPeakInterp: float | None = Field(default=None, ge=0, le=1) + + OccupancyBase: float | None = Field(default=None, ge=0, le=1) + OccupancyAMInterp: float | None = Field(default=None, ge=0, le=1) + OccupancyLunchInterp: float | None = Field(default=None, ge=0, le=1) + OccupancyPMInterp: float | None = Field(default=None, ge=0, le=1) + OccupancyWeekendPeakInterp: float | None = Field(default=None, ge=0, le=1) + OccupancySummerPeakInterp: float | None = Field(default=None, ge=0, le=1) + + HeatingSetpointBase: float | None = Field(default=None, ge=0, le=23) + SetpointDeadband: float | None = Field(default=None, ge=0, le=10) + HeatingSetpointSetback: float | None = Field(default=None, ge=0, le=10) + CoolingSetpointSetback: float | None = Field(default=None, ge=0, le=10) + NightSetback: float | None = Field(default=None, ge=0, le=1) + WeekendSetback: float | None = Field(default=None, ge=0, le=1) + SummerSetback: float | None = Field(default=None, ge=0, le=1) + + HeatingFuel: FuelType | None = None + CoolingFuel: FuelType | None = None + HeatingSystemCOP: float | None = Field(default=None, ge=0) + CoolingSystemCOP: float | None = Field(default=None, ge=0) + HeatingDistributionCOP: float | None = Field(default=None, ge=0) + CoolingDistributionCOP: float | None = Field(default=None, ge=0) + + EquipmentPowerDensity: float | None = Field(default=None, ge=0, le=200) + LightingPowerDensity: float | None = Field(default=None, ge=0, le=100) + OccupantDensity: float | None = Field(default=None, ge=0, le=50) + + VentFlowRatePerPerson: float | None = Field(default=None, ge=0) + VentFlowRatePerArea: float | None = Field(default=None, ge=0) + VentProvider: VentilationProvider | None = None + VentHRV: HRVMethod | None = None + VentEconomizer: EconomizerMethod | None = None + VentDCV: DCVMethod | None = None + + DHWFlowRatePerPerson: float | None = Field(default=None, ge=0) + DHWFuel: DHWFuelType | None = None + DHWSystemCOP: float | None = Field(default=None, ge=0) + DHWDistributionCOP: float | None = Field(default=None, ge=0) + + InfiltrationACH: float | None = Field(default=None, ge=0) + + WindowUValue: float | None = Field(default=None, ge=0) + WindowSHGF: float | None = Field(default=None, ge=0, le=1) + WindowTVis: float | None = Field(default=None, ge=0, le=1) + + FacadeRValue: float | None = Field(default=None, gt=0) + WWR: float | None = Field(default=None, ge=0, le=1) + + IdealLoadsHeatingOn: bool | None = None + IdealLoadsCoolingOn: bool | None = None + + # NOTE: RoofRValue and SlabRValue are intentionally absent from the partial + # override model. They are boundary-envelope quantities that are applied + # building-wide from ``defaults`` (the shared opaque envelope), and cannot + # yet vary per floor. Exposing them here would silently accept overrides + # that never reach the IDF. They remain available on ``ZoneTemplate`` for + # the building-wide defaults. + + +class FloorBand(BaseModel, extra="forbid"): + """A half-open range of above-grade floors with optional role overrides. + + Both ``template`` and the values of ``role_overrides`` are sparse + ``PartialZoneTemplate`` patches applied on top of the building ``defaults``. + Using a typed partial (rather than a full template or a raw dict) means + unknown or unsupported override fields fail when the model is created, not + silently during the build. + """ + + start: int = Field(ge=0) + stop: int = Field(gt=0) + template: PartialZoneTemplate | None = None + role_overrides: dict[ZoneRole, PartialZoneTemplate] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_range(self) -> FloorBand: + """Require a non-empty half-open floor range and supported overrides.""" + if self.stop <= self.start: + msg = f"FloorBand stop ({self.stop}) must be greater than start ({self.start})." + raise ValueError(msg) + core_override = self.role_overrides.get(ZoneRole.core) + if core_override is not None and core_override.WWR is not None: + msg = ( + "WWR cannot be overridden on core zones; core zones have no " + "exterior walls, so a core WWR override would have no physical " + "effect. Set WWR on the floor band template or perimeter roles." + ) + raise ValueError(msg) + return self + + def contains(self, floor_index: int) -> bool: + """Return True if this band applies to the above-grade floor index.""" + return self.start <= floor_index < self.stop + + +class ParsedZoneKey(BaseModel, frozen=True): + """Semantic identity parsed from a generated EnergyPlus zone name.""" + + ep_zone_name: str + ep_storey_index: int | None + floor_index: int | None + role: ZoneRole + category: ZoneCategory + + +class ResolvedZone(BaseModel, arbitrary_types_allowed=True): + """A resolved EnergyPlus zone plus floor metadata and final parameters.""" + + ep_zone_name: str + ep_storey_index: int | None + floor_index: int | None + role: ZoneRole + category: ZoneCategory + params: ZoneTemplate + floor_area_m2: float | None = None + + +_STOREY_RE = re.compile(r"Storey\s+(-?\d+)\s*$", re.IGNORECASE) +_PERIM_RE = re.compile(r"Perimeter_Zone_(\d+)", re.IGNORECASE) + + +def roles_for_zoning(zoning: ZoningType) -> frozenset[ZoneRole]: + """Return the valid above-grade roles for a zoning strategy.""" + if zoning == "by_storey": + return frozenset({ZoneRole.floor}) + return frozenset({ + ZoneRole.core, + ZoneRole.perim_1, + ZoneRole.perim_2, + ZoneRole.perim_3, + ZoneRole.perim_4, + }) + + +def parse_zone_key(zone_name: str, geometry: ShoeboxGeometry) -> ParsedZoneKey: # noqa: C901 + """Map a generated EnergyPlus zone name to semantic floor/role metadata.""" + normalized = zone_name.strip() + low = normalized.lower() + if "attic" in low: + return ParsedZoneKey( + ep_zone_name=zone_name, + ep_storey_index=None, + floor_index=None, + role=ZoneRole.attic, + category="attic", + ) + + match = _STOREY_RE.search(normalized) + if not match: + msg = f"Cannot parse storey index from zone name: {zone_name!r}." + raise ValueError(msg) + ep_storey_index = int(match.group(1)) + + category: ZoneCategory = "main" + if geometry.basement: + is_basement = ( + ep_storey_index == -1 + if geometry.zoning == "by_storey" + else ep_storey_index == 0 + ) + if is_basement: + category = "basement" + + if category == "basement": + floor_index = None + elif geometry.basement and geometry.zoning == "core/perim": + floor_index = ep_storey_index - 1 + else: + floor_index = ep_storey_index + + if geometry.zoning == "by_storey": + role = ZoneRole.basement if category == "basement" else ZoneRole.floor + elif "core_zone" in low: + role = ZoneRole.core + else: + perim_match = _PERIM_RE.search(normalized) + if not perim_match: + msg = f"Cannot classify core/perimeter zone name: {zone_name!r}." + raise ValueError(msg) + perim_index = int(perim_match.group(1)) + if perim_index not in range(1, 5): + msg = f"Unexpected perimeter index {perim_index} in {zone_name!r}." + raise ValueError(msg) + role = ZoneRole(f"perim_{perim_index}") + + return ParsedZoneKey( + ep_zone_name=zone_name, + ep_storey_index=ep_storey_index, + floor_index=floor_index, + role=role, + category=category, + ) + + +def safe_zone_tag(zone_name: str) -> str: + """Create an EnergyPlus-object-safe suffix from a zone name.""" + return "".join(char if char.isalnum() else "_" for char in zone_name) + + +class ZoneAssignmentResolver(BaseModel, extra="forbid"): + """Resolve defaults, floor bands, and role overrides for generated zones.""" + + defaults: ZoneTemplate + n_floors: int = Field(ge=1) + floor_bands: list[FloorBand] = Field(default_factory=list) + + @field_validator("floor_bands") + @classmethod + def sort_floor_bands(cls, value: list[FloorBand]) -> list[FloorBand]: + """Keep floor bands deterministic.""" + return sorted(value, key=lambda band: (band.start, band.stop)) + + @model_validator(mode="after") + def validate_floor_bands(self) -> ZoneAssignmentResolver: + """Reject overlapping or out-of-range floor bands.""" + covered: set[int] = set() + for band in self.floor_bands: + if band.stop > self.n_floors: + msg = ( + f"FloorBand {band.start}:{band.stop} extends beyond " + f"n_floors={self.n_floors}." + ) + raise ValueError(msg) + floors = set(range(band.start, band.stop)) + overlap = covered.intersection(floors) + if overlap: + msg = f"Overlapping floor bands for floor(s): {sorted(overlap)}." + raise ValueError(msg) + covered.update(floors) + return self + + def floor_band_for(self, floor_index: int) -> FloorBand | None: + """Return the floor band for a zero-based above-grade floor index.""" + for band in self.floor_bands: + if band.contains(floor_index): + return band + return None + + @staticmethod + def _patch_dict(template: PartialZoneTemplate | None) -> dict: + """Return non-null values from a sparse template patch.""" + if template is None: + return {} + return template.model_dump(exclude_none=True) + + def resolve_key( + self, + key: ParsedZoneKey, + *, + attic_use_fraction: float | None = None, + attic_conditioned: bool = False, + basement_use_fraction: float | None = None, + basement_conditioned: bool = False, + ) -> ZoneTemplate: + """Resolve final zone parameters for a parsed generated zone key.""" + data = self.defaults.model_dump() + + if key.category == "main": + if key.floor_index is None: + msg = f"Main zone {key.ep_zone_name!r} has no floor_index." + raise ValueError(msg) + if not 0 <= key.floor_index < self.n_floors: + msg = ( + f"Zone {key.ep_zone_name!r} resolved to floor_index " + f"{key.floor_index}, outside 0..{self.n_floors - 1}." + ) + raise ValueError(msg) + + band = self.floor_band_for(key.floor_index) + if band is not None: + data.update(self._patch_dict(band.template)) + data.update(self._patch_dict(band.role_overrides.get(key.role))) + + params = ZoneTemplate.model_validate(data) + + if key.category == "attic": + frac = attic_use_fraction or 0 + params = params.model_copy( + update={ + "EquipmentPowerDensity": frac * params.EquipmentPowerDensity, + "LightingPowerDensity": frac * params.LightingPowerDensity, + "OccupantDensity": frac * params.OccupantDensity, + } + ) + if not attic_conditioned: + params = params.model_copy( + update={ + "VentProvider": "None", + "IdealLoadsHeatingOn": False, + "IdealLoadsCoolingOn": False, + } + ) + return params + + if key.category == "basement": + frac = basement_use_fraction or 0 + params = params.model_copy( + update={ + "EquipmentPowerDensity": frac * params.EquipmentPowerDensity, + "LightingPowerDensity": frac * params.LightingPowerDensity, + "OccupantDensity": frac * params.OccupantDensity, + } + ) + if not basement_conditioned: + params = params.model_copy( + update={ + "VentProvider": "None", + "IdealLoadsHeatingOn": False, + "IdealLoadsCoolingOn": False, + } + ) + else: + params = params.model_copy(update={"IdealLoadsCoolingOn": False}) + return params + + return params + + def resolve_zone( + self, + zone_name: str, + geometry: ShoeboxGeometry, + *, + attic_use_fraction: float | None = None, + attic_conditioned: bool = False, + basement_use_fraction: float | None = None, + basement_conditioned: bool = False, + floor_area_m2: float | None = None, + ) -> ResolvedZone: + """Resolve one generated EnergyPlus zone name.""" + key = parse_zone_key(zone_name, geometry) + params = self.resolve_key( + key, + attic_use_fraction=attic_use_fraction, + attic_conditioned=attic_conditioned, + basement_use_fraction=basement_use_fraction, + basement_conditioned=basement_conditioned, + ) + return ResolvedZone( + ep_zone_name=zone_name, + ep_storey_index=key.ep_storey_index, + floor_index=key.floor_index, + role=key.role, + category=key.category, + params=params, + floor_area_m2=floor_area_m2, + ) + + def resolved_zone_table( + self, + idf, + geometry: ShoeboxGeometry, + *, + attic_use_fraction: float | None = None, + attic_conditioned: bool = False, + basement_use_fraction: float | None = None, + basement_conditioned: bool = False, + ) -> list[ResolvedZone]: + """Resolve every generated zone in an IDF and include floor area where possible.""" + resolved: list[ResolvedZone] = [] + for zone in idf.idfobjects["ZONE"]: + try: + area = float(get_zone_floor_area(idf, zone.Name)) + except ValueError: + area = None + resolved.append( + self.resolve_zone( + zone.Name, + geometry, + attic_use_fraction=attic_use_fraction, + attic_conditioned=attic_conditioned, + basement_use_fraction=basement_use_fraction, + basement_conditioned=basement_conditioned, + floor_area_m2=area, + ) + ) + return resolved diff --git a/tests/test_analysis/test_zone_energy.py b/tests/test_analysis/test_zone_energy.py new file mode 100644 index 0000000..09ab8b1 --- /dev/null +++ b/tests/test_analysis/test_zone_energy.py @@ -0,0 +1,204 @@ +"""Tests for zone and floor energy aggregation.""" + +from typing import Any, cast + +import pandas as pd +import pytest + +from epinterface.analysis import zone_energy +from epinterface.analysis.zone_energy import ( + floor_energy_summary, + merge_assignment_and_energy, + zone_energy_summary, +) + + +class _FakeZone: + """Small stand-in for an EnergyPlus ZONE object.""" + + def __init__(self, name: str) -> None: + """Store the generated zone name.""" + self.Name = name + + +class _FakeIDF: + """Small stand-in exposing the idfobjects mapping used by zone_energy.""" + + def __init__(self, zone_names: list[str]) -> None: + """Create fake ZONE objects.""" + self.idfobjects = {"ZONE": [_FakeZone(name) for name in zone_names]} + + +class _FakeSql: + """Fake Sql object returning prepared hourly DataFrames by variable name.""" + + def __init__(self, data: dict[str, pd.DataFrame]) -> None: + """Store variable-name keyed DataFrames.""" + self.data = data + + def timeseries_by_name(self, names: list[str], reporting_frequency: str): + """Return the fake hourly timeseries for the requested variable.""" + assert reporting_frequency == "Hourly" + return self.data[names[0]] + + +def _hourly_df(variable_name: str, key_value: str, values: list[float]) -> pd.DataFrame: + """Create an archetypal-like hourly variable DataFrame.""" + columns = pd.MultiIndex.from_tuples( + [("Zone", key_value, variable_name)], + names=["IndexGroup", "KeyValue", "Name"], + ) + return pd.DataFrame(values, columns=columns) + + +def test_zone_energy_summary_returns_raw_and_normalized_kwh(monkeypatch) -> None: + """Zone energy summary converts Joules to kWh and normalizes by zone area.""" + zone_name = "Block shoebox Storey 0" + idf = _FakeIDF([zone_name]) + sql = _FakeSql({ + "Zone Lights Electricity Energy": _hourly_df( + "Zone Lights Electricity Energy", zone_name, [3_600_000, 3_600_000] + ), + "Zone Electric Equipment Electricity Energy": _hourly_df( + "Zone Electric Equipment Electricity Energy", zone_name, [3_600_000] + ), + "Zone Ideal Loads Zone Total Heating Energy": pd.DataFrame(), + "Zone Ideal Loads Zone Total Cooling Energy": pd.DataFrame(), + }) + monkeypatch.setattr(zone_energy, "get_zone_floor_area", lambda _idf, _zone: 10.0) + + result = zone_energy_summary(cast(Any, sql), cast(Any, idf)) + + assert result.loc[0, "sim_lighting_site_kwh"] == pytest.approx(2.0) + assert result.loc[0, "sim_lighting_site_kwh_per_m2"] == pytest.approx(0.2) + assert result.loc[0, "sim_equipment_site_kwh"] == pytest.approx(1.0) + assert result.loc[0, "sim_total_included_kwh"] == pytest.approx(3.0) + + +def test_zone_energy_summary_maps_ideal_loads_system_keys(monkeypatch) -> None: + """Ideal-loads output keys ending in system suffix map back to zone names.""" + zone_name = "Block shoebox Storey 0" + idf = _FakeIDF([zone_name]) + sql = _FakeSql({ + "Zone Lights Electricity Energy": pd.DataFrame(), + "Zone Electric Equipment Electricity Energy": pd.DataFrame(), + "Zone Ideal Loads Zone Total Heating Energy": _hourly_df( + "Zone Ideal Loads Zone Total Heating Energy", + f"{zone_name} Ideal Loads Air System", + [7_200_000], + ), + "Zone Ideal Loads Zone Total Cooling Energy": pd.DataFrame(), + }) + monkeypatch.setattr(zone_energy, "get_zone_floor_area", lambda _idf, _zone: 20.0) + + result = zone_energy_summary(cast(Any, sql), cast(Any, idf)) + + assert result.loc[0, "sim_heating_delivered_kwh"] == pytest.approx(2.0) + assert result.loc[0, "sim_heating_delivered_kwh_per_m2"] == pytest.approx(0.1) + + +def _two_floor_zone_df() -> pd.DataFrame: + """A small zone-energy table spanning two above-grade floors.""" + return pd.DataFrame({ + "ep_zone_name": ["z0", "z1", "z2"], + "category": ["main", "main", "main"], + "floor_index": [0, 0, 1], + "floor_area_m2": [10.0, 30.0, 20.0], + "sim_lighting_site_kwh": [10.0, 90.0, 20.0], + "sim_equipment_site_kwh": [0.0, 0.0, 0.0], + "sim_heating_delivered_kwh": [0.0, 0.0, 0.0], + "sim_cooling_delivered_kwh": [0.0, 0.0, 0.0], + "sim_total_included_kwh": [10.0, 90.0, 20.0], + }) + + +def test_floor_energy_summary_sums_raw_energy_then_normalizes() -> None: + """Floor aggregation sums raw zone kWh before dividing by total floor area.""" + floor_df = floor_energy_summary(_two_floor_zone_df()) + + assert floor_df.shape[0] == 2 + assert floor_df.loc[0, "floor_area_m2"] == 40.0 + assert floor_df.loc[0, "sim_lighting_site_kwh"] == 100.0 + assert floor_df.loc[0, "sim_lighting_site_kwh_per_m2"] == 2.5 + + +def test_floor_energy_summary_excludes_basement_and_attic() -> None: + """Only 'main' zones contribute, so basement/attic rows are excluded.""" + zone_df = _two_floor_zone_df() + extra = pd.DataFrame({ + "ep_zone_name": ["bsmt", "attic"], + "category": ["basement", "attic"], + "floor_index": [None, None], + "floor_area_m2": [50.0, 50.0], + "sim_lighting_site_kwh": [5.0, 5.0], + "sim_equipment_site_kwh": [0.0, 0.0], + "sim_heating_delivered_kwh": [0.0, 0.0], + "sim_cooling_delivered_kwh": [0.0, 0.0], + "sim_total_included_kwh": [5.0, 5.0], + }) + combined = pd.concat([zone_df, extra], ignore_index=True) + + floor_df = floor_energy_summary(combined, n_floors=2) + + assert set(floor_df["floor_index"]) == {0, 1} + + +def test_floor_energy_summary_validates_n_floors() -> None: + """A missing floor row is a hard error when n_floors is supplied.""" + # Only floors 0 and 1 are present; asserting 3 floors must fail. + with pytest.raises(ValueError, match="floor"): + floor_energy_summary(_two_floor_zone_df(), n_floors=3) + + +def test_floor_energy_summary_passes_when_n_floors_matches() -> None: + """Two floors present and n_floors=2 validates cleanly.""" + floor_df = floor_energy_summary(_two_floor_zone_df(), n_floors=2) + assert set(floor_df["floor_index"]) == {0, 1} + + +def test_merge_assignment_and_energy_has_no_suffixed_metadata() -> None: + """Merging assignment + energy must not create *_x / *_y duplicate columns.""" + assignment = pd.DataFrame({ + "ep_zone_name": ["z0", "z1"], + "floor_index": [0, 1], + "role": ["floor", "floor"], + "category": ["main", "main"], + "floor_area_m2": [10.0, 20.0], + "LightingPowerDensity": [5.0, 15.0], + }) + energy = pd.DataFrame({ + "ep_zone_name": ["z0", "z1"], + "floor_index": [0, 1], + "role": ["floor", "floor"], + "category": ["main", "main"], + "ep_storey_index": [0, 1], + "floor_area_m2": [10.0, 20.0], + "sim_lighting_site_kwh": [50.0, 300.0], + "sim_total_included_kwh": [50.0, 300.0], + }) + + merged = merge_assignment_and_energy(assignment, energy) + + assert not any(c.endswith(("_x", "_y")) for c in merged.columns) + # Metadata columns appear exactly once, energy columns are joined in. + assert list(merged["floor_index"]) == [0, 1] + assert merged.loc[0, "sim_lighting_site_kwh"] == 50.0 + assert merged.loc[1, "LightingPowerDensity"] == 15.0 + + +def test_zone_energy_summary_missing_variables_are_none(monkeypatch) -> None: + """Missing SQL variables yield None columns rather than crashing.""" + zone_name = "Block shoebox Storey 0" + idf = _FakeIDF([zone_name]) + sql = _FakeSql({ + "Zone Lights Electricity Energy": pd.DataFrame(), + "Zone Electric Equipment Electricity Energy": pd.DataFrame(), + "Zone Ideal Loads Zone Total Heating Energy": pd.DataFrame(), + "Zone Ideal Loads Zone Total Cooling Energy": pd.DataFrame(), + }) + monkeypatch.setattr(zone_energy, "get_zone_floor_area", lambda _idf, _zone: 10.0) + + result = zone_energy_summary(cast(Any, sql), cast(Any, idf)) + + assert result.loc[0, "sim_lighting_site_kwh"] is None + assert result.loc[0, "sim_total_included_kwh"] is None diff --git a/tests/test_sbem/conftest.py b/tests/test_sbem/conftest.py new file mode 100644 index 0000000..dbc6b39 --- /dev/null +++ b/tests/test_sbem/conftest.py @@ -0,0 +1,64 @@ +"""Fixtures for floor-aware SBEM tests.""" + +import pytest + +from epinterface.sbem.zone_assignment import ZoneTemplate + + +@pytest.fixture +def base_zone_template() -> ZoneTemplate: + """Return a simple valid zone template for floor-assignment tests.""" + return ZoneTemplate( + FacadeRValue=3.0, + RoofRValue=3.0, + SlabRValue=3.0, + WindowUValue=3.0, + WindowSHGF=0.7, + WindowTVis=0.5, + WWR=0.2, + 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.01, + 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, + 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, + ) diff --git a/tests/test_sbem/test_flat_model_compat.py b/tests/test_sbem/test_flat_model_compat.py new file mode 100644 index 0000000..528a51d --- /dev/null +++ b/tests/test_sbem/test_flat_model_compat.py @@ -0,0 +1,117 @@ +"""Regression tests for the legacy FlatModel -> BuildingFlatModel adapter. + +These guard the compatibility path called out in the PR review: ``ZoneTemplate`` +forbids extras, so ``FlatModel.zone_template()`` must project onto only the +template fields, and a uniform ``FlatModel`` converted to a ``BuildingFlatModel`` +must produce an equivalent IDF (same lighting power density and window-to-wall +ratio). +""" + +from pathlib import Path + +import pytest + +from epinterface.data import DefaultEPWZipPath +from epinterface.geometry import ( + get_zone_exterior_wall_area, + get_zone_glazed_area, +) +from epinterface.sbem.builder import SimulationPathConfig +from epinterface.sbem.building_flat_model import BuildingFlatModel +from epinterface.sbem.flat_model import FlatModel +from epinterface.sbem.zone_assignment import ZoneTemplate + + +@pytest.fixture +def uniform_flat_model(base_zone_template: ZoneTemplate) -> FlatModel: + """A uniform FlatModel sharing the conftest template's operating params.""" + return FlatModel( + **base_zone_template.model_dump(), + F2FHeight=3.0, + NFloors=1, + Width=10.0, + Depth=10.0, + Rotation=0.0, + EPWURI=DefaultEPWZipPath, + zoning="by_storey", + ) + + +def _single_zone_lpd(idf) -> float: + """Return the lighting power density for the single LIGHTS object.""" + lights = idf.idfobjects["LIGHTS"] + assert len(lights) == 1 + watts_field = next( + name + for name in lights[0].fieldnames + if name + in { + "Watts_per_Zone_Floor_Area", + "Watts_per_Space_Floor_Area", + "Watts_per_Floor_Area", + } + ) + return float(getattr(lights[0], watts_field)) + + +def _single_zone_actual_wwr(idf) -> float: + """Return glazed/exterior-wall ratio for the single main zone.""" + zone_name = idf.idfobjects["ZONE"][0].Name + glazed = get_zone_glazed_area(idf, zone_name) + wall = get_zone_exterior_wall_area(idf, zone_name) + return glazed / wall + + +def test_flat_model_zone_template_succeeds( + uniform_flat_model: FlatModel, + base_zone_template: ZoneTemplate, +) -> None: + """zone_template() projects onto ZoneTemplate without tripping extra=forbid.""" + template = uniform_flat_model.zone_template() + assert isinstance(template, ZoneTemplate) + assert template.LightingPowerDensity == base_zone_template.LightingPowerDensity + assert template.EquipmentPowerDensity == base_zone_template.EquipmentPowerDensity + assert template.WWR == base_zone_template.WWR + + +def test_flat_model_to_building_model_preserves_template( + uniform_flat_model: FlatModel, +) -> None: + """to_building_model() keeps zoning, floor count, and template values.""" + building = uniform_flat_model.to_building_model() + assert isinstance(building, BuildingFlatModel) + assert building.shell.zoning == uniform_flat_model.zoning + assert building.shell.NFloors == uniform_flat_model.NFloors + assert ( + building.defaults.LightingPowerDensity + == uniform_flat_model.LightingPowerDensity + ) + assert building.defaults.WWR == uniform_flat_model.WWR + + +def test_uniform_flat_model_and_building_model_have_same_lpd_and_wwr( + tmp_path: Path, + uniform_flat_model: FlatModel, +) -> None: + """A uniform FlatModel and its converted BuildingFlatModel build equivalently.""" + flat_model_obj, callback = uniform_flat_model.to_model() + flat_idf = flat_model_obj.build( + SimulationPathConfig(output_dir=tmp_path / "flat"), + post_geometry_callback=callback, + ) + + building = uniform_flat_model.to_building_model() + building_idf = building.build_idf(output_dir=tmp_path / "building") + + assert _single_zone_lpd(flat_idf) == pytest.approx(_single_zone_lpd(building_idf)) + assert _single_zone_lpd(building_idf) == pytest.approx( + uniform_flat_model.LightingPowerDensity + ) + # WWR is applied geometrically (whole-shoebox vs per-zone), so compare the + # as-built glazing ratio rather than exact areas. + assert _single_zone_actual_wwr(flat_idf) == pytest.approx( + _single_zone_actual_wwr(building_idf), rel=0.05 + ) + assert _single_zone_actual_wwr(building_idf) == pytest.approx( + uniform_flat_model.WWR, rel=0.1 + ) diff --git a/tests/test_sbem/test_floor_assignment_build.py b/tests/test_sbem/test_floor_assignment_build.py new file mode 100644 index 0000000..7bf1c99 --- /dev/null +++ b/tests/test_sbem/test_floor_assignment_build.py @@ -0,0 +1,382 @@ +"""IDF-build tests for floor-aware assignment models.""" + +from pathlib import Path +from typing import cast + +import pytest + +from epinterface.data import DefaultEPWZipPath +from epinterface.geometry import get_zone_glazed_area +from epinterface.sbem.building_flat_model import BuildingFlatModel, BuildingShell +from epinterface.sbem.zone_assignment import ( + FloorBand, + PartialZoneTemplate, + ZoneRole, +) + + +def _light_power_by_zone(idf) -> dict[str, float]: + """Return LIGHTS density keyed by assigned zone name.""" + out: dict[str, float] = {} + for lights in idf.idfobjects["LIGHTS"]: + zone_field = next( + name + for name in lights.fieldnames + if "Zone_or_ZoneList" in name or "Zone_or_Space" in name + ) + watts_field = next( + name + for name in lights.fieldnames + if name + in { + "Watts_per_Zone_Floor_Area", + "Watts_per_Space_Floor_Area", + "Watts_per_Floor_Area", + } + ) + out[str(getattr(lights, zone_field))] = float(getattr(lights, watts_field)) + return out + + +def _exterior_wall_constructions_by_zone(idf) -> dict[str, set[str]]: + """Return exterior wall construction names by zone.""" + out: dict[str, set[str]] = {} + for surface in idf.idfobjects["BUILDINGSURFACE:DETAILED"]: + if ( + str(surface.Surface_Type).lower() == "wall" + and str(surface.Outside_Boundary_Condition).lower() == "outdoors" + ): + out.setdefault(str(surface.Zone_Name), set()).add( + str(surface.Construction_Name) + ) + return out + + +def _window_constructions_by_zone(idf) -> dict[str, set[str]]: + """Return fenestration construction names by parent wall zone.""" + walls = { + str(wall.Name): str(wall.Zone_Name) + for wall in idf.idfobjects["BUILDINGSURFACE:DETAILED"] + } + out: dict[str, set[str]] = {} + for window in idf.idfobjects["FENESTRATIONSURFACE:DETAILED"]: + zone_name = walls[str(window.Building_Surface_Name)] + out.setdefault(zone_name, set()).add(str(window.Construction_Name)) + return out + + +def test_by_storey_floor_assignments_change_lpd_wwr_and_constructions( + tmp_path: Path, + base_zone_template, +) -> None: + """By-storey floor bands visibly change IDF loads, windows, and constructions.""" + model = BuildingFlatModel( + shell=BuildingShell( + EPWURI=DefaultEPWZipPath, + zoning="by_storey", + Width=10, + Depth=10, + F2FHeight=3, + NFloors=2, + ), + defaults=base_zone_template, + floor_bands=[ + FloorBand( + start=0, + stop=1, + template=PartialZoneTemplate( + LightingPowerDensity=5, + WWR=0.1, + FacadeRValue=3.0, + WindowUValue=2.0, + ), + ), + FloorBand( + start=1, + stop=2, + template=PartialZoneTemplate( + LightingPowerDensity=15, + WWR=0.5, + FacadeRValue=5.0, + WindowUValue=4.0, + ), + ), + ], + ) + + idf = model.build_idf(output_dir=tmp_path) + light_power = _light_power_by_zone(idf) + zone_0 = "Block shoebox Storey 0" + zone_1 = "Block shoebox Storey 1" + + assert light_power[zone_0] == 5 + assert light_power[zone_1] == 15 + assert get_zone_glazed_area(idf, zone_1) > get_zone_glazed_area(idf, zone_0) * 4 + + wall_constructions = _exterior_wall_constructions_by_zone(idf) + window_constructions = _window_constructions_by_zone(idf) + assert wall_constructions[zone_0] != wall_constructions[zone_1] + assert window_constructions[zone_0] != window_constructions[zone_1] + + +def test_core_perim_floor_and_role_assignments( + tmp_path: Path, + base_zone_template, +) -> None: + """Core/perim models apply floor templates to all roles, then role overrides.""" + model = BuildingFlatModel( + shell=BuildingShell( + EPWURI=DefaultEPWZipPath, + zoning="core/perim", + Width=12, + Depth=12, + F2FHeight=3, + NFloors=2, + ), + defaults=base_zone_template, + floor_bands=[ + FloorBand( + start=0, + stop=1, + template=PartialZoneTemplate(LightingPowerDensity=5, WWR=0.1), + ), + FloorBand( + start=1, + stop=2, + template=PartialZoneTemplate(LightingPowerDensity=10, WWR=0.4), + role_overrides={ + ZoneRole.core: PartialZoneTemplate(LightingPowerDensity=4) + }, + ), + ], + ) + + idf = model.build_idf(output_dir=tmp_path) + light_power = _light_power_by_zone(idf) + + assert len(light_power) == 10 + assert light_power["Block Core_Zone Storey 1"] == 4 + assert light_power["Block Perimeter_Zone_1 Storey 1"] == 10 + assert light_power["Block Perimeter_Zone_1 Storey 0"] == 5 + assert get_zone_glazed_area(idf, "Block Perimeter_Zone_1 Storey 1") > ( + get_zone_glazed_area(idf, "Block Perimeter_Zone_1 Storey 0") * 3 + ) + + +_MONTHS = ( + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +) + + +def _ground_temperatures(idf) -> list[float]: + """Return the 12 monthly site ground temperatures from the built IDF.""" + objs = idf.idfobjects["SITE:GROUNDTEMPERATURE:BUILDINGSURFACE"] + assert len(objs) == 1 + return [float(getattr(objs[0], f"{month}_Ground_Temperature")) for month in _MONTHS] + + +def _names_for_type(idf, key: str) -> list[str]: + """Return the identifier field of every object of a given IDF type. + + Most objects use ``Name`` as their first field, but some (e.g. + ``HVACTEMPLATE:ZONE:IDEALLOADSAIRSYSTEM``) are keyed by ``Zone_Name``. The + first field value (index 1, after the object-type key) is the identifier in + every case, so use that uniformly. + """ + names: list[str] = [] + for obj in idf.idfobjects[key]: + values = obj.fieldvalues + names.append(str(values[1] if len(values) > 1 else values[0])) + return names + + +def test_ground_temperature_only_driven_by_ground_contact_floor( + tmp_path: Path, + base_zone_template, +) -> None: + """Only the lowest (ground-contact) floor's setpoints drive ground temps. + + Two by-storey buildings share an identical floor 0 but differ wildly on floor + 1's heating setpoint. Because ground-contact heat transfer is driven by floor + 0 only, the computed site ground temperatures must be identical. + """ + + def build(top_floor_hsp: float): + model = BuildingFlatModel( + shell=BuildingShell( + EPWURI=DefaultEPWZipPath, + zoning="by_storey", + Width=10, + Depth=10, + F2FHeight=3, + NFloors=2, + ), + defaults=base_zone_template, + floor_bands=[ + FloorBand( + start=0, + stop=1, + template=PartialZoneTemplate(HeatingSetpointBase=21), + ), + FloorBand( + start=1, + stop=2, + template=PartialZoneTemplate(HeatingSetpointBase=top_floor_hsp), + ), + ], + ) + return model.build_idf(output_dir=tmp_path / f"hsp_{int(top_floor_hsp)}") + + same_top = _ground_temperatures(build(21)) + cold_top = _ground_temperatures(build(5)) + + assert same_top == pytest.approx(cold_top) + + +def test_heterogeneous_core_perim_object_names_are_unique( + tmp_path: Path, + base_zone_template, +) -> None: + """A heterogeneous core/perim build must not produce duplicate object names.""" + model = BuildingFlatModel( + shell=BuildingShell( + EPWURI=DefaultEPWZipPath, + zoning="core/perim", + Width=12, + Depth=12, + F2FHeight=3, + NFloors=2, + ), + defaults=base_zone_template, + floor_bands=[ + FloorBand( + start=0, + stop=1, + template=PartialZoneTemplate( + LightingPowerDensity=5, + EquipmentPowerDensity=8, + WWR=0.1, + FacadeRValue=3.0, + WindowUValue=2.0, + ), + ), + FloorBand( + start=1, + stop=2, + template=PartialZoneTemplate( + LightingPowerDensity=12, + EquipmentPowerDensity=20, + WWR=0.4, + FacadeRValue=5.0, + WindowUValue=4.0, + ), + role_overrides={ + ZoneRole.core: PartialZoneTemplate(LightingPowerDensity=3) + }, + ), + ], + ) + + idf = model.build_idf(output_dir=tmp_path) + + checked_types = [ + "LIGHTS", + "PEOPLE", + "ELECTRICEQUIPMENT", + "ZONEINFILTRATION:DESIGNFLOWRATE", + "HVACTEMPLATE:ZONE:IDEALLOADSAIRSYSTEM", + "HVACTEMPLATE:THERMOSTAT", + "SCHEDULE:YEAR", + "SCHEDULE:WEEK:DAILY", + "CONSTRUCTION", + ] + present = False + for key in checked_types: + names = _names_for_type(idf, key) + if not names: + continue + present = True + duplicates = {name for name in names if names.count(name) > 1} + assert not duplicates, f"Duplicate {key} names: {sorted(duplicates)}" + assert present, "Expected at least one of the checked object types to be present." + + +def test_core_perim_floor_summary_collapses_to_one_row_per_floor( + tmp_path: Path, + base_zone_template, +) -> None: + """Ten core/perim zones aggregate to exactly NFloors floor rows. + + Also checks the as-built WWR reporting: core zones (no exterior walls) report + actual_wwr == 0, and the floor-level actual_wwr reflects the perimeter facade. + """ + model = BuildingFlatModel( + shell=BuildingShell( + EPWURI=DefaultEPWZipPath, + zoning="core/perim", + Width=12, + Depth=12, + F2FHeight=3, + NFloors=2, + ), + defaults=base_zone_template, + floor_bands=[ + FloorBand(start=0, stop=1, template=PartialZoneTemplate(WWR=0.1)), + FloorBand(start=1, stop=2, template=PartialZoneTemplate(WWR=0.4)), + ], + ) + + idf = model.build_idf(output_dir=tmp_path) + zone_summary = model.zone_assignment_summary(idf=idf) + floor_summary = model.floor_assignment_summary(idf=idf) + + main_zones = zone_summary[zone_summary["category"] == "main"] + assert len(main_zones) == 10 + assert set(floor_summary["floor_index"]) == {0, 1} + + # Core zones have no exterior walls, so their as-built WWR is 0 regardless of + # the requested WWR; perimeter zones carry the glazing. + core_rows = zone_summary[zone_summary["role"] == "core"] + assert (core_rows["actual_wwr"] == 0).all() + + # The floor-level as-built WWR is wall-area-weighted and follows the request. + floor_summary = floor_summary.set_index("floor_index") + wwr_floor_1 = cast(float, floor_summary.loc[1, "actual_wwr"]) + wwr_floor_0 = cast(float, floor_summary.loc[0, "actual_wwr"]) + assert wwr_floor_1 > wwr_floor_0 + + +def test_invalid_role_for_zoning_is_rejected(base_zone_template) -> None: + """A core role override is not valid for by-storey zoning.""" + with pytest.raises(ValueError, match="Invalid role override"): + BuildingFlatModel( + shell=BuildingShell( + EPWURI=DefaultEPWZipPath, + zoning="by_storey", + Width=10, + Depth=10, + F2FHeight=3, + NFloors=2, + ), + defaults=base_zone_template, + floor_bands=[ + FloorBand( + start=0, + stop=1, + role_overrides={ + ZoneRole.core: PartialZoneTemplate(LightingPowerDensity=4) + }, + ) + ], + ) diff --git a/tests/test_sbem/test_zone_assignment.py b/tests/test_sbem/test_zone_assignment.py new file mode 100644 index 0000000..280261f --- /dev/null +++ b/tests/test_sbem/test_zone_assignment.py @@ -0,0 +1,232 @@ +"""Tests for typed zone assignment resolution.""" + +import pytest +from pydantic import ValidationError + +from epinterface.geometry import ShoeboxGeometry +from epinterface.sbem.zone_assignment import ( + FloorBand, + PartialZoneTemplate, + ZoneAssignmentResolver, + ZoneRole, + parse_zone_key, +) + + +def test_parse_zone_key_by_storey() -> None: + """By-storey zones resolve to floor roles and zero-based floor indices.""" + geometry = ShoeboxGeometry( + x=0, + y=0, + w=10, + d=10, + h=3, + num_stories=2, + zoning="by_storey", + wwr=0.2, + ) + + key = parse_zone_key("Block shoebox Storey 1", geometry) + + assert key.ep_storey_index == 1 + assert key.floor_index == 1 + assert key.role == ZoneRole.floor + assert key.category == "main" + + +def test_parse_zone_key_core_perim() -> None: + """Core/perim zones resolve to their perimeter or core role.""" + geometry = ShoeboxGeometry( + x=0, + y=0, + w=12, + d=12, + h=3, + num_stories=2, + zoning="core/perim", + wwr=0.2, + ) + + key = parse_zone_key("Block Perimeter_Zone_3 Storey 0", geometry) + core_key = parse_zone_key("Block Core_Zone Storey 1", geometry) + + assert key.floor_index == 0 + assert key.role == ZoneRole.perim_3 + assert core_key.floor_index == 1 + assert core_key.role == ZoneRole.core + + +def test_resolver_precedence(base_zone_template) -> None: + """Role overrides take precedence over floor templates and defaults.""" + resolver = ZoneAssignmentResolver( + defaults=base_zone_template, + n_floors=2, + floor_bands=[ + FloorBand( + start=1, + stop=2, + template=PartialZoneTemplate(LightingPowerDensity=12, WWR=0.4), + role_overrides={ + ZoneRole.core: PartialZoneTemplate(LightingPowerDensity=4) + }, + ) + ], + ) + geometry = ShoeboxGeometry( + x=0, + y=0, + w=12, + d=12, + h=3, + num_stories=2, + zoning="core/perim", + wwr=0.2, + ) + + core_params = resolver.resolve_zone("Block Core_Zone Storey 1", geometry).params + perim_params = resolver.resolve_zone( + "Block Perimeter_Zone_1 Storey 1", geometry + ).params + default_params = resolver.resolve_zone( + "Block Perimeter_Zone_1 Storey 0", geometry + ).params + + assert core_params.LightingPowerDensity == 4 + assert core_params.WWR == 0.4 + assert perim_params.LightingPowerDensity == 12 + assert ( + default_params.LightingPowerDensity == base_zone_template.LightingPowerDensity + ) + + +def test_floor_band_validation_rejects_overlap(base_zone_template) -> None: + """Overlapping floor bands fail during resolver validation.""" + with pytest.raises(ValueError, match="Overlapping floor bands"): + ZoneAssignmentResolver( + defaults=base_zone_template, + n_floors=3, + floor_bands=[ + FloorBand(start=0, stop=2), + FloorBand(start=1, stop=3), + ], + ) + + +def test_partial_template_rejects_unknown_fields() -> None: + """Sparse templates are typed and reject unknown override fields.""" + with pytest.raises(ValidationError): + PartialZoneTemplate.model_validate({"NotARealField": 1}) + + +@pytest.mark.parametrize("field", ["RoofRValue", "SlabRValue"]) +def test_partial_template_rejects_boundary_envelope_fields(field: str) -> None: + """Roof/slab R-values cannot be set per floor; they are building-wide. + + They are deliberately absent from PartialZoneTemplate so an override that + could never reach the IDF fails at model-creation time instead of silently. + """ + with pytest.raises(ValidationError): + PartialZoneTemplate.model_validate({field: 3.0}) + + +def test_floor_band_rejects_wwr_override_on_core() -> None: + """Core zones have no exterior walls, so a core WWR override is rejected.""" + with pytest.raises(ValueError, match="WWR cannot be overridden on core"): + FloorBand( + start=0, + stop=1, + role_overrides={ZoneRole.core: PartialZoneTemplate(WWR=0.4)}, + ) + + +def test_floor_band_allows_non_wwr_core_override() -> None: + """A core role override that does not touch WWR is allowed.""" + band = FloorBand( + start=0, + stop=1, + role_overrides={ZoneRole.core: PartialZoneTemplate(LightingPowerDensity=4)}, + ) + assert band.role_overrides[ZoneRole.core].LightingPowerDensity == 4 + + +def test_parse_zone_key_attic() -> None: + """Attic zones parse to the attic category with no above-grade floor index.""" + geometry = ShoeboxGeometry( + x=0, y=0, w=10, d=10, h=3, num_stories=2, zoning="by_storey", wwr=0.2 + ) + key = parse_zone_key("Block shoebox attic", geometry) + assert key.category == "attic" + assert key.role == ZoneRole.attic + assert key.floor_index is None + + +def test_parse_zone_key_basement_by_storey() -> None: + """A by-storey basement (Storey -1) parses to the basement category.""" + geometry = ShoeboxGeometry( + x=0, + y=0, + w=10, + d=10, + h=3, + num_stories=2, + zoning="by_storey", + wwr=0.2, + basement=True, + ) + key = parse_zone_key("Block shoebox Storey -1", geometry) + assert key.category == "basement" + assert key.floor_index is None + + above = parse_zone_key("Block shoebox Storey 0", geometry) + assert above.category == "main" + assert above.floor_index == 0 + + +def test_parse_zone_key_basement_core_perim_offsets_floor_index() -> None: + """With a core/perim basement, above-grade floor indices start at 0.""" + geometry = ShoeboxGeometry( + x=0, + y=0, + w=12, + d=12, + h=3, + num_stories=2, + zoning="core/perim", + wwr=0.2, + basement=True, + ) + basement = parse_zone_key("Block Core_Zone Storey 0", geometry) + assert basement.category == "basement" + assert basement.floor_index is None + + first_floor = parse_zone_key("Block Core_Zone Storey 1", geometry) + assert first_floor.category == "main" + assert first_floor.floor_index == 0 + + +def test_resolver_scales_unconditioned_basement_loads(base_zone_template) -> None: + """Unconditioned basements scale loads by use fraction and disable systems.""" + resolver = ZoneAssignmentResolver(defaults=base_zone_template, n_floors=2) + geometry = ShoeboxGeometry( + x=0, + y=0, + w=10, + d=10, + h=3, + num_stories=2, + zoning="by_storey", + wwr=0.2, + basement=True, + ) + params = resolver.resolve_zone( + "Block shoebox Storey -1", + geometry, + basement_use_fraction=0.5, + basement_conditioned=False, + ).params + + assert params.LightingPowerDensity == base_zone_template.LightingPowerDensity * 0.5 + assert params.OccupantDensity == base_zone_template.OccupantDensity * 0.5 + assert params.VentProvider == "None" + assert params.IdealLoadsHeatingOn is False + assert params.IdealLoadsCoolingOn is False