Repository: ladybug-tools/ladybug

Issue:
Add a method to EPW to produce DesignDays from the 8760 values in the file and a percentile
It seems that many of the EPWs distributed by the DoE do not come with any design days in their .ddy files, which means that they cannot be used for the vast majority of EnergyPlus simulations.

In order to give people some means of running energy simulations with these EPW files, we can introduce a method on the EPW class that takes the type of design day people would like to approximate (eg. "Summer" or "Winter") and the percentile of the design day (eg. 0.4, 1.0) and produces DesignDay objects that can be written into a ddy file such that that the energy simulation can still proceed.

This method should be similar to [the hacked-together legacy code that can be found here](https://github.com/ladybug-tools/honeybee-legacy/blob/master/src/Honeybee_Export%20To%20OpenStudio.py#L413-L535) but it should be more rigorous in its methods and more transparent about its assumptions.

Once we have this in place, I can add functionality to the Grasshopper components that make useof this method in energy simulation.

Discussion:
@ladybugbot: :tada: This issue has been resolved in version 0.27.0 :tada:

The release is available on:
- [GitHub release](https://github.com/ladybug-tools/ladybug/releases/tag/v0.27.0)
- `v0.27.0`

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:@Leelpsd: Hi Chris,
Thank you for sharing the code which is so insightful. I am pretty new to Anaconda and Python and when I copy the code, I get this error:

ModuleNotFoundError                       Traceback (most recent call last)
Cell In[10], line 4
      1 # coding=utf-8
----> 4 from ladybug.epw import EPW
      5 from ladybug.datacollection import HourlyContinuousCollection, MonthlyCollection
      6 from ladybug.designday import DesignDay

ModuleNotFoundError: No module named 'ladybug.epw'

I tried many ways to fix this error but with no results. Could you please help me how to fix this issue? Thank you very much@mostaphaRoudsari: How did you install ladybug? You should use `pip install ladybug-core`. There is another package called `ladybug` that you might have installed by mistake.

Solution patch:
diff --git a/ladybug/_datacollectionbase.py b/ladybug/_datacollectionbase.py
index 49057f3..f97f2a7 100644
--- a/ladybug/_datacollectionbase.py
+++ b/ladybug/_datacollectionbase.py
@@ -186,7 +186,7 @@ class BaseCollection(object):
         new_obj._validated_a_period = self._validated_a_period
         return new_obj
 
-    def get_highest_values(self, count):
+    def highest_values(self, count):
         """Get a list of the the x highest values of the Data Collection and their indices.
 
         This is useful for situations where one needs to know the times of
@@ -221,7 +221,7 @@ class BaseCollection(object):
                                       reverse=True)[0:count]
         return highest_values, highest_values_index
 
-    def get_lowest_values(self, count):
+    def lowest_values(self, count):
         """Get a list of the the x lowest values of the Data Collection and their indices.
 
         This is useful for situations where one needs to know the times of
@@ -251,7 +251,7 @@ class BaseCollection(object):
                                      key=lambda k: self._values[k])[0:count]
         return lowest_values, lowest_values_index
 
-    def get_percentile(self, percentile):
+    def percentile(self, percentile):
         """Get a value representing a the input percentile of the Data Collection.
 
         Args:
diff --git a/ladybug/datacollection.py b/ladybug/datacollection.py
index 51feca1..d5a97a3 100644
--- a/ladybug/datacollection.py
+++ b/ladybug/datacollection.py
@@ -16,7 +16,7 @@ Collections have the following inheritance structure:
 
 
 All Data Collections in this module have the ability to:
-    * max, min, bounds, average, median, total, get_percentile, get_highest/lowest values
+    * max, min, bounds, average, median, total, percentile, highest/lowest values
     * perform unit conversions on the data: to_unit, to_ip, to_si
     * filter based on conditional statements
     * filter based on analysis period
diff --git a/ladybug/epw.py b/ladybug/epw.py
index b0c9dfd..d3086e8 100644
--- a/ladybug/epw.py
+++ b/ladybug/epw.py
@@ -3,16 +3,19 @@ from __future__ import division
 
 import os
 
+from .dt import Date
 from .analysisperiod import AnalysisPeriod
 from .datacollection import HourlyContinuousCollection
 from .datacollection import MonthlyCollection
 from .datatype import angle, distance, energyflux, energyintensity, generic, \
     illuminance, luminance, fraction, pressure, speed, temperature
 from .designday import DesignDay
+from .ddy import DDY
 from .futil import write_to_file
 from .header import Header
 from .location import Location
 from .skymodel import calc_sky_temperature
+from .psychrometrics import rel_humid_from_db_dpt, wet_bulb_from_db_rh
 
 readmode = 'rb'
 try:
@@ -1317,6 +1320,129 @@ pdfs/pdfs_v8.4.0/AuxiliaryPrograms.pdf (Chapter 2.9.1)
             "site_elevation %.1f\n" % self.location.elevation + \
             "weather_data_file_unit 1\n"
 
+    def approximate_design_day(self, day_type='SummerDesignDay', percentile=0.4):
+        """Get a DesignDay object derived from percentile analysis of annual EPW data.
+
+        Note that this method is only intended to be used when there are no design
+        days in any DDY files associated with the EPW and the EPW's values for
+        annual_heating_design_day or annual_cooling_design_day properties are None.
+
+        The approximated design days produced by this method tend to be less
+        accurate than these other sources, which are usually derived from multiple
+        years of climate data instead of only one year. Information on the error
+        introduced by using only one year of data to create design days can be
+        found in AHSRAE HOF 2013, Chapter 14, pg 14.
+
+        Args:
+            day_type: Text for the type of design day to be produced. Choose from.
+
+                * SummerDesignDay
+                * WinterDesignDay
+
+            percentile: A number between 0 and 50 for the percentile difference
+                from the most extreme conditions within the EPW to be used for
+                the design day. Typical values are 0.4 and 1.0. (Default: 0.4).
+        """
+        # get values used for both winter and summer design days
+        avg_pres = self.atmospheric_station_pressure.average
+        pressure = round(avg_pres) if avg_pres != 999999 else 101325
+        avg_mon_temp = self.dry_bulb_temperature.average_monthly()
+        hr_count = int(87.6 * percentile * 2)
+
+        if day_type == 'WinterDesignDay':  # create winter design day criteria
+            # get temperature at percentile and indices of coldest hours
+            temp = self.dry_bulb_temperature.percentile(percentile)
+            temps, indices = self.dry_bulb_temperature.lowest_values(hr_count)
+            # get average wind speed and direction at coldest hours
+            wind_speed = round(sum(self.wind_speed[i] for i in indices) / hr_count, 1)
+            wind_dir = round(sum(self.wind_direction[i] for i in indices) / hr_count)
+            # get the date as the 21st of the coldest month
+            date_obj = Date(avg_mon_temp.lowest_values(1)[1][0] + 1, 21)
+            # return the design day object
+            day_name = '{}% Heating Design Day for {}'.format(
+                100 - percentile, self.location.city)
+            return DesignDay.from_design_day_properties(
+                day_name, day_type, self.location, date_obj, temp, 0,
+                'Wetbulb', temp, pressure, wind_speed, wind_dir,
+                'ASHRAEClearSky', [0.0])
+        elif day_type == 'SummerDesignDay':  # create summer design day criteria
+            # get temperature at percentile and indices of hottest hours
+            temp = self.dry_bulb_temperature.percentile(100 - percentile)
+            temps, indices = self.dry_bulb_temperature.highest_values(hr_count)
+            # get average humidity, wind speed and direction at hottest hours
+            dew_pt = sum(self.dew_point_temperature[i] for i in indices) / hr_count
+            rh = rel_humid_from_db_dpt(temp, dew_pt)
+            wb_temp = round(wet_bulb_from_db_rh(temp, rh, pressure), 1)
+            wind_speed = round(sum(self.wind_speed[i] for i in indices) / hr_count, 1)
+            wind_dir = round(sum(self.wind_direction[i] for i in indices) / hr_count)
+            # get the date as the 21st of the hottest month
+            date_obj = Date(avg_mon_temp.highest_values(1)[1][0] + 1, 21)
+            # compute the daily range of temperature from the days of the hottest month
+            hot_mon_db = self.dry_bulb_temperature.filter_by_analysis_period(
+                AnalysisPeriod(st_month=date_obj.month, end_month=date_obj.month))
+            temp_ranges = []
+            for day in hot_mon_db.group_by_day().values():
+                if day != []:
+                    temp_ranges.append(max(day) - min(day))
+            temp_range = round(sum(temp_ranges) / len(temp_ranges), 1)
+            # return the design day object
+            day_name = '{}% Cooling Design Day for {}'.format(
+                percentile, self.location.city)
+            return DesignDay.from_design_day_properties(
+                day_name, day_type, self.location, date_obj, temp, temp_range,
+                'Wetbulb', wb_temp, pressure, wind_speed, wind_dir,
+                'ASHRAEClearSky', [1.0])
+        else:
+            raise ValueError(
+                'Unrecognized design day type "{}".\nChoose from: "SummerDesignDay", '
+                '"WinterDesignDay"'.format(day_type))
+
+    def to_ddy(self, file_path, percentile=0.4):
+        """Produce a DDY file with a heating + cooling design day from this EPW.
+
+        This method will first check if there is a heating or cooling design day
+        that meet the input percentile contained within the EPW itself. If None is
+        found, the heating and cooling design days will be derived from analysis
+        of the annual data within the EPW, which is usually less accurate.
+
+        Args:
+            file_path: Full file path for output ddy file.
+            percentile: A number between 0 and 50 for the percentile difference
+                from the most extreme conditions within the EPW to be used for
+                the design day. Typical values are 0.4 and 1.0. (Default: 0.4).
+        """
+        # ensure the EPW file data is all in SI before creating design days
+        originally_ip = False
+        if self.is_ip:
+            self.convert_to_si()
+            originally_ip = True
+
+        # get the design day objects
+        des_days = []
+        if percentile == 0.4 and self.annual_heating_design_day_996 is not None:
+            des_days.append(self.annual_heating_design_day_996)
+        elif percentile == 1 and self.annual_heating_design_day_990 is not None:
+            des_days.append(self.annual_heating_design_day_990)
+        else:
+            des_days.append(self.approximate_design_day('WinterDesignDay', percentile))
+
+        if percentile == 0.4 and self.annual_cooling_design_day_004 is not None:
+            des_days.append(self.annual_cooling_design_day_004)
+        elif percentile == 1 and self.annual_cooling_design_day_010 is not None:
+            des_days.append(self.annual_cooling_design_day_010)
+        else:
+            des_days.append(self.approximate_design_day('SummerDesignDay', percentile))
+
+        # write the DDY
+        if not file_path.lower().endswith('.ddy'):
+            file_path += '.ddy'
+        ddy = DDY(self.location, des_days)
+        ddy.save(file_path)
+
+        if originally_ip:
+            self.convert_to_ip()
+        return file_path
+
     def to_wea(self, file_path, hoys=None):
         """Write an wea file from the epw file.
 


Test patch:
diff --git a/tests/datacollection_test.py b/tests/datacollection_test.py
index de69c2b..4955989 100644
--- a/tests/datacollection_test.py
+++ b/tests/datacollection_test.py
@@ -596,48 +596,48 @@ def test_to_ip_si():
     assert dc3.values[0] == dc1.values[0]
 
 
-def test_get_highest_values():
+def test_highest_values():
     header = Header(Temperature(), 'C', AnalysisPeriod())
     test_data = list(xrange(8760))
     test_count = len(test_data)/2
     dc3 = HourlyContinuousCollection(header, test_data)
 
     test_highest_values, test_highest_values_index = \
-        dc3.get_highest_values(test_count)
+        dc3.highest_values(test_count)
 
     assert test_highest_values == list(reversed(test_data[4380:8760]))
     assert test_highest_values_index == list(xrange(8759, 4379, -1))
 
 
-def test_get_lowest_values():
+def test_lowest_values():
     header = Header(Temperature(), 'C', AnalysisPeriod())
     test_data = [50] * 8760
     test_count = len(test_data)/2
     dc3 = HourlyContinuousCollection(header, test_data)
 
     test_lowest_values, test_lowest_values_index = \
-        dc3.get_lowest_values(test_count)
+        dc3.lowest_values(test_count)
 
     assert test_lowest_values == list(test_data[0:4380])
     assert test_lowest_values_index == list(xrange(0, 4380))
 
 
-def test_get_percentile():
-    """Test the get_percentile method."""
+def test_percentile():
+    """Test the percentile method."""
     header1 = Header(Temperature(), 'C', AnalysisPeriod())
     values = list(xrange(8760))
     dc = HourlyContinuousCollection(header1, values)
 
-    assert dc.get_percentile(0) == 0
-    assert dc.get_percentile(25) == 2189.75
-    assert dc.get_percentile(50) == 4379.5
-    assert dc.get_percentile(75) == 6569.25
-    assert dc.get_percentile(100) == 8759
+    assert dc.percentile(0) == 0
+    assert dc.percentile(25) == 2189.75
+    assert dc.percentile(50) == 4379.5
+    assert dc.percentile(75) == 6569.25
+    assert dc.percentile(100) == 8759
 
     with pytest.raises(Exception):
-        dc.get_percentile(-10)
+        dc.percentile(-10)
     with pytest.raises(Exception):
-        dc.get_percentile(110)
+        dc.percentile(110)
 
 
 def test_filter_by_conditional_statement():
diff --git a/tests/epw_test.py b/tests/epw_test.py
index 3ddd1c7..131a45e 100644
--- a/tests/epw_test.py
+++ b/tests/epw_test.py
@@ -334,6 +334,24 @@ def test_save_converted_epw():
     os.remove(modified_path)
 
 
+def test_save_ddy():
+    """Test save wea_rel."""
+    path = './tests/fixtures/epw/chicago.epw'
+    epw = EPW(path)
+
+    ddy_path = './tests/fixtures/epw/chicago_epw.ddy'
+    epw.to_ddy(ddy_path)
+    assert os.path.isfile(ddy_path)
+    assert os.stat(ddy_path).st_size > 1
+    os.remove(ddy_path)
+
+    ddy_path = './tests/fixtures/epw/chicago_epw_02.ddy'
+    epw.to_ddy(ddy_path, 2)
+    assert os.path.isfile(ddy_path)
+    assert os.stat(ddy_path).st_size > 1
+    os.remove(ddy_path)
+
+
 def test_save_wea():
     """Test save wea_rel."""
     path = './tests/fixtures/epw/chicago.epw'