Source code for ecoengine.objects.building.ClimateZone

from __future__ import annotations

import csv
import importlib.resources as pkg_resources

# ---------------------------------------------------------------------------
# Module-level helpers
# ---------------------------------------------------------------------------

# First day-of-year (0-indexed) for each month (0 = January, 11 = December).
# Used to convert a day-of-year into a month index for inlet-water lookups.
_MONTH_START_DAY = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]

_CLIMATE_DATA_PKG = 'ecoengine.data.climate_data'


def _open_climate_csv(filename: str):
    """Return an open text stream for a file in the climate_data package."""
    return (pkg_resources.files(_CLIMATE_DATA_PKG) / filename).open('r', newline='')


def _day_of_year_to_month(day_of_year: int) -> int:
    """
    Convert a 0-indexed day-of-year (0-364) to a 0-indexed month (0-11).
    January = 0, December = 11.
    """
    for month in range(11, -1, -1):
        if day_of_year >= _MONTH_START_DAY[month]:
            return month
    return 0  # shouldn't be reached for valid input


# ---------------------------------------------------------------------------
# ClimateZone
# ---------------------------------------------------------------------------

[docs] class ClimateZone: """ Stores outdoor air temperature (OAT) and cold/inlet water temperature for a building's location over a typical meteorological year. OAT is stored hourly (8,760 values — one per hour of the year). Inlet water temp is stored monthly (12 values — one per month). The simulator queries this object at every timestep to obtain ambient conditions for heat-pump performance and load calculations. """
[docs] def __init__( self, zone_id: int | None, oat_f_by_hour: list[float] | None, inlet_water_temp_f_by_month: list[float] | None, constant_oat_f: float | None = None, constant_inlet_water_temp_f: float | None = None, ) -> None: """ Parameters ---------- zone_id : int | None Numeric climate zone identifier (1-96 for OAT data; 1-19 for full CA data including kG/kWh). None for design-condition zones. oat_f_by_hour : list[float] | None Outdoor air temperatures for every hour of a typical year [°F]. 8,760 elements for real zones; None for design-condition zones. inlet_water_temp_f_by_month : list[float] | None Average cold-water inlet temperature for each calendar month [°F]. 12 elements for real zones; None for design-condition zones. constant_oat_f : float | None If set, all OAT queries return this value instead of reading from oat_f_by_hour. Set automatically by from_design_conditions(). constant_inlet_water_temp_f : float | None If set, all inlet water temp queries return this value instead of reading from inlet_water_temp_f_by_month. Set automatically by from_design_conditions(). """ self.zone_id = zone_id self._oat_f_by_hour = oat_f_by_hour self._inlet_water_temp_f_by_month = inlet_water_temp_f_by_month self._constant_oat_f = constant_oat_f self._constant_inlet_water_temp_f = constant_inlet_water_temp_f
# ------------------------------------------------------------------ # Factory constructors # ------------------------------------------------------------------
[docs] @classmethod def from_zip_code(cls, zip_code: str | int) -> ClimateZone: """ Construct a ClimateZone by looking up the CA climate zone for a zip code and loading the corresponding weather data. Parameters ---------- zip_code : str | int A California 5-digit zip code. Returns ------- ClimateZone Raises ------ ValueError If the zip code is not found in the CA lookup table. """ zone_id = cls._lookup_zone_for_zip(zip_code) return cls.from_zone_id(zone_id)
[docs] @classmethod def from_weather_station(cls, station_id: str) -> ClimateZone: """ Construct a ClimateZone from a named weather station. Parameters ---------- station_id : str Weather station name exactly as it appears in the lookup CSV (e.g. ``'ID - Boise Air Terminal'``). Returns ------- ClimateZone Raises ------ ValueError If the station name is not found in the lookup table. """ zone_id = cls._lookup_zone_for_station(station_id) return cls.from_zone_id(zone_id)
[docs] @classmethod def from_zone_id(cls, zone_id: int) -> ClimateZone: """ Construct a ClimateZone directly from a numeric climate zone ID. Parameters ---------- zone_id : int Climate zone number (1-96 for OAT data; 1-19 for full CA data). Returns ------- ClimateZone """ oat_f_by_hour = cls._load_oat_data(zone_id) inlet_water_temp_f_by_month = cls._load_inlet_water_data(zone_id) return cls(zone_id, oat_f_by_hour, inlet_water_temp_f_by_month)
@classmethod def _from_zone_id(cls, zone_id: int) -> ClimateZone: """Internal alias for from_zone_id, used by from_zip_code / from_weather_station.""" return cls.from_zone_id(zone_id)
[docs] @classmethod def from_design_conditions( cls, design_oat_f: float | None = None, design_inlet_water_temp_f: float | None = None, ) -> ClimateZone: """ Create a constant-condition ClimateZone for use when real climate data is unavailable. Every OAT query returns design_oat_f; every inlet water temp query returns design_inlet_water_temp_f. The design values are also returned by get_design_oat_f() and get_design_inlet_water_temp_f() directly. Parameters ---------- design_oat_f : float | None Constant outdoor air temperature to return for all timesteps [°F]. design_inlet_water_temp_f : float | None Constant cold-water inlet temperature to return for all timesteps [°F]. Returns ------- ClimateZone """ return cls( zone_id=None, oat_f_by_hour=None, inlet_water_temp_f_by_month=None, constant_oat_f=design_oat_f, constant_inlet_water_temp_f=design_inlet_water_temp_f, )
# ------------------------------------------------------------------ # Per-timestep queries (called by the simulator at every step) # ------------------------------------------------------------------
[docs] def get_oat_f(self, timestep_interval: int, interval_min: int = 1) -> float: """ Return outdoor air temperature at the given simulation timestep. OAT data is stored hourly, so this rounds down to the nearest hour. Wraps around yearly (safe for multi-year or 3-day simulations that start mid-year). Parameters ---------- timestep_interval : int Number of intervals elapsed from the start of the simulation. interval_min : int Length of each interval in minutes. Defaults to 1 (minute-resolution). Example: timestep_interval=3, interval_min=15 → minute 45. Returns ------- float Outdoor air temperature [°F]. """ if self._constant_oat_f is not None: return self._constant_oat_f actual_minute = timestep_interval * interval_min hour_of_year = (actual_minute // 60) % len(self._oat_f_by_hour) return self._oat_f_by_hour[hour_of_year]
[docs] def get_inlet_water_temp_f(self, timestep_interval: int, interval_min: int = 1) -> float: """ Return cold/inlet water temperature at the given simulation timestep. Inlet water temperature is stored as a monthly average, so the timestep is converted to a day-of-year and then to a month. Parameters ---------- timestep_interval : int Number of intervals elapsed from the start of the simulation. interval_min : int Length of each interval in minutes. Defaults to 1 (minute-resolution). Example: timestep_interval=3, interval_min=15 → minute 45. Returns ------- float Inlet water temperature [°F]. """ if self._constant_inlet_water_temp_f is not None: return self._constant_inlet_water_temp_f actual_minute = timestep_interval * interval_min day_of_year = (actual_minute // (60 * 24)) % 365 month = _day_of_year_to_month(day_of_year) return self._inlet_water_temp_f_by_month[month]
# ------------------------------------------------------------------ # Design-condition queries (used during sizing, not simulation) # ------------------------------------------------------------------
[docs] def get_design_oat_f(self) -> float: """ Return the design-day outdoor air temperature [°F]. For real climate zones: the annual minimum OAT (worst-case heating condition for heat-pump sizing). For design-condition zones: the constant value set at construction. """ if self._constant_oat_f is not None: return self._constant_oat_f return min(self._oat_f_by_hour)
[docs] def get_design_inlet_water_temp_f(self) -> float: """ Return the design-day cold water inlet temperature [°F]. For real climate zones: the coldest monthly average (worst-case sizing condition). For design-condition zones: the constant value set at construction. """ if self._constant_inlet_water_temp_f is not None: return self._constant_inlet_water_temp_f return min(self._inlet_water_temp_f_by_month)
[docs] def get_oat_buckets(self) -> dict[float, int]: """ Return the distribution of daily average outdoor air temperatures across 5°F buckets for the full typical meteorological year. For each of the 365 days in the year the 24 hourly OAT values are averaged to produce a daily mean, which is then assigned to the nearest lower multiple of 5°F (e.g. 62.4°F → 60.0°F bucket). Returns ------- dict[float, int] Mapping of bucket temperature [°F] → number of days in that bucket. Only buckets with at least one day are included. Raises ------ ValueError If this ClimateZone was created with ``from_design_conditions()`` and has no real hourly OAT data. """ if self._oat_f_by_hour is None: raise ValueError( "get_oat_buckets() requires real hourly OAT data. " "This ClimateZone was constructed from design conditions " "and has no annual weather data to bucket." ) buckets: dict[float, int] = {} for day in range(365): start = day * 24 daily_avg = sum(self._oat_f_by_hour[start : start + 24]) / 24.0 bucket = float((daily_avg // 5) * 5) buckets[bucket] = buckets.get(bucket, 0) + 1 return buckets
# ------------------------------------------------------------------ # CSV loading helpers (private) # ------------------------------------------------------------------ @classmethod def _lookup_zone_for_zip(cls, zip_code: str | int) -> int: """ Search ZipCode_ClimateZone_Lookup.csv for the given zip code. Returns ------- int Climate zone ID. Raises ------ ValueError If the zip code is not in the table. """ zip_str = str(zip_code).strip() with _open_climate_csv('ZipCode_ClimateZone_Lookup.csv') as f: reader = csv.DictReader(f) for row in reader: if row['Zip Code'].strip() == zip_str: return int(row['Building CZ']) raise ValueError( f"Zip code '{zip_code}' was not found in the CA climate zone lookup. " f"Only California zip codes are supported." ) @classmethod def _lookup_zone_for_station(cls, station_id: str) -> int: """ Search WeatherStation_ClimateZone_Lookup.csv for the given station name. Returns ------- int Climate zone ID. Raises ------ ValueError If the station name is not in the table. """ station_str = str(station_id).strip() with _open_climate_csv('WeatherStation_ClimateZone_Lookup.csv') as f: reader = csv.DictReader(f) for row in reader: if row['Weather Station'].strip() == station_str: return int(row['Building CZ']) raise ValueError( f"Weather station '{station_id}' was not found in the lookup table." ) @classmethod def _load_oat_data(cls, zone_id: int) -> list[float]: """ Load the full-year hourly OAT sequence for the given zone from DryBulbTemperatures_ByClimateZone.csv. The CSV has one column per climate zone (headers 1-96) and one row per hour of the year (8,760 data rows, not including the header). Returns ------- list[float] 8,760 hourly OAT values [°F]. """ col_index = zone_id - 1 # CSV columns are 1-indexed oat_values = [] with _open_climate_csv('DryBulbTemperatures_ByClimateZone.csv') as f: reader = csv.reader(f) next(reader) # skip header row for row in reader: oat_values.append(float(row[col_index])) return oat_values @classmethod def _load_inlet_water_data(cls, zone_id: int) -> list[float]: """ Load the 12 monthly average inlet-water temperatures for the given zone from InletWaterTemperatures_ByClimateZone.csv. The CSV has one column per climate zone (headers 1-96) and one row per month (12 data rows, not including the header). Returns ------- list[float] 12 monthly average inlet water temperatures [°F], index 0 = January, index 11 = December. """ col_index = zone_id - 1 # CSV columns are 1-indexed monthly_temps = [] with _open_climate_csv('InletWaterTemperatures_ByClimateZone.csv') as f: reader = csv.reader(f) next(reader) # skip header row for row in reader: monthly_temps.append(float(row[col_index])) return monthly_temps