Source code for ladybug_comfort.chart.polygonpmv

# coding=utf-8
"""Object for plotting a PMV comfort polygon on a Psychrometric Chart."""
from __future__ import division

from ..pmv import calc_missing_pmv_input, pmv_from_ppd
from ..parameter.pmv import PMVParameter

from ladybug.psychchart import PsychrometricChart
from ladybug.psychrometrics import humid_ratio_from_db_rh, wet_bulb_from_db_hr, \
    humid_ratio_from_db_wb, db_temp_from_rh_hr
from ladybug._datacollectionbase import BaseCollection
from ladybug.datacollection import HourlyContinuousCollection, \
    HourlyDiscontinuousCollection
from ladybug.datatype.temperature import Temperature
from ladybug.datatype.temperaturedelta import TemperatureDelta
from ladybug.datatype.thermalcondition import ThermalComfort

from ladybug_geometry.geometry2d.pointvector import Point2D, Vector2D
from ladybug_geometry.geometry2d.ray import Ray2D
from ladybug_geometry.geometry2d.line import LineSegment2D
from ladybug_geometry.geometry2d.polyline import Polyline2D
from ladybug_geometry.intersection2d import intersect_line2d_infinite


[docs]class PolygonPMV(object): """Object to plot a PMV comfort polygon on a Psychrometric Chart. Args: psychrometric_chart: A ladybug-core PsychrometricChart object on which the PMV comfort polygon will be plot. rad_temperature: A list of numbers for the mean radiant temperature in Celsius. If None, a polygon for operative temperature will be plot, assuming that radiant temperature and air temperature are the same. (Default: None). air_speed: A list of numbers for the air speed values in m/s. If None, a low air speed of 0.1 m/s wil be used for all polygons. (Default: None). met_rate: A list of numbers for the metabolic rate in met. If None, a met rate of 1.1 met will be used for all polygons, indicating a human subject who is seated, typing. (Default: None). clo_value: A list of numbers for the clothing level in clo. If None, a clo level of 0.7 clo will be used for all polygons, indicating a human subject with a long sleeve shirt and pants. (Default: None). external_work: A list of numbers for the external work in met. If None, a met rate of 0 met will be used for all polygons, indicating a human subject who is seated. (Default: None). comfort_parameter: Optional PMVParameter object to specify parameters under which conditions are considered acceptable. If None, default will assume a PPD threshold of 10%, no absolute humidity constraints and a still air threshold of 0.1 m/s. Properties: * psychrometric_chart * rad_temperature * air_speed * met_rate * clo_value * external_work * comfort_parameter * polygon_count * left_comfort_lines * right_comfort_lines * left_comfort_line * right_comfort_line * comfort_polygons * merged_comfort_polygon * comfort_values * comfort_data * merged_comfort_values * merged_comfort_data * is_comfort_too_hot * is_comfort_too_cold """ TEMP_TYPE = Temperature() DELTA_TEMP_TYPE = TemperatureDelta() def __init__(self, psychrometric_chart, rad_temperature=None, air_speed=None, met_rate=None, clo_value=None, external_work=None, comfort_parameter=None): """Initialize a PMV comfort polygon.""" # check the psychrometric_chart input assert isinstance(psychrometric_chart, PsychrometricChart), 'PolygonPMV ' \ 'psychrometric_chart must be a ladybug PsychrometricChart. ' \ 'Got {}.'.format(type(psychrometric_chart)) self._psychrometric_chart = psychrometric_chart # determine the number of comfort polygons to be drawn all_data = (rad_temperature, air_speed, met_rate, clo_value, external_work) param_lens = [len(arr) for arr in all_data if arr is not None] self._polygon_count = max(param_lens) if len(param_lens) != 0 else 0 self._polygon_count = 1 if self._polygon_count == 0 else self._polygon_count # check parameters with defaults self._rad_temperature = self._check_input( rad_temperature, 'rad_temperature', None) self._air_speed = self._check_input(air_speed, 'air_speed', 0.1, True) self._met_rate = self._check_input(met_rate, 'met_rate', 1.1, True) self._clo_value = self._check_input(clo_value, 'clo_value', 0.7, True) self._external_work = self._check_input(external_work, 'external_work', 0., True) # check comfort parameters if comfort_parameter is None: self._comfort_par = PMVParameter() else: assert isinstance(comfort_parameter, PMVParameter), 'comfort_parameter '\ 'must be a PMVParameter object. Got {}'.format(type(comfort_parameter)) self._comfort_par = comfort_parameter # create the left and right polylines _left, _right = [], [] for p in range(self._polygon_count): min_poly, max_poly = self.comfort_polylines(p) _left.append(min_poly) _right.append(max_poly) self._left_comfort_lines, self._right_comfort_lines = tuple(_left), tuple(_right) # set parameters to None, which will be computed on demand self._left_comfort_line = None self._right_comfort_line = None self._comfort_polygons = None self._merged_comfort_polygons = None self._comfort_values = None self._comfort_data = None self._merged_comfort_values = None self._merged_comfort_data = None @property def psychrometric_chart(self): """The ladybug PsychrometricChart object on which the polygons are plot.""" return self._psychrometric_chart @property def rad_temperature(self): """Tuple of mean radiant temperature (MRT) values in degrees C. None indicates that the radiant temperature is the same as the air temperature. """ return self._rad_temperature @property def air_speed(self): """Tuple of air speed values in m/s.""" return self._air_speed @property def met_rate(self): """Tuple of metabolic rate in met. * 1 met = Metabolic rate of a resting seated person * 1.2 met = Metabolic rate of a standing person * 2 met = Metabolic rate of a walking person * If left blank, default is set to 1.1 met (for seated, typing). """ return self._met_rate @property def clo_value(self): """Tuple of clothing level of the human subject in clo. * 1 clo = Three-piece suit * 0.5 clo = Shorts + T-shirt * 0 clo = No clothing * If left blank, default is set to 0.85 clo. """ return self._clo_value @property def external_work(self): """Tuple of the work done by the human subject in met.""" return self._external_work @property def comfort_parameter(self): """PMV comfort parameters that are assigned to this object.""" return self._comfort_par.duplicate() # duplicate since ppd_thresh is set-able @property def polygon_count(self): """Integer for the number of comfort polygons contained on the object.""" return self._polygon_count @property def left_comfort_lines(self): """Tuple of Polyline2D for the left of the comfort polygons.""" return self._left_comfort_lines @property def right_comfort_lines(self): """Tuple of Polyline2D for the right of the comfort polygons.""" return self._right_comfort_lines @property def left_comfort_line(self): """A single Polyline2D for the left of the merged comfort polygons.""" if self._left_comfort_line is None: li = self._left_comfort_lines self._left_comfort_line = li[0] if len(li) == 1 else self._min_polylines(li) return self._left_comfort_line @property def right_comfort_line(self): """A single Polyline2D for the right of the merged comfort polygons.""" if self._right_comfort_line is None: li = self._right_comfort_lines self._right_comfort_line = li[0] if len(li) == 1 else self._max_polylines(li) return self._right_comfort_line @property def comfort_polygons(self): """A tuple of tuples where each sub-tuple defines one comfort polygon. Sub-tuple comfort polygons consist of four or five Polyline2D or LineSegment2D that are ordered as follows (left, bottom, right, top). """ if self._comfort_polygons is None: self._comfort_polygons = [] ll, rl = self.left_comfort_lines, self.right_comfort_lines for lt, rt in zip(ll, rl): self._comfort_polygons.append(self._build_comfort_polygon(lt, rt)) return tuple(self._comfort_polygons) @property def merged_comfort_polygon(self): """A tuple of Polyline2D or LineSegment2D that define the merged comfort polygon. Comfort polygon consists of four or five Polyline2D or LineSegment2D that are ordered as follows (left, bottom, right, top). """ if self._merged_comfort_polygons is None: lt, rt = self.left_comfort_line, self.right_comfort_line self._merged_comfort_polygons = self._build_comfort_polygon(lt, rt) return self._merged_comfort_polygons @property def comfort_values(self): """A tuple of tuples with each sub-tuple representing one of comfort polygons. Each sub-tuple contains 0/1 values for whether the point is inside the comfort polygon or not. """ if self._comfort_values is None: self._comfort_values = [] for poly in self.comfort_polygons: self._comfort_values.append(self._evaluate_comfort(poly[0], poly[2])) return tuple(self._comfort_values) @property def comfort_data(self): """A tuple of data collections or 0/1 values for each of the comfort polygons.""" if self._comfort_data is None: self._comfort_data = [] for i, dat in enumerate(self.comfort_values): if len(dat) == 1: self._comfort_data.append(dat[0]) else: name = 'Comfort {}'.format(i + 1) self._comfort_data.append(self.create_collection(dat, name)) return tuple(self._comfort_data) @property def merged_comfort_values(self): """A tuple of 0/1 for whether each point is in the merged comfort polygon or not. """ if self._merged_comfort_values is None: poly = self.merged_comfort_polygon self._merged_comfort_values = self._evaluate_comfort(poly[0], poly[2]) return self._merged_comfort_values @property def merged_comfort_data(self): """A data collection or 0/1 for whether the data is in merged comfort polygon. """ if self._merged_comfort_data is None: if len(self.merged_comfort_values) == 1: self._merged_comfort_data = self.merged_comfort_values[0] else: self._merged_comfort_data = \ self.create_collection(self.merged_comfort_values, 'Comfort') return self._merged_comfort_data @property def is_comfort_too_hot(self): """Boolean to note whether comfort polygons are off the chart on the hot side.""" psy = self.psychrometric_chart return self.merged_comfort_polygon[2][0].x >= psy.base_point.x + \ (psy._max_temperature - psy._min_temperature) * psy._x_dim @property def is_comfort_too_cold(self): """Boolean to note whether comfort polygons are off the chart on the cold side. """ psy = self.psychrometric_chart return self.merged_comfort_polygon[0][0].x <= psy.base_point.x
[docs] def evaporative_cooling_polygon(self): """Get a tuple of Polyline2D and LineSegment2D for evaporative cooling polygon. This will be None if the polygon does not fit on the chart. """ # check to be sure the evaporative cooling polygon fits on the chart if self.is_comfort_too_hot: return None psy = self._psychrometric_chart comf_poly = self.merged_comfort_polygon # get the line of constant wet bulb that forms the top of the polygon top_pt = comf_poly[2][-1] _, db_c = self._x_to_t(top_pt.x) hr = self._y_to_hr(top_pt.y) wb_c = wet_bulb_from_db_hr(db_c, hr, psy.average_pressure) e_db = psy.max_temperature if not psy.use_ip else \ self.TEMP_TYPE.to_unit([psy.max_temperature], 'C', 'F')[0] e_hr = humid_ratio_from_db_wb(e_db, wb_c, psy.average_pressure) e_pt = Point2D(psy.t_x_value(psy.max_temperature), psy.hr_y_value(e_hr)) wb_line_top = LineSegment2D.from_end_points(e_pt, top_pt) # figure out if a vertical chart border seg is needed or trim the wb_line_top if e_hr > 0: bx = (psy.max_temperature - psy.min_temperature) * psy._x_dim b_pt = Point2D(psy.base_point.x + bx, psy.base_point.y) right_border = LineSegment2D.from_end_points(b_pt, e_pt) evap_lines = [wb_line_top, right_border] else: b_pt = psy.chart_border.intersect_line_ray(wb_line_top)[0] evap_lines = [LineSegment2D.from_end_points(b_pt, wb_line_top.p2)] # figure out if another constant WB line is needed on the left if self._comfort_par.humid_ratio_lower != 0: left_pt = comf_poly[1].p1 _, db_c = self._x_to_t(left_pt.x) hr = self._y_to_hr(left_pt.y) wb_c = wet_bulb_from_db_hr(db_c, hr, psy.average_pressure) e_db = psy.max_temperature if not psy.use_ip else \ self.TEMP_TYPE.to_unit([psy.max_temperature], 'C', 'F')[0] e_hr = humid_ratio_from_db_wb(e_db, wb_c, psy.average_pressure) e_pt = Point2D(psy.t_x_value(psy.max_temperature), psy.hr_y_value(e_hr)) wb_line_left = LineSegment2D.from_end_points(left_pt, e_pt) if e_hr > 0: # polygon intersects left of chart evap_lines[1] = LineSegment2D.from_end_points( wb_line_left.p2, evap_lines[1].p2) evap_lines.append(wb_line_left) else: # polygon intersects bottom of chart b_pt = psy.chart_border.intersect_line_ray(wb_line_left)[0] wb_line_left = LineSegment2D.from_end_points(wb_line_left.p1, b_pt) bot_line = LineSegment2D.from_end_points(b_pt, evap_lines[-1].p1) evap_lines.extend((bot_line, wb_line_left)) else: bot_line = LineSegment2D.from_end_points(comf_poly[2][0], evap_lines[-1].p1) evap_lines.append(bot_line) # add the lines that border the comfort polygon if self._comfort_par.humid_ratio_lower != 0: evap_lines.extend((comf_poly[1].flip(), comf_poly[2].reverse())) else: evap_lines.append(comf_poly[2].reverse()) evap_lines.reverse() return tuple(evap_lines)
[docs] def fan_use_polygon(self, air_speed=1.0): """Get a tuple of Polyline2D and LineSegment2D for use of fans in the space. This will be None if the polygon does not fit on the chart. Args: air_speed: The air speed around the occupants that the fans create in m/s. Note that values above 1 m/s tend to blow papers around. (Default: 1.0 m/3) """ # check to be sure the fan use polygon fits on the chart if self.is_comfort_too_hot: return None comf_poly = self.merged_comfort_polygon # get the warmest set of thermal conditions to add fans to poly_i = list(range(self.polygon_count)) p_x_vals = [pl[3].x for pl in self.right_comfort_lines] max_i = [x for _, x in sorted(zip(p_x_vals, poly_i))][-1] # get the PMV dict and check to be sure the air speed is less than fan speed sat = self._comfort_par.still_air_threshold _, pmv_max = pmv_from_ppd(self._comfort_par.ppd_comfort_thresh) if \ self._comfort_par.ppd_comfort_thresh != 10 else (-0.5, 0.5) pmv_dict = self._pmv_dict(max_i) if pmv_dict['vel'] >= air_speed: # comfort air speed too fast return None # compute the air temperatures and HR when the fan speed is higher pmv_dict['vel'] = air_speed pr = self.psychrometric_chart.average_pressure rel_humids = (0, 20, 40, 60, 80, 100) air_temps = [] for rh in rel_humids: pmv_dict['rh'] = rh max_dict = calc_missing_pmv_input(pmv_max, pmv_dict, still_air_threshold=sat) air_temps.append(max_dict['ta']) hr = [humid_ratio_from_db_rh(t, rh, pr) for t, rh in zip(air_temps, rel_humids)] # convert the air temperatures and HR to a polyline psy, right_pts = self.psychrometric_chart, [] for h, ta in zip(hr, air_temps): ta = ta if not psy.use_ip else self.TEMP_TYPE.to_unit([ta], 'F', 'C')[0] right_pts.append(Point2D(psy.t_x_value(ta), psy.hr_y_value(h))) right = Polyline2D(right_pts, interpolated=True) # trim the polyline top (and bottom if necessary) left = comf_poly[2].reverse() ray = Ray2D(left[0], Vector2D(1, 0)) right = self._intersect_top(right, ray) if self._comfort_par.humid_ratio_lower != 0: ray = Ray2D(left[-1], Vector2D(1, 0)) right = self._intersect_bottom(right, ray) # put everything together into one list bottom = LineSegment2D.from_end_points(left[-1], right[0]) top = LineSegment2D.from_end_points(right[-1], left[0]) return (left, bottom, right, top)
[docs] def night_flush_polygon(self, temperature_above_comfort=12): """Get a tuple of Polyline2D and LineSegment2D for a night flushing polygon. This will be None if the polygon does not fit on the chart. Args: temperature_above_comfort: A number in degrees Celsius representing the maximum daily temperature above the comfort range which can still be counted in the Night Flush polygon. (Default: 12 C). """ # check to be sure the night flush polygon fits on the chart if self.is_comfort_too_hot: return None psy = self._psychrometric_chart left = self.merged_comfort_polygon[2] # move the left line over by the temperature above comfort tac = temperature_above_comfort if not psy.use_ip else \ self.DELTA_TEMP_TYPE.to_unit([temperature_above_comfort], 'dF', 'dC')[0] move_vec = Vector2D(tac * psy.x_dim, 0) right = left.move(move_vec) left = left.reverse() # trim or simplify the right line if it is off of the chart m_x = psy.base_point.x + (psy.max_temperature - psy.min_temperature) * psy.x_dim ex_line = None if right[-1].x > m_x: # polygon off the chart; recreate it p1, p2 = Point2D(m_x, left[-1].y), Point2D(m_x, left[0].y) right = LineSegment2D.from_end_points(p1, p2) right = Polyline2D((right.p1, right.midpoint, right.p2)) elif right[0].x > m_x: # polygon partially off the chart; trim it border_seg = psy.chart_border.segments[2] b_pt = right.intersect_line_ray(border_seg)[0] ray = Ray2D(Point2D(left[0].x, b_pt.y), Vector2D(1, 0)) right = self._intersect_bottom(right, ray) ex_line = LineSegment2D.from_end_points(Point2D(m_x, left[-1].y), right[0]) # assemble everything into one list of polylines nf_lines = [left] if ex_line is not None: nf_lines.append(LineSegment2D.from_end_points(left[-1], ex_line.p1)) nf_lines.append(ex_line) else: nf_lines.append(LineSegment2D.from_end_points(left[-1], right[0])) nf_lines.append(right) nf_lines.append(LineSegment2D.from_end_points(right[-1], left[0])) return tuple(nf_lines)
[docs] def internal_heat_polygon(self, balance_temperature=12.8): """Get a tuple of Polyline2D and LineSegment2D for an internal heat gain polygon. This will be None if the polygon does not fit on the chart. Args: balance_temperature: The balance temperature of the building in Celsius when accounting for all internal heat. Must be greater or equal to 5 C. In order for this method to not return None, this value must be less than the coldest temperature of the merged comfort polygon. (Default: 12.8 C) """ # check to be sure the internal heat polygon fits on the chart self._balance_check(balance_temperature) psy = self._psychrometric_chart comf_poly = self.merged_comfort_polygon bal = balance_temperature if not psy.use_ip else \ self.TEMP_TYPE.to_unit([balance_temperature], 'F', 'C')[0] bal_x = psy.t_x_value(bal) if self.is_comfort_too_cold or comf_poly[0][0].x < bal_x: return None # get the vertical line at the balance point if psy.min_temperature <= bal: # the whole polygon fits on the chart hr_e = humid_ratio_from_db_rh(balance_temperature, 100, psy.average_pressure) hr_y = psy.hr_y_value(hr_e) hr_y = hr_y if hr_y < comf_poly[0][0].y else comf_poly[0][0].y left1 = Point2D(bal_x, hr_y) left2 = Point2D(bal_x, comf_poly[0][-1].y) else: _, min_tc = self._x_to_t(psy.base_point.x) hr_e = humid_ratio_from_db_rh(min_tc, 100, psy.average_pressure) hr_y = psy.hr_y_value(hr_e) hr_y = hr_y if hr_y < comf_poly[0][0].y else comf_poly[0][0].y left1 = Point2D(psy.base_point.x, hr_y) left2 = Point2D(psy.base_point.x, comf_poly[0][-1].y) left_lin = LineSegment2D.from_end_points(left1, left2) # get the bottom line and the line bordering the comfort polygon bot_lin = LineSegment2D.from_end_points(left_lin.p2, comf_poly[0][-1]) right_lin = comf_poly[0].reverse() inht_lines = [left_lin, bot_lin, right_lin] # get the last line, which may intersect with the saturation line if left_lin.p1.y == comf_poly[0][0].y: # straight line across top_lin = LineSegment2D.from_end_points(comf_poly[0][0], left_lin.p1) inht_lines.append(top_lin) else: # polygon includes some of the saturation line l_comf_pt = self.left_comfort_line[-1] x_mid = (left_lin.p1.x + l_comf_pt.x) / 2 t_mid, t_mid_c = self._x_to_t(x_mid) hr_mid = humid_ratio_from_db_rh(t_mid_c, 100, psy.average_pressure) mx, my = psy.t_x_value(t_mid), psy.hr_y_value(hr_mid) sat_line = Polyline2D((left_lin.p1, Point2D(mx, my), l_comf_pt), interpolated=True) if comf_poly[0][0].y == l_comf_pt.y: # sat line only inht_lines.append(sat_line.reverse()) else: # sat line gets split with the max HR max_hr_y = psy.hr_y_value(self._comfort_par.humid_ratio_upper) left_x = psy.base_point.x - 100 * psy.x_dim ray = Ray2D(Point2D(left_x, max_hr_y), Vector2D(1, 0)) sat_line = self._intersect_top(sat_line, ray) intpt = sat_line[-1] if isinstance(sat_line, Polyline2D) else sat_line.p2 inht_lines.append(LineSegment2D.from_end_points(right_lin[-1], intpt)) sat_line = sat_line.reverse() if isinstance(sat_line, Polyline2D) \ else sat_line.flip() inht_lines.append(sat_line) return tuple(inht_lines)
[docs] def passive_solar_polygon(self, max_temperature_delta, balance_temperature=None): """Get a tuple of Polyline2D and LineSegment2D for a passive solar polygon. This will be None if the polygon does not fit on the chart. Args: max_temperature_delta: The maximum temperature delta from the balance temperature (in Celsius) that passive solar heating is able to support. This can be obtained by running the evaluate_passive_solar method on this class balance_temperature: The balance temperature of the building in Celsius when accounting for all internal heat. Must be greater or equal to 5 C. If None, it will be assumed that the passively-heated space has no internal heat gains and all passive solar potential will be evaluated from the coldest comfort temperature. (Default: None). """ # check that the passive solar polygon will fit on the chart psy = self._psychrometric_chart pres = psy.average_pressure comf_poly = self.merged_comfort_polygon bal_temp = balance_temperature if balance_temperature is not None else \ self._x_to_t(comf_poly[0][-1].x)[1] if balance_temperature is None and bal_temp < 5: return None self._balance_check(bal_temp) bal = bal_temp if not psy.use_ip else \ self.TEMP_TYPE.to_unit([bal_temp], 'F', 'C')[0] min_sol_t = bal_temp - max_temperature_delta min_sol_t = min_sol_t if not psy.use_ip else \ self.TEMP_TYPE.to_unit([min_sol_t], 'F', 'C')[0] min_sol_t = min_sol_t if min_sol_t > psy.min_temperature else psy.min_temperature min_sol_x = psy.t_x_value(min_sol_t) min_sol_t_c = min_sol_t if not psy.use_ip else \ self.TEMP_TYPE.to_unit([min_sol_t], 'C', 'F')[0] if self.is_comfort_too_cold or comf_poly[0][0].x < min_sol_x or \ psy.min_temperature >= bal: return None # get the polyline for the right of the polygon bal_x, need_connect = psy.t_x_value(bal), True if balance_temperature is None or comf_poly[0][0].x < bal_x: right = comf_poly[0].reverse() if comf_poly[0][0].y == self.left_comfort_line[-1].y: need_connect = False else: # there's a single vertical line for the right of the polygon hr_e = humid_ratio_from_db_rh(balance_temperature, 100, pres) hr_y = psy.hr_y_value(hr_e) if hr_y < comf_poly[0][0].y: need_connect = False else: hr_y = comf_poly[0][0].y r1, r2 = Point2D(bal_x, comf_poly[0][-1].y), Point2D(bal_x, hr_y) right = LineSegment2D.from_end_points(r1, r2) right = Polyline2D((right.p1, right.midpoint, right.p2)) sol_lines = [right] # create the connector line to the saturation line if its needed need_sat = True if need_connect: hr = self._y_to_hr(right[-1].y) sat_int_c = db_temp_from_rh_hr(100, hr, pres) sat_int = sat_int_c if not psy.use_ip else \ self.TEMP_TYPE.to_unit([sat_int_c], 'F', 'C')[0] if sat_int < min_sol_t: # we don't make it to the saturation line need_sat = False t1, t2 = right[-1], Point2D(min_sol_x, right[-1].y) else: sat_int_x = psy.t_x_value(sat_int) t1, t2 = right[-1], Point2D(sat_int_x, right[-1].y) sol_lines.append(LineSegment2D.from_end_points(t1, t2)) # create the left line if it fits (or get the saturation line intersect) left, int_pt = None, None if need_sat: hr_l = humid_ratio_from_db_rh(min_sol_t_c, 100, pres) min_hr = self._comfort_par.humid_ratio_lower if hr_l > min_hr: # left line exists l1 = Point2D(min_sol_x, psy.hr_y_value(hr_l)) l2 = Point2D(min_sol_x, right[0].y) left = LineSegment2D.from_end_points(l1, l2) else: # left line does not exist; determine the intersection int_t_c = db_temp_from_rh_hr(100, min_hr, pres) int_t = int_t_c if not psy.use_ip else \ self.TEMP_TYPE.to_unit([int_t_c], 'F', 'C')[0] int_pt = Point2D(psy.t_x_value(int_t), psy.hr_y_value(min_hr)) else: # no intersection with the saturation line l1, l2 = sol_lines[-1].p2, Point2D(sol_lines[-1].p2.x, right[0].y) left = LineSegment2D.from_end_points(l1, l2) # create the portion against the saturation line if its needed if need_sat: r_pt = sol_lines[-1].p2 if isinstance(sol_lines[-1], LineSegment2D) \ else sol_lines[-1][-1] l_pt = left.p1 if left is not None else int_pt x_mid = (l_pt.x + r_pt.x) / 2 t_mid, t_mid_c = self._x_to_t(x_mid) hr_mid = humid_ratio_from_db_rh(t_mid_c, 100, pres) mx, my = psy.t_x_value(t_mid), psy.hr_y_value(hr_mid) sat_line = Polyline2D((r_pt, Point2D(mx, my), l_pt), interpolated=True) sol_lines.append(sat_line) if left is not None: sol_lines.append(left) # create the bottom line l_pt = sol_lines[-1].p2 if isinstance(sol_lines[-1], LineSegment2D) \ else sol_lines[-1][-1] sol_lines.append(LineSegment2D.from_end_points(l_pt, right[0])) return sol_lines
[docs] def shade_line(self, balance_temperature=None): """Get a Polyline2D or LineSegment2D for the line above which shade is needed. The purpose of this line is to show which other polygons may not work as intended if there is no shade available. This will be None if the line does not fit on the chart. Args: balance_temperature: An optional balance temperature of the building to which the shade is applied (Celsius). Must be greater or equal to 5 C. In order for this method to not return None, this value must be less than the coldest temperature of the merged comfort polygon. If None, the shade line will be at the lower edge of the comfort polygon, essentially assuming that the shade is applied directly to the occupant, who is not inside of a building. """ psy = self._psychrometric_chart # check to be sure the shade line fits on the chart self._balance_check(balance_temperature) comf_poly = self.merged_comfort_polygon bal = balance_temperature if not psy.use_ip else \ self.TEMP_TYPE.to_unit([balance_temperature], 'F', 'C')[0] bal_x = psy.t_x_value(bal) if self.is_comfort_too_cold or comf_poly[0][0].x < bal_x: return None
[docs] def evaluate_polygon(self, polygon, tolerance=0.01): """Evaluate a strategy polygon in relation to the data points of the chart. Args: polygon: A tuple of Polyline2D and LineSegment2D that form a closed polygon on the psychrometric chart. tolerance: The minimum difference between vertices below which vertices are considered the same. (Default: 0.01). Returns: A list of 0 and 1 values for whether data points lie inside the polygon. These can be passed to the create_collection method on this class to get a data collection for the time inside the polygon. """ joined_poly = self._lines_to_polygon(polygon, tolerance) # get a joined polygon # create a list of all points in the polygon value_list = [] for point in self._psychrometric_chart.data_points: val = 1 if joined_poly.is_point_inside_bound_rect(point) else 0 value_list.append(val) return value_list
[docs] def evaluate_night_flush_polygon(self, polygon, outdoor_temperature, night_below_comfort=3.0, time_constant=8, tolerance=0.01): """Evaluate the night flush strategy polygon in relation to the data points. Args: polygon: A tuple of Polyline2D and LineSegment2D that form a closed polygon on the psychrometric chart for the night flushing polygon. outdoor_temperature: An annual hourly continuous data collection of outdoor temperature in Celsius, which will be used to evaluate if previous hours are cool enough to benefit from night flushing. night_below_comfort: A number in degrees Celsius representing the minimum temperature below the maximum comfort temperature that the outdoor temperature must drop at night in order to count towards the Night Flush polygon. (Default: 3C). time_constant: A number that represents the number of hours that a theoretical building can passively maintain its temperature. This is used to determine how many hours a space can maintain the coolth of the night_below_comfort before conditions drive it out of the comfort polygon. The better-insulated a building is and the higher its thermal mass, the greater this number can be. tolerance: The minimum difference between vertices below which vertices are considered the same. (Default: 0.01). """ # check to be sure that the data is hourly continuous psy = self.psychrometric_chart joined_poly = self._lines_to_polygon(polygon, tolerance) # get a joined polygon temp_vals = psy._t_values_c # all temperatures on the chart if len(temp_vals) == 1: val = 1 if joined_poly.is_point_inside_bound_rect(psy.data_points[0]) \ else 0 return [val] else: # make sure the collection is hourly continuous time_ind = self._check_hourly(outdoor_temperature, 'Night Flushing') tcon_i = time_constant * outdoor_temperature.header.analysis_period.timestep # calculate the target temperature to hit at night for flushing right = self.merged_comfort_polygon[2] avg_x = (right[0].x + right[0].x) / 2 _, max_t_c = self._x_to_t(avg_x) target_temp = max_t_c - night_below_comfort # night temperature ok to flush # create a list of all points in the polygon value_list = [] for hour, point in zip(time_ind, psy.data_points): if joined_poly.is_point_inside_bound_rect(point): for past_temp in outdoor_temperature[hour - tcon_i:hour]: if past_temp < target_temp: value_list.append(1) break else: value_list.append(0) else: value_list.append(0) return value_list
[docs] def evaluate_passive_solar(self, incident_irradiance, solar_heat_capacity=50, time_constant=8, balance_temperature=None): """Evaluate the psychrometric chart data points in relation to passive heating. Args: incident_irradiance: An annual hourly continuous data collection of irradiance (or radiation) in W/m2 (or Wh/m2) that aligns with the data points on the psychrometric chart. The irradiance values should be incident on the orientation of the passive solar heated windows. So using global horizontal radiation assumes that all windows are skylights (like a greenhouse). The directional_irradiance method on the ladybug core Wea class can be used to get irradiance data for a specific surface orientation. solar_heat_capacity: A number representing the amount of outdoor solar flux (W/m2) that is needed to raise the temperature of a theoretical building by 1 degree Celsius. The lower this number, the more efficiently the space is able to absorb passive solar heat. The default assumes a relatively small passively solar heated zone without much mass. A higher number will be required the larger the space is and the more mass that it has. (Default: 50 W/m2) time_constant: A number that represents the number of hours that a theoretical building can passively maintain its temperature. This is used to determine how many hours a space can maintain the warmth of the sun before conditions drive it out of the comfort polygon. The better-insulated a building is and the higher its thermal mass, the greater this number can be. (Default: 8). balance_temperature: The balance temperature of the building in Celsius when accounting for all internal heat. Must be greater or equal to 5 C. If None, it will be assumed that the passively-heated space has no internal heat gains and all passive solar potential will be evaluated from the coldest comfort temperature. (Default: None). Returns: A tuple of two values. The first is a list of 0/1 values for whether the data points can be passively solar heated. The second is the maximum temperature delta from the balance_temperature (in Celsius) that the passive solar heating was able to support. """ # check the building balance temperature psy = self._psychrometric_chart comf_poly = self.merged_comfort_polygon bal_temp = balance_temperature if balance_temperature is not None else \ self._x_to_t(comf_poly[0][-1].x)[1] bal_temp = 5 if bal_temp < 5 else bal_temp # check that the data is hourly continuous temp_vals = psy._t_values_c # all temperatures on the chart comf_val = self.merged_comfort_values if len(temp_vals) == 1: t = temp_vals[0] val = 1 if bal_temp - 20 < t < bal_temp and comf_val[0] == 0 else 0 return ([val]), 20 else: # make sure the collection is hourly continuous time_ind = self._check_hourly(incident_irradiance, 'Passive Solar') tcon_i = time_constant * incident_irradiance.header.analysis_period.timestep # get a list of booleans that account for the HR limits if self._comfort_par.humid_ratio_lower == 0: hr_in_range = [True] * psy._calc_length else: min_hr_y, hr_in_range = comf_poly[0][-1].y, [] for pt in psy.data_points: hr_ok = True if pt.y > min_hr_y else False hr_in_range.append(hr_ok) if comf_poly[0][0].y != self.left_comfort_line[-1]: # max HR in effect max_hr_y = comf_poly[0][0].y for i, pt in enumerate(psy.data_points): if pt.y > max_hr_y: hr_in_range[i] = False # loop through the data and determine if the point can be passively heated deltas, value_list = [], [] for temp, comf, hr_ok, hour in zip(temp_vals, comf_val, hr_in_range, time_ind): if comf == 0 and hr_ok and temp <= bal_temp: # compute the total amount of solar heat over the time constant past_rad = incident_irradiance[hour - tcon_i:hour] solar_heat_contribs = [rad * ((i + 1) / time_constant) for i, rad in enumerate(past_rad)] # see if enough solar heat has collected in the space to overcome delta t temp_delta = bal_temp - temp if sum(solar_heat_contribs) > solar_heat_capacity * temp_delta: deltas.append(temp_delta) value_list.append(1) else: value_list.append(0) else: # the conditions are too warm to be in the polygon value_list.append(0) max_delta = max(deltas) if len(deltas) != 0 else 20 return value_list, max_delta
[docs] def comfort_polylines(self, polygon_index): """Get the left and right Polyline2D that define a PMV polygon comfort range. Args: polygon_index: Integer for the comfort polygon for which min and max temperature will be computed. Returns: The left and right Polyline2D that define the comfort range. """ # get the air temperature and humidity rations rel_humids = (0, 20, 40, 60, 80, 100) pres = self.psychrometric_chart.average_pressure air_temps = self.max_min_air_temperatures(polygon_index, rel_humids) humid_ratios = [] for i, temp in enumerate(air_temps): hr_min = humid_ratio_from_db_rh(temp[0], rel_humids[i], pres) hr_max = humid_ratio_from_db_rh(temp[1], rel_humids[i], pres) humid_ratios.append((hr_min, hr_max)) # create the points from the temperature and humidity ratios psy, left_pts, right_pts = self.psychrometric_chart, [], [] for hr, ta in zip(humid_ratios, air_temps): ta1, ta2 = ta if not psy.use_ip else self.TEMP_TYPE.to_unit(ta, 'F', 'C') left_pts.append(Point2D(psy.t_x_value(ta1), psy.hr_y_value(hr[0]))) right_pts.append(Point2D(psy.t_x_value(ta2), psy.hr_y_value(hr[1]))) return Polyline2D(left_pts, interpolated=True), \ Polyline2D(right_pts, interpolated=True)
[docs] def max_min_air_temperatures(self, polygon_index, rel_humid): """Get the max and min air temperature for a comfort polygon at a relative humid. Args: polygon_index: Integer for the comfort polygon for which min and max temperature will be computed. rel_humid: A list of relative humidity values for which air temperature will be computed. Returns: A list of tuples where each tuple contains two air temperature values in Celsius. The first air temperature is the minimum temperature that meets the PPD threshold. The second is the maximum that meets the PPD threshold """ # get the PPD thresholds and PMV dict sat = self._comfort_par.still_air_threshold pmv_min, pmv_max = pmv_from_ppd(self._comfort_par.ppd_comfort_thresh) if \ self._comfort_par.ppd_comfort_thresh != 10 else (-0.5, 0.5) pmv_dict = self._pmv_dict(polygon_index) # compute the min and max air temperatures of relative humidity air_temperatures = [] for rh in rel_humid: pmv_dict['rh'] = rh min_dict = calc_missing_pmv_input(pmv_min, pmv_dict, still_air_threshold=sat) max_dict = calc_missing_pmv_input(pmv_max, pmv_dict, still_air_threshold=sat) air_temperatures.append((min_dict['ta'], max_dict['ta'])) return air_temperatures
[docs] def create_collection(self, value_list, polygon_name=None): """Create a data collection of comfort data values from a list of values. Args: value_list: A list of data that align with the number of points in the underlying psychrometric chart polygon_name: An optional name to be used to create to the data collection metadata. """ psy = self.psychrometric_chart base = psy.temperature if isinstance(psy.temperature, BaseCollection) \ else psy.relative_humidity coll = base.get_aligned_collection(value_list, ThermalComfort(), 'condition') if polygon_name: coll.header.metadata = {'polygon': polygon_name} return coll
def _build_comfort_polygon(self, left, right): """Build a comfort polygon from left and right polylines.""" # create the saturation line if it will be required psy = self.psychrometric_chart max_hr_y = psy.hr_y_value(self._comfort_par.humid_ratio_upper) if max_hr_y >= left[-1].y: x_mid = (left[-1].x + right[-1].x) / 2 t_mid, t_mid_c = self._x_to_t(x_mid) hr_mid = humid_ratio_from_db_rh(t_mid_c, 100, psy.average_pressure) mx, my = psy.t_x_value(t_mid), psy.hr_y_value(hr_mid) sat_line = Polyline2D((left[-1], Point2D(mx, my), right[-1]), interpolated=True) # clip the left and right comfort lines if there are max and min HR left_x = psy.base_point.x - 100 * psy.x_dim right_y = right[-1].y if self._comfort_par.humid_ratio_lower != 0: min_hr_y = psy.hr_y_value(self._comfort_par.humid_ratio_lower) if min_hr_y >= left[-1].y: raise ValueError( 'humid_ratio_lower is too high for a comfort polygon in such cold ' 'temperatures.\nRaise the humid_ratio_lower to see the comfort ' 'polygon.') ray = Ray2D(Point2D(left_x, min_hr_y), Vector2D(1, 0)) left = self._intersect_bottom(left, ray) right = self._intersect_bottom(right, ray) if max_hr_y < right_y: # trim the polylines with the max/min HR ray = Ray2D(Point2D(left_x, max_hr_y), Vector2D(1, 0)) left = self._intersect_top(left, ray) right = self._intersect_top(right, ray) # create the bottom of the comfort polygon comf_polygon = [left.reverse()] comf_polygon.append(LineSegment2D.from_end_points(left[0], right[0])) # create the top of the comfort polygon comf_polygon.append(right) if max_hr_y <= left[-1].y: comf_polygon.append(LineSegment2D.from_end_points(right[-1], left[-1])) elif max_hr_y < right_y: sat_line = self._intersect_top(sat_line, ray) int_pt = sat_line[-1] if isinstance(sat_line, Polyline2D) else sat_line.p2 comf_polygon.append(LineSegment2D.from_end_points(right[-1], int_pt)) comf_polygon.append(sat_line) else: comf_polygon.append(sat_line) return tuple(comf_polygon) def _evaluate_comfort(self, left, right): """Get a tuple of 0s and 1s for comfort from left and right polylines.""" comfort_vals = [] vec = Vector2D(1, 0) for pt in self._psychrometric_chart.data_points: ray = Ray2D(pt, vec) if len(right.intersect_line_ray(ray)) != 0: if len(left.intersect_line_ray(ray)) == 0: comfort_vals.append(1) else: comfort_vals.append(0) else: comfort_vals.append(0) return tuple(comfort_vals) def _pmv_dict(self, polygon_index): """Get a PMV dictionary for on set of inputs.""" return {'ta': None, 'tr': self._rad_temperature[polygon_index], 'vel': self._air_speed[polygon_index], 'met': self._met_rate[polygon_index], 'clo': self._clo_value[polygon_index], 'wme': self._external_work[polygon_index]} def _x_to_t(self, x_value): """Convert an X value on the psychrometric chart to a temperature.""" psy = self.psychrometric_chart t_val = ((x_value - psy.base_point.x) / psy.x_dim) + psy.min_temperature t_val_c = t_val if not psy.use_ip else \ self.TEMP_TYPE.to_unit([t_val], 'C', 'F')[0] return t_val, t_val_c def _y_to_hr(self, y_value): """Convert an Y value on the psychrometric chart to a humidity ratio.""" psy = self.psychrometric_chart return (y_value - psy.base_point.y) / psy._y_dim def _check_input(self, input_param, input_name, default=None, check_positive=False): """Check a given input value.""" if input_param is not None and len(input_param) != 0: assert isinstance(input_param, (list, tuple)), \ 'Input {} must be a list or a tuple.'.format(input_name) new_input_param = [] for val in input_param: if val is not None: val = float(val) if check_positive: assert val >= 0, 'Input {} must be greater or equal ' \ 'to 0.'.format(input_name) new_input_param.append(val) input_param = tuple(new_input_param) if len(input_param) != self._polygon_count: return input_param + \ (input_param[-1],) * (self._polygon_count - len(input_param)) return input_param else: return (default,) * self._polygon_count def _check_hourly(self, data, strategy_name): """Check data on the psych chart originates from a hourly collection. Args: data: The data that is being evaluated against the psychrometric chart data points. strategy_name: The name of the strategy that's being evaluated. Returns: A tuple of indices for which hours of the input data correspond to the psychrometric chart data points. """ # check the input data first assert isinstance(data, HourlyContinuousCollection), '{} ' \ 'data must be hourly and continuous.'.format(strategy_name) assert data.header.analysis_period.is_annual, '{} ' \ 'data must be annual.'.format(strategy_name) # check the data in relation to the psychrometric chart data psy = self.psychrometric_chart coll = psy.temperature if isinstance(psy.temperature, BaseCollection) \ else psy.relative_humidity accept = (HourlyContinuousCollection, HourlyDiscontinuousCollection) assert isinstance(coll, accept), 'Psychrometric chart ' \ 'data must be hourly to evaluate {}.'.format(strategy_name) a_per = coll.header.analysis_period t_step = a_per.timestep assert coll.header.analysis_period.timestep == t_step, '{} data' \ 'timestep must be the same as data on the psych chart.'.format(strategy_name) # get the indices of psych chart data to compare with strategy data if isinstance(coll, HourlyContinuousCollection): return tuple(int(hr * t_step) for hr in a_per.hoys) else: return tuple(int(dt.hoy * t_step) for dt in coll.datetimes) @staticmethod def _balance_check(balance_temperature): """Check to see if a building balance temperature is acceptable.""" assert balance_temperature >= 5, 'balance_temperature must be greater than or ' \ 'equal to 5 C in order to be drawn correctly and reasonably represent a ' \ 'real building.' @staticmethod def _lines_to_polygon(polygon, tolerance): """Convert a list of Polyline2D and LineSegment2D to a single Polygon2D.""" all_segs = [] for obj in polygon: if isinstance(obj, Polyline2D): all_segs.extend(obj.segments) else: all_segs.append(obj) joined_segs = Polyline2D.join_segments(all_segs, tolerance)[0] return joined_segs.to_polygon(tolerance) @staticmethod def _intersect_bottom(polyline, ray): """Intersect a Polyline2D on the bottom.""" min_dist = polyline[0].distance_to_point(polyline[1]) / 4 for i, _s in enumerate(polyline.segments): inters = intersect_line2d_infinite(_s, ray) if inters is not None: if inters.distance_to_point(polyline[i + 1]) > min_dist: verts = (inters,) + polyline.vertices[i + 1:] else: # avoid a bad interpolation end_v = polyline.vertices[i + 2:] verts = (inters,) + end_v if len(end_v) != 0 else \ (inters,) + polyline.vertices[i + 1:] polyline = Polyline2D(verts, interpolated=True) if len(verts) != 2 else \ LineSegment2D.from_end_points(verts[0], verts[1]) break if isinstance(polyline, LineSegment2D): polyline = Polyline2D((polyline.p1, polyline.midpoint, polyline.p2)) return polyline @staticmethod def _intersect_top(polyline, ray): """Intersect a Polyline2D on the top.""" min_dist = polyline[0].distance_to_point(polyline[1]) / 4 verts = [polyline[0]] for i, _s in enumerate(polyline.segments): inters = intersect_line2d_infinite(_s, ray) if inters is None: verts.append(polyline[i + 1]) else: if len(verts) == 1 or inters.distance_to_point(verts[-1]) > min_dist: verts.append(inters) else: # avoid a bad interpolation verts[-1] = inters break polyline = Polyline2D(verts, interpolated=True) if len(verts) != 2 else \ LineSegment2D.from_end_points(verts[0], verts[1]) if isinstance(polyline, LineSegment2D): polyline = Polyline2D((polyline.p1, polyline.midpoint, polyline.p2)) return polyline @staticmethod def _min_polylines(polylines): """Construct a minimum polyline form a list of polylines.""" vert_list = list(polylines[0].vertices) for poly in polylines[1:]: for i, vert in enumerate(poly.vertices): if vert.x < vert_list[i].x: vert_list[i] = vert return Polyline2D(vert_list, interpolated=True) @staticmethod def _max_polylines(polylines): """Construct a maximum polyline form a list of polylines.""" vert_list = list(polylines[0].vertices) for poly in polylines[1:]: for i, vert in enumerate(poly.vertices): if vert.x > vert_list[i].x: vert_list[i] = vert return Polyline2D(vert_list, interpolated=True)
[docs] def ToString(self): """Overwrite .NET ToString.""" return self.__repr__()
def __repr__(self): """PolygonPMV representation.""" return "Polygon PMV: ({} Polygons)".format(self._polygon_count)