Skip to content

Design Days API

Design day parsing, classification, and model injection.

DesignDayManager

idfkit.weather.designday.DesignDayManager

Parse DDY files and inject design day conditions into IDF models.

A DDY file is a valid IDF-syntax file containing Site:Location and SizingPeriod:DesignDay objects. This class uses [idfkit.load_idf][idfkit.load_idf] to parse the file and classifies each design day by its ASHRAE condition type.

Parameters:

Name Type Description Default
ddy_path Path | str

Path to a .ddy file.

required
version tuple[int, int, int] | None

EnergyPlus version to use for schema resolution. Defaults to the latest supported version (design day fields are stable across versions).

None
Source code in src/idfkit/weather/designday.py
class DesignDayManager:
    """Parse DDY files and inject design day conditions into IDF models.

    A DDY file is a valid IDF-syntax file containing ``Site:Location`` and
    ``SizingPeriod:DesignDay`` objects.  This class uses `[idfkit.load_idf][idfkit.load_idf]`
    to parse the file and classifies each design day by its ASHRAE condition
    type.

    Args:
        ddy_path: Path to a ``.ddy`` file.
        version: EnergyPlus version to use for schema resolution.  Defaults
            to the latest supported version (design day fields are stable
            across versions).
    """

    __slots__ = ("_all_objects", "_design_days", "_doc", "_location", "_path", "_station")

    def __init__(
        self,
        ddy_path: Path | str,
        version: tuple[int, int, int] | None = None,
        *,
        station: WeatherStation | None = None,
    ) -> None:
        from ..idf_parser import parse_idf

        self._path = Path(ddy_path)
        self._doc = parse_idf(self._path, version=version or LATEST_VERSION)
        self._design_days: dict[DesignDayType, IDFObject] = {}
        self._all_objects: list[IDFObject] = []
        self._location: IDFObject | None = None
        self._station: WeatherStation | None = station
        self._parse()

    def _parse(self) -> None:
        """Parse and classify the design days in the document."""
        # Extract Site:Location
        if "Site:Location" in self._doc:
            collection = self._doc["Site:Location"]
            if len(collection) > 0:
                self._location = next(iter(collection))

        # Store all SizingPeriod:DesignDay objects and classify annual ones
        if "SizingPeriod:DesignDay" in self._doc:
            for dd in self._doc["SizingPeriod:DesignDay"]:
                self._all_objects.append(dd)
                dd_type = _classify_design_day(dd.name)
                if dd_type is not None:
                    self._design_days[dd_type] = dd

    @classmethod
    def from_station(
        cls,
        station: WeatherStation,
        *,
        dataset: str | None = None,
        version: tuple[int, int, int] | None = None,
    ) -> DesignDayManager:
        """Download the DDY file for a station and parse it.

        Args:
            station: The weather station.
            dataset: TMYx variant to download.  Defaults to the most recent.
            version: EnergyPlus version for schema resolution.
        """
        from .download import WeatherDownloader

        # If a specific dataset is requested we need to find the matching
        # station entry (same WMO, matching URL).  For now, download whatever
        # the station's URL points to.
        _ = dataset  # reserved for future dataset selection
        downloader = WeatherDownloader()
        ddy_path = downloader.get_ddy(station)
        return cls(ddy_path, version=version, station=station)

    # --- Accessors ----------------------------------------------------------

    @property
    def all_design_days(self) -> list[IDFObject]:
        """All ``SizingPeriod:DesignDay`` objects from the DDY file."""
        return list(self._all_objects)

    @property
    def annual(self) -> list[IDFObject]:
        """All classified annual design day objects."""
        return list(self._design_days.values())

    @property
    def monthly(self) -> list[IDFObject]:
        """All monthly design day objects.

        Monthly design days follow the naming pattern
        ``{Location} {Month} {pct}% Condns {type}`` and are not classified
        into the [DesignDayType][idfkit.weather.designday.DesignDayType] enum.
        """
        return [dd for dd in self._all_objects if _MONTH_PATTERN.search(dd.name)]

    def get(self, dd_type: DesignDayType) -> IDFObject | None:
        """Get a specific design day by type."""
        return self._design_days.get(dd_type)

    @property
    def heating(self) -> list[IDFObject]:
        """All heating design days."""
        return [dd for t, dd in self._design_days.items() if t.value.startswith("heating")]

    @property
    def cooling(self) -> list[IDFObject]:
        """All cooling dry-bulb, wet-bulb, enthalpy, and dehumidification design days."""
        return [dd for t, dd in self._design_days.items() if t.value.startswith(("cooling", "dehumid"))]

    @property
    def location(self) -> IDFObject | None:
        """The ``Site:Location`` object from the DDY file, if present."""
        return self._location

    def raise_if_empty(self) -> None:
        """Raise :exc:`NoDesignDaysError` if this DDY has no design days.

        When constructed via [from_station][idfkit.weather.designday.DesignDayManager.from_station], the error message includes
        suggestions for nearby stations that may have design day data.

        Raises:
            NoDesignDaysError: If ``all_design_days`` is empty.
        """
        if self._all_objects:
            return

        station_name: str | None = None
        nearby: list[str] = []

        # Extract station name from Site:Location if available
        if self._location is not None:
            station_name = self._location.name
        elif self._station is not None:
            station_name = self._station.display_name

        # Find nearby stations if we have coordinates
        if self._station is not None:
            from .index import StationIndex

            try:
                index = StationIndex.load()
                results = index.nearest(
                    self._station.latitude,
                    self._station.longitude,
                    limit=6,
                )
                for r in results:
                    # Skip the current station
                    if r.station.wmo == self._station.wmo:
                        continue
                    nearby.append(f"{r.station.display_name} (WMO {r.station.wmo}, {r.distance_km:.0f} km)")
                    if len(nearby) >= 5:
                        break
            except Exception:  # noqa: S110 - Don't let suggestion lookup break the error
                pass

        raise NoDesignDaysError(
            station_name=station_name,
            ddy_path=str(self._path),
            nearby_suggestions=nearby,
        )

    # --- Injection ----------------------------------------------------------

    @staticmethod
    def _select_types(
        *,
        heating: str,
        cooling: str,
        include_wet_bulb: bool,
        include_enthalpy: bool,
        include_dehumidification: bool,
        include_wind: bool,
    ) -> list[DesignDayType]:
        """Build the list of design day types to inject."""
        _P = DesignDayType
        selected: list[DesignDayType] = []

        # Heating
        if heating in ("99.6%", "both"):
            selected.append(_P.HEATING_99_6)
        if heating in ("99%", "both"):
            selected.append(_P.HEATING_99)

        # Cooling dry-bulb
        _cooling_db = {"0.4%": [_P.COOLING_DB_0_4], "1%": [_P.COOLING_DB_1], "2%": [_P.COOLING_DB_2]}
        _cooling_db["all"] = [_P.COOLING_DB_0_4, _P.COOLING_DB_1, _P.COOLING_DB_2]
        selected.extend(_cooling_db.get(cooling, []))

        # Cooling wet-bulb
        if include_wet_bulb:
            _wb = {"0.4%": [_P.COOLING_WB_0_4], "1%": [_P.COOLING_WB_1], "2%": [_P.COOLING_WB_2]}
            _wb["all"] = [_P.COOLING_WB_0_4, _P.COOLING_WB_1, _P.COOLING_WB_2]
            selected.extend(_wb.get(cooling, []))

        # Cooling enthalpy
        if include_enthalpy:
            _e = {"0.4%": [_P.COOLING_ENTH_0_4], "1%": [_P.COOLING_ENTH_1], "2%": [_P.COOLING_ENTH_2]}
            _e["all"] = [_P.COOLING_ENTH_0_4, _P.COOLING_ENTH_1, _P.COOLING_ENTH_2]
            selected.extend(_e.get(cooling, []))

        # Dehumidification
        if include_dehumidification:
            _d = {"0.4%": [_P.DEHUMID_0_4], "1%": [_P.DEHUMID_1], "2%": [_P.DEHUMID_2]}
            _d["all"] = [_P.DEHUMID_0_4, _P.DEHUMID_1, _P.DEHUMID_2]
            selected.extend(_d.get(cooling, []))

        # Wind (both OneBuilding "Htg Wind" and EnergyPlus "Coldest Month WS" formats)
        if include_wind:
            selected.extend([_P.HTG_WIND_99_6, _P.HTG_WIND_99, _P.WIND_0_4, _P.WIND_1])

        return selected

    def apply_to_model(
        self,
        model: IDFDocument,
        *,
        heating: Literal["99.6%", "99%", "both"] = "99.6%",
        cooling: Literal["0.4%", "1%", "2%", "all"] = "1%",
        include_wet_bulb: bool = False,
        include_enthalpy: bool = False,
        include_dehumidification: bool = False,
        include_wind: bool = False,
        update_location: bool = True,
        replace_existing: bool = True,
    ) -> list[str]:
        """Inject design day objects into an IDF model.

        Selects the appropriate design days based on common ASHRAE sizing
        practices and adds them as ``SizingPeriod:DesignDay`` objects.

        Args:
            model: The target [IDFDocument][idfkit.document.IDFDocument].
            heating: Which heating percentile to include.
            cooling: Which cooling dry-bulb percentile to include.
            include_wet_bulb: Also include cooling wet-bulb design days.
            include_enthalpy: Also include cooling enthalpy design days.
            include_dehumidification: Also include dehumidification design days.
            include_wind: Also include heating wind-speed design days.
            update_location: Update the ``Site:Location`` object to match the
                DDY file metadata.
            replace_existing: Remove existing ``SizingPeriod:DesignDay`` objects
                before adding new ones.

        Returns:
            List of design day names that were added.
        """
        selected_types = self._select_types(
            heating=heating,
            cooling=cooling,
            include_wet_bulb=include_wet_bulb,
            include_enthalpy=include_enthalpy,
            include_dehumidification=include_dehumidification,
            include_wind=include_wind,
        )

        # Remove existing design days if requested
        if replace_existing and "SizingPeriod:DesignDay" in model:
            existing = list(model["SizingPeriod:DesignDay"])
            model.removeidfobjects(existing)

        # Add selected design days (as copies so the DDY document is not mutated)
        added_names: list[str] = []
        for dd_type in selected_types:
            dd = self._design_days.get(dd_type)
            if dd is not None:
                model.copyidfobject(dd)
                added_names.append(dd.name)

        # Update Site:Location if requested
        if update_location and self._location is not None:
            if "Site:Location" in model:
                existing_locs = list(model["Site:Location"])
                model.removeidfobjects(existing_locs)
            model.copyidfobject(self._location)

        return added_names

    def summary(self) -> str:
        """Human-readable summary of all design days in the DDY file."""
        monthly_count = len(self.monthly)
        lines = [f"Design days from: {self._path.name}", ""]
        if self._location:
            lines.append(f"  Location: {self._location.name}")
        lines.append(f"  Design days found: {len(self._all_objects)}")
        lines.append(f"  Annual (classified): {len(self._design_days)}")
        if monthly_count:
            lines.append(f"  Monthly: {monthly_count}")
        lines.append("")
        for dd_type, dd in sorted(self._design_days.items(), key=lambda x: x[0].value):
            lines.append(f"  [{dd_type.value}] {dd.name}")
        return "\n".join(lines)

annual property

All classified annual design day objects.

monthly property

All monthly design day objects.

Monthly design days follow the naming pattern {Location} {Month} {pct}% Condns {type} and are not classified into the DesignDayType enum.

location property

The Site:Location object from the DDY file, if present.

get(dd_type)

Get a specific design day by type.

Source code in src/idfkit/weather/designday.py
def get(self, dd_type: DesignDayType) -> IDFObject | None:
    """Get a specific design day by type."""
    return self._design_days.get(dd_type)

apply_to_model(model, *, heating='99.6%', cooling='1%', include_wet_bulb=False, include_enthalpy=False, include_dehumidification=False, include_wind=False, update_location=True, replace_existing=True)

Inject design day objects into an IDF model.

Selects the appropriate design days based on common ASHRAE sizing practices and adds them as SizingPeriod:DesignDay objects.

Parameters:

Name Type Description Default
model IDFDocument

The target IDFDocument.

required
heating Literal['99.6%', '99%', 'both']

Which heating percentile to include.

'99.6%'
cooling Literal['0.4%', '1%', '2%', 'all']

Which cooling dry-bulb percentile to include.

'1%'
include_wet_bulb bool

Also include cooling wet-bulb design days.

False
include_enthalpy bool

Also include cooling enthalpy design days.

False
include_dehumidification bool

Also include dehumidification design days.

False
include_wind bool

Also include heating wind-speed design days.

False
update_location bool

Update the Site:Location object to match the DDY file metadata.

True
replace_existing bool

Remove existing SizingPeriod:DesignDay objects before adding new ones.

True

Returns:

Type Description
list[str]

List of design day names that were added.

Source code in src/idfkit/weather/designday.py
def apply_to_model(
    self,
    model: IDFDocument,
    *,
    heating: Literal["99.6%", "99%", "both"] = "99.6%",
    cooling: Literal["0.4%", "1%", "2%", "all"] = "1%",
    include_wet_bulb: bool = False,
    include_enthalpy: bool = False,
    include_dehumidification: bool = False,
    include_wind: bool = False,
    update_location: bool = True,
    replace_existing: bool = True,
) -> list[str]:
    """Inject design day objects into an IDF model.

    Selects the appropriate design days based on common ASHRAE sizing
    practices and adds them as ``SizingPeriod:DesignDay`` objects.

    Args:
        model: The target [IDFDocument][idfkit.document.IDFDocument].
        heating: Which heating percentile to include.
        cooling: Which cooling dry-bulb percentile to include.
        include_wet_bulb: Also include cooling wet-bulb design days.
        include_enthalpy: Also include cooling enthalpy design days.
        include_dehumidification: Also include dehumidification design days.
        include_wind: Also include heating wind-speed design days.
        update_location: Update the ``Site:Location`` object to match the
            DDY file metadata.
        replace_existing: Remove existing ``SizingPeriod:DesignDay`` objects
            before adding new ones.

    Returns:
        List of design day names that were added.
    """
    selected_types = self._select_types(
        heating=heating,
        cooling=cooling,
        include_wet_bulb=include_wet_bulb,
        include_enthalpy=include_enthalpy,
        include_dehumidification=include_dehumidification,
        include_wind=include_wind,
    )

    # Remove existing design days if requested
    if replace_existing and "SizingPeriod:DesignDay" in model:
        existing = list(model["SizingPeriod:DesignDay"])
        model.removeidfobjects(existing)

    # Add selected design days (as copies so the DDY document is not mutated)
    added_names: list[str] = []
    for dd_type in selected_types:
        dd = self._design_days.get(dd_type)
        if dd is not None:
            model.copyidfobject(dd)
            added_names.append(dd.name)

    # Update Site:Location if requested
    if update_location and self._location is not None:
        if "Site:Location" in model:
            existing_locs = list(model["Site:Location"])
            model.removeidfobjects(existing_locs)
        model.copyidfobject(self._location)

    return added_names

summary()

Human-readable summary of all design days in the DDY file.

Source code in src/idfkit/weather/designday.py
def summary(self) -> str:
    """Human-readable summary of all design days in the DDY file."""
    monthly_count = len(self.monthly)
    lines = [f"Design days from: {self._path.name}", ""]
    if self._location:
        lines.append(f"  Location: {self._location.name}")
    lines.append(f"  Design days found: {len(self._all_objects)}")
    lines.append(f"  Annual (classified): {len(self._design_days)}")
    if monthly_count:
        lines.append(f"  Monthly: {monthly_count}")
    lines.append("")
    for dd_type, dd in sorted(self._design_days.items(), key=lambda x: x[0].value):
        lines.append(f"  [{dd_type.value}] {dd.name}")
    return "\n".join(lines)

DesignDayType

idfkit.weather.designday.DesignDayType

Bases: Enum

Classification of ASHRAE annual design day conditions.

Values encode the condition type and annual percentile.

Source code in src/idfkit/weather/designday.py
class DesignDayType(Enum):
    """Classification of ASHRAE annual design day conditions.

    Values encode the condition type and annual percentile.
    """

    HEATING_99_6 = "heating_99.6"
    HEATING_99 = "heating_99"
    COOLING_DB_0_4 = "cooling_db_0.4"
    COOLING_DB_1 = "cooling_db_1"
    COOLING_DB_2 = "cooling_db_2"
    COOLING_WB_0_4 = "cooling_wb_0.4"
    COOLING_WB_1 = "cooling_wb_1"
    COOLING_WB_2 = "cooling_wb_2"
    COOLING_ENTH_0_4 = "cooling_enth_0.4"
    COOLING_ENTH_1 = "cooling_enth_1"
    COOLING_ENTH_2 = "cooling_enth_2"
    DEHUMID_0_4 = "dehumid_0.4"
    DEHUMID_1 = "dehumid_1"
    DEHUMID_2 = "dehumid_2"
    HUMIDIFICATION_99_6 = "humidif_99.6"
    HUMIDIFICATION_99 = "humidif_99"
    HTG_WIND_99_6 = "htg_wind_99.6"
    HTG_WIND_99 = "htg_wind_99"
    WIND_0_4 = "wind_0.4"
    WIND_1 = "wind_1"

HEATING_99_6 = 'heating_99.6' class-attribute instance-attribute

HEATING_99 = 'heating_99' class-attribute instance-attribute

HTG_WIND_99_6 = 'htg_wind_99.6' class-attribute instance-attribute

COOLING_DB_0_4 = 'cooling_db_0.4' class-attribute instance-attribute

COOLING_DB_1 = 'cooling_db_1' class-attribute instance-attribute

COOLING_DB_2 = 'cooling_db_2' class-attribute instance-attribute

COOLING_WB_0_4 = 'cooling_wb_0.4' class-attribute instance-attribute

COOLING_WB_1 = 'cooling_wb_1' class-attribute instance-attribute

COOLING_WB_2 = 'cooling_wb_2' class-attribute instance-attribute

COOLING_ENTH_0_4 = 'cooling_enth_0.4' class-attribute instance-attribute

COOLING_ENTH_1 = 'cooling_enth_1' class-attribute instance-attribute

COOLING_ENTH_2 = 'cooling_enth_2' class-attribute instance-attribute

DEHUMID_0_4 = 'dehumid_0.4' class-attribute instance-attribute

DEHUMID_1 = 'dehumid_1' class-attribute instance-attribute

DEHUMID_2 = 'dehumid_2' class-attribute instance-attribute

apply_ashrae_sizing

idfkit.weather.designday.apply_ashrae_sizing(model, station, *, standard='general', version=None)

Apply standard ASHRAE sizing design days to a model.

This is the one-line convenience function for the most common use case.

Presets
  • "90.1": Heating 99.6% + Cooling 1% DB + Cooling 1% WB (per ASHRAE Standard 90.1 requirements).
  • "general": Heating 99.6% + Cooling 0.4% DB (conservative general practice).

Parameters:

Name Type Description Default
model IDFDocument

The IDFDocument to modify.

required
station WeatherStation

Weather station whose DDY file to use.

required
standard Literal['90.1', 'general']

ASHRAE preset to apply.

'general'
version tuple[int, int, int] | None

EnergyPlus version for schema resolution.

None

Returns:

Type Description
list[str]

List of design day names that were added.

Raises:

Type Description
NoDesignDaysError

If the station's DDY file contains no design days. The exception includes suggestions for nearby stations that may have design day data.

Source code in src/idfkit/weather/designday.py
def apply_ashrae_sizing(
    model: IDFDocument,
    station: WeatherStation,
    *,
    standard: Literal["90.1", "general"] = "general",
    version: tuple[int, int, int] | None = None,
) -> list[str]:
    """Apply standard ASHRAE sizing design days to a model.

    This is the one-line convenience function for the most common use case.

    Presets:
        - ``"90.1"``: Heating 99.6% + Cooling 1% DB + Cooling 1% WB
          (per ASHRAE Standard 90.1 requirements).
        - ``"general"``: Heating 99.6% + Cooling 0.4% DB
          (conservative general practice).

    Args:
        model: The [IDFDocument][idfkit.document.IDFDocument] to modify.
        station: Weather station whose DDY file to use.
        standard: ASHRAE preset to apply.
        version: EnergyPlus version for schema resolution.

    Returns:
        List of design day names that were added.

    Raises:
        NoDesignDaysError: If the station's DDY file contains no design days.
            The exception includes suggestions for nearby stations that may
            have design day data.
    """
    ddm = DesignDayManager.from_station(station, version=version)
    ddm.raise_if_empty()
    if standard == "90.1":
        return ddm.apply_to_model(model, heating="99.6%", cooling="1%", include_wet_bulb=True)
    return ddm.apply_to_model(model, heating="99.6%", cooling="0.4%")

NoDesignDaysError

See NoDesignDaysError in the Exceptions API.